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
+78 -36
View File
@@ -1,7 +1,30 @@
var workerPath =
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
// MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg.
// Packaged builds ship it locally (npm run vendor); a plain checkout falls back
// to the public mirror, which needs network access.
var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js';
var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
var ffmpegAsmUrlPromise = null;
function processInWebWorker() {
// The worker is built from a blob, so importScripts() needs an absolute URL.
function resolveFfmpegAsmUrl() {
if (!ffmpegAsmUrlPromise) {
ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' })
.then(function (res) {
// A catch-all/SPA route answers 200 with HTML; that is not the script.
var type = res.headers.get('content-type') || '';
var local = res.ok && type.indexOf('text/html') === -1;
return local
? new URL(FFMPEG_ASM_LOCAL, location.href).href
: FFMPEG_ASM_REMOTE;
})
.catch(function () {
return FFMPEG_ASM_REMOTE;
});
}
return ffmpegAsmUrlPromise;
}
function processInWebWorker(workerPath) {
var blob = URL.createObjectURL(
new Blob(
[
@@ -21,37 +44,62 @@ function processInWebWorker() {
}
var worker;
// The worker is created once and reused, so its "ready" handshake only ever
// arrives for the first conversion. Remember it across calls.
var workerIsReady = false;
function convertStreams(videoBlob, setting) {
async function convertStreams(videoBlob, setting) {
var aab;
var buffersReady;
var workerReady;
var posted;
var buffersReady = false;
var posted = false;
function convertFailed(reason) {
console.error('Conversion failed: ' + reason);
alert(
'Sorry, the ' +
setting.toUpperCase() +
' conversion failed. The WEBM format is always available.'
);
resetRecordingUI();
}
var fileReader = new FileReader();
fileReader.onload = function () {
aab = this.result;
postMessage();
buffersReady = true;
if (workerIsReady) postMessage();
};
fileReader.onerror = function () {
convertFailed('could not read the recorded video');
};
fileReader.readAsArrayBuffer(videoBlob);
if (!worker) {
worker = processInWebWorker();
// Safe to await here: workerIsReady is still false, so the FileReader
// callback cannot post a command before the worker exists.
worker = processInWebWorker(await resolveFfmpegAsmUrl());
}
worker.onerror = function (e) {
convertFailed(e.message || 'worker error');
};
worker.onmessage = function (event) {
var message = event.data;
if (message.type == 'ready') {
workerReady = true;
workerIsReady = true;
if (buffersReady) postMessage();
} else if (message.type == 'done') {
var result = message.data[0];
var result = message.data && message.data[0];
if (!result || !result.data) {
convertFailed('the encoder returned no data');
return;
}
if (setting == 'gif') {
var blob = new File([result.data], 'test.gif', {
var blob = new File([result.data], 'video.gif', {
type: 'image/gif',
});
PostBlob(blob);
} else if (setting == 'mp4') {
var blob = new File([result.data], 'test.mp4', {
var blob = new File([result.data], 'video.mp4', {
type: 'video/mp4',
});
PostBlob(blob);
@@ -59,11 +107,17 @@ function convertStreams(videoBlob, setting) {
}
};
var postMessage = function () {
if (posted) return;
posted = true;
// The recording was made at this rate, so the transcode has to keep it:
// a fixed -r would duplicate or drop frames and drift the timing.
const fps = getExportFramerate();
if (setting == 'gif') {
worker.postMessage({
type: 'command',
arguments: '-i video.webm -r 24 output-10.gif'.split(' '),
arguments: ('-i video.webm -r ' + fps + ' output-10.gif').split(
' '
),
files: [
{
data: new Uint8Array(aab),
@@ -74,10 +128,11 @@ function convertStreams(videoBlob, setting) {
} else if (setting == 'mp4') {
worker.postMessage({
type: 'command',
arguments:
'-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split(
' '
),
arguments: (
'-i video.webm -c:v mpeg4 -b:v 6400k -r ' +
fps +
' -strict experimental output.mp4'
).split(' '),
files: [
{
data: new Uint8Array(aab),
@@ -97,22 +152,9 @@ function PostBlob(blob) {
a.download = blob.name || 'video';
document.body.appendChild(a);
a.click();
recording = false;
currenttime = 0;
animate(false, 0);
$('#seekbar').offset({
left:
offset_left +
$('#inner-timeline').offset().left +
currenttime / timelinetime,
});
canvas.renderAll();
resizeCanvas();
if (background_audio != false) {
background_audio.pause();
background_audio = new Audio(background_audio.src);
}
$('#download-real').html('Download');
$('#download-real').removeClass('downloading');
updateRecordCanvas();
document.body.removeChild(a);
window.setTimeout(function () {
URL.revokeObjectURL(url);
}, 60000);
resetRecordingUI();
}