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:
@@ -7,7 +7,7 @@
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
@@ -270,6 +270,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
|
||||
onAddFrame: () => onAddNode(CanvasNodeType.Frame),
|
||||
onAddFolder,
|
||||
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
|
||||
onAddExtensionNode: onAddNode,
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
onUpload,
|
||||
|
||||
@@ -11,7 +11,7 @@ export type CanvasCreateCommand = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
badge?: string;
|
||||
section: "node" | "project" | "resource";
|
||||
section: "node" | "extension" | "project" | "resource";
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const projectCommands = commands.filter((command) => command.section === "project");
|
||||
const nodeCommands = commands.filter((command) => command.section === "node");
|
||||
const extensionCommands = commands.filter((command) => command.section === "extension");
|
||||
const resourceCommands = commands.filter((command) => command.section === "resource");
|
||||
|
||||
return (
|
||||
@@ -44,6 +45,13 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
|
||||
<MenuSection title="创作节点" color={theme.node.muted} />
|
||||
<CanvasCreateCommandGrid commands={nodeCommands} variant="node" />
|
||||
|
||||
{extensionCommands.length ? (
|
||||
<>
|
||||
<MenuSection title="展示与加工" color={theme.node.muted} spaced />
|
||||
<CanvasCreateCommandGrid commands={extensionCommands} variant="node" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<MenuSection title="导入资源" color={theme.node.muted} spaced />
|
||||
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
// 批次子图操作条(下载/创建副本/删除)与主图位下载需要调用画布级动作,
|
||||
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
|
||||
@@ -9,6 +9,8 @@ export type CanvasNodeActionContextValue = {
|
||||
download?: (node: CanvasNodeData) => void;
|
||||
duplicate?: (node: CanvasNodeData) => void;
|
||||
deleteNode?: (node: CanvasNodeData) => void;
|
||||
/** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */
|
||||
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
|
||||
};
|
||||
|
||||
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});
|
||||
|
||||
@@ -17,6 +17,13 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { createDefaultSubtitleStyle } from "@/types/timeline";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
|
||||
import { MarkdownNodeContent } from "./nodes/markdown-node";
|
||||
import { ChartNodeContent } from "./nodes/chart-node";
|
||||
import { CompareNodeContent } from "./nodes/compare-node";
|
||||
import { ColorGradeNodeContent } from "./nodes/color-grade-node";
|
||||
import { HtmlNodeContent } from "./nodes/html-node";
|
||||
import { PanoramaNodeContent } from "./nodes/panorama-node";
|
||||
import { SvgNodeContent } from "./nodes/svg-node";
|
||||
|
||||
type CanvasNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
@@ -65,6 +72,13 @@ const nodeContentRenderers = {
|
||||
[CanvasNodeType.Audio]: AudioNodeContent,
|
||||
[CanvasNodeType.Drawing]: DrawingContent,
|
||||
[CanvasNodeType.Frame]: UnknownNodeContent,
|
||||
[CanvasNodeType.Markdown]: MarkdownNodeContent,
|
||||
[CanvasNodeType.Svg]: SvgNodeContent,
|
||||
[CanvasNodeType.Html]: HtmlNodeContent,
|
||||
[CanvasNodeType.Panorama]: PanoramaNodeContent,
|
||||
[CanvasNodeType.Compare]: CompareNodeContent,
|
||||
[CanvasNodeType.Chart]: ChartNodeContent,
|
||||
[CanvasNodeType.ColorGrade]: ColorGradeNodeContent,
|
||||
} satisfies Record<CanvasNodeType, (props: CanvasNodeContentProps) => ReactNode>;
|
||||
|
||||
function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) {
|
||||
@@ -418,6 +432,7 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
19
web/src/components/canvas/canvas-node-graph-context.ts
Normal file
19
web/src/components/canvas/canvas-node-graph-context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
// 扩展节点(对比/图表/调色等)要读自己的上游才能工作,但节点经 CanvasProjectWorldLayers
|
||||
// 渲染、CanvasNodeContentProps 里只有 node 本身,没有 nodes/connections。
|
||||
// 与 canvas-node-action-context 同一个理由:通过 Context 注入,避免改动 world-layers 的透传链。
|
||||
// 无 Provider 时静默降级为「没有上游」,节点自行显示空状态而不是崩。
|
||||
export type CanvasNodeGraphContextValue = {
|
||||
getUpstreamNodes?: (nodeId: string) => CanvasNodeData[];
|
||||
};
|
||||
|
||||
export const CanvasNodeGraphContext = createContext<CanvasNodeGraphContextValue>({});
|
||||
|
||||
/** 取该节点的直接上游素材节点;无 Provider 或无上游时返回空数组。 */
|
||||
export function useUpstreamNodes(nodeId: string) {
|
||||
const { getUpstreamNodes } = useContext(CanvasNodeGraphContext);
|
||||
return getUpstreamNodes?.(nodeId) ?? [];
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import type { CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
|
||||
export function CanvasToolbar({
|
||||
selectedCount,
|
||||
@@ -34,6 +34,7 @@ export function CanvasToolbar({
|
||||
onAddFrame,
|
||||
onAddFolder,
|
||||
onAddDrawing,
|
||||
onAddExtensionNode,
|
||||
onOpenDirector,
|
||||
onUndo,
|
||||
onRedo,
|
||||
@@ -64,6 +65,7 @@ export function CanvasToolbar({
|
||||
onAddFrame: () => void;
|
||||
onAddFolder: () => void;
|
||||
onAddDrawing: () => void;
|
||||
onAddExtensionNode: (type: CanvasNodeType) => void;
|
||||
onOpenDirector: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
@@ -126,6 +128,7 @@ export function CanvasToolbar({
|
||||
onAddFrame,
|
||||
onAddFolder,
|
||||
onAddDrawing,
|
||||
onAddExtensionNode,
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
onUpload,
|
||||
|
||||
124
web/src/components/canvas/nodes/chart-node.tsx
Normal file
124
web/src/components/canvas/nodes/chart-node.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ChartColumn } from "lucide-react";
|
||||
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ChartNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
type ChartRow = Record<string, string | number>;
|
||||
type ParsedChart = { rows: ChartRow[]; xKey: string; series: string[] };
|
||||
|
||||
/**
|
||||
* 把一段文本解析成图表数据;解析不出来返回 null,由调用方显示错误态。
|
||||
*
|
||||
* 这个函数**必须不抛**:数据来自模型输出,非法 JSON 是常态而不是异常,
|
||||
* 抛出来会连带整个画布白屏。
|
||||
*/
|
||||
export function parseChartSource(text: string): ParsedChart | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
const rows = parseJsonRows(trimmed) ?? parseCsvRows(trimmed);
|
||||
if (!rows?.length) return null;
|
||||
|
||||
const keys = Object.keys(rows[0]);
|
||||
if (!keys.length) return null;
|
||||
// 数值列作为系列,其余第一列作为横轴;全是数值时用行号当横轴。
|
||||
const series = keys.filter((key) => rows.every((row) => typeof row[key] === "number"));
|
||||
const xKey = keys.find((key) => !series.includes(key)) || "__index";
|
||||
if (!series.length) return null;
|
||||
if (xKey === "__index") rows.forEach((row, index) => { row.__index = index + 1; });
|
||||
return { rows, xKey, series };
|
||||
}
|
||||
|
||||
function parseJsonRows(text: string): ChartRow[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const rows = parsed.filter((item): item is ChartRow => Boolean(item) && typeof item === "object" && !Array.isArray(item));
|
||||
return rows.length ? rows.map(normalizeRow) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvRows(text: string): ChartRow[] | null {
|
||||
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length < 2) return null;
|
||||
const header = lines[0].split(",").map((cell) => cell.trim());
|
||||
if (header.length < 2) return null;
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = line.split(",").map((cell) => cell.trim());
|
||||
return normalizeRow(Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])));
|
||||
});
|
||||
}
|
||||
|
||||
/** 把看起来是数字的值转成数字,否则 recharts 会把 "12" 当类别处理。 */
|
||||
function normalizeRow(row: Record<string, unknown>): ChartRow {
|
||||
return Object.fromEntries(Object.entries(row).map(([key, value]) => {
|
||||
if (typeof value === "number") return [key, value];
|
||||
const text = String(value ?? "").trim();
|
||||
const numeric = text !== "" && Number.isFinite(Number(text));
|
||||
return [key, numeric ? Number(text) : text];
|
||||
}));
|
||||
}
|
||||
|
||||
const SERIES_COLORS = ["#f59e0b", "#22c55e", "#06b6d4", "#a855f7", "#ef4444"];
|
||||
|
||||
/**
|
||||
* 图表节点:把上游文本(JSON 数组或 CSV)渲染成柱状图/折线图。
|
||||
* metadata.chartKind 为 "line" 时画折线,缺省柱状。
|
||||
*/
|
||||
export function ChartNodeContent({ node, theme }: ChartNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
|
||||
const source = node.metadata?.content || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
|
||||
const parsed = parseChartSource(source);
|
||||
const asLine = node.metadata?.chartKind === "line";
|
||||
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<ChartColumn className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>{source.trim() ? "无法解析为图表数据(需要 JSON 数组或 CSV)" : "连接输出 JSON 数组或 CSV 的文本节点"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full px-2 py-2"
|
||||
data-canvas-no-zoom
|
||||
style={{ color: theme.node.text }}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{asLine ? (
|
||||
<LineChart data={parsed.rows}>
|
||||
<CartesianGrid stroke={theme.node.stroke} strokeDasharray="3 3" />
|
||||
<XAxis dataKey={parsed.xKey} tick={{ fill: theme.node.muted, fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: theme.node.muted, fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
{parsed.series.length > 1 ? <Legend /> : null}
|
||||
{parsed.series.map((key, index) => <Line key={key} type="monotone" dataKey={key} stroke={SERIES_COLORS[index % SERIES_COLORS.length]} dot={false} />)}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={parsed.rows}>
|
||||
<CartesianGrid stroke={theme.node.stroke} strokeDasharray="3 3" />
|
||||
<XAxis dataKey={parsed.xKey} tick={{ fill: theme.node.muted, fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: theme.node.muted, fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
{parsed.series.length > 1 ? <Legend /> : null}
|
||||
{parsed.series.map((key, index) => <Bar key={key} dataKey={key} fill={SERIES_COLORS[index % SERIES_COLORS.length]} />)}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
web/src/components/canvas/nodes/color-grade-node.tsx
Normal file
93
web/src/components/canvas/nodes/color-grade-node.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Palette, RotateCcw } from "lucide-react";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { useCanvasNodeActions } from "@/components/canvas/canvas-node-action-context";
|
||||
import { colorGradeCssFilter, DEFAULT_COLOR_GRADE, isNeutralColorGrade, type CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ColorGradeNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
const SLIDERS: Array<{ key: keyof CanvasColorGrade; label: string; min: number; max: number }> = [
|
||||
{ key: "brightness", label: "亮度", min: 0, max: 200 },
|
||||
{ key: "contrast", label: "对比", min: 0, max: 200 },
|
||||
{ key: "saturate", label: "饱和", min: 0, max: 200 },
|
||||
{ key: "hueRotate", label: "色相", min: -180, max: 180 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 调色节点:吃上游图片,本地预览调色结果,并作为图片素材喂给下游。
|
||||
*
|
||||
* 预览只用 CSS filter,不上传任何东西;真正落地成资源要等到生成时——
|
||||
* 见 canvas-color-grade.ts 的 resolveCanvasColorGradeReference。拖滑杆不产生上传,
|
||||
* 也不消耗文件容量。
|
||||
*/
|
||||
export function ColorGradeNodeContent({ node, theme }: ColorGradeNodeContentProps) {
|
||||
const { updateMetadata } = useCanvasNodeActions();
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
// 判据要与 canvas-node-generation 的 readReferenceImage 完全一致,
|
||||
// 否则会出现「预览有图、生成却没用上」这种不报错的偏差。
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image" && item.metadata?.content);
|
||||
const url = inherited?.metadata?.content || "";
|
||||
const grade = node.metadata?.colorGrade || DEFAULT_COLOR_GRADE;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<Palette className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>连接一张图片即可调色</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const editable = Boolean(updateMetadata);
|
||||
const update = (key: keyof CanvasColorGrade, value: number) => updateMetadata?.(node.id, { colorGrade: { ...grade, [key]: value } });
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden" style={{ background: theme.node.fill }}>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<img src={url} alt={node.title || "调色"} className="h-full w-full object-contain" draggable={false} style={{ filter: colorGradeCssFilter(grade) }} />
|
||||
{isNeutralColorGrade(grade) ? (
|
||||
<span className="pointer-events-none absolute left-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-white" style={{ fontSize: "var(--fs-tiny)" }}>未调色</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editable ? (
|
||||
<div
|
||||
className="shrink-0 border-t px-2 py-1.5"
|
||||
data-canvas-no-zoom
|
||||
style={{ borderColor: theme.node.stroke }}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{SLIDERS.map((slider) => (
|
||||
<label key={slider.key} className="flex items-center gap-2" style={{ fontSize: "var(--fs-tiny)", color: theme.node.muted }}>
|
||||
<span className="w-6 shrink-0">{slider.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="min-w-0 flex-1"
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
value={grade[slider.key]}
|
||||
onChange={(event) => update(slider.key, Number(event.target.value))}
|
||||
/>
|
||||
<span className="w-8 shrink-0 text-right tabular-nums">{grade[slider.key]}</span>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ fontSize: "var(--fs-tiny)", color: theme.node.muted }}
|
||||
onClick={() => updateMetadata?.(node.id, { colorGrade: DEFAULT_COLOR_GRADE })}
|
||||
>
|
||||
<RotateCcw className="size-3" />复位
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
web/src/components/canvas/nodes/compare-node.tsx
Normal file
72
web/src/components/canvas/nodes/compare-node.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Columns2 } from "lucide-react";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type CompareNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* A/B 对比节点:吃两个图片上游,中间一根滑杆左右拖动对比。
|
||||
*
|
||||
* 分割位置只存组件内 state,不落 metadata——它是「看的时候临时挪一下」,
|
||||
* 不是画布内容;CanvasNodeContentProps 也只给了 onContentChange(content),
|
||||
* 为它另开一条写元数据的通道不值得。
|
||||
*/
|
||||
export function CompareNodeContent({ node, theme }: CompareNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const images = upstream.filter((item) => getNodeResourceKind(item) === "image");
|
||||
const [split, setSplit] = useState(50);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const before = images[0]?.metadata?.content || "";
|
||||
const after = images[1]?.metadata?.content || "";
|
||||
|
||||
if (!before || !after) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<Columns2 className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>{images.length === 1 ? "再连一张图片即可对比" : "连接两张图片进行对比"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const moveTo = (clientX: number) => {
|
||||
const rect = hostRef.current?.getBoundingClientRect();
|
||||
if (!rect?.width) return;
|
||||
setSplit(Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="relative h-full w-full select-none overflow-hidden"
|
||||
data-canvas-no-zoom
|
||||
style={{ background: theme.node.fill, cursor: "ew-resize" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => {
|
||||
draggingRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
moveTo(event.clientX);
|
||||
}}
|
||||
onPointerMove={(event) => { if (draggingRef.current) moveTo(event.clientX); }}
|
||||
onPointerUp={(event) => {
|
||||
draggingRef.current = false;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
>
|
||||
<img src={after} alt="B" className="absolute inset-0 h-full w-full object-contain" draggable={false} />
|
||||
{/* 上层按分割位置裁切,露出下层的 B */}
|
||||
<img src={before} alt="A" className="absolute inset-0 h-full w-full object-contain" draggable={false} style={{ clipPath: `inset(0 ${100 - split}% 0 0)` }} />
|
||||
<div className="pointer-events-none absolute inset-y-0 w-px bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${split}%` }} />
|
||||
<span className="pointer-events-none absolute left-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-white" style={{ fontSize: "var(--fs-tiny)" }}>A</span>
|
||||
<span className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-white" style={{ fontSize: "var(--fs-tiny)" }}>B</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
web/src/components/canvas/nodes/html-node.tsx
Normal file
47
web/src/components/canvas/nodes/html-node.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Code } from "lucide-react";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import { SandboxedFrame } from "./sandboxed-frame";
|
||||
|
||||
type HtmlNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML 展示节点:在沙箱 iframe 里预览 HTML。
|
||||
*
|
||||
* 源码取自身 metadata.content,为空时回落上游文本(与 SVG / Markdown 一致)。
|
||||
* 自身有源码时,上游文本作为数据填进 `{{input}}`,所以「模板 + 数据」两条线可以分开接。
|
||||
*
|
||||
* 脚本可执行(allow-scripts),但 SandboxedFrame 结构上不允许同时给 allow-same-origin
|
||||
* —— 否则页面能读到本源登录态。
|
||||
*/
|
||||
export function HtmlNodeContent({ node, theme }: HtmlNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
|
||||
const upstreamText = inherited?.metadata?.content || inherited?.metadata?.prompt || "";
|
||||
const own = node.metadata?.content || "";
|
||||
// 与 SVG / Markdown 一致:自身为空时把上游文本当源码,这样「AI 写页面 → 连线 → 看效果」
|
||||
// 不需要先把源码搬进本节点。
|
||||
const source = own || upstreamText;
|
||||
// 源码本身就来自上游时,{{input}} 没有第二份数据可填——替换成空串,
|
||||
// 否则会把同一段内容再塞回自己里。
|
||||
const input = own ? upstreamText : "";
|
||||
|
||||
if (!source.trim()) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<Code className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>连接输出 HTML 的文本节点</span>
|
||||
<span style={{ fontSize: "var(--fs-tiny)" }}>源码里的 {"{{input}}"} 会替换为上游文本</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SandboxedFrame srcDoc={source.replaceAll("{{input}}", input)} theme={theme} allowScripts />;
|
||||
}
|
||||
48
web/src/components/canvas/nodes/markdown-node.tsx
Normal file
48
web/src/components/canvas/nodes/markdown-node.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type MarkdownNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown 展示节点。
|
||||
*
|
||||
* 内容取自身 metadata.content;为空时回落到上游文本素材——这样「AI 写文档 → 连一根线 →
|
||||
* 排版好的文档」不需要先把内容搬进本节点。上游取用统一经 CanvasNodeGraphContext,
|
||||
* 判定文本素材复用注册表的 resourceKind,不在这里另立一套判断。
|
||||
*/
|
||||
export function MarkdownNodeContent({ node, theme }: MarkdownNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const own = node.metadata?.content || "";
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
|
||||
const source = own || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
|
||||
|
||||
if (!source.trim()) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<FileText className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>连接文本节点,或双击编辑 Markdown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// 滚动区要吞掉 wheel 并标 data-canvas-no-zoom,否则滚动会被画布缩放拦走。
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto overflow-x-hidden px-4 py-3"
|
||||
data-canvas-no-zoom
|
||||
style={{ color: theme.node.text }}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<AIMessageMarkdown>{source}</AIMessageMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
web/src/components/canvas/nodes/panorama-node.tsx
Normal file
205
web/src/components/canvas/nodes/panorama-node.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Globe, X } from "lucide-react";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type PanoramaNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
reduceMediaEffects?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 360° 全景查看器节点:取上游图片,拖拽环视。
|
||||
*
|
||||
* 三个刻意的取舍:
|
||||
* 1. **点击才激活 WebGL**。浏览器对同时存在的 WebGL 上下文有硬上限(十几个),
|
||||
* 画布上可以有任意多个全景节点,自动激活必然在某个数量后整片黑。默认只显示平面预览,
|
||||
* 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
|
||||
* 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose——
|
||||
* 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
|
||||
* 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
|
||||
* reduceMediaEffects 时只给静态预览。
|
||||
*/
|
||||
export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
|
||||
const url = node.metadata?.content || inherited?.metadata?.content || "";
|
||||
const [active, setActive] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
// 上游换图后退出环视,避免旧上下文继续持有已被替换的纹理。
|
||||
useEffect(() => {
|
||||
setActive(false);
|
||||
setFailed(false);
|
||||
}, [url]);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<Globe className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>连接一张 360° 等距柱状全景图</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.fill }}>
|
||||
<img src={url} alt={node.title || "全景"} className="h-full w-full object-cover opacity-80" draggable={false} />
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2" style={{ background: "linear-gradient(to top, rgba(0,0,0,.85), rgba(0,0,0,.35))", boxShadow: "0 -1px 0 rgba(0,0,0,.25)" }}>
|
||||
<span className="min-w-0 truncate text-white" style={{ fontSize: "var(--fs-label)", textShadow: "0 1px 3px rgba(0,0,0,.85)" }}>
|
||||
{failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"}
|
||||
</span>
|
||||
{reduceMediaEffects ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-[var(--r-md)] px-2 py-1 font-medium text-white outline-none transition-colors"
|
||||
style={{ fontSize: "var(--fs-label)", background: "rgba(0,0,0,.78)", boxShadow: "0 1px 4px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.32)" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => { setFailed(false); setActive(true); }}
|
||||
>
|
||||
进入环视
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden" style={{ background: "#000" }}>
|
||||
<PanoramaViewport url={url} onError={() => { setFailed(true); setActive(false); }} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="退出环视"
|
||||
className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-black/70 text-white outline-none transition-colors hover:bg-black/90"
|
||||
style={{ boxShadow: "0 1px 4px rgba(0,0,0,.5)" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => setActive(false)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
// onError 是内联箭头函数,每次渲染都是新引用;直接放进 effect 依赖会让
|
||||
// 父级任何重渲染(悬停/选中/画布状态)都销毁重建 WebGL 上下文 ——
|
||||
// 表现就是环视画面闪动、视角回到初始位置。用 ref 稳定它。
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let disposed = false;
|
||||
let frame = 0;
|
||||
// three 体积大,按需加载,不进主包。
|
||||
const teardown: Array<() => void> = [];
|
||||
|
||||
void (async () => {
|
||||
const THREE = await import("three");
|
||||
if (disposed) return;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100);
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin("anonymous");
|
||||
const texture = await new Promise<InstanceType<typeof THREE.Texture> | null>((resolve) => {
|
||||
loader.load(url, resolve, undefined, () => resolve(null));
|
||||
});
|
||||
if (disposed) { renderer.dispose(); return; }
|
||||
if (!texture) { renderer.dispose(); onErrorRef.current(); return; }
|
||||
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const geometry = new THREE.SphereGeometry(500, 60, 40);
|
||||
const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
host.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab";
|
||||
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
dragging = true;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
renderer.domElement.setPointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
lon -= (event.clientX - lastX) * 0.18;
|
||||
lat += (event.clientY - lastY) * 0.18;
|
||||
lat = Math.max(-85, Math.min(85, lat));
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
dragging = false;
|
||||
if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
// 滚轮留给画布缩放,全景不吃 wheel —— 节点里再嵌一层缩放会让手感彻底乱。
|
||||
renderer.domElement.addEventListener("pointerdown", onPointerDown);
|
||||
renderer.domElement.addEventListener("pointermove", onPointerMove);
|
||||
renderer.domElement.addEventListener("pointerup", onPointerUp);
|
||||
renderer.domElement.addEventListener("pointercancel", onPointerUp);
|
||||
|
||||
const resize = () => {
|
||||
const { clientWidth, clientHeight } = host;
|
||||
if (!clientWidth || !clientHeight) return;
|
||||
renderer.setSize(clientWidth, clientHeight, false);
|
||||
camera.aspect = clientWidth / clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
const tick = () => {
|
||||
const phi = THREE.MathUtils.degToRad(90 - lat);
|
||||
const theta = THREE.MathUtils.degToRad(lon);
|
||||
camera.lookAt(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
|
||||
teardown.push(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
renderer.domElement.removeEventListener("pointerdown", onPointerDown);
|
||||
renderer.domElement.removeEventListener("pointermove", onPointerMove);
|
||||
renderer.domElement.removeEventListener("pointerup", onPointerUp);
|
||||
renderer.domElement.removeEventListener("pointercancel", onPointerUp);
|
||||
renderer.domElement.remove();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
texture.dispose();
|
||||
// forceContextLoss 才真正释放上下文;只 dispose 在部分浏览器上仍占着配额。
|
||||
renderer.forceContextLoss();
|
||||
renderer.dispose();
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
teardown.forEach((fn) => fn());
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return <div ref={hostRef} className="h-full w-full" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} />;
|
||||
}
|
||||
41
web/src/components/canvas/nodes/sandboxed-frame.tsx
Normal file
41
web/src/components/canvas/nodes/sandboxed-frame.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
|
||||
type SandboxedFrameProps = {
|
||||
/** 完整的 HTML 文档字符串,作为 iframe 的 srcDoc */
|
||||
srcDoc: string;
|
||||
theme: CanvasTheme;
|
||||
/**
|
||||
* 是否允许文档内脚本执行。
|
||||
*
|
||||
* ⚠️ 只给 allow-scripts,**绝不同时给 allow-same-origin** —— 两者并存等于取消沙箱:
|
||||
* 文档能拿到本源 DOM 与存储(含登录态)。这里刻意不暴露 sandbox 的完整取值,
|
||||
* 就是为了让这条约束没有被绕过的入口。
|
||||
*/
|
||||
allowScripts?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 沙箱 iframe —— SVG 与 HTML 节点共用。
|
||||
*
|
||||
* 依赖里没有 DOMPurify,手写消毒不可靠(script / on* / foreignObject 等绕法很多),
|
||||
* 所以两种节点都不走 innerHTML,统一塞进沙箱 iframe 渲染。
|
||||
*/
|
||||
export function SandboxedFrame({ srcDoc, theme, allowScripts = false }: SandboxedFrameProps) {
|
||||
return (
|
||||
<iframe
|
||||
title="节点预览"
|
||||
className="h-full w-full border-0"
|
||||
sandbox={allowScripts ? "allow-scripts" : ""}
|
||||
srcDoc={srcDoc}
|
||||
data-canvas-no-zoom
|
||||
style={{ background: theme.node.fill }}
|
||||
// 指针交给文档自己处理,但按下时别让画布把它当成拖拽节点。
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把一段片段包成可渲染的最小文档;居中且不溢出节点。 */
|
||||
export function centeredFrameDocument(body: string, extraCss = "") {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;background:transparent}body{display:grid;place-items:center;overflow:hidden}svg,img{max-width:100%;max-height:100%}${extraCss}</style></head><body>${body}</body></html>`;
|
||||
}
|
||||
45
web/src/components/canvas/nodes/svg-node.tsx
Normal file
45
web/src/components/canvas/nodes/svg-node.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Shapes } from "lucide-react";
|
||||
|
||||
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import { centeredFrameDocument, SandboxedFrame } from "./sandboxed-frame";
|
||||
|
||||
type SvgNodeContentProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
};
|
||||
|
||||
/** 只取到第一个 <svg> 根元素,避免上游文本里的说明文字被一起塞进文档。 */
|
||||
function extractSvgSource(text: string) {
|
||||
const start = text.indexOf("<svg");
|
||||
if (start < 0) return "";
|
||||
const end = text.lastIndexOf("</svg>");
|
||||
return end > start ? text.slice(start, end + "</svg>".length) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG 展示节点:把 SVG 源码渲染成图形。
|
||||
*
|
||||
* 源码取自身 metadata.content,为空时回落上游文本素材——「让 AI 输出 SVG 代码 → 连线 → 看图」
|
||||
* 是这个节点存在的理由。渲染走沙箱 iframe(见 sandboxed-frame),不给脚本权限。
|
||||
*/
|
||||
export function SvgNodeContent({ node, theme }: SvgNodeContentProps) {
|
||||
const upstream = useUpstreamNodes(node.id);
|
||||
const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
|
||||
const raw = node.metadata?.content || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
|
||||
const svg = extractSvgSource(raw);
|
||||
|
||||
if (!svg) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
|
||||
<Shapes className="size-5 opacity-60" />
|
||||
<span style={{ fontSize: "var(--fs-label)" }}>{raw.trim() ? "上游内容里没有找到 <svg> 源码" : "连接输出 SVG 的文本节点"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SandboxedFrame srcDoc={centeredFrameDocument(svg)} theme={theme} />;
|
||||
}
|
||||
@@ -18,6 +18,13 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Video]: { width: 720, height: 405, title: "视频" },
|
||||
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
||||
[CanvasNodeType.Frame]: { width: 760, height: 520, title: "未命名背板" },
|
||||
[CanvasNodeType.Markdown]: { width: 420, height: 320, title: "Markdown" },
|
||||
[CanvasNodeType.Svg]: { width: 420, height: 320, title: "SVG" },
|
||||
[CanvasNodeType.Html]: { width: 520, height: 380, title: "HTML" },
|
||||
[CanvasNodeType.Panorama]: { width: 520, height: 300, title: "全景" },
|
||||
[CanvasNodeType.Compare]: { width: 520, height: 320, title: "对比" },
|
||||
[CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" },
|
||||
[CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
@@ -65,6 +72,34 @@ export const NODE_SPECS = {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Frame],
|
||||
metadata: { frame: { collapsed: false, expandedWidth: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width, expandedHeight: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height } },
|
||||
},
|
||||
[CanvasNodeType.Markdown]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Markdown],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Svg]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Svg],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Html]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Html],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Panorama]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Panorama],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Compare]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Compare],
|
||||
metadata: { status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Chart]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Chart],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.ColorGrade],
|
||||
metadata: { status: "idle" },
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||
|
||||
export function getNodeSpec(type: CanvasNodeType) {
|
||||
|
||||
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;
|
||||
|
||||
@@ -62,7 +62,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { useFocusMode } from "@/hooks/use-focus-mode";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getContextResourceNodes, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
@@ -83,6 +83,7 @@ import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolba
|
||||
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
|
||||
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
|
||||
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
|
||||
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { CanvasRefreshShell } from "./canvas-refresh-shell";
|
||||
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
|
||||
import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
|
||||
@@ -189,7 +190,7 @@ function InfiniteCanvasPage() {
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
|
||||
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("dots");
|
||||
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
|
||||
const [showImageInfo, setShowImageInfo] = useState(false);
|
||||
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("move");
|
||||
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
|
||||
@@ -409,6 +410,10 @@ function InfiniteCanvasPage() {
|
||||
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
|
||||
}, [linkedProjectQuery.data, projectLoaded, setNodes]);
|
||||
const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
|
||||
// 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发;
|
||||
// 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo——
|
||||
// 每帧新对象会让所有节点跟着重渲染,错题本里多条崩溃都出在画布高频更新。
|
||||
const nodeGraphContext = useMemo<CanvasNodeGraphContextValue>(() => ({ getUpstreamNodes: (nodeId: string) => getContextResourceNodes(nodeId, nodes, connections) }), [connections, nodes]);
|
||||
|
||||
const {
|
||||
applyGenerationTaskResult,
|
||||
@@ -1867,7 +1872,8 @@ function InfiniteCanvasPage() {
|
||||
onFileDragLeave={handleFileDragLeave}
|
||||
onFileDragOver={handleFileDragOver}
|
||||
>
|
||||
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])) }}>
|
||||
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))) }}>
|
||||
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
|
||||
<CanvasProjectWorldLayers
|
||||
projectId={projectId}
|
||||
viewportScale={viewport.k}
|
||||
@@ -1938,6 +1944,7 @@ function InfiniteCanvasPage() {
|
||||
onOpenDrawing={openDrawingNode}
|
||||
onStartBatchConnection={startBatchConnection}
|
||||
/>
|
||||
</CanvasNodeGraphContext.Provider>
|
||||
</CanvasNodeActionContext.Provider>
|
||||
</InfiniteCanvas>
|
||||
|
||||
@@ -1981,6 +1988,7 @@ function InfiniteCanvasPage() {
|
||||
onAddFrame={() => createNode(CanvasNodeType.Frame)}
|
||||
onAddFolder={createFolder}
|
||||
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
|
||||
onAddExtensionNode={(type) => createNode(type)}
|
||||
onOpenDirector={() => createDirectorShot()}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
|
||||
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
||||
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
|
||||
@@ -23,6 +24,15 @@ export enum CanvasNodeType {
|
||||
Video = "video",
|
||||
Audio = "audio",
|
||||
Frame = "frame",
|
||||
// 扩展节点:展示与加工。新增一个成员后,编译器会逐个点出还缺哪张表
|
||||
// (NODE_DEFAULT_SIZE / NODE_SPECS / 节点注册表定义 / nodeContentRenderers)。
|
||||
Markdown = "markdown",
|
||||
Svg = "svg",
|
||||
Html = "html",
|
||||
Panorama = "panorama",
|
||||
Compare = "compare",
|
||||
Chart = "chart",
|
||||
ColorGrade = "colorgrade",
|
||||
}
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
@@ -294,6 +304,10 @@ export type CanvasNodeMetadata = {
|
||||
skillId?: string;
|
||||
skillVersion?: number;
|
||||
skillSnapshot?: CanvasSkillSnapshot;
|
||||
/** 图表节点的图形类型,缺省柱状图。落盘字段——新增扩展节点的自有字段都要在这里声明。 */
|
||||
chartKind?: "bar" | "line";
|
||||
/** 调色节点的参数;缺省视为未调色。 */
|
||||
colorGrade?: CanvasColorGrade;
|
||||
storyboard?: StoryboardData;
|
||||
storyboardShotDuration?: StoryboardShotDuration;
|
||||
storyboardShotCount?: StoryboardShotCount;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
|
||||
|
||||
export type ReferenceImage = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,10 +10,20 @@ export type ReferenceImage = {
|
||||
bytes?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
source?: {
|
||||
kind: "drawing";
|
||||
drawingId: string;
|
||||
revision: number;
|
||||
shapeCount: number;
|
||||
};
|
||||
// 参考图的来源:需要在真正生成时才落地成资源的那几类。
|
||||
// 判别字段是 kind——新增一类时,canvas-node-generation 里的分派也要跟着加分支,
|
||||
// 漏了会静默按普通图片处理(dataUrl 为空 → 参考图丢失)。
|
||||
source?:
|
||||
| {
|
||||
kind: "drawing";
|
||||
drawingId: string;
|
||||
revision: number;
|
||||
shapeCount: number;
|
||||
}
|
||||
| {
|
||||
kind: "colorgrade";
|
||||
/** 上游源图地址 */
|
||||
url: string;
|
||||
grade: CanvasColorGrade;
|
||||
};
|
||||
};
|
||||
|
||||
112
web/test/canvas-extension-nodes.test.ts
Normal file
112
web/test/canvas-extension-nodes.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseChartSource } from "../src/components/canvas/nodes/chart-node";
|
||||
import { colorGradeCssFilter, DEFAULT_COLOR_GRADE, isNeutralColorGrade } from "../src/lib/canvas/canvas-color-grade";
|
||||
import { NODE_DEFAULT_SIZE, NODE_SPECS } from "../src/constant/canvas";
|
||||
import { getNodeGenerationMode, getNodeInputKind, getNodeLabel, getNodeResourceKind } from "../src/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
const EXTENSION_TYPES = [
|
||||
CanvasNodeType.Markdown,
|
||||
CanvasNodeType.Svg,
|
||||
CanvasNodeType.Html,
|
||||
CanvasNodeType.Panorama,
|
||||
CanvasNodeType.Compare,
|
||||
CanvasNodeType.Chart,
|
||||
CanvasNodeType.ColorGrade,
|
||||
] as const;
|
||||
|
||||
function node(type: CanvasNodeType, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
|
||||
return { id: `${type}-1`, type, title: "", position: { x: 0, y: 0 }, width: 100, height: 100, metadata };
|
||||
}
|
||||
|
||||
describe("扩展节点注册完整性", () => {
|
||||
test("7 个扩展节点在四张表里都有登记", () => {
|
||||
EXTENSION_TYPES.forEach((type) => {
|
||||
expect(getNodeLabel(type), `${type} 缺少注册表定义`).toBeTruthy();
|
||||
expect(NODE_DEFAULT_SIZE[type], `${type} 缺少默认尺寸`).toBeTruthy();
|
||||
expect(NODE_SPECS[type], `${type} 缺少节点规格`).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("展示节点不参与生成,自身不发起生成行为", () => {
|
||||
EXTENSION_TYPES.forEach((type) => {
|
||||
expect(getNodeGenerationMode(node(type)), `${type} 不应有生成模式`).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("扩展节点的素材与输入判据", () => {
|
||||
test("文本类扩展节点有内容才算素材", () => {
|
||||
[CanvasNodeType.Markdown, CanvasNodeType.Svg, CanvasNodeType.Html].forEach((type) => {
|
||||
expect(getNodeResourceKind(node(type, { content: "x" }))).toBe("text");
|
||||
expect(getNodeResourceKind(node(type, {}))).toBeNull();
|
||||
expect(getNodeInputKind(type)).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
test("查看器类节点消费图片但自身不是素材", () => {
|
||||
[CanvasNodeType.Panorama, CanvasNodeType.Compare].forEach((type) => {
|
||||
expect(getNodeResourceKind(node(type, { content: "x" })), `${type} 不应作为素材`).toBeNull();
|
||||
expect(getNodeInputKind(type)).toBe("image");
|
||||
});
|
||||
});
|
||||
|
||||
test("图表节点吃文本但不产出素材", () => {
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.Chart, { content: "[]" }))).toBeNull();
|
||||
expect(getNodeInputKind(CanvasNodeType.Chart)).toBe("text");
|
||||
});
|
||||
|
||||
test("调色节点即使自身没有 content 也必须算图片素材", () => {
|
||||
// 它的图来自上游。若按「有没有 content」判断,它会永远进不了生成输入——
|
||||
// 这是个不报错的静默失效,所以单独钉住。
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.ColorGrade, {}))).toBe("image");
|
||||
expect(getNodeInputKind(CanvasNodeType.ColorGrade)).toBe("image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("调色参数", () => {
|
||||
test("缺省参数视为未调色", () => {
|
||||
expect(isNeutralColorGrade(DEFAULT_COLOR_GRADE)).toBe(true);
|
||||
expect(isNeutralColorGrade({ ...DEFAULT_COLOR_GRADE, brightness: 120 })).toBe(false);
|
||||
expect(isNeutralColorGrade({ ...DEFAULT_COLOR_GRADE, hueRotate: -10 })).toBe(false);
|
||||
});
|
||||
|
||||
test("预览与导出共用同一个 filter 字符串", () => {
|
||||
expect(colorGradeCssFilter({ brightness: 110, contrast: 90, saturate: 120, hueRotate: -15 }))
|
||||
.toBe("brightness(110%) contrast(90%) saturate(120%) hue-rotate(-15deg)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("图表数据解析", () => {
|
||||
test("解析 JSON 数组,数值列作为系列", () => {
|
||||
const parsed = parseChartSource('[{"月份":"1月","销量":120,"退货":8},{"月份":"2月","销量":150,"退货":5}]');
|
||||
expect(parsed?.xKey).toBe("月份");
|
||||
expect(parsed?.series).toEqual(["销量", "退货"]);
|
||||
expect(parsed?.rows.length).toBe(2);
|
||||
});
|
||||
|
||||
test("解析 CSV,并把数字字符串转成数值", () => {
|
||||
const parsed = parseChartSource("城市,人口\n北京,2189\n上海,2487");
|
||||
expect(parsed?.xKey).toBe("城市");
|
||||
expect(parsed?.series).toEqual(["人口"]);
|
||||
expect(parsed?.rows[0].人口).toBe(2189);
|
||||
});
|
||||
|
||||
test("全是数值列时回落行号作横轴", () => {
|
||||
const parsed = parseChartSource("[{\"a\":1,\"b\":2},{\"a\":3,\"b\":4}]");
|
||||
expect(parsed?.xKey).toBe("__index");
|
||||
expect(parsed?.rows[0].__index).toBe(1);
|
||||
});
|
||||
|
||||
// 数据来自模型输出,非法输入是常态而不是异常;抛出来会连带整个画布白屏。
|
||||
test("非法或无系列的输入返回 null 而不抛", () => {
|
||||
expect(parseChartSource("")).toBeNull();
|
||||
expect(parseChartSource("{ 这不是 JSON")).toBeNull();
|
||||
expect(parseChartSource("just a sentence")).toBeNull();
|
||||
expect(parseChartSource("[]")).toBeNull();
|
||||
expect(parseChartSource('{"a":1}')).toBeNull();
|
||||
expect(parseChartSource('[{"名称":"甲"},{"名称":"乙"}]')).toBeNull();
|
||||
expect(parseChartSource("只有一行")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user