From 98690b4a0a9fc930ee0465c4c7100850084cf4be Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 19 May 2026 09:46:08 +0800 Subject: [PATCH] auto-save 2026-05-19 09:46 (~6) --- .memory/worklog.json | 26 +++++++++ src/app/api/img/[bucket]/[filename]/route.ts | 4 +- src/lib/providers.ts | 55 ++++++++++++++++++++ src/lib/storage.ts | 49 +++++++++++++++-- src/lib/templates.ts | 16 ++++-- src/lib/types.ts | 28 ++++++++++ 6 files changed, 168 insertions(+), 10 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index bb2eff2..76c3ab8 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -366,6 +366,32 @@ "message": "auto-save 2026-05-19 09:24 (+1, ~1)", "hash": "be0efc3", "files_changed": 2 + }, + { + "ts": "2026-05-19T09:29:50+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 09:29 (~3)", + "hash": "1224ddf", + "files_changed": 3 + }, + { + "ts": "2026-05-19T01:29:59Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 09:29 (~3)", + "files_changed": 1 + }, + { + "ts": "2026-05-19T09:32:28+08:00", + "type": "commit", + "message": "chore: deploy ai toy patent to vps", + "hash": "781d29c", + "files_changed": 2 + }, + { + "ts": "2026-05-19T01:39:59Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:chore: deploy ai toy patent to vps", + "files_changed": 1 } ] } diff --git a/src/app/api/img/[bucket]/[filename]/route.ts b/src/app/api/img/[bucket]/[filename]/route.ts index 16567f1..0e93776 100644 --- a/src/app/api/img/[bucket]/[filename]/route.ts +++ b/src/app/api/img/[bucket]/[filename]/route.ts @@ -5,13 +5,13 @@ export const runtime = 'nodejs'; export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string; filename: string }> }) { const { bucket, filename } = await ctx.params; - if (!['generated', 'selected', 'refs', 'packs'].includes(bucket)) { + if (!['generated', 'selected', 'refs', 'packs', 'anchors'].includes(bucket)) { return NextResponse.json({ error: 'bad bucket' }, { status: 400 }); } if (filename.includes('..') || filename.includes('/')) { return NextResponse.json({ error: 'bad filename' }, { status: 400 }); } - const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs' | 'packs', filename); + const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs' | 'packs' | 'anchors', filename); if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 }); return new NextResponse(new Uint8Array(r.buf), { headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' }, diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 0512ee8..2ba7d73 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -1,4 +1,5 @@ import type { GenImage } from './types'; +import { readImageUrl } from './storage'; export type Provider = 'mock' | 'gpt'; @@ -93,6 +94,60 @@ export async function generateGptImages(opts: { return Promise.all(calls); } +function readEditImageBase64(payload: unknown): string { + return readImageBase64(payload); +} + +function dataUrlFromImageResponse(payload: unknown): string { + return `data:image/png;base64,${readEditImageBase64(payload)}`; +} + +export async function generateGptImageEdit(opts: { + sessionId: string; + prompt: string; + anchorImage: Buffer | string; + anchorMime?: string; + anchorFilename?: string; + size?: '1024x1024' | '1024x1536' | '1536x1024'; +}): Promise { + const key = process.env.OPENAI_API_KEY; + if (!key) throw new Error('OPENAI_API_KEY missing'); + + const source = typeof opts.anchorImage === 'string' + ? await readImageUrl(opts.anchorImage) + : { + buf: opts.anchorImage, + type: opts.anchorMime || 'image/png', + filename: opts.anchorFilename || 'anchor.png', + }; + + if (source.type.includes('svg')) { + throw new Error('GPT image edits require a raster anchor image; regenerate the source image with GPT first'); + } + + const form = new FormData(); + form.set('model', process.env.GPT_IMAGE_EDIT_MODEL || GPT_IMAGE_MODEL); + form.set('prompt', opts.prompt); + form.set('size', opts.size || '1024x1024'); + form.set('response_format', 'b64_json'); + form.set('image', new Blob([source.buf], { type: source.type }), source.filename); + + const res = await fetch(`${GPT_API_BASE}/images/edits`, { + method: 'POST', + headers: { Authorization: `Bearer ${key}` }, + body: form, + }); + if (!res.ok) throw new Error(`GPT image edit ${res.status}: ${await res.text()}`); + const data = await res.json(); + return { + id: `img_${opts.sessionId}_edit`, + url: dataUrlFromImageResponse(data), + prompt: opts.prompt, + status: 'pending', + meta: { provider: 'gpt', model: process.env.GPT_IMAGE_EDIT_MODEL || GPT_IMAGE_MODEL, edit: true }, + }; +} + export async function generateGptJson(opts: { prompt: string; fallback: T; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 18cec84..84403e1 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -8,10 +8,11 @@ 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 EXPORT_DIR = path.join(ROOT, 'exports'); async function ensureDirs() { - await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true }))); + await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true }))); } export async function saveSession(s: GenSession) { @@ -68,6 +69,17 @@ export async function savePackImage(packId: string, assetId: string, dataUrl: st return `/api/img/packs/${packId}_${assetId}.${ext}`; } +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}`; + await fs.writeFile(path.join(ANCHOR_DIR, filename), Buffer.from(m[2], 'base64')); + return `/api/img/anchors/${filename}`; +} + export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise { await ensureDirs(); // srcUrl 形如 /api/img/generated/xxx.png @@ -80,12 +92,13 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl: return `/api/img/selected/${filename}`; } -export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | 'packs', filename: string): Promise<{ buf: Buffer; type: string } | null> { +export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | 'packs' | 'anchors', filename: string): Promise<{ buf: Buffer; type: string } | null> { try { const dir = bucket === 'generated' ? GEN_DIR : bucket === 'selected' ? SEL_DIR : bucket === 'refs' ? REF_DIR - : PACK_DIR; + : bucket === 'packs' ? PACK_DIR + : ANCHOR_DIR; const buf = await fs.readFile(path.join(dir, filename)); const ext = path.extname(filename).slice(1).toLowerCase(); const type = ext === 'jpg' ? 'image/jpeg' @@ -97,6 +110,36 @@ export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | } } +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)\/([^/?#]+)$/); + if (localMatch) { + const bucket = localMatch[1] as 'generated' | 'selected' | 'refs' | 'packs' | 'anchors'; + 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}`); +} + export async function saveRefImage(sessionId: string, idx: number, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 00535b5..0e38976 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -1084,11 +1084,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [ }, ]; +function withPackAnchor(templates: AssetTemplate[], rootTemplateId: string): AssetTemplate[] { + return templates.map(template => ({ + ...template, + anchorTemplateId: template.id === rootTemplateId ? undefined : rootTemplateId, + })); +} + export const PACK_TEMPLATES: Record = { - patent: PATENT_TEMPLATES, - accessories: ACCESSORY_TEMPLATES, - production: PRODUCTION_TEMPLATES, - marketing: MARKETING_TEMPLATES, + patent: withPackAnchor(PATENT_TEMPLATES, 'patent_front'), + accessories: withPackAnchor(ACCESSORY_TEMPLATES, 'acc_inventory_sheet'), + production: withPackAnchor(PRODUCTION_TEMPLATES, 'prod_front_spec'), + marketing: withPackAnchor(MARKETING_TEMPLATES, 'mkt_white_front'), }; export const PACK_TEMPLATE_SUMMARY = PACK_ORDER.map(kind => ({ @@ -1133,4 +1140,3 @@ export function renderCharacterSummary(spec: CharacterSpec): string { `不要出现:${spec.negativePrompt}`, ].join(';'); } - diff --git a/src/lib/types.ts b/src/lib/types.ts index 131ef1d..b576d86 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,6 +52,7 @@ export type CharacterSpec = { negativePrompt: string; sourceImageId?: string; sourceImageUrl?: string; + cleanReferenceImageUrl?: string; lockedAt: number; }; @@ -66,6 +67,7 @@ export type AssetTemplate = { filenamePart: string; promptTemplate: string; checklist: string[]; + anchorTemplateId?: string; }; export type TextTemplate = { @@ -94,6 +96,9 @@ export type ToyAsset = { aspectRatio: AssetTemplate['aspectRatio']; required: boolean; createdAt: number; + anchorAssetId?: string; + anchorImageUrl?: string; + derivationLevel: 0 | 1 | 2 | 3; meta?: Record; }; @@ -176,6 +181,29 @@ export type LockCharacterResponse = { provider: 'mock' | 'gpt'; }; +export type CleanupCharacterRequest = { + sessionId: string; + imageId: string; + force?: boolean; +}; + +export type CleanupCharacterResponse = { + characterSpec: CharacterSpec; + cleanReferenceImageUrl: string; + provider: 'mock' | 'gpt'; +}; + +export type RegenerateAssetRequest = { + sessionId: string; + userRefinement?: string; +}; + +export type RegenerateAssetResponse = { + asset: ToyAsset; + pack: AssetPack; + provider: 'mock' | 'gpt'; +}; + export type VideoGenerationRequest = { prompt: string; imageUrl?: string;