From f053e5a94d61de99ba702901f6d2d7ad92c4f9c2 Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Sat, 15 Aug 2026 22:40:43 +0800 Subject: [PATCH] fix(canvas): size video nodes to the video's real aspect ratio Dragging or pasting a video created the node at the generic 400x300 (4:3) default: the drop/paste builder uploaded the file without measuring it, so `naturalDimensions` was absent and `resolveAddNodes` fell back to the type default. A 720x1280 clip rendered letterboxed inside a 4:3 box. Add `getVideoDimensionsFromBlob` alongside `getImageDimensionsFromBlob` and call it from the video branch of `uploadFileToNodeInput`. `CanvasToolbar` now reuses the shared helpers instead of its private copies. Probing is non-fatal: a codec the browser cannot decode (e.g. ProRes .mov) still yields a node at the default size, where previously the rejected probe propagated through `Promise.all` and dropped the node entirely. Fixes #105 --- .../Panels/Canvas/CanvasToolbar.tsx | 41 +++++-------------- .../canvasCommand/nodeInputBuilders.ts | 9 +++- apps/web/src/utils/io/media.ts | 25 +++++++++++ 3 files changed, 43 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx b/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx index 6fda909a1..57d3e383d 100644 --- a/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx +++ b/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx @@ -33,7 +33,12 @@ import { } from './canvasInputPolicy.ts'; import { NODE_ICON } from '../../../config/nodeIcons.ts'; import useCanvasStore from '../../../store/canvasStore.ts'; -import { detectNodeType, detectOfficeFormat } from '../../../utils/io/media.ts'; +import { + detectNodeType, + detectOfficeFormat, + getImageDimensionsFromBlob, + getVideoDimensionsFromBlob, +} from '../../../utils/io/media.ts'; import { Button } from '../../Common/Button.tsx'; import { Modal } from '../../Common/Modal.tsx'; import { Popover } from '../../Common/Popover.tsx'; @@ -225,34 +230,6 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { return { x: rect.left, y: rect.top }; }; - const getImageDimensions = ( - file: File, - ): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.src = URL.createObjectURL(file); - img.onload = () => { - resolve({ width: img.naturalWidth, height: img.naturalHeight }); - URL.revokeObjectURL(img.src); - }; - img.onerror = reject; - }); - }; - - const getVideoDimensions = ( - file: File, - ): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const video = document.createElement('video'); - video.src = URL.createObjectURL(file); - video.onloadedmetadata = () => { - resolve({ width: video.videoWidth, height: video.videoHeight }); - URL.revokeObjectURL(video.src); - }; - video.onerror = reject; - }); - }; - const handleFileChange = async (e: ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -273,14 +250,16 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { if (type === 'image') { const [uploadedUrl, dims] = await Promise.all([ uploadImage(file, canvasId), - getImageDimensions(file), + getImageDimensionsFromBlob(file).catch(() => undefined), ]); url = uploadedUrl; naturalDimensions = dims; } else if (type === 'video') { const [uploadedUrl, dims] = await Promise.all([ uploadVideo(file, canvasId), - getVideoDimensions(file), + // A codec the browser cannot decode still deserves a node — it + // just falls back to the default size. + getVideoDimensionsFromBlob(file).catch(() => undefined), ]); url = uploadedUrl; naturalDimensions = dims; diff --git a/apps/web/src/handler/canvasCommand/nodeInputBuilders.ts b/apps/web/src/handler/canvasCommand/nodeInputBuilders.ts index 57670ad9b..e7fcf10d4 100644 --- a/apps/web/src/handler/canvasCommand/nodeInputBuilders.ts +++ b/apps/web/src/handler/canvasCommand/nodeInputBuilders.ts @@ -19,6 +19,7 @@ import { detectNodeTypeFromMime, detectOfficeFormat, getImageDimensionsFromBlob, + getVideoDimensionsFromBlob, normalizeUrl, } from '../../utils/io/media'; @@ -57,11 +58,17 @@ export async function uploadFileToNodeInput( } if (type === 'video') { - const src = await uploadVideo(file, canvasId); + const [src, naturalDimensions] = await Promise.all([ + uploadVideo(file, canvasId), + // A codec the browser cannot decode still deserves a node — it just + // falls back to the default size instead of the real aspect ratio. + getVideoDimensionsFromBlob(file).catch(() => undefined), + ]); return { nodeType: 'video', placementPoint, data: { src, label: file.name, origin }, + naturalDimensions, }; } diff --git a/apps/web/src/utils/io/media.ts b/apps/web/src/utils/io/media.ts index 6577a409b..7fbd99350 100644 --- a/apps/web/src/utils/io/media.ts +++ b/apps/web/src/utils/io/media.ts @@ -118,3 +118,28 @@ export const getImageDimensionsFromBlob = ( }; img.src = url; }); + +/** + * Get intrinsic dimensions of a video from a Blob/File. + * + * Only the container metadata is needed, hence `preload = 'metadata'`. + * Callers use this to size a new video node to the real aspect ratio — + * without it the node falls back to the generic 4:3 default. + */ +export const getVideoDimensionsFromBlob = ( + blob: Blob, +): Promise<{ width: number; height: number }> => + new Promise((resolve, reject) => { + const url = URL.createObjectURL(blob); + const video = document.createElement('video'); + video.preload = 'metadata'; + video.onloadedmetadata = () => { + resolve({ width: video.videoWidth, height: video.videoHeight }); + URL.revokeObjectURL(url); + }; + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load video')); + }; + video.src = url; + });