Files
Motionity/PACKAGING.md
T
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

8.4 KiB

Packaging Motionity

Three distribution targets share one source tree (src/, a plain static app):

Target Output Build host
Desktop Motionity Setup 1.0.0.exe, Motionity-1.0.0-x64.AppImage, .flatpak Windows for .exe, Linux (or WSL2) for AppImage/Flatpak
Container motionity:latest, ~86 MB on disk / ~57 MB pulled any Docker host
Bare metal src/ behind Node, nginx or Caddy any

0. One prerequisite for every target: vendor the assets

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 into src/vendor/ and the app now references only those local copies. Without this step the page loads but every script tag 404s.

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

electron/main.js starts the same static server the bare-metal target uses, on 127.0.0.1 with a random port, and points the window at it.

This is deliberate, do not "simplify" it to loadFile(). Chromium exposes WebCodecs (VideoEncoder, the fast exporter in src/js/render.js) and IndexedDB (project storage via localbase) only in a secure context. file:// is not one; a loopback HTTP origin is. Load the app from file:// and export silently falls back to real-time MediaRecorder capture and projects stop saving.

npm run dev            # run the desktop app from source

npm run dist:win       # NSIS installer + portable .exe -> dist/
npm run dist:appimage  # .AppImage -> dist/
npm run dist:flatpak   # .flatpak  -> dist/
npm run dist:linux     # both Linux targets

Each dist:* script re-runs vendor and regenerates build/icon.png (scripts/make-icon.cjs rasterises the logo geometry with zlib only — no ImageMagick, no sharp).

If the NSIS step fails with "Access denied" on Motionity.exe

On a locked-down Windows machine the security agent can take an exclusive lock on the freshly written 214 MB unsigned dist/win-unpacked/Motionity.exe, and the 7-Zip step that builds the installer payload then cannot read it. The symptom is a build that produces dist/win-unpacked/ correctly and dies right after "Archive size":

.\Motionity.exe : Accès refusé.
WARNING: Cannot open 2 files

The ACL is intact (the owner still has FullControl), so this is a filter driver, not a permissions problem. Fixes, in order of preference:

  1. Exclude the repository's dist/ directory in the endpoint protection agent.
  2. Build on a machine or CI runner without that agent.
  3. Ship dist/win-unpacked/electron-builder --win dir is unaffected.

Running npm run dev from a VS Code terminal

VS Code exports ELECTRON_RUN_AS_NODE=1, which turns the electron binary into plain Node and leaves the electron module empty. main.js detects this and prints a message. Fix it in the shell:

env -u ELECTRON_RUN_AS_NODE npm run dev     # bash
$env:ELECTRON_RUN_AS_NODE=$null; npm run dev  # PowerShell

Is WSL enough for AppImage and Flatpak?

AppImage: yes. electron-builder produces the squashfs itself, so no FUSE is needed at build time. To run the result inside WSL you need libfuse2 (or ./Motionity-1.0.0-x64.AppImage --appimage-extract-and-run), and WSLg on Windows 11 gives you the GUI.

Flatpak: technically yes, practically annoying. flatpak-builder runs under WSL2 (the kernel has the user namespaces and /dev/fuse that bubblewrap needs), but you must install the runtimes by hand first and there is no xdg-desktop-portal to fall back on. If it fights you, build it in a Linux container instead — it is the same command with fewer moving parts.

.exe: no. Build it on the Windows side. Cross-building NSIS from Linux needs Wine and rules out signing.

This machine currently has no WSL distro other than docker-desktop, so the Linux targets have not been run here. Setup, from PowerShell:

wsl --install -d Ubuntu

Then inside Ubuntu:

sudo apt update
sudo apt install -y nodejs npm libfuse2            # AppImage
sudo apt install -y flatpak flatpak-builder elfutils  # Flatpak
flatpak remote-add --if-not-exists --user flathub \
  https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub \
  org.freedesktop.Platform//23.08 \
  org.freedesktop.Sdk//23.08 \
  org.electronjs.Electron2.BaseApp//23.08

cd /mnt/c/Users/<you>/git\ azuze/motionity-2
npm install
npm run dist:linux

Note that building on /mnt/c is slow. Copying the tree into the WSL filesystem (~/motionity) is several times faster.

2. Docker

docker build -t motionity:latest .
docker run --rm -p 8080:8080 motionity:latest
# or: docker compose up --build

The runtime image is static-web-server (Rust) on Alpine — 9 MB of base image, no Node, no shell tooling, running as UID 65534. Everything above that is the app itself: 46 MB of bundled stock media plus 20 MB of vendored libraries.

Two knobs if the size matters more than offline completeness:

# -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
# presets; uploads still work). Add to .dockerignore:
#   src/assets/audio

3. Bare metal

npm run vendor
npm start                       # http://127.0.0.1:8080
HOST=0.0.0.0 PORT=3000 npm start

scripts/server.cjs is dependency-free: correct MIME types, byte ranges (the audio and video panels seek), no directory listings, path-traversal guard. Node 18+.

For a real install, deploy/ has a systemd unit plus nginx and Caddy configs that serve src/ directly, no Node process involved:

sudo cp -r . /opt/motionity
sudo cp deploy/motionity.service /etc/systemd/system/
sudo systemctl enable --now motionity

The secure-context rule, once more

Both the container and the bare-metal targets serve plain HTTP. That is fine on http://localhost, which browsers treat as secure. It is not fine on a LAN address or a domain: VideoEncoder disappears (export falls back to slow real-time capture) and IndexedDB is blocked (projects stop saving), with no error message beyond a console warning.

Anything beyond localhost needs TLS. deploy/Caddyfile is the shortest path — it obtains the certificate itself.

What still needs the internet

Vendoring makes the editor start and export offline. Three features remain online by design, and degrade quietly rather than breaking:

  1. Google Fonts — the font picker loads families through WebFont.load, and src/js/init.js lists them from the Google Fonts API. Offline, text 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.