diff --git a/apps/web/__tests__/fixtures/converter-audio.mp4 b/apps/web/__tests__/fixtures/converter-audio.mp4 new file mode 100644 index 00000000000..24a2cff81de Binary files /dev/null and b/apps/web/__tests__/fixtures/converter-audio.mp4 differ diff --git a/apps/web/__tests__/unit/media-format-converter.test.ts b/apps/web/__tests__/unit/media-format-converter.test.ts index c4d26498e14..f6912585779 100644 --- a/apps/web/__tests__/unit/media-format-converter.test.ts +++ b/apps/web/__tests__/unit/media-format-converter.test.ts @@ -4,13 +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, 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", () => ({ @@ -18,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", @@ -213,3 +220,98 @@ 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(); +}); + +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 16a45e44f99..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,28 +397,59 @@ 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 webcodecs = await import("@remotion/webcodecs"); + // 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: () => { + canceled = true; + 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); @@ -428,7 +462,11 @@ 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(); } }; 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=="],