diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx
index 9bf06f681..e15fee68c 100644
--- a/web/src/pages/projects/detail/chapters.tsx
+++ b/web/src/pages/projects/detail/chapters.tsx
@@ -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
{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}·{formatCount(wordCount)} 字·{chapterCanvasCount(selectedUnit.id)} 个画布
- } disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "characters"} onClick={() => void extractCharacters()}>提取角色
- } disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "storyboard"} onClick={generateStoryboard}>{chapterShotCount(selectedUnit.id) ? "重新生成分镜" : "生成分镜"}
- {chapterShotCount(selectedUnit.id) ? , label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: , label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}>} loading={Boolean(importingCanvasId)} disabled={Boolean(analysisKind)}>导入分镜 : null}
+ } disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} loading={extractingCharacters} onClick={() => void extractCharacters()}>提取角色
+ {chapterShotCount(selectedUnit.id) ? , label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: , label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}>} loading={Boolean(importingCanvasId)} disabled={extractingCharacters}>导入分镜 : } disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} onClick={onCreateCanvas}>在画布中分镜}
: } disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}
diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts
index 644cab700..9b33497f4 100644
--- a/web/src/pages/projects/detail/project-chapter-ai.ts
+++ b/web/src/pages/projects/detail/project-chapter-ai.ts
@@ -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 {
- 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;
- 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;
-}