486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
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<GenSession | null> {
|
|
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<GenSession[]> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<UploadedImage> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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}`;
|
|
}
|