From 9a4d14613ff467b1e3ee87737dd31fcddcc0d5c1 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 12 Aug 2026 15:41:40 +0200 Subject: [PATCH] feat: replace the archive.org asm.js encoder with vendored ffmpeg.wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MP4/GIF export used to importScripts() an 18.5 MB asm.js ffmpeg build from https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js: no integrity check, no pinning, executed in the page, and unavailable offline. vendor.mjs now copies ffmpeg.wasm out of node_modules, where package-lock.json pins it by hash, and no CDN fallback is left anywhere in the app. @ffmpeg/core-st is the single-threaded core, chosen deliberately: the default @ffmpeg/core is built with pthreads and needs SharedArrayBuffer, which requires COOP/COEP isolation, which would break the Pixabay, Unsplash and Google Fonts requests. That core also forces two things worth knowing: - mainName: 'main' is mandatory. The loader defaults to proxy_main, which only the multi-threaded build exports, so load() compiles all 23 MB and then aborts. - Its main() calls exit(), so an instance survives exactly one command. Reusing one dies with "Program terminated with exit(0)", so convertStreams builds and tears one down per conversion (~110 ms, and the 23 MB heap comes back in between). The teardown also runs on failure: an interrupted run otherwise leaves the loader's "running" flag set and wedges every later conversion until a page reload. MP4 encodes with libx264 -crf 23 -pix_fmt yuv420p plus AAC rather than mpeg4 -b:v 6400k. Same core, better quality per byte, and yuv420p is what makes it play in Safari and QuickTime. The two @ffmpeg packages are dependencies, not devDependencies, so the Docker vendor stage can npm ci --omit=dev without pulling in electron; build.files excludes them from the asar since src/vendor/ffmpeg/ already carries the copies the app loads. WITH_FFMPEG=0 now means MP4/GIF export is unavailable and says so, rather than silently fetching an encoder at run time. Also deletes src/js/libraries/ffmpeg.min.js, an unreferenced ffmpeg.wasm loader stub that would have fetched its core from unpkg, and prunes the stale src/vendor/ffmpeg_asm.js from existing checkouts — src/vendor/ is packaged whole, so it would have shipped 18.5 MB of dead weight in every installer. Verified in Chromium against a real MediaRecorder WebM: core loads with crossOriginIsolated false, MP4 24 KB decoding to 320x240 / 2.00 s, GIF 138 KB, the two back to back, and the missing-core path reporting correctly. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 12 +- PACKAGING.md | 39 ++++- README.md | 1 + TODO-FIXES.md | 50 ++++++- package-lock.json | 86 +++++++++++ package.json | 26 +++- scripts/vendor.mjs | 70 +++++++-- src/index.html | 3 + src/js/converter.js | 262 +++++++++++++++++---------------- src/js/libraries/ffmpeg.min.js | 2 - 10 files changed, 394 insertions(+), 157 deletions(-) delete mode 100644 src/js/libraries/ffmpeg.min.js diff --git a/Dockerfile b/Dockerfile index 44d9cde..5fed10a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,13 +6,19 @@ # Build: docker build -t motionity:latest . # Run: docker run --rm -p 8080:8080 motionity:latest # -# WITH_FFMPEG=0 drops the 18.5 MB asm.js ffmpeg build from the image. MP4/GIF -# export then downloads it from archive.org on first use instead of working -# offline; everything else is unaffected. +# WITH_FFMPEG=0 drops the 23 MB ffmpeg.wasm core from the image and disables +# MP4/GIF export, which then reports itself as unavailable. It no longer falls +# back to downloading the encoder at run time: the old asm.js build came from a +# public archive.org mirror with no integrity check. Everything else, WEBM +# export included, is unaffected. FROM node:22-alpine AS vendor ARG WITH_FFMPEG=1 WORKDIR /app +# ffmpeg.wasm is a runtime dependency, so --omit=dev pulls it in without +# dragging electron and electron-builder (~400 MB) into the build. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund COPY scripts/vendor.mjs scripts/ COPY src/index.html src/ RUN if [ "$WITH_FFMPEG" = "1" ]; then \ diff --git a/PACKAGING.md b/PACKAGING.md index cc39592..e24d21b 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -11,18 +11,37 @@ Three distribution targets share one source tree (`src/`, a plain static app): ## 0. One prerequisite for every target: vendor the assets ```bash -npm install # only needed for the desktop builds -npm run vendor # ~20 MB, writes src/vendor/ (gitignored) +npm install # now required for every target, not just the desktop ones +npm run vendor # ~24 MB, writes src/vendor/ (gitignored) ``` `index.html` used to pull jQuery, fabric.js, lottie, pickr, selection-js, the WebFont loader and Inter from five different CDNs. `scripts/vendor.mjs` -downloads all of them plus the 18.5 MB asm.js ffmpeg build into `src/vendor/` -and the app now references only those local copies. Without this step the page -loads but every script tag 404s. +downloads all of them into `src/vendor/` and the app now references only those +local copies. Without this step the page loads but every script tag 404s. -The Docker build runs the vendor step inside the image, so it is the one target -where you can skip it locally. +ffmpeg.wasm is handled differently: `vendor.mjs` **copies** it out of +`node_modules` instead of downloading it, which is why `npm install` is now a +prerequisite everywhere. `package-lock.json` pins those packages by integrity +hash, so the bytes that reach the image are the bytes npm verified. The asm.js +build this replaced was fetched from a public archive.org mirror with no +integrity check of any kind — a changed object there would have executed in the +page unnoticed. + +Two consequences worth knowing: + +- `@ffmpeg/core-st` is the **single-threaded** core, chosen deliberately. The + default `@ffmpeg/core` is built with pthreads and needs `SharedArrayBuffer`, + which requires COOP/COEP cross-origin isolation, which would break the + Pixabay, Unsplash and Google Fonts requests the editor makes. +- The two `@ffmpeg/*` packages are `dependencies`, not `devDependencies`, so the + Docker vendor stage can `npm ci --omit=dev` without pulling in electron. That + would make electron-builder bundle them into the asar as well, so + `build.files` excludes `node_modules/@ffmpeg/**` — the copies under + `src/vendor/ffmpeg/` are the ones the app loads. + +The Docker build runs `npm ci` and the vendor step inside the image, so it is +the one target where you can skip both locally. ## 1. Desktop — Electron @@ -139,7 +158,8 @@ app itself: 46 MB of bundled stock media plus 20 MB of vendored libraries. Two knobs if the size matters more than offline completeness: ```bash -# -18.5 MB: MP4/GIF export fetches ffmpeg from archive.org on first use +# -23 MB: MP4/GIF export reports itself unavailable (WEBM export is unaffected). +# There is no runtime download to fall back on any more, by design. docker build --build-arg WITH_FFMPEG=0 -t motionity:slim . # -33 MB: drop the bundled royalty-free music library (removes the Audio panel @@ -189,3 +209,6 @@ online by design, and degrade quietly rather than breaking: falls back to a system font. Only Inter (the UI font) is bundled. 2. **Pixabay** — the Images, Videos and Audio browsers search Pixabay live. 3. **Unsplash** — sample images on the empty-state screen. + +MP4 and GIF export used to be a fourth: the encoder came from archive.org at +conversion time. It is now vendored, so export works fully offline. diff --git a/README.md b/README.md index 671e91f..ca2e9d7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ This is a fork of the original project aiming to fix issues and add features. ## Running it ```bash +npm install # ffmpeg.wasm is vendored out of node_modules, so this is required npm run vendor # downloads the third-party libraries into src/vendor/ npm start # http://127.0.0.1:8080 ``` diff --git a/TODO-FIXES.md b/TODO-FIXES.md index 6aa3a8e..097a382 100644 --- a/TODO-FIXES.md +++ b/TODO-FIXES.md @@ -255,9 +255,53 @@ inspection. - **`src/js/encode-worker.js`** is still unreferenced. It was the old File-System-Access-API sketch; `render.js` supersedes it and buffers to memory instead of requiring a save-file picker. Deleting it is a product decision. -- **MP4 and GIF** still go through the ancient asm.js ffmpeg worker - (`converter.js`, loaded from archive.org). The frame-accurate renderer feeds it - a better WebM, but that dependency is unchanged and may be offline. +- **MP4 and GIF** go through ffmpeg.wasm (`converter.js`), vendored out of + `node_modules` and pinned by `package-lock.json`. This replaced the asm.js + worker loaded from archive.org, which had no integrity check and needed the + network. See "ffmpeg.wasm migration" below. + +--- + +# ffmpeg.wasm migration (new) + +MP4/GIF export used to `importScripts()` an 18.5 MB asm.js ffmpeg build from +`https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js` — no integrity check, no +pinning, and executed in the page. `scripts/vendor.mjs` now **copies** +ffmpeg.wasm out of `node_modules`, where `package-lock.json` pins it by hash. +There is no CDN fallback left anywhere in the app. + +Three things this ran into, all of which cost a debugging round: + +- **`@ffmpeg/core` needs `SharedArrayBuffer`.** The default core is built with + pthreads, which requires COOP/COEP cross-origin isolation, which would break + the Pixabay, Unsplash and Google Fonts requests. `@ffmpeg/core-st` — the + single-threaded build — is used instead. Verified: the core loads with + `crossOriginIsolated === false` and `SharedArrayBuffer` undefined. +- **`mainName: 'main'` is mandatory with that core.** The loader defaults to + `proxy_main`, which only the multi-threaded build exports. Without it, `load()` + compiles all 23 MB and then aborts with *Cannot call unknown function + proxy_main*. +- **One conversion per load.** The single-threaded core's `main` calls `exit()`, + so a second `run()` on the same instance dies with *Program terminated with + exit(0)*. `convertStreams` therefore builds and tears down an instance per + conversion — measured at ~110 ms, and it returns the 23 MB heap in between. + The teardown also runs on failure: an interrupted run otherwise leaves the + loader's internal "running" flag set and every later conversion fails with + *can only run one command at a time* until the page is reloaded. + +MP4 now encodes with `libx264 -crf 23 -pix_fmt yuv420p` plus AAC audio rather +than `mpeg4 -b:v 6400k`. Same core, better quality per byte, and `yuv420p` is +what makes it play in Safari and QuickTime. + +`WITH_FFMPEG=0` no longer means "download it at run time" — it means MP4/GIF +export is unavailable, and `converter.js` says so instead of failing obscurely. + +Verified in Chromium against a real MediaRecorder WebM: MP4 24 KB with an +`ftypisom` header that decodes to 320x240 / 2.00 s, GIF 138 KB with a `GIF89a` +header, the two run back to back, and the missing-core path produces the right +message. + +--- ## Not verified diff --git a/package-lock.json b/package-lock.json index 577acf2..0ebc187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "motionity", "version": "1.0.0", "license": "MIT", + "dependencies": { + "@ffmpeg/core-st": "^0.11.1", + "@ffmpeg/ffmpeg": "^0.11.6" + }, "devDependencies": { "electron": "^40.0.0", "electron-builder": "^26.0.0" @@ -317,6 +321,27 @@ "node": ">=14.14" } }, + "node_modules/@ffmpeg/core-st": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@ffmpeg/core-st/-/core-st-0.11.1.tgz", + "integrity": "sha512-8R0kdXjQjjOgVaChDMUx/abrTD5/g9JFnuZLqB+lvzJbfNpNEFEZPxFR1Fu4eoON+fVq3K3URVKZcHEEGKZVTQ==", + "license": "MIT" + }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.11.6.tgz", + "integrity": "sha512-uN8J8KDjADEavPhNva6tYO9Fj0lWs9z82swF3YXnTxWMBoFLGq3LZ6FLlIldRKEzhOBKnkVfA8UnFJuvGvNxcA==", + "license": "MIT", + "dependencies": { + "is-url": "^1.2.4", + "node-fetch": "^2.6.1", + "regenerator-runtime": "^0.13.7", + "resolve-url": "^0.2.1" + }, + "engines": { + "node": ">=12.16.1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2192,6 +2217,12 @@ "node": ">=8" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2531,6 +2562,26 @@ "semver": "^7.3.5" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -2942,6 +2993,12 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2987,6 +3044,13 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "license": "MIT" + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -3365,6 +3429,12 @@ "tmp": "^0.2.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -3481,6 +3551,22 @@ "tslib": "^2.8.1" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", diff --git a/package.json b/package.json index 09315ac..b385129 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease", "test": "node --test \"test/**/*.test.js\"" }, + "dependencies": { + "@ffmpeg/core-st": "^0.11.1", + "@ffmpeg/ffmpeg": "^0.11.6" + }, "devDependencies": { "electron": "^40.0.0", "electron-builder": "^26.0.0" @@ -38,12 +42,23 @@ "electron/**/*", "scripts/server.cjs", "src/**/*", - "!src/**/*.map" + "!src/**/*.map", + "!node_modules/@ffmpeg/**" ], "win": { "target": [ - { "target": "nsis", "arch": ["x64"] }, - { "target": "portable", "arch": ["x64"] } + { + "target": "nsis", + "arch": [ + "x64" + ] + }, + { + "target": "portable", + "arch": [ + "x64" + ] + } ], "icon": "build/icon.png" }, @@ -55,7 +70,10 @@ "shortcutName": "Motionity" }, "linux": { - "target": ["AppImage", "flatpak"], + "target": [ + "AppImage", + "flatpak" + ], "icon": "build/icon.png", "category": "Graphics", "synopsis": "Motion graphics editor", diff --git a/scripts/vendor.mjs b/scripts/vendor.mjs index e69f461..b84406e 100644 --- a/scripts/vendor.mjs +++ b/scripts/vendor.mjs @@ -8,7 +8,7 @@ // fallback font when offline. import { createHash } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { existsSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const vendorDir = join(root, 'src', 'vendor'); const fontsDir = join(vendorDir, 'fonts'); +const ffmpegDir = join(vendorDir, 'ffmpeg'); // A desktop UA is required for the Google Fonts API to answer with woff2 // instead of the ancient truetype payload. @@ -52,14 +53,57 @@ const assets = [ url: 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js', file: 'webfont.js', }, - { - // ~18.5 MB asm.js build of ffmpeg, used by converter.js for MP4/GIF export. - url: 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js', - file: 'ffmpeg_asm.js', - optional: true, - }, ]; +// ffmpeg.wasm, used by converter.js for MP4/GIF export. Copied out of +// node_modules rather than downloaded: package-lock.json pins these by integrity +// hash, so the bytes that land in the image are the bytes npm verified. The +// asm.js build this replaced came from a public archive.org mirror with no +// integrity check at all. +// +// The loader's lazily-loaded webpack chunk has to sit beside it, and the core's +// .wasm and .worker.js beside ffmpeg-core.js — the loader derives both paths by +// substitution, so the layout is not negotiable. +const ffmpegFiles = [ + { from: '@ffmpeg/ffmpeg/dist/ffmpeg.min.js', to: 'ffmpeg.min.js' }, + { from: '@ffmpeg/ffmpeg/dist/046d0074eee1d99a674a.js', to: '046d0074eee1d99a674a.js' }, + { from: '@ffmpeg/core-st/dist/ffmpeg-core.js', to: 'ffmpeg-core.js' }, + { from: '@ffmpeg/core-st/dist/ffmpeg-core.worker.js', to: 'ffmpeg-core.worker.js' }, + // 23 MB, and the only reason --skip-ffmpeg exists. + { from: '@ffmpeg/core-st/dist/ffmpeg-core.wasm', to: 'ffmpeg-core.wasm', heavy: true }, +]; + +// core-st is the single-threaded core on purpose: the default @ffmpeg/core is +// built with pthreads and needs SharedArrayBuffer, which means COOP/COEP +// isolation, which would break the Pixabay, Unsplash and Google Fonts requests. +async function vendorFfmpeg({ skipHeavy }) { + // An existing checkout still has the 18.5 MB asm.js blob the archive.org path + // left behind. Nothing loads it any more, but src/vendor/ is packaged whole, + // so it would ship in every installer until someone noticed. + const legacy = join(vendorDir, 'ffmpeg_asm.js'); + if (existsSync(legacy)) { + await rm(legacy); + console.log(' prune src/vendor/ffmpeg_asm.js (replaced by ffmpeg.wasm)'); + } + + await mkdir(ffmpegDir, { recursive: true }); + for (const { from, to, heavy } of ffmpegFiles) { + const src = join(root, 'node_modules', from); + if (heavy && skipHeavy) { + console.log(` omit src/vendor/ffmpeg/${to} (--skip-ffmpeg)`); + continue; + } + if (!existsSync(src)) { + throw new Error( + `${from} is missing — run "npm install" before vendoring (ffmpeg.wasm now ` + + `comes from node_modules, not a CDN).` + ); + } + await copyFile(src, join(ffmpegDir, to)); + console.log(` copy src/vendor/ffmpeg/${to} (${human(statSync(src).size)})`); + } +} + const fontCss = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'; @@ -106,20 +150,18 @@ async function vendorFonts({ force }) { } const force = process.argv.includes('--force'); -// Docker builds can drop the 18.5 MB ffmpeg blob; MP4/GIF export then falls -// back to fetching it from the public mirror at conversion time. -const skipOptional = process.argv.includes('--skip-ffmpeg'); +// Docker builds can drop the 23 MB ffmpeg core. Unlike the old asm.js build +// there is no runtime mirror to fall back to, so this now means MP4/GIF export +// is unavailable in that image — converter.js says so rather than failing late. +const skipFfmpeg = process.argv.includes('--skip-ffmpeg'); await mkdir(fontsDir, { recursive: true }); console.log(`Vendoring third-party assets into src/vendor/`); for (const asset of assets) { - if (asset.optional && skipOptional) { - console.log(` omit src/vendor/${asset.file} (--skip-ffmpeg)`); - continue; - } await download(asset.url, join(vendorDir, asset.file), { force }); } await vendorFonts({ force }); +await vendorFfmpeg({ skipHeavy: skipFfmpeg }); // Sanity check: index.html must not have regained a CDN reference. const html = await readFile(join(root, 'src', 'index.html'), 'utf8'); diff --git a/src/index.html b/src/index.html index c180706..8b20c0e 100644 --- a/src/index.html +++ b/src/index.html @@ -390,6 +390,9 @@ + + diff --git a/src/js/converter.js b/src/js/converter.js index 928555c..d4e9330 100644 --- a/src/js/converter.js +++ b/src/js/converter.js @@ -1,147 +1,163 @@ -// MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg. -// Packaged builds ship it locally (npm run vendor); a plain checkout falls back -// to the public mirror, which needs network access. -var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js'; -var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; -var ffmpegAsmUrlPromise = null; +// MP4/GIF export transcodes the captured WebM with ffmpeg.wasm. +// +// Everything is served from vendor/ffmpeg/, vendored out of node_modules by +// `npm run vendor` and pinned by package-lock.json. There is deliberately no +// CDN fallback: the asm.js build this replaced was fetched from a public +// archive.org mirror with no integrity check, so a changed object there would +// have run in the page unnoticed. +// +// The core is the single-threaded build. The default multi-threaded one needs +// SharedArrayBuffer, which requires COOP/COEP isolation, which would break the +// Pixabay, Unsplash and Google Fonts requests the editor makes. -// The worker is built from a blob, so importScripts() needs an absolute URL. -function resolveFfmpegAsmUrl() { - if (!ffmpegAsmUrlPromise) { - ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' }) - .then(function (res) { - // A catch-all/SPA route answers 200 with HTML; that is not the script. - var type = res.headers.get('content-type') || ''; - var local = res.ok && type.indexOf('text/html') === -1; - return local - ? new URL(FFMPEG_ASM_LOCAL, location.href).href - : FFMPEG_ASM_REMOTE; - }) - .catch(function () { - return FFMPEG_ASM_REMOTE; - }); +var FFMPEG_DIR = 'vendor/ffmpeg/'; +var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js'; +var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm'; +var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js'; + +// One instance per conversion, deliberately not cached. The single-threaded +// core's `main` calls exit() when the command finishes, which tears the wasm +// runtime down: a second run on the same instance dies with "Program terminated +// with exit(0)". Reloading costs about 110ms and returns the 23 MB heap in +// between, so this is cheaper than it looks. +var ffmpegBusy = false; + +function ffmpegAvailable() { + return typeof FFmpeg !== 'undefined' && typeof FFmpeg.createFFmpeg === 'function'; +} + +// The loader resolves the core through `new URL(corePath, import.meta.url)`, +// which points at the bundle rather than the page once it is minified. Passing +// all three paths absolute skips that resolution entirely. +function absolute(path) { + return new URL(path, location.href).href; +} + +async function loadFfmpeg() { + if (!ffmpegAvailable()) { + throw new Error( + 'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/' + ); } - return ffmpegAsmUrlPromise; -} -function processInWebWorker(workerPath) { - var blob = URL.createObjectURL( - new Blob( - [ - 'importScripts("' + - workerPath + - '");var now = Date.now;function print(text) {postMessage({"type" : "stdout","data" : text});};onmessage = function(event) {var message = event.data;if (message.type === "command") {var Module = {print: print,printErr: print,files: message.files || [],arguments: message.arguments || [],TOTAL_MEMORY: message.TOTAL_MEMORY||536870912 || false};postMessage({"type" : "start","data" : Module.arguments.join(" ")});postMessage({"type" : "stdout","data" : "Received command: " +Module.arguments.join(" ") +((Module.TOTAL_MEMORY ) ? ". Processing with " + Module.TOTAL_MEMORY + " bits." : "")});var time = now();var result = ffmpeg_run(Module);var totalTime = now() - time;postMessage({"type" : "stdout","data" : "Finished processing (took " + totalTime + "ms)"});postMessage({"type" : "done","data" : result,"time" : totalTime});}};postMessage({"type" : "ready"});', - ], - { - type: 'application/javascript', + // A build made with WITH_FFMPEG=0 ships the loader but not the 23 MB core, + // so check before paying for the load and report it as a build choice + // rather than a failure. + var head = await fetch(FFMPEG_WASM, { method: 'HEAD' }).catch(function () { + return null; + }); + if (!head || !head.ok) { + throw new Error( + 'this build ships without the ffmpeg core (WITH_FFMPEG=0), so MP4 and GIF ' + + 'export are unavailable. WEBM export always works.' + ); + } + + var instance = FFmpeg.createFFmpeg({ + corePath: absolute(FFMPEG_CORE), + wasmPath: absolute(FFMPEG_WASM), + workerPath: absolute(FFMPEG_WORKER), + // The loader defaults to the entry point of the multi-threaded core; the + // single-threaded one exports plain `main`. Without this, load() gets as + // far as compiling the 23 MB wasm and then aborts with + // "Cannot call unknown function proxy_main". + mainName: 'main', + log: false, + logger: function (entry) { + if (entry.type === 'fferr') console.debug('[ffmpeg]', entry.message); + }, + progress: function (entry) { + if (typeof entry.ratio === 'number' && entry.ratio >= 0 && entry.ratio <= 1) { + $('#download-real').html('Converting ' + Math.round(entry.ratio * 100) + '%'); } - ) - ); - - var worker = new Worker(blob); - URL.revokeObjectURL(blob); - return worker; + }, + }); + await instance.load(); + return instance; } -var worker; -// The worker is created once and reused, so its "ready" handshake only ever -// arrives for the first conversion. Remember it across calls. -var workerIsReady = false; +// The recording was made at this rate, so the transcode has to keep it: a +// different -r would duplicate or drop frames and drift the timing. +function conversionArgs(setting, fps) { + if (setting === 'gif') { + return ['-i', 'input.webm', '-r', String(fps), 'output.gif']; + } + // libx264 rather than the mpeg4 the asm.js path used: same core, far better + // quality per byte, and it plays in Safari and QuickTime. yuv420p is what + // makes that true — x264 defaults to yuv444p here, which they refuse. + return [ + '-i', 'input.webm', + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-crf', '23', + '-pix_fmt', 'yuv420p', + '-r', String(fps), + '-c:a', 'aac', + '-b:a', '192k', + 'output.mp4', + ]; +} async function convertStreams(videoBlob, setting) { - var aab; - var buffersReady = false; - var posted = false; + var outputName = setting === 'gif' ? 'output.gif' : 'output.mp4'; + var mimeType = setting === 'gif' ? 'image/gif' : 'video/mp4'; function convertFailed(reason) { console.error('Conversion failed: ' + reason); alert( 'Sorry, the ' + setting.toUpperCase() + - ' conversion failed. The WEBM format is always available.' + ' conversion failed. The WEBM format is always available.\n\n' + + reason ); resetRecordingUI(); } - var fileReader = new FileReader(); - fileReader.onload = function () { - aab = this.result; - buffersReady = true; - if (workerIsReady) postMessage(); - }; - fileReader.onerror = function () { - convertFailed('could not read the recorded video'); - }; - fileReader.readAsArrayBuffer(videoBlob); - - if (!worker) { - // Safe to await here: workerIsReady is still false, so the FileReader - // callback cannot post a command before the worker exists. - worker = processInWebWorker(await resolveFfmpegAsmUrl()); + if (setting !== 'gif' && setting !== 'mp4') { + convertFailed('unknown output format "' + setting + '"'); + return; } - worker.onerror = function (e) { - convertFailed(e.message || 'worker error'); - }; - worker.onmessage = function (event) { - var message = event.data; - if (message.type == 'ready') { - workerIsReady = true; - if (buffersReady) postMessage(); - } else if (message.type == 'done') { - var result = message.data && message.data[0]; - if (!result || !result.data) { - convertFailed('the encoder returned no data'); - return; - } - if (setting == 'gif') { - var blob = new File([result.data], 'video.gif', { - type: 'image/gif', - }); - PostBlob(blob); - } else if (setting == 'mp4') { - var blob = new File([result.data], 'video.mp4', { - type: 'video/mp4', - }); - PostBlob(blob); + if (ffmpegBusy) { + convertFailed('a conversion is already running — wait for it to finish'); + return; + } + + ffmpegBusy = true; + var ffmpeg = null; + try { + $('#download-real').html('Loading converter...'); + ffmpeg = await loadFfmpeg(); + + ffmpeg.FS('writeFile', 'input.webm', new Uint8Array(await videoBlob.arrayBuffer())); + $('#download-real').html('Converting 0%'); + await ffmpeg.run.apply(ffmpeg, conversionArgs(setting, getExportFramerate())); + + var data = ffmpeg.FS('readFile', outputName); + if (!data || !data.length) { + convertFailed('the encoder returned no data'); + return; + } + // Copy out of the wasm heap before unlinking: the view would otherwise be + // backed by memory ffmpeg is free to reuse. + PostBlob(new File([data.slice()], 'video.' + setting, { type: mimeType })); + } catch (err) { + convertFailed((err && err.message) || String(err)); + } finally { + // Tear the instance down either way. After a success the runtime has + // already exited and is unusable; after a failure the loader's internal + // "running" flag would otherwise stay set and every later conversion would + // fail with "can only run one command at a time" until a page reload. + // Dropping the reference also returns the 23 MB heap and whatever MEMFS + // still holds, which for a long export is most of the memory in play. + if (ffmpeg) { + try { + ffmpeg.exit(); + } catch (e) { + /* already torn down by its own exit(0) */ } } - }; - var postMessage = function () { - if (posted) return; - posted = true; - // The recording was made at this rate, so the transcode has to keep it: - // a fixed -r would duplicate or drop frames and drift the timing. - const fps = getExportFramerate(); - if (setting == 'gif') { - worker.postMessage({ - type: 'command', - arguments: ('-i video.webm -r ' + fps + ' output-10.gif').split( - ' ' - ), - files: [ - { - data: new Uint8Array(aab), - name: 'video.webm', - }, - ], - }); - } else if (setting == 'mp4') { - worker.postMessage({ - type: 'command', - arguments: ( - '-i video.webm -c:v mpeg4 -b:v 6400k -r ' + - fps + - ' -strict experimental output.mp4' - ).split(' '), - files: [ - { - data: new Uint8Array(aab), - name: 'video.webm', - }, - ], - }); - } - }; + ffmpegBusy = false; + } } function PostBlob(blob) { diff --git a/src/js/libraries/ffmpeg.min.js b/src/js/libraries/ffmpeg.min.js deleted file mode 100644 index 1c8795e..0000000 --- a/src/js/libraries/ffmpeg.min.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpeg=t():e.FFmpeg=t()}(self,(function(){return e={497:(e,t,r)=>{r(72);var n=r(306).devDependencies;e.exports={corePath:"https://unpkg.com/@ffmpeg/core@".concat(n["@ffmpeg/core"].substring(1),"/dist/ffmpeg-core.js")}},663:(e,t,r)=>{function n(e,t,r,n,o,i,a){try{var c=e[i](a),s=c.value}catch(e){return void r(e)}c.done?t(s):Promise.resolve(s).then(n,o)}var o=r(72),i=function(e){return new Promise((function(t,r){var n=new FileReader;n.onload=function(){t(n.result)},n.onerror=function(e){var t=e.target.error.code;r(Error("File could not be read! Code=".concat(t)))},n.readAsArrayBuffer(e)}))};e.exports=function(){var e,t=(e=regeneratorRuntime.mark((function e(t){var r,n;return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(r=t,void 0!==t){e.next=3;break}return e.abrupt("return",new Uint8Array);case 3:if("string"!=typeof t){e.next=16;break}if(!/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(t)){e.next=8;break}r=atob(t.split(",")[1]).split("").map((function(e){return e.charCodeAt(0)})),e.next=14;break;case 8:return e.next=10,fetch(o(t));case 10:return n=e.sent,e.next=13,n.arrayBuffer();case 13:r=e.sent;case 14:e.next=20;break;case 16:if(!(t instanceof File||t instanceof Blob)){e.next=20;break}return e.next=19,i(t);case 19:r=e.sent;case 20:return e.abrupt("return",new Uint8Array(r));case 21:case"end":return e.stop()}}),e)})),function(){var t=this,r=arguments;return new Promise((function(o,i){var a=e.apply(t,r);function c(e){n(a,o,i,c,s,"next",e)}function s(e){n(a,o,i,c,s,"throw",e)}c(void 0)}))});return function(e){return t.apply(this,arguments)}}()},452:(e,t,r)=>{function n(e,t,r,n,o,i,a){try{var c=e[i](a),s=c.value}catch(e){return void r(e)}c.done?t(s):Promise.resolve(s).then(n,o)}function o(e){return function(){var t=this,r=arguments;return new Promise((function(o,i){var a=e.apply(t,r);function c(e){n(a,o,i,c,s,"next",e)}function s(e){n(a,o,i,c,s,"throw",e)}c(void 0)}))}}var i=r(72),a=r(185).log,c=function(){var e=o(regeneratorRuntime.mark((function e(t,r){var n,o,i;return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return a("info","fetch ".concat(t)),e.next=3,fetch(t);case 3:return e.next=5,e.sent.arrayBuffer();case 5:return n=e.sent,a("info","".concat(t," file size = ").concat(n.byteLength," bytes")),o=new Blob([n],{type:r}),i=URL.createObjectURL(o),a("info","".concat(t," blob URL = ").concat(i)),e.abrupt("return",i);case 11:case"end":return e.stop()}}),e)})));return function(t,r){return e.apply(this,arguments)}}();e.exports=function(){var e=o(regeneratorRuntime.mark((function e(t){var r,n,o,s,u;return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if("string"==typeof(r=t.corePath)){e.next=3;break}throw Error("corePath should be a string!");case 3:return n=i(r),e.next=6,c(n,"application/javascript");case 6:return o=e.sent,e.next=9,c(n.replace("ffmpeg-core.js","ffmpeg-core.wasm"),"application/wasm");case 9:return s=e.sent,e.next=12,c(n.replace("ffmpeg-core.js","ffmpeg-core.worker.js"),"application/javascript");case 12:if(u=e.sent,"undefined"!=typeof createFFmpegCore){e.next=15;break}return e.abrupt("return",new Promise((function(e){var t=document.createElement("script");t.src=o,t.type="text/javascript",t.addEventListener("load",(function r(){t.removeEventListener("load",r),a("info","ffmpeg-core.js script loaded"),e({createFFmpegCore,corePath:o,wasmPath:s,workerPath:u})})),document.getElementsByTagName("head")[0].appendChild(t)})));case 15:return a("info","ffmpeg-core.js script is loaded already"),e.abrupt("return",Promise.resolve({createFFmpegCore,corePath:o,wasmPath:s,workerPath:u}));case 17:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()},698:(e,t,r)=>{var n=r(497),o=r(452),i=r(663);e.exports={defaultOptions:n,getCreateFFmpegCore:o,fetchFile:i}},500:e=>{e.exports={defaultArgs:["./ffmpeg","-nostdin","-y"],baseOptions:{log:!1,logger:function(){},progress:function(){},corePath:""}}},906:(e,t,r)=>{function n(e){return function(e){if(Array.isArray(e))return o(e)}(e)||function(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return o(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?o(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function o(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=new Array(t);r=0||(o[r]=e[r]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(n=0;n=0||Object.prototype.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}var l=r(500),p=l.defaultArgs,h=l.baseOptions,m=r(185),g=m.setLogging,d=m.setCustomLogger,y=m.log,v=r(583),b=r(319),w=r(698),x=w.defaultOptions,j=w.getCreateFFmpegCore,E=r(306).version,O=Error("ffmpeg.wasm is not ready, make sure you have completed load().");e.exports=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=s(s(s({},h),x),e),r=t.log,o=t.logger,i=t.progress,c=f(t,["log","logger","progress"]),u=null,l=null,m=null,w=!1,F=i,L=function(e){"FFMPEG_END"===e&&null!==m&&(m(),m=null,w=!1)},P=function(e){var t=e.type,r=e.message;y(t,r),v(r,F),L(r)},k=function(){var e=a(regeneratorRuntime.mark((function e(){var t,r,n,o,i;return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(y("info","load ffmpeg-core"),null!==u){e.next=17;break}return y("info","loading ffmpeg-core"),e.next=5,j(c);case 5:return t=e.sent,r=t.createFFmpegCore,n=t.corePath,o=t.workerPath,i=t.wasmPath,e.next=12,r({mainScriptUrlOrBlob:n,printErr:function(e){return P({type:"fferr",message:e})},print:function(e){return P({type:"ffout",message:e})},locateFile:function(e,t){if("undefined"!=typeof window){if(void 0!==i&&e.endsWith("ffmpeg-core.wasm"))return i;if(void 0!==o&&e.endsWith("ffmpeg-core.worker.js"))return o}return t+e}});case 12:u=e.sent,l=u.cwrap("proxy_main","number",["number","number"]),y("info","ffmpeg-core loaded"),e.next=18;break;case 17:throw Error("ffmpeg.wasm was loaded, you should not load it again, use ffmpeg.isLoaded() to check next time.");case 18:case"end":return e.stop()}}),e)})));return function(){return e.apply(this,arguments)}}(),S=function(){return null!==u},A=function(){for(var e=arguments.length,t=new Array(e),r=0;r1?t-1:0),n=1;n")})).join(" "))),null===u)throw O;var o=null;try{var i;o=(i=u.FS)[e].apply(i,r)}catch(t){throw"readdir"===e?Error("ffmpeg.FS('readdir', '".concat(r[0],"') error. Check if the path exists, ex: ffmpeg.FS('readdir', '/')")):"readFile"===e?Error("ffmpeg.FS('readFile', '".concat(r[0],"') error. Check if the path exists")):Error("Oops, something went wrong in FS operation.")}return o},C=function(){if(null===u)throw O;w=!1,u.exit(1),u=null,l=null,m=null},R=function(e){F=e},T=function(e){d(e)};return g(r),d(o),y("info","use ffmpeg.wasm v".concat(E)),{setProgress:R,setLogger:T,setLogging:g,load:k,isLoaded:S,run:A,exit:C,FS:_}}},352:(e,t,r)=>{r(666);var n=r(906),o=r(698).fetchFile;e.exports={createFFmpeg:n,fetchFile:o}},185:e=>{var t=!1,r=function(){};e.exports={logging:t,setLogging:function(e){t=e},setCustomLogger:function(e){r=e},log:function(e,n){r({type:e,message:n}),t&&console.log("[".concat(e,"] ").concat(n))}}},319:e=>{e.exports=function(e,t){var r=e._malloc(t.length*Uint32Array.BYTES_PER_ELEMENT);return t.forEach((function(t,n){var o=e._malloc(t.length+1);e.writeAsciiToMemory(t,o),e.setValue(r+Uint32Array.BYTES_PER_ELEMENT*n,o,"i32")})),[t.length,r]}},583:e=>{function t(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=new Array(t);ra)&&(r=a)}else if(e.startsWith("frame")||e.startsWith("size")){var c=e.split("time=")[1].split(" ")[0],s=o(c);t({ratio:n=s/r,time:s})}else e.startsWith("video:")&&(t({ratio:1}),r=0)}},666:e=>{var t=function(e){"use strict";var t,r=Object.prototype,n=r.hasOwnProperty,o="function"==typeof Symbol?Symbol:{},i=o.iterator||"@@iterator",a=o.asyncIterator||"@@asyncIterator",c=o.toStringTag||"@@toStringTag";function s(e,t,r){return Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}),e[t]}try{s({},"")}catch(e){s=function(e,t,r){return e[t]=r}}function u(e,t,r,n){var o=t&&t.prototype instanceof d?t:d,i=Object.create(o.prototype),a=new k(n||[]);return i._invoke=function(e,t,r){var n=l;return function(o,i){if(n===h)throw new Error("Generator is already running");if(n===m){if("throw"===o)throw i;return A()}for(r.method=o,r.arg=i;;){var a=r.delegate;if(a){var c=F(a,r);if(c){if(c===g)continue;return c}}if("next"===r.method)r.sent=r._sent=r.arg;else if("throw"===r.method){if(n===l)throw n=m,r.arg;r.dispatchException(r.arg)}else"return"===r.method&&r.abrupt("return",r.arg);n=h;var s=f(e,t,r);if("normal"===s.type){if(n=r.done?m:p,s.arg===g)continue;return{value:s.arg,done:r.done}}"throw"===s.type&&(n=m,r.method="throw",r.arg=s.arg)}}}(e,r,a),i}function f(e,t,r){try{return{type:"normal",arg:e.call(t,r)}}catch(e){return{type:"throw",arg:e}}}e.wrap=u;var l="suspendedStart",p="suspendedYield",h="executing",m="completed",g={};function d(){}function y(){}function v(){}var b={};b[i]=function(){return this};var w=Object.getPrototypeOf,x=w&&w(w(S([])));x&&x!==r&&n.call(x,i)&&(b=x);var j=v.prototype=d.prototype=Object.create(b);function E(e){["next","throw","return"].forEach((function(t){s(e,t,(function(e){return this._invoke(t,e)}))}))}function O(e,t){function r(o,i,a,c){var s=f(e[o],e,i);if("throw"!==s.type){var u=s.arg,l=u.value;return l&&"object"==typeof l&&n.call(l,"__await")?t.resolve(l.__await).then((function(e){r("next",e,a,c)}),(function(e){r("throw",e,a,c)})):t.resolve(l).then((function(e){u.value=e,a(u)}),(function(e){return r("throw",e,a,c)}))}c(s.arg)}var o;this._invoke=function(e,n){function i(){return new t((function(t,o){r(e,n,t,o)}))}return o=o?o.then(i,i):i()}}function F(e,r){var n=e.iterator[r.method];if(n===t){if(r.delegate=null,"throw"===r.method){if(e.iterator.return&&(r.method="return",r.arg=t,F(e,r),"throw"===r.method))return g;r.method="throw",r.arg=new TypeError("The iterator does not provide a 'throw' method")}return g}var o=f(n,e.iterator,r.arg);if("throw"===o.type)return r.method="throw",r.arg=o.arg,r.delegate=null,g;var i=o.arg;return i?i.done?(r[e.resultName]=i.value,r.next=e.nextLoc,"return"!==r.method&&(r.method="next",r.arg=t),r.delegate=null,g):i:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,g)}function L(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function P(e){var t=e.completion||{};t.type="normal",delete t.arg,e.completion=t}function k(e){this.tryEntries=[{tryLoc:"root"}],e.forEach(L,this),this.reset(!0)}function S(e){if(e){var r=e[i];if(r)return r.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length)){var o=-1,a=function r(){for(;++o=0;--i){var a=this.tryEntries[i],c=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var s=n.call(a,"catchLoc"),u=n.call(a,"finallyLoc");if(s&&u){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),P(r),g}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var o=n.arg;P(r)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(e,r,n){return this.delegate={iterator:S(e),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=t),g}},e}(e.exports);try{regeneratorRuntime=t}catch(e){Function("r","regeneratorRuntime = r")(t)}},72:function(e,t,r){var n,o;void 0===(o="function"==typeof(n=function(){return function(){var e=arguments.length;if(0===e)throw new Error("resolveUrl requires at least one argument; got none.");var t=document.createElement("base");if(t.href=arguments[0],1===e)return t.href;var r=document.getElementsByTagName("head")[0];r.insertBefore(t,r.firstChild);for(var n,o=document.createElement("a"),i=1;i{"use strict";e.exports=JSON.parse('{"name":"@ffmpeg/ffmpeg","version":"0.10.1","description":"FFmpeg WebAssembly version","main":"src/index.js","types":"src/index.d.ts","directories":{"example":"examples"},"scripts":{"start":"node scripts/server.js","build":"rimraf dist && webpack --config scripts/webpack.config.prod.js","prepublishOnly":"npm run build","lint":"eslint src","wait":"rimraf dist && wait-on http://localhost:3000/dist/ffmpeg.dev.js","test":"npm-run-all -p -r start test:all","test:all":"npm-run-all wait test:browser:ffmpeg test:node:all","test:node":"node --experimental-wasm-threads --experimental-wasm-bulk-memory node_modules/.bin/_mocha --exit --bail --require ./scripts/test-helper.js","test:node:all":"npm run test:node -- ./tests/*.test.js","test:browser":"mocha-headless-chrome -a allow-file-access-from-files -a incognito -a no-sandbox -a disable-setuid-sandbox -a disable-logging -t 300000","test:browser:ffmpeg":"npm run test:browser -- -f ./tests/ffmpeg.test.html"},"browser":{"./src/node/index.js":"./src/browser/index.js"},"repository":{"type":"git","url":"git+https://github.com/ffmpegwasm/ffmpeg.wasm.git"},"keywords":["ffmpeg","WebAssembly","video"],"author":"Jerome Wu ","license":"MIT","bugs":{"url":"https://github.com/ffmpegwasm/ffmpeg.wasm/issues"},"engines":{"node":">=12.16.1"},"homepage":"https://github.com/ffmpegwasm/ffmpeg.wasm#readme","dependencies":{"is-url":"^1.2.4","node-fetch":"^2.6.1","regenerator-runtime":"^0.13.7","resolve-url":"^0.2.1"},"devDependencies":{"@babel/core":"^7.12.3","@babel/preset-env":"^7.12.1","@ffmpeg/core":"^0.10.0","@types/emscripten":"^1.39.4","babel-loader":"^8.1.0","chai":"^4.2.0","cors":"^2.8.5","eslint":"^7.12.1","eslint-config-airbnb-base":"^14.1.0","eslint-plugin-import":"^2.22.1","express":"^4.17.1","mocha":"^8.2.1","mocha-headless-chrome":"^2.0.3","npm-run-all":"^4.1.5","wait-on":"^5.3.0","webpack":"^5.3.2","webpack-cli":"^4.1.0","webpack-dev-middleware":"^4.0.0"}}')}},t={},function r(n){if(t[n])return t[n].exports;var o=t[n]={exports:{}};return e[n].call(o.exports,o,o.exports,r),o.exports}(352);var e,t})); -//# sourceMappingURL=ffmpeg.min.js.map \ No newline at end of file