import { promises as fs } from 'node:fs'; import { randomBytes } from 'node:crypto'; import path from 'node:path'; import { imageFileInfo, recordEvent, upsertImageAsset } from './auditDb'; import type { ExportManifest, GenSession, UploadedImage, UploadedImageRole } from './types'; const ROOT = path.join(process.cwd(), 'data'); const SESS_DIR = path.join(ROOT, 'sessions'); const SEL_DIR = path.join(ROOT, 'selected'); const REF_DIR = path.join(ROOT, 'refs'); const GEN_DIR = path.join(ROOT, 'generated'); const PACK_DIR = path.join(ROOT, 'packs'); const ANCHOR_DIR = path.join(ROOT, 'anchors'); const UPLOAD_DIR = path.join(ROOT, 'uploads'); const VIDEO_DIR = path.join(ROOT, 'videos'); const EXPORT_DIR = path.join(ROOT, 'exports'); const BUCKET_DIRS = { generated: GEN_DIR, selected: SEL_DIR, refs: REF_DIR, packs: PACK_DIR, anchors: ANCHOR_DIR, uploads: UPLOAD_DIR, } as const; async function ensureDirs() { await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, VIDEO_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true }))); } export async function saveSession(s: GenSession) { await ensureDirs(); await fs.writeFile(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2), 'utf-8'); indexSessionImages(s); recordEvent({ action: 'session.saved', sessionId: s.id, targetType: 'session', targetId: s.id, status: 'ok', metadata: { images: s.images.length, packs: s.packs?.length ?? 0, packAssets: (s.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0), inputMode: s.inputMode ?? 'idea', }, }); } export async function loadSession(id: string): Promise { try { const raw = await fs.readFile(path.join(SESS_DIR, `${id}.json`), 'utf-8'); return JSON.parse(raw); } catch { return null; } } export async function listSessions(): Promise { await ensureDirs(); const files = await fs.readdir(SESS_DIR); const all = await Promise.all( files.filter(f => f.endsWith('.json')).map(async f => { const raw = await fs.readFile(path.join(SESS_DIR, f), 'utf-8'); return JSON.parse(raw) as GenSession; }) ); return all.sort((a, b) => b.createdAt - a.createdAt); } function extFromMime(mime: string): string { if (mime.includes('jpeg')) return 'jpg'; if (mime.includes('svg')) return 'svg'; if (mime.includes('mp4')) return 'mp4'; if (mime.includes('quicktime')) return 'mov'; if (mime.includes('webm')) return 'webm'; if (mime.includes('png')) return 'png'; if (mime.includes('webp')) return 'webp'; return 'bin'; } function safePart(input: string): string { return input .normalize('NFKD') .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'image'; } function localFileFromUrl(url: string): { bucket: keyof typeof BUCKET_DIRS; filename: string; filePath: string } | null { const match = url.match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/); if (!match) return null; const bucket = match[1] as keyof typeof BUCKET_DIRS; const filename = decodeURIComponent(match[2]); return { bucket, filename, filePath: path.join(BUCKET_DIRS[bucket], filename) }; } function packKindFromPackId(packId: string): string | null { return packId.match(/^pack_([^_]+)_/)?.[1] ?? null; } function indexSessionImages(session: GenSession) { for (const image of session.images) { const local = localFileFromUrl(image.url); if (!local) continue; const info = imageFileInfo(local.filePath); upsertImageAsset({ filename: local.filename, url: image.url, bucket: local.bucket, sessionId: session.id, targetId: image.id, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: image.status === 'selected' || image.status === 'pending', origin: local.bucket === 'selected' ? 'selected' : local.bucket === 'uploads' ? 'upload' : 'generated', status: image.status, }); } for (const upload of session.uploadedImages ?? []) { const local = localFileFromUrl(upload.url); if (!local) continue; const info = imageFileInfo(local.filePath); upsertImageAsset({ filename: local.filename, url: upload.url, bucket: local.bucket, sessionId: session.id, targetId: upload.id, title: upload.originalFilename ?? upload.filename, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'upload', status: upload.role, }); } if (session.characterSpec?.cleanReferenceImageUrl) { const local = localFileFromUrl(session.characterSpec.cleanReferenceImageUrl); if (local) { const info = imageFileInfo(local.filePath); upsertImageAsset({ filename: local.filename, url: session.characterSpec.cleanReferenceImageUrl, bucket: local.bucket, sessionId: session.id, targetId: session.characterSpec.sourceImageId, title: `${session.characterSpec.name} L1 anchor`, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'anchor', status: 'clean_reference', }); } } for (const pack of session.packs ?? []) { for (const asset of pack.assets) { const local = localFileFromUrl(asset.url); if (!local) continue; const info = imageFileInfo(local.filePath); upsertImageAsset({ filename: local.filename, url: asset.url, bucket: local.bucket, sessionId: session.id, packId: pack.id, kind: pack.kind, templateId: asset.templateId, title: asset.title, aspectRatio: asset.aspectRatio, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: local.bucket === 'uploads' ? 'upload' : 'pack', status: asset.status, }); } } } export async function saveGeneratedImage(sessionId: string, imageId: string, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!m) throw new Error('Invalid data URL'); const ext = extFromMime(m[1]); const file = path.join(GEN_DIR, `${sessionId}_${imageId}.${ext}`); const buffer = Buffer.from(m[2], 'base64'); await fs.writeFile(file, buffer); const filename = `${sessionId}_${imageId}.${ext}`; const url = `/api/img/generated/${filename}`; const info = imageFileInfo(file); upsertImageAsset({ filename, url, bucket: 'generated', sessionId, targetId: imageId, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: false, origin: 'generated', status: 'saved', }); recordEvent({ action: 'image.saved', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { bucket: 'generated', filename, bytes: buffer.length } }); return url; } export async function savePackImage(packId: string, assetId: string, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!m) throw new Error('Invalid data URL'); const ext = extFromMime(m[1]); const file = path.join(PACK_DIR, `${packId}_${assetId}.${ext}`); const buffer = Buffer.from(m[2], 'base64'); await fs.writeFile(file, buffer); const filename = `${packId}_${assetId}.${ext}`; const url = `/api/img/packs/${filename}`; const info = imageFileInfo(file); upsertImageAsset({ filename, url, bucket: 'packs', packId, kind: packKindFromPackId(packId), targetId: assetId, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: false, origin: 'pack', status: 'saved', }); recordEvent({ action: 'image.saved', targetType: 'pack_asset', targetId: assetId, status: 'ok', metadata: { bucket: 'packs', packId, filename, bytes: buffer.length } }); return url; } export async function saveAnchorImage(sessionId: string, imageId: string, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!m) throw new Error('Invalid data URL'); const ext = extFromMime(m[1]); const safeImageId = imageId.replace(/[^a-zA-Z0-9_-]+/g, '-'); const filename = `${sessionId}_${safeImageId}_clean.${ext}`; const file = path.join(ANCHOR_DIR, filename); const buffer = Buffer.from(m[2], 'base64'); await fs.writeFile(file, buffer); const url = `/api/img/anchors/${filename}`; const info = imageFileInfo(file); upsertImageAsset({ filename, url, bucket: 'anchors', sessionId, targetId: imageId, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'anchor', status: 'clean_reference', }); recordEvent({ action: 'image.saved', sessionId, targetType: 'anchor', targetId: imageId, status: 'ok', metadata: { bucket: 'anchors', filename, bytes: buffer.length } }); return url; } export async function saveUploadedImage(opts: { buffer: Buffer; mimeType: string; originalFilename?: string; role: UploadedImageRole; accessoryName?: string; needsCleanup?: boolean; }): Promise { await ensureDirs(); const uploadedAt = Date.now(); const id = `upl_${uploadedAt.toString(36)}_${randomBytes(3).toString('hex')}`; const ext = extFromMime(opts.mimeType); const baseName = opts.originalFilename ? safePart(path.parse(opts.originalFilename).name) : 'upload'; const filename = `${id}_${baseName}.${ext}`; const file = path.join(UPLOAD_DIR, filename); await fs.writeFile(file, opts.buffer); const uploaded = { id, url: `/api/img/uploads/${filename}`, filename, originalFilename: opts.originalFilename, mimeType: opts.mimeType, uploadedAt, role: opts.role, accessoryName: opts.accessoryName, needsCleanup: opts.needsCleanup ?? true, }; const info = imageFileInfo(file); upsertImageAsset({ filename, url: uploaded.url, bucket: 'uploads', targetId: id, title: uploaded.originalFilename ?? filename, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'upload', status: opts.role, }); recordEvent({ action: 'upload.saved', targetType: 'upload', targetId: id, status: 'ok', metadata: { filename, originalFilename: opts.originalFilename, role: opts.role, bytes: opts.buffer.length } }); return uploaded; } export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise { await ensureDirs(); // srcUrl 形如 /api/img/generated/xxx.png const match = srcUrl.match(/\/api\/img\/generated\/(.+)$/); if (!match) return srcUrl; const filename = match[1]; const src = path.join(GEN_DIR, filename); const dst = path.join(SEL_DIR, filename); await fs.copyFile(src, dst); const url = `/api/img/selected/${filename}`; const info = imageFileInfo(dst); upsertImageAsset({ filename, url, bucket: 'selected', sessionId, targetId: imageId, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'selected', status: 'selected', }); recordEvent({ action: 'image.selected_copy', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { filename } }); return url; } export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads'; export async function readImageFile(bucket: ImageBucket, filename: string): Promise<{ buf: Buffer; type: string } | null> { try { const dir = bucket === 'generated' ? GEN_DIR : bucket === 'selected' ? SEL_DIR : bucket === 'refs' ? REF_DIR : bucket === 'packs' ? PACK_DIR : bucket === 'anchors' ? ANCHOR_DIR : UPLOAD_DIR; const buf = await fs.readFile(path.join(dir, filename)); const ext = path.extname(filename).slice(1).toLowerCase(); const type = ext === 'jpg' ? 'image/jpeg' : ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; return { buf, type }; } catch { return null; } } export async function readImageUrl(url: string): Promise<{ buf: Buffer; type: string; filename: string }> { const dataMatch = url.match(/^data:([^;]+);base64,(.+)$/); if (dataMatch) { return { buf: Buffer.from(dataMatch[2], 'base64'), type: dataMatch[1], filename: `anchor.${extFromMime(dataMatch[1])}`, }; } const localMatch = url.match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/); if (localMatch) { const bucket = localMatch[1] as ImageBucket; const filename = decodeURIComponent(localMatch[2]); const image = await readImageFile(bucket, filename); if (!image) throw new Error(`anchor image not found: ${url}`); return { ...image, filename }; } if (/^https?:\/\//i.test(url)) { const res = await fetch(url); if (!res.ok) throw new Error(`failed to fetch anchor image ${res.status}: ${url}`); const type = res.headers.get('content-type')?.split(';')[0] || 'image/png'; const filename = new URL(url).pathname.split('/').pop() || `remote.${extFromMime(type)}`; return { buf: Buffer.from(await res.arrayBuffer()), type, filename }; } throw new Error(`unsupported anchor image URL: ${url}`); } function videoTypeFromFilename(filename: string) { const ext = path.extname(filename).slice(1).toLowerCase(); if (ext === 'webm') return 'video/webm'; if (ext === 'mov') return 'video/quicktime'; return 'video/mp4'; } export async function saveRemoteVideo(sessionId: string, taskId: string, url: string): Promise { if (url.startsWith('/api/video-file/')) return url; if (!/^https?:\/\//i.test(url)) return url; await ensureDirs(); const res = await fetch(url); if (!res.ok) throw new Error(`failed to fetch generated video ${res.status}`); const type = res.headers.get('content-type')?.split(';')[0] || 'video/mp4'; const fromPath = new URL(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]; const inferredExt = (fromPath || extFromMime(type) || 'mp4').toLowerCase(); const ext = inferredExt === 'bin' || inferredExt === 'm4v' ? 'mp4' : inferredExt; const safeTaskId = safePart(taskId); const filename = `${safePart(sessionId)}_${safeTaskId}.${ext}`; const file = path.join(VIDEO_DIR, filename); const buffer = Buffer.from(await res.arrayBuffer()); await fs.writeFile(file, buffer); recordEvent({ action: 'video.saved', sessionId, targetType: 'video', targetId: taskId, status: 'ok', metadata: { filename, bytes: buffer.length, type }, }); return `/api/video-file/${filename}`; } export async function readVideoFile(filename: string): Promise<{ buf: Buffer; type: string } | null> { try { const safeFilename = path.basename(filename); const buf = await fs.readFile(path.join(VIDEO_DIR, safeFilename)); return { buf, type: videoTypeFromFilename(safeFilename) }; } catch { return null; } } export async function saveRefImage(sessionId: string, idx: number, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!m) throw new Error('Invalid ref data URL'); const ext = extFromMime(m[1]); const file = path.join(REF_DIR, `${sessionId}_ref${idx}.${ext}`); const buffer = Buffer.from(m[2], 'base64'); await fs.writeFile(file, buffer); const filename = `${sessionId}_ref${idx}.${ext}`; const url = `/api/img/refs/${filename}`; const info = imageFileInfo(file); upsertImageAsset({ filename, url, bucket: 'refs', sessionId, targetId: `ref${idx}`, width: info.width, height: info.height, sizeBytes: info.sizeBytes, current: true, origin: 'ref', status: 'reference', }); recordEvent({ action: 'image.saved', sessionId, targetType: 'ref', targetId: `ref${idx}`, status: 'ok', metadata: { bucket: 'refs', filename, bytes: buffer.length } }); return url; } export async function saveExportManifest(manifest: ExportManifest): Promise { await ensureDirs(); const filename = `${manifest.sessionId}_${manifest.packKind}_${manifest.version}_manifest.json`; await fs.writeFile(path.join(EXPORT_DIR, filename), JSON.stringify(manifest, null, 2), 'utf-8'); recordEvent({ action: 'export.manifest_saved', sessionId: manifest.sessionId, targetType: 'manifest', targetId: manifest.id, status: 'ok', metadata: { filename, packId: manifest.packId, packKind: manifest.packKind, files: manifest.files.length }, }); return `/api/export/${filename}`; }