Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-project-domain.ts
dlh acb2cfc448 fix(canvas): 画布连线 - 端点收敛到端口中点并新增流动光影
两件事,都在连线层。

一、多条线接同一个端口时不再沿着边散开。

原因是 use-canvas-connection-controller 把**鼠标松手的 Y 位置**记成了 anchorRatio,
于是每条线各自钉在落点上。判据还是反的:真正多端口的分镜脚本传 undefined,
而左右各只有一个出入口的普通节点却按落点比例取 Y,看起来像节点有很多端口。

改成单端口一侧永远取边的正中。这条规则有**两份实现**(canvas-connections 的
connectionHandleY 与 canvas-project-domain 的 getConnectionTargetAnchor),只改一处
会让实际画出来的线和吸附点对不上,所以两处一起改。同时把 anchorRatio 参数从这两个
几何函数上摘掉,让「不按落点」写在签名里而不是悄悄丢掉一个入参 —— 摘掉之后编译器
点出了 4 处仍在传旧参数的调用,正是这么做的收益。分镜脚本的 row: 句柄不受影响。

二、连线新增光晕与流光两层。

光晕(blur 的宽描边)和流光(一小段高亮沿路径跑)**只在悬停/选中态渲染**:blur 是
filter,成本随线条数量线性上升,常态几十条线全开会明显掉帧。常态仍是原来那条极淡的
虚线,只有指向的那条亮起来,顺带让「当前在看哪条链路」更清楚。

流光周期 2.1s 与虚线流动 1.25s 刻意错拍 —— 同频会锁成一条整体平移的虚线,
看不出有光在走。keyframes 的位移量等于 dasharray 之和(16 + 118 = 134),
否则循环处会跳一帧。

新动画已加入全部三处禁用名单:prefers-reduced-motion、.no-motion,以及
[data-canvas-viewport-interacting](平移缩放期间)。第三处最关键 —— 漏掉的话拖画布时
几十条线还在跑动画,正是错题本里「画布高频重绘导致崩溃」那一类。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 12:33:10 +08:00

432 lines
23 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 { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
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 { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, 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: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(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", "plotDescription", "dialogue"], 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: "",
referenceNodeIds: [],
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([
...(columns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]),
"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",
referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [],
});
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() : "";
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: videoPrompt, ...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;
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 }
: { ...item, referenceNodeIds: Array.from(new Set([...(item.referenceNodeIds || []), linkedNode.id])) }),
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
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}`]) {
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,
referenceNodeIds: (row.referenceNodeIds || []).filter((id) => !removedIds.has(id)),
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)}。`;
}