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>
16 KiB
Bugs & Issues — audit + fixes
Audit of src/js, then all findings fixed. Every file below passes node --check.
No live browser smoke test was run (a Chrome instance held the Playwright profile
lock), so the changes are verified statically only — see Not verified at the end.
Legend: [x] fixed · P0 = feature broken · P1 = wrong data written · P2 = crash on edge path · P3 = silently wrong UI · P4 = perf/leak · P5 = cleanup
P0 — Broken features
-
MP4 export dead-ended and locked the UI —
src/js/functions.jsThe mp4 branch ofdownloadRecordingwastype = 'video/mp4', an implicit global assignment that did nothing: no download,recordingstuck attrue, the button stuck on "Downloading...", anddownloadModal()refusing to reopen until reload. mp4 now goes throughconvertStreams(blob, 'mp4')like gif, and a new sharedresetRecordingUI()unlocks the editor on every exit path (success, unknown format, worker error, FileReader error,MediaRecordererror). -
Copying keyframes threw —
src/js/events.jscanvas.getActiveObject().isEditingran onnull, because selecting keyframes clears the canvas selection — the normal path. The active object is now resolved once and guarded, and empty$.grepresults are no longer pushed to the clipboard. -
canvas.getItemByidtypo —src/js/functions.jsLowercasei; TypeError when pasting text/charSpacingkeyframes. -
Ctrl+Z crashed on an empty stack; Ctrl+Shift+Z was a no-op —
src/js/events.jsMerged into one guarded handler: shift picks redo, and each branch checks its own stack length. Previously Ctrl+Shift+Z matched bothifblocks (redo then undo). -
Blur slider threw with nothing selected —
src/js/events.jsobj.applyFilters()was outside theif (canvas.getActiveObject())guard. Also renamed the shadowedxin the blur/noise/chromafind()callbacks.
P1 — Wrong values written
heightkeyframes stored the width —src/js/functions.js(3 sites: twice inkeyframeChanges, once incrop)var scaleX = obj, scaleX;—src/js/text.js; the fabric object was being assigned as a scale factor.- NaN letter delay + shadowed global
duration—src/js/text.jsdelay = i * durationread the hoisted local before its own initialiser. Renamed tostep, computed before use, and!(delay > 0)now catches NaN. - Every letter animation wrote to the last letter —
src/js/text.jsindex,animation,startandinstanceareletper iteration instead ofvar. - Shadow defaults/keyframes stored
undefined—src/js/functions.jsfabric'sget()is not a path getter. AddedgetPropValue()(handlesshadow.*) andsetDefaultValue()/getDefaultValue(), and routed ~26 direct.defaults.find(...).value = …writes through them — which also creates the entry when a project predates a property instead of throwing. - WebGL filter backend clobbered with
undefined—src/js/init.js,src/js/database.jsBoth assignments are now conditional on the backend having constructed.
P2 — Crashes on edge paths
RangeError: Invalid array length—src/js/functions.jstemparr.length = findIndex(...)went negative for a stale keyframe reference.lastKeyframe/nextKeyframeare now index lookups that returnfalse.animate()had no null guards —src/js/functions.jsObject andp_keyframeslookups are resolved once and guarded in every per-frame loop (animate,recordAnimate, the playback update loop,playVideos,playAudio), which also removed dozens of repeated.find()calls.getAssets()recursed synchronously forever —src/js/database.jsRetries on a 250 ms timer, capped at 20 attempts, and rebuilds the asset arrays instead of appending duplicates on re-entry after an import.deleteObjectthrew and leakedfiles—src/js/functions.jsThe entry was compared to a string so it never matched; now filtered byname. Video elements are also paused and unloaded on delete.- Unguarded
keyarr[0]/.defaults.find(...)—copyKeyframes,updateKeyframe,applyEasing,keyframeProperties,removeKeyframe,checkAnyKeyframe. The four repetitive counterpart blocks were replaced by oneKEYFRAME_COUNTERPARTSmap, so a missing counterpart is skipped, not fatal. - Other unguarded lookups —
deleteAsset,reGroup,renderLayer,renderProp,setDuration,setTimelineZoom,saveLayerName,updateInputs,updatePanel,updateStrokeValues,animateText,scrollIntoView(3 sites),object:modified/object:rotating/mouse:out/mouse:uphandlers.importProjectvalidates the payload before touchingdata.project[0], andline_h/line_vgo through a newhideGuides()helper.
P3 — Silently wrong behaviour
document.onmousedownpermanently disabled —src/js/functions.jsdragTimelineinstalled areturn falsehandler and never removed it. It now only overridesonselectstart, and restores it on mouseup.- Keyframe time drifted on every drag —
src/js/functions.jsdata-timeis now always the absolute timeline time (the value every lookup keys off), with the visual offset applied as CSS only. This also fixes keyframes on an expanded row, whosedata-timeused to be layer-relative so no lookup matched.updateKeyframe's unused third argument is gone. - Shift-deselect never removed a keyframe —
thisinside a$.grepcallback is not the element. e.shiftDown→e.shiftKey.- Snap guide never hid — the row-local index was compared against the global
.keyframecount. - Comparison / typo bugs
canvas.getActiveObjects.length→getActiveObjects().length·strokeDashArray == [10, 5]→ element comparison · chromasetValue(distance)→distance * 100·'#FFFFF'→'#FFFFFF'·videoPlayer.videoheight→videoHeight(and both thumbnail helpers no longer draw the canvas onto itself) ·if (start && play && !paused)inplayAudio(playis the global function, always truthy) ·#redogated onredo.length·:last-child()→:last-child. - Paste loop closure —
var imgObj→let, so each thumbnail saves its own file. - Layers added mid-timeline were shortened —
end: duration - currenttime→duration(media layers clamp tomin(start + assetDuration, duration)). - Malformed HTML — 5 unterminated
<img …'strings and a stray</div>insrc/js/ui.js. - Duplicate DOM ids —
id="easing"on both wrapper and<select>(the select is noweasing-select;#easing selectstill matches),id="filters-title"×4 andid='item-text'×6 became classes, withsrc/styles.cssupdated to match. Added the missing<meta charset>.
P4 — Performance, leaks, export gaps
save()rebuilt the record canvas on every edit —src/js/functions.jsupdateRecordCanvas()+autoSave()now run through a 400 ms debounce (schedulePersist).record()still awaitsupdateRecordCanvas()directly, so an export always captures current state.updateObjectValuesno longer callsautoSave()on every keystroke.async forEachraced the snapshot —src/js/functions.js,src/js/database.jsBoth filter-stripping loops are sequentialfor…ofinasyncfunctions, sotoJSON/toDatalessJSONcan no longer run mid-strip.- O(n²·log n) playback —
src/js/functions.jsbuildKeyframeIndex()groups keyframes byid|nameonce per rendered frame;lastKeyframe,nextKeyframeandcheckAnyKeyframeuse it instead of sorting a copy of the entire keyframe list per keyframe per frame. The two duplicated innernextKeyframecopies are gone. - Exports lost audio-layer sound —
src/js/recorder.jsAudio layers were never routed into the capture stream. All sources (video elements, audio layers, background audio) now mix through one AudioContext into one destination track — necessary becauseMediaRecorderonly records the first audio track. Audio layers start/stop on theirp_keyframesboundaries. - Export timing —
src/js/recorder.jsThe render clock starts afterrecorder.start()(it used to run before, losing the first frames), stops when the animation clock coversdurationrather than on a wall-clocksetTimeout, and has aduration + 5ssafety stop. - Leaks — AudioContexts are closed and stream tracks stopped on export end;
object URLs are revoked (
downloadRecording,PostBlob,exportProject,importProject); the browser scroll handler is unbound before rebinding;sortableis initialised once instead of per rendered layer; deleted videos are paused and unloaded; category/audio grids are cleared before repopulating.
P5 — Dead code, config, cleanup
- Unreachable code calling an undefined
waitForEvent—src/js/recorder.jsinitRecorder/recordFrame/exportRecordingand ~180 lines of commented-out abandoned experiments were removed; the file is now just the live export path. - Placeholder API keys —
src/js/init.jsHAS_FONTS_KEY/HAS_PIXABAY_KEYgate the requests. Without a fonts key the pickers fall back to the bundled families instead of staying empty; without a Pixabay key search shows an explanatory message instead of 401-ing. var timeoutcollided withrecorder.js'stimeout()— removed (it was unused).- Implicit globals —
srcfreeze,osc,type,newcolorkeyframe(deleted, never read). - Duplicate / unreachable branches — the second
shadow.blurarm in all threesetValuecopies, the duplicatedobjectCachingkey, the three copies of the keyframe sort comparator (nowsortKeyframes()), and the two identical crop blocks inevents.js(nowupdateCropBounds()). - Converter was unfinished —
src/js/converter.jsTheworkerReadyhandshake never fired (buffersReadywas never set); worker readiness is now tracked across calls, since the worker is created once and reused. Addedonerrorhandling, an empty-result guard, and a user-visible failure path. - Misc —
overlay()no longer reuses the artboard'soverlayid ·getItemByIdrecurses so nested groups are found and stops at the first hit ·fabric.Lottieguardsthis.canvasbefore the object is added ·newLottieAnimationno longer multiplies an ms duration by 1000 · lottie layers get a colour ·calculateTextWidthhonours the requested font ·changeFont/loadImage/loadVideo/handleLottieUploadhave failure paths ·checkFilterno longer tests for a non-existentvideofabric type ·AnimatedText.assignToappliestext/props·animate(currenttime, false)→animate(false, currenttime)(3 sites — text animation changes never refreshed) ·exportProjectuses a Blob instead of adata:URL ·clearProjectreloads after the deletes resolve ·readTextFilehandles blob-URL status 0 and errors ·.catchadded to the Localbase promises · opacity inputs clamp the input, not the wrapper · arrow-key nudge callssetCoords()and is suppressed while typing · layer reorder shortcuts accept Ctrl as well as Cmd · alignment guides ignore hidden objects ·alignObjectsaves and dropped itsconsole.log.
Frame-accurate export (new)
The real-time capture path was the root cause of "heavy scenes drop frames" and "video layers are sampled at the wrong moment". It is now the fallback, not the default.
src/js/render.js — offline renderer
No clock is attached to the render. For each of duration × 30 frames:
- every video layer is seeked to the exact frame time and the code awaits
seekedbefore drawing (a barecurrentTime =is async — drawing straight after captures the previous frame, which is why real-time exports smeared video layers); - lottie layers are advanced to the same time;
recordAnimate(time)lays out the frame,canvasrecord.renderAll()draws it;- a
VideoFrameis built from the canvas with an explicit timestamp and pushed through aVideoEncoder(VP9, falling back to VP8), with the encoder queue held at ≤ 8 frames so memory stays bounded.
Audio is mixed in one OfflineAudioContext pass — every audio layer, every video
layer's soundtrack and the background track, each placed at its own
start/trimstart/end with its own gain — then encoded to Opus with
AudioEncoder. Sample-accurate, and unlike the live path it does not depend on
playback keeping up.
Both tracks are sorted by timestamp and muxed into one WebM.
recordAnimate() skips playVideos() while offlinerender is set, so real-time
playback cannot fight the explicit seeking.
src/js/webm-writer2.js — extended
Was single-track video only, and unreferenced by index.html. Now loaded, and:
- optional second Opus track (
A_OPUS+ 19-byteOpusHeadCodecPrivate,CodecDelay,SeekPreRoll,Audioelement with sample rate/channels); addFrame(chunk, trackNumber)acceptsEncodedAudioChunkas well asEncodedVideoChunk;addFrameToClusterno longer hardcodes track 1.
Three bugs fixed in the vendored library along the way:
MAX_CLUSTER_DURATION_MSECwas5000000(~58 days), so every frame went into a single cluster and block timecodes — a signed 16-bit offset — overflowed past ~32 s. Now 5000 ms, and clusters close on a video keyframe.- The header buffer was a fixed 256 bytes, which a second
TrackEntryoverflows (ArrayBufferDataStream's pos lies beyond end of buffer). Now 1024. instanceof Uint8Arrayfor byte payloads fails across a realm boundary;ArrayBuffer.isViewis now accepted too.
Safety net
The muxer is hand-rolled, so renderFrameAccurate() loads the finished blob into
a <video> element and checks it decodes before handing it over. Any failure —
no WebCodecs, unsupported codec, encoder error, failed verification — returns
null and record() transparently falls back to the real-time encoder.
Tests — test/webm-muxer.test.js
Runs in plain Node (no deps): node test/webm-muxer.test.js
Loads the muxer in a VM context, feeds fake encoded chunks, then parses the
resulting bytes with a small EBML reader and asserts the EBML header, Segment,
one or two TrackEntry with the right CodecID/TrackType, a 19-byte
OpusHead, multiple clusters, every block present on the right track, block
timecodes inside signed 16-bit range, ascending cluster timecodes, and that
cluster timecode + block timecode reconstructs the input timestamps to within
1 ms. Covers video-only and video+audio at 75 s (13 clusters, 3000 blocks).
Both cases pass. The 256-byte header bug above was caught by this test, not by inspection.
Left in place deliberately
src/js/encode-worker.jsis still unreferenced. It was the old File-System-Access-API sketch;render.jssupersedes it and buffers to memory instead of requiring a save-file picker. Deleting it is a product decision.- MP4 and GIF still go through the ancient asm.js ffmpeg worker
(
converter.js, loaded from archive.org). The frame-accurate renderer feeds it a better WebM, but that dependency is unchanged and may be offline.
Not verified
node --check passes on every script; the muxer is covered by the Node test
above. The app itself was not loaded in a browser (a running Chrome instance
held the Playwright profile lock), so the following still needs a manual pass:
cd src && python -m http.server 8765, openhttp://localhost:8765- Add a shape, keyframe it, drag the keyframe, scrub, undo/redo
- Export as WEBM with a video layer and an audio layer present — confirm the
console shows
Rendering n%(frame-accurate path) and notFalling back to the real-time encoder - Play the result: check audio is in sync and the video layer is not smeared