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:
+9
-3
@@ -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 \
|
||||
|
||||
+31
-8
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+47
-3
@@ -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
|
||||
|
||||
|
||||
Generated
+86
@@ -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",
|
||||
|
||||
+22
-4
@@ -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",
|
||||
|
||||
+56
-14
@@ -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');
|
||||
|
||||
@@ -390,6 +390,9 @@
|
||||
<script src="vendor/fabric.min.js"></script>
|
||||
<script src="js/libraries/anime.min.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/ui.js"></script>
|
||||
<script src="js/align.js"></script>
|
||||
|
||||
+139
-123
@@ -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) {
|
||||
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user