Preserve uploaded media aspect ratios in video canvas

This commit is contained in:
134 2026-09-24 14:36:22 +08:00
parent 439af60c2a
commit 1c2acee547
3 changed files with 111 additions and 19 deletions

View File

@ -128,6 +128,7 @@ import { VideoPromptToolDialog } from "@/components/canvas/VideoPromptToolDialog
import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools";
import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence";
import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import";
import { getUploadedMediaNodeSize } from "@/lib/video-canvas/media-node-size";
import type { CanvasInitialProject } from "@/types/canvas";
import {
getVideoCanvasFunctionBillingCopy,
@ -4190,6 +4191,13 @@ function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNod
uploadStatus: "failed", uploadError: "上次素材未完成上传,请重新上传文件" } };
}
const action = getDraftString(node, "action");
if (action === "VIDEO_RESOURCE" && node.width === 420 && node.height === 280) {
const sizeLabel = getDraftString(node, "outputSizeLabel").match(/(\d+)\s*[×x]\s*(\d+)/);
const width = getDraftNumber(node, "intrinsicWidth", Number(sizeLabel?.[1] || 0));
const height = getDraftNumber(node, "intrinsicHeight", Number(sizeLabel?.[2] || 0));
const layout = getUploadedMediaNodeSize(width, height, "video");
if (layout) return { ...node, ...layout };
}
const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node);
const isNineGridResultNode =
!isCharacterThreeViewResultNode &&
@ -5476,7 +5484,29 @@ export function LiblibVideoCanvasReplica({
};
}, []);
const readImageIntrinsicSize = useCallback(async (file: File) => {
const readMediaIntrinsicSize = useCallback(async (file: File) => {
if (file.type.startsWith("video/")) {
return await new Promise<{ width: number; height: number } | null>((resolve) => {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
let settled = false;
const finish = (size: { width: number; height: number } | null) => {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(url);
resolve(size);
};
const timeout = window.setTimeout(() => finish(null), 5000);
video.preload = "metadata";
video.onloadedmetadata = () => finish(video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight } : null);
video.onerror = () => finish(null);
video.src = url;
});
}
if (!file.type.startsWith("image/")) return null;
try {
const bitmap = await createImageBitmap(file);
@ -5504,29 +5534,54 @@ export function LiblibVideoCanvasReplica({
}
}, []);
const legacyImageSizingAttempts = useRef(new Set<string>());
useEffect(() => {
for (const node of nodes) {
if (getDraftString(node, "action") !== "IMAGE_RESOURCE" ||
!node.draftValues?.resourceMeta ||
getDraftNumber(node, "intrinsicWidth") > 0) continue;
const url = getNodeMediaUrl(node);
if (!url || legacyImageSizingAttempts.current.has(`${node.id}:${url}`)) continue;
legacyImageSizingAttempts.current.add(`${node.id}:${url}`);
const image = new Image();
image.onload = () => {
const width = image.naturalWidth;
const height = image.naturalHeight;
if (!width || !height) return;
const aspect = width / height;
const oldWidth = aspect >= 1 ? 420 : Math.min(420, Math.max(220, Math.round(560 * aspect)));
const oldHeight = aspect >= 1 ? Math.min(560, Math.max(180, Math.round(420 / aspect))) : 560;
const layout = getUploadedMediaNodeSize(width, height, "image");
setNodes(current => current.map(item => item.id === node.id ? {
...item,
...(layout && Math.abs(item.width - oldWidth) <= 2 && Math.abs(item.height - oldHeight) <= 2 ? layout : {}),
draftValues: { ...item.draftValues, intrinsicWidth: width, intrinsicHeight: height,
outputSizeLabel: getDraftString(item, "outputSizeLabel") || `${width} × ${height}` },
} : item));
};
image.src = url;
}
}, [nodes, setNodes]);
const addUploadedFilesAtPoint = useCallback(
async (files: File[], center: { x: number; y: number }, connectTargetId?: string) => {
if (files.length === 0) return;
const { files: batch, errors } = prepareCanvasMediaFiles(files);
if (errors.length) toast.error(errors.slice(0, 3).join("\n"));
if (!batch.length) return;
const sizes = await Promise.all(batch.map(file => readImageIntrinsicSize(file)));
const sizes = await Promise.all(batch.map(file => readMediaIntrinsicSize(file)));
const newNodes = batch.map((file, index) => {
const created = createUploadedMediaNode(file, "", 0, 0, nodes.length + index + 1);
const size = sizes[index];
if (size && size.width > 0 && size.height > 0) {
const aspect = size.width / size.height;
if (aspect >= 1) {
created.width = 420;
created.height = Math.min(560, Math.max(180, Math.round(420 / aspect)));
} else {
created.height = 560;
created.width = Math.min(420, Math.max(220, Math.round(560 * aspect)));
}
}
const layout = size && file.type.startsWith("video/")
? getUploadedMediaNodeSize(size.width, size.height, "video")
: size ? getUploadedMediaNodeSize(size.width, size.height, "image") : null;
if (layout) { created.width = layout.width; created.height = layout.height; }
return { ...created, x: Math.round(center.x - created.width / 2 + index * 450),
y: Math.round(center.y - created.height / 2),
draftValues: { ...created.draftValues, uploadStatus: "uploading" } };
draftValues: { ...created.draftValues, uploadStatus: "uploading",
intrinsicWidth: size?.width, intrinsicHeight: size?.height,
outputSizeLabel: size ? `${size.width} × ${size.height}` : undefined } };
});
addNodes(newNodes, connectTargetId ? newNodes.map(node => ({ id: makeId("edge"), source: node.id, target: connectTargetId })) : []);
setActivePanel(null);
@ -5537,10 +5592,16 @@ export function LiblibVideoCanvasReplica({
const id = newNodes[index].id;
try {
const data = await uploadCanvasMediaFile(file);
setNodes(current => current.map(node => node.id === id ? { ...node, draftValues: {
setNodes(current => current.map(node => node.id === id ? { ...node,
...(!sizes[index] && data.width && data.height && data.mediaType !== "audio"
? getUploadedMediaNodeSize(data.width, data.height, data.mediaType) || {} : {}),
draftValues: {
...node.draftValues, mediaUrl: data.url, url: [data.url], uploadStatus: "complete",
uploadError: undefined,
durationSeconds: data.duration, outputSizeLabel: data.width && data.height ? `${data.width} × ${data.height}` : undefined,
durationSeconds: data.duration,
intrinsicWidth: sizes[index]?.width || data.width,
intrinsicHeight: sizes[index]?.height || data.height,
outputSizeLabel: sizes[index] ? `${sizes[index].width} × ${sizes[index].height}` : data.width && data.height ? `${data.width} × ${data.height}` : undefined,
} } : node));
toast.success(`${newNodes[index].title} 上传完成`);
} catch (error) {
@ -5553,7 +5614,7 @@ export function LiblibVideoCanvasReplica({
}
});
},
[addNodes, nodes.length, readImageIntrinsicSize, uploadCanvasMediaFile]
[addNodes, nodes.length, readMediaIntrinsicSize, uploadCanvasMediaFile]
);
const addUploadedFiles = useCallback(
@ -19856,7 +19917,7 @@ function OfficialVideoResultCard({
onPointerMove={() => setHovered(true)}
>
<video
className="pointer-events-none h-full w-full rounded-[10px] object-cover"
className="pointer-events-none h-full w-full rounded-[10px] bg-[#101010] object-contain"
muted
onLoadedMetadata={event => { const value = event.currentTarget.duration; if (Number.isFinite(value) && value > 0) setDecodedDuration(value); }}
onError={() => setPlaying(false)}
@ -20279,7 +20340,7 @@ function PresetMediaCanvasNode({
) : (
<div
className={cn(
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-[#101010] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
@ -20316,7 +20377,7 @@ function PresetMediaCanvasNode({
style={{ transform: `rotate(${rotation}deg)` }}
/>
) : (
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
<div className="flex h-full items-center justify-center text-[13px] text-white/40">等待图片</div>
)}
{annotationMode ? (
<div className="pointer-events-none absolute inset-0">

View File

@ -0,0 +1,15 @@
export function getUploadedMediaNodeSize(
intrinsicWidth: number,
intrinsicHeight: number,
mediaType: "image" | "video",
) {
if (!(intrinsicWidth > 0) || !(intrinsicHeight > 0)) return null;
// The node has a 24px title and an 8px gap above the media viewport.
const maxWidth = mediaType === "video" ? 560 : 420;
const maxHeight = 560;
const scale = Math.min(maxWidth / intrinsicWidth, maxHeight / intrinsicHeight);
const width = Math.max(1, Math.round(intrinsicWidth * scale));
const mediaHeight = Math.max(1, Math.round(intrinsicHeight * scale));
return { width, height: mediaHeight + 32 };
}

View File

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { canvasMediaType, prepareCanvasMediaFiles } from "@/lib/video-canvas/media-import";
import { getUploadedMediaNodeSize } from "@/lib/video-canvas/media-node-size";
test("Finder files without MIME retain bytes and get the correct image/video upload route", async () => {
const input = [new File(["image bytes"], "照片.PNG"), new File(["video bytes"], "镜头.MOV", { type: "application/octet-stream" })];
@ -25,3 +26,18 @@ test("a drop beyond the batch limit explicitly reports skipped files", () => {
expect(result.files).toHaveLength(10);
expect(result.errors).toEqual(["每次最多导入 10 个素材,其余文件请分批拖入"]);
});
test("portrait and landscape uploads preserve their visible aspect ratio below the node title", () => {
for (const [width, height, mediaType] of [
[1080, 1440, "video"],
[1440, 1080, "video"],
[768, 1024, "image"],
[1920, 1080, "image"],
] as const) {
const node = getUploadedMediaNodeSize(width, height, mediaType);
expect(node).not.toBeNull();
expect(node!.width).toBeLessThanOrEqual(mediaType === "video" ? 560 : 420);
expect(node!.height - 32).toBeLessThanOrEqual(560);
expect(node!.width / (node!.height - 32)).toBeCloseTo(width / height, 2);
}
});