From 48d753893ee973e054dee0c5ca1cd4723f79f52b Mon Sep 17 00:00:00 2001 From: Paddy <89168564+patrickballard@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:31:31 +0000 Subject: [PATCH 1/2] fix(web): make the MP4 to MP3 converter write MP3 The MP4 to MP3 tool asked Remotion for a WAV container and downloaded the result as .mp3. Convert with mediabunny, which the web app already uses, to an MP3 output encoded by its LAME extension, since browsers can't encode MP3 through WebCodecs. A video without an audio track now gets an error instead of an empty file. --- .../__tests__/fixtures/converter-audio.mp4 | Bin 0 -> 17840 bytes .../unit/media-format-converter.test.ts | 63 ++++++++++++++++++ .../components/tools/MediaFormatConverter.tsx | 63 +++++++++++++----- apps/web/package.json | 1 + bun.lock | 3 + 5 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 apps/web/__tests__/fixtures/converter-audio.mp4 diff --git a/apps/web/__tests__/fixtures/converter-audio.mp4 b/apps/web/__tests__/fixtures/converter-audio.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..24a2cff81de63f290bdfd1403e298672dc3a311a GIT binary patch literal 17840 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 literal 0 HcmV?d00001 diff --git a/apps/web/__tests__/unit/media-format-converter.test.ts b/apps/web/__tests__/unit/media-format-converter.test.ts index c4d26498e14..e3a514061ac 100644 --- a/apps/web/__tests__/unit/media-format-converter.test.ts +++ b/apps/web/__tests__/unit/media-format-converter.test.ts @@ -4,6 +4,7 @@ import { Blob, File } from "node:buffer"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseMedia } from "@remotion/media-parser"; +import { ALL_FORMATS, BlobSource, Input, MP3 } from "mediabunny"; import { type ButtonHTMLAttributes, createElement, @@ -213,3 +214,65 @@ it.each([ expect(container.textContent).toContain("Download GIF"); }, ); + +const convertToMp3 = async (fixture: string) => { + const bytes = await readFile(join(__dirname, `../fixtures/${fixture}`)); + root.render( + createElement(MediaFormatConverter, { + initialConversionPath: "mp4-to-mp3", + }), + ); + await vi.waitFor(() => + expect(container.querySelector("input[type=file]")).not.toBeNull(), + ); + const input = container.querySelector("input[type=file]") as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File([bytes], "clip.mp4", { type: "video/mp4" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => expect(container.textContent).toContain("clip.mp4")); + const convert = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Convert to MP3", + ); + if (!convert) throw new Error("Missing MP3 conversion action"); + convert.click(); +}; + +it("extracts an MP4's audio as an MP3", async () => { + await convertToMp3("converter-audio.mp4"); + + await vi.waitFor( + () => expect(container.textContent).toContain("Conversion complete!"), + { timeout: 10_000 }, + ); + if (!(output instanceof globalThis.Blob)) { + throw new Error("No converted audio was saved"); + } + expect(output.type).toBe("audio/mpeg"); + const audio = new Input({ + source: new BlobSource(output), + formats: ALL_FORMATS, + }); + expect(await audio.getFormat()).toBe(MP3); + expect((await audio.getPrimaryAudioTrack())?.codec).toBe("mp3"); + expect(await audio.computeDuration()).toBeCloseTo(0.25, 1); + let downloadedName: string | undefined; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + downloadedName = this.download; + }); + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Download MP3") + ?.click(); + expect(downloadedName).toBe("clip.mp3"); +}); + +it("says when an MP4 has no audio to extract", async () => { + await convertToMp3("gif-6s.mp4"); + + await vi.waitFor(() => + expect(container.textContent).toContain("This video has no audio track."), + ); + expect(output).toBeUndefined(); +}); diff --git a/apps/web/components/tools/MediaFormatConverter.tsx b/apps/web/components/tools/MediaFormatConverter.tsx index 16a45e44f99..37662b353f5 100644 --- a/apps/web/components/tools/MediaFormatConverter.tsx +++ b/apps/web/components/tools/MediaFormatConverter.tsx @@ -394,28 +394,57 @@ export const MediaFormatConverter = ({ }; const extractAudioFromVideo = async (inputFile: File): Promise => { + let input: import("mediabunny").Input | undefined; try { - const webcodecs = await import("@remotion/webcodecs"); + const { + ALL_FORMATS, + BlobSource, + BufferTarget, + Conversion, + Input, + Mp3OutputFormat, + Output, + canEncodeAudio, + } = await import("mediabunny"); + + // Browsers can't encode MP3 through WebCodecs, so use LAME instead. + if (!(await canEncodeAudio("mp3"))) { + const { registerMp3Encoder } = await import("@mediabunny/mp3-encoder"); + registerMp3Encoder(); + } - const _handleProgress = (progressEvent: { progress: number }) => { - setProgress(Math.min(Math.round(progressEvent.progress * 100), 99)); - }; + input = new Input({ + source: new BlobSource(inputFile), + formats: ALL_FORMATS, + }); + const target = new BufferTarget(); + const conversion = await Conversion.init({ + input, + output: new Output({ format: new Mp3OutputFormat(), target }), + video: { discard: true }, + showWarnings: false, + }); - const controller = webcodecs.webcodecsController(); - parserControllerRef.current = controller; + if (!conversion.isValid) { + throw new Error( + (await input.getPrimaryAudioTrack()) + ? "Your browser can't decode this video's audio." + : "This video has no audio track.", + ); + } - const result = await webcodecs.convertMedia({ - src: inputFile, - container: "wav", - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - setProgress(Math.min(Math.round(overallProgress * 100), 99)); - } + parserControllerRef.current = { + abort: () => { + void conversion.cancel(); }, - controller, - }); + }; + conversion.onProgress = (progress) => { + setProgress(Math.min(Math.round(progress * 100), 99)); + }; + await conversion.execute(); - const blob = await result.save(); + if (!target.buffer) throw new Error("No audio was written."); + const blob = new Blob([target.buffer], { type: "audio/mpeg" }); const url = URL.createObjectURL(blob); setOutputUrl(url); setProgress(100); @@ -429,6 +458,8 @@ export const MediaFormatConverter = ({ } catch (error) { console.error("Error extracting audio:", error); throw error; + } finally { + input?.dispose(); } }; diff --git a/apps/web/package.json b/apps/web/package.json index 9284b7e66dc..d3aedb7264b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,7 @@ "@hono/zod-validator": "^0.4.3", "@hookform/resolvers": "^3.3.2", "@kubiks/otel-drizzle": "^2.1.0", + "@mediabunny/mp3-encoder": "^1.62.1", "@modelcontextprotocol/ext-apps": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@mux/mux-node": "^8.5.1", diff --git a/bun.lock b/bun.lock index ae3266e557d..2c79bf1a6ce 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "@hono/zod-validator": "^0.4.3", "@hookform/resolvers": "^3.3.2", "@kubiks/otel-drizzle": "^2.1.0", + "@mediabunny/mp3-encoder": "^1.62.1", "@modelcontextprotocol/ext-apps": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@mux/mux-node": "^8.5.1", @@ -1454,6 +1455,8 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "2.0.14" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.62.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-v5L8Z9T298IcSWGvWPlGGNhoM2YzY+fOVAtdkwc6Leh8c1HXl5ivJVBKCcRZeCbp4LDJOSuwdu2SE/V67SZo7w=="], + "@mediabunny/server": ["@mediabunny/server@1.49.0", "", { "dependencies": { "node-av": "6.1.0" }, "peerDependencies": { "mediabunny": "^1.45.0" } }, "sha512-weIn+TdKPhy3+yoEGGHbBfTHxZIV7zNyq54mmd1ObZUXiigb+4ZeFjUeItN0xqHOp7NxQx11L44I9g35RhrsYg=="], "@minhducsun2002/leb128": ["@minhducsun2002/leb128@1.0.0", "", {}, "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g=="], From adf49e1455fab2292052843514a0548526b5f292 Mon Sep 17 00:00:00 2001 From: Paddy <89168564+patrickballard@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:12:40 +0000 Subject: [PATCH 2/2] fix(web): show a cancelled MP3 conversion as cancelled Switching formats during an MP3 conversion cancels it, but the converter only recognised Remotion's abort error, so it showed "Conversion failed: Output has been canceled." and tracked a failed conversion. A cancelled mediabunny conversion can reject with the output's own error rather than ConversionCanceledError, so rethrow any error after a cancel as ConversionCanceledError and treat that as a cancellation. --- .../unit/media-format-converter.test.ts | 43 ++++++++++++++++++- .../components/tools/MediaFormatConverter.tsx | 33 ++++++++------ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/web/__tests__/unit/media-format-converter.test.ts b/apps/web/__tests__/unit/media-format-converter.test.ts index e3a514061ac..f6912585779 100644 --- a/apps/web/__tests__/unit/media-format-converter.test.ts +++ b/apps/web/__tests__/unit/media-format-converter.test.ts @@ -4,14 +4,16 @@ import { Blob, File } from "node:buffer"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseMedia } from "@remotion/media-parser"; -import { ALL_FORMATS, BlobSource, Input, MP3 } from "mediabunny"; +import { ALL_FORMATS, BlobSource, Conversion, Input, MP3 } from "mediabunny"; import { + type AnchorHTMLAttributes, type ButtonHTMLAttributes, createElement, type PropsWithChildren, } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { trackEvent } from "@/app/utils/analytics"; import { MediaFormatConverter } from "@/components/tools/MediaFormatConverter"; vi.mock("@cap/ui", () => ({ @@ -19,7 +21,11 @@ vi.mock("@cap/ui", () => ({ createElement("button", { ...props, type: "button" }), })); vi.mock("next/link", () => ({ - default: ({ children }: PropsWithChildren) => children, + default: ({ + children, + ...props + }: PropsWithChildren>) => + createElement("a", props, children), })); vi.mock("next/navigation", () => ({ usePathname: () => "/tools/convert/mov-to-mp4", @@ -276,3 +282,36 @@ it("says when an MP4 has no audio to extract", async () => { ); expect(output).toBeUndefined(); }); + +it("says an MP3 conversion was cancelled when the format changes while converting", async () => { + const execute = Conversion.prototype.execute; + vi.spyOn(Conversion.prototype, "execute").mockImplementation(function ( + this: Conversion, + ) { + const onProgress = this.onProgress; + this.onProgress = (progress, processedTime) => { + onProgress?.(progress, processedTime); + if (progress < 1) { + this.onProgress = onProgress; + container + .querySelector( + 'a[aria-label="Convert to GIF format"]', + ) + ?.click(); + } + }; + return execute.call(this); + }); + vi.mocked(trackEvent).mockClear(); + + await convertToMp3("converter-audio.mp4"); + + await vi.waitFor(() => + expect(container.textContent).toContain("Conversion was cancelled"), + ); + expect(container.textContent).not.toContain("Conversion failed"); + expect(trackEvent).not.toHaveBeenCalledWith( + "media_format_conversion_failed", + expect.anything(), + ); +}); diff --git a/apps/web/components/tools/MediaFormatConverter.tsx b/apps/web/components/tools/MediaFormatConverter.tsx index 37662b353f5..c8fd01b763e 100644 --- a/apps/web/components/tools/MediaFormatConverter.tsx +++ b/apps/web/components/tools/MediaFormatConverter.tsx @@ -366,7 +366,10 @@ export const MediaFormatConverter = ({ } catch (err: any) { console.error("Detailed conversion error:", err); - if (MediaParser.hasBeenAborted?.(err)) { + if ( + MediaParser.hasBeenAborted?.(err) || + err?.name === "ConversionCanceledError" + ) { setError("Conversion was cancelled"); } else { let errorMessage = "Conversion failed: "; @@ -394,19 +397,20 @@ export const MediaFormatConverter = ({ }; const extractAudioFromVideo = async (inputFile: File): Promise => { + const { + ALL_FORMATS, + BlobSource, + BufferTarget, + Conversion, + ConversionCanceledError, + Input, + Mp3OutputFormat, + Output, + canEncodeAudio, + } = await import("mediabunny"); let input: import("mediabunny").Input | undefined; + let canceled = false; try { - const { - ALL_FORMATS, - BlobSource, - BufferTarget, - Conversion, - Input, - Mp3OutputFormat, - Output, - canEncodeAudio, - } = await import("mediabunny"); - // Browsers can't encode MP3 through WebCodecs, so use LAME instead. if (!(await canEncodeAudio("mp3"))) { const { registerMp3Encoder } = await import("@mediabunny/mp3-encoder"); @@ -435,6 +439,7 @@ export const MediaFormatConverter = ({ parserControllerRef.current = { abort: () => { + canceled = true; void conversion.cancel(); }, }; @@ -457,7 +462,9 @@ export const MediaFormatConverter = ({ }); } catch (error) { console.error("Error extracting audio:", error); - throw error; + // Canceling mid-write can reject with the output's "Output has been + // canceled." error rather than ConversionCanceledError. + throw canceled ? new ConversionCanceledError() : error; } finally { input?.dispose(); }