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:
@@ -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,
|
||||
|
||||
@@ -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 "图片";
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
30
web/src/lib/canvas/canvas-context-summary.ts
Normal file
30
web/src/lib/canvas/canvas-context-summary.ts
Normal 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));
|
||||
}
|
||||
@@ -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() }));
|
||||
}
|
||||
|
||||
40
web/src/lib/canvas/canvas-drawing-reference.ts
Normal file
40
web/src/lib/canvas/canvas-drawing-reference.ts
Normal 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}` : "绘图参考图上传失败");
|
||||
}
|
||||
}
|
||||
264
web/src/lib/canvas/canvas-drawing-storage.ts
Normal file
264
web/src/lib/canvas/canvas-drawing-storage.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
410
web/src/lib/canvas/canvas-emotion.ts
Normal file
410
web/src/lib/canvas/canvas-emotion.ts
Normal 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));
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
79
web/src/lib/canvas/canvas-face-detection.ts
Normal file
79
web/src/lib/canvas/canvas-face-detection.ts
Normal 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;
|
||||
}
|
||||
85
web/src/lib/canvas/canvas-face-detector.worker.ts
Normal file
85
web/src/lib/canvas/canvas-face-detector.worker.ts
Normal 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 {};
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
131
web/src/lib/canvas/canvas-node-asset.ts
Normal file
131
web/src/lib/canvas/canvas-node-asset.ts
Normal 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 "画布文本";
|
||||
}
|
||||
@@ -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` } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
47
web/src/lib/canvas/canvas-rich-text.ts
Normal file
47
web/src/lib/canvas/canvas-rich-text.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
230
web/src/lib/project-workbench.ts
Normal file
230
web/src/lib/project-workbench.ts
Normal 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;
|
||||
}
|
||||
16
web/src/lib/settings-navigation.ts
Normal file
16
web/src/lib/settings-navigation.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user