Packaging/release scripts #1

Merged
kawa merged 9 commits from packaging/release-scripts into main 2026-08-12 17:38:12 +02:00
10 changed files with 394 additions and 157 deletions
Showing only changes of commit 9a4d14613f - Show all commits
+9 -3
View File
@@ -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
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
```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.
+1
View File
@@ -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
View File
@@ -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
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+3
View File
@@ -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>
+134 -118
View File
@@ -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;
});
}
return ffmpegAsmUrlPromise;
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';
}
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',
// 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/'
);
var worker = new Worker(blob);
URL.revokeObjectURL(blob);
return worker;
}
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;
// 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) + '%');
}
},
});
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) {
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) {
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;
}
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);
// 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) {
File diff suppressed because one or more lines are too long