Preserve uploaded media aspect ratios in video canvas
This commit is contained in:
parent
439af60c2a
commit
1c2acee547
@ -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">
|
||||
|
||||
15
lib/video-canvas/media-node-size.ts
Normal file
15
lib/video-canvas/media-node-size.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user