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
+113 -35
View File
@@ -88,15 +88,24 @@ function checkDB() {
} else {
loadProject();
}
})
.catch(function (e) {
console.error('Could not open the local project database', e);
});
}
// Automatically save project (locally)
function autoSave() {
async function autoSave() {
if (checkstatus) {
canvas.clipPath = null;
objects.forEach(async function (object) {
// Sequential, not forEach(async): toDatalessJSON below must not run
// while filters are still being stripped off the objects.
for (const object of objects) {
var obj = canvas.getItemById(object.id);
if (!obj) {
object.filters = [];
continue;
}
if (obj.filters) {
if (obj.filters.length > 0) {
object.filters = [];
@@ -174,7 +183,7 @@ function autoSave() {
} else {
object.filters = [];
}
});
}
const inst = canvas.toDatalessJSON([
'volume',
'audioSrc',
@@ -250,6 +259,9 @@ function autoSave() {
activepreset: activepreset,
width: artboard.width,
height: artboard.height,
})
.catch(function (e) {
console.error('Autosave failed', e);
});
objects.forEach(function (object) {
replaceSource(canvas.getItemById(object.id), canvas);
@@ -289,17 +301,24 @@ function loadProject() {
currenttime = 0;
canvas.clipPath = null;
canvas.clear();
fabric.filterBackend = webglBackend;
if (webglBackend) {
fabric.filterBackend = webglBackend;
}
f = fabric.Image.filters;
canvas.loadFromJSON(JSON.parse(project.canvas), function () {
canvas.clipPath = artboard;
canvas.getItemById('line_h').set({ opacity: 0 });
canvas.getItemById('line_v').set({ opacity: 0 });
hideGuides(canvas);
canvas.renderAll();
$('.object-props').remove();
$('.layer').remove();
objects.forEach(function (object) {
var animatethis = false;
if (!object.animate) {
object.animate = [];
}
if (!canvas.getItemById(object.id)) {
return;
}
if (object.animate.length > 5) {
if (isSameSet(object.animate, props)) {
animatethis = true;
@@ -331,6 +350,9 @@ function loadProject() {
}
});
keyframes.forEach(function (keyframe) {
if (!canvas.getItemById(keyframe.id)) {
return;
}
if (
keyframe.name != 'top' &&
keyframe.name != 'scaleY' &&
@@ -510,12 +532,18 @@ function deleteAsset(key) {
.doc({ key: key })
.get()
.then((asset) => {
if (!asset) {
return;
}
var temp = files.filter((x) => x.file == asset.src);
if (temp.length > 0) {
temp.forEach(function (file) {
deleteObject(canvas.getItemById(file.name));
const object = canvas.getItemById(file.name);
if (object) {
deleteObject(object);
}
files = $.grep(files, function (a) {
return a != file;
return a !== file;
});
});
}
@@ -534,14 +562,26 @@ function deleteAsset(key) {
});
}
function getAssets() {
function getAssets(attempt) {
attempt = attempt || 0;
db.collection('assets')
.get()
.then((assets) => {
// Sometimes the assets aren't ready when importing, really annoying
// Sometimes the assets aren't ready when importing, really annoying.
// Retry on a timer with a hard cap - a bare synchronous re-call spins
// the CPU forever when the collection never resolves.
if (assets === undefined) {
getAssets();
if (attempt < 20) {
window.setTimeout(function () {
getAssets(attempt + 1);
}, 250);
} else {
console.error('Could not read assets from the local database');
}
} else if (assets.length > 0) {
// Rebuild rather than append: getAssets() runs again after an import
uploaded_images = [];
uploaded_videos = [];
assets.forEach(function (asset) {
if (asset.type == 'image') {
uploaded_images.push({
@@ -562,6 +602,9 @@ function getAssets() {
}
});
}
})
.catch(function (e) {
console.error('Could not read assets', e);
});
}
@@ -570,10 +613,20 @@ function readTextFile(file, callback) {
rawFile.overrideMimeType('application/json');
rawFile.open('GET', file, true);
rawFile.onreadystatechange = function () {
if (rawFile.readyState === 4 && rawFile.status == '200') {
callback(rawFile.responseText);
if (rawFile.readyState === 4) {
// A blob: URL resolves with status 0
if (rawFile.status == 200 || rawFile.status === 0) {
callback(rawFile.responseText);
} else {
alert('Could not read the file');
$('#import-project span').html('Import');
}
}
};
rawFile.onerror = function () {
alert('Could not read the file');
$('#import-project span').html('Import');
};
rawFile.send(null);
}
@@ -582,10 +635,20 @@ async function importProject(e) {
var file = e.target.files[0];
var path = (window.URL || window.webkitURL).createObjectURL(file);
readTextFile(path, function (text) {
var data = JSON.parse(text);
var data;
try {
data = JSON.parse(text);
} catch (e) {
data = null;
}
if (!data || !Array.isArray(data.project) || data.project.length == 0) {
alert('Wrong file type');
$('#import-project span').html('Import');
return;
}
delete data.project[0].id;
if (data.project.length > 0) {
if (data.assets.length > 0) {
{
if (Array.isArray(data.assets) && data.assets.length > 0) {
data.assets.forEach(function (asset) {
delete asset.id;
db.collection('assets').add(asset);
@@ -598,10 +661,14 @@ async function importProject(e) {
$('#import-project span').html('Import');
hideModals();
loadProject();
})
.catch(function (e) {
console.error('Import failed', e);
alert('Import failed');
$('#import-project span').html('Import');
});
} else {
alert('Wrong file type');
}
window.URL.revokeObjectURL(path);
});
}
@@ -619,18 +686,24 @@ function exportProject() {
.get()
.then((assets) => {
var exportarr = { project: project, assets: assets };
$('<a />', {
download: 'data.json',
href:
'data:application/json,' +
encodeURIComponent(JSON.stringify(exportarr)),
})
.appendTo('body')
.click(function () {
$(this).remove();
$('#export-project span').html('Export');
})[0]
.click();
// Blob, not a data: URL - projects with media blow past the
// maximum URL length.
const url = URL.createObjectURL(
new Blob([JSON.stringify(exportarr)], {
type: 'application/json',
})
);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'data.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(function () {
URL.revokeObjectURL(url);
}, 60000);
$('#export-project span').html('Export');
});
} else {
alert('Empty project');
@@ -649,11 +722,16 @@ function clearProject() {
'Are you sure you want to clear this project? This action cannot be undone.'
)
) {
db.collection('projects').delete();
db.collection('assets').delete();
window.setTimeout(function () {
location.reload();
}, 1000);
Promise.all([
db.collection('projects').delete(),
db.collection('assets').delete(),
])
.catch(function (e) {
console.error('Could not clear the project', e);
})
.then(function () {
location.reload();
});
}
hideMore();
}