feat(*): 画布创作 - 支持文本多模态生成并优化媒体节点、连接线与视频预览

Fixes #59
This commit is contained in:
ddCat
2026-07-31 19:44:02 +08:00
parent 5db39ccad4
commit 8de9b42c00
29 changed files with 231 additions and 111 deletions

View File

@@ -1,5 +1,5 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
@@ -17,8 +17,6 @@ type BackendGenerationResult = {
text?: string;
};
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult {
if (!task.resultJson) throw new Error("后端任务没有返回结果");
@@ -102,7 +100,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
const video = result.video.storageKey
? { url: await resolveMediaUrl(result.video.storageKey, result.video.dataUrl), storageKey: result.video.storageKey, width: result.video.width, height: result.video.height, durationMs: result.video.durationMs, bytes: result.video.bytes || 0, mimeType: result.video.mimeType || "video/mp4" }
: await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_WIDTH, video.height || node.height || VIDEO_NODE_MAX_HEIGHT, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_SIZE.width, video.height || node.height || VIDEO_NODE_MAX_SIZE.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
return {
...node,
type: CanvasNodeType.Video,

View File

@@ -1,6 +1,7 @@
import type { SelectionBox, ViewportTransform } from "@/types/canvas";
export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) {
@@ -15,6 +16,8 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
container.style.setProperty("--canvas-dot-size", viewport.k < 0.12 ? "0.8px" : "1.15px");
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
// Ant Design overlays watch scrollable ancestors, but CSS transforms do not emit layout events.
@@ -22,6 +25,12 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
}
}
export function subscribeCanvasGraphicsViewportPreview(container: HTMLDivElement, listener: (viewport: ViewportTransform) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<ViewportTransform>).detail);
container.addEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, handlePreview);
}
export function subscribeCanvasViewportPreview(container: HTMLDivElement, listener: (viewport: ViewportTransform) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<ViewportTransform>).detail);
container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);

View File

@@ -1,7 +1,15 @@
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640) {
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export const MEDIA_NODE_MIN_SIZE = { width: 300, height: 220 } as const;
export const VIDEO_NODE_MAX_SIZE = { width: 480, height: 480 } as const;
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640, minWidth = MEDIA_NODE_MIN_SIZE.width, minHeight = MEDIA_NODE_MIN_SIZE.height) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const scale = Math.min(1, maxWidth / w, maxHeight / h);
// 媒体节点既要保留原始比例,也要给生成状态、操作按钮留下稳定的可读空间。
const preferredScale = Math.min(1, maxWidth / w, maxHeight / h);
const minimumScale = Math.max(minWidth / w, minHeight / h);
const scale = Math.max(preferredScale, minimumScale);
return { width: w * scale, height: h * scale };
}
@@ -16,5 +24,23 @@ export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: n
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
const ratio = width / Math.max(1, height);
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
return ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight };
const candidateSize = ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight };
return fitNodeSize(candidateSize.width, candidateSize.height, baseWidth, baseHeight);
}
export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return node;
if (node.width >= MEDIA_NODE_MIN_SIZE.width && node.height >= MEDIA_NODE_MIN_SIZE.height) return node;
const scale = Math.max(1, MEDIA_NODE_MIN_SIZE.width / Math.max(1, node.width), MEDIA_NODE_MIN_SIZE.height / Math.max(1, node.height));
const width = node.width * scale;
const height = node.height * scale;
return {
...node,
position: {
x: node.position.x + node.width / 2 - width / 2,
y: node.position.y + node.height / 2 - height / 2,
},
width,
height,
};
}

View File

@@ -7,6 +7,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { imageMetadata, parseBackendGenerationResult } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
import type { ReferenceImage } from "@/types/image";
@@ -363,7 +364,8 @@ export function supportsVideoReferenceAudio(config: AiConfig) {
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
return nodes.map((node) => {
const resizedNode = node.type === CanvasNodeType.Config && node.height < configHeight ? { ...node, height: configHeight } : node.type === CanvasNodeType.Script && node.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height ? { ...node, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height } : node;
const mediaNode = ensureMediaNodeMinimumSize(node);
const resizedNode = mediaNode.type === CanvasNodeType.Config && mediaNode.height < configHeight ? { ...mediaNode, height: configHeight } : mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height ? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height } : mediaNode;
return resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
});
}