diff --git a/web/src/lib/canvas/canvas-video-merge-args.ts b/web/src/lib/canvas/canvas-video-merge-args.ts new file mode 100644 index 000000000..07c93cb81 --- /dev/null +++ b/web/src/lib/canvas/canvas-video-merge-args.ts @@ -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]; +} diff --git a/web/src/lib/canvas/canvas-video-merge.ts b/web/src/lib/canvas/canvas-video-merge.ts index 27f10692c..430e4fae1 100644 --- a/web/src/lib/canvas/canvas-video-merge.ts +++ b/web/src/lib/canvas/canvas-video-merge.ts @@ -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" }); diff --git a/web/test/canvas-video-merge-args.test.ts b/web/test/canvas-video-merge-args.test.ts new file mode 100644 index 000000000..f39189d4f --- /dev/null +++ b/web/test/canvas-video-merge-args.test.ts @@ -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"]); + }); +});