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/templates/route.ts` |
|
||||||
| 角色锁定 API | `src/app/api/character/lock/route.ts` |
|
| 角色锁定 API | `src/app/api/character/lock/route.ts` |
|
||||||
| 单包生成 API | `src/app/api/packs/generate/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` |
|
| 视频生成 API | `src/app/api/video/generate/route.ts` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1085,10 +1084,10 @@ Agent 跑到中途失败(API 超时、Key 限流)的处理:
|
|||||||
- 用户手动点每个包的"生成"按钮
|
- 用户手动点每个包的"生成"按钮
|
||||||
- 没有自动拓扑、没有自检
|
- 没有自动拓扑、没有自检
|
||||||
|
|
||||||
**第 2 期:拓扑批量生成**
|
**第 2 期:串行阶段生成**
|
||||||
- 完成 §10.5(buildDAG + topologicalSort)+ §10.C(runGenerationLoop)
|
- 完成 §10.5(buildDAG + topologicalSort)+ §10.C(runGenerationLoop)
|
||||||
- 用户点一次"一键全包",agent 按 wave 并行跑完
|
- 从专利包开始逐步推进,前一包完成后才允许下一包进入队列
|
||||||
- 还没有自检
|
- 不提供"一键全包",避免跳过人工检查和误触高成本生成
|
||||||
|
|
||||||
**第 3 期:自检 + 自动重做**
|
**第 3 期:自检 + 自动重做**
|
||||||
- 完成 §10.6 + §10.H
|
- 完成 §10.6 + §10.H
|
||||||
@@ -1338,4 +1337,3 @@ extra fingers, distorted toy proportions
|
|||||||
|
|
||||||
3 个 slot 跑通了,宣发素材就够发一波。
|
3 个 slot 跑通了,宣发素材就够发一波。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
7
RULES.md
7
RULES.md
@@ -92,9 +92,10 @@
|
|||||||
3. 九宫格快筛:数字键 `1-9` 选中,`Shift+1-9` 打叉
|
3. 九宫格快筛:数字键 `1-9` 选中,`Shift+1-9` 打叉
|
||||||
4. 选中的图自动复制到 `data/selected/`
|
4. 选中的图自动复制到 `data/selected/`
|
||||||
5. 锁定角色设定 `CharacterSpec`
|
5. 锁定角色设定 `CharacterSpec`
|
||||||
6. 一键生成完整三包:专利包、生产打样包、宣发包
|
6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包`
|
||||||
7. Seedance 生成视频任务:旋转展示、开箱、触感细节、角色故事
|
7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API
|
||||||
8. 侧栏保留历史会话,点击切换
|
8. 四个图片包完成后,才解锁文案模板和 Seedance 视频任务:旋转展示、开箱、触感细节、角色故事
|
||||||
|
9. 侧栏保留历史会话,点击切换
|
||||||
|
|
||||||
## 后续路线
|
## 后续路线
|
||||||
- 导出专利包:PNG高清 + PDF合订
|
- 导出专利包: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 { generateAssetPack } from '@/lib/packGenerator';
|
||||||
import { detectProvider } from '@/lib/providers';
|
import { detectProvider } from '@/lib/providers';
|
||||||
import { loadSession, saveSession } from '@/lib/storage';
|
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';
|
import type { AssetPack, GeneratePackRequest, GeneratePackResponse, GenSession, PackKind } from '@/lib/types';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
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) {
|
async function persistPackProgress(session: GenSession, imageId: string, pack: AssetPack) {
|
||||||
session.characterSpec = pack.characterSpec;
|
session.characterSpec = pack.characterSpec;
|
||||||
@@ -20,6 +21,44 @@ async function persistPackProgress(session: GenSession, imageId: string, pack: A
|
|||||||
await saveSession(session);
|
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) {
|
export async function POST(req: Request) {
|
||||||
const { sessionId, imageId, kind, background = false } = (await req.json()) as GeneratePackRequest;
|
const { sessionId, imageId, kind, background = false } = (await req.json()) as GeneratePackRequest;
|
||||||
if (!sessionId || !imageId || !PACK_KINDS.includes(kind)) {
|
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 });
|
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 baseSession = session;
|
||||||
const baseSourceImage = sourceImage;
|
const baseSourceImage = sourceImage;
|
||||||
const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates';
|
|||||||
import type {
|
import type {
|
||||||
GenImage,
|
GenImage,
|
||||||
GenSession,
|
GenSession,
|
||||||
GenerateAllPacksResponse,
|
|
||||||
GeneratePackResponse,
|
GeneratePackResponse,
|
||||||
GenerateResponse,
|
GenerateResponse,
|
||||||
LockCharacterResponse,
|
LockCharacterResponse,
|
||||||
@@ -146,7 +145,6 @@ export default function Home() {
|
|||||||
const [current, setCurrent] = useState<GenSession | null>(null);
|
const [current, setCurrent] = useState<GenSession | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
||||||
const [allLoading, setAllLoading] = useState(false);
|
|
||||||
const [characterLoading, setCharacterLoading] = useState(false);
|
const [characterLoading, setCharacterLoading] = useState(false);
|
||||||
const [videoLoading, setVideoLoading] = useState(false);
|
const [videoLoading, setVideoLoading] = useState(false);
|
||||||
const [uploadLoading, setUploadLoading] = 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) {
|
async function handleRegenerateAsset(assetId: string, userRefinement?: string) {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
||||||
@@ -472,11 +448,9 @@ export default function Home() {
|
|||||||
<PackPanel
|
<PackPanel
|
||||||
session={current}
|
session={current}
|
||||||
loadingKind={loadingKind}
|
loadingKind={loadingKind}
|
||||||
allLoading={allLoading}
|
|
||||||
characterLoading={characterLoading}
|
characterLoading={characterLoading}
|
||||||
videoLoading={videoLoading}
|
videoLoading={videoLoading}
|
||||||
onGenerate={handleGeneratePack}
|
onGenerate={handleGeneratePack}
|
||||||
onGenerateAll={handleGenerateAll}
|
|
||||||
onLockCharacter={handleLockCharacter}
|
onLockCharacter={handleLockCharacter}
|
||||||
onRegenerateAsset={handleRegenerateAsset}
|
onRegenerateAsset={handleRegenerateAsset}
|
||||||
onGenerateVideo={handleGenerateVideo}
|
onGenerateVideo={handleGenerateVideo}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
|
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 { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
|
||||||
import { HoverImagePreview } from './HoverImagePreview';
|
import { HoverImagePreview } from './HoverImagePreview';
|
||||||
@@ -154,23 +154,26 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pack Section (collapsible) ───────────────── */
|
/* ── Pack Section (collapsible) ───────────────── */
|
||||||
function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, onRegenerateAsset }: {
|
function PackSection({ kind, pack, isLoading, locked, lockReason, stepIndex, onGenerate, onRegenerateAsset }: {
|
||||||
kind: PackKind;
|
kind: PackKind;
|
||||||
session: GenSession;
|
|
||||||
primaryImage: GenImage;
|
|
||||||
pack: AssetPack | undefined;
|
pack: AssetPack | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lockReason?: string;
|
||||||
|
stepIndex: number;
|
||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<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 accent = PACK_ACCENT[kind];
|
||||||
const templates = PACK_TEMPLATES[kind];
|
const templates = PACK_TEMPLATES[kind];
|
||||||
const generatedCount = pack?.assets.length ?? 0;
|
const generatedCount = pack?.assets.length ?? 0;
|
||||||
const total = templates.length;
|
const total = templates.length;
|
||||||
const progressPct = Math.round((generatedCount / total) * 100);
|
const progressPct = Math.round((generatedCount / total) * 100);
|
||||||
|
const complete = generatedCount >= total;
|
||||||
|
|
||||||
function handleGenerateClick() {
|
function handleGenerateClick() {
|
||||||
|
if (locked) return;
|
||||||
if (pack) {
|
if (pack) {
|
||||||
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
|
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -179,16 +182,18 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card" id={`pack-${kind}`}>
|
<section className={`card ${locked ? 'opacity-55' : ''}`} id={`pack-${kind}`} aria-disabled={locked}>
|
||||||
{/* header — always visible */}
|
{/* header — always visible */}
|
||||||
<div className="p-4 flex items-center gap-3">
|
<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`}>
|
<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`}>
|
||||||
{accent.icon}
|
{stepIndex}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold text-white">{PACK_LABELS[kind]}</span>
|
<span className="text-sm font-semibold text-white">{PACK_LABELS[kind]}</span>
|
||||||
<span className="text-[10px] text-white/35">{PACK_DESCRIPTIONS[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>
|
</div>
|
||||||
{/* progress bar */}
|
{/* progress bar */}
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<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">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateClick}
|
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`}
|
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
|
||||||
|
title={locked ? lockReason : undefined}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<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" />
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
) : pack ? '重新生成' : `生成 ${total} 张`}
|
) : locked ? '等待前一步' : pack ? '重新生成' : `生成 ${total} 张`}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => setOpen(v => !v)}
|
||||||
@@ -226,6 +232,12 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* asset list — collapsible */}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="px-4 pb-4 space-y-2 border-t border-white/[0.05]">
|
<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) ──────── */
|
/* ── Text Template Section (collapsible) ──────── */
|
||||||
function TextTemplateSection() {
|
function TextTemplateSection({ locked }: { locked: boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -265,9 +277,12 @@ function TextTemplateSection() {
|
|||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
<span className="chip bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30 text-[10px]">GPT Text</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => {
|
||||||
title={open ? '收起' : '展开'}
|
if (!locked) setOpen(v => !v);
|
||||||
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={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">
|
<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" />
|
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
|
||||||
@@ -324,16 +339,17 @@ function TextTemplateSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Video Section (collapsible) ──────────────── */
|
/* ── Video Section (collapsible) ──────────────── */
|
||||||
function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
|
function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
|
||||||
videoLoading: boolean;
|
videoLoading: boolean;
|
||||||
primaryImage: GenImage;
|
primaryImage: GenImage;
|
||||||
|
locked: boolean;
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<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">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="chip chip-violet text-[10px]">Seedance</span>
|
<span className="chip chip-violet text-[10px]">Seedance</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => {
|
||||||
title={open ? '收起' : '展开'}
|
if (!locked) setOpen(v => !v);
|
||||||
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={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">
|
<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" />
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onGenerateVideo(primaryImage, template)}
|
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"
|
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
|
||||||
|
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
|
||||||
>
|
>
|
||||||
{videoLoading ? '...' : '提交'}
|
{locked ? '锁定' : videoLoading ? '...' : '提交'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -443,27 +463,37 @@ function SectionNav({ active, onChange }: { active: string; onChange: (id: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main Export ──────────────────────────────── */
|
/* ── 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({
|
export default function PackPanel({
|
||||||
session,
|
session,
|
||||||
loadingKind,
|
loadingKind,
|
||||||
allLoading,
|
|
||||||
characterLoading,
|
characterLoading,
|
||||||
videoLoading,
|
videoLoading,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onGenerateAll,
|
|
||||||
onLockCharacter,
|
onLockCharacter,
|
||||||
onRegenerateAsset,
|
onRegenerateAsset,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
}: {
|
}: {
|
||||||
session: GenSession;
|
session: GenSession;
|
||||||
loadingKind: PackKind | null;
|
loadingKind: PackKind | null;
|
||||||
allLoading: boolean;
|
|
||||||
characterLoading: boolean;
|
characterLoading: boolean;
|
||||||
videoLoading: boolean;
|
videoLoading: boolean;
|
||||||
onGenerate: (image: GenImage, kind: PackKind) => void;
|
onGenerate: (image: GenImage, kind: PackKind) => void;
|
||||||
onGenerateAll: (image: GenImage) => void;
|
|
||||||
onLockCharacter: (image: GenImage) => void;
|
onLockCharacter: (image: GenImage) => void;
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => 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 selectedImages = session.images.filter(image => image.status === 'selected');
|
||||||
const primaryImage = selectedImages[0] ?? null;
|
const primaryImage = selectedImages[0] ?? null;
|
||||||
const packs = session.packs ?? [];
|
const packs = session.packs ?? [];
|
||||||
|
const characterReady = Boolean(session.characterSpec);
|
||||||
|
|
||||||
if (!primaryImage) {
|
if (!primaryImage) {
|
||||||
return (
|
return (
|
||||||
@@ -495,6 +526,8 @@ export default function PackPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generatedTotal = packs.reduce((s, p) => s + p.assets.length, 0);
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
<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 flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="section-eyebrow">Step · 03 · Lock & Generate</span>
|
<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]">
|
<p className="mt-0.5 text-[11px] text-white/40 max-w-[440px]">
|
||||||
锁定角色设定后,下方四类图片包 + 文案包 + 视频任务已固化 Prompt,随时生成。
|
先锁定角色,再从专利包开始逐步生成;前一步完成后,下一步才会解锁。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* primary image + stats */}
|
{/* primary image + stats */}
|
||||||
@@ -526,7 +559,7 @@ export default function PackPanel({
|
|||||||
<div className="grid grid-cols-1 gap-2 2xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 2xl:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onLockCharacter(primaryImage)}
|
onClick={() => onLockCharacter(primaryImage)}
|
||||||
disabled={characterLoading || !!loadingKind || allLoading}
|
disabled={characterLoading || !!loadingKind}
|
||||||
className="btn btn-glass justify-center text-xs disabled:opacity-40"
|
className="btn btn-glass justify-center text-xs disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{characterLoading ? (
|
{characterLoading ? (
|
||||||
@@ -541,25 +574,9 @@ export default function PackPanel({
|
|||||||
)}
|
)}
|
||||||
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
|
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="rounded-[8px] bg-white/[0.035] px-3 py-2 text-[11px] text-white/52 ring-1 ring-white/[0.07]">
|
||||||
onClick={() => {
|
当前进度:{completedPackCount}/{PACK_ORDER.length} 个图片包完成
|
||||||
const ok = window.confirm(`一键全包最多会生成 ${totalImageSlots} 张图片,费用会明显高于单张。确认启动?`);
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* CharacterSpec */}
|
{/* 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">
|
<div className="pack-scroll min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
{/* Pack sections */}
|
{/* Pack sections */}
|
||||||
{PACK_ORDER.map(kind => {
|
{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 (
|
return (
|
||||||
<PackSection
|
<PackSection
|
||||||
key={kind}
|
key={kind}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
session={session}
|
|
||||||
primaryImage={primaryImage}
|
|
||||||
pack={pack}
|
pack={pack}
|
||||||
isLoading={loadingKind === kind}
|
isLoading={loadingKind === kind}
|
||||||
|
locked={locked}
|
||||||
|
lockReason={lockReason}
|
||||||
|
stepIndex={index + 1}
|
||||||
onGenerate={() => onGenerate(primaryImage, kind)}
|
onGenerate={() => onGenerate(primaryImage, kind)}
|
||||||
onRegenerateAsset={onRegenerateAsset}
|
onRegenerateAsset={onRegenerateAsset}
|
||||||
/>
|
/>
|
||||||
@@ -613,8 +640,20 @@ export default function PackPanel({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Text + Video */}
|
{/* Text + Video */}
|
||||||
<TextTemplateSection />
|
<div className={imagePacksComplete ? '' : 'opacity-55'}>
|
||||||
<VideoSection videoLoading={videoLoading} primaryImage={primaryImage} onGenerateVideo={onGenerateVideo} />
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -199,18 +199,6 @@ export type GeneratePackResponse = {
|
|||||||
provider: 'mock' | 'gpt';
|
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 = {
|
export type LockCharacterRequest = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
imageId: string;
|
imageId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user