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
84 changes: 62 additions & 22 deletions src/background/src/services/ffmpeg-muxer.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,57 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";

export type MuxRequest = {
ffmpeg: FFmpeg;
outputFileName: string;
videoBlob?: Blob;
audioBlob?: Blob;
videoData?: Uint8Array;
audioData?: Uint8Array;
subtitleText?: string;
subtitleLanguage?: string;
};

export type MuxExecRequest = {
ffmpeg: FFmpeg;
outputFileName: string;
hasVideo: boolean;
hasAudio: boolean;
subtitleText?: string;
subtitleLanguage?: string;
};

export type MuxResult = { blob: Blob; mime: string };

export async function writeMediaToFFmpegFS(
ffmpeg: FFmpeg,
filename: string,
data: Uint8Array
): Promise<void> {
await ffmpeg.writeFile(filename, data);
}

async function writeSubtitles(
ffmpeg: FFmpeg,
subtitleText: string | undefined
) {
if (subtitleText === undefined) {
return;
}
const blob = new Blob([subtitleText], { type: "text/vtt" });
await ffmpeg.writeFile("subtitles.vtt", await fetchFile(blob));
await ffmpeg.writeFile("subtitles.vtt", new TextEncoder().encode(subtitleText));
}

export async function muxStreams({
export async function muxExec({
ffmpeg,
outputFileName,
videoBlob,
audioBlob,
hasVideo,
hasAudio,
subtitleText,
subtitleLanguage,
}: MuxRequest): Promise<MuxResult> {
}: MuxExecRequest): Promise<MuxResult> {
const includeSubtitles = subtitleText !== undefined;
const hasVideo = Boolean(videoBlob);
const hasAudio = Boolean(audioBlob);

if (hasVideo) {
await ffmpeg.writeFile("video.ts", await fetchFile(videoBlob!));
}
if (hasAudio) {
await ffmpeg.writeFile("audio.ts", await fetchFile(audioBlob!));
if (!hasVideo && !hasAudio) {
throw new Error("No media to mux");
}

await writeSubtitles(ffmpeg, subtitleText);

const args: string[] = ["-y"];
Expand All @@ -62,8 +73,8 @@ export async function muxStreams({
}
args.push("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc");
} else if (hasVideo) {
// Map all streams from the video file (preserves embedded audio)
args.push("-map", "0", "-c", "copy");
// Map video + optional embedded audio, skip data/metadata streams
args.push("-map", "0:v", "-map", "0:a?", "-c", "copy");
if (includeSubtitles) {
args.push("-map", "1:s:0", "-c:s", "webvtt");
}
Expand All @@ -81,19 +92,23 @@ export async function muxStreams({
if (includeSubtitles) {
args.push("-map", "1:s:0");
}
} else {
throw new Error("No media to mux");
}

if (includeSubtitles) {
args.push("-c:s", "webvtt");
args.push("-metadata:s:s:0", `language=${subtitleLanguage || "und"}`);
}

args.push("-shortest", outputFileName);
if (hasVideo && hasAudio) {
args.push("-shortest");
}
args.push(outputFileName);

try {
await ffmpeg.exec(args);
const exitCode = await ffmpeg.exec(args);
if (exitCode !== 0) {
throw new Error(`FFmpeg exited with code ${exitCode}`);
}
const data = await ffmpeg.readFile(outputFileName);
const mime = includeSubtitles ? "video/x-matroska" : "video/mp4";
return { blob: new Blob([data], { type: mime }), mime };
Expand All @@ -107,3 +122,28 @@ export async function muxStreams({
}
}
}

export async function muxStreams({
ffmpeg,
outputFileName,
videoData,
audioData,
subtitleText,
subtitleLanguage,
}: MuxRequest): Promise<MuxResult> {
if (videoData) {
await writeMediaToFFmpegFS(ffmpeg, "video.ts", videoData);
}
if (audioData) {
await writeMediaToFFmpegFS(ffmpeg, "audio.ts", audioData);
}

return muxExec({
ffmpeg,
outputFileName,
hasVideo: Boolean(videoData),
hasAudio: Boolean(audioData),
subtitleText,
subtitleLanguage,
});
}
77 changes: 61 additions & 16 deletions src/background/src/services/indexedb-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import browser from "webextension-polyfill";
import filenamify from "filenamify";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { muxStreams } from "./ffmpeg-muxer";
import { writeMediaToFFmpegFS, muxExec } from "./ffmpeg-muxer";

const buckets: Record<string, IndexedDBBucket> = {};
const chromeApi = (globalThis as any).chrome;
Expand Down Expand Up @@ -415,30 +415,40 @@ export class IndexedDBBucket implements Bucket {
// write somewhere predictable (avoid path/punctuation issues)
const outputFileName = includeSubtitles ? "output.mkv" : "output.mp4";

const videoBlob =
this.videoLength > 0
? await this.concatenateChunks(0, this.videoLength)
: undefined;
const audioBlob =
this.audioLength > 0
? await this.concatenateChunks(this.videoLength, this.audioLength)
: undefined;
const hasVideo = this.videoLength > 0;
const hasAudio = this.audioLength > 0;

try {
const result = await muxStreams({
// Phase 1: Load video, write to FFmpeg FS, release
if (hasVideo) {
const videoData = await this.concatenateChunksToUint8Array(0, this.videoLength);
await writeMediaToFFmpegFS(ffmpeg, "video.ts", videoData);
// videoData is block-scoped -- eligible for GC
}

// Phase 2: Load audio, write to FFmpeg FS, release
if (hasAudio) {
const audioData = await this.concatenateChunksToUint8Array(this.videoLength, this.audioLength);
await writeMediaToFFmpegFS(ffmpeg, "audio.ts", audioData);
// audioData is block-scoped -- eligible for GC
}

// Phase 3: Exec (no large data in JS heap)
const result = await muxExec({
ffmpeg,
outputFileName,
videoBlob,
audioBlob,
hasVideo,
hasAudio,
subtitleText: subtitle?.text,
subtitleLanguage: subtitle?.language,
});
onProgress?.(1, "Done");
return result.blob;
} catch (error) {
console.error(`Muxing failed for ${outputFileName}:`, error);
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Muxing failed (output file missing). Check audio/subtitle tracks and try again.`
`Muxing failed: ${detail}`
);
}
}
Expand Down Expand Up @@ -475,6 +485,30 @@ export class IndexedDBBucket implements Bucket {

return new Blob(chunks);
}

private async concatenateChunksToUint8Array(
startIndex: number,
length: number
): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let totalSize = 0;

for (let i = 0; i < length; i++) {
const chunk = await this.readChunkByIndex(startIndex + i);
if (chunk) {
chunks.push(chunk);
totalSize += chunk.byteLength;
}
}

const result = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
}

const cleanup: IFS["cleanup"] = async function () {
Expand Down Expand Up @@ -630,14 +664,25 @@ const saveAs: IFS["saveAs"] = async function (
}
const filename = filenamify(path ?? "stream.mp4").normalize("NFC");

await downloadsApi.download({
const downloadId = await downloadsApi.download({
url: link,
saveAs: dialog,
conflictAction: "uniquify",
filename,
});
// URL.revokeObjectURL(link);
return Promise.resolve();

if (link.startsWith("blob:") && downloadsApi.onChanged && typeof URL.revokeObjectURL === "function") {
const listener = (delta: { id: number; state?: { current: string } }) => {
if (delta.id !== downloadId) return;
if (!delta.state) return;
const state = delta.state.current;
if (state === "complete" || state === "interrupted") {
URL.revokeObjectURL(link);
downloadsApi.onChanged.removeListener(listener);
}
};
downloadsApi.onChanged.addListener(listener);
}
};

export const IndexedDBFS: IFS = {
Expand Down
116 changes: 116 additions & 0 deletions src/background/test/__snapshots__/mux-pipeline-e2e.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Mux Pipeline E2E (host ffmpeg) > audio-only → AAC MP4 1`] = `
{
"duration": "4.010000",
"sha256": "4ea88c669b04beef17d5c45ddf39e1b1fa19e177f82488b684188cd9173bc73d",
"sizeBytes": 100019,
"streams": [
{
"channels": 2,
"codec_name": "aac",
"codec_type": "audio",
"nb_frames": "189",
"sample_rate": "48000",
},
],
}
`;

exports[`Mux Pipeline E2E (host ffmpeg) > concat 2 muxed v+a segments → MP4 1`] = `
{
"duration": "6.073000",
"sha256": "a3d8cea48e0f77ac4143b5298ea4f199b1a8c8a49049ad265f876d1865b520e8",
"sizeBytes": 461794,
"streams": [
{
"codec_name": "h264",
"codec_type": "video",
"height": 480,
"nb_frames": "363",
"width": 848,
},
{
"channels": 2,
"codec_name": "aac",
"codec_type": "audio",
"nb_frames": "260",
"sample_rate": "44100",
},
],
}
`;

exports[`Mux Pipeline E2E (host ffmpeg) > muxed-with-data-stream → MP4, no data streams (#509) 1`] = `
{
"duration": "4.224000",
"sha256": "2b0c00e05930a0c975e1aa2eb47d0924428c5407d4d4897023c4c77f3c4c6360",
"sizeBytes": 55577,
"streams": [
{
"codec_name": "h264",
"codec_type": "video",
"height": 100,
"nb_frames": "96",
"width": 224,
},
{
"channels": 2,
"codec_name": "aac",
"codec_type": "audio",
"nb_frames": "190",
"sample_rate": "48000",
},
{
"channels": 1,
"codec_name": "aac",
"codec_type": "audio",
"nb_frames": "33",
"sample_rate": "8000",
},
],
}
`;

exports[`Mux Pipeline E2E (host ffmpeg) > separate video + audio → MP4 1`] = `
{
"duration": "3.000000",
"sha256": "12032a2dc38e4df2e65a0e611add8f7e9981488f38754efdd339c0e59869a85a",
"sizeBytes": 84240,
"streams": [
{
"codec_name": "h264",
"codec_type": "video",
"height": 184,
"nb_frames": "90",
"width": 320,
},
{
"channels": 2,
"codec_name": "aac",
"codec_type": "audio",
"nb_frames": "140",
"sample_rate": "48000",
},
],
}
`;

exports[`Mux Pipeline E2E (host ffmpeg) > video + subtitles → MKV 1`] = `
{
"duration": "3.000000",
"sizeBytes": 56239,
"streams": [
{
"codec_name": "h264",
"codec_type": "video",
"height": 184,
"width": 320,
},
{
"codec_name": "webvtt",
"codec_type": "subtitle",
},
],
}
`;
Loading