Skip to content

[Guide/Feature] Fully working MV3 Chrome Extension implementation (Bypassing Worker CSP, Blob issues & exit(0) crash) #934

@CNCmaisdeMILL

Description

@CNCmaisdeMILL

(Description)**

### Is your feature request related to a problem? Please describe.
Developing Chrome Extensions using Manifest V3 (MV3) with `ffmpeg.wasm` is currently a nightmare for many developers. The strict Content Security Policy (CSP) in MV3 completely blocks the creation of Web Workers via `blob:` URLs, which breaks the standard `@ffmpeg/ffmpeg` wrapper. 

Furthermore, even when trying to use `@ffmpeg/core-st` inside an Offscreen Document, developers hit three major walls:
1. Emscripten's prompt asking for input (since there is no standard terminal).
2. The lack of standard documentation on how to manually pass arguments via pointers without the wrapper.
3. The `Program terminated with exit(0)` being thrown as a fatal error, crashing the JS execution despite the successful conversion.

### Describe the solution you'd like
We would like to propose adding an **Official MV3 Chrome Extension Guide** to the documentation, featuring a "Direct Core Injection" method. 

By bypassing the wrapper and interacting directly with the C++ core (`ccall`), we can execute FFmpeg synchronously in an Offscreen Document without triggering any CSP violations, Worker blocks, or memory leaks.

Here is the bulletproof implementation we successfully built and tested for MV3:

### Proposed Implementation Guide (Code Snippet)

**1. Load the core safely (No Blobs):**
Instead of using `load()`, inject the raw single-thread script directly into the DOM of the `offscreen.html`.

```javascript
// Load @ffmpeg/core-st safely
const script = document.createElement('script');
script.src = chrome.runtime.getURL('ffmpeg/ffmpeg-core.js');
document.head.appendChild(script);

2. The Bridge (Translating JS to C++ pointers):
Execute the command manually using _malloc and ccall to talk to the main function.

function executeFFmpeg(core, args) {
    const commandArgs = ['ffmpeg', ...args];
    
    // Allocate memory for strings
    const pointers = commandArgs.map((arg) => {
        const length = core.lengthBytesUTF8(arg) + 1;
        const pointer = core._malloc(length);
        core.stringToUTF8(arg, pointer, length);
        return pointer;
    });
    
    // Allocate memory for the argv array
    const argvPointer = core._malloc(pointers.length * 4);
    pointers.forEach((ptr, i) => core.setValue(argvPointer + (i * 4), ptr, 'i32'));

    // Call C++ main door directly
    const result = core.ccall('main', 'number', ['number', 'number'], [pointers.length, argvPointer]);

    // Cleanup to prevent memory leaks
    pointers.forEach(ptr => core._free(ptr));
    core._free(argvPointer);
    return result;
}

3. The Execution Flow (Handling -nostdin and exit(0)):

try {
    // -nostdin prevents the Emscripten prompt bug
    executeFFmpeg(core, [
        '-nostdin', '-y', '-i', 'input.audio', 
        '-c:a', 'libopus', '-b:a', '16k', 
        '-ac', '1', '-ar', '16000', '-f', 'ogg', 'output.ogg'
    ]);
} catch (error) {
    // Catch Emscripten's successful exit(0) signal
    const errMsg = error.message || String(error);
    if (errMsg.includes("exit(0)")) {
        console.log("Success! FFmpeg finished the job.");
    } else {
        throw error; // Real error
    }
}

Describe alternatives you've considered

We considered chunking files and passing them via postMessage to a sandboxed iframe, but that disables Chrome extension APIs and creates massive overhead for large media files. The Direct Core Injection using IndexedDB for storage is drastically faster (under 600ms for small audio files) and entirely native.

Additional context

Providing this as a boilerplate or documentation section for "Browser Extensions (Manifest V3)" will save countless hours for developers trying to implement offline audio/video processing in Chrome. We are sharing this because it works flawlessly in production!


Directory Structure & Data Flow (The Architecture)
To make this work flawlessly in MV3 without hitting message-passing memory limits (e.g., crashing when sending large Base64 strings between the background and offscreen), we use IndexedDB as a shared virtual hard drive.

Here is the exact file structure you need:

Plaintext
extension-root/
│
├── manifest.json         # MV3 Manifest (Requires 'offscreen', 'storage', and 'unlimitedStorage' permissions)
├── background.js         # Service Worker (Manages queues and creates the offscreen document)
├── db-utility.js         # IndexedDB Wrapper (Saves/Reads Blobs to the local browser storage)
├── offscreen.html        # The invisible DOM (Only imports db-utility.js and offscreen.js)
├── offscreen.js          # The Core Injector (Bridges JS and C++, reads/writes to IndexedDB)
│
└── ffmpeg/               # The raw Single-Thread core files (No @ffmpeg/ffmpeg wrappers!)
    ├── ffmpeg-core.js    # From @ffmpeg/core-st (v0.11.1)
    └── ffmpeg-core.wasm  # From @ffmpeg/core-st (v0.11.1)
How the Data Flows (The Secret Sauce):

Background.js downloads the audio/video file and saves the raw Blob directly into IndexedDB (db-utility.js).

Background.js sends a tiny ping to offscreen.js with just the ID of the file (e.g., { action: 'CONVERT', id: '123' }).

Offscreen.js wakes up, retrieves the Blob from IndexedDB, injects the .wasm core, and executes the C++ main function using pointers.

Once converted, offscreen.js saves the new .ogg Blob back into IndexedDB and replies success: true.

Background.js retrieves the final file from IndexedDB and delivers it to the active tab.

This architecture completely eliminates the Receiving end does not exist crashes, bypasses the CSP Blob blocks, and handles files of any size without RAM bottlenecks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions