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>
Two halves of one pass; TODO-FIXES.md lists every finding.
The audit fixed, among ~90 items: MP4 export dead-ending and locking the UI
behind a stuck "Downloading..." button; height keyframes storing the width;
shadow defaults and keyframes storing undefined because fabric's get() is not
a path getter; every letter animation writing to the last letter; keyframe
times drifting on each drag because data-time was layer-relative on expanded
rows; O(n^2 log n) playback, now indexed once per frame; and a save() that
rebuilt the record canvas on every edit, now debounced.
Exports also lost audio-layer sound outright: MediaRecorder records only the
first audio track, so every source now mixes through one AudioContext into one
destination.
The new half is src/js/render.js, an offline renderer. Real-time capture was
the root cause of dropped frames on heavy scenes and smeared video layers: a
bare currentTime assignment is async, so drawing straight after it captures the
previous frame. Each frame is now seeked, awaited on 'seeked', drawn, and
pushed through a VideoEncoder; audio is mixed in one OfflineAudioContext pass
and encoded to Opus. webm-writer2.js gained a second Opus track and lost three
bugs, including a MAX_CLUSTER_DURATION_MSEC of ~58 days that overflowed the
signed 16-bit block timecode past ~32s.
Real-time capture remains the fallback: the hand-rolled muxer's output is
decoded in a <video> element before being handed over, and any failure returns
null so record() falls back transparently.
test/webm-muxer.test.js parses the muxer output with an EBML reader in plain
Node. It caught the fixed-256-byte header overflow that inspection missed.
Not verified in a browser: node --check passes on every script and the muxer
test passes, but a running Chrome held the Playwright profile lock, so the
manual pass listed at the end of TODO-FIXES.md is still outstanding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Serve src/ directly from the web server with no Node process involved. The
Caddyfile is the shortest path to TLS, which every non-localhost deployment
needs: on a plain-HTTP LAN address Chromium drops WebCodecs and blocks
IndexedDB, so export slows to real-time capture and projects stop saving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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, rather than calling
loadFile(). Chromium exposes WebCodecs and IndexedDB only in a secure context:
over file:// the exporter would fall back to real-time MediaRecorder capture
and projects would stop saving, both silently.
The renderer runs sandboxed with no node integration, denies every permission
request, and hands http(s) navigations to the real browser.
PACKAGING.md documents all three distribution targets, including the NSIS
"Access denied" failure caused by an endpoint protection agent locking the
freshly written unsigned exe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dockerfile builds a static-web-server image serving src/ with the third-party
assets vendored in a first stage, so the runtime layer carries no Node and no
shell tooling. WITH_FFMPEG=0 drops the 18.5 MB asm.js encoder for a smaller
image, at the cost of fetching it at export time.
.gitignore covers dist/, which the release scripts write on every run: without
it a fresh clone reports the build output as untracked and publish.ps1 warns
that the worktree is dirty on every build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/build-release.ps1 packages the desktop installers; scripts/publish.ps1
pushes the container image to git.azuze.fr and attaches the installers to the
Gitea release for the same tag. The split keeps a local build free of any
credential, and lets a failed upload be retried with -BinariesOnly
-NoBinaryBuild without paying for the build again.
Two details the plain `npm run dist:*` path gets wrong for a release:
- package.json's global artifactName resolves NSIS and portable to the same
file name, so one silently overwrites the other. The build script passes
distinct -c.nsis.artifactName / -c.portable.artifactName.
- Artifact names now carry the tag, so publish.ps1 can glob exactly one tag's
files and never ship a stale one. electron-builder's update metadata
(.blockmap, latest*.yml, the NSIS .7z payload) is swept out of dist/ so it
cannot match that glob and end up attached to the release.
The tag defaults to v<package.json version> rather than git describe, because
electron-builder stamps package.json into the app: a SHA-based tag would name
an installer whose About box disagrees with it.
Also brings scripts/server.cjs, vendor.mjs and make-icon.cjs under version
control, which the release scripts and the Docker build both call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Fix download filename handling in converter and export functions
- Make updateRecordCanvas return Promise to handle async loadFromJSON correctly
- Add setCoords() call in setObjectValue to fix object selection after updates
- Update recorder initialization and animation frame handling
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>