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:
87
web/src/lib/canvas/canvas-color-grade.ts
Normal file
87
web/src/lib/canvas/canvas-color-grade.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
/** 调色参数,单位与 CSS filter 一致(百分比 / 度) */
|
||||
export type CanvasColorGrade = {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturate: number;
|
||||
hueRotate: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_COLOR_GRADE: CanvasColorGrade = { brightness: 100, contrast: 100, saturate: 100, hueRotate: 0 };
|
||||
|
||||
export function isNeutralColorGrade(grade: CanvasColorGrade) {
|
||||
return grade.brightness === 100 && grade.contrast === 100 && grade.saturate === 100 && grade.hueRotate === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览(img 的 CSS filter)与导出(canvas 的 ctx.filter)共用同一个字符串。
|
||||
* 两处各写一份的话,用户看到的和生成用的就会悄悄不一致。
|
||||
*/
|
||||
export function colorGradeCssFilter(grade: CanvasColorGrade) {
|
||||
return `brightness(${grade.brightness}%) contrast(${grade.contrast}%) saturate(${grade.saturate}%) hue-rotate(${grade.hueRotate}deg)`;
|
||||
}
|
||||
|
||||
// 同一张图 + 同一组参数在一次会话内只上传一次。
|
||||
// 不做跨会话缓存:那需要往节点 metadata 回写,而构建参考图的路径拿不到写入通道。
|
||||
const publishedCache = new Map<string, { url: string; storageKey: string; type: string }>();
|
||||
|
||||
function cacheKey(url: string, grade: CanvasColorGrade) {
|
||||
return `${url}|${grade.brightness}|${grade.contrast}|${grade.saturate}|${grade.hueRotate}`;
|
||||
}
|
||||
|
||||
async function renderGradedBlob(url: string, grade: CanvasColorGrade) {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("源图无法读取(可能不允许跨域)"));
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("无法创建画布上下文");
|
||||
// ctx.filter 在少数浏览器上不存在——那会静默导出一张未调色的图,
|
||||
// 与用户看到的预览不一致。宁可明确失败。
|
||||
if (!("filter" in ctx)) throw new Error("当前浏览器不支持导出调色结果,请换用 Chrome / Edge");
|
||||
ctx.filter = colorGradeCssFilter(grade);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!blob) throw new Error("调色结果导出失败");
|
||||
return { blob, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把调色节点的参考图落地成真实资源——照 resolveCanvasDrawingReference 的做法:
|
||||
* 本地渲染 → 上传 → 换成 storageKey/url,让下游按普通图片消费。
|
||||
*
|
||||
* 只在生成真正要用它时才调用(见 canvas-node-generation),所以拖滑杆调参数不会产生上传。
|
||||
*/
|
||||
export async function resolveCanvasColorGradeReference(image: ReferenceImage): Promise<ReferenceImage> {
|
||||
const source = image.source;
|
||||
if (!source || source.kind !== "colorgrade") return image;
|
||||
|
||||
const cached = publishedCache.get(cacheKey(source.url, source.grade));
|
||||
if (cached) return { ...image, dataUrl: "", url: cached.url, storageKey: cached.storageKey, type: cached.type };
|
||||
|
||||
try {
|
||||
const render = await renderGradedBlob(source.url, source.grade);
|
||||
const resource = await uploadResourceFile(render.blob, "image", {
|
||||
width: render.width,
|
||||
height: render.height,
|
||||
fileName: `colorgrade-${image.id}.png`,
|
||||
});
|
||||
const storageKey = resourceStorageKey(resource.id);
|
||||
const url = resource.publicUrl || resourceFileUrl(resource.id);
|
||||
const type = resource.mimeType || "image/png";
|
||||
publishedCache.set(cacheKey(source.url, source.grade), { url, storageKey, type });
|
||||
return { ...image, dataUrl: "", url, storageKey, type };
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? `调色参考图生成失败:${error.message}` : "调色参考图生成失败");
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,11 @@ export function collectUpstreamVideoNodes(nodeId: string, nodes: CanvasNodeData[
|
||||
return result;
|
||||
}
|
||||
|
||||
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
/**
|
||||
* 该节点的直接上游素材节点(按连线取 fromNodeId,只保留构成素材的)。
|
||||
* 扩展节点经 CanvasNodeGraphContext 复用它,不要另写一份取上游的逻辑。
|
||||
*/
|
||||
export function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
return connections
|
||||
.filter((connection) => connection.toNodeId === nodeId)
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clapperboard, Image as ImageIcon, Music2, PanelTop, Pencil, Settings2, Sparkles, Type, Video } from "lucide-react";
|
||||
import { ChartColumn, Clapperboard, Code, Columns2, FileText, Globe, Image as ImageIcon, Music2, PanelTop, Palette, Pencil, Settings2, Shapes, Sparkles, Type, Video } from "lucide-react";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
@@ -105,6 +105,67 @@ const BUILTIN_NODE_TRAITS = {
|
||||
showInCreateMenu: true,
|
||||
// 背板只是视觉容器,既不是素材也不参与计数。
|
||||
},
|
||||
[CanvasNodeType.Markdown]: {
|
||||
label: "Markdown",
|
||||
icon: <FileText />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 渲染的是 Markdown 源码,作为素材与输入都按文本计。
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "text" : null),
|
||||
inputKind: "text",
|
||||
// 不设 generationMode:它是展示节点,自身不发起生成。
|
||||
},
|
||||
[CanvasNodeType.Svg]: {
|
||||
label: "SVG",
|
||||
icon: <Shapes />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 内容是 SVG 源码,按文本素材计;渲染成图但不产出图片资源。
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "text" : null),
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.Html]: {
|
||||
label: "HTML",
|
||||
icon: <Code />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "text" : null),
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.Panorama]: {
|
||||
label: "全景",
|
||||
icon: <Globe />,
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 查看器:消费上游图片,自身不作为素材被引用,故不设 resourceKind。
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.Compare]: {
|
||||
label: "对比",
|
||||
icon: <Columns2 />,
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 只看不产出:消费两张上游图片,自身不作为素材。
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.Chart]: {
|
||||
label: "图表",
|
||||
icon: <ChartColumn />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
label: "调色",
|
||||
icon: <Palette />,
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 无条件返回 image:它的图来自上游而不是自身 metadata,用「有没有 content」判断
|
||||
// 会让它永远不被当成素材、从而进不了生成输入。没有上游时由
|
||||
// readReferenceImage 返回 null 跳过,不需要在这里提前判空。
|
||||
resourceKind: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
|
||||
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
|
||||
|
||||
@@ -9,6 +9,14 @@ function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" |
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "node", ...rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展节点命令:落在独立的「展示与加工」分区,不挤占上方调好的四列创作网格。
|
||||
* 创建逻辑一律是 createNode(type),所以统一走 onAddExtensionNode 而不是各开一个 handler。
|
||||
*/
|
||||
function extensionCommand(type: CanvasNodeType, defaultOrder: number): AddNodeMenuCommand {
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "extension", defaultOrder, run: (ctx) => ctx.handlers.onAddExtensionNode(type) };
|
||||
}
|
||||
|
||||
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
|
||||
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
|
||||
@@ -23,6 +31,14 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
// 展示与加工(扩展节点)
|
||||
extensionCommand(CanvasNodeType.Markdown, 10),
|
||||
extensionCommand(CanvasNodeType.Svg, 20),
|
||||
extensionCommand(CanvasNodeType.Html, 30),
|
||||
extensionCommand(CanvasNodeType.Panorama, 40),
|
||||
extensionCommand(CanvasNodeType.Compare, 50),
|
||||
extensionCommand(CanvasNodeType.Chart, 60),
|
||||
extensionCommand(CanvasNodeType.ColorGrade, 70),
|
||||
// 导入资源
|
||||
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
|
||||
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
|
||||
|
||||
@@ -38,6 +38,12 @@ export type ToolbarHandlers = {
|
||||
onAddFrame: () => void;
|
||||
onAddFolder: () => void;
|
||||
onAddDrawing: () => void;
|
||||
/**
|
||||
* 扩展节点(Markdown / SVG / HTML / 全景 / 对比 / 图表 / 调色)统一走这一个入口。
|
||||
* 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀,
|
||||
* 而它们的创建逻辑完全一致(都只是 createNode(type))。
|
||||
*/
|
||||
onAddExtensionNode: (type: CanvasNodeType) => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: () => void;
|
||||
// 主工具栏——资源
|
||||
@@ -134,6 +140,7 @@ export type AddNodeMenuContext = {
|
||||
| "onAddFrame"
|
||||
| "onAddFolder"
|
||||
| "onAddDrawing"
|
||||
| "onAddExtensionNode"
|
||||
| "onChooseStyle"
|
||||
| "onOpenDirector"
|
||||
| "onUpload"
|
||||
@@ -173,7 +180,8 @@ export type AddNodeMenuCommand = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
badge?: string;
|
||||
section: "node" | "project" | "resource";
|
||||
// extension:展示与加工类扩展节点。单独一区,避免挤散 node 区调好的四列网格。
|
||||
section: "node" | "extension" | "project" | "resource";
|
||||
defaultOrder: number;
|
||||
applicable?: (ctx: AddNodeMenuContext) => boolean;
|
||||
run: (ctx: AddNodeMenuContext) => void;
|
||||
|
||||
Reference in New Issue
Block a user