diff --git a/HANDOFF_IMAGE_PIPELINE.md b/HANDOFF_IMAGE_PIPELINE.md index 56be03c..4518b89 100644 --- a/HANDOFF_IMAGE_PIPELINE.md +++ b/HANDOFF_IMAGE_PIPELINE.md @@ -413,7 +413,6 @@ L0 意向图 ──→ L1 白底锚图 ──┬──→ 专利主图 ──→ | 模板查询 API | `src/app/api/templates/route.ts` | | 角色锁定 API | `src/app/api/character/lock/route.ts` | | 单包生成 API | `src/app/api/packs/generate/route.ts` | -| 全包生成 API | `src/app/api/packs/generate-all/route.ts` | | 视频生成 API | `src/app/api/video/generate/route.ts` | --- @@ -1085,10 +1084,10 @@ Agent 跑到中途失败(API 超时、Key 限流)的处理: - 用户手动点每个包的"生成"按钮 - 没有自动拓扑、没有自检 -**第 2 期:拓扑批量生成** +**第 2 期:串行阶段生成** - 完成 §10.5(buildDAG + topologicalSort)+ §10.C(runGenerationLoop) -- 用户点一次"一键全包",agent 按 wave 并行跑完 -- 还没有自检 +- 从专利包开始逐步推进,前一包完成后才允许下一包进入队列 +- 不提供"一键全包",避免跳过人工检查和误触高成本生成 **第 3 期:自检 + 自动重做** - 完成 §10.6 + §10.H @@ -1338,4 +1337,3 @@ extra fingers, distorted toy proportions 3 个 slot 跑通了,宣发素材就够发一波。 - diff --git a/RULES.md b/RULES.md index d603160..6e143aa 100644 --- a/RULES.md +++ b/RULES.md @@ -92,9 +92,10 @@ 3. 九宫格快筛:数字键 `1-9` 选中,`Shift+1-9` 打叉 4. 选中的图自动复制到 `data/selected/` 5. 锁定角色设定 `CharacterSpec` -6. 一键生成完整三包:专利包、生产打样包、宣发包 -7. Seedance 生成视频任务:旋转展示、开箱、触感细节、角色故事 -8. 侧栏保留历史会话,点击切换 +6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包` +7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API +8. 四个图片包完成后,才解锁文案模板和 Seedance 视频任务:旋转展示、开箱、触感细节、角色故事 +9. 侧栏保留历史会话,点击切换 ## 后续路线 - 导出专利包:PNG高清 + PDF合订 diff --git a/src/app/api/packs/generate-all/route.ts b/src/app/api/packs/generate-all/route.ts deleted file mode 100644 index 8cfdcb5..0000000 --- a/src/app/api/packs/generate-all/route.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { NextResponse } from 'next/server'; -import { recordEvent } from '@/lib/auditDb'; -import { startGenerationLock } from '@/lib/generationLocks'; -import { generateAssetPack } from '@/lib/packGenerator'; -import { detectProvider } from '@/lib/providers'; -import { loadSession, saveSession } from '@/lib/storage'; -import { getPackTemplates, PACK_ORDER } from '@/lib/templates'; -import type { AssetPack, GenerateAllPacksRequest, GenerateAllPacksResponse, GenSession } from '@/lib/types'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -async function persistPackProgress(session: GenSession, imageId: string, pack: AssetPack) { - session.characterSpec = pack.characterSpec; - session.packs = [ - ...(session.packs ?? []).filter(existing => !(existing.kind === pack.kind && existing.sourceImageId === imageId)), - { ...pack, assets: [...pack.assets] }, - ]; - await saveSession(session); -} - -function isCompletePack(pack: AssetPack, imageId: string): boolean { - if (pack.sourceImageId !== imageId || pack.status !== 'complete') return false; - const expectedIds = new Set(getPackTemplates(pack.kind).map(template => template.id)); - const assetIds = new Set(pack.assets.map(asset => asset.templateId)); - return expectedIds.size > 0 && [...expectedIds].every(templateId => assetIds.has(templateId)); -} - -export async function POST(req: Request) { - const { sessionId, imageId, background = false } = (await req.json()) as GenerateAllPacksRequest; - 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 }); - if (sourceImage.status !== 'selected') { - return NextResponse.json({ error: 'image must be selected before generating packs' }, { status: 400 }); - } - - const baseSession = session; - const baseSourceImage = sourceImage; - const releaseAllLock = startGenerationLock(`packs:all:${sessionId}:${imageId}`); - if (!releaseAllLock) { - recordEvent({ - action: 'packs.generate_all_blocked_running', - sessionId, - targetType: 'pack_all', - targetId: imageId, - status: 'blocked', - provider: detectProvider(), - }); - return NextResponse.json({ - ok: true, - background: true, - running: true, - provider: detectProvider(), - }, { status: 202 }); - } - const releaseAll = releaseAllLock; - - async function run() { - const packs: AssetPack[] = []; - const manifests = []; - let workingSession: GenSession = baseSession; - - try { - recordEvent({ action: 'packs.generate_all_started', sessionId, targetType: 'pack_all', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { background } }); - for (const kind of PACK_ORDER) { - const existingPack = workingSession.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId)); - if (existingPack) { - recordEvent({ action: 'pack.generate_skipped_existing', sessionId, targetType: 'pack', targetId: existingPack.id, status: 'ok', provider: detectProvider(), metadata: { kind, assets: existingPack.assets.length } }); - const existingManifest = workingSession.exports?.find(manifest => ( - manifest.packKind === kind && - manifest.source.sourceImageId === imageId && - manifest.packId === existingPack.id - )); - packs.push(existingPack); - if (existingManifest) manifests.push(existingManifest); - continue; - } - - const releasePackLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`); - if (!releasePackLock) { - recordEvent({ action: 'pack.generate_blocked_running', sessionId, targetType: 'pack', targetId: kind, status: 'blocked', provider: detectProvider(), metadata: { imageId, fromAll: true } }); - continue; - } - - try { - recordEvent({ action: 'pack.generate_started', sessionId, targetType: 'pack', targetId: kind, status: 'started', provider: detectProvider(), metadata: { imageId, fromAll: true } }); - const generated = await generateAssetPack({ - session: workingSession, - sourceImage: baseSourceImage, - kind, - onProgress: async progressPack => { - await persistPackProgress(workingSession, imageId, progressPack); - recordEvent({ action: 'pack.generate_progress', sessionId, targetType: 'pack', targetId: progressPack.id, status: 'running', provider: detectProvider(), metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status, fromAll: true } }); - }, - }); - recordEvent({ action: 'pack.generate_completed', sessionId, targetType: 'pack', targetId: generated.pack.id, status: 'ok', provider: generated.provider, metadata: { kind, assets: generated.pack.assets.length, fromAll: true } }); - packs.push(generated.pack); - manifests.push(generated.manifest); - workingSession = { - ...workingSession, - characterSpec: generated.pack.characterSpec, - packs: [ - ...(workingSession.packs ?? []).filter(existing => !(existing.kind === kind && existing.sourceImageId === imageId)), - generated.pack, - ], - exports: [ - ...(workingSession.exports ?? []).filter(existing => !(existing.packKind === kind && existing.source.sourceImageId === imageId)), - generated.manifest, - ], - }; - } finally { - releasePackLock(); - } - } - - await saveSession(workingSession); - recordEvent({ action: 'packs.generate_all_completed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'ok', provider: detectProvider(), metadata: { packs: packs.length, manifests: manifests.length } }); - - return { - packs, - manifests, - provider: detectProvider(), - } satisfies GenerateAllPacksResponse; - } finally { - releaseAll(); - } - } - - if (background) { - void run().catch(error => { - recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: true } }); - console.error('[packs:all] background generation failed', error); - }); - return NextResponse.json({ - ok: true, - background: true, - provider: detectProvider(), - }, { status: 202 }); - } - - try { - return NextResponse.json(await run()); - } catch (error) { - recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: false } }); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/src/app/api/packs/generate/route.ts b/src/app/api/packs/generate/route.ts index e30b0d6..273e0fd 100644 --- a/src/app/api/packs/generate/route.ts +++ b/src/app/api/packs/generate/route.ts @@ -4,12 +4,13 @@ import { startGenerationLock } from '@/lib/generationLocks'; import { generateAssetPack } from '@/lib/packGenerator'; import { detectProvider } from '@/lib/providers'; import { loadSession, saveSession } from '@/lib/storage'; +import { getPackTemplates, PACK_LABELS, PACK_ORDER } from '@/lib/templates'; import type { AssetPack, GeneratePackRequest, GeneratePackResponse, GenSession, PackKind } from '@/lib/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const PACK_KINDS: PackKind[] = ['patent', 'accessories', 'production', 'marketing']; +const PACK_KINDS: PackKind[] = PACK_ORDER; async function persistPackProgress(session: GenSession, imageId: string, pack: AssetPack) { session.characterSpec = pack.characterSpec; @@ -20,6 +21,44 @@ async function persistPackProgress(session: GenSession, imageId: string, pack: A await saveSession(session); } +function isCompletePack(pack: AssetPack, imageId: string): boolean { + if (pack.sourceImageId !== imageId || pack.status !== 'complete') return false; + const expectedIds = new Set(getPackTemplates(pack.kind).map(template => template.id)); + const assetIds = new Set(pack.assets.map(asset => asset.templateId)); + return expectedIds.size > 0 && [...expectedIds].every(templateId => assetIds.has(templateId)); +} + +function getPreviousKind(kind: PackKind): PackKind | null { + const index = PACK_ORDER.indexOf(kind); + if (index <= 0) return null; + return PACK_ORDER[index - 1]; +} + +function findCompletePack(session: GenSession, imageId: string, kind: PackKind) { + return session.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId)); +} + +function validateSequentialGate(session: GenSession, imageId: string, kind: PackKind) { + if (!session.characterSpec) { + return { + error: '请先锁定角色设定,然后从专利包开始生成。', + requiredKind: 'character', + }; + } + + const previousKind = getPreviousKind(kind); + if (!previousKind) return null; + + if (!findCompletePack(session, imageId, previousKind)) { + return { + error: `请先完成${PACK_LABELS[previousKind]},再生成${PACK_LABELS[kind]}。`, + requiredKind: previousKind, + }; + } + + return null; +} + export async function POST(req: Request) { const { sessionId, imageId, kind, background = false } = (await req.json()) as GeneratePackRequest; if (!sessionId || !imageId || !PACK_KINDS.includes(kind)) { @@ -35,6 +74,21 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'image must be selected before generating packs' }, { status: 400 }); } + const gate = validateSequentialGate(session, imageId, kind); + if (gate) { + recordEvent({ + action: 'pack.generate_blocked_sequence', + sessionId, + targetType: 'pack', + targetId: kind, + status: 'blocked', + provider: detectProvider(), + message: gate.error, + metadata: { imageId, kind, requiredKind: gate.requiredKind }, + }); + return NextResponse.json({ error: gate.error, requiredKind: gate.requiredKind }, { status: 409 }); + } + const baseSession = session; const baseSourceImage = sourceImage; const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`); diff --git a/src/app/page.tsx b/src/app/page.tsx index 07816ca..e265162 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,7 +10,6 @@ import { PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates'; import type { GenImage, GenSession, - GenerateAllPacksResponse, GeneratePackResponse, GenerateResponse, LockCharacterResponse, @@ -146,7 +145,6 @@ export default function Home() { const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(false); const [loadingKind, setLoadingKind] = useState(null); - const [allLoading, setAllLoading] = useState(false); const [characterLoading, setCharacterLoading] = useState(false); const [videoLoading, setVideoLoading] = useState(false); const [uploadLoading, setUploadLoading] = useState(false); @@ -310,28 +308,6 @@ export default function Home() { } } - async function handleGenerateAll(image: GenImage) { - if (!current || allLoading) return; - setAllLoading(true); - try { - const r = await fetch('/api/packs/generate-all', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: current.id, imageId: image.id, background: true }), - }); - if (!r.ok) { - alert('完整三包生成失败:' + (await r.text())); - return; - } - const d: GenerateAllPacksResponse = await r.json(); - setProvider(d.provider); - await reloadCurrent(current.id); - scheduleSessionRefresh(current.id); - } finally { - setAllLoading(false); - } - } - async function handleRegenerateAsset(assetId: string, userRefinement?: string) { if (!current) return; const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, { @@ -472,11 +448,9 @@ export default function Home() { void; onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(kind === 'patent'); const accent = PACK_ACCENT[kind]; const templates = PACK_TEMPLATES[kind]; const generatedCount = pack?.assets.length ?? 0; const total = templates.length; const progressPct = Math.round((generatedCount / total) * 100); + const complete = generatedCount >= total; function handleGenerateClick() { + if (locked) return; if (pack) { const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`); if (!ok) return; @@ -179,16 +182,18 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, } return ( -
+
{/* header — always visible */}
-
- {accent.icon} +
+ {stepIndex}
{PACK_LABELS[kind]} {PACK_DESCRIPTIONS[kind]} + {complete && 完成} + {locked && 锁定}
{/* progress bar */}
@@ -205,14 +210,15 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
+ {locked && lockReason && ( +
+ {lockReason} +
+ )} + {/* asset list — collapsible */} {open && (
@@ -244,12 +256,12 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, } /* ── Text Template Section (collapsible) ──────── */ -function TextTemplateSection() { +function TextTemplateSection({ locked }: { locked: boolean }) { const [open, setOpen] = useState(false); const [showPromptId, setShowPromptId] = useState(null); return ( -
+
T
@@ -265,9 +277,12 @@ function TextTemplateSection() {
GPT Text
); @@ -443,27 +463,37 @@ function SectionNav({ active, onChange }: { active: string; onChange: (id: strin } /* ── Main Export ──────────────────────────────── */ -const totalImageSlots = PACK_ORDER.reduce((s, k) => s + PACK_TEMPLATES[k].length, 0); +const totalImageSlots = PACK_ORDER.reduce((sum, kind) => sum + PACK_TEMPLATES[kind].length, 0); + +function packForKind(packs: AssetPack[], kind: PackKind, sourceImageId: string) { + return packs.find(pack => pack.kind === kind && pack.sourceImageId === sourceImageId); +} + +function isPackComplete(pack: AssetPack | undefined, kind: PackKind) { + return Boolean(pack && pack.assets.length >= PACK_TEMPLATES[kind].length); +} + +function previousPackLabel(kind: PackKind) { + const index = PACK_ORDER.indexOf(kind); + if (index <= 0) return null; + return PACK_LABELS[PACK_ORDER[index - 1]]; +} export default function PackPanel({ session, loadingKind, - allLoading, characterLoading, videoLoading, onGenerate, - onGenerateAll, onLockCharacter, onRegenerateAsset, onGenerateVideo, }: { session: GenSession; loadingKind: PackKind | null; - allLoading: boolean; characterLoading: boolean; videoLoading: boolean; 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; @@ -472,6 +502,7 @@ export default function PackPanel({ const selectedImages = session.images.filter(image => image.status === 'selected'); const primaryImage = selectedImages[0] ?? null; const packs = session.packs ?? []; + const characterReady = Boolean(session.characterSpec); if (!primaryImage) { return ( @@ -495,6 +526,8 @@ export default function PackPanel({ } const generatedTotal = packs.reduce((s, p) => s + p.assets.length, 0); + const completedPackCount = PACK_ORDER.filter(kind => isPackComplete(packForKind(packs, kind, primaryImage.id), kind)).length; + const imagePacksComplete = completedPackCount === PACK_ORDER.length; return (
@@ -503,9 +536,9 @@ export default function PackPanel({
Step · 03 · Lock & Generate -

角色锁定 & 资产清单

+

串行生产流程

- 锁定角色设定后,下方四类图片包 + 文案包 + 视频任务已固化 Prompt,随时生成。 + 先锁定角色,再从专利包开始逐步生成;前一步完成后,下一步才会解锁。

{/* primary image + stats */} @@ -526,7 +559,7 @@ export default function PackPanel({
- +
+ 当前进度:{completedPackCount}/{PACK_ORDER.length} 个图片包完成 +
{/* CharacterSpec */} @@ -597,15 +614,25 @@ export default function PackPanel({
{/* Pack sections */} {PACK_ORDER.map(kind => { - const pack = packs.find(p => p.kind === kind && p.sourceImageId === primaryImage.id); + const pack = packForKind(packs, kind, primaryImage.id); + const index = PACK_ORDER.indexOf(kind); + const previousKind = index > 0 ? PACK_ORDER[index - 1] : null; + const previousComplete = !previousKind || isPackComplete(packForKind(packs, previousKind, primaryImage.id), previousKind); + const locked = !characterReady || !previousComplete; + const lockReason = !characterReady + ? '先锁定角色设定,然后从专利包开始。' + : previousComplete + ? undefined + : `请先完成${previousPackLabel(kind)},再生成${PACK_LABELS[kind]}。`; return ( onGenerate(primaryImage, kind)} onRegenerateAsset={onRegenerateAsset} /> @@ -613,8 +640,20 @@ export default function PackPanel({ })} {/* Text + Video */} - - +
+ {!imagePacksComplete && ( +
+ 文案和视频在四个图片包完成后解锁。 +
+ )} + + +
); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0697ad9..4509715 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -199,18 +199,6 @@ export type GeneratePackResponse = { provider: 'mock' | 'gpt'; }; -export type GenerateAllPacksRequest = { - sessionId: string; - imageId: string; - background?: boolean; -}; - -export type GenerateAllPacksResponse = { - packs: AssetPack[]; - manifests: ExportManifest[]; - provider: 'mock' | 'gpt'; -}; - export type LockCharacterRequest = { sessionId: string; imageId: string;