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
+174 -91
View File
@@ -1,5 +1,8 @@
// Update panel (when selecting / de-selecting objects)
function updatePanel(selection) {
if (selection && !canvas.getActiveObject()) {
selection = false;
}
if (!selection) {
$('#align').addClass('align-off');
$('#object-specific').html(canvas_panel);
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
function updateStrokeValues() {
const object = canvas.getActiveObject();
if (!object) {
return;
}
$('.line-join-active').removeClass('line-join-active');
if (
object.get('strokeDashArray') == false &&
object.get('strokeWidth') == 0
) {
const dash = object.get('strokeDashArray');
const hasDash = Array.isArray(dash) && dash.length > 0;
if (!hasDash && object.get('strokeWidth') == 0) {
$('#miter').addClass('line-join-active');
$('#miter img').attr('src', 'assets/miter-active.svg');
} else if (object.get('strokeDashArray') == false) {
} else if (!hasDash) {
$('#bevel').addClass('line-join-active');
$('#bevel img').attr('src', 'assets/bevel-active.svg');
} else if (object.get('strokeDashArray') == [10, 5]) {
} else if (dash[0] == 10 && dash[1] == 5) {
$('#round').addClass('line-join-active');
$('#round img').attr('src', 'assets/round-active.svg');
} else {
@@ -350,39 +355,39 @@ function updateStrokeValues() {
}
function toggleAnimationOrder() {
var object = canvas.getActiveObject();
const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active').removeClass(
'order-toggle-item-active'
);
if ($(this).attr('id') == 'order-backward') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ order: 'backward' }, canvas);
text.setProp({ order: 'backward' });
} else if ($(this).attr('id') == 'order-forward') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ order: 'forward' }, canvas);
text.setProp({ order: 'forward' });
}
$(this).addClass('order-toggle-item-active');
animate(currenttime, false);
// animate(play, time) - the arguments used to be the other way round, so
// the canvas never refreshed after changing a text animation.
animate(false, currenttime);
save();
}
function toggleAnimationType() {
var object = canvas.getActiveObject();
const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active-2').removeClass(
'order-toggle-item-active-2'
);
if ($(this).attr('id') == 'type-words') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'word' }, canvas);
text.setProp({ typeAnim: 'word' });
} else if ($(this).attr('id') == 'type-letters') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'letter' }, canvas);
text.setProp({ typeAnim: 'letter' });
}
$(this).addClass('order-toggle-item-active-2');
animate(currenttime, false);
animate(false, currenttime);
save();
}
$(document).on(
@@ -473,7 +478,7 @@ function updatePanelValues() {
var tempstore = false;
var object = canvas.getActiveObject();
if (
canvas.getActiveObjects.length > 1 ||
canvas.getActiveObjects().length > 1 ||
object.get('type') == 'activeSelection'
) {
object = object.toGroup();
@@ -590,7 +595,8 @@ function updatePanelValues() {
// Update opacity input
function updateInputs(id) {
if (canvas.getActiveObject().get('assetType') == 'audio') {
const active = canvas.getActiveObject();
if (!active || active.get('assetType') == 'audio') {
return false;
}
if ($('#object-o input').val() > 100) {
@@ -604,7 +610,7 @@ function updateInputs(id) {
id == 'object-color-fill-opacity'
) {
if ($('#object-color-fill-opacity input').val() > 100) {
$('#object-color-fill-opacity').val(100);
$('#object-color-fill-opacity input').val(100);
} else if ($('#object-color-fill-opacity input').val() < 0) {
$('#object-color-fill-opacity input').val(0);
}
@@ -628,7 +634,7 @@ function updateInputs(id) {
id == 'object-color-stroke-opacity'
) {
if ($('#object-color-stroke-opacity input').val() > 100) {
$('#object-color-stroke-opacity').val(100);
$('#object-color-stroke-opacity input').val(100);
} else if ($('#object-color-stroke-opacity input').val() < 0) {
$('#object-color-stroke-opacity input').val(0);
}
@@ -652,7 +658,7 @@ function updateInputs(id) {
id == 'object-color-shadow-opacity'
) {
if ($('#object-color-shadow-opacity input').val() > 100) {
$('#object-color-shadow-opacity').val(100);
$('#object-color-shadow-opacity input').val(100);
} else if ($('#object-color-shadow-opacity input').val() < 0) {
$('#object-color-shadow-opacity input').val(0);
}
@@ -673,7 +679,6 @@ function updateInputs(id) {
// Update object position based on panel input values
function updateObjectValues(type) {
autoSave();
if (canvas.getActiveObjects().length > 0) {
if ($(this).find('input').val() || type) {
var object = canvas.getActiveObject();
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
}
}
function setTextAnimation() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.reset(
$(this).parent().find('input').val(),
animatedtext.find((x) => x.id == object.id).props,
canvas
);
const text = activeAnimatedText();
if (!text) {
return;
}
text.reset($(this).parent().find('input').val(), text.props, canvas);
}
$(document).on('input', '.property-input', updateObjectValues);
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
// Toggle picker (maybe it could be condensed?)
function togglePicker() {
const object = canvas.getActiveObject();
if (!object && $(this).attr('id') != 'canvas-color') {
return;
}
if (!o_fill.isOpen()) {
newcolorkeyframe = true;
if ($(this).attr('id') == 'object-color-fill') {
colormode = 'fill';
o_fill.setColor(object.get('fill'));
@@ -879,7 +883,6 @@ function togglePicker() {
colormode = 'shadow';
o_fill.setColor(object.shadow.color);
}
newcolorkeyframe = false;
o_fill.show();
} else {
o_fill.hide();
@@ -917,6 +920,7 @@ function populateGrid(type) {
});
} else if (type == 'image-tool') {
$('#images-grid').html('');
$('#categories').html('');
image_categories.forEach(function (category) {
$('#categories').append(
"<div class='category' data-name='" +
@@ -930,6 +934,7 @@ function populateGrid(type) {
});
} else if (type == 'video-tool') {
$('#images-grid').html('');
$('#categories').html('');
video_categories.forEach(function (category) {
$('#categories').append(
"<div class='category' data-name='" +
@@ -959,7 +964,7 @@ function populateGrid(type) {
item.key +
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
item.thumb +
"'</div>"
"'></div>"
);
}
});
@@ -999,6 +1004,7 @@ function populateGrid(type) {
}
} else if (type == 'audio-tool') {
var flag = false;
$('#audio-list').html('');
audio_items.forEach(function (item) {
if (item.src == background_key) {
flag = true;
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
item.desc +
"</a><div class='audio-info-duration'>" +
item.duration +
'</div></div></div></div>'
'</div></div></div>'
);
} else {
$('#audio-list').append(
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
item.desc +
"</a><div class='audio-info-duration'>" +
item.duration +
'</div></div></div></div>'
'</div></div></div>'
);
}
});
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
if (type == 'image-tool') {
$('#browser-container').html(image_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'shape-tool') {
$('#browser-container').html(shape_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'video-tool') {
$('#browser-container').html(video_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'text-tool') {
$('#browser-container').html(text_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'upload-tool') {
$('#browser-container').html(upload_browser);
populateGrid('images-tab');
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'audio-tool') {
$('#browser-container').html(audio_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
}
}
@@ -1260,10 +1266,14 @@ $(document).on('click', '.tool:not(.tool-active)', switchTool);
// Replace image or video by dragging on top and holding a key
function replaceObject(src, object) {
if (!src || !object) {
return;
}
var img = new Image();
var width = object.width;
var height = object.height;
oldsrc = object._originalElement.currentSrc;
const element = object.getElement();
oldsrc = element ? element.currentSrc || element.src : null;
oldobj = object;
img.onload = function () {
object.setElement(img);
@@ -1271,6 +1281,9 @@ function replaceObject(src, object) {
object.set('height', height);
canvas.renderAll();
};
img.onerror = function () {
console.warn('Could not load replacement image');
};
img.src = src;
}
@@ -1339,7 +1352,9 @@ function dragObject(e) {
(replacing && !e.ctrlKey)
) {
drag.css('visibility', 'visible');
replaceObject(oldsrc, oldobj);
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false;
canvas.discardActiveObject();
$('#replace-image').removeClass('replace-active');
@@ -1361,7 +1376,21 @@ function dragObject(e) {
$('#properties').removeClass('noselect');
$('#controls').removeClass('noselect');
draggingPanel = false;
$('body').off('mousemove', dragging).off('mouseup', released);
// An aborted drag (pointercancel, a stray native dragstart, or the window
// losing focus) carries no pointer position, so there is nowhere to drop.
// Undo any in-progress replacement and bin the ghost.
if (!e || e.type != 'pointerup') {
if (replacing) {
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false;
canvas.discardActiveObject();
canvas.renderAll();
}
drag.remove();
return false;
}
canvasx = canvas.getPointer(e).x;
canvasy = canvas.getPointer(e).y;
var xpos = canvasx + offsetx - artboard.get('left');
@@ -1561,16 +1590,32 @@ function dragObject(e) {
}
drag.remove();
}
$('body').on('mouseup', released).on('mousemove', dragging);
// No pointer capture here (unlike the timeline drags): the drop target is
// worked out from the canvas hover state, and capturing would retarget those
// events to the panel item and leave overCanvas permanently false.
bindPointerDrag(e, null, dragging, released);
}
$(document).on('mousedown', '.image-grid-item', dragObject);
$(document).on('mousedown', '.video-grid-item', dragObject);
$(document).on('mousedown', '.grid-item', dragObject);
$(document).on('mousedown', '.grid-emoji-item', dragObject);
$(document).on('mousedown', '.add-text', dragObject);
$(document).on('mousedown click mouseup', '.credit', function (e) {
e.stopPropagation();
});
$(document).on('pointerdown', '.image-grid-item', dragObject);
$(document).on('pointerdown', '.video-grid-item', dragObject);
$(document).on('pointerdown', '.grid-item', dragObject);
$(document).on('pointerdown', '.grid-emoji-item', dragObject);
$(document).on('pointerdown', '.add-text', dragObject);
$(document).on(
'pointerdown mousedown click mouseup',
'.credit',
function (e) {
e.stopPropagation();
}
);
// Panel items must never become native drag sources - a native drag swallows
// the pointerup and leaves the dragged ghost stuck to the cursor
$(document).on(
'dragstart',
'.image-grid-item, .video-grid-item, .grid-item, .grid-emoji-item, .add-text, .credit',
function (e) {
e.preventDefault();
}
);
// Collapse library
function collapsePanel() {
@@ -1613,18 +1658,29 @@ function setPreset() {
}
$(document).on('change', '#preset', setPreset);
function activeAnimatedText() {
const object = canvas.getActiveObject();
if (!object) {
return null;
}
return animatedtext.find((x) => x.id == object.id) || null;
}
function setTextPreset() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.setProp({ preset: $(this).val() }, canvas);
const text = activeAnimatedText();
if (!text) {
return;
}
text.setProp({ preset: $(this).val() });
animate(false, currenttime);
save();
}
function setTextEasing() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.setProp({ easing: $(this).val() }, canvas);
const text = activeAnimatedText();
if (!text) {
return;
}
text.setProp({ easing: $(this).val() });
animate(false, currenttime);
save();
}
$(document).on('change', '#preset-picker', setTextPreset);
@@ -1651,11 +1707,14 @@ function saveLayerName() {
if ($('.name-active').val() == '') {
$('.name-active').val('Untitled layer');
}
objects.find(
const entry = objects.find(
(x) =>
x.id == $('.name-active').parent().parent().attr('data-object')
).label = $('.name-active').val();
save();
);
if (entry) {
entry.label = $('.name-active').val();
save();
}
$('.name-active').removeClass('name-active');
if (window.getSelection) {
if (window.getSelection().empty) {
@@ -1750,6 +1809,9 @@ function fancyTimeFormat(duration) {
function loadMoreMedia() {
var value = $('#browser-search input').val();
if (!HAS_PIXABAY_KEY) {
return;
}
if (value != '' && page != false) {
page += 1;
if ($('#image-tool').hasClass('tool-active')) {
@@ -1772,7 +1834,7 @@ function loadMoreMedia() {
hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL +
"'</div>"
"'></div>"
);
});
} else {
@@ -1800,7 +1862,7 @@ function loadMoreMedia() {
hit.user +
"</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
);
createVideoThumbnail(video, 250, 0, true).then(function (
data
@@ -1821,6 +1883,15 @@ function loadMoreMedia() {
function search() {
page = 1;
var value = $('#browser-search input').val();
const pixabaySearch =
$('#image-tool').hasClass('tool-active') ||
$('#video-tool').hasClass('tool-active');
if (pixabaySearch && !HAS_PIXABAY_KEY) {
$('#shapes-cont').html(
"<div id='no-results'>Image and video search needs a Pixabay API key. Set API_KEY in js/init.js.</div>"
);
return;
}
if ($('#image-tool').hasClass('tool-active')) {
var URL =
'https://pixabay.com/api/?key=' +
@@ -1845,7 +1916,7 @@ function search() {
hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL +
"'</div>"
"'></div>"
);
});
} else {
@@ -1883,7 +1954,7 @@ function search() {
hit.user +
"</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
);
//createVideoThumbnail(video, 250, 0, true).then(function(data){
$(".image-grid-item[data-src='" + video + "']")
@@ -1963,7 +2034,7 @@ function search() {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
font +
"' style='font-family: " +
font +
@@ -2069,10 +2140,7 @@ function checkFilter() {
resetFilters();
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (
canvas.getActiveObjects().length == 1 &&
(obj.type == 'image' || obj.type == 'video')
) {
if (canvas.getActiveObjects().length == 1 && obj.filters) {
var value = 'none';
if (obj.filters.length > 0) {
obj.filters.forEach(function (filter) {
@@ -2224,6 +2292,9 @@ $(document).on('click', '#reset-filters', removeFilters);
function updateChromaValues() {
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (!obj.filters) {
return;
}
if ($('.status-active').attr('id') == 'status-on') {
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance =
@@ -2264,7 +2335,8 @@ function updateChromaUI() {
$('.status-active').removeClass('status-active');
$('#status-on').addClass('status-active');
chromaslider.setValue(
obj.filters.find((x) => x.type == 'RemoveColor').distance
obj.filters.find((x) => x.type == 'RemoveColor').distance *
100
);
$('#chroma-color input').val(
obj.filters.find((x) => x.type == 'RemoveColor').color
@@ -2278,7 +2350,7 @@ function updateChromaUI() {
$('#status-off').addClass('status-active');
chromaslider.setValue(1);
$('#chroma-color input').val('#FFFFFF');
$('#color-chroma-side').css('background-color', '#FFFFF');
$('#color-chroma-side').css('background-color', '#FFFFFF');
}
}
}
@@ -2350,13 +2422,24 @@ function hideMore() {
function handleLottieUpload() {
var filething = $('#filepick3').get(0).files;
if (!filething || filething.length == 0) {
return;
}
var reader = new FileReader();
reader.onload = function (event) {
newLottieAnimation(
artboard.get('left') + artboard.get('width') / 2,
artboard.get('top') + artboard.get('height') / 2,
event.target.result
);
try {
newLottieAnimation(
artboard.get('left') + artboard.get('width') / 2,
artboard.get('top') + artboard.get('height') / 2,
event.target.result
);
} catch (e) {
console.error(e);
alert('That does not look like a valid Lottie file');
}
};
reader.onerror = function () {
alert('Could not read the file');
};
reader.readAsDataURL(filething.item(0));
}