Files
ai-toy-patent-workflow/src/lib/storage.ts

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}`;
}