|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
import { existsSync, mkdirSync, 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'
|
|
|
|
|
import { promisify } from 'node:util'
|
|
|
|
|
import type { H3Event } from 'h3'
|
|
|
|
@@ -18,6 +19,7 @@ export interface LibraryFolder {
|
|
|
|
|
export interface LibraryClip {
|
|
|
|
|
id: string
|
|
|
|
|
folderId: string
|
|
|
|
|
name: string
|
|
|
|
|
prompt: string
|
|
|
|
|
aspect: string
|
|
|
|
|
width: number
|
|
|
|
@@ -177,6 +179,18 @@ function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
|
|
|
|
return run
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
|
|
|
|
|
const named = (clip.name || '').trim()
|
|
|
|
|
if (named) return named
|
|
|
|
|
const fromPrompt = (clip.prompt || '').split(/[.!?\n]/)[0].trim().slice(0, 60)
|
|
|
|
|
return fromPrompt || 'Untitled clip'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function safeDownloadName(name: string) {
|
|
|
|
|
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
|
|
|
|
return cleaned.slice(0, 80) || 'aigen'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function passwordVersion(hash: string | null) {
|
|
|
|
|
return hash ? hash.slice(-24) : 'open'
|
|
|
|
|
}
|
|
|
|
@@ -224,7 +238,11 @@ export function publicLibrary(event: H3Event) {
|
|
|
|
|
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
|
|
|
|
return {
|
|
|
|
|
folders,
|
|
|
|
|
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)),
|
|
|
|
|
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
|
|
|
|
|
...clip,
|
|
|
|
|
name: clipTitle(clip),
|
|
|
|
|
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
|
|
|
|
|
})),
|
|
|
|
|
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -375,7 +393,11 @@ export async function unlockMatchingFolders(owner: string, password: string, fol
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clipDir(owner: string, id: string) {
|
|
|
|
|
return join(ownerRoot(owner), 'files', id)
|
|
|
|
|
const current = join(ownerRoot(owner), 'files', id)
|
|
|
|
|
if (existsSync(current)) return current
|
|
|
|
|
const legacy = join(libraryRoot(), 'files', id)
|
|
|
|
|
if (existsSync(legacy)) return legacy
|
|
|
|
|
return current
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clipVideoPath(owner: string, id: string) {
|
|
|
|
@@ -383,7 +405,143 @@ export function clipVideoPath(owner: string, id: string) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clipThumbPath(owner: string, id: string) {
|
|
|
|
|
return join(clipDir(owner, id), 'thumb')
|
|
|
|
|
const dir = clipDir(owner, id)
|
|
|
|
|
for (const name of ['thumb.jpg', 'thumb.png', 'thumb']) {
|
|
|
|
|
const path = join(dir, name)
|
|
|
|
|
if (existsSync(path)) return path
|
|
|
|
|
}
|
|
|
|
|
return join(dir, 'thumb.jpg')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractVideoThumb(videoPath: string, destPath: string) {
|
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
|
|
|
const proc = spawn('ffmpeg', ['-y', '-ss', '0.2', '-i', videoPath, '-frames:v', '1', '-q:v', '4', destPath], { stdio: 'ignore' })
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
|
|
|
|
resolve(false)
|
|
|
|
|
}, 20000)
|
|
|
|
|
proc.on('close', (code) => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
resolve(code === 0 && existsSync(destPath))
|
|
|
|
|
})
|
|
|
|
|
proc.on('error', () => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
resolve(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function probeVideoSize(videoPath: string) {
|
|
|
|
|
return new Promise<{ width: number; height: number } | null>((resolve) => {
|
|
|
|
|
const proc = spawn('ffprobe', [
|
|
|
|
|
'-v', 'error',
|
|
|
|
|
'-select_streams', 'v:0',
|
|
|
|
|
'-show_entries', 'stream=width,height',
|
|
|
|
|
'-of', 'csv=p=0:s=x',
|
|
|
|
|
videoPath
|
|
|
|
|
], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
|
|
|
let out = ''
|
|
|
|
|
proc.stdout?.on('data', (chunk: Buffer | string) => { out += String(chunk) })
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
|
|
|
|
resolve(null)
|
|
|
|
|
}, 10000)
|
|
|
|
|
proc.on('close', () => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
const [width, height] = out.trim().split(/[x,]/).map(Number)
|
|
|
|
|
resolve(width && height ? { width, height } : null)
|
|
|
|
|
})
|
|
|
|
|
proc.on('error', () => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
resolve(null)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function ensureClipThumbnails(owner: string) {
|
|
|
|
|
const catalog = readCatalog(owner)
|
|
|
|
|
const needsHistory = catalog.clips.some(clip => clip.comfyFilename && (!clip.steps || !clip.seed))
|
|
|
|
|
const history = needsHistory
|
|
|
|
|
? await fetchHistoryAll().catch(() => ({} as Record<string, unknown>))
|
|
|
|
|
: {}
|
|
|
|
|
const byFile = new Map<string, { prompt: string; width: number; height: number; steps: number; seed: number }>()
|
|
|
|
|
for (const [promptId, entry] of Object.entries(history)) {
|
|
|
|
|
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
|
|
|
|
|
if (!video) continue
|
|
|
|
|
const meta = extractClipMetaFromHistory(entry)
|
|
|
|
|
byFile.set(video.filename, {
|
|
|
|
|
prompt: extractPromptFromHistory(entry),
|
|
|
|
|
width: meta.width,
|
|
|
|
|
height: meta.height,
|
|
|
|
|
steps: meta.steps,
|
|
|
|
|
seed: meta.seed
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sizes = new Map<string, { width: number; height: number }>()
|
|
|
|
|
for (const clip of catalog.clips) {
|
|
|
|
|
if (clip.hideThumbnail) continue
|
|
|
|
|
const thumb = clipThumbPath(owner, clip.id)
|
|
|
|
|
const video = clipVideoPath(owner, clip.id)
|
|
|
|
|
if (!existsSync(video)) continue
|
|
|
|
|
if (!existsSync(thumb)) {
|
|
|
|
|
await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg'))
|
|
|
|
|
}
|
|
|
|
|
if (!clip.width || !clip.height) {
|
|
|
|
|
const probed = await probeVideoSize(video)
|
|
|
|
|
if (probed) sizes.set(clip.id, probed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dirty = false
|
|
|
|
|
for (const clip of catalog.clips) {
|
|
|
|
|
const found = clip.comfyFilename ? byFile.get(clip.comfyFilename) : undefined
|
|
|
|
|
const probed = sizes.get(clip.id)
|
|
|
|
|
if (found) {
|
|
|
|
|
if (!clip.width && found.width) {
|
|
|
|
|
clip.width = found.width
|
|
|
|
|
clip.height = found.height
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
if (!clip.steps && found.steps) {
|
|
|
|
|
clip.steps = found.steps
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
if (!clip.seed && found.seed) {
|
|
|
|
|
clip.seed = found.seed
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
if ((!clip.prompt || clip.prompt === 'Recovered from ComfyUI') && found.prompt) {
|
|
|
|
|
clip.prompt = found.prompt
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ((!clip.width || !clip.height) && probed) {
|
|
|
|
|
clip.width = probed.width
|
|
|
|
|
clip.height = probed.height
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
const titled = clipTitle(clip)
|
|
|
|
|
if (clip.name !== titled) {
|
|
|
|
|
clip.name = titled
|
|
|
|
|
dirty = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (dirty) {
|
|
|
|
|
await mutate(owner, (next) => {
|
|
|
|
|
const byId = new Map(catalog.clips.map(clip => [clip.id, clip]))
|
|
|
|
|
for (const clip of next.clips) {
|
|
|
|
|
const updated = byId.get(clip.id)
|
|
|
|
|
if (!updated) continue
|
|
|
|
|
clip.name = updated.name
|
|
|
|
|
clip.prompt = updated.prompt
|
|
|
|
|
clip.width = updated.width
|
|
|
|
|
clip.height = updated.height
|
|
|
|
|
clip.steps = updated.steps
|
|
|
|
|
clip.seed = updated.seed
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stillPath(owner: string, id: string) {
|
|
|
|
@@ -451,6 +609,7 @@ export function imageContentType(buf: Buffer) {
|
|
|
|
|
export async function saveClip(params: {
|
|
|
|
|
ownerKey: string
|
|
|
|
|
folderId: string
|
|
|
|
|
name?: string
|
|
|
|
|
prompt: string
|
|
|
|
|
aspect: string
|
|
|
|
|
width: number
|
|
|
|
@@ -472,6 +631,7 @@ export async function saveClip(params: {
|
|
|
|
|
const clip: LibraryClip = {
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
folderId: folder.id,
|
|
|
|
|
name: clipTitle({ name: params.name || '', prompt: params.prompt }),
|
|
|
|
|
prompt: params.prompt,
|
|
|
|
|
aspect: params.aspect,
|
|
|
|
|
width: params.width,
|
|
|
|
@@ -486,7 +646,9 @@ export async function saveClip(params: {
|
|
|
|
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
|
|
|
|
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
|
|
|
|
if (!params.hideThumbnail && params.thumb?.length) {
|
|
|
|
|
writeFileSync(clipThumbPath(params.ownerKey, clip.id), params.thumb)
|
|
|
|
|
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
|
|
|
|
|
} else if (!params.hideThumbnail) {
|
|
|
|
|
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
|
|
|
|
}
|
|
|
|
|
if (params.thumb?.length) {
|
|
|
|
|
await saveStill({
|
|
|
|
@@ -543,14 +705,20 @@ export async function importMissingComfyVideos(owner: string, folderId?: string)
|
|
|
|
|
const known = new Set(catalog.clips.map(clip => clip.comfyFilename).filter(Boolean) as string[])
|
|
|
|
|
const unlabeled = catalog.clips.filter(clip => !clip.comfyFilename).length
|
|
|
|
|
const history = await fetchHistoryAll()
|
|
|
|
|
const found: { promptId: string; video: { filename: string; subfolder: string; type: string }; prompt: string }[] = []
|
|
|
|
|
const found: { promptId: string; video: { filename: string; subfolder: string; type: string }; prompt: string; width: number; height: number; steps: number; seed: number }[] = []
|
|
|
|
|
for (const [promptId, entry] of Object.entries(history)) {
|
|
|
|
|
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
|
|
|
|
|
if (!video || known.has(video.filename)) continue
|
|
|
|
|
const prompt = extractPromptFromHistory(entry) || 'Recovered from ComfyUI'
|
|
|
|
|
const meta = extractClipMetaFromHistory(entry)
|
|
|
|
|
found.push({
|
|
|
|
|
promptId,
|
|
|
|
|
video,
|
|
|
|
|
prompt: extractPromptFromHistory(entry) || 'Recovered from ComfyUI'
|
|
|
|
|
prompt,
|
|
|
|
|
width: meta.width,
|
|
|
|
|
height: meta.height,
|
|
|
|
|
steps: meta.steps,
|
|
|
|
|
seed: meta.seed
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
found.sort((a, b) => a.video.filename.localeCompare(b.video.filename, undefined, { numeric: true }))
|
|
|
|
@@ -561,13 +729,14 @@ export async function importMissingComfyVideos(owner: string, folderId?: string)
|
|
|
|
|
const clip = await saveClip({
|
|
|
|
|
ownerKey: owner,
|
|
|
|
|
folderId: folder.id,
|
|
|
|
|
name: clipTitle({ name: '', prompt: item.prompt }),
|
|
|
|
|
prompt: item.prompt,
|
|
|
|
|
aspect: 'auto',
|
|
|
|
|
width: 0,
|
|
|
|
|
height: 0,
|
|
|
|
|
steps: 0,
|
|
|
|
|
width: item.width,
|
|
|
|
|
height: item.height,
|
|
|
|
|
steps: item.steps,
|
|
|
|
|
turbo: true,
|
|
|
|
|
seed: 0,
|
|
|
|
|
seed: item.seed,
|
|
|
|
|
hideThumbnail: false,
|
|
|
|
|
video: buffer,
|
|
|
|
|
thumb: null,
|
|
|
|
|