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:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user