Files
kawaandClaude Opus 5 9a4d14613f 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>
2026-08-12 15:41:40 +02:00

178 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
// Downloads every third-party asset that index.html used to pull from a CDN
// into src/vendor/, so the app runs with no network access. Run once before
// packaging (npm run vendor); the directory is gitignored.
//
// The only runtime network dependency left after this is the Google Fonts
// family the user picks in the text panel (WebFont.load), which degrades to a
// fallback font when offline.
import { createHash } from 'node:crypto';
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';
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.
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const assets = [
{
url: 'https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js',
file: 'lottie.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js',
file: 'selection.min.js',
},
{
url: 'https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js',
file: 'jquery.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js',
file: 'pickr.min.js',
},
{
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css',
file: 'pickr-nano.min.css',
},
{
url: 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js',
file: 'fabric.min.js',
},
{
url: 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js',
file: 'webfont.js',
},
];
// 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';
async function fetchBuffer(url) {
const res = await fetch(url, { headers: { 'user-agent': UA } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return Buffer.from(await res.arrayBuffer());
}
function human(bytes) {
return bytes > 1e6
? `${(bytes / 1e6).toFixed(1)} MB`
: `${Math.round(bytes / 1024)} KB`;
}
async function download(url, dest, { force }) {
if (!force && existsSync(dest) && statSync(dest).size > 0) {
console.log(` skip ${dest.slice(root.length + 1)} (already vendored)`);
return;
}
const buf = await fetchBuffer(url);
await writeFile(dest, buf);
console.log(` get ${dest.slice(root.length + 1)} (${human(buf.length)})`);
}
// Rewrites the remote font files referenced by the Google Fonts stylesheet to
// local copies so no request leaves the machine at startup.
async function vendorFonts({ force }) {
const dest = join(vendorDir, 'inter.css');
if (!force && existsSync(dest) && statSync(dest).size > 0) {
console.log(` skip src/vendor/inter.css (already vendored)`);
return;
}
let css = (await fetchBuffer(fontCss)).toString('utf8');
const urls = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1]))];
for (const url of urls) {
const ext = url.split('.').pop().split('?')[0];
const name = `inter-${createHash('sha1').update(url).digest('hex').slice(0, 10)}.${ext}`;
await writeFile(join(fontsDir, name), await fetchBuffer(url));
css = css.split(url).join(`fonts/${name}`);
}
await writeFile(dest, css);
console.log(` get src/vendor/inter.css (+${urls.length} font files)`);
}
const force = process.argv.includes('--force');
// 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) {
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');
const remote = [...html.matchAll(/(?:src|href)="(https?:\/\/[^"]+)"/g)]
.map((m) => m[1])
.filter((u) => !/github\.com|motionity\.app|twitter\.com/.test(u));
if (remote.length) {
console.warn(`\nWARNING: index.html still loads remote assets:`);
for (const u of remote) console.warn(` ${u}`);
process.exitCode = 1;
} else {
console.log(`\nDone. index.html loads no remote assets.`);
}