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() {
- {template.promptTemplate}
+ {asset?.prompt ?? template.promptTemplate}
)}
+ {ready && asset.anchorImageUrl && (
+