Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-node-generation.ts

576 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AiTextMessage } from "@/services/api/image";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import { getGenerationResourceNodes, getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { ModelReferenceLimits } from "@/lib/model-selection";
import type { Asset } from "@/stores/use-asset-store";
export type CharacterGenerationReference = {
nodeId: string;
assetId: string;
requestedVersionId?: string;
};
export type ResolvedCharacterVoice = {
assetId: string;
versionId: string;
characterName: string;
voiceKey: string;
sampleResourceId?: string;
language?: string;
voiceAge?: string;
timbre?: string;
deliveryInstructions?: string;
instructions: string;
};
export type NodeGenerationContext = {
prompt: string;
referenceImages: ReferenceImage[];
referenceVideos: ReferenceVideo[];
referenceAudios: ReferenceAudio[];
characterReferences: CharacterGenerationReference[];
resolvedCharacterVersions: Array<{ assetId: string; versionId: string }>;
resolvedCharacterVoices: ResolvedCharacterVoice[];
textCount: number;
imageCount: number;
videoCount: number;
audioCount: number;
};
export type NodeGenerationInput = {
nodeId: string;
type: "text" | "image" | "video" | "audio" | "character";
sourceKind?: "drawing";
title: string;
alwaysIncludeText?: boolean;
text?: string;
image?: ReferenceImage;
video?: ReferenceVideo;
audio?: ReferenceAudio;
character?: CharacterGenerationReference;
};
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string, assets: Asset[], promptOnly = false): NodeGenerationContext {
const connectedInputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const sourceNode = nodes.find((node) => node.id === nodeId);
const portraitTextureInput = sourceNode?.type === CanvasNodeType.Image && sourceNode.metadata?.content && sourceNode.metadata?.portraitTexture
? (() => {
const image = readReferenceImage(sourceNode, nodes, connections);
return image ? [{ nodeId: sourceNode.id, type: "image" as const, title: sourceNode.title, image }] : [];
})()
: [];
const inputs = [...connectedInputs, ...portraitTextureInput, ...buildAssetGenerationInputs(assets)];
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
const hasExplicitResourceMention = /@\[(?:node|asset):[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs));
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource;
return buildComposerGenerationContext(
inputs,
prompt,
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
autoIncludeWorkflowMedia ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
promptOnly,
autoIncludeWorkflowMedia,
connectedInputs,
);
}
const isStoryboardMedia = sourceNode?.type === CanvasNodeType.Image || sourceNode?.type === CanvasNodeType.Video;
const basePrompt = isStoryboardMedia && storyboardInputs.length ? removeTrailingInputBlocks(prompt, storyboardInputs) : prompt;
const textInputs = connectedInputs.filter((input) => input.type === "text");
const characterReferences = connectedInputs.map((input) => input.character).filter((item): item is CharacterGenerationReference => Boolean(item));
const upstreamText = textInputs
.map((input) => input.text)
.filter(Boolean)
.join("\n\n");
const referenceImages = connectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = connectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = connectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
return {
prompt: promptOnly ? prompt : upstreamText ? `${basePrompt}\n\n${upstreamText}` : basePrompt,
referenceImages,
referenceVideos,
referenceAudios,
characterReferences,
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
textCount: textInputs.length,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
audioCount: referenceAudios.length,
};
}
function removeTrailingInputBlocks(prompt: string, inputs: NodeGenerationInput[]) {
let next = prompt.trim();
let removed = true;
while (removed) {
removed = false;
for (const input of inputs) {
const block = input.text?.trim();
if (!block || !next.endsWith(block)) continue;
const prefix = next.slice(0, next.length - block.length);
if (!prefix.trim() || !/\n\s*\n$/.test(prefix)) continue;
next = prefix.trimEnd();
removed = true;
break;
}
}
return next;
}
function buildComposerGenerationContext(
inputs: NodeGenerationInput[],
prompt: string,
videoFrameNodeIds: string[] = [],
promptOnly = false,
autoIncludeWorkflowMedia = false,
workflowMediaInputs: NodeGenerationInput[] = [],
): NodeGenerationContext {
const normalizedPrompt = normalizeLegacyNodeMentions(prompt, inputs);
const inputByToken = new Map(inputs.map((input) => [generationInputToken(input), input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
const selectedInputs: NodeGenerationInput[] = [];
const labelByNodeId = new Map<string, string>();
const textBlocks: string[] = [];
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
let hasToken = false;
let lastIndex = 0;
let nextPrompt = "";
if (autoIncludeWorkflowMedia) {
// 先固定“图片1/视频1”等提示词标签的顺序,和工作流槽位保持一致;
// 用户先 @ 第二张图时,提示词也不会把它误标成第一张。
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || labelByNodeId.has(input.nodeId)) return;
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
labelByNodeId.set(input.nodeId, generationLabel(labelKind, counts[labelKind]++));
});
}
for (const match of normalizedPrompt.matchAll(/@\[(node|asset):([^\]]+)\]/g)) {
if (match.index === undefined) continue;
hasToken = true;
nextPrompt += normalizedPrompt.slice(lastIndex, match.index);
const input = inputByToken.get(`${match[1]}:${match[2]}`);
if (input) {
let label = labelByNodeId.get(input.nodeId);
if (!label) {
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
label = generationLabel(labelKind, counts[labelKind]++);
labelByNodeId.set(input.nodeId, label);
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
else selectedInputs.push(input);
}
nextPrompt += input.type === "text" ? `【${label}】` : label;
} else nextPrompt += match[0];
lastIndex = match.index + match[0].length;
}
nextPrompt += normalizedPrompt.slice(lastIndex);
if (textBlocks.length && !promptOnly) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
if (autoIncludeWorkflowMedia) {
// RunningHub/ComfyUI 工作流按保存的字段槽位接收图片、视频和音频;
// 配置节点不能因为提示词里没有逐个 @ 就丢失已连接媒体。
// 先按连线顺序放入媒体,避免用户在提示词里 @图片2 后改变工作流槽位的索引;
// 素材库中的显式 @ 引用仍保留在后面,不会被自动模式吞掉。
const explicitInputs = selectedInputs.splice(0);
const selectedNodeIds = new Set<string>();
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
explicitInputs.forEach((input) => {
if (selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
}
// 首尾帧是结构化生成参数,不受提示词中的 @ 引用筛选影响。
const selectedNodeIds = new Set(selectedInputs.map((input) => input.nodeId));
videoFrameNodeIds.forEach((nodeId) => {
const input = nodeInputById.get(nodeId);
if (!input?.image || selectedNodeIds.has(nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(nodeId);
});
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
const characterReferences = selectedInputs.map((input) => input.character).filter((item): item is CharacterGenerationReference => Boolean(item));
if (!hasToken && !textBlocks.length && !selectedInputs.length) {
return {
prompt,
referenceImages: [],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
textCount: 0,
imageCount: 0,
videoCount: 0,
audioCount: 0,
};
}
return {
prompt: nextPrompt,
referenceImages,
referenceVideos,
referenceAudios,
characterReferences,
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
textCount: counts.text,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
audioCount: referenceAudios.length,
};
}
// 旧画布保存的是 @角色1 等显示标签;生成时升级为稳定节点 Token,避免标题或排序变化后引用错位。
function normalizeLegacyNodeMentions(prompt: string, inputs: NodeGenerationInput[]) {
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
const labels = inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => {
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
return { label: generationLabel(kind, counts[kind]++), nodeId: input.nodeId };
}).sort((a, b) => b.label.length - a.label.length);
let next = prompt;
labels.forEach(({ label, nodeId }) => {
const token = `@${label}`;
let cursor = 0;
let result = "";
while (cursor < next.length) {
const found = next.indexOf(token, cursor);
if (found < 0) {
result += next.slice(cursor);
break;
}
const end = found + token.length;
result += next.slice(cursor, found);
result += hasMentionBoundary(next, end) ? `@[node:${nodeId}]` : token;
cursor = end;
}
next = result;
});
return next;
}
function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
}
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
const resourceNodes = getGenerationResourceNodes(nodeId, nodes, connections);
return resourceNodes.flatMap((node): NodeGenerationInput[] => {
const character = readCharacterReference(node);
if (character) return [{ nodeId: node.id, type: "character" as const, title: node.title, character }];
const image = readReferenceImage(node, nodes, connections);
// sourceKind 只是「标签用绘图N而不是参考图N」的覆盖开关,不是来源全集。
// 调色节点在下游就是一张普通参考图,按 image 标签即正确。
if (image) return [{ nodeId: node.id, type: "image" as const, sourceKind: image.source?.kind === "drawing" ? "drawing" : undefined, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
const audio = readReferenceAudio(node);
if (audio) return [{ nodeId: node.id, type: "audio" as const, title: node.title, audio }];
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
});
}
function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
return assets.flatMap((asset): NodeGenerationInput[] => {
const nodeId = `asset:${asset.id}`;
if (asset.kind === "text") return [{ nodeId, type: "text", title: asset.title, text: asset.data.content }];
if (asset.kind === "image") return [{ nodeId, type: "image", title: asset.title, image: { id: asset.id, name: asset.title, type: asset.data.mimeType, dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height } }];
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, video: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs } }];
if (asset.kind === "audio") return [{ nodeId, type: "audio", title: asset.title, audio: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, durationMs: asset.data.durationMs } }];
if (asset.kind === "entity" && asset.category === "character") return [{ nodeId, type: "character", title: asset.title, character: { nodeId, assetId: asset.id, requestedVersionId: asset.primaryVersionId } }];
return [];
});
}
function generationInputToken(input: NodeGenerationInput) {
return input.nodeId.startsWith("asset:") ? input.nodeId : `node:${input.nodeId}`;
}
function getConnectedStoryboardRows(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
const targetNodeIds = new Set([nodeId]);
connections.forEach((connection) => {
if (connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config) {
targetNodeIds.add(connection.toNodeId);
}
});
const seen = new Set<string>();
return connections.flatMap((connection): NodeGenerationInput[] => {
if (!targetNodeIds.has(connection.toNodeId) || !connection.fromHandleId?.startsWith("row:")) return [];
const scriptNode = nodes.find((node) => node.id === connection.fromNodeId && node.type === CanvasNodeType.Script);
const row = scriptNode?.metadata?.storyboard?.rows.find((item) => `row:${item.id}` === connection.fromHandleId);
if (!scriptNode || !row) return [];
const inputId = `${scriptNode.id}:${connection.fromHandleId}`;
if (seen.has(inputId)) return [];
seen.add(inputId);
const characters = (row.characters || []).map((character) => [character.characterName, character.characterDescription].filter(Boolean).join(":")).filter(Boolean).join("、");
const text = [
`【分镜 ${row.shotNumber}】`,
`时长:${row.durationSeconds} 秒`,
row.plotDescription && `画面描述:${row.plotDescription}`,
row.dialogue && `台词/旁白:${row.dialogue}`,
characters && `角色:${characters}`,
row.shotSize && `景别:${row.shotSize}`,
row.emotion && `情绪:${row.emotion}`,
row.lightingAndAtmosphere && `光影氛围:${row.lightingAndAtmosphere}`,
row.audioEffects && `音效:${row.audioEffects}`,
row.camera && `镜头设计:${row.camera}`,
row.motion && `运镜:${row.motion}`,
row.timeBeats && `时间节拍:${row.timeBeats}`,
row.imageGenerationPrompt && `图片提示词:${row.imageGenerationPrompt}`,
row.videoMotionPrompt && `视频提示词:${row.videoMotionPrompt}`,
row.negativePrompt && `负面要求:${row.negativePrompt}`,
].filter(Boolean).join("\n");
return [{ nodeId: inputId, type: "text", title: `${scriptNode.title} · 镜头 ${row.shotNumber}`, text, alwaysIncludeText: true }];
});
}
export function buildNodeResponseMessages(context: NodeGenerationContext): AiTextMessage[] {
if (!context.referenceImages.length) {
return [{ role: "user", content: context.prompt }];
}
return [
{
role: "user",
content: [{ type: "text" as const, text: context.prompt }, ...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } }))],
},
];
}
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true, referenceLimits?: ModelReferenceLimits) {
const { imageToDataUrl } = await import("@/services/image-storage");
let referenceImages = await Promise.all(
context.referenceImages.map(async (image) => {
if (image.source?.kind === "drawing") return resolveCanvasDrawingReference(projectId, image);
if (image.source?.kind === "colorgrade") return resolveCanvasColorGradeReference(image);
return { ...image, dataUrl: await imageToDataUrl(image) };
}),
);
if (!context.characterReferences.length) return { ...context, referenceImages };
if (!domainProjectId) throw new Error("角色引用未关联短剧项目,无法解析角色版本");
const { getProjectCharacter } = await import("@/services/api/projects");
const { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId)));
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
const selected = details.flatMap((detail) => {
const representation = preferredCharacterRepresentation(detail.character.representations);
return representation ? [representation] : [];
});
if (selected.length > remainingBudget) throw new Error(`当前模型参考图容量不足:角色至少需要 ${selected.length} 张主参考图`);
const usedResourceIds = new Set(selected.map((item) => item.resourceId));
const supplements = details.flatMap((detail) => detail.character.representations.filter((item) => {
if (!["front", "side", "back", "turnaround_sheet"].includes(item.role) || usedResourceIds.has(item.resourceId)) return false;
usedResourceIds.add(item.resourceId);
return true;
}));
const characterImages = [...selected, ...supplements].slice(0, Math.max(0, remainingBudget)).map((representation, index) => ({
id: `character-reference-${index + 1}`,
name: `character-reference-${index + 1}.png`,
type: "image/png",
dataUrl: "",
storageKey: resourceStorageKey(representation.resourceId),
} satisfies ReferenceImage));
const hydratedCharacterImages = await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })));
referenceImages = [...referenceImages, ...hydratedCharacterImages];
const characterBlocks = details.map((detail) => compileCharacterReferencePrompt(detail.asset.title, detail.character.definition));
const resolvedCharacterVersions = details.map((detail) => ({ assetId: detail.asset.id, versionId: detail.character.versionId }));
const resolvedCharacterVoices = details.flatMap((detail): ResolvedCharacterVoice[] => {
const voice = detail.character.voice;
if (!voice) return [];
const language = stringField(detail.character.definition.voiceLanguage) || stringField(voice.profile.language);
const voiceAge = stringField(detail.character.definition.voiceAge);
const timbre = stringField(detail.character.definition.voiceTimbre) || stringField(voice.profile.timbre);
const deliveryInstructions = stringField(voice.instructions);
const sampleResourceId = stringField(voice.profile.sampleResourceId);
return [{
assetId: detail.asset.id,
versionId: detail.character.versionId,
characterName: detail.asset.title,
voiceKey: stringField(voice.profile.voiceKey),
sampleResourceId: sampleResourceId || undefined,
language: language || undefined,
voiceAge: voiceAge || undefined,
timbre: timbre || undefined,
deliveryInstructions: deliveryInstructions || undefined,
instructions: [language && `语言与口音:${language}`, voiceAge && `声音年龄感:${voiceAge}`, timbre && `音色气质:${timbre}`, deliveryInstructions].filter(Boolean).join(";"),
}];
});
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => resourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
const voiceSamples: ResolvedCharacterVoice[] = [];
// 视频模型接收声音样本;独立配音任务仍通过 voiceKey 选音色,不能把两种协议混用。
if (mode === "video" && includeCharacterVoiceSamples) {
resolvedCharacterVoices.forEach((voice) => {
if (!voice.sampleResourceId || usedAudioResourceIds.has(voice.sampleResourceId)) return;
usedAudioResourceIds.add(voice.sampleResourceId);
voiceSamples.push(voice);
});
}
const maxAudios = referenceLimits?.maxAudios ?? 3;
if (context.referenceAudios.length + voiceSamples.length > maxAudios) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`);
const characterVoiceAudios = voiceSamples.map((voice) => ({
id: `character-voice-${voice.assetId}`,
name: `${voice.characterName}-声音样本.mp3`,
type: "audio/mpeg",
url: resourceFileUrl(voice.sampleResourceId!),
storageKey: resourceStorageKey(voice.sampleResourceId!),
} satisfies ReferenceAudio));
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.map(compileResolvedVoicePrompt) : [];
return {
...context,
prompt: includeCharacterPrompt ? [context.prompt.trim(), ...characterBlocks, ...voiceBlocks].filter(Boolean).join("\n\n") : context.prompt,
referenceImages,
referenceAudios,
resolvedCharacterVersions,
resolvedCharacterVoices,
imageCount: referenceImages.length,
audioCount: referenceAudios.length,
};
}
function readNodeTextInput(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
if (node.type === CanvasNodeType.Skill) return readSkillInput(node);
return node.metadata?.prompt || "";
}
function readCharacterReference(node: CanvasNodeData): CharacterGenerationReference | null {
const assetId = node.metadata?.workflowKind === "character" ? node.metadata.characterAssetId?.trim() : "";
return assetId ? { nodeId: node.id, assetId, requestedVersionId: node.metadata?.characterVersionPolicy === "pinned" ? node.metadata.characterVersionId : undefined } : null;
}
function preferredCharacterRepresentation(representations: Array<{ id: string; resourceId: string; role: string }>) {
return ["turnaround_sheet", "primary", "front", "side", "back"].map((role) => representations.find((item) => item.role === role)).find(Boolean);
}
function compileResolvedVoicePrompt(voice: ResolvedCharacterVoice) {
return [
`【角色声音:${voice.characterName}】`,
voice.language && `语言与口音:${voice.language}`,
voice.voiceAge && `声音年龄感:${voice.voiceAge}`,
voice.timbre && `音色气质:${voice.timbre}`,
voice.deliveryInstructions && `表演与朗读要求:${voice.deliveryInstructions}`,
].filter(Boolean).join("\n");
}
function stringField(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function readSkillInput(node: CanvasNodeData) {
const skill = node.metadata?.skillSnapshot;
if (!skill) return node.metadata?.content || "";
return [
`【技能:${skill.name}】`,
skill.description ? `用途:${skill.description}` : "",
`执行模板:\n${skill.template}`,
skill.outputContract ? `输出约束:\n${skill.outputContract}` : "",
"请严格执行该技能,只输出结果,不要输出解释性套话。",
]
.filter(Boolean)
.join("\n\n");
}
function generationLabel(type: NodeGenerationInput["type"] | "drawing", index: number) {
if (type === "character") return `角色${index + 1}`;
if (type === "drawing") return `绘图${index + 1}`;
if (type === "image") return imageReferenceLabel(index);
if (type === "video") return seedanceReferenceLabel("video", index);
if (type === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`;
}
function readReferenceImage(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]): ReferenceImage | null {
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) {
return {
id: node.id,
name: node.title || `绘图-${node.id}`,
type: "image/png",
dataUrl: "",
source: {
kind: "drawing",
drawingId: node.metadata.drawingId,
revision: node.metadata.drawingRevision || 0,
shapeCount: node.metadata.drawingShapeCount || 0,
},
};
}
if (node.type === CanvasNodeType.ColorGrade) {
// 调色节点自己不存图:源图在上游,参数在 metadata。没连线就跳过这条输入,
// 而不是抛错——画布上放一个还没连线的调色节点是很正常的中间状态。
// 判据与节点渲染保持一致(都要求上游是带 content 的图片),
// 否则会出现「预览里看到了、生成时却没用上」这类不报错的偏差。
const source = getContextResourceNodes(node.id, nodes, connections).find((item) => getNodeResourceKind(item) === "image" && item.metadata?.content);
const url = source?.metadata?.content;
if (!source || !url) return null;
const grade = node.metadata?.colorGrade;
// 未调色时直接透传源图,省掉一次渲染与上传(也就不占文件容量)。
if (!grade || isNeutralColorGrade(grade)) {
const passthrough = nodeReferenceImage(source);
return passthrough ? { ...passthrough, id: node.id, name: node.title || `调色-${node.id}` } : null;
}
return {
id: node.id,
name: node.title || `调色-${node.id}`,
type: "image/png",
dataUrl: "",
source: { kind: "colorgrade", url, grade },
};
}
return nodeReferenceImage(node);
}
function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
if (node.type !== CanvasNodeType.Video || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp4`,
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content || "",
storageKey: node.metadata.storageKey,
bytes: node.metadata.bytes,
width: node.metadata.naturalWidth,
height: node.metadata.naturalHeight,
durationMs: node.metadata.durationMs,
};
}
function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
if (node.type !== CanvasNodeType.Audio || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp3`,
type: node.metadata.mimeType || "audio/mpeg",
url: node.metadata.content || "",
storageKey: node.metadata.storageKey,
bytes: node.metadata.bytes,
durationMs: node.metadata.durationMs,
};
}