fix(canvas): 剧情章节 - 移除重复付费的分镜生成入口

Fixes #43
This commit is contained in:
ddCat
2026-07-28 19:42:49 +08:00
parent 8ccba30366
commit 940b716e5c
2 changed files with 9 additions and 86 deletions

View File

@@ -18,7 +18,6 @@ import {
Bold,
Check,
ChevronDown,
Clapperboard,
Code2,
Crosshair,
Eraser,
@@ -62,7 +61,6 @@ import {
importProjectUnits,
linkCanvasUnit,
reorderProjectUnits,
replaceProjectUnitShots,
updateProjectUnit,
type ProjectUnit,
} from "@/services/api/projects";
@@ -71,13 +69,13 @@ import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
import { extractChapterCharacters } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 52;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message, modal } = App.useApp();
export default function ProjectChaptersView({ detail, refreshProject, onCreateCanvas }: ProjectDetailViewProps) {
const { message } = App.useApp();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { chapterId = "" } = useParams();
@@ -93,7 +91,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
const [draftTitle, setDraftTitle] = useState("");
const [draftHtml, setDraftHtml] = useState("");
const [dirty, setDirty] = useState(false);
const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">("");
const [extractingCharacters, setExtractingCharacters] = useState(false);
const [importingCanvasId, setImportingCanvasId] = useState("");
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
@@ -259,7 +257,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
try {
const input = chapterAnalysisInput();
if (!input) return;
setAnalysisKind("characters");
setExtractingCharacters(true);
const characters = await extractChapterCharacters(input);
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
const knownNames = new Set(detail.assetCandidates
@@ -276,43 +274,14 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
} finally {
setAnalysisKind("");
setExtractingCharacters(false);
}
};
const replaceStoryboard = async () => {
try {
const input = chapterAnalysisInput();
if (!input) return;
setAnalysisKind("storyboard");
const shots = await generateChapterStoryboard(input);
await replaceProjectUnitShots(detail.project.id, input.chapterId, shots);
refreshProject();
message.success(`已生成 ${shots.length} 个章节分镜`);
} catch (error) {
message.error(error instanceof Error ? error.message : "分镜生成失败");
} finally {
setAnalysisKind("");
}
};
const generateStoryboard = () => {
const existingCount = selectedUnit ? chapterShotCount(selectedUnit.id) : 0;
if (!existingCount) {
void replaceStoryboard();
return;
}
modal.confirm({
title: "重新生成本章分镜?",
content: `当前 ${existingCount} 个镜头会被新分镜整体替换,已关联的镜头素材不会删除,但会解除镜头引用。`,
okText: "重新生成",
cancelText: "取消",
onOk: replaceStoryboard,
});
};
const importStoryboardToCanvas = async (targetCanvasId?: string) => {
if (!selectedUnit) return;
const shots = detail.shots.filter((shot) => shot.unitId === selectedUnit.id);
if (!shots.length) {
message.warning("请先生成本章分镜");
message.warning("本章没有可导入的历史分镜");
return;
}
setImportingCanvasId(targetCanvasId || "new");
@@ -452,9 +421,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "characters"} onClick={() => void extractCharacters()}>提取角色</Button>
<Button size="small" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "storyboard"} onClick={generateStoryboard}>{chapterShotCount(selectedUnit.id) ? "重新生成分镜" : "生成分镜"}</Button>
{chapterShotCount(selectedUnit.id) ? <Dropdown trigger={["click"]} menu={{ items: [{ key: "new", icon: <Plus className="size-3.5" />, label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: <LayoutGrid className="size-3.5" />, label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}><Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} loading={Boolean(importingCanvasId)} disabled={Boolean(analysisKind)}>导入分镜</Button></Dropdown> : null}
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} loading={extractingCharacters} onClick={() => void extractCharacters()}>提取角色</Button>
{chapterShotCount(selectedUnit.id) ? <Dropdown trigger={["click"]} menu={{ items: [{ key: "new", icon: <Plus className="size-3.5" />, label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: <LayoutGrid className="size-3.5" />, label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}><Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} loading={Boolean(importingCanvasId)} disabled={extractingCharacters}>导入分镜</Button></Dropdown> : <Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} onClick={onCreateCanvas}>在画布中分镜</Button>}
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
</header>

View File

@@ -2,12 +2,6 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-gene
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import type { AiConfig } from "@/stores/use-config-store";
export type ChapterStoryboardDraft = {
title: string;
description: string;
durationMs: number;
};
type ChapterAnalysisInput = {
projectId: string;
projectName: string;
@@ -33,20 +27,6 @@ export async function extractChapterCharacters(input: ChapterAnalysisInput): Pro
return parseCharacterBreakdown(result);
}
export async function generateChapterStoryboard(input: ChapterAnalysisInput): Promise<ChapterStoryboardDraft[]> {
const prompt = [
`请把短剧项目《${input.projectName}》的章节“${input.chapterTitle}”拆成可直接制作的分镜镜头。`,
"镜头顺序必须忠于正文事件顺序;每个镜头只承担一个清楚的叙事动作。描述必须包含主体、动作、场景、景别、机位、镜头运动、光线和必要对白,不要写抽象评价。单镜头建议 3 至 8 秒。",
`【项目画风】\n${input.projectStyle || "项目尚未指定画风,保持镜头描述中性、可执行。"}`,
"只返回 JSON,不要 Markdown 或解释。JSON 结构必须严格为:",
'{"shots":[{"title":"镜头名称","description":"可直接用于分镜制作的完整镜头描述","durationSeconds":5}]}',
"【章节正文】",
input.sourceText,
].join("\n\n");
const result = await runProjectTextTask(input, "chapter_storyboard", prompt);
return parseChapterStoryboard(result);
}
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, prompt: string) {
const model = input.config.textModel || input.config.model;
const result = await runBackendCanvasGenerationTask({
@@ -60,28 +40,3 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
return result.text;
}
function parseChapterStoryboard(raw: string): ChapterStoryboardDraft[] {
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
const start = unfenced.indexOf("{");
const end = unfenced.lastIndexOf("}");
if (start < 0 || end < start) throw new Error("分镜生成没有返回可识别的 JSON");
let parsed: unknown;
try {
parsed = JSON.parse(unfenced.slice(start, end + 1));
} catch (error) {
throw new Error(`分镜结果格式不正确:${error instanceof Error ? error.message : "无法解析 JSON"}`);
}
const shots = parsed && typeof parsed === "object" ? (parsed as { shots?: unknown }).shots : undefined;
if (!Array.isArray(shots)) throw new Error("分镜结果缺少 shots 数组");
const result = shots.flatMap((item, index) => {
if (!item || typeof item !== "object") return [];
const value = item as Record<string, unknown>;
const description = String(value.description || "").trim();
if (!description) return [];
const durationSeconds = Math.min(30, Math.max(1, Number(value.durationSeconds) || 5));
return [{ title: String(value.title || `镜头 ${index + 1}`).trim(), description, durationMs: Math.round(durationSeconds * 1000) }];
});
if (!result.length) throw new Error("分镜结果中没有可用镜头");
return result;
}