fix: enforce sequential pack workflow
This commit is contained in:
@@ -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 跑通了,宣发素材就够发一波。
|
||||
|
||||
|
||||
|
||||
7
RULES.md
7
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合订
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user