From 7b4b5f7450e2b974d578dd25882d9e5ea1ab0bb7 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 19 May 2026 09:51:34 +0800 Subject: [PATCH] auto-save 2026-05-19 09:51 (+2, ~11) --- .env.local.example | 3 + .memory/worklog.json | 13 ++ RULES.md | 1 + docker-compose.prod.yml | 1 + .../api/assets/[assetId]/regenerate/route.ts | 31 +++ src/app/api/character/cleanup/route.ts | 38 ++++ src/app/api/character/lock/route.ts | 12 +- src/app/page.tsx | 36 +++- src/components/PackPanel.tsx | 66 ++++++- src/lib/packGenerator.ts | 182 ++++++++++++++++-- src/lib/providers.ts | 3 +- src/lib/types.ts | 3 + src/lib/videoProviders.ts | 5 + 13 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 src/app/api/assets/[assetId]/regenerate/route.ts create mode 100644 src/app/api/character/cleanup/route.ts diff --git a/.env.local.example b/.env.local.example index 6baac68..45e4802 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,3 +8,6 @@ GPT_API_BASE=https://api.openai.com/v1 SEEDANCE_API_KEY= SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3 + +# 生产环境填写公网入口,用于把 /api/img/... 补成 Seedance 可访问的绝对 URL。 +PUBLIC_APP_URL= diff --git a/.memory/worklog.json b/.memory/worklog.json index 76c3ab8..38e03ec 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -392,6 +392,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:chore: deploy ai toy patent to vps", "files_changed": 1 + }, + { + "ts": "2026-05-19T09:46:08+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 09:46 (~6)", + "hash": "98690b4", + "files_changed": 6 + }, + { + "ts": "2026-05-19T01:49:59Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 10 项未提交变更 · 最近提交:auto-save 2026-05-19 09:46 (~6)", + "files_changed": 10 } ] } diff --git a/RULES.md b/RULES.md index d9b00de..20ec6ec 100644 --- a/RULES.md +++ b/RULES.md @@ -33,6 +33,7 @@ - `SEEDANCE_API_KEY` — Seedance 视频生成 Key;未配置时视频接口返回 503 - `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128` - `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3` +- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL - 配置位置:`.env.local`(gitignored),参考 `.env.local.example` - 图片生成未配置 GPT Key 时回退 mock(SVG 占位图),视频生成不 mock,必须配置 Seedance Key diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2b92758..ed7b45b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,6 +13,7 @@ services: environment: NODE_ENV: production PORT: "4560" + PUBLIC_APP_URL: https://ai-toy.kang-kang.com volumes: - ./data:/app/data networks: diff --git a/src/app/api/assets/[assetId]/regenerate/route.ts b/src/app/api/assets/[assetId]/regenerate/route.ts new file mode 100644 index 0000000..774db73 --- /dev/null +++ b/src/app/api/assets/[assetId]/regenerate/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { regeneratePackAsset } from '@/lib/packGenerator'; +import { loadSession, saveSession } from '@/lib/storage'; +import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) { + const { assetId } = await ctx.params; + const { sessionId, userRefinement } = (await req.json()) as RegenerateAssetRequest; + + if (!assetId || !sessionId) { + return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 }); + } + + const session = await loadSession(sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + + try { + const regenerated = await regeneratePackAsset({ session, assetId, userRefinement }); + await saveSession(session); + return NextResponse.json({ + asset: regenerated.asset, + pack: regenerated.pack, + provider: regenerated.provider, + } satisfies RegenerateAssetResponse); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/character/cleanup/route.ts b/src/app/api/character/cleanup/route.ts new file mode 100644 index 0000000..1be4b99 --- /dev/null +++ b/src/app/api/character/cleanup/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; +import { loadSession, saveSession } from '@/lib/storage'; +import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + const { sessionId, imageId, force = false } = (await req.json()) as CleanupCharacterRequest; + + if (!sessionId || !imageId) { + return NextResponse.json({ error: 'sessionId and imageId required' }, { status: 400 }); + } + + const session = await loadSession(sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + + const sourceImage = session.images.find(image => image.id === imageId); + if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 }); + + try { + const characterSpec = session.characterSpec?.sourceImageId === imageId + ? session.characterSpec + : await buildCharacterSpec(session, sourceImage); + const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force }); + session.characterSpec = cleaned.characterSpec; + await saveSession(session); + + return NextResponse.json({ + characterSpec: cleaned.characterSpec, + cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl, + provider: cleaned.provider, + } satisfies CleanupCharacterResponse); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/character/lock/route.ts b/src/app/api/character/lock/route.ts index cc7298e..726440a 100644 --- a/src/app/api/character/lock/route.ts +++ b/src/app/api/character/lock/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { buildCharacterSpec } from '@/lib/packGenerator'; +import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; import { loadSession, saveSession } from '@/lib/storage'; import type { LockCharacterRequest, LockCharacterResponse } from '@/lib/types'; @@ -30,11 +30,17 @@ export async function POST(req: Request) { try { const characterSpec = await buildCharacterSpec(session, sourceImage); - session.characterSpec = characterSpec; + const cleaned = await cleanupCharacterAnchor({ + session, + sourceImage, + characterSpec, + force, + }); + session.characterSpec = cleaned.characterSpec; await saveSession(session); const response: LockCharacterResponse = { - characterSpec, + characterSpec: cleaned.characterSpec, provider: detectProvider(), }; return NextResponse.json(response); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0b68e1f..e48a448 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import type { GenerateResponse, LockCharacterResponse, PackKind, + RegenerateAssetResponse, VideoGenerationResponse, } from '@/lib/types'; import type { VIDEO_TEMPLATES } from '@/lib/templates'; @@ -147,6 +148,33 @@ export default function Home() { } } + async function handleRegenerateAsset(assetId: string, userRefinement?: string) { + if (!current) return; + const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: current.id, userRefinement }), + }); + if (!r.ok) { + alert('单张重做失败:' + (await r.text())); + return; + } + const d: RegenerateAssetResponse = await r.json(); + setProvider(d.provider); + await reloadCurrent(current.id); + } + + function resolveVideoAnchor(image: GenImage) { + const packs = current?.packs ?? []; + const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front'); + const patentFront = packs.find(pack => pack.kind === 'patent')?.assets.find(asset => asset.templateId === 'patent_front'); + const cleanReference = current?.characterSpec?.cleanReferenceImageUrl; + if (mktFront) return { url: mktFront.url, label: '宣发白底图' }; + if (patentFront) return { url: patentFront.url, label: '专利主图' }; + if (cleanReference) return { url: cleanReference, label: 'L1 白底锚图' }; + return { url: image.url, label: '意向图' }; + } + async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) { if (!current || videoLoading) return; setVideoLoading(true); @@ -155,12 +183,13 @@ export default function Home() { ? `${current.characterSpec.name},${current.characterSpec.oneLiner}` : current.prompt; const prompt = template.promptTemplate.replace('{character}', character); + const anchor = resolveVideoAnchor(image); const r = await fetch('/api/video/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, - imageUrl: image.url, + imageUrl: anchor.url, duration: template.duration, ratio: template.ratio, generateAudio: true, @@ -172,7 +201,7 @@ export default function Home() { return; } const d: VideoGenerationResponse = await r.json(); - alert(`Seedance 任务已提交:${d.taskId ?? d.status}`); + alert(`Seedance 任务已提交:${d.taskId ?? d.status};参考:${anchor.label}`); } finally { setVideoLoading(false); } @@ -208,7 +237,7 @@ export default function Home() {
- {provider === 'gpt' ? 'GPT · image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider} + {provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
@@ -237,6 +266,7 @@ export default function Home() { onGenerate={handleGeneratePack} onGenerateAll={handleGenerateAll} onLockCharacter={handleLockCharacter} + onRegenerateAsset={handleRegenerateAsset} onGenerateVideo={handleGenerateVideo} /> diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx index a4c49ce..9d5043e 100644 --- a/src/components/PackPanel.tsx +++ b/src/components/PackPanel.tsx @@ -32,13 +32,28 @@ function manifestUrl(sessionId: string, kind: PackKind, version: string) { } /* ── Asset Row ────────────────────────────────── */ -function AssetRow({ template, asset, accent }: { +function AssetRow({ template, asset, accent, onRegenerate }: { template: AssetTemplate; asset: ToyAsset | undefined; accent: typeof PACK_ACCENT[PackKind]; + onRegenerate?: (assetId: string, userRefinement?: string) => Promise; }) { const [showPrompt, setShowPrompt] = useState(false); + const [showRedo, setShowRedo] = useState(false); + const [refinement, setRefinement] = useState(''); + const [regenerating, setRegenerating] = useState(false); const ready = !!asset; + async function handleRedo() { + if (!asset || !onRegenerate || regenerating) return; + setRegenerating(true); + try { + await onRegenerate(asset.id, refinement); + setShowRedo(false); + setRefinement(''); + } finally { + setRegenerating(false); + } + } return (
{/* thumbnail */} @@ -76,30 +91,63 @@ function AssetRow({ template, asset, accent }: { {showPrompt && (
-            {template.promptTemplate}
+            {asset?.prompt ?? template.promptTemplate}
           
)} + {ready && asset.anchorImageUrl && ( +
+ anchor: {asset.anchorAssetId ?? asset.anchorImageUrl} +
+ )} + {showRedo && ready && ( +
+ setRefinement(event.target.value)} + placeholder="重做要求" + className="min-w-0 flex-1 rounded-lg bg-black/30 ring-1 ring-white/[0.08] px-2 py-1 text-[11px] text-white/80 outline-none focus:ring-violet-400/40" + /> + +
+ )}
{/* meta */}
{ASPECT_PX[template.aspectRatio]} - - {ready ? 'Ready' : '待生成'} - +
+ + {ready ? `L${asset!.derivationLevel}` : '待生成'} + + {ready && onRegenerate && ( + + )} +
); } /* ── Pack Section (collapsible) ───────────────── */ -function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate }: { +function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, onRegenerateAsset }: { kind: PackKind; session: GenSession; primaryImage: GenImage; pack: AssetPack | undefined; isLoading: boolean; onGenerate: () => void; + onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise; }) { const [open, setOpen] = useState(false); const accent = PACK_ACCENT[kind]; @@ -172,7 +220,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate {templates.map(template => { const asset = pack?.assets.find(a => a.templateId === template.id); return ( - + ); })} @@ -391,6 +439,7 @@ export default function PackPanel({ onGenerate, onGenerateAll, onLockCharacter, + onRegenerateAsset, onGenerateVideo, }: { session: GenSession; @@ -401,6 +450,7 @@ export default function PackPanel({ onGenerate: (image: GenImage, kind: PackKind) => void; onGenerateAll: (image: GenImage) => void; onLockCharacter: (image: GenImage) => void; + onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; }) { const [activeNav, setActiveNav] = useState('pack-patent'); @@ -507,6 +557,7 @@ export default function PackPanel({ ['比例', session.characterSpec.bodyRatio], ['配色', session.characterSpec.colorPalette.join('、')], ['材料', session.characterSpec.materials.join('、')], + ['L1', session.characterSpec.cleanReferenceImageUrl ? '白底锚图已生成' : '未生成'], ].map(([label, value]) => (
{label} @@ -533,6 +584,7 @@ export default function PackPanel({ pack={pack} isLoading={loadingKind === kind} onGenerate={() => onGenerate(primaryImage, kind)} + onRegenerateAsset={onRegenerateAsset} /> ); })} diff --git a/src/lib/packGenerator.ts b/src/lib/packGenerator.ts index 548274d..53eeeb5 100644 --- a/src/lib/packGenerator.ts +++ b/src/lib/packGenerator.ts @@ -8,8 +8,8 @@ import type { PackKind, ToyAsset, } from './types'; -import { detectProvider, generateGptImages, generateGptJson, generateMock } from './providers'; -import { saveExportManifest, savePackImage } from './storage'; +import { detectProvider, generateGptImageEdit, generateGptJson, generateMock } from './providers'; +import { saveAnchorImage, saveExportManifest, savePackImage } from './storage'; import { FILENAME_SCHEMA, getPackTemplates, PACK_LABELS, renderCharacterSummary, TEMPLATE_FREEZE_VERSION } from './templates'; function slugify(input: string): string { @@ -83,20 +83,98 @@ function renderPrompt(template: string, spec: CharacterSpec, sourceImageUrl: str ].join('\n'); } +function sizeForAspect(aspectRatio: '1:1' | '3:4' | '4:5' | '9:16' | '16:9' | 'long'): '1024x1024' | '1024x1536' | '1536x1024' { + if (aspectRatio === '16:9') return '1536x1024'; + if (aspectRatio === '1:1') return '1024x1024'; + return '1024x1536'; +} + +function resolveRootAnchor(characterSpec: CharacterSpec, sourceImage: GenImage): string { + return characterSpec.cleanReferenceImageUrl || characterSpec.sourceImageUrl || sourceImage.url; +} + +function sortTemplatesByAnchor(templates: T[]): T[] { + const remaining = [...templates]; + const done = new Set(); + const sorted: T[] = []; + while (remaining.length > 0) { + const index = remaining.findIndex(template => !template.anchorTemplateId || done.has(template.anchorTemplateId)); + if (index === -1) { + throw new Error(`template anchor cycle or missing root: ${remaining.map(template => template.id).join(', ')}`); + } + const [template] = remaining.splice(index, 1); + sorted.push(template); + done.add(template.id); + } + return sorted; +} + +export async function cleanupCharacterAnchor(opts: { + session: GenSession; + sourceImage: GenImage; + characterSpec?: CharacterSpec; + force?: boolean; +}): Promise<{ characterSpec: CharacterSpec; cleanReferenceImageUrl: string; provider: 'mock' | 'gpt' }> { + const provider = detectProvider(); + const characterSpec = opts.characterSpec ?? opts.session.characterSpec ?? await buildCharacterSpec(opts.session, opts.sourceImage); + + if (!opts.force && characterSpec.cleanReferenceImageUrl) { + return { characterSpec, cleanReferenceImageUrl: characterSpec.cleanReferenceImageUrl, provider }; + } + + const prompt = [ + '保持参考图中的玩具角色完全一致,只做产品图净化。', + '把背景换成纯白色,产品居中,正面或轻微正面视角,光线均匀。', + '不要改变五官、主配色、身体比例、毛绒材质、核心配件和识别元素。', + '不要文字、水印、logo、场景道具、价格、促销贴纸。', + `角色设定:${renderCharacterSummary(characterSpec)}`, + ].join('\n'); + + const image = provider === 'gpt' + ? await generateGptImageEdit({ + sessionId: `${opts.session.id}_clean`, + prompt, + anchorImage: opts.sourceImage.url, + size: '1024x1024', + }) + : (await generateMock({ + sessionId: `${opts.session.id}_clean`, + prompt, + count: 1, + }))[0]; + + if (!image) throw new Error('clean anchor generation failed'); + const cleanReferenceImageUrl = image.url.startsWith('data:') + ? await saveAnchorImage(opts.session.id, opts.sourceImage.id, image.url) + : image.url; + + return { + characterSpec: { + ...characterSpec, + sourceImageId: opts.sourceImage.id, + sourceImageUrl: opts.sourceImage.url, + cleanReferenceImageUrl, + }, + cleanReferenceImageUrl, + provider, + }; +} + async function generateAssetImage(opts: { packId: string; assetId: string; prompt: string; - sourceImageUrl: string; + anchorImageUrl: string; + aspectRatio: '1:1' | '3:4' | '4:5' | '9:16' | '16:9' | 'long'; }): Promise<{ url: string; provider: 'mock' | 'gpt'; raw?: unknown }> { const provider = detectProvider(); const images = provider === 'gpt' - ? await generateGptImages({ + ? [await generateGptImageEdit({ sessionId: `${opts.packId}_${opts.assetId}`, prompt: opts.prompt, - count: 1, - refImages: opts.sourceImageUrl.startsWith('http') ? [opts.sourceImageUrl] : [], - }) + anchorImage: opts.anchorImageUrl, + size: sizeForAspect(opts.aspectRatio), + })] : await generateMock({ sessionId: `${opts.packId}_${opts.assetId}`, prompt: opts.prompt, @@ -129,10 +207,16 @@ export async function generateAssetPack(opts: { sourceImage: GenImage; kind: PackKind; }): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'gpt' }> { - const templates = getPackTemplates(opts.kind); - const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id + const templates = sortTemplatesByAnchor(getPackTemplates(opts.kind)); + const initialCharacterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id ? opts.session.characterSpec : await buildCharacterSpec(opts.session, opts.sourceImage); + const cleaned = await cleanupCharacterAnchor({ + session: opts.session, + sourceImage: opts.sourceImage, + characterSpec: initialCharacterSpec, + }); + const characterSpec = cleaned.characterSpec; const version = 'v01'; const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`; const createdAt = Date.now(); @@ -141,12 +225,20 @@ export async function generateAssetPack(opts: { const assets: ToyAsset[] = []; for (const template of templates) { const assetId = `${opts.kind}_${template.filenamePart}_${randomBytes(3).toString('hex')}`; - const prompt = renderPrompt(template.promptTemplate, characterSpec, opts.sourceImage.url); + const anchorAsset = template.anchorTemplateId + ? assets.find(asset => asset.templateId === template.anchorTemplateId) + : undefined; + if (template.anchorTemplateId && !anchorAsset) { + throw new Error(`anchor ${template.anchorTemplateId} not generated yet`); + } + const anchorImageUrl = anchorAsset?.url ?? resolveRootAnchor(characterSpec, opts.sourceImage); + const prompt = renderPrompt(template.promptTemplate, characterSpec, anchorImageUrl); const generated = await generateAssetImage({ packId, assetId, prompt, - sourceImageUrl: opts.sourceImage.url, + anchorImageUrl, + aspectRatio: template.aspectRatio, }); assets.push({ id: assetId, @@ -162,10 +254,14 @@ export async function generateAssetPack(opts: { aspectRatio: template.aspectRatio, required: template.required, createdAt: Date.now(), + anchorAssetId: anchorAsset?.id, + anchorImageUrl, + derivationLevel: anchorAsset ? 3 : 2, meta: { provider: generated.provider, packLabel: PACK_LABELS[opts.kind], templateFreezeVersion: TEMPLATE_FREEZE_VERSION, + anchorTemplateId: template.anchorTemplateId, raw: generated.raw, }, }); @@ -221,6 +317,9 @@ export async function generateAssetPack(opts: { required: asset.required, aspectRatio: asset.aspectRatio, prompt: asset.prompt, + anchorAssetId: asset.anchorAssetId, + anchorImageUrl: asset.anchorImageUrl, + derivationLevel: asset.derivationLevel, checklist: template?.checklist ?? [], }; }), @@ -230,3 +329,64 @@ export async function generateAssetPack(opts: { await saveExportManifest(manifest); return { pack, manifest, provider }; } + +export async function regeneratePackAsset(opts: { + session: GenSession; + assetId: string; + userRefinement?: string; +}): Promise<{ pack: AssetPack; asset: ToyAsset; provider: 'mock' | 'gpt' }> { + const pack = opts.session.packs?.find(candidate => candidate.assets.some(asset => asset.id === opts.assetId)); + if (!pack) throw new Error('asset pack not found'); + const assetIndex = pack.assets.findIndex(asset => asset.id === opts.assetId); + const asset = pack.assets[assetIndex]; + if (!asset) throw new Error('asset not found'); + const template = getPackTemplates(pack.kind).find(candidate => candidate.id === asset.templateId); + if (!template) throw new Error(`template not found: ${asset.templateId}`); + + const anchorAsset = template.anchorTemplateId + ? pack.assets.find(candidate => candidate.templateId === template.anchorTemplateId) + : undefined; + if (template.anchorTemplateId && !anchorAsset) { + throw new Error(`anchor ${template.anchorTemplateId} not generated yet`); + } + + const sourceImage = opts.session.images.find(image => image.id === pack.sourceImageId); + const anchorImageUrl = anchorAsset?.url + ?? pack.characterSpec.cleanReferenceImageUrl + ?? pack.characterSpec.sourceImageUrl + ?? sourceImage?.url; + if (!anchorImageUrl) throw new Error('anchor image not found'); + + const prompt = [ + renderPrompt(template.promptTemplate, pack.characterSpec, anchorImageUrl), + opts.userRefinement?.trim() ? `用户重做要求:${opts.userRefinement.trim()}` : '', + ].filter(Boolean).join('\n'); + + const generated = await generateAssetImage({ + packId: pack.id, + assetId: asset.id, + prompt, + anchorImageUrl, + aspectRatio: template.aspectRatio, + }); + + const updatedAsset: ToyAsset = { + ...asset, + url: generated.url, + prompt, + status: 'draft', + createdAt: Date.now(), + anchorAssetId: anchorAsset?.id, + anchorImageUrl, + derivationLevel: anchorAsset ? 3 : 2, + meta: { + ...(asset.meta ?? {}), + provider: generated.provider, + anchorTemplateId: template.anchorTemplateId, + regeneratedAt: Date.now(), + raw: generated.raw, + }, + }; + pack.assets[assetIndex] = updatedAsset; + return { pack, asset: updatedAsset, provider: detectProvider() }; +} diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 2ba7d73..27be212 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -130,7 +130,8 @@ export async function generateGptImageEdit(opts: { 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 imageBytes = source.buf.buffer.slice(source.buf.byteOffset, source.buf.byteOffset + source.buf.byteLength) as ArrayBuffer; + form.set('image', new Blob([imageBytes], { type: source.type }), source.filename); const res = await fetch(`${GPT_API_BASE}/images/edits`, { method: 'POST', diff --git a/src/lib/types.ts b/src/lib/types.ts index b576d86..d4f3e5f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -142,6 +142,9 @@ export type ExportManifest = { required: boolean; aspectRatio: AssetTemplate['aspectRatio']; prompt: string; + anchorAssetId?: string; + anchorImageUrl?: string; + derivationLevel?: 0 | 1 | 2 | 3; checklist: string[]; }>; exportTargets: Array<'zip' | 'pdf' | 'manifest-json'>; diff --git a/src/lib/videoProviders.ts b/src/lib/videoProviders.ts index ed9324e..48cf7d6 100644 --- a/src/lib/videoProviders.ts +++ b/src/lib/videoProviders.ts @@ -16,6 +16,11 @@ function normalizeStatus(status?: string): VideoGenerationResponse['status'] { function publicUrlOrUndefined(url?: string): string | undefined { if (!url) return undefined; + if (url.startsWith('/')) { + const base = process.env.PUBLIC_APP_URL || process.env.NEXT_PUBLIC_APP_URL; + if (!base || /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(base)) return undefined; + return new URL(url, base).toString(); + } if (!/^https?:\/\//i.test(url)) return undefined; if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(url)) return undefined; return url;