feat(canvas): 扩展节点 - 新增 7 种展示与加工节点并补齐上游读取基建

在已落地的节点注册表上加两层节点:展示层(Markdown / SVG / HTML / 全景)与
接入生成链的加工层(对比 / 图表 / 调色)。全部是第一方内置节点,不引入远程插件
加载,因此不涉及沙箱信任模型,也不需要把 CanvasNodeType 放开成任意字符串。

刻意保留穷尽性检查而不是改成运行时注册。全库只有 4 张按类型建的穷眉表
(NODE_DEFAULT_SIZE / NODE_SPECS / 注册表定义 / nodeContentRenderers),
让编译器强制每个新节点填满它们,比少改几个文件更值 —— 加第一个枚举成员时
编译器点出了 5 处,其中 pages/canvas/shared.tsx 是原先没编目的那一处;
换成运行时注册,那里会静默回落成「未知节点」。

两处前置基建:
- CanvasNodeGraphContext:渲染器只拿到 node、没有 connections,而加工层三个节点
  都要读上游。仿已有的 canvas-node-action-context(同一个问题:节点经
  CanvasProjectWorldLayers 渲染不便逐个透传),取上游复用
  canvas-resource-references 的 getContextResourceNodes,不另写一份。
  Provider 值必须 memo —— 每帧新对象会让所有节点跟着重渲染。
- ToolbarHandlers.onAddExtensionNode(type):一个泛用入口,不给每种扩展节点单开
  一个 onAddXxx。菜单另开「展示与加工」分区,不挤占上方调好的四列创作网格。

SVG 与 HTML 统一走 sandbox iframe:依赖里没有 DOMPurify,手写 SVG 消毒不可靠
(script / on* / foreignObject)。SandboxedFrame 只暴露 allowScripts 布尔而不是
sandbox 取值,让「同时给 allow-same-origin」这条没有入口。

全景节点三个取舍:点击才激活 WebGL(浏览器对同时存在的上下文有硬上限,画布可以有
任意多个全景节点,自动激活必然在某个数量后整片黑);用原生 three 而非 fiber
(需要精确 dispose,forceContextLoss 才真正释放上下文);性能模式下只给静态预览。

调色是唯一碰生成路径的节点,照 Drawing 的先例:拖滑杆只做本地 CSS filter 预览,
落地成资源要等到生成消费它时才走 uploadResourceFile。预览与导出共用同一个
filter 字符串,两处各写一份就会「看到的和用的悄悄不一致」;未调色时直接透传源图,
省掉一次渲染与上传。ctx.filter 不受支持时明确报错而不是导出一张没调色的图。

resourceKind 与 inputKind 分开两个字段:空图片节点不是素材(不进 @ 引用),
但照样计入 imageCount。调色节点的 resourceKind 无条件返回 image —— 它的图来自
上游,按「有没有 content」判断会让它永远进不了生成输入,这是不报错的静默失效。

测试:新增 test/canvas-extension-nodes.test.ts(12 用例 / 64 断言),覆盖 7 个节点的
判据矩阵、调色参数、以及图表解析。图表解析函数刻意不抛 —— 数据来自模型输出,
非法 JSON 是常态而不是异常,抛出来会连带整个画布白屏,有 7 条断言钉这个。
测试列表是显式枚举,已把新文件加进 package.json,不加进去不会跑。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlh
2026-08-22 10:54:47 +08:00
parent f36938e372
commit 53d05d632b
26 changed files with 1128 additions and 20 deletions

View File

@@ -4,7 +4,9 @@ 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 } from "@/lib/canvas/canvas-resource-references";
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";
@@ -227,8 +229,10 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
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);
if (image) return [{ nodeId: node.id, type: "image" as const, sourceKind: image.source?.kind, title: node.title, image }];
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);
@@ -311,6 +315,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
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) };
}),
);
@@ -448,7 +453,7 @@ function generationLabel(type: NodeGenerationInput["type"] | "drawing", index: n
return `文本${index + 1}`;
}
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
function readReferenceImage(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]): ReferenceImage | null {
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) {
return {
id: node.id,
@@ -463,6 +468,29 @@ function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
},
};
}
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);
}