Move library collections as a group and map v2 identity refs.
Collapsed collection actions now move, rename, delete, and rerun every part so chains stay together. Identity mode follows the v2u3 Ref2VA picture wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+221
-20
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||
@@ -39,6 +39,9 @@ export interface LibraryClip {
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
hideInput?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryStill {
|
||||
@@ -211,16 +214,19 @@ function ensureOwner(owner: string) {
|
||||
function readCatalog(owner: string): Catalog {
|
||||
ensureOwner(owner)
|
||||
const path = catalogPath(owner)
|
||||
let catalog: Catalog
|
||||
try {
|
||||
return normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
} catch {
|
||||
if (existsSync(path)) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
|
||||
}
|
||||
const created = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(created, null, 2))
|
||||
return created
|
||||
catalog = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||
return catalog
|
||||
}
|
||||
if (purgeStrayExtensionStills(owner, catalog).count) writeCatalog(owner, catalog)
|
||||
return catalog
|
||||
}
|
||||
|
||||
function writeCatalog(owner: string, catalog: Catalog) {
|
||||
@@ -249,6 +255,10 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
|
||||
return fromPrompt || 'Untitled clip'
|
||||
}
|
||||
|
||||
export function familyBaseName(name: string) {
|
||||
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
}
|
||||
|
||||
export function nextClipPartName(name: string) {
|
||||
const trimmed = (name || '').trim()
|
||||
if (!trimmed) return ''
|
||||
@@ -259,6 +269,30 @@ export function nextClipPartName(name: string) {
|
||||
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
}
|
||||
|
||||
export function sortFamilyParts(a: LibraryClip, b: LibraryClip) {
|
||||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||
if (chain) return chain
|
||||
return a.createdAt - b.createdAt
|
||||
}
|
||||
|
||||
export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) {
|
||||
const folderClips = catalog.clips.filter(item => item.folderId === clip.folderId)
|
||||
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||
const ids = new Set<string>([clip.id])
|
||||
if (clip.familyId) {
|
||||
for (const item of folderClips) {
|
||||
if (item.familyId === clip.familyId) ids.add(item.id)
|
||||
}
|
||||
}
|
||||
if (base) {
|
||||
for (const item of folderClips) {
|
||||
if (item.familyId && item.familyId !== clip.familyId) continue
|
||||
if (familyBaseName(item.name || '').toLowerCase() === base) ids.add(item.id)
|
||||
}
|
||||
}
|
||||
return folderClips.filter(item => ids.has(item.id))
|
||||
}
|
||||
|
||||
export function safeDownloadName(name: string) {
|
||||
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
||||
return cleaned.slice(0, 80) || 'aigen'
|
||||
@@ -322,7 +356,7 @@ 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)),
|
||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId) && !isStrayExtensionStill(catalog, still)),
|
||||
drafts: catalog.drafts
|
||||
}
|
||||
}
|
||||
@@ -652,6 +686,123 @@ function bytesHash(data: Buffer) {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
function fileBytesHash(path: string) {
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return bytesHash(readFileSync(path))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionClip(clip: LibraryClip) {
|
||||
return Boolean(clip.parentClipId) || (clip.chainIndex || 0) > 0
|
||||
}
|
||||
|
||||
function lastFrameStillName(name: string) {
|
||||
return /last[\s._-]*frame/i.test(String(name || '').trim())
|
||||
}
|
||||
|
||||
export function isPipelineFrameFilename(name: string) {
|
||||
return lastFrameStillName(name)
|
||||
}
|
||||
|
||||
function clipIdFromStillFilename(name: string) {
|
||||
const match = String(name || '').trim().match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i)
|
||||
return match ? match[1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
function isStrayExtensionStill(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))
|
||||
}
|
||||
|
||||
function originalInputHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
if (isExtensionClip(clip)) continue
|
||||
const hash = fileBytesHash(clipThumbPath(owner, clip.id))
|
||||
if (hash) hashes.add(hash)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
function extensionFrameHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
if (!isExtensionClip(clip)) continue
|
||||
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||
if (last) hashes.add(last)
|
||||
const rawThumb = fileBytesHash(join(clipDir(owner, clip.id), 'thumb'))
|
||||
if (rawThumb) hashes.add(rawThumb)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
function allLastFrameHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||
if (last) hashes.add(last)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
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[] = []
|
||||
const drop: LibraryStill[] = []
|
||||
for (const still of catalog.stills) {
|
||||
const clipId = clipIdFromStillFilename(still.filename)
|
||||
const namedLastFrame = lastFrameStillName(still.filename)
|
||||
const namedExtClip = Boolean(clipId && extIds.has(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)
|
||||
&& (namedLastFrame || namedExtClip || (matchesPipelineFrame && !isOriginal))
|
||||
if (dropThis) drop.push(still)
|
||||
else keep.push(still)
|
||||
}
|
||||
if (!drop.length) return { count: 0, filenames: [] as string[] }
|
||||
catalog.stills = keep
|
||||
for (const still of drop) {
|
||||
rmSync(stillPath(owner, still.id), { force: true })
|
||||
}
|
||||
return { count: drop.length, filenames: drop.map(still => still.filename) }
|
||||
}
|
||||
|
||||
export function sweepOwnerExtensionStills(owner: string) {
|
||||
return mutate(owner, (catalog) => purgeStrayExtensionStills(owner, catalog))
|
||||
}
|
||||
|
||||
export function sweepAllExtensionStills() {
|
||||
const users = join(libraryRoot(), 'users')
|
||||
let owners = 0
|
||||
const filenames: string[] = []
|
||||
if (!existsSync(users)) return { owners, deleted: 0, filenames }
|
||||
for (const entry of readdirSync(users, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
owners += 1
|
||||
const path = catalogPath(entry.name)
|
||||
if (!existsSync(path)) continue
|
||||
try {
|
||||
const catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
const removed = purgeStrayExtensionStills(entry.name, catalog)
|
||||
if (removed.count) writeCatalog(entry.name, catalog)
|
||||
filenames.push(...removed.filenames)
|
||||
} catch {
|
||||
/* leave a bad catalog for the next authenticated read */
|
||||
}
|
||||
}
|
||||
return { owners, deleted: filenames.length, filenames }
|
||||
}
|
||||
|
||||
export async function saveStill(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
@@ -660,13 +811,15 @@ export async function saveStill(params: {
|
||||
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)
|
||||
if (existing) {
|
||||
if (lastFrameStillName(existing.filename)) return null
|
||||
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
const item = next.stills.find(still => still.id === existing.id)
|
||||
@@ -676,6 +829,9 @@ export async function saveStill(params: {
|
||||
}
|
||||
return existing
|
||||
}
|
||||
if (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash)) {
|
||||
return null
|
||||
}
|
||||
const still: LibraryStill = {
|
||||
id: crypto.randomUUID(),
|
||||
folderId: folder.id,
|
||||
@@ -814,6 +970,8 @@ export async function saveClip(params: {
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
@@ -839,7 +997,10 @@ export async function saveClip(params: {
|
||||
familyId: params.familyId,
|
||||
parentClipId: params.parentClipId,
|
||||
chainIndex: params.chainIndex,
|
||||
workflow: params.workflow
|
||||
workflow: params.workflow,
|
||||
stillId: params.stillId,
|
||||
sound: params.sound,
|
||||
hideInput: params.hideInput === true
|
||||
}
|
||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||
@@ -858,18 +1019,8 @@ export async function saveClip(params: {
|
||||
} else {
|
||||
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
||||
}
|
||||
const saveInputStill = Boolean(params.thumb?.length) && !params.parentClipId && !(params.chainIndex || 0)
|
||||
if (saveInputStill) {
|
||||
await saveStill({
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: folder.id,
|
||||
filename: `${clip.id}.png`,
|
||||
data: params.thumb!,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
hideInput: params.hideInput
|
||||
})
|
||||
}
|
||||
const isInitial = !params.parentClipId && !(params.chainIndex || 0)
|
||||
if (!isInitial) clip.stillId = undefined
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
next.clips.unshift(clip)
|
||||
})
|
||||
@@ -882,6 +1033,12 @@ export function getClip(owner: string, id: string) {
|
||||
return clip
|
||||
}
|
||||
|
||||
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
||||
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
||||
for (const item of family) item.familyId = familyId
|
||||
return familyId
|
||||
}
|
||||
|
||||
export function deleteClip(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
@@ -892,6 +1049,20 @@ export function deleteClip(owner: string, id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteClipFamily(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const family = clipsInSameFamily(catalog, clip)
|
||||
const ids = new Set(family.map(item => item.id))
|
||||
catalog.clips = catalog.clips.filter(item => !ids.has(item.id))
|
||||
for (const item of family) {
|
||||
rmSync(clipDir(owner, item.id), { recursive: true, force: true })
|
||||
}
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function moveClip(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
@@ -903,6 +1074,19 @@ export function moveClip(owner: string, id: string, folderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function moveClipFamily(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
const family = clipsInSameFamily(catalog, clip)
|
||||
stampFamilyId(family, clip.familyId)
|
||||
for (const item of family) item.folderId = folder.id
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function renameClip(owner: string, id: string, name: string) {
|
||||
const trimmed = name.trim().slice(0, 80)
|
||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
@@ -914,6 +1098,23 @@ export function renameClip(owner: string, id: string, name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function renameClipFamily(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 clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const family = [...clipsInSameFamily(catalog, clip)].sort(sortFamilyParts)
|
||||
const base = familyBaseName(trimmed) || trimmed
|
||||
stampFamilyId(family, clip.familyId)
|
||||
family.forEach((item, index) => {
|
||||
const suffix = index === 0 ? '' : ` p${index + 1}`
|
||||
item.name = `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
})
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||
let lastStatus = 0
|
||||
|
||||
Reference in New Issue
Block a user