Keep uploaded stills in the input picker and generated work in the library, and run image-edit passes as a chain like video extensions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 09:58:08 -05:00
co-authored by Cursor
parent f88dfa1146
commit 860aeafb1e
9 changed files with 1001 additions and 177 deletions
+2
View File
@@ -82,8 +82,10 @@ export interface Job {
extendSourceClipId?: string
familyId?: string
parentClipId?: string
parentStillId?: string
workflow?: 'v1' | 'v2'
chainContinuing?: boolean
passes?: { prompt: string }[]
}
error?: string
socketReady?: boolean
+181 -16
View File
@@ -44,6 +44,8 @@ export interface LibraryClip {
hideInput?: boolean
}
export type StillRole = 'input' | 'output'
export interface LibraryStill {
id: string
folderId: string
@@ -53,6 +55,12 @@ export interface LibraryStill {
hash: string
createdAt: number
hideInput: boolean
role: StillRole
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
}
export interface PublicFolder {
@@ -63,6 +71,7 @@ export interface PublicFolder {
protected: boolean
unlocked: boolean
clipCount: number
imageCount: number
}
export interface QueuedExtension {
@@ -153,6 +162,18 @@ function normalizeFolder(folder: LibraryFolder): LibraryFolder {
}
}
function generatedOutputFilename(name: string) {
const filename = String(name || '').trim()
return /aigen[_-]?edit/i.test(filename)
|| /^ComfyUI[_-]/i.test(filename)
|| /_\d{5}_(?:\.[a-z0-9]+)?$/i.test(filename)
}
function stillRole(still: Pick<LibraryStill, 'role' | 'filename'>): StillRole {
if (still.role === 'output' || still.role === 'input') return still.role
return generatedOutputFilename(still.filename) ? 'output' : 'input'
}
function normalizeStill(still: LibraryStill): LibraryStill {
return {
id: still.id,
@@ -162,7 +183,13 @@ function normalizeStill(still: LibraryStill): LibraryStill {
height: still.height || 0,
hash: still.hash || '',
createdAt: still.createdAt || Date.now(),
hideInput: still.hideInput === true
hideInput: still.hideInput === true,
role: stillRole(still),
name: still.name,
prompt: still.prompt,
familyId: still.familyId,
parentStillId: still.parentStillId,
chainIndex: still.chainIndex
}
}
@@ -293,6 +320,24 @@ export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) {
return folderClips.filter(item => ids.has(item.id))
}
export function stillsInSameFamily(catalog: Catalog, still: LibraryStill) {
const folderStills = catalog.stills.filter(item => item.folderId === still.folderId && stillRole(item) === 'output')
const ids = new Set<string>([still.id])
if (still.familyId) {
for (const item of folderStills) {
if (item.familyId === still.familyId) ids.add(item.id)
}
}
return folderStills.filter(item => ids.has(item.id))
}
export function stillChainName(base: string, index: number) {
const trimmed = (base || '').trim()
if (!trimmed || index <= 0) return trimmed.slice(0, 80)
const suffix = ` p${index + 1}`
return `${trimmed.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
}
export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen'
@@ -327,7 +372,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
}
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0): PublicFolder {
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder {
return {
id: folder.id,
name: folder.name,
@@ -335,7 +380,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
thumbnailDefault: folder.thumbnailDefault,
protected: Boolean(folder.passwordHash),
unlocked: folderUnlocked(event, folder),
clipCount
clipCount,
imageCount
}
}
@@ -345,7 +391,8 @@ export function publicLibrary(event: H3Event) {
const folders = catalog.folders.map(folder => publicFolder(
event,
folder,
catalog.clips.filter(clip => clip.folderId === folder.id).length
catalog.clips.filter(clip => clip.folderId === folder.id).length,
catalog.stills.filter(still => still.folderId === folder.id && !isPipelineStill(catalog, still) && stillRole(still) === 'output').length
))
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
return {
@@ -356,7 +403,9 @@ export function publicLibrary(event: H3Event) {
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
hasVideo: existsSync(clipVideoPath(owner, clip.id))
})),
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId) && !isStrayExtensionStill(catalog, still)),
stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still))
.map(still => ({ ...still, role: stillRole(still) })),
drafts: catalog.drafts
}
}
@@ -712,11 +761,11 @@ function clipIdFromStillFilename(name: string) {
return match ? match[1].toLowerCase() : ''
}
function isStrayExtensionStill(catalog: Catalog, still: LibraryStill) {
function isPipelineStill(catalog: Catalog, still: LibraryStill) {
if (lastFrameStillName(still.filename)) return true
const clipId = clipIdFromStillFilename(still.filename)
if (!clipId) return false
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId && isExtensionClip(clip))
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId)
}
function originalInputHashes(owner: string, catalog: Catalog) {
@@ -753,7 +802,6 @@ function allLastFrameHashes(owner: string, catalog: Catalog) {
function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
const protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[])
const originalHashes = originalInputHashes(owner, catalog)
const extIds = new Set(catalog.clips.filter(isExtensionClip).map(clip => clip.id.toLowerCase()))
const extHashes = extensionFrameHashes(owner, catalog)
const lastHashes = allLastFrameHashes(owner, catalog)
const keep: LibraryStill[] = []
@@ -761,7 +809,7 @@ function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
for (const still of catalog.stills) {
const clipId = clipIdFromStillFilename(still.filename)
const namedLastFrame = lastFrameStillName(still.filename)
const namedExtClip = Boolean(clipId && extIds.has(clipId))
const namedExtClip = Boolean(clipId && catalog.clips.some(clip => clip.id.toLowerCase() === clipId))
const matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash)))
const isOriginal = Boolean(still.hash && originalHashes.has(still.hash))
const dropThis = !protectedIds.has(still.id)
@@ -811,25 +859,47 @@ export async function saveStill(params: {
width?: number
height?: number
hideInput?: boolean
role?: StillRole
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
}): Promise<LibraryStill | null> {
if (lastFrameStillName(params.filename)) return null
const role = params.role === 'output' ? 'output' : 'input'
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
const hash = bytesHash(params.data)
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
const sameChain = (item: LibraryStill) => !params.familyId
|| (item.familyId === params.familyId && (item.chainIndex || 0) === (params.chainIndex || 0))
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash && stillRole(item) === role && sameChain(item))
if (existing) {
if (lastFrameStillName(existing.filename)) return null
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
if (lastFrameStillName(existing.filename) || isPipelineStill(catalog, existing)) return null
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt) {
await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id)
if (item) item.hideInput = params.hideInput === true
if (!item) return
item.hideInput = hideInput
item.role = role
if (name) item.name = name
if (prompt) item.prompt = prompt
if (params.familyId) item.familyId = params.familyId
if (params.parentStillId) item.parentStillId = params.parentStillId
if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex
})
existing.hideInput = params.hideInput === true
existing.hideInput = hideInput
existing.role = role
if (name) existing.name = name
if (prompt) existing.prompt = prompt
}
return existing
}
if (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash)) {
if (role === 'input' && (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash))) {
return null
}
const still: LibraryStill = {
@@ -840,7 +910,13 @@ export async function saveStill(params: {
height: params.height || 0,
hash,
createdAt: Date.now(),
hideInput: params.hideInput === true
hideInput: params.hideInput === true,
role,
name: params.name?.trim().slice(0, 80) || undefined,
prompt: params.prompt || undefined,
familyId: params.familyId,
parentStillId: params.parentStillId,
chainIndex: params.chainIndex
}
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
@@ -850,6 +926,26 @@ export async function saveStill(params: {
return still
}
export async function rememberInputStill(params: {
ownerKey: string
folderId: string
filename: string
data: Buffer
width?: number
height?: number
hideInput?: boolean
}): Promise<LibraryStill | null> {
if (lastFrameStillName(params.filename)) return null
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
const hash = bytesHash(params.data)
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash && !isPipelineStill(catalog, item))
if (existing) return existing
if (/^start\.(jpe?g|png|webp)$/i.test(String(params.filename || '').trim())) return null
return saveStill({ ...params, role: 'input' })
}
export async function saveRetryDraft(params: {
ownerKey: string
id?: string
@@ -938,6 +1034,75 @@ export function deleteStill(owner: string, id: string) {
})
}
export function moveStill(owner: string, id: string, folderId: string) {
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
const folder = catalog.folders.find(item => item.id === folderId)
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
still.folderId = folder.id
return still
})
}
export function deleteStillFamily(owner: string, id: string) {
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
const family = stillsInSameFamily(catalog, still)
const ids = new Set(family.map(item => item.id))
catalog.stills = catalog.stills.filter(item => !ids.has(item.id))
for (const item of family) {
rmSync(stillPath(owner, item.id), { force: true })
}
return family
})
}
export function moveStillFamily(owner: string, id: string, folderId: string) {
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
const folder = catalog.folders.find(item => item.id === folderId)
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
const family = stillsInSameFamily(catalog, still)
const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID()
for (const item of family) {
item.folderId = folder.id
item.familyId = familyId
}
return family
})
}
export function renameStill(owner: string, id: string, name: string) {
const trimmed = name.trim().slice(0, 80)
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
still.name = trimmed
return still
})
}
export function renameStillFamily(owner: string, id: string, name: string) {
const trimmed = name.trim().slice(0, 80)
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
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()
family.forEach((item, index) => {
item.familyId = familyId
item.name = stillChainName(base, index)
})
return family
})
}
export function imageContentType(buf: Buffer) {
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'