fix(canvas): 预演录制转码 - 奇数分辨率白膜录像按偶数缩放并无音轨导出 MP4

WebGL 录制没有音轨,dpr 1.5 下宽高经常是奇数,libx264 的 yuv420p 会直接失败。转码改为偶数缩放、去掉音轨,并用 ultrafast 单线程适配关闭汇编的 ffmpeg 内核。
This commit is contained in:
ddcat666
2026-10-09 17:17:38 +08:00
parent c0a9250cb3
commit b7416b8346
3 changed files with 40 additions and 9 deletions

View File

@@ -0,0 +1,9 @@
// 画布录制转 MP4 的 ffmpeg 参数,纯函数便于单测。
// 预演台白膜用 WebGL canvas.captureStream 录制,没有音轨,画布在 dpr 1.5 下宽高经常是奇数。
// @ffmpeg/core 的 libx264 + yuv420p 要求宽高都能被 2 整除,奇数帧会直接失败:
// "width not divisible by 2" → exit 1,界面只看到「视频转码失败,请重试」。
// 该内核还关闭了 pthreads 和 asm,默认 medium preset 在视口分辨率下转不动,所以用 ultrafast 单线程。
export function buildRecordingTranscodeArgs(inputName: string, outputName: string): string[] {
return ["-i", inputName, "-an", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", outputName];
}

View File

@@ -3,6 +3,7 @@ import ffmpegCoreURL from "@ffmpeg/core?url";
import ffmpegWasmURL from "@ffmpeg/core/wasm?url";
import { fetchFile } from "@ffmpeg/util";
import { getMediaBlob } from "@/services/file-storage";
import { buildRecordingTranscodeArgs } from "./canvas-video-merge-args";
export type MergeVideoInput = { id: string; url?: string; storageKey?: string };
export type MergeVideoProgress = { phase: "loading" | "reading" | "encoding"; progress: number };
@@ -40,15 +41,21 @@ export async function transcodeVideoToMp4(input: Blob, onProgress?: (progress: M
try {
await ffmpeg.writeFile(inputName, await fetchFile(input));
onProgress?.({ phase: "encoding", progress: 55 });
const exitCode = await ffmpeg.exec([
"-i", inputName,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-movflags", "+faststart",
outputName,
]);
if (exitCode !== 0) throw new Error("视频转码失败,请重试");
const logs: string[] = [];
const onLog = ({ message }: { message: string }) => {
if (message) logs.push(message);
};
ffmpeg.on("log", onLog);
let exitCode = 1;
try {
exitCode = await ffmpeg.exec(buildRecordingTranscodeArgs(inputName, outputName));
} finally {
ffmpeg.off("log", onLog);
}
if (exitCode !== 0) {
console.error("白膜视频转码失败", logs.slice(-12));
throw new Error("视频转码失败,请重试");
}
const output = await ffmpeg.readFile(outputName);
onProgress?.({ phase: "encoding", progress: 100 });
return new Blob([output as BlobPart], { type: "video/mp4" });

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { buildRecordingTranscodeArgs } from "../src/lib/canvas/canvas-video-merge-args";
describe("buildRecordingTranscodeArgs", () => {
test("奇数画布先裁成偶数,并且不要求音轨", () => {
const args = buildRecordingTranscodeArgs("recording.webm", "recording-transcoded.mp4");
expect(args).toContain("-an");
expect(args).toContain("scale=trunc(iw/2)*2:trunc(ih/2)*2");
expect(args).not.toContain("-c:a");
expect(args.indexOf("-vf")).toBeGreaterThan(args.indexOf("-i"));
expect(args.indexOf("-vf")).toBeLessThan(args.indexOf("recording-transcoded.mp4"));
expect(args).toEqual(["-i", "recording.webm", "-an", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-threads", "1", "recording-transcoded.mp4"]);
});
});