448 lines
25 KiB
TypeScript
448 lines
25 KiB
TypeScript
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
|
||
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||
import { normalizeStoryboardAssetBindings } from "@/lib/canvas/canvas-storyboard-assets";
|
||
import { bindingForConnectedNode, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer";
|
||
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
|
||
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||
import type { GenerationTask } from "@/services/api/task-center";
|
||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||
|
||
const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1";
|
||
|
||
export function readCanvasWorkspaceMode(): CanvasWorkspaceMode {
|
||
if (typeof window === "undefined") return "professional";
|
||
try {
|
||
return scopedLocalStorage.getItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY) === "simple" ? "simple" : "professional";
|
||
} catch (error) {
|
||
console.warn("读取画布工作模式失败,已使用专业模式", error);
|
||
return "professional";
|
||
}
|
||
}
|
||
|
||
export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) {
|
||
try {
|
||
scopedLocalStorage.setItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY, mode);
|
||
} catch (error) {
|
||
console.warn("保存画布工作模式失败", error);
|
||
}
|
||
}
|
||
|
||
|
||
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||
const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined;
|
||
const pluginDefinition = getNodeDefinition(type);
|
||
const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined);
|
||
if (!spec) throw new Error(`未注册的画布节点类型:${type}`);
|
||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||
|
||
return {
|
||
id,
|
||
type,
|
||
title: spec.title,
|
||
position: {
|
||
x: position.x - spec.width / 2,
|
||
y: position.y - spec.height / 2,
|
||
},
|
||
width: spec.width,
|
||
height: spec.height,
|
||
metadata: type === CanvasNodeType.Script
|
||
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"], referenceNodeIds: [] } }
|
||
: { ...spec.metadata, ...metadata, ...(type === CanvasNodeType.Drawing ? { drawingId: metadata?.drawingId || `${id}-document` } : {}) },
|
||
};
|
||
}
|
||
|
||
export function createStoryboardRow(shotNumber: number, patch: Partial<StoryboardRow> = {}): StoryboardRow {
|
||
return {
|
||
id: `shot-${Date.now()}-${shotNumber}-${Math.random().toString(36).slice(2, 6)}`,
|
||
shotNumber,
|
||
durationSeconds: 6,
|
||
plotDescription: "",
|
||
dialogue: "",
|
||
characters: [],
|
||
narrativeIntent: "",
|
||
viewerPOV: "",
|
||
performanceBlocking: "",
|
||
shotSize: "",
|
||
emotion: "",
|
||
lightingAndAtmosphere: "",
|
||
audioEffects: "",
|
||
camera: "",
|
||
motion: "",
|
||
timeBeats: "",
|
||
imageGenerationPrompt: "",
|
||
videoMotionPrompt: "",
|
||
mustHave: [],
|
||
optionalDetails: [],
|
||
continuityOut: "",
|
||
negativePrompt: "",
|
||
assetBindings: [],
|
||
status: "idle",
|
||
...patch,
|
||
};
|
||
}
|
||
|
||
// 有结构化变量时由服务端按最新平台模板和用户偏好编译;变量被清除表示用户已做镜头级手动覆盖。
|
||
export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "image" | "video"): Pick<CanvasNodeMetadata, "promptTemplateOperation" | "promptTemplateVariables"> {
|
||
const variables = kind === "image" ? row.imagePromptTemplateVariables : row.videoPromptTemplateVariables;
|
||
return variables
|
||
? { promptTemplateOperation: kind === "image" ? "storyboard_first_frame" : "storyboard_video", promptTemplateVariables: variables }
|
||
: { promptTemplateOperation: undefined, promptTemplateVariables: undefined };
|
||
}
|
||
|
||
export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] {
|
||
return Array.from(new Set([
|
||
"shotNumber",
|
||
"durationSeconds",
|
||
"videoMotionPrompt",
|
||
"dialogue",
|
||
"assets",
|
||
...(columns || []),
|
||
"shotSize",
|
||
"narrativeIntent",
|
||
"viewerPOV",
|
||
"performanceBlocking",
|
||
"camera",
|
||
"motion",
|
||
"timeBeats",
|
||
"lightingAndAtmosphere",
|
||
"continuityOut",
|
||
"negativePrompt",
|
||
])) as StoryboardColumn[];
|
||
}
|
||
|
||
export function storyboardRowsFromTask(task: GenerationTask) {
|
||
const result = JSON.parse(task.resultJson || "{}") as { title?: string; rows?: Array<Partial<StoryboardRow>> };
|
||
if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行");
|
||
return {
|
||
title: result.title?.trim(),
|
||
rows: result.rows.map((row, index) => {
|
||
const next = createStoryboardRow(index + 1, {
|
||
...row,
|
||
id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`,
|
||
shotNumber: index + 1,
|
||
status: "idle",
|
||
assetBindings: normalizeStoryboardAssetBindings(row.assetBindings),
|
||
});
|
||
next.characters = Array.isArray(row.characters) ? row.characters : [];
|
||
next.mustHave = Array.isArray(row.mustHave) ? row.mustHave : [];
|
||
next.optionalDetails = Array.isArray(row.optionalDetails) ? row.optionalDetails : [];
|
||
return next;
|
||
}),
|
||
};
|
||
}
|
||
|
||
|
||
// 模型切换时必须清理的节点级生成参数:这些参数属于旧模型的能力档位(分辨率/宽高比/质量等),
|
||
// 新模型不支持时若残留,会在 buildNodeConfig 的「节点优先、全局兜底」合并中反复叠加(issue #254)。
|
||
const NODE_MODEL_GENERATION_PARAMS: ReadonlyArray<keyof CanvasNodeMetadata> = [
|
||
"size",
|
||
"quality",
|
||
"transparentBackground",
|
||
"count",
|
||
"seconds",
|
||
"vquality",
|
||
"generateAudio",
|
||
"watermark",
|
||
"audioVoice",
|
||
"audioFormat",
|
||
"audioSpeed",
|
||
"audioInstructions",
|
||
];
|
||
|
||
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeMetadata>) {
|
||
const safePatch = patch || {};
|
||
const nextPatch = resetGenerationParamsOnModelSwitch(node, safePatch);
|
||
const next = { ...node, metadata: { ...node.metadata, ...nextPatch } };
|
||
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null;
|
||
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
||
}
|
||
|
||
// 切换模型(不同模型标识)时,节点级生成参数必须随旧模型一起失效,回落全局配置;
|
||
// 显式传入的同批 patch(如用户同时调整了参数)仍然优先。
|
||
function resetGenerationParamsOnModelSwitch(node: CanvasNodeData, patch: Partial<CanvasNodeMetadata>): Partial<CanvasNodeMetadata> {
|
||
if (typeof patch.model !== "string" || patch.model === node.metadata?.model) {
|
||
return patch;
|
||
}
|
||
const reset: Partial<CanvasNodeMetadata> = {};
|
||
for (const key of NODE_MODEL_GENERATION_PARAMS) {
|
||
reset[key] = undefined;
|
||
}
|
||
return { ...reset, ...patch };
|
||
}
|
||
|
||
/**
|
||
* 连线落到目标节点上的吸附点。单端口一侧取边的正中——与 connectionHandleY 保持同一个
|
||
* 口径,否则吸附点和实际画出来的线会对不上(这两处是同一规则的两份实现,改一处必错)。
|
||
*/
|
||
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0) {
|
||
return {
|
||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||
y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
|
||
};
|
||
}
|
||
|
||
export function storyboardHandleAtY(node: CanvasNodeData, worldY: number, scrollTop = 0) {
|
||
const rows = node.metadata?.storyboard?.rows || [];
|
||
const localY = worldY - node.position.y - STORYBOARD_HEADER_HEIGHT;
|
||
const tableHeight = storyboardTableHeight(node.height, node.metadata?.storyboardComposerHeight);
|
||
if (rows.length && localY >= 0 && localY <= tableHeight) {
|
||
const index = Math.max(0, Math.min(rows.length - 1, Math.floor((localY + scrollTop) / STORYBOARD_ROW_HEIGHT)));
|
||
return `row:${rows[index].id}`;
|
||
}
|
||
const composerTop = node.height - (node.metadata?.storyboardComposerHeight || 104);
|
||
if (worldY >= node.position.y + composerTop && worldY <= node.position.y + node.height) return "storyboard:context";
|
||
return undefined;
|
||
}
|
||
|
||
function storyboardHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
|
||
if (node.type !== CanvasNodeType.Script) return undefined;
|
||
if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2;
|
||
if (!handleId?.startsWith("row:")) return undefined;
|
||
const rowId = handleId.slice(4);
|
||
const index = (node.metadata?.storyboard?.rows || []).findIndex((row) => row.id === rowId);
|
||
if (index < 0) return undefined;
|
||
const tableHeight = storyboardTableHeight(node.height, node.metadata?.storyboardComposerHeight);
|
||
return node.position.y + STORYBOARD_HEADER_HEIGHT + clamp(index * STORYBOARD_ROW_HEIGHT + STORYBOARD_ROW_HEIGHT / 2 - scrollTop, 4, tableHeight - 4);
|
||
}
|
||
|
||
export function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
|
||
const first = nodes.find((node) => node.id === firstNodeId);
|
||
const second = nodes.find((node) => node.id === secondNodeId);
|
||
if (!first || !second || first.id === second.id) return null;
|
||
if (isFrameNode(first) || isFrameNode(second)) return null;
|
||
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
|
||
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
||
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||
if (firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
||
return { fromNodeId: first.id, toNodeId: second.id };
|
||
}
|
||
|
||
export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: Pick<CanvasConnection, "fromNodeId" | "toNodeId" | "fromHandleId" | "toHandleId">) {
|
||
const fromStoryboardHandle = connection.fromHandleId?.startsWith("row:") || connection.fromHandleId === "storyboard:context";
|
||
const toStoryboardHandle = connection.toHandleId?.startsWith("row:") || connection.toHandleId === "storyboard:context";
|
||
const scriptNodeId = fromStoryboardHandle ? connection.fromNodeId : toStoryboardHandle ? connection.toNodeId : null;
|
||
const handleId = connection.fromHandleId || connection.toHandleId;
|
||
const rowId = handleId?.startsWith("row:") ? handleId.slice(4) : null;
|
||
const linkedNodeId = scriptNodeId === connection.fromNodeId ? connection.toNodeId : connection.fromNodeId;
|
||
const linkedNode = nodes.find((node) => node.id === linkedNodeId);
|
||
const scriptNode = nodes.find((node) => node.id === scriptNodeId && node.type === CanvasNodeType.Script);
|
||
if (!scriptNodeId || !linkedNode || !scriptNode) return nodes;
|
||
const row = rowId ? scriptNode.metadata?.storyboard?.rows.find((item) => item.id === rowId) : undefined;
|
||
const videoPrompt = row ? (row.videoMotionPrompt || row.plotDescription).trim() : "";
|
||
const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false), nodes) : "";
|
||
|
||
return nodes.map((node) => {
|
||
if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) {
|
||
return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoComposerContent, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } };
|
||
}
|
||
if (node.id !== scriptNodeId || node.type !== CanvasNodeType.Script) return node;
|
||
const storyboard = node.metadata?.storyboard;
|
||
const binding = bindingForConnectedNode(linkedNode);
|
||
return {
|
||
...node,
|
||
metadata: {
|
||
...node.metadata,
|
||
storyboard: {
|
||
rows: (storyboard?.rows || []).map((item) => item.id !== rowId ? item : scriptNodeId === connection.fromNodeId
|
||
? { ...item, imageNodeId: linkedNode.type === CanvasNodeType.Image ? linkedNode.id : item.imageNodeId, videoNodeId: linkedNode.type === CanvasNodeType.Video ? linkedNode.id : item.videoNodeId }
|
||
: binding && !(item.assetBindings || []).some((candidate) => candidate.nodeId === linkedNode.id)
|
||
? { ...item, assetBindings: [...(item.assetBindings || []), binding] }
|
||
: item),
|
||
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"],
|
||
referenceNodeIds: handleId === "storyboard:context" ? Array.from(new Set([...(storyboard?.referenceNodeIds || []), linkedNode.id])) : storyboard?.referenceNodeIds || [],
|
||
},
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
export function storyboardRowFromHandle(nodes: CanvasNodeData[], nodeId: string, handleId?: string) {
|
||
if (!handleId?.startsWith("row:")) return undefined;
|
||
return nodes.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script)?.metadata?.storyboard?.rows.find((row) => `row:${row.id}` === handleId);
|
||
}
|
||
|
||
export function expandStoryboardTextMentions(prompt: string, references: CanvasResourceReference[]) {
|
||
let expanded = prompt;
|
||
references.filter((reference) => reference.active && reference.kind === "text" && reference.text?.trim()).forEach((reference) => {
|
||
const replacement = `【项目设定:${reference.title}】\n${reference.text!.trim()}`;
|
||
for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`, reference.nodeId ? `@[node:${reference.nodeId}]` : ""]) {
|
||
if (!token) continue;
|
||
if (expanded.includes(token)) expanded = expanded.split(token).join(replacement);
|
||
}
|
||
});
|
||
return expanded;
|
||
}
|
||
|
||
export function getInputSummary(inputs: NodeGenerationInput[]) {
|
||
return {
|
||
textCount: inputs.filter((input) => input.type === "text").length,
|
||
imageCount: inputs.filter((input) => input.type === "image").length,
|
||
videoCount: inputs.filter((input) => input.type === "video").length,
|
||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||
characterCount: inputs.filter((input) => input.type === "character").length,
|
||
};
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
export type NodeAlignmentContext = {
|
||
movingBounds: { left: number; top: number; right: number; bottom: number };
|
||
targets: Array<{ x: number[]; y: number[] }>;
|
||
};
|
||
|
||
export function createNodeAlignmentContext(nodes: CanvasNodeData[], initialPositions: Array<{ id: string; x: number; y: number }>): NodeAlignmentContext | null {
|
||
const movingIds = new Set(initialPositions.map((item) => item.id));
|
||
const initialById = new Map(initialPositions.map((item) => [item.id, item]));
|
||
const movingNodes = nodes.filter((node) => movingIds.has(node.id));
|
||
if (!movingNodes.length) return null;
|
||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||
const left = Math.min(...movingNodes.map((node) => initialById.get(node.id)?.x ?? node.position.x));
|
||
const top = Math.min(...movingNodes.map((node) => initialById.get(node.id)?.y ?? node.position.y));
|
||
const right = Math.max(...movingNodes.map((node) => (initialById.get(node.id)?.x ?? node.position.x) + node.width));
|
||
const bottom = Math.max(...movingNodes.map((node) => (initialById.get(node.id)?.y ?? node.position.y) + node.height));
|
||
const targets = nodes.flatMap((node) => {
|
||
if (movingIds.has(node.id)) return [];
|
||
const batchRoot = node.metadata?.batchRootId ? nodeById.get(node.metadata.batchRootId) : null;
|
||
if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) return [];
|
||
const parent = node.parentId ? nodeById.get(node.parentId) : null;
|
||
if (parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed) return [];
|
||
return [{
|
||
x: [node.position.x, node.position.x + node.width / 2, node.position.x + node.width],
|
||
y: [node.position.y, node.position.y + node.height / 2, node.position.y + node.height],
|
||
}];
|
||
});
|
||
return { movingBounds: { left, top, right, bottom }, targets };
|
||
}
|
||
|
||
export function calculateNodeAlignment(context: NodeAlignmentContext | null, rawOffset: Position, threshold: number) {
|
||
if (!context) return { offset: rawOffset, guides: {} as { vertical?: number; horizontal?: number } };
|
||
const { left, top, right, bottom } = context.movingBounds;
|
||
const movingX = [left + rawOffset.x, (left + right) / 2 + rawOffset.x, right + rawOffset.x];
|
||
const movingY = [top + rawOffset.y, (top + bottom) / 2 + rawOffset.y, bottom + rawOffset.y];
|
||
let bestXDelta: number | undefined;
|
||
let bestXGuide: number | undefined;
|
||
let bestYDelta: number | undefined;
|
||
let bestYGuide: number | undefined;
|
||
context.targets.forEach(({ x: targetsX, y: targetsY }) => {
|
||
movingX.forEach((value, anchorIndex) => {
|
||
const target = targetsX[anchorIndex];
|
||
const delta = target - value;
|
||
if (Math.abs(delta) <= threshold && (bestXDelta === undefined || Math.abs(delta) < Math.abs(bestXDelta))) {
|
||
bestXDelta = delta;
|
||
bestXGuide = target;
|
||
}
|
||
});
|
||
movingY.forEach((value, anchorIndex) => {
|
||
const target = targetsY[anchorIndex];
|
||
const delta = target - value;
|
||
if (Math.abs(delta) <= threshold && (bestYDelta === undefined || Math.abs(delta) < Math.abs(bestYDelta))) {
|
||
bestYDelta = delta;
|
||
bestYGuide = target;
|
||
}
|
||
});
|
||
});
|
||
return {
|
||
offset: { x: rawOffset.x + (bestXDelta || 0), y: rawOffset.y + (bestYDelta || 0) },
|
||
guides: { vertical: bestXGuide, horizontal: bestYGuide },
|
||
};
|
||
}
|
||
|
||
|
||
export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
|
||
const rootId = node.metadata?.batchRootId;
|
||
if (!rootId) return false;
|
||
const root = nodes.find((item) => item.id === rootId);
|
||
if (root && collapsingBatchIds?.has(rootId)) return false;
|
||
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
||
}
|
||
|
||
export function sameStringSet(left: Set<string>, right: Set<string>) {
|
||
if (left.size !== right.size) return false;
|
||
for (const value of left) if (!right.has(value)) return false;
|
||
return true;
|
||
}
|
||
|
||
export function sameNodeSemanticData(left: CanvasNodeData, right: CanvasNodeData) {
|
||
return left.id === right.id && left.type === right.type && left.title === right.title && left.parentId === right.parentId && left.width === right.width && left.height === right.height && left.metadata === right.metadata;
|
||
}
|
||
|
||
export function applyBatchPrimaryImage(root: CanvasNodeData, primary: CanvasNodeData): CanvasNodeData {
|
||
// 主图是根节点对媒体的完整代理;可选字段也必须显式覆盖,避免残留上一张图的存储身份。
|
||
return {
|
||
...root,
|
||
width: primary.width,
|
||
height: primary.height,
|
||
metadata: {
|
||
...root.metadata,
|
||
primaryImageId: primary.id,
|
||
content: primary.metadata?.content,
|
||
storageKey: primary.metadata?.storageKey,
|
||
status: primary.metadata?.status,
|
||
naturalWidth: primary.metadata?.naturalWidth,
|
||
naturalHeight: primary.metadata?.naturalHeight,
|
||
bytes: primary.metadata?.bytes,
|
||
mimeType: primary.metadata?.mimeType,
|
||
errorDetails: primary.metadata?.errorDetails,
|
||
generationErrorCode: primary.metadata?.generationErrorCode,
|
||
failedPromptFingerprint: primary.metadata?.failedPromptFingerprint,
|
||
freeResize: primary.metadata?.freeResize,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<string>) {
|
||
const removedIds = new Set(requestedIds);
|
||
nodes.forEach((node) => {
|
||
if (requestedIds.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => removedIds.add(childId));
|
||
});
|
||
const remainingNodes = nodes.filter((node) => !removedIds.has(node.id));
|
||
const nextNodes = remainingNodes.map((node) => {
|
||
const detached = node.parentId && removedIds.has(node.parentId) ? { ...node, parentId: undefined } : node;
|
||
const storyboard = detached.metadata?.storyboard;
|
||
const cleaned = storyboard
|
||
? {
|
||
...detached,
|
||
metadata: {
|
||
...detached.metadata,
|
||
storyboard: {
|
||
...storyboard,
|
||
referenceNodeIds: storyboard.referenceNodeIds.filter((id) => !removedIds.has(id)),
|
||
rows: storyboard.rows.map((row) => ({
|
||
...row,
|
||
assetBindings: (row.assetBindings || []).filter((binding) => !removedIds.has(binding.nodeId)),
|
||
imageNodeId: row.imageNodeId && !removedIds.has(row.imageNodeId) ? row.imageNodeId : undefined,
|
||
videoNodeId: row.videoNodeId && !removedIds.has(row.videoNodeId) ? row.videoNodeId : undefined,
|
||
})),
|
||
},
|
||
},
|
||
}
|
||
: detached;
|
||
const childIds = cleaned.metadata?.batchChildIds?.filter((childId) => !removedIds.has(childId));
|
||
if (!cleaned.metadata?.isBatchRoot || childIds?.length === cleaned.metadata.batchChildIds?.length) return cleaned;
|
||
const primaryImageId = childIds?.includes(cleaned.metadata.primaryImageId || "") ? cleaned.metadata.primaryImageId : childIds?.[0];
|
||
const primaryNode = remainingNodes.find((item) => item.id === primaryImageId);
|
||
const batchRoot = { ...cleaned, metadata: { ...cleaned.metadata, batchChildIds: childIds, primaryImageId } };
|
||
return primaryNode ? applyBatchPrimaryImage(batchRoot, primaryNode) : batchRoot;
|
||
});
|
||
return { removedIds, nodes: nextNodes };
|
||
}
|
||
|
||
export function buildAngleLabel(params: CanvasImageAngleParams) {
|
||
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`;
|
||
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`;
|
||
return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`;
|
||
}
|
||
|
||
export function buildAnglePrompt(params: CanvasImageAngleParams) {
|
||
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`;
|
||
}
|