From eeff12cdab85f1725b880b482791dc5618196371 Mon Sep 17 00:00:00 2001 From: Towsty Date: Mon, 7 Sep 2026 20:16:01 -0500 Subject: [PATCH] Add manual image groups to the library --- pages/library.vue | 48 +++++++++++++++--- server/api/library/still-groups.post.ts | 11 ++++ server/utils/library.ts | 40 ++++++++++++++- tests/manual-image-groups.test.mjs | 67 +++++++++++++++++++++++++ utils/libraryBrowse.ts | 7 +++ 5 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 server/api/library/still-groups.post.ts create mode 100644 tests/manual-image-groups.test.mjs diff --git a/pages/library.vue b/pages/library.vue index d574ea6..0d6f178 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -128,6 +128,8 @@ {{ folder.name }}{{ folder.protected && !folder.unlocked ? ' ยท locked' : '' }} + + @@ -183,6 +185,7 @@ class="h-full w-full object-cover" > + {{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }} -
+
+ Expand images ({{ focusedParts.length }}) +
-
+
+

{{ focusedDisplayNodes.filter(node => node.type === 'clip').length }} parts hidden with preview

@@ -507,6 +513,7 @@ import { formatClipDuration, formatWhen, treeOrderedParts, + wrappedIndex, type FamilyDisplayNode, type LibraryClip, type LibraryEntry, @@ -549,6 +556,33 @@ const sort = ref('date-desc') const searchDraft = ref('') const search = ref('') const selectedKeys = ref([]) +const groupBusy = ref(false) +const cardImages = ref>({}) +const canGroupSelection = computed(() => selectedEntries().length >= 2 && selectedEntries().every(entry => entry.kind === 'still')) +const canUngroupSelection = computed(() => selectedEntries().length > 0 && selectedEntries().every(entry => entry.kind === 'still' && entry.stillGroup?.head.manualGroupId)) +function cardImageIndex(entry: LibraryEntry) { + return (cardImages.value[entry.key] || 0) % Math.max(1, partCount(entry)) +} +function stepCardImage(entry: LibraryEntry, delta: number) { + cardImages.value[entry.key] = wrappedIndex(partCount(entry), cardImageIndex(entry), delta) + if (focusedKey.value === entry.key) focusedPartId.value = entry.stillGroup!.parts[cardImageIndex(entry)].id +} +async function groupSelectedImages(ungroup = false) { + if (groupBusy.value) return + groupBusy.value = true + try { + const ids = selectedEntries().flatMap(entry => entry.stillGroup?.parts.map(part => part.id) || []) + applyLibrary(await $fetch('/api/library/still-groups', { method: 'POST', body: { ids, ungroup } })) + clearSelection() + clearFocus() + toast(ungroup ? 'Images ungrouped. Original iterations restored.' : 'Images grouped. Use the arrows to browse, or open the group to expand it.') + } catch (error: any) { + toast(error?.data?.statusMessage || 'Could not update image group') + } finally { + groupBusy.value = false + } +} + const lastSelectedKey = ref('') const selectMode = ref(false) const lightboxOpen = ref(false) @@ -796,11 +830,11 @@ function cardClass(entry: LibraryEntry) { function itemHidden(entry: LibraryEntry) { if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail) if (entry.kind === 'track') return false - return Boolean(entry.stillGroup?.head.hideInput) + return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput) } function thumbHidden(entry: LibraryEntry) { - const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.head.id + const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id if (id && forceHiddenIds.value.includes(id)) return true return itemHidden(entry) && !revealAll.value } @@ -852,7 +886,7 @@ async function toggleFocusedHidden() { function thumbSrc(entry: LibraryEntry) { if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb` if (entry.kind === 'track') return '' - if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1` + if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1` return '' } @@ -960,7 +994,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) { ? (entry.group?.head.id || '') : entry.kind === 'track' ? (entry.track?.id || '') - : (entry.stillGroup?.head.id || '') + : (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '') if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value cancelRename() } diff --git a/server/api/library/still-groups.post.ts b/server/api/library/still-groups.post.ts new file mode 100644 index 0000000..ee77a8f --- /dev/null +++ b/server/api/library/still-groups.post.ts @@ -0,0 +1,11 @@ +export default defineEventHandler(async (event) => { + const { owner } = assertLibraryOwner(event) + const body = await readBody(event) + if (!Array.isArray(body?.ids) || !body.ids.length || body.ids.length > 1000 || body.ids.some((id: unknown) => typeof id !== 'string')) { + throw createError({ statusCode: 400, statusMessage: 'Select images to group' }) + } + const ids = [...new Set(body.ids)] + for (const id of ids) assertFolderAccess(event, getStill(owner, id).folderId) + await groupLibraryStills(owner, ids, body.ungroup === true) + return publicLibrary(event) +}) diff --git a/server/utils/library.ts b/server/utils/library.ts index f4752f0..8e7c3f4 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -105,6 +105,9 @@ export interface LibraryStill { promptPre?: string promptPost?: string familyId?: string + manualGroupId?: string + manualGroupName?: string + manualGroupIndex?: number parentStillId?: string chainIndex?: number settings?: StillGenerationSettings @@ -258,6 +261,9 @@ function normalizeStill(still: LibraryStill): LibraryStill { promptPre: still.promptPre, promptPost: still.promptPost, familyId: still.familyId, + manualGroupId: still.manualGroupId, + manualGroupName: still.manualGroupName, + manualGroupIndex: still.manualGroupIndex, parentStillId: still.parentStillId, chainIndex: still.chainIndex, settings: normalizeStillSettings(still.settings) @@ -413,10 +419,11 @@ export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) { export function stillsInSameFamily(catalog: Catalog, still: LibraryStill) { const folderStills = catalog.stills.filter(item => item.folderId === still.folderId && stillRole(item) === 'output') + if (still.manualGroupId) return folderStills.filter(item => item.manualGroupId === still.manualGroupId) const ids = new Set([still.id]) if (still.familyId) { for (const item of folderStills) { - if (item.familyId === still.familyId) ids.add(item.id) + if (!item.manualGroupId && item.familyId === still.familyId) ids.add(item.id) } } return folderStills.filter(item => ids.has(item.id)) @@ -1580,7 +1587,7 @@ export function moveStillFamily(owner: string, id: string, folderId: string) { const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID() for (const item of family) { item.folderId = folder.id - item.familyId = familyId + if (!still.manualGroupId) item.familyId = familyId } return family }) @@ -1617,6 +1624,10 @@ export function renameStillFamily(owner: string, id: string, name: string) { const family = [...stillsInSameFamily(catalog, still)].sort((a, b) => (a.chainIndex || 0) - (b.chainIndex || 0) || a.createdAt - b.createdAt) const base = familyBaseName(trimmed) || trimmed const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID() + if (still.manualGroupId) { + for (const item of family) item.manualGroupName = trimmed + return family + } family.forEach((item, index) => { item.familyId = familyId item.name = stillChainName(base, index) @@ -2153,3 +2164,28 @@ export async function importMissingComfyAudio(owner: string, folderId?: string) } return imported } + +// Grouping only changes library organization; generation ancestry remains intact. +export function groupLibraryStills(owner: string, ids: string[], ungroup = false) { + return mutate(owner, (catalog) => { + const selected = ids.map(id => catalog.stills.find(item => item.id === id)) + if (selected.some(item => !item || stillRole(item) !== 'output')) throw createError({ statusCode: 400, statusMessage: 'Choose generated images' }) + const images = selected as LibraryStill[] + if (new Set(images.map(item => item.folderId)).size !== 1) throw createError({ statusCode: 400, statusMessage: 'Move the images into the same folder first' }) + const members = [...new Map(images.flatMap(item => stillsInSameFamily(catalog, item)).map(item => [item.id, item])).values()] + if (!ungroup && members.length < 2) throw createError({ statusCode: 400, statusMessage: 'Select at least two images' }) + const groupId = crypto.randomUUID() + const name = images.find(item => item.manualGroupName)?.manualGroupName || 'Image group' + members.forEach((item, index) => { + if (ungroup) { + delete item.manualGroupId + delete item.manualGroupName + delete item.manualGroupIndex + } else { + item.manualGroupId = groupId + item.manualGroupName = name + item.manualGroupIndex = index + } + }) + }) +} diff --git a/tests/manual-image-groups.test.mjs b/tests/manual-image-groups.test.mjs new file mode 100644 index 0000000..8253bc8 --- /dev/null +++ b/tests/manual-image-groups.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +const source = readFileSync(new URL('../utils/libraryBrowse.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { buildLibraryEntries, chronologicalStills } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +const image = (id, extra = {}) => ({ id, folderId: 'inbox', filename: `${id}.png`, role: 'output', createdAt: 1, width: 512, height: 512, ...extra }) +const entries = stills => buildLibraryEntries({ stills, clips: [], folderId: 'all', filter: 'images', search: '', sort: 'date-desc' }) + +test('manual grouping joins unrelated images without changing iteration ancestry', () => { + const a = image('a', { familyId: 'iteration-a', chainIndex: 8, manualGroupId: 'manual', manualGroupName: 'Favorites', manualGroupIndex: 0 }) + const b = image('b', { familyId: 'iteration-b', chainIndex: 0, manualGroupId: 'manual', manualGroupName: 'Favorites', manualGroupIndex: 1 }) + const grouped = entries([b, a]) + assert.equal(grouped.length, 1) + assert.equal(grouped[0].name, 'Favorites') + assert.deepEqual(chronologicalStills(grouped[0].stillGroup.parts).map(x => x.id), ['a', 'b']) + assert.equal(a.familyId, 'iteration-a') + assert.equal(b.familyId, 'iteration-b') + assert.equal(entries([a, b].map(({ manualGroupId, manualGroupName, manualGroupIndex, ...rest }) => rest)).length, 2) +}) + +test('groups remain folder scoped and do not absorb future iterations', () => { + const a = image('a', { familyId: 'iteration', manualGroupId: 'manual' }) + const b = image('b', { familyId: 'iteration' }) + const c = image('c', { folderId: 'private', manualGroupId: 'manual' }) + assert.equal(entries([a, b, c]).length, 3) +}) + +const librarySource = readFileSync(new URL('../server/utils/library.ts', import.meta.url), 'utf8') +const ast = ts.createSourceFile('library.ts', librarySource, ts.ScriptTarget.Latest, true) +const names = ['stillsInSameFamily', 'groupLibraryStills', 'moveStillFamily', 'renameStillFamily'] +const functions = ast.statements.filter(node => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map(node => node.getText(ast).replace(/^export /, '')).join('\n') +const serverJs = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText +function server(catalog) { + return new Function('mutate', 'stillRole', 'createError', 'familyBaseName', 'stillChainName', `${serverJs}; return { ${names.join(',')} };`)( + async (_, fn) => fn(catalog), item => item.role, error => Object.assign(new Error(error.statusMessage), error), name => name, (name, index) => `${name} ${index}`) +} + +test('group, rename, move and ungroup preserve original generation links and names', async () => { + const a = image('a', { familyId: 'first', name: 'Original A' }) + const b = image('b', { familyId: 'second', name: 'Original B' }) + const catalog = { stills: [a, b], folders: [{ id: 'inbox' }, { id: 'other' }] } + const api = server(catalog) + await api.groupLibraryStills('owner', ['a', 'b']) + assert.equal(a.manualGroupId, b.manualGroupId) + await api.renameStillFamily('owner', 'a', 'Favorites') + await api.moveStillFamily('owner', 'a', 'other') + assert.equal(b.folderId, 'other') + assert.equal(b.manualGroupName, 'Favorites') + assert.equal(a.name, 'Original A') + assert.equal(b.familyId, 'second') + await api.groupLibraryStills('owner', ['a'], true) + assert.equal(a.manualGroupId, undefined) + assert.equal(b.manualGroupId, undefined) + assert.equal(a.familyId, 'first') +}) + +test('invalid grouping is rejected before modifying any images', async () => { + const a = image('a') + const b = image('b', { folderId: 'private' }) + const api = server({ stills: [a, b] }) + await assert.rejects(api.groupLibraryStills('owner', ['a', 'b']), /same folder/) + await assert.rejects(api.groupLibraryStills('owner', ['a', 'missing']), /generated images/) + assert.equal(a.manualGroupId, undefined) +}) diff --git a/utils/libraryBrowse.ts b/utils/libraryBrowse.ts index 792e12a..82b8903 100644 --- a/utils/libraryBrowse.ts +++ b/utils/libraryBrowse.ts @@ -30,6 +30,9 @@ export type LibraryStill = { promptPre?: string promptPost?: string familyId?: string + manualGroupId?: string + manualGroupName?: string + manualGroupIndex?: number parentStillId?: string chainIndex?: number settings?: StillGenerationSettings @@ -289,11 +292,13 @@ export function clipFamilyKey(clip: LibraryClip) { } export function stillFamilyKey(still: LibraryStill) { + if (still.manualGroupId) return `still-group:${still.folderId}:${still.manualGroupId}` if (still.familyId) return `still-family:${still.folderId}:${still.familyId}` return `still-solo:${still.id}` } export function sortStillParts(a: LibraryStill, b: LibraryStill) { + if (a.manualGroupId && a.manualGroupId === b.manualGroupId) return (a.manualGroupIndex || 0) - (b.manualGroupIndex || 0) const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0) if (chainDelta) return chainDelta return b.createdAt - a.createdAt @@ -301,6 +306,7 @@ export function sortStillParts(a: LibraryStill, b: LibraryStill) { export function chronologicalStills(parts: LibraryStill[]) { return [...parts].sort((a, b) => { + if (a.manualGroupId && a.manualGroupId === b.manualGroupId) return (a.manualGroupIndex || 0) - (b.manualGroupIndex || 0) const chain = (a.chainIndex || 0) - (b.chainIndex || 0) if (chain) return chain return a.createdAt - b.createdAt @@ -344,6 +350,7 @@ export function collectionTitle(group: ClipGroup) { } export function stillCollectionTitle(group: StillGroup) { + if (group.head.manualGroupId) return group.head.manualGroupName || 'Image group' const named = group.parts.map(part => familyBaseName(part.name || '')).find(Boolean) return named || stillTitle(group.head) }