feat(canvas): 优化图片和视频节点尺寸适配

- 在 InsertAssetPayload 类型中为视频添加 width 和 height 属性
- 引入 fitNodeSize 和 nodeSizeFromRatio 工具函数处理节点尺寸计算
- 设置视频节点最大宽度和高度限制为 420px
- 将原有的 fitImageNodeSize 替换为通用的 fitNodeSize 函数
- 为视频节点添加自然宽高元数据存储
- 实现视频文件上传时读取真实宽高信息
- 支持空节点在尺寸比例切换时自动调整外框大小
- 修复视频节点在生成和重试过程中的尺寸适配问题
- 统一图片和视频节点的尺寸调整逻辑
- 添加视频节点纵横比锁定功能
This commit is contained in:
HouYunFei
2026-05-23 18:30:32 +08:00
parent ef7772a703
commit f871a5d015
6 changed files with 66 additions and 34 deletions
+1
View File
@@ -2,6 +2,7 @@
- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data``size``seconds``vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。
- 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos``/api/v1/videos/:id``/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。
- 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
- 模型选择器改为共享的 shadcn 风格下拉,按模型名称显示 OpenAI、Claude、Gemini 图标,并把画布节点下方、右侧助手和配置弹窗统一到同一套组件;右侧助手的模型和模式按钮会随工具栏宽度自动在文字态和图标态之间切换,需要确认生图页、画布助手、节点配置和配置弹窗中的选中态和下拉列表显示正常。
- 画布图像设置里的尺寸输入去掉了原生数字步进箭头,需要确认宽高仍可直接输入、禁用态正常、设置值回写正常。
- 后台提示词定时同步默认改为开启且默认每 5 分钟执行一次,需要确认新环境未保存过设置时开关默认打开、Cron 默认值正确,用户手动关闭或修改 Cron 后能保持配置。
@@ -17,6 +17,7 @@ import { UserStatusActions } from "@/components/layout/user-status-actions";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { cropDataUrl } from "../utils/canvas-image-data";
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
import { App, Button, Dropdown, Modal } from "antd";
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
@@ -67,7 +68,8 @@ type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
backgroundMode: CanvasBackgroundMode;
};
const UPLOADED_IMAGE_MAX_SIDE = 640;
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
@@ -1056,7 +1058,7 @@ function InfiniteCanvasPage() {
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const image = await uploadImage(file);
const size = fitImageNodeSize(image.width, image.height);
const size = fitNodeSize(image.width, image.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newNode: CanvasNodeData = {
id,
@@ -1076,7 +1078,7 @@ function InfiniteCanvasPage() {
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const video = await uploadMediaFile(file, "video");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((prev) => [
...prev,
@@ -1084,9 +1086,9 @@ function InfiniteCanvasPage() {
id,
type: CanvasNodeType.Video,
title: file.name,
position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 },
width: spec.width,
height: spec.height,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: videoMetadata(video),
},
]);
@@ -1304,7 +1306,7 @@ function InfiniteCanvasPage() {
}, []);
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(patch || {}) } } : node)));
setNodes((prev) => prev.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
}, []);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
@@ -1415,7 +1417,7 @@ function InfiniteCanvasPage() {
(items) => items[0],
);
const uploaded = await uploadImage(image.dataUrl);
const size = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
const size = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
@@ -1445,7 +1447,8 @@ function InfiniteCanvasPage() {
if (target?.nodeId) {
if (file.type.startsWith("video/")) {
const video = await uploadMediaFile(file, "video");
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, width: NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, height: NODE_DEFAULT_SIZE[CanvasNodeType.Video].height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node)));
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node)));
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
@@ -1454,7 +1457,7 @@ function InfiniteCanvasPage() {
return;
}
const image = await uploadImage(file);
const size = fitImageNodeSize(image.width, image.height);
const size = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
@@ -1672,7 +1675,7 @@ function InfiniteCanvasPage() {
? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages).then((items) => items[0])
: await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
const imageSize = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => {
const root = prev.find((node) => node.id === rootId);
return prev.map((node) => {
@@ -1724,7 +1727,7 @@ function InfiniteCanvasPage() {
}
if (mode === "video") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
const videoId = isEmptyVideoNode ? nodeId : nanoid();
const parent = sourceNode?.position || { x: 0, y: 0 };
@@ -1741,7 +1744,8 @@ function InfiniteCanvasPage() {
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages), "video");
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node)));
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node)));
return;
}
@@ -1859,14 +1863,15 @@ function InfiniteCanvasPage() {
}
if (node.type === CanvasNodeType.Video) {
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt, retryReferenceImages || []), "video");
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
return;
}
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const generationMetadata = savedImageMetadata?.generationType
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []);
@@ -1935,7 +1940,7 @@ function InfiniteCanvasPage() {
async (image: CanvasAssistantImage) => {
const storedImage = image.storageKey ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(image.dataUrl);
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
const config = fitImageNodeSize(meta.width, meta.height);
const config = fitNodeSize(meta.width, meta.height);
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
@@ -1979,7 +1984,8 @@ function InfiniteCanvasPage() {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - spec.width / 2, y: center.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS } }]);
const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height } }]);
setSelectedNodeIds(new Set([id]));
} else {
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
@@ -2483,7 +2489,7 @@ function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
}
function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
return { content: video.url, storageKey: video.storageKey, status: "success", bytes: video.bytes, mimeType: video.mimeType || "video/mp4" };
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4" };
}
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
@@ -2549,21 +2555,17 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
);
}
function fitImageNodeSize(width: number, height: number, maxWidth = UPLOADED_IMAGE_MAX_SIDE, maxHeight = UPLOADED_IMAGE_MAX_SIDE) {
const safeWidth = Math.max(1, width);
const safeHeight = Math.max(1, height);
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight);
return {
width: safeWidth * scale,
height: safeHeight * scale,
};
}
function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
const next = { ...node, metadata: { ...node.metadata, ...(patch || {}) } };
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const size = typeof patch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(patch.size, spec.width, spec.height) : null;
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
}
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
const first = nodes.find((node) => node.id === firstNodeId);
const second = nodes.find((node) => node.id === secondNodeId);
@@ -12,7 +12,7 @@ import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"
export type AssetPickerTab = "my-assets" | "library";
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string } | { kind: "video"; url: string; title: string; storageKey?: string };
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string } | { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number };
type Props = {
open: boolean;
@@ -207,7 +207,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
} else {
onInsert(asset.kind === "video" ? { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title } : { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
onInsert(asset.kind === "video" ? { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height } : { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
}
};
@@ -209,7 +209,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: data.type === CanvasNodeType.Image && !data.metadata?.freeResize,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
@@ -0,0 +1,18 @@
"use client";
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const scale = Math.min(1, maxWidth / w, maxHeight / h);
return { width: w * scale, height: h * scale };
}
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
const match = size?.match(/^(\d+)(?:x|:)(\d+)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
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 };
}
+13 -2
View File
@@ -3,7 +3,7 @@
import localforage from "localforage";
import { nanoid } from "nanoid";
export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string };
export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string; width?: number; height?: number };
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" });
const objectUrls = new Map<string, string>();
@@ -14,7 +14,8 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return { url, storageKey, bytes: blob.size, mimeType: blob.type || "application/octet-stream" };
const meta = blob.type.startsWith("video/") ? await readVideoMeta(url) : {};
return { url, storageKey, bytes: blob.size, mimeType: blob.type || "application/octet-stream", ...meta };
}
export async function resolveMediaUrl(storageKey?: string, fallback = "") {
@@ -43,3 +44,13 @@ export function collectMediaStorageKeys(value: unknown, keys = new Set<string>()
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectMediaStorageKeys(child, keys)) : collectMediaStorageKeys(item, keys)));
return keys;
}
function readVideoMeta(url: string) {
return new Promise<{ width: number; height: number }>((resolve) => {
const video = document.createElement("video");
const done = () => resolve({ width: video.videoWidth || 1280, height: video.videoHeight || 720 });
video.onloadedmetadata = done;
video.onerror = done;
video.src = url;
});
}