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

702 lines
38 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, getMentionResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade";
import { getNodeGenerationMode, getNodeResourceKind } from "@/lib/canvas/node-registry";
import { mediaConversionSourceFingerprint } from "@/lib/media-conversion/contracts";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt, normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { audioFileExtension } from "@/lib/character-voice-formats";
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;
previewUrl?: 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 pendingConversion = findPendingMediaConversionInput(nodeId, nodes, connections);
if (pendingConversion) {
const status = pendingConversion.metadata?.mediaConversion?.status;
const reason = status === "stale" ? "输入或方式已变化" : status === "processing" ? "仍在处理中" : "尚未完成本地转换";
throw new Error(`转换节点「${pendingConversion.title || pendingConversion.id}」${reason},完成后才能执行下游生成`);
}
const connectedInputs = withArkAssetReferenceInputs(buildNodeGenerationInputs(nodeId, nodes, connections), nodes, assets);
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 }] : [];
})()
: [];
// 显式 @ 引用必须与提示词面板展示的资源集合一致;默认自动输入仍只取入边,
// 避免已有图片在没有 @图片N 时被悄悄当作自身参考图。
const mentionInputs = withArkAssetReferenceInputs(mergeGenerationInputs(buildNodeMentionGenerationInputs(nodeId, nodes, connections), portraitTextureInput, buildAssetGenerationInputs(assets)), nodes, assets);
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
assertResolvableGenerationMentions(prompt, mentionInputs);
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, mentionInputs);
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
const hasConnectedMedia = connectedInputs.some((input) => input.type === "image" || input.type === "video" || input.type === "audio" || input.type === "character");
// 豆包音频按连线决定纯文本、参考音频或参考图片,不能因为提示词里没有逐个 @ 就丢掉已连接素材。
const includeConnectedAudioMedia = Boolean(sourceNode && getNodeGenerationMode(sourceNode) === "audio");
if ((promptOnly && hasConnectedMedia) || (Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource || includeConnectedAudioMedia;
return buildComposerGenerationContext(
mentionInputs,
prompt,
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia) ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
promptOnly,
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia),
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,
};
}
/**
* 转换节点允许提前连到下游,但只有与当前唯一媒体输入匹配的已物化结果才能进入生成请求。
* 沿上游链路检查也覆盖“生成节点 -> 配置节点 -> 转换节点”的接法。
*/
export function findPendingMediaConversionInput(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
const visited = new Set<string>();
while (queue.length) {
const sourceId = queue.shift()!;
if (visited.has(sourceId)) continue;
visited.add(sourceId);
const source = nodesById.get(sourceId);
if (!source) continue;
if (source.type === CanvasNodeType.MediaConversion) {
const state = source.metadata?.mediaConversion;
const sourceInputs = connections
.filter((connection) => connection.toNodeId === source.id)
.map((connection) => nodesById.get(connection.fromNodeId))
.filter((input): input is CanvasNodeData => Boolean(input && (input.type === CanvasNodeType.Image || input.type === CanvasNodeType.Video)));
const sourceInput = sourceInputs[0];
const ready = state?.status === "completed"
&& Boolean(state.resultStorageKey)
&& sourceInputs.length === 1
&& Boolean(sourceInput)
&& state.sourceNodeId === sourceInput?.id
&& state.sourceFingerprint === mediaConversionSourceFingerprint(sourceInput!);
if (!ready) return source;
}
connections.filter((connection) => connection.toNodeId === sourceId).forEach((connection) => queue.push(connection.fromNodeId));
}
return null;
}
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 = normalizeGenerationNodeMentionTokens(prompt, inputs);
const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input]));
const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), 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(GENERATION_MENTION_PATTERN)) {
if (match.index === undefined) continue;
nextPrompt += normalizedPrompt.slice(lastIndex, match.index);
const input = resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById);
if (input) {
hasToken = true;
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);
// 显式 @文本 引用是用户写进输入框的内容,必须内联真实文本;promptOnly 只拦自动上游文本。
if (textBlocks.length) 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,
};
}
const GENERATION_MENTION_PATTERN = /@\[(node|asset):([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g;
export function generationInputMentionLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const entry = generationSlotEntries(inputs).find((item) => item.input.nodeId === input.nodeId);
return entry?.label || generationLabel(input.sourceKind === "drawing" ? "drawing" : input.type, 0);
}
export function normalizeGenerationNodeMentionTokens(prompt: string, inputs: NodeGenerationInput[]) {
const slots = generationSlotEntries(inputs);
const labelByNodeId = new Map(slots.map(({ input, label }) => [input.nodeId, label]));
const normalized = prompt.replace(/@\[node:([^\]]+)\]/g, (token, nodeId: string) => {
const label = labelByNodeId.get(nodeId);
return label ? `@${label}` : token;
});
return normalizeCharacterImageMentions(normalized, slots.filter(({ input }) => input.type === "image").length, slots.filter(({ input }) => input.type === "character").map(({ label }) => label));
}
function hasResolvableGenerationMention(prompt: string, inputs: NodeGenerationInput[]) {
return inspectGenerationMentions(prompt, inputs).hasResolved;
}
function assertResolvableGenerationMentions(prompt: string, inputs: NodeGenerationInput[]) {
const { unresolved } = inspectGenerationMentions(prompt, inputs);
if (!unresolved.length) return;
throw new Error(`提示词中的 ${unresolved.join("、")} 没有对应的画布资源,请重新选择引用后再生成`);
}
function inspectGenerationMentions(prompt: string, inputs: NodeGenerationInput[]) {
const normalizedPrompt = normalizeGenerationNodeMentionTokens(prompt, inputs);
const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input]));
const unresolved = new Set<string>();
let hasResolved = false;
for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) {
if (match[3]) {
const end = (match.index || 0) + match[0].length;
if (!hasMentionBoundary(normalizedPrompt, end)) continue;
}
if (resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById)) hasResolved = true;
else unresolved.add(match[0]);
}
return { hasResolved, unresolved: [...unresolved] };
}
function generationSlotEntries(inputs: NodeGenerationInput[]) {
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
return inputs.flatMap((input) => {
if (input.nodeId.startsWith("asset:")) return [];
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
const label = generationLabel(kind, counts[kind]++);
return [{ input, label, token: `@${label}` }];
});
}
function resolveGenerationMention(
prompt: string,
match: RegExpMatchArray,
slotInputByToken: Map<string, NodeGenerationInput>,
nodeInputById: Map<string, NodeGenerationInput>,
assetInputById: Map<string, NodeGenerationInput>,
) {
if (match[3]) {
const end = (match.index || 0) + match[0].length;
return hasMentionBoundary(prompt, end) ? slotInputByToken.get(match[0]) : undefined;
}
if (match[1] === "node") return nodeInputById.get(match[2]);
if (match[1] === "asset") return assetInputById.get(match[2]);
return undefined;
}
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[] {
return buildGenerationInputs(getGenerationResourceNodes(nodeId, nodes, connections), nodes, connections);
}
function buildNodeMentionGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
return buildGenerationInputs(getMentionResourceNodes(nodeId, nodes, connections), nodes, connections);
}
function buildGenerationInputs(resourceNodes: CanvasNodeData[], nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
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, previewUrl: canvasNodeVideoPreviewUrl(node), 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 mergeGenerationInputs(...groups: NodeGenerationInput[][]) {
const seen = new Set<string>();
return groups.flatMap((group) =>
group.filter((input) => {
if (seen.has(input.nodeId)) return false;
seen.add(input.nodeId);
return true;
}),
);
}
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, ...(asset.arkAssetId ? { arkAssetId: asset.arkAssetId } : {}) } }];
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, previewUrl: canvasVideoAssetPreviewUrl(asset.data.url, asset.coverUrl), 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 [];
});
}
// 图片节点绑定的素材若已录入方舟素材 ID,把 ID 附加到参考图上;是否改用 asset:// 引用
// 由 generation-task 按视频渠道决定,非方舟渠道与本地回退路径不受影响。
function withArkAssetReferenceInputs(inputs: NodeGenerationInput[], nodes: CanvasNodeData[], assets: Asset[]): NodeGenerationInput[] {
if (!assets.length) return inputs;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
return inputs.map((input) => {
if (input.type !== "image" || !input.image || input.image.arkAssetId) return input;
const boundAssetId = nodeById.get(input.nodeId)?.metadata?.assetId;
const arkAssetId = boundAssetId ? assetById.get(boundAssetId)?.arkAssetId : undefined;
return arkAssetId ? { ...input, image: { ...input.image, arkAssetId } } : input;
});
}
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 };
const { getCharacter } = await import("@/services/api/projects");
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(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 = await Promise.all(voiceSamples.map(async (voice) => {
const resource = await getResource(voice.sampleResourceId!);
const extension = audioFileExtension(resource.mimeType, resource.objectKey);
return {
id: `character-voice-${voice.assetId}`,
name: `${voice.characterName}-声音样本.${extension}`,
type: resource.mimeType || "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,
};
}