fix: audit src/js and add a frame-accurate export path

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>
This commit is contained in:
2026-08-12 12:58:51 +02:00
co-authored by Claude Opus 5
parent 337f38d58e
commit a6ec6c980a
16 changed files with 2934 additions and 1910 deletions
+31 -15
View File
@@ -1,15 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<meta charset="utf-8">
<!-- Third-party assets live in vendor/ so the app works offline. -->
<!-- Run `npm run vendor` to populate that directory. -->
<link rel="stylesheet" href="vendor/inter.css">
<link rel="stylesheet" href="nice-select.css">
<link ref="stylesheet" href="range-slider.min.css">
<link rel="stylesheet" href="magic-check.min.css">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css"/>
<link rel="stylesheet" href="vendor/pickr-nano.min.css"/>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Primary Meta Tags -->
<title>Motionity - The web-based motion graphics editor for everyone</title>
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
@@ -79,6 +81,18 @@
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
<label for="image-format">Image</label>
</div>
<div id="framerate-row">
<p class="subheader">Frame rate</p>
<select id="framerate">
<option value="12">12 fps</option>
<option value="15">15 fps</option>
<option value="24">24 fps</option>
<option value="25">25 fps</option>
<option value="30" selected>30 fps</option>
<option value="50">50 fps</option>
<option value="60">60 fps</option>
</select>
</div>
<div id="download-real">Download</div>
</div>
<div id="import-export-modal">
@@ -168,7 +182,7 @@
<div id="filters">
<div id="filters-container">
<div id="filters-header">
<div id="filters-title">Filters</div>
<div class="filters-title">Filters</div>
<img src="assets/close.svg" id="filters-close">
</div>
<select id="filters-list">
@@ -183,7 +197,7 @@
<option value="Polaroid">Polaroid</option>
</select>
<hr>
<div id="filters-title">Adjustments</div>
<div class="filters-title">Adjustments</div>
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
<div class="filter-row">
<th class="name-col">Brightness</th>
@@ -216,7 +230,7 @@
</th>
</div>
<hr>
<div id="filters-title">Chroma key</div>
<div class="filters-title">Chroma key</div>
<div class="filter-row">
<th class="name-col">Status</th>
<th class="value-col">
@@ -242,7 +256,7 @@
</th>
</div>
<hr>
<div id="filters-title">Stylize</div>
<div class="filters-title">Stylize</div>
<div class="filter-row">
<th class="name-col">Noise</th>
<th class="value-col">
@@ -289,7 +303,7 @@
<div id="keyframe-properties">
<div id="easing">
<p class="property-title">Keyframe easing</p>
<select id="easing">
<select id="easing-select">
<option value="linear">Linear</option>
<option value="easeInQuad">Ease in</option>
<option value="easeOutQuad">Ease out</option>
@@ -366,16 +380,16 @@
<video id="test-video"></video>
<input id="emptyInput" value=" " style="opacity:0">
<script src="js/libraries/localbase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="vendor/lottie.min.js"></script>
<script src="vendor/selection.min.js"></script>
<script src="vendor/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.min.js"></script>
<script src="js/libraries/jquery.nice-select.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
<script src="vendor/pickr.min.js"></script>
<script src="vendor/fabric.min.js"></script>
<script src="js/libraries/anime.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
<script src="vendor/webfont.js"></script>
<script src="js/init.js"></script>
<script src="js/ui.js"></script>
<script src="js/align.js"></script>
@@ -383,6 +397,8 @@
<script src="js/database.js"></script>
<script src="js/lottie.js"></script>
<script src="js/text.js"></script>
<script src="js/webm-writer2.js"></script>
<script src="js/render.js"></script>
<script src="js/recorder.js"></script>
<script src="js/functions.js"></script>
<script src="js/events.js"></script>