feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -8,8 +8,8 @@ const neutral = {
primaryText: "#ffffff",
menuBg: "#f5f5f5",
menuText: "#171717",
selectActiveBg: "#f5f5f5",
selectSelectedBg: "#f0f0f0",
selectActiveBg: "rgba(17, 17, 17, 0.035)",
selectSelectedBg: "rgba(17, 17, 17, 0.065)",
selectText: "#171717",
tableSelectedBg: "rgba(17, 17, 17, 0.05)",
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
@@ -20,8 +20,8 @@ const neutral = {
primaryText: "#171717",
menuBg: "#262626",
menuText: "#fafafa",
selectActiveBg: "#262626",
selectSelectedBg: "#333333",
selectActiveBg: "rgba(255, 255, 255, 0.055)",
selectSelectedBg: "rgba(255, 255, 255, 0.09)",
selectText: "#fafafa",
tableSelectedBg: "rgba(255, 255, 255, 0.08)",
tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)",
@@ -33,7 +33,19 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
const elevatedBackground = dark ? "rgba(31, 31, 32, 0.96)" : "rgba(255, 255, 255, 0.96)";
const subtleBackground = dark ? "rgba(255, 255, 255, 0.055)" : "rgba(17, 17, 17, 0.035)";
const interactiveBorder = dark ? "rgba(255, 255, 255, 0.18)" : "rgba(17, 17, 17, 0.18)";
const focusShadow = dark ? "0 0 0 3px rgba(116, 133, 238, 0.16)" : "0 0 0 3px rgba(17, 17, 17, 0.08)";
const focusShadow = dark ? "0 0 0 3px rgba(255, 255, 255, 0.12)" : "0 0 0 3px rgba(17, 17, 17, 0.08)";
// 暗色界面的黑白主按钮与选择控件分色,避免开关轨道、勾选符号和滑块融成一片。
const selectionControl = dark
? {
active: "#4657c5",
border: "rgba(255, 255, 255, 0.3)",
disabledBackground: "rgba(255, 255, 255, 0.06)",
focus: "rgba(116, 133, 238, 0.28)",
hover: "#7485ee",
primary: "#5b6ee1",
surface: "rgba(255, 255, 255, 0.035)",
}
: null;
return {
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
@@ -51,9 +63,11 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
borderRadius: 6,
borderRadiusLG: 8,
borderRadiusSM: 5,
controlHeight: 34,
controlHeightLG: 40,
controlHeightSM: 28,
controlHeight: 36,
controlHeightLG: 42,
controlHeightSM: 30,
fontSize: 13,
fontSizeSM: 12,
motionDurationFast: "0.12s",
motionDurationMid: "0.18s",
motionDurationSlow: "0.24s",
@@ -61,12 +75,16 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
components: {
Button: {
primaryShadow: "none",
fontWeight: 500,
paddingInline: 14,
paddingInlineLG: 16,
paddingInlineSM: 10,
...(dark
? {
colorPrimary: "#5b6ee1",
colorPrimaryHover: "#7485ee",
colorPrimaryActive: "#4657c5",
primaryColor: "#ffffff",
colorPrimary: "#f5f5f4",
colorPrimaryHover: "#ffffff",
colorPrimaryActive: "#e7e5e4",
primaryColor: "#171717",
defaultBg: "#262626",
defaultColor: "#f5f5f4",
defaultBorderColor: "#404040",
@@ -80,6 +98,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
: {}),
},
Input: {
paddingInline: 11,
activeBg: elevatedBackground,
hoverBg: elevatedBackground,
activeBorderColor: interactiveBorder,
@@ -96,12 +115,47 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
Switch: {
handleBg: dark ? "#fafafa" : "#ffffff",
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
...(dark
...(selectionControl
? {
colorPrimary: "#5b6ee1",
colorPrimaryHover: "#7485ee",
colorTextQuaternary: "rgba(255, 255, 255, 0.18)",
colorTextTertiary: "rgba(255, 255, 255, 0.26)",
colorPrimary: selectionControl.primary,
colorPrimaryActive: selectionControl.active,
colorPrimaryHover: selectionControl.hover,
colorTextQuaternary: "rgba(255, 255, 255, 0.16)",
colorTextTertiary: "rgba(255, 255, 255, 0.24)",
controlOutline: selectionControl.focus,
}
: {}),
},
Checkbox: {
...(selectionControl
? {
colorBgContainer: selectionControl.surface,
colorBgContainerDisabled: selectionControl.disabledBackground,
colorBorder: selectionControl.border,
colorPrimary: selectionControl.primary,
colorPrimaryActive: selectionControl.active,
colorPrimaryHover: selectionControl.hover,
controlOutline: selectionControl.focus,
}
: {}),
},
Radio: {
radioSize: 16,
dotSize: 6,
...(selectionControl
? {
buttonBg: selectionControl.surface,
buttonCheckedBg: "rgba(91, 110, 225, 0.12)",
buttonSolidCheckedActiveBg: selectionControl.active,
buttonSolidCheckedBg: selectionControl.primary,
buttonSolidCheckedHoverBg: selectionControl.hover,
colorBgContainer: selectionControl.surface,
colorBgContainerDisabled: selectionControl.disabledBackground,
colorBorder: selectionControl.border,
colorPrimary: selectionControl.primary,
colorPrimaryActive: selectionControl.active,
colorPrimaryHover: selectionControl.hover,
controlOutline: selectionControl.focus,
}
: {}),
},
@@ -116,9 +170,12 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
},
Select: {
selectorBg: elevatedBackground,
optionHeight: 36,
optionPadding: "8px 10px",
multipleItemHeight: 24,
activeBorderColor: interactiveBorder,
hoverBorderColor: interactiveBorder,
activeOutlineColor: dark ? "rgba(116, 133, 238, 0.16)" : "rgba(17, 17, 17, 0.08)",
activeOutlineColor: dark ? "rgba(255, 255, 255, 0.12)" : "rgba(17, 17, 17, 0.08)",
optionActiveBg: color.selectActiveBg,
optionSelectedBg: color.selectSelectedBg,
optionSelectedColor: color.selectText,
@@ -162,6 +219,10 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
Dropdown: {
paddingBlock: 6,
},
Skeleton: {
gradientFromColor: dark ? "rgba(255, 255, 255, 0.055)" : "rgba(15, 23, 42, 0.055)",
gradientToColor: dark ? "rgba(255, 255, 255, 0.11)" : "rgba(15, 23, 42, 0.1)",
},
Card: {
headerBg: "transparent",
headerFontSize: 15,

View File

@@ -15,6 +15,7 @@ export type CanvasAgentOp =
export type CanvasAgentSnapshot = {
projectId: string;
domainProjectId?: string;
title: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
@@ -22,6 +23,15 @@ export type CanvasAgentSnapshot = {
viewport: ViewportTransform;
};
export type CanvasAgentOperationImpact = {
operationCount: number;
affectedNodeCount: number;
destructiveCount: number;
generationCount: number;
items: string[];
warning: string;
};
export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
const counts = (Array.isArray(ops) ? ops : []).reduce<Record<string, number>>((acc, op) => {
if (!op?.type) return acc;
@@ -33,6 +43,73 @@ export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
.join(",");
}
export function previewCanvasAgentOps(ops?: CanvasAgentOp[], snapshot?: CanvasAgentSnapshot): CanvasAgentOperationImpact {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const nodeById = new Map((snapshot?.nodes || []).map((node) => [node.id, node]));
const affectedNodeIds = new Set<string>();
let addedNodeCount = 0;
let destructiveCount = 0;
let generationCount = 0;
const items: string[] = [];
safeOps.forEach((op) => {
if (op.type === "add_node") {
addedNodeCount += 1;
items.push(`新增${canvasNodeTypeLabel(op.nodeType)}${op.title ? `「${op.title}」` : ""}`);
return;
}
if (op.type === "update_node") {
affectedNodeIds.add(op.id);
items.push(`修改「${nodeById.get(op.id)?.title || op.id}」`);
return;
}
if (op.type === "delete_node") {
const ids = op.ids || (op.id ? [op.id] : op.nodeType ? (snapshot?.nodes || []).filter((node) => node.type === op.nodeType).map((node) => node.id) : []);
ids.forEach((id) => affectedNodeIds.add(id));
destructiveCount += Math.max(1, ids.length);
const names = ids.slice(0, 3).map((id) => nodeById.get(id)?.title || id);
items.push(ids.length ? `删除 ${ids.length} 个节点${names.length ? `:${names.join("、")}${ids.length > names.length ? "等" : ""}` : ""}` : `删除全部${canvasNodeTypeLabel(op.nodeType)}`);
return;
}
if (op.type === "connect_nodes") {
affectedNodeIds.add(op.fromNodeId);
affectedNodeIds.add(op.toNodeId);
items.push(`连接「${nodeById.get(op.fromNodeId)?.title || op.fromNodeId}」到「${nodeById.get(op.toNodeId)?.title || op.toNodeId}」`);
return;
}
if (op.type === "delete_connections") {
const count = op.all ? snapshot?.connections.length || 0 : op.ids?.length || (op.id ? 1 : 0);
destructiveCount += Math.max(1, count);
items.push(op.all ? `删除全部 ${count} 条连线` : `删除 ${count || 1} 条连线`);
return;
}
if (op.type === "run_generation") {
affectedNodeIds.add(op.nodeId);
generationCount += 1;
items.push(`为「${nodeById.get(op.nodeId)?.title || op.nodeId}」触发${generationModeLabel(op.mode)}生成`);
return;
}
if (op.type === "select_nodes") {
op.ids.forEach((id) => affectedNodeIds.add(id));
items.push(`选择 ${op.ids.length} 个节点`);
return;
}
if (op.type === "set_viewport") items.push("调整当前画布视图");
});
const warnings = [];
if (destructiveCount) warnings.push("包含删除操作,批准后可从最近 Agent 批次逐步撤销。");
if (generationCount) warnings.push("生成任务可能产生模型费用,画布撤销不会取消已提交任务。");
return {
operationCount: safeOps.length,
affectedNodeCount: affectedNodeIds.size + addedNodeCount,
destructiveCount,
generationCount,
items: items.slice(0, 8),
warning: warnings.join(" "),
};
}
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasAgentOp[]) {
let nodes = snapshot.nodes;
let connections = snapshot.connections;
@@ -104,3 +181,22 @@ function opLabel(type: string) {
if (type === "run_generation") return "触发生成";
return type;
}
function canvasNodeTypeLabel(type?: CanvasNodeType) {
if (type === CanvasNodeType.Image) return "图片节点";
if (type === CanvasNodeType.Video) return "视频节点";
if (type === CanvasNodeType.Audio) return "音频节点";
if (type === CanvasNodeType.Config) return "生成配置";
if (type === CanvasNodeType.Script) return "分镜脚本";
if (type === CanvasNodeType.Frame) return "背板";
if (type === CanvasNodeType.Drawing) return "绘图节点";
if (type === CanvasNodeType.Skill) return "技能节点";
return "文本节点";
}
function generationModeLabel(mode?: "text" | "image" | "video" | "audio") {
if (mode === "text") return "文本";
if (mode === "video") return "视频";
if (mode === "audio") return "音频";
return "图片";
}

View File

@@ -1,10 +1,8 @@
import { nanoid } from "nanoid";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import type { CanvasNodeData } from "@/types/canvas";
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
import { FRAME_HEADER_HEIGHT } from "@/lib/canvas/canvas-frame";
import { CanvasNodeType, type CanvasConnection, type CanvasDocumentChapter, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
export type DocumentCharacterBreakdown = {
export type CharacterBreakdown = {
name: string;
aliases: string[];
role: string;
@@ -15,13 +13,69 @@ export type DocumentCharacterBreakdown = {
props: string;
consistencyPrompt: string;
multiViewPrompt: string;
voiceLanguage: string;
voiceAge: string;
voiceTimbre: string;
};
export function refreshCanvasCharacterReferenceNodes(nodes: CanvasNodeData[], assets: ProjectAsset[]) {
const characters = new Map(assets.filter((asset) => asset.category === "character" && asset.character).map((asset) => [asset.id, asset]));
let changed = false;
const next = nodes.map((node) => {
const metadata = node.metadata;
const assetId = metadata?.workflowKind === "character" ? metadata.characterAssetId : "";
const asset = assetId ? characters.get(assetId) : undefined;
if (!metadata || !asset?.character || metadata.characterVersionPolicy === "pinned") return node;
const card = asset.character;
const cover = card.representations.find((item) => item.role === "turnaround_sheet") || card.representations.find((item) => item.role === "primary") || card.representations.find((item) => item.role === "front");
const aliases = Array.isArray(card.definition.aliases) ? card.definition.aliases.filter((value): value is string => typeof value === "string") : [];
const patch = {
characterVersionId: card.versionId,
characterName: asset.title,
characterPrompt: compileCharacterReferencePrompt(asset.title, card.definition),
characterAliases: aliases,
characterDefinition: card.definition,
characterCoverUrl: cover ? resourceFileUrl(cover.resourceId) : undefined,
characterVisualStatus: card.visualStatus,
characterVoiceStatus: card.voiceStatus,
characterVoiceName: card.voice?.profile.name,
characterVoiceProfile: card.voice ? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
} : undefined,
characterVoiceInstructions: card.voice?.instructions,
};
if (node.title === asset.title
&& metadata.characterVersionId === patch.characterVersionId
&& metadata.characterPrompt === patch.characterPrompt
&& metadata.characterCoverUrl === patch.characterCoverUrl
&& metadata.characterVisualStatus === patch.characterVisualStatus
&& metadata.characterVoiceStatus === patch.characterVoiceStatus
&& metadata.characterVoiceName === patch.characterVoiceName
&& JSON.stringify(metadata.characterDefinition) === JSON.stringify(patch.characterDefinition)
&& JSON.stringify(metadata.characterVoiceProfile) === JSON.stringify(patch.characterVoiceProfile)
&& metadata.characterVoiceInstructions === patch.characterVoiceInstructions
&& (metadata.characterAliases || []).join("\u0000") === aliases.join("\u0000")) return node;
changed = true;
return { ...node, title: asset.title, metadata: { ...metadata, ...patch } };
});
return changed ? next : nodes;
}
export function compileCharacterReferencePrompt(name: string, definition: Record<string, unknown>) {
const parts = [definition.role, definition.appearance, definition.physique, definition.clothing, definition.personality, definition.props, definition.consistencyPrompt]
.map((value) => typeof value === "string" ? value.trim() : "")
.filter(Boolean);
return [`【角色卡:${name}】`, ...parts].join("\n");
}
export function normalizeCharacterName(value?: string) {
return (value || "").toLocaleLowerCase("zh-CN").replace(/^角色[::]\s*/, "").replace(/[\s·•・._-]+/g, "").trim();
}
export function parseDocumentCharacterBreakdown(raw: string): DocumentCharacterBreakdown[] {
export function parseCharacterBreakdown(raw: string): CharacterBreakdown[] {
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
const starts = [unfenced.indexOf("{"), unfenced.indexOf("[")].filter((index) => index >= 0);
const start = starts.length ? Math.min(...starts) : -1;
@@ -38,7 +92,7 @@ export function parseDocumentCharacterBreakdown(raw: string): DocumentCharacterB
if (!Array.isArray(candidates)) throw new Error("角色拆解结果缺少 characters 数组");
const seen = new Set<string>();
const characters: DocumentCharacterBreakdown[] = [];
const characters: CharacterBreakdown[] = [];
candidates.forEach((candidate) => {
if (!candidate || typeof candidate !== "object") return;
const value = candidate as Record<string, unknown>;
@@ -47,11 +101,20 @@ export function parseDocumentCharacterBreakdown(raw: string): DocumentCharacterB
const aliases = Array.isArray(value.aliases) ? value.aliases.map((alias) => String(alias).trim()).filter(Boolean) : [];
const identityKeys = [key, ...aliases.map(normalizeCharacterName)].filter(Boolean);
if (!name || !key || identityKeys.some((identityKey) => seen.has(identityKey))) return;
const role = String(value.role || "").trim();
const descriptiveFields = [value.appearance, value.clothing, value.physique, value.personality, value.consistencyPrompt, value.multiViewPrompt]
.map((field) => String(field || "").trim())
.filter(Boolean);
const voiceLanguage = String(value.voiceLanguage || "").trim();
const voiceAge = String(value.voiceAge || "").trim();
const voiceTimbre = String(value.voiceTimbre || "").trim();
// AI 提取属于角色写入路径:只有名称不足以建立角色卡,避免空设定进入项目后再由用户猜测补全。
if (!role || descriptiveFields.length < 3 || !voiceLanguage || !voiceAge || !voiceTimbre) throw new Error(`角色“${name}”缺少剧情定位、稳定设定或声音画像,请重新提取`);
identityKeys.forEach((identityKey) => seen.add(identityKey));
characters.push({
name,
aliases: Array.from(new Set(aliases.filter((alias) => normalizeCharacterName(alias) !== key))),
role: String(value.role || "").trim(),
role,
appearance: String(value.appearance || "").trim(),
clothing: String(value.clothing || "").trim(),
physique: String(value.physique || "").trim(),
@@ -59,195 +122,11 @@ export function parseDocumentCharacterBreakdown(raw: string): DocumentCharacterB
props: String(value.props || "").trim(),
consistencyPrompt: String(value.consistencyPrompt || "").trim(),
multiViewPrompt: String(value.multiViewPrompt || "").trim(),
voiceLanguage,
voiceAge,
voiceTimbre,
});
});
if (!characters.length) throw new Error("没有从小说中识别到可用角色");
if (!characters.length) throw new Error("没有从章节正文中识别到可用角色");
return characters;
}
export function buildCharacterIdentityPrompt(character: DocumentCharacterBreakdown) {
return [
character.role && `人物定位:${character.role}`,
character.appearance && `固定外貌:${character.appearance}`,
character.clothing && `固定服饰:${character.clothing}`,
character.physique && `身形体态:${character.physique}`,
character.personality && `气质表演:${character.personality}`,
character.props && `固定道具:${character.props}`,
character.consistencyPrompt && `一致性约束:${character.consistencyPrompt}`,
].filter(Boolean).join("\n");
}
export function buildCharacterMultiViewPrompt(character: DocumentCharacterBreakdown, projectStyle: string) {
return [
`【任务】为短剧项目生成“${character.name}”的单张多视角角色转面参考图。`,
`【项目画风】\n${projectStyle.trim()}`,
`【角色固定设定】\n${buildCharacterIdentityPrompt(character)}`,
`【转面设计】同一画面从左到右依次排布正面全身、严格左侧面全身、严格背面全身三个标准视角;三个人像是同一角色的转面展示,不是三个角色。${character.multiViewPrompt ? ` ${character.multiViewPrompt}` : ""}`,
"【画面规范】纯净中性背景,均匀柔光,无透视夸张,三个视角等高、等比例、完整全身、间距清楚;必须使用同一脸型、同一发型、同一服装版本、同一体型、同一道具和同一材质系统,清楚展示服装正侧背结构与道具佩戴位置。",
"【禁止】禁止额外人物、剧情场景、动作姿势、表情变化、文字、标注、水印、遮挡、裁切身体、服装变化、换脸、年龄漂移、体型漂移、道具错位和画风漂移。",
].filter(Boolean).join("\n\n");
}
export function partitionCharacterBreakdowns(characters: DocumentCharacterBreakdown[], nodes: CanvasNodeData[]) {
const existingNames = new Set(nodes.flatMap((node) => {
const kind = node.metadata?.workflowKind as string | undefined;
if (kind !== "character" && kind !== "character_card") return [];
return [node.metadata?.characterName, ...(node.metadata?.characterAliases || [])].map(normalizeCharacterName).filter(Boolean);
}));
const existing: DocumentCharacterBreakdown[] = [];
const newCharacters: DocumentCharacterBreakdown[] = [];
characters.forEach((character) => {
const keys = [character.name, ...character.aliases].map(normalizeCharacterName).filter(Boolean);
(keys.some((key) => existingNames.has(key)) ? existing : newCharacters).push(character);
});
return { existing, newCharacters };
}
export function removeLegacyCharacterCards(nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const legacyIds = new Set(nodes.filter((node) => (node.metadata?.workflowKind as string | undefined) === "character_card").map((node) => node.id));
if (!legacyIds.size) return { nodes, connections };
return {
nodes: nodes.filter((node) => !legacyIds.has(node.id)),
connections: connections.filter((connection) => !legacyIds.has(connection.fromNodeId) && !legacyIds.has(connection.toNodeId)),
};
}
export function upsertCharacterReferenceBoard({
nodes,
connections,
documentNode,
documentTitle,
chapter,
characters,
projectStyle,
}: {
nodes: CanvasNodeData[];
connections: CanvasConnection[];
documentNode: CanvasNodeData;
documentTitle: string;
chapter?: CanvasDocumentChapter;
characters: DocumentCharacterBreakdown[];
projectStyle: string;
}) {
let nextNodes = [...nodes];
let nextConnections = [...connections];
const imageWidth = NODE_DEFAULT_SIZE[CanvasNodeType.Image].width;
const imageHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Image].height;
const framePadding = 24;
const nodeGap = 20;
let frame = nextNodes.find((node) => node.type === CanvasNodeType.Frame && node.metadata?.workflowTitle === "角色多视角" && node.metadata?.documentNodeId === documentNode.id);
if (!frame) {
const columns = Math.min(3, Math.max(1, characters.length));
const rows = Math.ceil(characters.length / columns);
const width = framePadding * 2 + columns * imageWidth + (columns - 1) * nodeGap;
const height = FRAME_HEADER_HEIGHT + framePadding * 2 + rows * imageHeight + (rows - 1) * nodeGap;
const position = findFreeFramePosition(nextNodes, documentNode, width, height);
frame = createReferenceNode(CanvasNodeType.Frame, { x: position.x + width / 2, y: position.y + height / 2 }, {
workflowKind: "reference_set",
workflowTitle: "角色多视角",
documentNodeId: documentNode.id,
frame: { collapsed: false, expandedWidth: width, expandedHeight: height },
});
frame.position = position;
frame.width = width;
frame.height = height;
nextNodes.push(frame);
}
let createdCount = 0;
let updatedCount = 0;
characters.forEach((character) => {
const keys = new Set([character.name, ...character.aliases].map(normalizeCharacterName).filter(Boolean));
const matchesCharacter = (node: CanvasNodeData) => [node.metadata?.characterName, ...(node.metadata?.characterAliases || [])].some((value) => keys.has(normalizeCharacterName(value)));
const legacyCards = nextNodes.filter((node) => (node.metadata?.workflowKind as string | undefined) === "character_card" && matchesCharacter(node));
const existingImages = nextNodes.filter((node) => node.type === CanvasNodeType.Image && node.metadata?.workflowKind === "character" && matchesCharacter(node));
const existing = existingImages.find((node) => node.metadata?.characterView === "multi");
const identityPrompt = buildCharacterIdentityPrompt(character);
const multiViewPrompt = buildCharacterMultiViewPrompt(character, projectStyle);
const metadata: CanvasNodeMetadata = {
...existing?.metadata,
prompt: multiViewPrompt,
composerContent: multiViewPrompt,
status: existing?.metadata?.content ? "success" : "idle",
errorDetails: undefined,
generationMode: "image",
workflowKind: "character",
workflowTitle: `${character.name}多视角参考图`,
documentNodeId: documentNode.id,
chapterId: chapter?.id,
chapterTitle: chapter?.title,
characterName: character.name,
characterAliases: character.aliases,
characterPrompt: identityPrompt,
characterView: "multi",
assetTags: Array.from(new Set([...(existing?.metadata?.assetTags || []).filter((tag) => !/^角色[::]|^视角[::]/.test(tag)), `角色: ${character.name}`, "视角: 多视角"])),
};
if (existing) {
nextNodes = nextNodes.map((node) => node.id === existing.id ? { ...existing, title: `${character.name} · 多视角参考`, parentId: frame!.id, metadata } : node);
updatedCount += 1;
} else {
const imageNode = createReferenceNode(CanvasNodeType.Image, { x: frame!.position.x + framePadding + imageWidth / 2, y: frame!.position.y + FRAME_HEADER_HEIGHT + framePadding + imageHeight / 2 }, metadata);
imageNode.title = `${character.name} · 多视角参考`;
imageNode.parentId = frame!.id;
nextNodes.push(imageNode);
createdCount += 1;
}
const obsoleteViewIds = new Set(existingImages.filter((node) => node.metadata?.characterView !== "multi").map((node) => node.id));
nextNodes = nextNodes.flatMap((node) => {
if (!obsoleteViewIds.has(node.id)) return [node];
if (!node.metadata?.content) return [];
return [{ ...node, parentId: undefined, metadata: { ...node.metadata, workflowKind: "free" as const, workflowTitle: "旧角色参考", characterName: undefined, characterAliases: undefined, characterPrompt: undefined, characterView: undefined, assetTags: (node.metadata.assetTags || []).filter((tag) => !/^角色[::]|^视角[::]/.test(tag)) } }];
});
nextConnections = nextConnections.filter((connection) => !obsoleteViewIds.has(connection.fromNodeId) && !obsoleteViewIds.has(connection.toNodeId));
const legacyIds = new Set(legacyCards.map((node) => node.id));
nextNodes = nextNodes.filter((node) => !legacyIds.has(node.id));
nextConnections = nextConnections.filter((connection) => !legacyIds.has(connection.fromNodeId) && !legacyIds.has(connection.toNodeId));
});
const children = nextNodes.filter((node) => node.parentId === frame!.id && node.type === CanvasNodeType.Image && node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi");
const columns = Math.min(3, Math.max(1, children.length));
const rows = Math.ceil(children.length / columns);
const frameWidth = framePadding * 2 + columns * imageWidth + (columns - 1) * nodeGap;
const frameHeight = FRAME_HEADER_HEIGHT + framePadding * 2 + rows * imageHeight + (rows - 1) * nodeGap;
nextNodes = nextNodes.map((node) => {
if (node.id === frame!.id) return { ...node, title: `角色参考 · ${documentTitle}`, width: frameWidth, height: frameHeight, metadata: { ...node.metadata, workflowKind: "reference_set", workflowTitle: "角色多视角", documentNodeId: documentNode.id, chapterId: undefined, chapterTitle: undefined, frame: { collapsed: false, expandedWidth: frameWidth, expandedHeight: frameHeight } } };
const index = children.findIndex((child) => child.id === node.id);
if (index < 0) return node;
const column = index % columns;
const row = Math.floor(index / columns);
return { ...node, position: { x: frame!.position.x + framePadding + column * (imageWidth + nodeGap) + (imageWidth - node.width) / 2, y: frame!.position.y + FRAME_HEADER_HEIGHT + framePadding + row * (imageHeight + nodeGap) + (imageHeight - node.height) / 2 } };
});
const emptyFrameIds = new Set(nextNodes.filter((node) => node.type === CanvasNodeType.Frame && node.id !== frame!.id && node.metadata?.workflowTitle === "角色多视角" && !nextNodes.some((child) => child.parentId === node.id)).map((node) => node.id));
return {
nodes: nextNodes.filter((node) => !emptyFrameIds.has(node.id)),
connections: nextConnections.filter((connection) => !emptyFrameIds.has(connection.fromNodeId) && !emptyFrameIds.has(connection.toNodeId)),
frameId: frame.id,
createdCount,
updatedCount,
};
}
function findFreeFramePosition(nodes: CanvasNodeData[], documentNode: CanvasNodeData, width: number, height: number) {
const left = documentNode.position.x;
let top = documentNode.position.y + documentNode.height + 72;
const overlaps = (node: CanvasNodeData) => !node.parentId && left < node.position.x + node.width + 24 && left + width + 24 > node.position.x && top < node.position.y + node.height + 24 && top + height + 24 > node.position.y;
while (nodes.some(overlaps)) top += height + 36;
return { x: left, y: top };
}
function createReferenceNode(type: CanvasNodeType.Image | CanvasNodeType.Frame, position: Position, metadata: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
return {
id: `${type}-${Date.now()}-${nanoid(5)}`,
type,
title: spec.title,
position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 },
width: spec.width,
height: spec.height,
metadata: { ...spec.metadata, ...metadata },
};
}

View File

@@ -0,0 +1,30 @@
import type { ProjectUnit } from "@/services/api/projects";
import type { CanvasNodeData } from "@/types/canvas";
export type CanvasContextSummary = {
nodeCount: number;
selectedCount: number;
chapterLabel: string;
shotLabel: string;
};
export function summarizeCanvasContext(nodes: CanvasNodeData[], selectedNodeIds: Set<string>, units: ProjectUnit[] = []): CanvasContextSummary {
const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id));
const unitTitleById = new Map(units.map((unit) => [unit.id, unit.title]));
const chapterTitles = unique(selectedNodes.flatMap((node) => {
const chapterTitle = node.metadata?.chapterTitle || (node.metadata?.chapterId ? unitTitleById.get(node.metadata.chapterId) : "");
return chapterTitle ? [chapterTitle] : [];
}));
const shotIndexes = unique(selectedNodes.flatMap((node) => typeof node.metadata?.shotIndex === "number" ? [node.metadata.shotIndex] : []));
return {
nodeCount: nodes.length,
selectedCount: selectedNodes.length,
chapterLabel: chapterTitles.length === 1 ? chapterTitles[0] : chapterTitles.length > 1 ? `${chapterTitles.length} 个章节` : "",
shotLabel: shotIndexes.length === 1 ? `镜头 ${shotIndexes[0] + 1}` : shotIndexes.length > 1 ? `${shotIndexes.length} 个镜头` : "",
};
}
function unique<T>(values: T[]) {
return Array.from(new Set(values));
}

View File

@@ -1,6 +1,3 @@
import type { JSONContent } from "@tiptap/core";
import type { CanvasDocumentChapter, CanvasRichDocument } from "@/types/canvas";
const maxChapterHeadingLength = 120;
const chineseChapterNumber = "[零〇○一二两三四五六七八九十百千万亿壹贰叁肆伍陆柒捌玖拾佰仟\\d]+";
const englishChapterNumber = "(?:\\d+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)";
@@ -14,44 +11,7 @@ const chapterHeadingPatterns = [
new RegExp(`^(?:prologue|preface|introduction|epilogue|afterword|appendix)${chapterTitleSuffix}$`, "i"),
];
export function emptyDocument(): JSONContent {
return { type: "doc", content: [{ type: "paragraph" }] };
}
export function textToDocument(text: string): JSONContent {
const content = text.split(/\n{2,}/).map((paragraph) => {
const value = paragraph.trim();
if (!value) return { type: "paragraph" };
const markdownHeading = /^(#{1,3})\s+(.+)$/.exec(value);
if (markdownHeading) return { type: "heading", attrs: { level: markdownHeading[1].length }, content: [{ type: "text", text: markdownHeading[2] }] };
return { type: "paragraph", content: [{ type: "text", text: value }] };
});
return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
}
export function createDocumentChapter(title: string, plainText = "", order = 0): CanvasDocumentChapter {
return {
id: `chapter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title,
order,
json: textToDocument(plainText) as unknown as Record<string, unknown>,
plainText: plainText.trim(),
characterCount: Array.from(plainText.trim()).length,
storyboardStatus: "idle",
updatedAt: new Date().toISOString(),
};
}
export function normalizeDocumentChapters(document: CanvasRichDocument | undefined, fallbackText = "") {
if (document?.chapters?.length) {
return document.chapters
.map((chapter, index) => ({ ...chapter, title: chapter.title.trim() || `第 ${index + 1} 章`, order: index }))
.sort((left, right) => left.order - right.order);
}
const chapter = createDocumentChapter("第 1 章", document?.plainText || fallbackText, 0);
if (document?.json) chapter.json = document.json;
return [chapter];
}
export type ImportedNovelChapter = { title: string; plainText: string };
function chapterTitleFromLine(line: string) {
const trimmed = line.trim();
@@ -87,7 +47,7 @@ export function decodeNovelText(buffer: ArrayBuffer) {
export function splitTextIntoChapters(text: string) {
const normalized = text.replace(/\r\n?/g, "\n").trim();
if (!normalized) return [createDocumentChapter("第 1 章")];
if (!normalized) return [{ title: "第 1 章", plainText: "" }];
const lines = normalized.split("\n");
const sections: Array<{ title: string; lines: string[] }> = [];
let current: { title: string; lines: string[] } | null = null;
@@ -104,29 +64,7 @@ export function splitTextIntoChapters(text: string) {
else preface.push(line);
}
if (current) sections.push(current);
if (!sections.length) return [createDocumentChapter("第 1 章", normalized, 0)];
if (!sections.length) return [{ title: "第 1 章", plainText: normalized }];
if (preface.some((line) => line.trim())) sections.unshift({ title: "序章", lines: preface });
return sections.map((section, index) => createDocumentChapter(section.title, section.lines.join("\n").trim(), index));
}
export function buildRichDocument(
previous: CanvasRichDocument | undefined,
chapters: CanvasDocumentChapter[],
activeChapterId: string,
sourceFileName?: string,
): CanvasRichDocument {
const ordered = chapters.map((chapter, index) => ({ ...chapter, order: index }));
const active = ordered.find((chapter) => chapter.id === activeChapterId) || ordered[0];
const plainText = ordered.map((chapter) => `${chapter.title}\n\n${chapter.plainText}`.trim()).filter(Boolean).join("\n\n");
return {
kind: previous?.kind || "novel",
format: "tiptap-json",
json: active?.json || (emptyDocument() as unknown as Record<string, unknown>),
plainText,
characterCount: ordered.reduce((total, chapter) => total + chapter.characterCount, 0),
chapters: ordered,
activeChapterId: active?.id,
sourceFileName,
updatedAt: new Date().toISOString(),
};
return sections.map((section): ImportedNovelChapter => ({ title: section.title, plainText: section.lines.join("\n").trim() }));
}

View File

@@ -0,0 +1,40 @@
import { loadCanvasDrawingRender, saveCanvasDrawingRenderPublication } from "@/lib/canvas/canvas-drawing-storage";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import type { ReferenceImage } from "@/types/image";
export async function resolveCanvasDrawingReference(projectId: string, image: ReferenceImage): Promise<ReferenceImage> {
const source = image.source;
if (!source || source.kind !== "drawing") return image;
if (!source.shapeCount) throw new Error(`绘图“${image.name}”为空,请先完成绘图再生成`);
const render = await loadCanvasDrawingRender(projectId, source.drawingId);
if (!render || render.revision !== source.revision) {
throw new Error(`绘图“${image.name}”缺少当前版本的生成图,请打开绘图并保存后重试`);
}
if (resourceIdFromStorageKey(render.storageKey)) {
return {
...image,
dataUrl: "",
url: render.url,
storageKey: render.storageKey,
type: render.mimeType,
};
}
try {
const resource = await uploadResourceFile(render.blob, "image", {
width: render.width,
height: render.height,
fileName: `drawing-${source.drawingId}-r${source.revision}.png`,
});
const storageKey = resourceStorageKey(resource.id);
const url = resource.publicUrl || resourceFileUrl(resource.id);
const saved = await saveCanvasDrawingRenderPublication(projectId, source.drawingId, source.revision, { storageKey, url });
if (!saved) throw new Error("绘图在上传期间发生了修改,请重新生成");
return { ...image, dataUrl: "", url, storageKey, type: resource.mimeType || render.mimeType };
} catch (error) {
if (error instanceof Error && error.message === "绘图在上传期间发生了修改,请重新生成") throw error;
throw new Error(error instanceof Error ? `绘图参考图上传失败:${error.message}` : "绘图参考图上传失败");
}
}

View File

@@ -0,0 +1,264 @@
import localforage from "localforage";
import { AssetRecordType, createShapeId, createTLStore, type TLImageShape } from "tldraw";
import { readImageMeta } from "@/lib/image-utils";
import { getActiveUserScope } from "@/lib/user-scope";
import { imageToDataUrl } from "@/services/image-storage";
export type CanvasDrawingSnapshot = {
version: 1;
snapshot: unknown;
revision: number;
updatedAt: string;
shapeCount: number;
pageCount: number;
};
export type CanvasDrawingRenderDraft = {
blob: Blob;
pageId: string;
width: number;
height: number;
mimeType: string;
background: "white";
storageKey?: string;
url?: string;
};
export type CanvasDrawingRender = CanvasDrawingRenderDraft & {
version: 1;
revision: number;
updatedAt: string;
};
const drawingStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_documents" });
const drawingPreviewStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_previews" });
const drawingRenderStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_generation_renders" });
const INITIAL_DRAWING_RENDER_MAX_DIMENSION = 2048;
const INITIAL_DRAWING_RENDER_PADDING = 24;
const INITIAL_DRAWING_SHAPE_MAX_DIMENSION = 1200;
function drawingKey(projectId: string, drawingId: string) {
return `${getActiveUserScope()}:${projectId}:${drawingId}`;
}
export async function loadCanvasDrawing(projectId: string, drawingId: string) {
if (!projectId || !drawingId) return null;
return drawingStore.getItem<CanvasDrawingSnapshot>(drawingKey(projectId, drawingId));
}
export async function saveCanvasDrawing(
projectId: string,
drawingId: string,
snapshot: unknown,
previous?: CanvasDrawingSnapshot | null,
preview?: Blob | null,
render?: CanvasDrawingRenderDraft | null,
) {
const summary = summarizeCanvasDrawing(snapshot);
const revision = (previous?.revision || 0) + 1;
const updatedAt = new Date().toISOString();
const next: CanvasDrawingSnapshot = {
version: 1,
snapshot,
revision,
updatedAt,
shapeCount: summary.shapeCount,
pageCount: Math.min(summary.pageCount, 1),
};
await drawingStore.setItem(drawingKey(projectId, drawingId), next);
if (preview) await drawingPreviewStore.setItem(drawingKey(projectId, drawingId), preview);
else if (preview === null) await drawingPreviewStore.removeItem(drawingKey(projectId, drawingId));
if (render) {
await drawingRenderStore.setItem<CanvasDrawingRender>(drawingKey(projectId, drawingId), {
...render,
version: 1,
revision,
updatedAt,
});
} else if (render === null) await drawingRenderStore.removeItem(drawingKey(projectId, drawingId));
return next;
}
export async function createCanvasDrawingFromImage(
projectId: string,
drawingId: string,
image: { url: string; storageKey?: string; name: string; mimeType?: string },
) {
const dataUrl = await imageToDataUrl({ url: image.url, storageKey: image.storageKey, name: image.name, mimeType: image.mimeType });
if (!dataUrl?.startsWith("data:image/")) throw new Error("无法读取来源图片");
const [{ width, height, mimeType }, sourceBlob] = await Promise.all([
readImageMeta(dataUrl),
fetch(dataUrl).then((response) => response.blob()),
]);
const store = createTLStore();
const page = store.allRecords().find((record) => record.typeName === "page");
if (!page) throw new Error("无法初始化绘图页面");
const assetId = AssetRecordType.createId();
const shapeScale = Math.min(1, INITIAL_DRAWING_SHAPE_MAX_DIMENSION / Math.max(width, height));
const shapeWidth = Math.max(1, Math.round(width * shapeScale));
const shapeHeight = Math.max(1, Math.round(height * shapeScale));
const asset = AssetRecordType.create({
id: assetId,
type: "image",
props: {
w: width,
h: height,
name: image.name || "来源图片",
isAnimated: false,
mimeType: mimeType || sourceBlob.type || image.mimeType || "image/png",
src: dataUrl,
...(sourceBlob.size > 0 ? { fileSize: sourceBlob.size } : {}),
},
});
const shape: TLImageShape = {
id: createShapeId(),
typeName: "shape",
type: "image",
parentId: page.id,
index: "a1" as TLImageShape["index"],
x: -shapeWidth / 2,
y: -shapeHeight / 2,
rotation: 0,
isLocked: false,
opacity: 1,
props: {
w: shapeWidth,
h: shapeHeight,
playing: false,
url: "",
assetId,
crop: null,
flipX: false,
flipY: false,
altText: image.name || "来源图片",
},
meta: {},
};
store.put([asset, shape]);
// 来源图必须进入绘图快照本身,不能继续依赖可能被替换或清理的原节点 URL。
try {
const render = await createInitialDrawingRender(dataUrl, width, height, page.id);
return await saveCanvasDrawing(projectId, drawingId, store.getStoreSnapshot(), null, render.blob, render);
} catch (error) {
await removeCanvasDrawing(projectId, drawingId).catch((cleanupError) => console.warn("清理失败的绘图初始化数据失败", cleanupError));
throw error;
}
}
export async function loadCanvasDrawingPreview(projectId: string, drawingId: string) {
if (!projectId || !drawingId) return null;
return drawingPreviewStore.getItem<Blob>(drawingKey(projectId, drawingId));
}
export async function loadCanvasDrawingRender(projectId: string, drawingId: string) {
if (!projectId || !drawingId) return null;
return drawingRenderStore.getItem<CanvasDrawingRender>(drawingKey(projectId, drawingId));
}
export async function saveCanvasDrawingRenderPublication(projectId: string, drawingId: string, revision: number, publication: Pick<CanvasDrawingRenderDraft, "storageKey" | "url">) {
const key = drawingKey(projectId, drawingId);
const render = await drawingRenderStore.getItem<CanvasDrawingRender>(key);
if (!render || render.revision !== revision) return false;
await drawingRenderStore.setItem(key, { ...render, ...publication });
return true;
}
export async function removeCanvasDrawing(projectId: string, drawingId: string) {
if (!projectId || !drawingId) return;
await Promise.all([
drawingStore.removeItem(drawingKey(projectId, drawingId)),
drawingPreviewStore.removeItem(drawingKey(projectId, drawingId)),
drawingRenderStore.removeItem(drawingKey(projectId, drawingId)),
]);
}
export async function cloneCanvasDrawing(projectId: string, sourceDrawingId: string, targetDrawingId: string) {
const [source, preview, render] = await Promise.all([
loadCanvasDrawing(projectId, sourceDrawingId),
loadCanvasDrawingPreview(projectId, sourceDrawingId),
loadCanvasDrawingRender(projectId, sourceDrawingId),
]);
if (!source) return null;
const renderDraft = render
? {
blob: render.blob,
pageId: render.pageId,
width: render.width,
height: render.height,
mimeType: render.mimeType,
background: render.background,
storageKey: render.storageKey,
url: render.url,
} satisfies CanvasDrawingRenderDraft
: undefined;
return saveCanvasDrawing(projectId, targetDrawingId, source.snapshot, null, preview || undefined, renderDraft);
}
export function summarizeCanvasDrawing(snapshot: unknown) {
const root = snapshot && typeof snapshot === "object" ? snapshot as Record<string, unknown> : {};
const document = root.document && typeof root.document === "object" ? root.document as Record<string, unknown> : root;
const pages = pagesFromSnapshot(document);
const store = document.store;
const shapeCount = countRecords(document.shapes, "shape:") || countRecords(store, "shape:");
const pageCount = pages || countRecords(store, "page:");
return { shapeCount, pageCount: Math.max(pageCount, 1) };
}
function pagesFromSnapshot(document: Record<string, unknown>) {
const pages = document.pages;
return pages && typeof pages === "object" && !Array.isArray(pages) ? Object.keys(pages).length : 0;
}
function countRecords(value: unknown, prefix: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
return Object.keys(value).filter((key) => key.startsWith(prefix)).length;
}
async function createInitialDrawingRender(dataUrl: string, width: number, height: number, pageId: string): Promise<CanvasDrawingRenderDraft> {
const source = await loadDrawingImage(dataUrl);
const paddedWidth = width + INITIAL_DRAWING_RENDER_PADDING * 2;
const paddedHeight = height + INITIAL_DRAWING_RENDER_PADDING * 2;
const scale = Math.min(1, INITIAL_DRAWING_RENDER_MAX_DIMENSION / Math.max(paddedWidth, paddedHeight));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(paddedWidth * scale));
canvas.height = Math.max(1, Math.round(paddedHeight * scale));
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法创建绘图预览");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(
source,
Math.round(INITIAL_DRAWING_RENDER_PADDING * scale),
Math.round(INITIAL_DRAWING_RENDER_PADDING * scale),
Math.max(1, Math.round(width * scale)),
Math.max(1, Math.round(height * scale)),
);
const blob = await canvasToPngBlob(canvas);
return {
blob,
pageId,
width: canvas.width,
height: canvas.height,
mimeType: "image/png",
background: "white",
};
}
function loadDrawingImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("来源图片无法载入绘图"));
image.src = dataUrl;
});
}
function canvasToPngBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("无法生成绘图预览")), "image/png");
});
}

View File

@@ -0,0 +1,410 @@
export type CanvasFaceBox = {
id: string;
x: number;
y: number;
width: number;
height: number;
confidence?: number;
source: "detected" | "manual";
};
export type CanvasEmotionEditRegion = {
x: number;
y: number;
width: number;
height: number;
};
export type CanvasEmotionImageArtifacts = {
sourceDataUrl: string;
maskDataUrl: string;
characterDataUrl: string;
editRegion: CanvasEmotionEditRegion;
imageWidth: number;
imageHeight: number;
};
export type CanvasEmotionPreset = {
id: string;
label: string;
intimacy: -2 | -1 | 0 | 1 | 2;
arousal: -2 | -1 | 0 | 1 | 2;
prompt: string;
};
export type CanvasEmotionParams = {
presetId: string;
intimacy: number;
arousal: number;
characterName: string;
faceBox: CanvasFaceBox;
};
type BlendshapeName =
| "browInnerUp"
| "browDown_L"
| "browDown_R"
| "browOuterUp_L"
| "browOuterUp_R"
| "eyeBlink_L"
| "eyeBlink_R"
| "eyeSquint_L"
| "eyeSquint_R"
| "eyeWide_L"
| "eyeWide_R"
| "cheekSquint_L"
| "cheekSquint_R"
| "noseSneer_L"
| "noseSneer_R"
| "jawOpen"
| "mouthPucker"
| "mouthClose"
| "mouthSmile_L"
| "mouthSmile_R"
| "mouthFrown_L"
| "mouthFrown_R"
| "mouthDimple_L"
| "mouthDimple_R"
| "mouthShrugUpper"
| "mouthPress_L"
| "mouthPress_R"
| "mouthStretch_L"
| "mouthStretch_R";
export type CanvasEmotionBlendshapes = Partial<Record<BlendshapeName, number>>;
const labels = [
["欣喜若狂", "兴高采烈", "惊喜", "震惊", "惊恐"],
["开怀", "期待", "专注", "警觉", "紧张"],
["温柔", "浅然莞尔", "中性克制", "隐忍", "疏离"],
["安心", "释然", "疲惫", "失落", "悲伤"],
["满足", "平静", "冷淡", "隐忍心伤", "绝望"],
] as const;
const prompts = [
["自然、明亮的露齿笑,嘴角对称上扬,脸颊自然抬起,眼角有轻微笑纹", "明显喜悦,笑意饱满", "意外惊喜,眼睛睁大并自然张嘴", "明显震惊,眉毛抬起,嘴部微张", "强烈惊恐,双眼睁大,面部紧绷"],
["自然开怀,眼角带笑", "期待而兴奋,表情明亮", "高度专注,目光坚定", "保持警觉,眉眼略微收紧", "紧张不安,嘴唇轻抿"],
["温柔亲近,轻微微笑", "克制而自然的浅笑", "完全中性、克制、放松", "压住情绪,表情略显僵硬", "疏离冷静,减少面部情绪"],
["安心放松,柔和闭合嘴角", "如释重负,眉眼放松", "明显疲惫,眼睑下垂", "情绪低落,嘴角轻微下垂", "悲伤,内眉抬起,嘴角下垂"],
["安静满足,轻微闭口笑", "平静松弛,呼吸感自然", "冷淡克制,目光平直", "强忍心伤,嘴唇压紧,眼神黯淡", "深度绝望,眉眼下沉,面部失去张力"],
] as const;
export const canvasEmotionPresets: CanvasEmotionPreset[] = labels.flatMap((row, rowIndex) =>
row.map((label, columnIndex) => {
const intimacy = (2 - columnIndex) as CanvasEmotionPreset["intimacy"];
const arousal = (2 - rowIndex) as CanvasEmotionPreset["arousal"];
return {
id: `emotion-${intimacy}-${arousal}`,
label,
intimacy,
arousal,
prompt: prompts[rowIndex][columnIndex],
};
}),
);
export const neutralEmotionPreset = canvasEmotionPresets.find((preset) => preset.intimacy === 0 && preset.arousal === 0)!;
export function findEmotionPreset(intimacy: number, arousal: number) {
const x = clampAxis(intimacy);
const y = clampAxis(arousal);
return canvasEmotionPresets.find((preset) => preset.intimacy === x && preset.arousal === y) || neutralEmotionPreset;
}
export function emotionBlendshapes(preset: CanvasEmotionPreset): CanvasEmotionBlendshapes {
if (preset === neutralEmotionPreset || (preset.intimacy === 0 && preset.arousal === 0)) return {};
const warmth = Math.max(0, preset.intimacy / 2);
const distance = Math.max(0, -preset.intimacy / 2);
const activation = Math.max(0, preset.arousal / 2);
const calm = Math.max(0, -preset.arousal / 2);
const surprise = Math.max(0, activation * (1 - Math.abs(preset.intimacy) / 3));
const sadness = Math.max(0, calm * (0.35 + distance * 0.65));
const smile = warmth * (0.24 + activation * 0.48 + calm * 0.12);
const tension = distance * (0.16 + activation * 0.46 + calm * 0.22);
const shapes: CanvasEmotionBlendshapes = {
browInnerUp: clamp01(surprise * 0.58 + sadness * 0.42),
browDown_L: clamp01(tension * 0.7),
browDown_R: clamp01(tension * 0.7),
browOuterUp_L: clamp01(activation * 0.22 + surprise * 0.32),
browOuterUp_R: clamp01(activation * 0.22 + surprise * 0.32),
eyeBlink_L: clamp01(calm * 0.28),
eyeBlink_R: clamp01(calm * 0.28),
eyeSquint_L: clamp01(smile * 0.34 + tension * 0.28),
eyeSquint_R: clamp01(smile * 0.34 + tension * 0.28),
eyeWide_L: clamp01(activation * (0.16 + surprise * 0.48 + distance * 0.18)),
eyeWide_R: clamp01(activation * (0.16 + surprise * 0.48 + distance * 0.18)),
cheekSquint_L: clamp01(smile * 0.42),
cheekSquint_R: clamp01(smile * 0.42),
noseSneer_L: clamp01(tension * activation * 0.35),
noseSneer_R: clamp01(tension * activation * 0.35),
jawOpen: clamp01(activation * (0.06 + surprise * 0.34)),
mouthPucker: clamp01(distance * calm * 0.12),
mouthClose: clamp01(calm * 0.18 + tension * 0.2),
mouthSmile_L: clamp01(smile),
mouthSmile_R: clamp01(smile),
mouthFrown_L: clamp01(sadness * 0.54 + distance * calm * 0.14),
mouthFrown_R: clamp01(sadness * 0.54 + distance * calm * 0.14),
mouthDimple_L: clamp01(warmth * 0.22),
mouthDimple_R: clamp01(warmth * 0.22),
mouthShrugUpper: clamp01(sadness * 0.18),
mouthPress_L: clamp01(tension * 0.48 + calm * distance * 0.18),
mouthPress_R: clamp01(tension * 0.48 + calm * distance * 0.18),
mouthStretch_L: clamp01(activation * distance * 0.24),
mouthStretch_R: clamp01(activation * distance * 0.24),
};
return Object.fromEntries(Object.entries(shapes).filter(([, value]) => Boolean(value && value > 0.01))) as CanvasEmotionBlendshapes;
}
export function clampAxis(value: number): CanvasEmotionPreset["intimacy"] {
return Math.max(-2, Math.min(2, Math.round(value))) as CanvasEmotionPreset["intimacy"];
}
export function buildEmotionPrompt(params: CanvasEmotionParams, editRegion: CanvasEmotionEditRegion) {
const preset = canvasEmotionPresets.find((item) => item.id === params.presetId) || findEmotionPreset(params.intimacy, params.arousal);
const targetRegion = describeEmotionTarget(params.faceBox, editRegion);
return [
`仅修改第一张输入图中“${params.characterName}”脸部的表情,其他像素保持源图一致。`,
`目标情绪:${preset.label};表情要求:${preset.prompt}。情绪强度通过眉眼、嘴角和脸颊的肌肉张力表达,不要通过夸大嘴巴或重绘整张脸表达。`,
"第一张输入图是唯一编辑目标,第二张输入图仅用于核对同一人物身份,不得复制第二张图的构图、背景或光线。",
targetRegion,
"如果第一张输入图中出现其他人脸,其他人脸全部视为不可编辑背景;只允许修改目标框及其透明蒙版对应的这一张脸。",
"只改变眉眼开合、眼角、嘴角、脸颊和口腔内部的表情细节;保持眼睛大小与方向、嘴裂宽度、牙齿数量大小排列、嘴唇厚度、下巴轮廓和脸型自然且与原图一致。",
"输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。",
"严格保持人物身份、五官比例、肤色、发型、发丝、耳朵、配饰、服装、姿势、头部朝向、镜头、景深、光线、背景及画面其他人物不变;不要重绘眼镜、帽子或遮挡物。",
"禁止夸张卡通笑、嘴巴过大或拉宽、牙齿像整齐白墙、露出不自然牙龈、眼睛变形或眯成线、脸颊鼓包、塑料磨皮、重复五官,以及任何身份漂移。",
].join("\n");
}
function describeEmotionTarget(box: CanvasFaceBox, region: CanvasEmotionEditRegion) {
const x = clamp(Math.round(box.x - region.x), 0, Math.max(0, region.width - 1));
const y = clamp(Math.round(box.y - region.y), 0, Math.max(0, region.height - 1));
const width = Math.max(1, Math.min(region.width - x, Math.round(box.width)));
const height = Math.max(1, Math.min(region.height - y, Math.round(box.height)));
const centerX = Math.round(((x + width / 2) / Math.max(1, region.width)) * 100);
const centerY = Math.round(((y + height / 2) / Math.max(1, region.height)) * 100);
return `目标人脸框(相对于第一张裁切输入图):x=${x}px,y=${y}px,width=${width}px,height=${height}px;人脸中心约位于裁切图的 ${centerX}% 横向、${centerY}% 纵向。实际可编辑范围以该人脸周围的透明椭圆蒙版为最终边界。`;
}
export async function buildEmotionImageArtifacts(dataUrl: string, box: CanvasFaceBox, imageWidth: number, imageHeight: number): Promise<CanvasEmotionImageArtifacts> {
const image = await loadImageBitmap(dataUrl);
try {
const width = image.width || imageWidth;
const height = image.height || imageHeight;
const normalized = clampFaceBox(box, width, height);
const editRegion = resolveEmotionEditRegion(normalized, width, height);
return {
sourceDataUrl: drawSourceCrop(image, editRegion),
maskDataUrl: drawFaceMask(normalized, editRegion),
characterDataUrl: drawFaceCrop(image, normalized, width, height),
editRegion,
imageWidth: width,
imageHeight: height,
};
} finally {
image.close();
}
}
export function emotionGenerationSize(region: CanvasEmotionEditRegion) {
const ratio = region.width / Math.max(1, region.height);
if (ratio >= 1.2) return "1536x1024";
if (ratio <= 0.83) return "1024x1536";
return "1024x1024";
}
// 模型只负责局部重绘;最终结果始终以源图为底,仅在羽化人脸蒙版内混合生成像素。
export async function compositeEmotionImage(sourceDataUrl: string, generatedDataUrl: string, region: CanvasEmotionEditRegion, faceBox: CanvasFaceBox) {
const [source, generated] = await Promise.all([loadImageBitmap(sourceDataUrl), loadImageBitmap(generatedDataUrl)]);
try {
const normalizedRegion = clampEditRegion(region, source.width, source.height);
const normalizedFace = clampFaceBox(faceBox, source.width, source.height);
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("浏览器无法合成表情编辑结果");
context.drawImage(source, 0, 0);
const generatedCanvas = document.createElement("canvas");
generatedCanvas.width = normalizedRegion.width;
generatedCanvas.height = normalizedRegion.height;
const generatedContext = generatedCanvas.getContext("2d", { willReadFrequently: true });
if (!generatedContext) throw new Error("浏览器无法读取表情生成结果");
generatedContext.imageSmoothingEnabled = true;
generatedContext.imageSmoothingQuality = "high";
generatedContext.drawImage(generated, 0, 0, generated.width, generated.height, 0, 0, normalizedRegion.width, normalizedRegion.height);
const sourcePixels = context.getImageData(normalizedRegion.x, normalizedRegion.y, normalizedRegion.width, normalizedRegion.height);
const generatedPixels = generatedContext.getImageData(0, 0, normalizedRegion.width, normalizedRegion.height);
const ellipse = emotionEditEllipse(normalizedFace, normalizedRegion);
const gains = sampleEdgeColorGains(sourcePixels.data, generatedPixels.data, normalizedRegion.width, normalizedRegion.height, ellipse);
blendEmotionPixels(sourcePixels.data, generatedPixels.data, normalizedRegion.width, normalizedRegion.height, ellipse, gains);
context.putImageData(sourcePixels, normalizedRegion.x, normalizedRegion.y);
return canvas.toDataURL("image/png");
} finally {
source.close();
generated.close();
}
}
export function clampFaceBox(box: CanvasFaceBox, imageWidth: number, imageHeight: number): CanvasFaceBox {
const x = Math.max(0, Math.min(imageWidth - 1, box.x));
const y = Math.max(0, Math.min(imageHeight - 1, box.y));
return {
...box,
x,
y,
width: Math.max(1, Math.min(imageWidth - x, box.width)),
height: Math.max(1, Math.min(imageHeight - y, box.height)),
};
}
function resolveEmotionEditRegion(box: CanvasFaceBox, imageWidth: number, imageHeight: number): CanvasEmotionEditRegion {
const left = Math.floor(Math.max(0, box.x - box.width * 0.85));
const top = Math.floor(Math.max(0, box.y - box.height * 0.75));
const right = Math.ceil(Math.min(imageWidth, box.x + box.width * 1.85));
const bottom = Math.ceil(Math.min(imageHeight, box.y + box.height * 2));
return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
}
function clampEditRegion(region: CanvasEmotionEditRegion, imageWidth: number, imageHeight: number): CanvasEmotionEditRegion {
const x = Math.max(0, Math.min(imageWidth - 1, Math.round(region.x)));
const y = Math.max(0, Math.min(imageHeight - 1, Math.round(region.y)));
return {
x,
y,
width: Math.max(1, Math.min(imageWidth - x, Math.round(region.width))),
height: Math.max(1, Math.min(imageHeight - y, Math.round(region.height))),
};
}
function drawSourceCrop(image: ImageBitmap, region: CanvasEmotionEditRegion) {
const canvas = document.createElement("canvas");
canvas.width = region.width;
canvas.height = region.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法裁切头部编辑区域");
context.drawImage(image, region.x, region.y, region.width, region.height, 0, 0, region.width, region.height);
return canvas.toDataURL("image/png");
}
function drawFaceMask(box: CanvasFaceBox, region: CanvasEmotionEditRegion) {
const canvas = document.createElement("canvas");
canvas.width = region.width;
canvas.height = region.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法创建人脸编辑蒙版");
context.fillStyle = "#fff";
context.fillRect(0, 0, region.width, region.height);
const ellipse = emotionEditEllipse(box, region);
context.globalCompositeOperation = "destination-out";
context.beginPath();
context.ellipse(ellipse.centerX, ellipse.centerY, ellipse.radiusX, ellipse.radiusY, 0, 0, Math.PI * 2);
context.fill();
return canvas.toDataURL("image/png");
}
type EmotionEditEllipse = { centerX: number; centerY: number; radiusX: number; radiusY: number };
function emotionEditEllipse(box: CanvasFaceBox, region: CanvasEmotionEditRegion): EmotionEditEllipse {
const localX = box.x - region.x;
const localY = box.y - region.y;
const expandX = box.width * 0.22;
const expandTop = box.height * 0.28;
const expandBottom = box.height * 0.2;
const width = box.width + expandX * 2;
const height = box.height + expandTop + expandBottom;
return {
centerX: localX + box.width / 2,
centerY: localY - expandTop + height / 2,
radiusX: Math.max(1, width / 2),
radiusY: Math.max(1, height / 2),
};
}
function sampleEdgeColorGains(source: Uint8ClampedArray, generated: Uint8ClampedArray, width: number, height: number, ellipse: EmotionEditEllipse): [number, number, number] {
// 仅采样羽化边缘,按 RGB 分通道校正可同时收敛亮度与冷暖色偏,中心表情仍以模型结果为主。
const sourceSum = [0, 0, 0];
const generatedSum = [0, 0, 0];
let count = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const distance = ellipseDistance(x, y, ellipse);
if (distance < 0.72 || distance > 0.96) continue;
const index = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
sourceSum[channel] += source[index + channel];
generatedSum[channel] += generated[index + channel];
}
count += 1;
}
}
if (count < 32) return [1, 1, 1];
return sourceSum.map((sum, channel) => clamp(sum / Math.max(1, generatedSum[channel]), 0.78, 1.22)) as [number, number, number];
}
function blendEmotionPixels(source: Uint8ClampedArray, generated: Uint8ClampedArray, width: number, height: number, ellipse: EmotionEditEllipse, gains: [number, number, number]) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const alpha = emotionFeatherAlpha(ellipseDistance(x, y, ellipse));
if (alpha <= 0) continue;
const index = (y * width + x) * 4;
const correctionStrength = 0.42 + (1 - alpha) * 0.58;
for (let channel = 0; channel < 3; channel += 1) {
const gain = 1 + (gains[channel] - 1) * correctionStrength;
const corrected = clamp(generated[index + channel] * gain, 0, 255);
source[index + channel] = Math.round(source[index + channel] * (1 - alpha) + corrected * alpha);
}
source[index + 3] = Math.round(source[index + 3] * (1 - alpha) + generated[index + 3] * alpha);
}
}
}
function ellipseDistance(x: number, y: number, ellipse: EmotionEditEllipse) {
const dx = (x + 0.5 - ellipse.centerX) / ellipse.radiusX;
const dy = (y + 0.5 - ellipse.centerY) / ellipse.radiusY;
return Math.sqrt(dx * dx + dy * dy);
}
function emotionFeatherAlpha(distance: number) {
if (distance <= 0.7) return 1;
if (distance >= 1) return 0;
const t = (distance - 0.7) / 0.3;
return 1 - t * t * (3 - 2 * t);
}
function drawFaceCrop(image: ImageBitmap, box: CanvasFaceBox, imageWidth: number, imageHeight: number) {
const paddingX = box.width * 0.45;
const paddingTop = box.height * 0.35;
const paddingBottom = box.height * 0.65;
const sx = Math.max(0, Math.floor(box.x - paddingX));
const sy = Math.max(0, Math.floor(box.y - paddingTop));
const sw = Math.max(1, Math.min(imageWidth - sx, Math.ceil(box.width + paddingX * 2)));
const sh = Math.max(1, Math.min(imageHeight - sy, Math.ceil(box.height + paddingTop + paddingBottom)));
const canvas = document.createElement("canvas");
canvas.width = 384;
canvas.height = 384;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法创建人物参考图");
context.fillStyle = "#111";
context.fillRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(canvas.width / sw, canvas.height / sh);
const dw = sw * scale;
const dh = sh * scale;
context.drawImage(image, sx, sy, sw, sh, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh);
return canvas.toDataURL("image/jpeg", 0.9);
}
async function loadImageBitmap(dataUrl: string) {
const response = await fetch(dataUrl);
if (!response.ok) throw new Error("无法读取源图片,请重新上传后再试");
return createImageBitmap(await response.blob());
}
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}

View File

@@ -5,8 +5,10 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
import type { CanvasDrawingExport } from "@/types/canvas-export";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "影策画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
@@ -20,7 +22,30 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
zipFiles.push({ name: path, data: blob });
}),
);
return { project, files };
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => node.type === "drawing" && node.metadata?.drawingId).map(async (node) => {
const drawingId = node.metadata?.drawingId;
if (!drawingId) return null;
const [saved, preview, render] = await Promise.all([
loadCanvasDrawing(project.id, drawingId),
loadCanvasDrawingPreview(project.id, drawingId),
loadCanvasDrawingRender(project.id, drawingId),
]);
if (!saved) return null;
const previewPath = preview ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.png` : undefined;
if (preview && previewPath) zipFiles.push({ name: previewPath, data: preview });
const generationRenderPath = render ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.generation.png` : undefined;
if (render && generationRenderPath) zipFiles.push({ name: generationRenderPath, data: render.blob });
return {
drawingId,
...saved,
previewPath,
generationRender: render && generationRenderPath
? { path: generationRenderPath, pageId: render.pageId, width: render.width, height: render.height, mimeType: render.mimeType, background: render.background }
: undefined,
} satisfies CanvasDrawingExport;
}))).filter((item): item is CanvasDrawingExport => Boolean(item));
drawingDocuments.forEach((document) => zipFiles.push({ name: `projects/${project.id}/drawings/${safeFileName(document.drawingId)}.json`, data: JSON.stringify(document) }));
return { project, files, drawingDocuments };
}),
);

View File

@@ -0,0 +1,79 @@
import type { CanvasFaceBox } from "./canvas-emotion";
type DetectFaceResponse = {
id: number;
faces?: CanvasFaceBox[];
imageWidth?: number;
imageHeight?: number;
error?: string;
};
type PendingRequest = {
resolve: (result: CanvasFaceDetectionResult) => void;
reject: (error: Error) => void;
cleanup: () => void;
};
export type CanvasFaceDetectionResult = {
faces: CanvasFaceBox[];
imageWidth: number;
imageHeight: number;
};
let detectorWorker: Worker | null = null;
let requestSequence = 0;
const pendingRequests = new Map<number, PendingRequest>();
export async function detectCanvasFaces(dataUrl: string, signal?: AbortSignal): Promise<CanvasFaceDetectionResult> {
if (signal?.aborted) throw new DOMException("人脸识别已取消", "AbortError");
const response = await fetch(dataUrl, { signal });
if (!response.ok) throw new Error("无法读取源图片,请重新上传后再试");
const image = await createImageBitmap(await response.blob());
const worker = getDetectorWorker();
const id = ++requestSequence;
return new Promise((resolve, reject) => {
const abort = () => {
const request = pendingRequests.get(id);
if (!request) return;
pendingRequests.delete(id);
request.cleanup();
reject(new DOMException("人脸识别已取消", "AbortError"));
};
const cleanup = () => signal?.removeEventListener("abort", abort);
pendingRequests.set(id, { resolve, reject, cleanup });
signal?.addEventListener("abort", abort, { once: true });
worker.postMessage({ id, image }, [image]);
});
}
function getDetectorWorker() {
if (detectorWorker) return detectorWorker;
const worker = new Worker(new URL("./canvas-face-detector.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (event: MessageEvent<DetectFaceResponse>) => {
const request = pendingRequests.get(event.data.id);
if (!request) return;
pendingRequests.delete(event.data.id);
request.cleanup();
if (event.data.error) {
request.reject(new Error(event.data.error));
return;
}
request.resolve({
faces: event.data.faces || [],
imageWidth: event.data.imageWidth || 0,
imageHeight: event.data.imageHeight || 0,
});
};
worker.onerror = (event) => {
const error = new Error(event.message || "人脸识别服务初始化失败");
pendingRequests.forEach((request) => {
request.cleanup();
request.reject(error);
});
pendingRequests.clear();
worker.terminate();
detectorWorker = null;
};
detectorWorker = worker;
return worker;
}

View File

@@ -0,0 +1,85 @@
/// <reference lib="webworker" />
import { FaceDetector } from "@mediapipe/tasks-vision";
import type { CanvasFaceBox } from "./canvas-emotion";
type DetectFaceRequest = {
id: number;
image: ImageBitmap;
};
type DetectFaceResponse = {
id: number;
faces?: CanvasFaceBox[];
imageWidth?: number;
imageHeight?: number;
error?: string;
};
let detectorPromise: Promise<FaceDetector> | null = null;
const workerGlobal = self as typeof self & {
importScripts: (...urls: string[]) => void;
import?: (url: string) => Promise<unknown>;
};
// MediaPipe 会在模块 Worker 中从 importScripts 失败分支转向 self.import。
// 这里显式标记为运行时 URL,避免 Vite 将 public loader 当作源码模块转换。
workerGlobal.importScripts = () => { throw new TypeError("module worker uses dynamic import"); };
workerGlobal.import = async (url: string) => {
const response = await fetch(url.replace(/\?import(?:&.*)?$/, ""));
if (!response.ok) throw new Error(`MediaPipe loader 加载失败:${response.status}`);
const blobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
try {
return await import(/* @vite-ignore */ blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
};
function getDetector() {
if (!detectorPromise) {
detectorPromise = FaceDetector.createFromOptions(
{
wasmLoaderPath: "/mediapipe/wasm/vision_wasm_module_internal.js",
wasmBinaryPath: "/mediapipe/wasm/vision_wasm_module_internal.wasm",
},
{
baseOptions: { modelAssetPath: "/canvas/models/blaze-face-full-range-sparse.tflite" },
runningMode: "IMAGE",
minDetectionConfidence: 0.25,
minSuppressionThreshold: 0.3,
},
);
}
return detectorPromise;
}
self.onmessage = async (event: MessageEvent<DetectFaceRequest>) => {
const { id, image } = event.data;
const response: DetectFaceResponse = { id, imageWidth: image.width, imageHeight: image.height };
try {
const detector = await getDetector();
response.faces = detector.detect(image).detections.flatMap((detection, index) => {
const box = detection.boundingBox;
if (!box) return [];
return [{
id: `face-${id}-${index}`,
x: box.originX,
y: box.originY,
width: box.width,
height: box.height,
confidence: detection.categories[0]?.score,
source: "detected" as const,
}];
});
} catch (error) {
response.error = error instanceof Error ? error.message : "人脸识别失败";
} finally {
image.close();
}
self.postMessage(response);
};
export {};

View File

@@ -10,7 +10,7 @@ export function isFrameNode(node?: CanvasNodeData | null): node is CanvasNodeDat
}
export function canFrameContain(node: CanvasNodeData) {
return node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Video;
return node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Video;
}
export function getFrameChildren(frameId: string, nodes: CanvasNodeData[]) {

View File

@@ -1,5 +1,6 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
import type { GenerationTask } from "@/services/api/task-center";
@@ -61,7 +62,7 @@ export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined };
}
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask): Promise<CanvasNodeData> {
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
const prompt = node.metadata?.prompt || task.prompt;
const result = parseBackendGenerationResult(task);
@@ -69,9 +70,19 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
if (mode === "image") {
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = image.storageKey
let resultDataUrl = image.dataUrl;
const emotionEdit = node.metadata?.emotionEdit;
if (emotionEdit) {
if (!emotionEdit.editRegion) throw new Error("情绪编辑任务缺少局部合成区域,已拒绝使用整图重绘结果");
const sourceNode = nodes.find((item) => item.id === emotionEdit.sourceNodeId);
if (!sourceNode?.metadata?.content) throw new Error("情绪编辑源图片已删除,无法恢复局部合成结果");
const sourceDataUrl = await resolveImageUrl(sourceNode.metadata.storageKey, sourceNode.metadata.content);
if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片,未使用整图重绘结果");
resultDataUrl = await compositeEmotionImage(sourceDataUrl, image.dataUrl, emotionEdit.editRegion, emotionEdit.faceBox);
}
const uploaded = image.storageKey && !emotionEdit
? { url: await resolveImageUrl(image.storageKey, image.dataUrl), storageKey: image.storageKey, width: image.width || 1024, height: image.height || 1024, bytes: image.bytes || 0, mimeType: image.mimeType || "image/png" }
: await uploadImage(image.dataUrl);
: await uploadImage(resultDataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitNodeSize(uploaded.width, uploaded.height, node.width || imageConfig.width, node.height || imageConfig.height);
return {
@@ -109,13 +120,13 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
}
if (!result.text) throw new Error("后端任务没有返回文本");
return { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: result.text, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } };
return { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } };
}
export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) {
const node = findGenerationTaskNode(nodes, task, targetNodeId);
if (!node) return { nodes, updated: false, nodeId: "", node: null };
const updatedNode = await buildGenerationTaskNodeResult(node, task);
const updatedNode = await buildGenerationTaskNodeResult(node, task, nodes);
return {
nodes: nodes.map((item) => (item.id === node.id ? updatedNode : item)),
updated: true,
@@ -132,7 +143,7 @@ export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
const node = findGenerationTaskNode(project.nodes, task);
if (!node) return false;
if (node.metadata?.taskId === task.id && node.metadata.status === "success" && node.metadata.content) return false;
const updatedNode = await buildGenerationTaskNodeResult(node, task);
const updatedNode = await buildGenerationTaskNodeResult(node, task, project.nodes);
const latest = useCanvasStore.getState().projects.find((item) => item.id === project.id);
if (!latest?.nodes.some((item) => item.id === node.id)) return false;
useCanvasStore.getState().updateProject(project.id, { nodes: latest.nodes.map((item) => (item.id === node.id ? updatedNode : item)) });

View File

@@ -0,0 +1,131 @@
import { getDataUrlByteSize } from "@/lib/image-utils";
import type { Asset, AssetCategory, NewAsset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export type CanvasAssetSource = "canvas-generation" | "canvas-upload" | "canvas-manual";
type CanvasNodeAssetOptions = {
canvasId: string;
source: CanvasAssetSource;
taskId?: string;
};
export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAssetOptions): NewAsset | null {
const content = node.metadata?.content?.trim();
if (!content) return null;
const title = node.metadata?.prompt?.slice(0, 24) || node.title || canvasAssetFallbackTitle(node.type);
const metadata = {
source: options.source,
canvasId: options.canvasId,
nodeId: node.id,
taskId: options.taskId || node.metadata?.taskId,
prompt: node.metadata?.prompt,
resourceKey: node.metadata?.storageKey,
};
const base = {
title,
coverUrl: node.type === CanvasNodeType.Image ? content : "",
tags: node.metadata?.assetTags || [],
category: canvasNodeAssetCategory(node),
status: "confirmed" as const,
source: "Canvas",
metadata,
};
if (node.type === CanvasNodeType.Text) {
return { ...base, kind: "text", data: { content } };
}
if (node.type === CanvasNodeType.Image) {
const dataUrl = node.metadata?.storageKey ? "" : content;
return {
...base,
kind: "image",
data: {
dataUrl,
storageKey: node.metadata?.storageKey,
width: node.metadata?.naturalWidth || node.width,
height: node.metadata?.naturalHeight || node.height,
bytes: node.metadata?.bytes || getDataUrlByteSize(dataUrl),
mimeType: node.metadata?.mimeType || "image/png",
},
};
}
if (node.type === CanvasNodeType.Video) {
return {
...base,
kind: "video",
data: {
url: content,
storageKey: node.metadata?.storageKey,
width: node.metadata?.naturalWidth || node.width,
height: node.metadata?.naturalHeight || node.height,
durationMs: node.metadata?.durationMs,
bytes: node.metadata?.bytes || 0,
mimeType: node.metadata?.mimeType || "video/mp4",
},
};
}
if (node.type === CanvasNodeType.Audio) {
return {
...base,
kind: "audio",
data: {
url: content,
storageKey: node.metadata?.storageKey,
durationMs: node.metadata?.durationMs,
bytes: node.metadata?.bytes || 0,
mimeType: node.metadata?.mimeType || "audio/mpeg",
},
};
}
return null;
}
export function findCanvasNodeAsset(assets: Asset[], node: CanvasNodeData, canvasId: string, taskId?: string) {
const explicitId = node.metadata?.assetId;
if (explicitId) {
const explicit = assets.find((asset) => asset.id === explicitId);
if (explicit && sameAssetResource(explicit, node)) return explicit;
}
const generationTaskId = taskId || node.metadata?.taskId;
return assets.find((asset) => {
if (generationTaskId && asset.metadata?.taskId === generationTaskId && asset.metadata?.nodeId === node.id) return true;
if (asset.metadata?.nodeId !== node.id) return false;
const sourceCanvasId = asset.metadata?.canvasId;
if (sourceCanvasId !== canvasId && (sourceCanvasId || asset.metadata?.source !== "canvas-generation")) return false;
const nodeResourceKey = node.metadata?.storageKey;
const assetResourceKey = typeof asset.metadata?.resourceKey === "string" ? asset.metadata.resourceKey : assetStorageKey(asset);
return !nodeResourceKey || !assetResourceKey || nodeResourceKey === assetResourceKey;
});
}
function sameAssetResource(asset: Asset, node: CanvasNodeData) {
const nodeResourceKey = node.metadata?.storageKey;
const assetResourceKey = typeof asset.metadata?.resourceKey === "string" ? asset.metadata.resourceKey : assetStorageKey(asset);
return !nodeResourceKey || !assetResourceKey || nodeResourceKey === assetResourceKey;
}
function assetStorageKey(asset: Asset) {
return asset.kind === "text" ? undefined : asset.data.storageKey;
}
export function declaredCanvasNodeAssetCategory(node: CanvasNodeData): AssetCategory | undefined {
if (node.metadata?.assetCategory) return node.metadata.assetCategory;
if (node.metadata?.workflowKind === "character") return "character";
if (node.metadata?.workflowKind === "scene") return "environment";
if (node.metadata?.workflowKind === "styleboard") return "style";
return undefined;
}
export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory {
const declared = declaredCanvasNodeAssetCategory(node);
if (declared) return declared;
return "other";
}
function canvasAssetFallbackTitle(type: CanvasNodeType) {
if (type === CanvasNodeType.Image) return "画布图片";
if (type === CanvasNodeType.Video) return "画布视频";
if (type === CanvasNodeType.Audio) return "画布音频";
return "画布文本";
}

View File

@@ -44,7 +44,9 @@ export function createCanvasNode(type: CanvasNodeType, position: Position, metad
},
width: spec.width,
height: spec.height,
metadata: type === CanvasNodeType.Script ? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], referenceNodeIds: [] } } : { ...spec.metadata, ...metadata },
metadata: type === CanvasNodeType.Script
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], referenceNodeIds: [] } }
: { ...spec.metadata, ...metadata, ...(type === CanvasNodeType.Drawing ? { drawingId: metadata?.drawingId || `${id}-document` } : {}) },
};
}

View File

@@ -3,7 +3,7 @@ import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { UpdreamSkill } from "@/services/api/skills";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill";
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character";
export type CanvasResourceReference = {
id: string;
@@ -15,6 +15,7 @@ export type CanvasResourceReference = {
storageKey?: string;
text?: string;
active: boolean;
sourceType?: CanvasNodeType;
skill?: UpdreamSkill;
};
@@ -60,12 +61,13 @@ function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[]
}
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0 };
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0 };
let drawingCount = 0;
return nodes.flatMap((node): CanvasResourceReference[] => {
const kind = resourceKind(node);
if (!kind) return [];
const index = counts[kind]++;
const label = labelForKind(kind, index);
const index = node.type === CanvasNodeType.Drawing ? drawingCount++ : counts[kind]++;
const label = node.type === CanvasNodeType.Drawing ? `绘图${index + 1}` : labelForKind(kind, index);
return [
{
id: node.id,
@@ -73,16 +75,18 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
kind,
label,
title: node.title || label,
previewUrl: node.metadata?.content,
previewUrl: node.metadata?.workflowKind === "character" ? node.metadata.characterCoverUrl : node.type === CanvasNodeType.Drawing ? node.metadata?.drawingPreviewUrl : node.metadata?.content,
storageKey: node.metadata?.storageKey,
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : node.type === CanvasNodeType.Skill ? skillResourceText(node) : undefined,
text: node.metadata?.workflowKind === "character" ? node.metadata.characterPrompt : node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : node.type === CanvasNodeType.Skill ? skillResourceText(node) : undefined,
active,
sourceType: node.type,
},
];
});
}
function labelForKind(kind: CanvasResourceKind, index: number) {
if (kind === "character") return `角色${index + 1}`;
if (kind === "image") return imageReferenceLabel(index);
if (kind === "video") return seedanceReferenceLabel("video", index);
if (kind === "audio") return seedanceReferenceLabel("audio", index);
@@ -95,7 +99,9 @@ function isResourceNode(node: CanvasNodeData) {
}
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) return "character";
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) return "image";
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";

View File

@@ -0,0 +1,47 @@
import type { JSONContent } from "@tiptap/core";
import { generateHTML } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Placeholder from "@tiptap/extension-placeholder";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import StarterKit from "@tiptap/starter-kit";
export function createCanvasRichTextExtensions(placeholder?: string) {
return [
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
TextStyle,
Color.configure({ types: ["textStyle"] }),
Highlight.configure({ multicolor: true }),
CharacterCount,
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
];
}
export function canvasRichTextHTML(document?: Record<string, unknown>) {
if (!document) return "";
try {
// 仅接受 Tiptap 结构化 JSON,不启用原始 HTML;输出链接再按协议收口。
const template = window.document.createElement("template");
template.innerHTML = generateHTML(document as JSONContent, createCanvasRichTextExtensions());
template.content.querySelectorAll("a[href]").forEach((link) => {
if (!isSafeCanvasRichTextLink(link.getAttribute("href") || "")) link.removeAttribute("href");
});
return template.innerHTML;
} catch {
return "";
}
}
export function isSafeCanvasRichTextLink(href: string) {
const value = href.trim();
if (!value) return false;
try {
const url = new URL(value, window.location.origin);
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
} catch {
return false;
}
}

View File

@@ -1,6 +1,5 @@
import { nanoid } from "nanoid";
import { createDocumentChapter } from "@/lib/canvas/canvas-document";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { scopedLocalStorage } from "@/lib/user-scope";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas";
@@ -41,11 +40,10 @@ export function createShortDramaPipeline(center: Position) {
status: "idle",
workflowKind: "story_input",
workflowTitle: "故事输入",
workflowDescription: "小说或梗概",
storyInputMode: "novel",
workflowDescription: "题材、角色、冲突和结局方向",
fontSize: 14,
});
storyNode.title = "故事输入 · 小说";
storyNode.title = "故事梗概";
storyNode.width = 420;
storyNode.height = 260;
@@ -80,8 +78,7 @@ export function deriveShortDramaProgress(nodes: CanvasNodeData[], connections: C
const scriptIds = new Set(storyboardScripts.map((node) => node.id));
const isConnectedToStoryboard = (nodeId: string) => connections.some((connection) => connection.fromNodeId === nodeId && scriptIds.has(connection.toNodeId));
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
const linkedNovelNode = nodes.find((node) => node.type === CanvasNodeType.Text && node.metadata?.document?.kind === "novel" && isConnectedToStoryboard(node.id));
const storyNode = storyInputNode || linkedNovelNode || agentScriptNode;
const storyNode = storyInputNode || agentScriptNode;
const scriptNode = storyboardScripts.find((node) => meaningfulStoryboardRows(node).length > 0) || storyboardScripts[0];
const meaningfulRows = scriptNode ? meaningfulStoryboardRows(scriptNode) : [];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
@@ -89,7 +86,7 @@ export function deriveShortDramaProgress(nodes: CanvasNodeData[], connections: C
const completedShotVideos = nodes.filter((node) => isSuccessfulVideoNode(node) && (node.metadata?.workflowKind === "shot" || connections.some((connection) => shotNodes.some((shot) => shot.id === connection.fromNodeId) && connection.toNodeId === node.id)));
const finalNode = finalNodes.find((node) => node.metadata?.status === "success" && Boolean(node.metadata.content));
const storyText = (storyNode?.metadata?.document?.plainText || storyNode?.metadata?.content || "").trim();
const storyText = (storyNode?.metadata?.content || "").trim();
// 手工流水线要求输入真实连到分镜脚本;Agent 协议的风格板和剧本没有这条连线,按领域节点本身判断。
const styleDone = Boolean((styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim() && (hasAgentPipeline || !scriptNode || isConnectedToStoryboard(styleNode!.id)));
const storyDone = Boolean(storyText && storyNode && (storyNode === agentScriptNode || !scriptNode || isConnectedToStoryboard(storyNode.id)));
@@ -123,36 +120,6 @@ export function deriveShortDramaProgress(nodes: CanvasNodeData[], connections: C
};
}
export function storyInputNodeWithMode(node: CanvasNodeData, mode: "novel" | "brief") {
const content = (node.metadata?.document?.plainText || node.metadata?.content || "").trim();
if (mode === "brief") {
const metadata = { ...node.metadata, content, storyInputMode: mode, status: content ? "success" as const : "idle" as const };
delete metadata.document;
return { ...node, title: "故事输入 · 梗概", metadata };
}
const chapter = createDocumentChapter("第 1 章", content, 0);
return {
...node,
title: "故事输入 · 小说",
metadata: {
...node.metadata,
content,
storyInputMode: mode,
status: content ? "success" as const : "idle" as const,
document: {
kind: "novel" as const,
format: "tiptap-json" as const,
json: chapter.json,
plainText: content,
characterCount: Array.from(content).length,
chapters: [chapter],
activeChapterId: chapter.id,
updatedAt: new Date().toISOString(),
},
},
};
}
export function readShortDramaGuideDismissed() {
try {
return scopedLocalStorage.getItem(SHORT_DRAMA_GUIDE_STORAGE_KEY) === "dismissed";

View File

@@ -0,0 +1,230 @@
import type { ProjectDetail, ProjectSummary, ProjectUnit } from "@/services/api/projects";
export type ProjectActionTone = "default" | "attention" | "danger";
export type ProjectWorkbenchAction = {
id: string;
title: string;
description: string;
href: string;
actionLabel: string;
tone: ProjectActionTone;
};
export type ProjectContinueTarget = {
href: string;
title: string;
context: string;
updatedAt: string;
};
export type ProjectStageCell = {
label: string;
state: "idle" | "active" | "attention" | "completed";
};
export type ProjectUnitStage = {
unit: ProjectUnit;
content: ProjectStageCell;
assets: ProjectStageCell;
storyboard: ProjectStageCell;
canvas: ProjectStageCell;
};
export function projectSummaryCompletion(summary: ProjectSummary) {
return summary.unitCount ? Math.round((summary.completedUnitCount / summary.unitCount) * 100) : 0;
}
export function projectSummaryStage(summary: ProjectSummary) {
if (summary.project.status === "archived") return { label: "已归档", detail: "可在项目设置中恢复" };
if (!summary.unitCount) return { label: "准备故事", detail: "还没有剧情章节" };
if (summary.completedUnitCount === summary.unitCount) return { label: "章节已完成", detail: "可继续检查镜头与交付结果" };
if (!summary.canvasCount) return { label: "组织章节", detail: "下一步可建立项目画布" };
if (!summary.assetCount) return { label: "准备资产", detail: "补充角色、场景或画风" };
return { label: "制作中", detail: `${summary.completedUnitCount}/${summary.unitCount} 章已完成` };
}
export function projectDetailStage(detail: ProjectDetail) {
if (detail.project.status === "archived") return { label: "已归档", detail: "恢复项目后可继续制作" };
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed").length;
if (failedSteps) return { label: "需要处理", detail: `${failedSteps} 个流程步骤失败` };
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length;
if (pendingCandidates) return { label: "资产确认", detail: `${pendingCandidates} 个候选待确认` };
if (!detail.units.length) return { label: "准备故事", detail: "添加或导入剧情章节" };
if (!detail.shots.length) return { label: "分镜准备", detail: "选择章节生成镜头" };
const runningSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "running" || step.status === "review").length;
if (runningSteps) return { label: "制作中", detail: `${runningSteps} 个流程步骤正在推进` };
const completedUnits = detail.units.filter((unit) => unit.status === "completed").length;
if (completedUnits === detail.units.length) return { label: "检查交付", detail: "章节已完成,可检查镜头与结果" };
return { label: "镜头制作", detail: `${detail.shots.length} 个镜头已建立` };
}
export function projectAttentionCount(detail: ProjectDetail) {
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length;
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed").length;
return pendingCandidates + failedSteps;
}
export function projectContinueTarget(detail: ProjectDetail): ProjectContinueTarget {
const latestCanvas = [...detail.canvases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
const latestUnit = [...detail.units].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
if (latestCanvas && (!latestUnit || latestCanvas.updatedAt >= latestUnit.updatedAt)) {
return {
href: `/canvas/${latestCanvas.id}`,
title: latestCanvas.title,
context: "继续编辑项目画布",
updatedAt: latestCanvas.updatedAt,
};
}
if (latestUnit) {
return {
href: `/projects/${detail.project.id}/chapters/${latestUnit.id}`,
title: latestUnit.title,
context: "继续处理剧情章节",
updatedAt: latestUnit.updatedAt,
};
}
return {
href: `/projects/${detail.project.id}/chapters`,
title: detail.project.name,
context: "从剧情章节开始",
updatedAt: detail.project.updatedAt,
};
}
// 项目概览必须把真实阻塞转成动作;没有事实依据时只提示通用的制作下一步。
export function projectNextActions(detail: ProjectDetail, limit = 4): ProjectWorkbenchAction[] {
const actions: ProjectWorkbenchAction[] = [];
const projectRoot = `/projects/${detail.project.id}`;
if (detail.project.status === "archived") {
return [{
id: "restore-project",
title: "项目已归档",
description: "恢复后才能创建画布和提交生成任务。",
href: `${projectRoot}/settings`,
actionLabel: "前往恢复",
tone: "attention",
}];
}
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed");
if (failedSteps.length) {
actions.push({
id: "failed-steps",
title: `处理 ${failedSteps.length} 个失败步骤`,
description: failedSteps[0].error?.trim() || "查看失败原因和输入后,仅重试受影响的任务。",
href: "/tasks?status=failed",
actionLabel: "查看失败任务",
tone: "danger",
});
}
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation");
if (pendingCandidates.length) {
const categories = new Set(pendingCandidates.map((candidate) => candidate.category));
actions.push({
id: "pending-assets",
title: `确认 ${pendingCandidates.length} 个资产候选`,
description: `${categories.size} 类角色、场景或制作资产需要确认后才能稳定复用。`,
href: `${projectRoot}/assets`,
actionLabel: "去确认",
tone: "attention",
});
}
if (!detail.units.length) {
actions.push({
id: "add-story",
title: "添加第一个剧情章节",
description: "导入小说、粘贴文本,或从空白章节开始。",
href: `${projectRoot}/chapters`,
actionLabel: "添加章节",
tone: "default",
});
} else {
const firstDraft = [...detail.units].sort(byPosition).find((unit) => unit.status === "draft");
if (firstDraft) {
actions.push({
id: `review-unit-${firstDraft.id}`,
title: `确认第 ${firstDraft.position + 1} 章内容`,
description: firstDraft.title,
href: `${projectRoot}/chapters/${firstDraft.id}`,
actionLabel: "继续处理",
tone: "default",
});
}
const unitsWithShots = new Set(detail.shots.map((shot) => shot.unitId).filter(Boolean));
const firstUnitWithoutShots = [...detail.units].sort(byPosition).find((unit) => unit.status !== "draft" && !unitsWithShots.has(unit.id));
if (firstUnitWithoutShots) {
actions.push({
id: `storyboard-unit-${firstUnitWithoutShots.id}`,
title: `为第 ${firstUnitWithoutShots.position + 1} 章建立分镜`,
description: `${firstUnitWithoutShots.title}还没有镜头,可先生成分镜草稿再逐项调整。`,
href: `${projectRoot}/chapters/${firstUnitWithoutShots.id}`,
actionLabel: "建立分镜",
tone: "default",
});
}
}
if (!detail.canvases.length && detail.units.length) {
actions.push({
id: "create-canvas",
title: "建立第一张项目画布",
description: "把章节、分镜和参考资产放进同一个制作空间。",
href: `${projectRoot}/canvases`,
actionLabel: "查看项目画布",
tone: "default",
});
}
if (!actions.length) {
const target = projectContinueTarget(detail);
actions.push({
id: "continue-project",
title: "继续最近的制作内容",
description: `${target.context}:${target.title}`,
href: target.href,
actionLabel: "继续创作",
tone: "default",
});
}
return actions.slice(0, limit);
}
export function projectUnitStages(detail: ProjectDetail, limit = 8): ProjectUnitStage[] {
const sortedUnits = [...detail.units].sort(byPosition).slice(0, limit);
return sortedUnits.map((unit) => {
const candidates = detail.assetCandidates.filter((candidate) => candidate.unitId === unit.id);
const pendingCandidates = candidates.filter((candidate) => candidate.status === "pending_confirmation").length;
const confirmedCandidates = candidates.filter((candidate) => candidate.status === "confirmed").length;
const shots = detail.shots.filter((shot) => shot.unitId === unit.id);
const canvasCount = new Set(detail.canvasUnitLinks.filter((link) => link.unitId === unit.id).map((link) => link.canvasId)).size;
return {
unit,
content: contentStage(unit),
assets: pendingCandidates
? { label: `${pendingCandidates} 待确认`, state: "attention" }
: confirmedCandidates
? { label: `${confirmedCandidates} 已确认`, state: "completed" }
: { label: "未识别", state: "idle" },
storyboard: shots.length
? { label: `${shots.length} 镜头`, state: shots.every((shot) => shot.status === "completed") ? "completed" : "active" }
: { label: "未开始", state: "idle" },
canvas: canvasCount
? { label: `${canvasCount} 张`, state: "active" }
: { label: "未关联", state: "idle" },
};
});
}
function contentStage(unit: ProjectUnit): ProjectStageCell {
if (unit.status === "completed") return { label: "已完成", state: "completed" };
if (unit.status === "ready") return { label: "待制作", state: "active" };
return { label: "草稿", state: "attention" };
}
function byPosition(left: ProjectUnit, right: ProjectUnit) {
return left.position - right.position;
}

View File

@@ -0,0 +1,16 @@
export type SettingsSection = "channels" | "models" | "preferences" | "storage";
export function settingsPath(section: SettingsSection = "channels", continueCreation = false) {
const params = new URLSearchParams({ section });
if (continueCreation) params.set("continue", "1");
return `/settings?${params.toString()}`;
}
/**
* 画布深层组件没有路由上下文出口时统一跳转到正式设置页,避免重新引入全局配置弹窗。
*/
export function navigateToSettings(options?: { section?: SettingsSection; continueCreation?: boolean }) {
const to = settingsPath(options?.section, options?.continueCreation);
const event = new CustomEvent<{ to: string }>("workspace:navigate", { detail: { to }, cancelable: true });
if (window.dispatchEvent(event)) window.location.assign(to);
}