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:
+198
-106
@@ -16,6 +16,19 @@ function parseCfg(raw: unknown) {
|
||||
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
|
||||
}
|
||||
|
||||
function parsePasses(raw: string | undefined) {
|
||||
if (!raw) return [] as { prompt: string }[]
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
|
||||
.filter(item => item.prompt)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const form = await readMultipartFormData(event).catch(() => null)
|
||||
const fields: Record<string, string> = {}
|
||||
@@ -32,12 +45,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const prompt = (fields.prompt || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
if (!image) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
|
||||
}
|
||||
if (!prompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
|
||||
}
|
||||
if (extraPasses.some(item => !item.prompt)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
|
||||
}
|
||||
if (!comfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
@@ -65,19 +82,17 @@ export default defineEventHandler(async (event) => {
|
||||
: Math.floor(Math.random() * 2_147_483_647)
|
||||
const size = imageDimensions(image.data)
|
||||
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput
|
||||
})
|
||||
if (reference && !isPipelineFrameFilename(reference.filename)) {
|
||||
await saveStill({
|
||||
const still = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput
|
||||
})
|
||||
if (reference) {
|
||||
await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: reference.filename,
|
||||
@@ -86,6 +101,9 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const passes = extraPasses
|
||||
const chainTotal = 1 + passes.length
|
||||
const familyId = chainTotal > 1 ? crypto.randomUUID() : undefined
|
||||
const job = createJob()
|
||||
job.kind = 'edit'
|
||||
job.maxStep = steps
|
||||
@@ -105,14 +123,28 @@ export default defineEventHandler(async (event) => {
|
||||
turbo: true,
|
||||
seed,
|
||||
cfg,
|
||||
stillId: still?.id
|
||||
stillId: still?.id,
|
||||
familyId,
|
||||
chainIndex: 0,
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: chainTotal > 1 ? 'Pass 1' : undefined,
|
||||
passes
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking Beast ComfyUI...', progress: 2 })
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: 'Checking Beast ComfyUI...',
|
||||
progress: 2,
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: job.library.chainLabel
|
||||
})
|
||||
|
||||
void runEdit(job, {
|
||||
image,
|
||||
reference,
|
||||
prompt,
|
||||
passes,
|
||||
negative: (fields.negative || '').trim(),
|
||||
steps,
|
||||
seed,
|
||||
@@ -131,7 +163,8 @@ export default defineEventHandler(async (event) => {
|
||||
steps,
|
||||
hideThumbnail: hideInput,
|
||||
folderLocked,
|
||||
dual: Boolean(reference)
|
||||
dual: Boolean(reference),
|
||||
chainTotal
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,14 +174,22 @@ async function runEdit(
|
||||
image: { filename: string; data: Buffer; type?: string }
|
||||
reference: { filename: string; data: Buffer; type?: string } | null
|
||||
prompt: string
|
||||
passes: { prompt: string }[]
|
||||
negative: string
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
}
|
||||
) {
|
||||
const library = job.library
|
||||
if (!library) throw new Error('Edit job is missing library metadata')
|
||||
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
|
||||
const chainTotal = prompts.length
|
||||
library.chainTotal = chainTotal
|
||||
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
|
||||
|
||||
await ensureComfyReady((status) => {
|
||||
emitJob(job, {
|
||||
emitChainJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
|
||||
@@ -159,96 +200,147 @@ async function runEdit(
|
||||
job.imageComfyHost = getComfyHost()
|
||||
|
||||
await withImageComfyHost(job.imageComfyHost, async () => {
|
||||
const dual = Boolean(params.reference)
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImage(params.image, job.id)
|
||||
const uploadedRef = params.reference
|
||||
? await uploadImage({
|
||||
...params.reference,
|
||||
filename: `ref_${params.reference.filename || 'image2.png'}`
|
||||
}, job.id)
|
||||
: null
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual
|
||||
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
||||
: 'Queueing Flux.2 Klein edit on Beast...',
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
referenceImageName: uploadedRef?.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
||||
progress: 18,
|
||||
maxStep: params.steps
|
||||
})
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
let current = params.image
|
||||
let parentStillId: string | undefined
|
||||
for (let index = 0; index < prompts.length; index++) {
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
const prompt = prompts[index]
|
||||
const last = index === prompts.length - 1
|
||||
if (index > 0) {
|
||||
await ensureComfyReady((status) => {
|
||||
emitChainJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? 4 : 2,
|
||||
busy: status.state === 'busy'
|
||||
})
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
}
|
||||
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
|
||||
library.prompt = prompt
|
||||
library.seed = seed
|
||||
library.chainIndex = index
|
||||
library.chainStep = index + 1
|
||||
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
|
||||
const reference = index === 0 ? params.reference : null
|
||||
const dual = Boolean(reference)
|
||||
const passName = stillChainName(library.name || '', index)
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded.name,
|
||||
imageSubfolder: uploaded.subfolder,
|
||||
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
||||
promptId: job.promptId
|
||||
})
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: 'Edit finished on Beast',
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImage(current, job.id)
|
||||
const uploadedRef = reference
|
||||
? await uploadImage({
|
||||
...reference,
|
||||
filename: `ref_${reference.filename || 'image2.png'}`
|
||||
}, job.id)
|
||||
: null
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual
|
||||
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
||||
: 'Queueing Flux.2 Klein edit on Beast...',
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
referenceImageName: uploadedRef?.name,
|
||||
steps: params.steps,
|
||||
seed,
|
||||
cfg: params.cfg,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
|
||||
})
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
||||
progress: 18,
|
||||
maxStep: params.steps
|
||||
})
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
onProgress: (event) => {
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
|
||||
emitChainJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: library.ownerKey,
|
||||
folderId: library.folderId,
|
||||
filename: passName ? `${passName}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.hideThumbnail === true || library.hideInput === true,
|
||||
role: 'output',
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
familyId: library.familyId,
|
||||
parentStillId,
|
||||
chainIndex: index
|
||||
})
|
||||
job.stillId = still?.id
|
||||
parentStillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded.name,
|
||||
imageSubfolder: uploaded.subfolder,
|
||||
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
||||
promptId: job.promptId
|
||||
})
|
||||
|
||||
if (!last) {
|
||||
emitChainJob(job, {
|
||||
type: 'checkpoint',
|
||||
message: `Pass ${index + 1} saved`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
current = {
|
||||
filename: still?.filename || `pass_${index + 1}.png`,
|
||||
data: buffer,
|
||||
type: 'image/png'
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
job.status = 'complete'
|
||||
emitChainJob(job, {
|
||||
type: 'complete',
|
||||
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+10
-12
@@ -123,22 +123,20 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const still = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const referenceStillIds: Array<string | null> = [null, null, null, null]
|
||||
if (useIdentityRefs) {
|
||||
for (const [index, ref] of referenceImages.entries()) {
|
||||
if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue
|
||||
const saved = await saveStill({
|
||||
const saved = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: ref.filename || `identity-ref-${index + 2}.png`,
|
||||
|
||||
@@ -31,7 +31,8 @@ export default defineEventHandler(async (event) => {
|
||||
data: image.data,
|
||||
width: Number(fields.width || 0),
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: fields.hideInput === 'true'
|
||||
hideInput: fields.hideInput === 'true',
|
||||
role: 'input'
|
||||
})
|
||||
return { still: still || undefined, ...publicLibrary(event) }
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
await deleteStill(owner, id)
|
||||
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
|
||||
if (family) await deleteStillFamily(owner, id)
|
||||
else await deleteStill(owner, id)
|
||||
return publicLibrary(event)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
||||
const folderId = String(body?.folderId || '')
|
||||
const hasName = typeof body?.name === 'string'
|
||||
const family = body?.family === true
|
||||
if (!folderId && !hasName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||
}
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
if (family) await moveStillFamily(owner, id, folderId)
|
||||
else await moveStill(owner, id, folderId)
|
||||
}
|
||||
if (hasName) {
|
||||
if (family) await renameStillFamily(owner, id, body.name || '')
|
||||
else await renameStill(owner, id, body.name || '')
|
||||
}
|
||||
return publicLibrary(event)
|
||||
})
|
||||
@@ -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