feat: replace the archive.org asm.js encoder with vendored ffmpeg.wasm

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 15:41:40 +02:00
co-authored by Claude Opus 5
parent a6ec6c980a
commit 9a4d14613f
10 changed files with 394 additions and 157 deletions
+9 -3
View File
@@ -6,13 +6,19 @@
# Build: docker build -t motionity:latest . # Build: docker build -t motionity:latest .
# Run: docker run --rm -p 8080:8080 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 # WITH_FFMPEG=0 drops the 23 MB ffmpeg.wasm core from the image and disables
# export then downloads it from archive.org on first use instead of working # MP4/GIF export, which then reports itself as unavailable. It no longer falls
# offline; everything else is unaffected. # 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 FROM node:22-alpine AS vendor
ARG WITH_FFMPEG=1 ARG WITH_FFMPEG=1
WORKDIR /app 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 scripts/vendor.mjs scripts/
COPY src/index.html src/ COPY src/index.html src/
RUN if [ "$WITH_FFMPEG" = "1" ]; then \ RUN if [ "$WITH_FFMPEG" = "1" ]; then \
+31 -8
View File
@@ -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 ## 0. One prerequisite for every target: vendor the assets
```bash ```bash
npm install # only needed for the desktop builds npm install # now required for every target, not just the desktop ones
npm run vendor # ~20 MB, writes src/vendor/ (gitignored) npm run vendor # ~24 MB, writes src/vendor/ (gitignored)
``` ```
`index.html` used to pull jQuery, fabric.js, lottie, pickr, selection-js, the `index.html` used to pull jQuery, fabric.js, lottie, pickr, selection-js, the
WebFont loader and Inter from five different CDNs. `scripts/vendor.mjs` 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/` downloads all of them into `src/vendor/` and the app now references only those
and the app now references only those local copies. Without this step the page local copies. Without this step the page loads but every script tag 404s.
loads but every script tag 404s.
The Docker build runs the vendor step inside the image, so it is the one target ffmpeg.wasm is handled differently: `vendor.mjs` **copies** it out of
where you can skip it locally. `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 ## 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: Two knobs if the size matters more than offline completeness:
```bash ```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 . docker build --build-arg WITH_FFMPEG=0 -t motionity:slim .
# -33 MB: drop the bundled royalty-free music library (removes the Audio panel # -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. falls back to a system font. Only Inter (the UI font) is bundled.
2. **Pixabay** — the Images, Videos and Audio browsers search Pixabay live. 2. **Pixabay** — the Images, Videos and Audio browsers search Pixabay live.
3. **Unsplash** — sample images on the empty-state screen. 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.
+1
View File
@@ -5,6 +5,7 @@ This is a fork of the original project aiming to fix issues and add features.
## Running it ## Running it
```bash ```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 run vendor # downloads the third-party libraries into src/vendor/
npm start # http://127.0.0.1:8080 npm start # http://127.0.0.1:8080
``` ```
+47 -3
View File
@@ -255,9 +255,53 @@ inspection.
- **`src/js/encode-worker.js`** is still unreferenced. It was the old - **`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 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. 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 - **MP4 and GIF** go through ffmpeg.wasm (`converter.js`), vendored out of
(`converter.js`, loaded from archive.org). The frame-accurate renderer feeds it `node_modules` and pinned by `package-lock.json`. This replaced the asm.js
a better WebM, but that dependency is unchanged and may be offline. 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 ## Not verified
+86
View File
@@ -8,6 +8,10 @@
"name": "motionity", "name": "motionity",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"@ffmpeg/core-st": "^0.11.1",
"@ffmpeg/ffmpeg": "^0.11.6"
},
"devDependencies": { "devDependencies": {
"electron": "^40.0.0", "electron": "^40.0.0",
"electron-builder": "^26.0.0" "electron-builder": "^26.0.0"
@@ -317,6 +321,27 @@
"node": ">=14.14" "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": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2192,6 +2217,12 @@
"node": ">=8" "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": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2531,6 +2562,26 @@
"semver": "^7.3.5" "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": { "node_modules/node-gyp": {
"version": "12.4.0", "version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -2942,6 +2993,12 @@
"util-deprecate": "~1.0.1" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -2987,6 +3044,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/responselike": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
@@ -3365,6 +3429,12 @@
"tmp": "^0.2.0" "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": { "node_modules/truncate-utf8-bytes": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -3481,6 +3551,22 @@
"tslib": "^2.8.1" "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": { "node_modules/which": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
+22 -4
View File
@@ -22,6 +22,10 @@
"release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease", "release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease",
"test": "node --test \"test/**/*.test.js\"" "test": "node --test \"test/**/*.test.js\""
}, },
"dependencies": {
"@ffmpeg/core-st": "^0.11.1",
"@ffmpeg/ffmpeg": "^0.11.6"
},
"devDependencies": { "devDependencies": {
"electron": "^40.0.0", "electron": "^40.0.0",
"electron-builder": "^26.0.0" "electron-builder": "^26.0.0"
@@ -38,12 +42,23 @@
"electron/**/*", "electron/**/*",
"scripts/server.cjs", "scripts/server.cjs",
"src/**/*", "src/**/*",
"!src/**/*.map" "!src/**/*.map",
"!node_modules/@ffmpeg/**"
], ],
"win": { "win": {
"target": [ "target": [
{ "target": "nsis", "arch": ["x64"] }, {
{ "target": "portable", "arch": ["x64"] } "target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
], ],
"icon": "build/icon.png" "icon": "build/icon.png"
}, },
@@ -55,7 +70,10 @@
"shortcutName": "Motionity" "shortcutName": "Motionity"
}, },
"linux": { "linux": {
"target": ["AppImage", "flatpak"], "target": [
"AppImage",
"flatpak"
],
"icon": "build/icon.png", "icon": "build/icon.png",
"category": "Graphics", "category": "Graphics",
"synopsis": "Motion graphics editor", "synopsis": "Motion graphics editor",
+56 -14
View File
@@ -8,7 +8,7 @@
// fallback font when offline. // fallback font when offline.
import { createHash } from 'node:crypto'; 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 { existsSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const vendorDir = join(root, 'src', 'vendor'); const vendorDir = join(root, 'src', 'vendor');
const fontsDir = join(vendorDir, 'fonts'); const fontsDir = join(vendorDir, 'fonts');
const ffmpegDir = join(vendorDir, 'ffmpeg');
// A desktop UA is required for the Google Fonts API to answer with woff2 // A desktop UA is required for the Google Fonts API to answer with woff2
// instead of the ancient truetype payload. // 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', url: 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js',
file: '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 = const fontCss =
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'; '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'); const force = process.argv.includes('--force');
// Docker builds can drop the 18.5 MB ffmpeg blob; MP4/GIF export then falls // Docker builds can drop the 23 MB ffmpeg core. Unlike the old asm.js build
// back to fetching it from the public mirror at conversion time. // there is no runtime mirror to fall back to, so this now means MP4/GIF export
const skipOptional = process.argv.includes('--skip-ffmpeg'); // 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 }); await mkdir(fontsDir, { recursive: true });
console.log(`Vendoring third-party assets into src/vendor/`); console.log(`Vendoring third-party assets into src/vendor/`);
for (const asset of assets) { 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 download(asset.url, join(vendorDir, asset.file), { force });
} }
await vendorFonts({ force }); await vendorFonts({ force });
await vendorFfmpeg({ skipHeavy: skipFfmpeg });
// Sanity check: index.html must not have regained a CDN reference. // Sanity check: index.html must not have regained a CDN reference.
const html = await readFile(join(root, 'src', 'index.html'), 'utf8'); const html = await readFile(join(root, 'src', 'index.html'), 'utf8');
+3
View File
@@ -390,6 +390,9 @@
<script src="vendor/fabric.min.js"></script> <script src="vendor/fabric.min.js"></script>
<script src="js/libraries/anime.min.js"></script> <script src="js/libraries/anime.min.js"></script>
<script src="vendor/webfont.js"></script> <script src="vendor/webfont.js"></script>
<!-- ffmpeg.wasm loader; the 23 MB core beside it is fetched on first
MP4/GIF export, not at page load. -->
<script src="vendor/ffmpeg/ffmpeg.min.js"></script>
<script src="js/init.js"></script> <script src="js/init.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/align.js"></script> <script src="js/align.js"></script>
+134 -118
View File
@@ -1,147 +1,163 @@
// MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg. // MP4/GIF export transcodes the captured WebM with ffmpeg.wasm.
// Packaged builds ship it locally (npm run vendor); a plain checkout falls back //
// to the public mirror, which needs network access. // Everything is served from vendor/ffmpeg/, vendored out of node_modules by
var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js'; // `npm run vendor` and pinned by package-lock.json. There is deliberately no
var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; // CDN fallback: the asm.js build this replaced was fetched from a public
var ffmpegAsmUrlPromise = null; // 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. var FFMPEG_DIR = 'vendor/ffmpeg/';
function resolveFfmpegAsmUrl() { var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js';
if (!ffmpegAsmUrlPromise) { var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm';
ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' }) var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js';
.then(function (res) {
// A catch-all/SPA route answers 200 with HTML; that is not the script. // One instance per conversion, deliberately not cached. The single-threaded
var type = res.headers.get('content-type') || ''; // core's `main` calls exit() when the command finishes, which tears the wasm
var local = res.ok && type.indexOf('text/html') === -1; // runtime down: a second run on the same instance dies with "Program terminated
return local // with exit(0)". Reloading costs about 110ms and returns the 23 MB heap in
? new URL(FFMPEG_ASM_LOCAL, location.href).href // between, so this is cheaper than it looks.
: FFMPEG_ASM_REMOTE; var ffmpegBusy = false;
})
.catch(function () { function ffmpegAvailable() {
return FFMPEG_ASM_REMOTE; return typeof FFmpeg !== 'undefined' && typeof FFmpeg.createFFmpeg === 'function';
});
}
return ffmpegAsmUrlPromise;
} }
function processInWebWorker(workerPath) { // The loader resolves the core through `new URL(corePath, import.meta.url)`,
var blob = URL.createObjectURL( // which points at the bundle rather than the page once it is minified. Passing
new Blob( // all three paths absolute skips that resolution entirely.
[ function absolute(path) {
'importScripts("' + return new URL(path, location.href).href;
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',
} }
)
async function loadFfmpeg() {
if (!ffmpegAvailable()) {
throw new Error(
'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/'
); );
var worker = new Worker(blob);
URL.revokeObjectURL(blob);
return worker;
} }
var worker; // A build made with WITH_FFMPEG=0 ships the loader but not the 23 MB core,
// The worker is created once and reused, so its "ready" handshake only ever // so check before paying for the load and report it as a build choice
// arrives for the first conversion. Remember it across calls. // rather than a failure.
var workerIsReady = false; 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) + '%');
}
},
});
await instance.load();
return instance;
}
// 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) { async function convertStreams(videoBlob, setting) {
var aab; var outputName = setting === 'gif' ? 'output.gif' : 'output.mp4';
var buffersReady = false; var mimeType = setting === 'gif' ? 'image/gif' : 'video/mp4';
var posted = false;
function convertFailed(reason) { function convertFailed(reason) {
console.error('Conversion failed: ' + reason); console.error('Conversion failed: ' + reason);
alert( alert(
'Sorry, the ' + 'Sorry, the ' +
setting.toUpperCase() + setting.toUpperCase() +
' conversion failed. The WEBM format is always available.' ' conversion failed. The WEBM format is always available.\n\n' +
reason
); );
resetRecordingUI(); resetRecordingUI();
} }
var fileReader = new FileReader(); if (setting !== 'gif' && setting !== 'mp4') {
fileReader.onload = function () { convertFailed('unknown output format "' + setting + '"');
aab = this.result; return;
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());
} }
worker.onerror = function (e) { if (ffmpegBusy) {
convertFailed(e.message || 'worker error'); convertFailed('a conversion is already running — wait for it to finish');
}; return;
worker.onmessage = function (event) { }
var message = event.data;
if (message.type == 'ready') { ffmpegBusy = true;
workerIsReady = true; var ffmpeg = null;
if (buffersReady) postMessage(); try {
} else if (message.type == 'done') { $('#download-real').html('Loading converter...');
var result = message.data && message.data[0]; ffmpeg = await loadFfmpeg();
if (!result || !result.data) {
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'); convertFailed('the encoder returned no data');
return; return;
} }
if (setting == 'gif') { // Copy out of the wasm heap before unlinking: the view would otherwise be
var blob = new File([result.data], 'video.gif', { // backed by memory ffmpeg is free to reuse.
type: 'image/gif', PostBlob(new File([data.slice()], 'video.' + setting, { type: mimeType }));
}); } catch (err) {
PostBlob(blob); convertFailed((err && err.message) || String(err));
} else if (setting == 'mp4') { } finally {
var blob = new File([result.data], 'video.mp4', { // Tear the instance down either way. After a success the runtime has
type: 'video/mp4', // already exited and is unusable; after a failure the loader's internal
}); // "running" flag would otherwise stay set and every later conversion would
PostBlob(blob); // 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) */
} }
} }
}; ffmpegBusy = false;
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',
},
],
});
} }
};
} }
function PostBlob(blob) { function PostBlob(blob) {
File diff suppressed because one or more lines are too long