Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/web/__tests__/fixtures/converter-audio.mp4
Binary file not shown.
104 changes: 103 additions & 1 deletion apps/web/__tests__/unit/media-format-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,28 @@ 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", () => ({
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) =>
createElement("button", { ...props, type: "button" }),
}));
vi.mock("next/link", () => ({
default: ({ children }: PropsWithChildren) => children,
default: ({
children,
...props
}: PropsWithChildren<AnchorHTMLAttributes<HTMLAnchorElement>>) =>
createElement("a", props, children),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/tools/convert/mov-to-mp4",
Expand Down Expand Up @@ -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<HTMLAnchorElement>(
'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(),
);
});
74 changes: 56 additions & 18 deletions apps/web/components/tools/MediaFormatConverter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ";
Expand Down Expand Up @@ -394,28 +397,59 @@ export const MediaFormatConverter = ({
};

const extractAudioFromVideo = async (inputFile: File): Promise<void> => {
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();
},
Comment on lines +440 to 444

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cancellation shows a failure

Switching formats during MP3 conversion calls conversion.cancel(), which rejects the running conversion with ConversionCanceledError. The shared catch only checks MediaParser.hasBeenAborted, so users see “Conversion failed” and the page sends media_format_conversion_failed for work they intentionally stopped. Recognize ConversionCanceledError or translate it to the existing cancellation error before rethrowing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/components/tools/MediaFormatConverter.tsx
Line: 436-439

Comment:
**Cancellation shows a failure**

Switching formats during MP3 conversion calls `conversion.cancel()`, which rejects the running conversion with `ConversionCanceledError`. The shared catch only checks `MediaParser.hasBeenAborted`, so users see “Conversion failed” and the page sends `media_format_conversion_failed` for work they intentionally stopped. Recognize `ConversionCanceledError` or translate it to the existing cancellation error before rethrowing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in adf49e1. Switching formats mid-conversion now shows "Conversion was cancelled" and no longer tracks a failure. A cancel mid-write rejects with mediabunny's "Output has been canceled." rather than ConversionCanceledError (seen in Chromium and in the test), so the converter rethrows any error after a cancel as ConversionCanceledError and the catch recognises it by name, as the Loom import does. A new test switches to GIF during a real conversion.

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);
Expand All @@ -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();
}
};

Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading