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

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

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

Refs #30

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

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

Refs #30

---------

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

View File

@@ -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 },
};
}