fix: enforce sequential pack workflow

This commit is contained in:
2026-05-20 15:47:26 +08:00
parent 794d95e7d5
commit 37d04d5f8c
7 changed files with 155 additions and 255 deletions

View File

@@ -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.5buildDAG + topologicalSort+ §10.CrunGenerationLoop
- 用户点一次"一键全包"agent 按 wave 并行跑完
- 还没有自检
- 从专利包开始逐步推进,前一包完成后才允许下一包进入队列
- 不提供"一键全包",避免跳过人工检查和误触高成本生成
**第 3 期:自检 + 自动重做**
- 完成 §10.6 + §10.H
@@ -1338,4 +1337,3 @@ extra fingers, distorted toy proportions
3 个 slot 跑通了,宣发素材就够发一波。

View File

@@ -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合订

View File

@@ -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 });
}
}

View File

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

View File

@@ -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<GenSession | null>(null);
const [loading, setLoading] = useState(false);
const [loadingKind, setLoadingKind] = useState<PackKind | null>(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() {
<PackPanel
session={current}
loadingKind={loadingKind}
allLoading={allLoading}
characterLoading={characterLoading}
videoLoading={videoLoading}
onGenerate={handleGeneratePack}
onGenerateAll={handleGenerateAll}
onLockCharacter={handleLockCharacter}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}

View File

@@ -1,6 +1,6 @@
'use client';
import { useRef, useState } from 'react';
import { useState } from 'react';
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
import { HoverImagePreview } from './HoverImagePreview';
@@ -154,23 +154,26 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
}
/* ── Pack Section (collapsible) ───────────────── */
function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, onRegenerateAsset }: {
function PackSection({ kind, pack, isLoading, locked, lockReason, stepIndex, onGenerate, onRegenerateAsset }: {
kind: PackKind;
session: GenSession;
primaryImage: GenImage;
pack: AssetPack | undefined;
isLoading: boolean;
locked: boolean;
lockReason?: string;
stepIndex: number;
onGenerate: () => void;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
}) {
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 (
<section className="card" id={`pack-${kind}`}>
<section className={`card ${locked ? 'opacity-55' : ''}`} id={`pack-${kind}`} aria-disabled={locked}>
{/* header — always visible */}
<div className="p-4 flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
{accent.icon}
<div className={`w-8 h-8 rounded-lg ${locked ? 'bg-white/[0.06] text-white/28' : `bg-gradient-to-br ${accent.bar} text-white`} flex items-center justify-center text-[11px] font-bold shrink-0`}>
{stepIndex}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white">{PACK_LABELS[kind]}</span>
<span className="text-[10px] text-white/35">{PACK_DESCRIPTIONS[kind]}</span>
{complete && <span className="chip chip-live text-[10px] py-0"></span>}
{locked && <span className="chip chip-neutral text-[10px] py-0"></span>}
</div>
{/* progress bar */}
<div className="mt-2 flex items-center gap-2">
@@ -205,14 +210,15 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleGenerateClick}
disabled={isLoading}
disabled={isLoading || locked}
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
title={locked ? lockReason : undefined}
>
{isLoading ? (
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
) : pack ? '重新生成' : `生成 ${total}`}
) : locked ? '等待前一步' : pack ? '重新生成' : `生成 ${total}`}
</button>
<button
onClick={() => setOpen(v => !v)}
@@ -226,6 +232,12 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
</div>
</div>
{locked && lockReason && (
<div className="mx-4 mb-4 rounded-[8px] border border-white/[0.06] bg-black/20 px-3 py-2 text-[11px] text-white/38">
{lockReason}
</div>
)}
{/* asset list — collapsible */}
{open && (
<div className="px-4 pb-4 space-y-2 border-t border-white/[0.05]">
@@ -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<string | null>(null);
return (
<section className="card overflow-hidden" id="pack-text">
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-text" aria-disabled={locked}>
<div className="p-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#8cb478] to-[#d6b36a] flex items-center justify-center text-white text-[11px] font-bold shrink-0">T</div>
<div className="flex-1 min-w-0">
@@ -265,9 +277,12 @@ function TextTemplateSection() {
<div className="flex items-center gap-2 shrink-0">
<span className="chip bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30 text-[10px]">GPT Text</span>
<button
onClick={() => setOpen(v => !v)}
title={open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors"
onClick={() => {
if (!locked) setOpen(v => !v);
}}
disabled={locked}
title={locked ? '四个图片包完成后解锁文案任务' : open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors disabled:cursor-not-allowed disabled:opacity-35"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
@@ -324,16 +339,17 @@ function TextTemplateSection() {
}
/* ── Video Section (collapsible) ──────────────── */
function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
videoLoading: boolean;
primaryImage: GenImage;
locked: boolean;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
}) {
const [open, setOpen] = useState(false);
const [showPromptId, setShowPromptId] = useState<string | null>(null);
return (
<section className="card overflow-hidden" id="pack-video">
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}>
<div className="p-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e6f578] to-[#8cb478] flex items-center justify-center text-white text-[11px] font-bold shrink-0">V</div>
<div className="flex-1 min-w-0">
@@ -349,9 +365,12 @@ function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
<div className="flex items-center gap-2 shrink-0">
<span className="chip chip-violet text-[10px]">Seedance</span>
<button
onClick={() => setOpen(v => !v)}
title={open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors"
onClick={() => {
if (!locked) setOpen(v => !v);
}}
disabled={locked}
title={locked ? '四个图片包完成后解锁视频任务' : open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors disabled:cursor-not-allowed disabled:opacity-35"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
@@ -395,10 +414,11 @@ function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
</div>
<button
onClick={() => onGenerateVideo(primaryImage, template)}
disabled={videoLoading}
disabled={videoLoading || locked}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{videoLoading ? '...' : '提交'}
{locked ? '锁定' : videoLoading ? '...' : '提交'}
</button>
</div>
);
@@ -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<void>;
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 (
<div className="flex h-full min-h-0 flex-col gap-4">
@@ -503,9 +536,9 @@ export default function PackPanel({
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
<div className="flex-1 min-w-0">
<span className="section-eyebrow">Step · 03 · Lock & Generate</span>
<h2 className="mt-1.5 text-base font-semibold text-white"> & </h2>
<h2 className="mt-1.5 text-base font-semibold text-white"></h2>
<p className="mt-0.5 text-[11px] text-white/40 max-w-[440px]">
+ + Prompt
</p>
</div>
{/* primary image + stats */}
@@ -526,7 +559,7 @@ export default function PackPanel({
<div className="grid grid-cols-1 gap-2 2xl:grid-cols-2">
<button
onClick={() => onLockCharacter(primaryImage)}
disabled={characterLoading || !!loadingKind || allLoading}
disabled={characterLoading || !!loadingKind}
className="btn btn-glass justify-center text-xs disabled:opacity-40"
>
{characterLoading ? (
@@ -541,25 +574,9 @@ export default function PackPanel({
)}
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
</button>
<button
onClick={() => {
const ok = window.confirm(`一键全包最多会生成 ${totalImageSlots} 张图片,费用会明显高于单张。确认启动?`);
if (ok) onGenerateAll(primaryImage);
}}
disabled={allLoading || !!loadingKind || characterLoading}
className="btn btn-primary justify-center text-xs disabled:opacity-40"
>
{allLoading ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
</>
) : (
`一键全包 · ${totalImageSlots}`
)}
</button>
<div className="rounded-[8px] bg-white/[0.035] px-3 py-2 text-[11px] text-white/52 ring-1 ring-white/[0.07]">
{completedPackCount}/{PACK_ORDER.length}
</div>
</div>
{/* CharacterSpec */}
@@ -597,15 +614,25 @@ export default function PackPanel({
<div className="pack-scroll min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
{/* 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 (
<PackSection
key={kind}
kind={kind}
session={session}
primaryImage={primaryImage}
pack={pack}
isLoading={loadingKind === kind}
locked={locked}
lockReason={lockReason}
stepIndex={index + 1}
onGenerate={() => onGenerate(primaryImage, kind)}
onRegenerateAsset={onRegenerateAsset}
/>
@@ -613,8 +640,20 @@ export default function PackPanel({
})}
{/* Text + Video */}
<TextTemplateSection />
<VideoSection videoLoading={videoLoading} primaryImage={primaryImage} onGenerateVideo={onGenerateVideo} />
<div className={imagePacksComplete ? '' : 'opacity-55'}>
{!imagePacksComplete && (
<div className="mb-3 rounded-[8px] border border-white/[0.06] bg-black/20 px-3 py-2 text-[11px] text-white/38">
</div>
)}
<TextTemplateSection locked={!imagePacksComplete} />
<VideoSection
videoLoading={videoLoading}
primaryImage={primaryImage}
locked={!imagePacksComplete}
onGenerateVideo={onGenerateVideo}
/>
</div>
</div>
</div>
);

View File

@@ -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;