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
+30 -18
View File
@@ -1,10 +1,16 @@
function animateText(group, ms, play, props, cv, id) {
var starttime = p_keyframes.find((x) => x.id == id).start;
ms -= starttime;
const p_keyframe = p_keyframes.find((x) => x.id == id);
if (!group || !p_keyframe) {
return;
}
ms -= p_keyframe.start;
var length = group._objects.length;
var globaldelay = 0;
// Every letter needs its own binding: the anime callbacks below run long
// after the loop has finished, so `var` would make them all share the last
// letter's state.
for (var i = 0; i < length; i++) {
var index = i;
let index = i;
if (props.order == 'backward') {
index = length - i - 1;
}
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
let top = group.item(index).defaultTop;
let scaleX = group.item(index).defaultScaleX;
let scaleY = group.item(index).defaultScaleY;
var delay = i * duration;
var duration = props.duration / length;
var animation = {
// Named `step` so it does not shadow the global project duration
let step = props.duration / length;
let delay = i * step;
let animation = {
opacity: 0,
top: top,
left: left,
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
scaleY: scaleY,
};
if (props.typeAnim == 'letter') {
delay = i * duration - 100;
delay = i * step - 100;
} else if (props.typeAnim == 'word') {
if (group.item(index).text == ' ') {
globaldelay += 500;
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
delay = globaldelay;
}
if (props.preset == 'typewriter') {
delay = i * duration;
duration = 20;
delay = i * step;
step = 20;
} else if (props.preset == 'fade in') {
} else if (props.preset == 'slide top') {
animation.top += 20;
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
animation.scaleX = 1.5;
animation.scaleY = 1.5;
}
if (delay < 0) {
if (!(delay > 0)) {
delay = 0;
}
if (duration < 20) {
duration = 20;
if (!(step > 20)) {
step = 20;
}
var start = false;
var instance = anime({
let start = false;
let instance = anime({
targets: animation,
delay: delay,
opacity: 1,
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
top: top,
scaleX: scaleX,
scaleY: scaleY,
duration: duration,
duration: step,
easing: props.easing,
autoplay: play,
update: function () {
@@ -228,8 +235,7 @@ class AnimatedText {
var obj = cv.getItemById(this.id);
var left = obj.left;
var top = obj.top;
var scaleX = obj,
scaleX;
var scaleX = obj.scaleX;
var scaleY = obj.scaleY;
var angle = obj.angle;
var start = p_keyframes.find((x) => x.id == this.id).start;
@@ -260,10 +266,16 @@ class AnimatedText {
cv,
this.id
);
animate(currenttime, false);
animate(false, currenttime);
save();
}
assignTo(id, text, props) {
this.id = id;
if (text !== undefined) {
this.text = text;
}
if (props !== undefined) {
this.props = props;
}
}
}