diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index f19a2c7a3..914c08966 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -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()); + 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)} >