修复mp4解析问题

This commit is contained in:
zhx 2026-06-09 15:58:55 +08:00
parent 7740d611b6
commit e423adc738
2 changed files with 122 additions and 40 deletions

View File

@ -19,6 +19,8 @@ const transparentLayoutKinds = new Set([
]); ]);
const videoFrameStepSeconds = 1 / 24; const videoFrameStepSeconds = 1 / 24;
const maxSeekFrameCount = 360; const maxSeekFrameCount = 360;
const videoLoadTimeoutMs = 8000;
const videoSeekTimeoutMs = 5000;
export function isMp4File(file) { export function isMp4File(file) {
if (!file?.name) { if (!file?.name) {
@ -201,15 +203,15 @@ export async function mp4FileFromSource(source) {
export async function readMp4VideoInfo(file) { export async function readMp4VideoInfo(file) {
const video = await loadVideo(file); const video = await loadVideo(file);
const info = { try {
duration: Number.isFinite(video.duration) ? video.duration : 0, return {
height: video.videoHeight || 0, duration: Number.isFinite(video.duration) ? video.duration : 0,
width: video.videoWidth || 0, height: video.videoHeight || 0,
}; width: video.videoWidth || 0,
URL.revokeObjectURL(video.src); };
video.removeAttribute("src"); } finally {
video.load(); releaseVideo(video);
return info; }
} }
export function detectMp4AlphaLayoutFromFrames(frames) { export function detectMp4AlphaLayoutFromFrames(frames) {
@ -409,65 +411,143 @@ function roundConfidence(value) {
async function readVideoFrames(file) { async function readVideoFrames(file) {
const video = await loadVideo(file); const video = await loadVideo(file);
const canvas = document.createElement("canvas"); try {
canvas.width = video.videoWidth; const canvas = document.createElement("canvas");
canvas.height = video.videoHeight; canvas.width = video.videoWidth;
const context = canvas.getContext("2d", { willReadFrequently: true }); canvas.height = video.videoHeight;
if (!context || canvas.width <= 0 || canvas.height <= 0) { const context = canvas.getContext("2d", { willReadFrequently: true });
throw new Error("MP4 画面无法读取"); 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) { function loadVideo(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const video = document.createElement("video"); 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.muted = true;
video.playsInline = true; video.playsInline = true;
video.preload = "metadata"; video.preload = "auto";
video.src = URL.createObjectURL(file); 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 = () => { video.onerror = () => {
URL.revokeObjectURL(video.src); finish(reject, new Error("MP4 元数据读取失败"));
reject(new Error("MP4 元数据读取失败")); releaseVideo(video);
}; };
}); });
} }
function seekVideo(video, time) { function seekVideo(video, time) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (Math.abs((video.currentTime || 0) - time) < 0.001 && video.readyState >= 2) {
resolve();
return;
}
let timeoutId = null;
const done = () => { const done = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("seeked", done); video.removeEventListener("seeked", done);
video.removeEventListener("error", fail); video.removeEventListener("error", fail);
resolve(); resolve();
}; };
const fail = () => { const fail = () => {
window.clearTimeout(timeoutId);
video.removeEventListener("seeked", done); video.removeEventListener("seeked", done);
video.removeEventListener("error", fail); video.removeEventListener("error", fail);
reject(new Error("MP4 画面解析失败")); 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("seeked", done, { once: true });
video.addEventListener("error", fail, { 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) { function scoreCandidate(frames, candidate) {
const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate)); const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate));
if (!scores.length) { if (!scores.length) {

View File

@ -710,6 +710,7 @@ function ResourceBatchUploadDialog({
</div> </div>
) : null} ) : null}
<ResourceBatchPreview <ResourceBatchPreview
mp4Parsing={progress.running && String(progress.label || "").includes("解析 MP4")}
resources={plan.resources} resources={plan.resources}
onChangeMp4Frame={onChangeMp4Frame} onChangeMp4Frame={onChangeMp4Frame}
onChangeMp4Layout={onChangeMp4Layout} onChangeMp4Layout={onChangeMp4Layout}
@ -738,7 +739,7 @@ function ResourceBatchRules() {
); );
} }
function ResourceBatchPreview({ onChangeMp4Frame, onChangeMp4Layout, onConfirmMp4Layout, resources }) { function ResourceBatchPreview({ mp4Parsing, onChangeMp4Frame, onChangeMp4Layout, onConfirmMp4Layout, resources }) {
if (!resources.length) { if (!resources.length) {
return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>; return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>;
} }
@ -761,6 +762,7 @@ function ResourceBatchPreview({ onChangeMp4Frame, onChangeMp4Layout, onConfirmMp
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation} {resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
</span> </span>
<BatchMp4LayoutCell <BatchMp4LayoutCell
mp4Parsing={mp4Parsing}
resource={resource} resource={resource}
onChangeFrame={(frameKey, value) => onChangeMp4Frame(index, frameKey, value)} onChangeFrame={(frameKey, value) => onChangeMp4Frame(index, frameKey, value)}
onChangeLayout={(alphaLayout) => onChangeMp4Layout(index, alphaLayout)} 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)) { if (!isMp4File(resource.animationFile)) {
return <span className={styles.meta}>-</span>; return <span className={styles.meta}>-</span>;
} }
const layout = mp4AlphaLayoutFromMetadata(resource.metadataJson); const layout = mp4AlphaLayoutFromMetadata(resource.metadataJson);
if (!layout) { if (!layout) {
return <span className={styles.meta}>{resource.mp4LayoutError || "解析失败"}</span>; return <span className={styles.meta}>{mp4Parsing ? "解析中" : resource.mp4LayoutError || "解析失败"}</span>;
} }
return ( return (
<div className={styles.mp4BatchCell}> <div className={styles.mp4BatchCell}>