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:
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user