Files
Motionity/src/js/text.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

282 lines
6.4 KiB
JavaScript

function animateText(group, ms, play, props, cv, id) {
const p_keyframe = p_keyframes.find((x) => x.id == id);
if (!group || !p_keyframe) {
return;
}
ms -= p_keyframe.start;
var length = group._objects.length;
var globaldelay = 0;
// Every letter needs its own binding: the anime callbacks below run long
// after the loop has finished, so `var` would make them all share the last
// letter's state.
for (var i = 0; i < length; i++) {
let index = i;
if (props.order == 'backward') {
index = length - i - 1;
}
let left = group.item(index).defaultLeft;
let top = group.item(index).defaultTop;
let scaleX = group.item(index).defaultScaleX;
let scaleY = group.item(index).defaultScaleY;
// Named `step` so it does not shadow the global project duration
let step = props.duration / length;
let delay = i * step;
let animation = {
opacity: 0,
top: top,
left: left,
scaleX: scaleX,
scaleY: scaleY,
};
if (props.typeAnim == 'letter') {
delay = i * step - 100;
} else if (props.typeAnim == 'word') {
if (group.item(index).text == ' ') {
globaldelay += 500;
}
delay = globaldelay;
}
if (props.preset == 'typewriter') {
delay = i * step;
step = 20;
} else if (props.preset == 'fade in') {
} else if (props.preset == 'slide top') {
animation.top += 20;
} else if (props.preset == 'slide bottom') {
animation.top -= 20;
} else if (props.preset == 'slide left') {
animation.left += 20;
} else if (props.preset == 'slide right') {
animation.left -= 20;
} else if (props.preset == 'scale') {
animation.scaleX = 0;
animation.scaleY = 0;
} else if (props.preset == 'shrink') {
animation.scaleX = 1.5;
animation.scaleY = 1.5;
}
if (!(delay > 0)) {
delay = 0;
}
if (!(step > 20)) {
step = 20;
}
let start = false;
let instance = anime({
targets: animation,
delay: delay,
opacity: 1,
left: left,
top: top,
scaleX: scaleX,
scaleY: scaleY,
duration: step,
easing: props.easing,
autoplay: play,
update: function () {
if (start && play) {
group.item(index).set({
opacity: animation.opacity,
left: animation.left,
top: animation.top,
scaleX: animation.scaleX,
scaleY: animation.scaleY,
});
cv.renderAll();
}
},
changeBegin: function () {
start = true;
},
});
instance.seek(ms);
if (!play) {
group.item(index).set({
opacity: animation.opacity,
left: animation.left,
top: animation.top,
scaleX: animation.scaleX,
scaleY: animation.scaleY,
});
cv.renderAll();
}
}
}
function setText(group, props, cv) {
var length = group._objects.length;
for (var i = 0; i < length; i++) {
group.item(i).set({
fill: props.fill,
fontFamily: props.fontFamily,
});
cv.renderAll();
}
}
function renderText(string, props, x, y, cv, id, isnew, start) {
var textOffset = 0;
var group = [];
function renderLetter(letter) {
var text = new fabric.Text(letter, {
left: textOffset,
top: 0,
fill: props.fill,
fontFamily: props.fontFamily,
opacity: 1,
});
text.set({
defaultLeft: text.left,
defaultTop: text.top,
defaultScaleX: 1,
defaultScaleY: 1,
});
textOffset += text.get('width');
return text;
}
for (var i = 0; i < string.length; i++) {
group.push(renderLetter(string.charAt(i)));
}
var result = new fabric.Group(group, {
cursorWidth: 1,
stroke: '#000',
strokeUniform: true,
paintFirst: 'stroke',
strokeWidth: 0,
originX: 'center',
originY: 'center',
left: x - artboard.left,
top: y - artboard.top,
cursorDuration: 1,
cursorDelay: 250,
assetType: 'animatedText',
id: id,
strokeDashArray: false,
inGroup: false,
});
if (isnew) {
result.set({
notnew: true,
starttime: start,
});
}
result.objectCaching = false;
cv.add(result);
cv.renderAll();
newLayer(result);
result._objects.forEach(function (object, index) {
result.item(index).set({
defaultLeft: result.item(index).defaultLeft - result.width / 2,
defaultTop: result.item(index).defaultTop - result.height / 2,
});
});
cv.setActiveObject(result);
cv.bringToFront(result);
return result.id;
}
class AnimatedText {
constructor(text, props) {
this.text = text;
this.props = props;
this.id = 'Text' + layer_count;
}
render(cv) {
this.id = renderText(
this.text,
this.props,
this.props.left,
this.props.top,
cv,
this.id,
false,
0
);
animateText(
cv.getItemById(this.id),
currenttime,
false,
this.props,
cv,
this.id
);
}
seek(ms, cv) {
animateText(
cv.getItemById(this.id),
ms,
false,
this.props,
cv,
this.id
);
}
play(cv) {
animateText(
cv.getItemById(this.id),
0,
true,
this.props,
cv,
this.id
);
}
getObject(cv) {
return cv.getItemById(this.id);
}
setProps(newprops, cv) {
this.props = $.extend(this.props, newprops);
setText(cv.getItemById(this.id), this.props, cv);
}
setProp(newprop) {
$.extend(this.props, newprop);
}
reset(text, newprops, cv) {
var obj = cv.getItemById(this.id);
var left = obj.left;
var top = obj.top;
var scaleX = obj.scaleX;
var scaleY = obj.scaleY;
var angle = obj.angle;
var start = p_keyframes.find((x) => x.id == this.id).start;
deleteObject(obj, false);
this.text = text;
this.props = newprops;
this.inst = renderText(
text,
this.props,
left,
top,
cv,
this.id,
true,
start
);
cv.getItemById(this.id).set({
angle: angle,
scaleX: scaleX,
scaleY: scaleY,
});
cv.renderAll();
animateText(
cv.getItemById(this.id),
currenttime,
false,
this.props,
cv,
this.id
);
animate(false, currenttime);
save();
}
assignTo(id, text, props) {
this.id = id;
if (text !== undefined) {
this.text = text;
}
if (props !== undefined) {
this.props = props;
}
}
}