From e423adc738f8e33c22a362296c1aa07d0e91dbaf Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 9 Jun 2026 15:58:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dmp4=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/resources/mp4AlphaLayout.js | 154 +++++++++++++----- .../resources/pages/ResourceListPage.jsx | 8 +- 2 files changed, 122 insertions(+), 40 deletions(-) diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js index bfa95c1..fbd87d1 100644 --- a/src/features/resources/mp4AlphaLayout.js +++ b/src/features/resources/mp4AlphaLayout.js @@ -19,6 +19,8 @@ const transparentLayoutKinds = new Set([ ]); const videoFrameStepSeconds = 1 / 24; const maxSeekFrameCount = 360; +const videoLoadTimeoutMs = 8000; +const videoSeekTimeoutMs = 5000; export function isMp4File(file) { if (!file?.name) { @@ -201,15 +203,15 @@ export async function mp4FileFromSource(source) { export async function readMp4VideoInfo(file) { const video = await loadVideo(file); - const info = { - duration: Number.isFinite(video.duration) ? video.duration : 0, - height: video.videoHeight || 0, - width: video.videoWidth || 0, - }; - URL.revokeObjectURL(video.src); - video.removeAttribute("src"); - video.load(); - return info; + try { + return { + duration: Number.isFinite(video.duration) ? video.duration : 0, + height: video.videoHeight || 0, + width: video.videoWidth || 0, + }; + } finally { + releaseVideo(video); + } } export function detectMp4AlphaLayoutFromFrames(frames) { @@ -409,65 +411,143 @@ function roundConfidence(value) { async function readVideoFrames(file) { const video = await loadVideo(file); - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context || canvas.width <= 0 || canvas.height <= 0) { - throw new Error("MP4 画面无法读取"); + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context || canvas.width <= 0 || canvas.height <= 0) { + throw new Error("MP4 画面无法读取"); + } + await waitForVideoCurrentData(video); + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; + const frameCount = Math.min(Math.max(Math.ceil(duration / videoFrameStepSeconds), 1), maxSeekFrameCount); + const frames = []; + for (let index = 0; index < frameCount; index += 1) { + const time = duration > 0 ? Math.min(duration - 0.001, index * videoFrameStepSeconds) : 0; + await seekVideo(video, Math.max(0, time)); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + frames.push({ + data: context.getImageData(0, 0, canvas.width, canvas.height).data, + height: canvas.height, + width: canvas.width, + }); + } + return frames; + } finally { + releaseVideo(video); } - const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; - const frameCount = Math.min(Math.max(Math.ceil(duration / videoFrameStepSeconds), 1), maxSeekFrameCount); - const frames = []; - for (let index = 0; index < frameCount; index += 1) { - const time = duration > 0 ? Math.min(duration - 0.001, index * videoFrameStepSeconds) : 0; - await seekVideo(video, Math.max(0, time)); - context.drawImage(video, 0, 0, canvas.width, canvas.height); - frames.push({ - data: context.getImageData(0, 0, canvas.width, canvas.height).data, - height: canvas.height, - width: canvas.width, - }); - } - URL.revokeObjectURL(video.src); - video.removeAttribute("src"); - video.load(); - return frames; } function loadVideo(file) { return new Promise((resolve, reject) => { const video = document.createElement("video"); + let settled = false; + let timeoutId = null; + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + window.clearTimeout(timeoutId); + video.onloadedmetadata = null; + video.onerror = null; + callback(value); + }; video.muted = true; video.playsInline = true; - video.preload = "metadata"; + video.preload = "auto"; video.src = URL.createObjectURL(file); - video.onloadedmetadata = () => resolve(video); + timeoutId = window.setTimeout(() => { + finish(reject, new Error("MP4 元数据读取超时")); + releaseVideo(video); + }, videoLoadTimeoutMs); + video.onloadedmetadata = () => finish(resolve, video); video.onerror = () => { - URL.revokeObjectURL(video.src); - reject(new Error("MP4 元数据读取失败")); + finish(reject, new Error("MP4 元数据读取失败")); + releaseVideo(video); }; }); } function seekVideo(video, time) { return new Promise((resolve, reject) => { + if (Math.abs((video.currentTime || 0) - time) < 0.001 && video.readyState >= 2) { + resolve(); + return; + } + let timeoutId = null; const done = () => { + window.clearTimeout(timeoutId); video.removeEventListener("seeked", done); video.removeEventListener("error", fail); resolve(); }; const fail = () => { + window.clearTimeout(timeoutId); video.removeEventListener("seeked", done); video.removeEventListener("error", fail); reject(new Error("MP4 画面解析失败")); }; + timeoutId = window.setTimeout(() => { + video.removeEventListener("seeked", done); + video.removeEventListener("error", fail); + reject(new Error("MP4 画面解析超时")); + }, videoSeekTimeoutMs); video.addEventListener("seeked", done, { once: true }); video.addEventListener("error", fail, { once: true }); - video.currentTime = time; + try { + video.currentTime = time; + } catch { + fail(); + } }); } +function waitForVideoCurrentData(video) { + return new Promise((resolve, reject) => { + if (video.readyState >= 2) { + resolve(); + return; + } + let timeoutId = null; + const done = () => { + window.clearTimeout(timeoutId); + video.removeEventListener("loadeddata", done); + video.removeEventListener("canplay", done); + video.removeEventListener("error", fail); + resolve(); + }; + const fail = () => { + window.clearTimeout(timeoutId); + video.removeEventListener("loadeddata", done); + video.removeEventListener("canplay", done); + video.removeEventListener("error", fail); + reject(new Error("MP4 画面读取失败")); + }; + timeoutId = window.setTimeout(() => { + video.removeEventListener("loadeddata", done); + video.removeEventListener("canplay", done); + video.removeEventListener("error", fail); + reject(new Error("MP4 画面读取超时")); + }, videoLoadTimeoutMs); + video.addEventListener("loadeddata", done, { once: true }); + video.addEventListener("canplay", done, { once: true }); + video.addEventListener("error", fail, { once: true }); + }); +} + +function releaseVideo(video) { + if (!video) { + return; + } + if (video.src) { + URL.revokeObjectURL(video.src); + } + video.removeAttribute("src"); + video.load(); +} + function scoreCandidate(frames, candidate) { const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate)); if (!scores.length) { diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 6274109..4ef3833 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -710,6 +710,7 @@ function ResourceBatchUploadDialog({ ) : null} 尚未选择资源文件夹; } @@ -761,6 +762,7 @@ function ResourceBatchPreview({ onChangeMp4Frame, onChangeMp4Layout, onConfirmMp {resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation} onChangeMp4Frame(index, frameKey, value)} onChangeLayout={(alphaLayout) => onChangeMp4Layout(index, alphaLayout)} @@ -772,13 +774,13 @@ function ResourceBatchPreview({ onChangeMp4Frame, onChangeMp4Layout, onConfirmMp ); } -function BatchMp4LayoutCell({ onChangeFrame, onChangeLayout, onConfirm, resource }) { +function BatchMp4LayoutCell({ mp4Parsing, onChangeFrame, onChangeLayout, onConfirm, resource }) { if (!isMp4File(resource.animationFile)) { return -; } const layout = mp4AlphaLayoutFromMetadata(resource.metadataJson); if (!layout) { - return {resource.mp4LayoutError || "解析失败"}; + return {mp4Parsing ? "解析中" : resource.mp4LayoutError || "解析失败"}; } return (