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
+70 -40
View File
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
// for legacy browsers
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
var oldsrc, oldobj;
var oldtimelinepos;
var speed = 1;
@@ -58,7 +57,6 @@ var editingpanel = false;
var files = [];
var re = /(?:\.([^.]+))?$/;
var filelist = [];
var timeout;
var spacehold = false;
var spacerelease = false;
var tempselection;
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
var isChrome =
window.chrome && Object.values(window.chrome).length !== 0;
var eyeDropper;
if (isChrome) {
if (isChrome && typeof EyeDropper !== 'undefined') {
eyeDropper = new EyeDropper();
}
var presets = [
@@ -156,20 +154,30 @@ var sliders = [];
var hovertime = 0;
var animatedtext = [];
// Get list of fonts
$.ajax({
url:
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
GOOGLE_FONTS_API_KEY +
'&sort=alpha',
type: 'GET',
dataType: 'json', // added data type
success: function (response) {
response.items.forEach(function (item) {
fonts.push(item.family);
});
},
});
// Get list of fonts.
// Both API keys are placeholders in the repository - replace them to enable
// the Google Fonts list and the Pixabay browser.
const HAS_FONTS_KEY =
GOOGLE_FONTS_API_KEY && GOOGLE_FONTS_API_KEY != 'GOOGLE_FONTS_API_KEY';
const HAS_PIXABAY_KEY = API_KEY && API_KEY != 'PIXABAY_API';
if (HAS_FONTS_KEY) {
$.ajax({
url:
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
GOOGLE_FONTS_API_KEY +
'&sort=alpha',
type: 'GET',
dataType: 'json', // added data type
success: function (response) {
response.items.forEach(function (item) {
fonts.push(item.family);
});
},
error: function () {
console.warn('Could not load the Google Fonts list');
},
});
}
// Panel variants
const canvas_panel =
@@ -444,6 +452,25 @@ var text_items = {
],
};
// Without a Google Fonts key the font pickers would be empty, so fall back
// to the families that are already bundled in the text browser.
// (Declared here because it reads text_items, defined just above.)
if (!HAS_FONTS_KEY) {
Object.keys(text_items).forEach(function (group) {
text_items[group].forEach(function (item) {
if (fonts.indexOf(item.fontname) == -1) {
fonts.push(item.fontname);
}
});
});
['Inter', 'Syne'].forEach(function (name) {
if (fonts.indexOf(name) == -1) {
fonts.push(name);
}
});
fonts.sort();
}
WebFont.load({
google: {
families: ['Syne'],
@@ -460,7 +487,11 @@ try {
var canvas2dBackend = new fabric.Canvas2dFilterBackend();
fabric.filterBackend = fabric.initFilterBackend();
fabric.filterBackend = webglBackend;
// Only take over the backend if WebGL actually initialized, otherwise filters
// would silently break on machines without a usable WebGL context.
if (webglBackend) {
fabric.filterBackend = webglBackend;
}
// Lottie support
fabric.Lottie = fabric.util.createClass(fabric.Image, {
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
});
this.lottieItem.addEventListener('enterFrame', (e) => {
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
});
this.lottieItem.addEventListener('DOMLoaded', () => {
this.lottieItem.goToAndStop(currenttime, false);
this.lottieItem.duration =
this.lottieItem.getDuration(false) * 1000;
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
canvas.renderAll();
canvas.fire('lottie:loaded', { any: 'payload' });
});
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
goToSeconds: function (seconds) {
this.lottieItem.goToAndStop(seconds, false);
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
},
goToFrame: function (frame) {
this.lottieItem.goToAndStop(frame, true);
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
// Get any object by ID
fabric.Canvas.prototype.getItemById = function (name) {
var object = null,
objects = this.getObjects();
for (var i = 0, len = this.size(); i < len; i++) {
if (objects[i].get('type') == 'group') {
if (objects[i].get('id') && objects[i].get('id') === name) {
object = objects[i];
break;
function search(list) {
for (var i = 0; i < list.length; i++) {
const item = list[i];
if (item.id && item.id === name) {
return item;
}
var wip = i;
for (var o = 0; o < objects[i]._objects.length; o++) {
if (
objects[wip]._objects[o].id &&
objects[wip]._objects[o].id === name
) {
object = objects[wip]._objects[o];
break;
// Recurse so groups nested more than one level deep are found too
if (item._objects && item._objects.length > 0) {
const found = search(item._objects);
if (found) {
return found;
}
}
} else if (objects[i].id && objects[i].id === name) {
object = objects[i];
break;
}
return null;
}
return object;
return search(this.getObjects());
};
// Create the artboard