Files
Motionity/src/js/align.js
T
kawaandClaude Opus 5 a6ec6c980a 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>
2026-08-12 12:58:51 +02:00

395 lines
9.7 KiB
JavaScript

// Center line reference
function initLines() {
if (canvas.getItemById('center_h')) {
canvas.remove(canvas.getItemById('center_h'));
canvas.remove(canvas.getItemById('center_v'));
}
if (canvas.getItemById('line_h')) {
canvas.remove(canvas.getItemById('line_h'));
canvas.remove(canvas.getItemById('line_v'));
}
// Canvas center reference
canvas.add(
new fabric.Line(
[
canvas.get('width') / 2,
0,
canvas.get('width') / 2,
canvas.get('height'),
],
{
opacity: 0,
selectable: false,
evented: false,
id: 'center_h',
}
)
);
canvas.add(
new fabric.Line(
[
0,
canvas.get('height') / 2,
canvas.get('width'),
canvas.get('height') / 2,
],
{
opacity: 0,
selectable: false,
evented: false,
id: 'center_v',
}
)
);
// Canvas alignemnt guides
line_h = new fabric.Line(
[
canvas.get('width') / 2,
artboard.get('top'),
canvas.get('width') / 2,
artboard.get('height') + artboard.get('top'),
],
{
stroke: 'red',
opacity: 0,
selectable: false,
evented: false,
id: 'line_h',
}
);
line_v = new fabric.Line(
[
artboard.get('left'),
canvas.get('height') / 2,
artboard.get('width') + artboard.get('left'),
canvas.get('height') / 2,
],
{
stroke: 'red',
opacity: 0,
selectable: false,
evented: false,
id: 'line_v',
}
);
canvas.add(line_h);
canvas.add(line_v);
}
// Hide the alignment guides after a canvas reload.
// They can be missing entirely in projects saved before they existed, so
// never dereference them directly.
function hideGuides(inst) {
const h = inst.getItemById('line_h');
const v = inst.getItemById('line_v');
if (h) {
h.set({ opacity: 0 });
}
if (v) {
v.set({ opacity: 0 });
}
}
function alignControls(object, type) {
if (type == 'align-top') {
object.set(
'top',
artboard.get('top') +
(object.get('height') * object.get('scaleY')) / 2
);
} else if (type == 'align-center-v') {
object.set(
'top',
artboard.get('top') + artboard.get('height') / 2
);
} else if (type == 'align-bottom') {
object.set(
'top',
artboard.get('top') +
artboard.get('height') -
(object.get('height') * object.get('scaleY')) / 2
);
} else if (type == 'align-left') {
object.set(
'left',
artboard.get('left') +
(object.get('width') * object.get('scaleX')) / 2
);
} else if (type == 'align-center-h') {
object.set(
'left',
artboard.get('left') + artboard.get('width') / 2
);
} else {
object.set(
'left',
artboard.get('left') +
artboard.get('width') -
(object.get('width') * object.get('scaleX')) / 2
);
}
}
// Align object
function alignObject() {
const type = $(this).attr('id');
const object = canvas.getActiveObject();
if (!object) {
return;
}
if (object.type == 'activeSelection') {
const tempselection = canvas.getActiveObject();
canvas.discardActiveObject();
tempselection._objects.forEach(function (object) {
alignControls(object, type);
canvas.renderAll();
newKeyframe(
'left',
object,
currenttime,
object.get('left'),
true
);
newKeyframe(
'top',
object,
currenttime,
object.get('top'),
true
);
});
reselect(tempselection);
} else {
alignControls(object, type);
canvas.renderAll();
newKeyframe(
'left',
object,
currenttime,
object.get('left'),
true
);
newKeyframe('top', object, currenttime, object.get('top'), true);
}
canvas.renderAll();
save();
}
$(document).on('click', '.align', alignObject);
// Alignment guides
function centerLines(e) {
if (!cropping) {
line_h.opacity = 0;
line_v.opacity = 0;
canvas.renderAll();
const snapZone = 5;
const obj_left = e.target.left;
const obj_top = e.target.top;
const obj_width = e.target.get('width') * e.target.get('scaleX');
const obj_height =
e.target.get('height') * e.target.get('scaleY');
canvas.forEachObject(function (obj, index, array) {
// Check for horizontal snapping
function checkHSnap(a, b, snapZone, e, type) {
if (a > b - snapZone && a < b + snapZone) {
line_h.opacity = 1;
line_h.bringToFront();
var value = b;
if (type == 1) {
value = b;
} else if (type == 2) {
value =
b -
(e.target.get('width') * e.target.get('scaleX')) / 2;
} else if (type == 3) {
value =
b +
(e.target.get('width') * e.target.get('scaleX')) / 2;
}
e.target
.set({
left: value,
})
.setCoords();
line_h
.set({
x1: b,
y1: artboard.get('top'),
x2: b,
y2: artboard.get('height') + artboard.get('top'),
})
.setCoords();
canvas.renderAll();
}
}
// Check for vertical snapping
function checkVSnap(a, b, snapZone, e, type) {
if (a > b - snapZone && a < b + snapZone) {
line_v.opacity = 1;
line_v.bringToFront();
var value = b;
if (type == 1) {
value = b;
} else if (type == 2) {
value =
b -
(e.target.get('height') * e.target.get('scaleY')) / 2;
} else if (type == 3) {
value =
b +
(e.target.get('height') * e.target.get('scaleY')) / 2;
}
e.target
.set({
top: value,
})
.setCoords();
line_v
.set({
y1: b,
x1: artboard.get('left'),
y2: b,
x2: artboard.get('width') + artboard.get('left'),
})
.setCoords();
canvas.renderAll();
}
}
if (
obj != e.target &&
obj != line_h &&
obj != line_v &&
obj.visible !== false
) {
if (
obj.get('id') == 'center_h' ||
obj.get('id') == 'center_v'
) {
var check1 = [[obj_left, obj.get('left'), 1]];
var check2 = [[obj_top, obj.get('top'), 1]];
for (var i = 0; i < check1.length; i++) {
checkHSnap(
check1[i][0],
check1[i][1],
snapZone,
e,
check1[i][2]
);
checkVSnap(
check2[i][0],
check2[i][1],
snapZone,
e,
check2[i][2]
);
}
} else {
var check1 = [
[obj_left, obj.get('left'), 1],
[
obj_left,
obj.get('left') +
(obj.get('width') * obj.get('scaleX')) / 2,
1,
],
[
obj_left,
obj.get('left') -
(obj.get('width') * obj.get('scaleX')) / 2,
1,
],
[obj_left + obj_width / 2, obj.get('left'), 2],
[
obj_left + obj_width / 2,
obj.get('left') +
(obj.get('width') * obj.get('scaleX')) / 2,
2,
],
[
obj_left + obj_width / 2,
obj.get('left') -
(obj.get('width') * obj.get('scaleX')) / 2,
2,
],
[obj_left - obj_width / 2, obj.get('left'), 3],
[
obj_left - obj_width / 2,
obj.get('left') +
(obj.get('width') * obj.get('scaleX')) / 2,
3,
],
[
obj_left - obj_width / 2,
obj.get('left') -
(obj.get('width') * obj.get('scaleX')) / 2,
3,
],
];
var check2 = [
[obj_top, obj.get('top'), 1],
[
obj_top,
obj.get('top') +
(obj.get('height') * obj.get('scaleY')) / 2,
1,
],
[
obj_top,
obj.get('top') -
(obj.get('height') * obj.get('scaleY')) / 2,
1,
],
[obj_top + obj_height / 2, obj.get('top'), 2],
[
obj_top + obj_height / 2,
obj.get('top') +
(obj.get('height') * obj.get('scaleY')) / 2,
2,
],
[
obj_top + obj_height / 2,
obj.get('top') -
(obj.get('height') * obj.get('scaleY')) / 2,
2,
],
[obj_top - obj_height / 2, obj.get('top'), 3],
[
obj_top - obj_height / 2,
obj.get('top') +
(obj.get('height') * obj.get('scaleY')) / 2,
3,
],
[
obj_top - obj_height / 2,
obj.get('top') -
(obj.get('height') * obj.get('scaleY')) / 2,
3,
],
];
for (var i = 0; i < check1.length; i++) {
checkHSnap(
check1[i][0],
check1[i][1],
snapZone,
e,
check1[i][2]
);
checkVSnap(
check2[i][0],
check2[i][1],
snapZone,
e,
check2[i][2]
);
}
}
}
});
}
}