Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 116 additions & 28 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1387,11 +1387,14 @@ function playRange() {
function playLimited() {
return state.workAreaPlay && !state.exporting && hasWorkArea();
}
/* Stop time while Limit is on. Playhead past OUT = manual override → full timeline. */
function playStopAt() {
if (!playLimited()) return Math.max(projDur(), 0);
/* Stop time while Limit is on. Playhead past OUT = manual override → full timeline.
`dur`, if given, is a precomputed projDur() (callers already looping every
clip once per frame can reuse it instead of triggering a second full scan). */
function playStopAt(dur) {
const d = Math.max(dur ?? projDur(), 0);
if (!playLimited()) return d;
const { end } = playRange();
if (state.time > end + 1e-4) return Math.max(projDur(), 0);
if (state.time > end + 1e-4) return d;
return end;
}
function gotoHome() {
Expand Down Expand Up @@ -1603,13 +1606,46 @@ function drawClipWave(cv, c, trackH) {
}
}

/* ── Ruler ── */
/* ── Ruler ──
Pure vector 2D drawing with no <video>/DOM dependency (unlike the program
monitor), so it's a clean fit to move off the main thread: transfer the
canvas to a Worker once and post it a handful of numbers per frame instead
of running the drawing code here. Falls back to drawing directly on the
main thread (drawRulerMainThread) when OffscreenCanvas/
transferControlToOffscreen isn't available. */
let rulerWorker = null, rulerWorkerTried = false;
function ensureRulerWorker() {
if (rulerWorkerTried) return;
rulerWorkerTried = true;
try {
if (window.Worker && els.ruler.transferControlToOffscreen) {
const offscreen = els.ruler.transferControlToOffscreen();
const w = new Worker("ruler-worker.js");
w.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
rulerWorker = w;
}
} catch { rulerWorker = null; }
}
function drawRuler() {
const cv = els.ruler, dpr = window.devicePixelRatio || 1;
ensureRulerWorker();
const dpr = window.devicePixelRatio || 1;
const w = els.timelineScroll.clientWidth, h = RULER_H;
els.ruler.style.width = w + "px"; els.ruler.style.height = h + "px";
if (rulerWorker) {
rulerWorker.postMessage({
type: "draw", w, h, dpr,
sl: els.timelineScroll.scrollLeft, pps: state.pps,
markers: project.markers, inPoint: project.inPoint, outPoint: project.outPoint,
time: state.time, fps: project.fps,
});
return;
}
drawRulerMainThread(w, h, dpr);
}
function drawRulerMainThread(w, h, dpr) {
const cv = els.ruler;
if (cv.width !== w * dpr || cv.height !== h * dpr) {
cv.width = w * dpr; cv.height = h * dpr;
cv.style.width = w + "px"; cv.style.height = h + "px";
}
const g = cv.getContext("2d");
g.setTransform(dpr, 0, 0, dpr, 0, 0);
Expand Down Expand Up @@ -2604,6 +2640,46 @@ const METER_DB_MAX = 0;
const METER_DB_MARKS = [0, -6, -12, -24, -36, -48];
const METER_MODES = ["rms", "lufs", "peak"];
const METER_MODE_LABEL = { rms: "RMS", lufs: "LUFS", peak: "PEAK" };
/* Each channel's segment ladder is one <canvas> instead of METER_SEGS separate
DOM nodes (was up to 16 tracks × 16 <div>s = 256 live elements, all touched
via classList every time the reading changed). Geometry matches the old
flex layout: 16 × 12px segments, 2px gaps, column-reverse (index 0 = bottom
= quietest). */
const METER_SEG_W = 8, METER_SEG_H = 12, METER_SEG_GAP = 2;
const METER_COL_W = METER_SEG_W;
const METER_COL_H = METER_SEGS * METER_SEG_H + (METER_SEGS - 1) * METER_SEG_GAP;
function makeMeterCanvas(cv) {
const dpr = window.devicePixelRatio || 1;
cv.style.width = METER_COL_W + "px";
cv.style.height = METER_COL_H + "px";
cv.width = Math.round(METER_COL_W * dpr);
cv.height = Math.round(METER_COL_H * dpr);
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { canvas: cv, ctx };
}
/** Paint the full 16-segment ladder for one channel in a single pass. `hold`
* is the peak-hold tick's segment index (-1 = none). */
function paintMeterSegs(entry, lit, hold) {
const ctx = entry.ctx;
ctx.clearRect(0, 0, METER_COL_W, METER_COL_H);
for (let i = 0; i < METER_SEGS; i++) {
const y = METER_COL_H - (i + 1) * METER_SEG_H - i * METER_SEG_GAP;
const on = i < lit || i === hold;
const u = i / (METER_SEGS - 1);
ctx.beginPath();
ctx.roundRect(0, y, METER_SEG_W, METER_SEG_H, 1);
if (on) {
ctx.fillStyle = u < 0.6 ? "#3dd68c" : u < 0.85 ? "#f0c14a" : "#e5484d";
ctx.fill();
} else {
ctx.fillStyle = "#2a2a33";
ctx.fill();
ctx.strokeStyle = "#0006"; ctx.lineWidth = 1;
ctx.stroke();
}
}
}
const meterState = {
mode: (() => {
try {
Expand All @@ -2619,6 +2695,8 @@ const meterState = {
peakHold: {},
peakHoldT: {},
segs: {},
lastLit: {}, // id -> last-painted lit/hold seg indices, to skip redundant DOM writes
lastHold: {},
modeBtn: null,
};
function audioMeterTracks() {
Expand Down Expand Up @@ -2728,6 +2806,8 @@ function buildMeterDOM() {
row.appendChild(scale);

meterState.segs = {};
meterState.lastLit = {};
meterState.lastHold = {};
meterState.trackIds = tracks.map((t) => t.id);
for (const t of tracks) {
if (meterState.disp[t.id] == null) {
Expand All @@ -2741,20 +2821,15 @@ function buildMeterDOM() {
const col = document.createElement("div");
col.className = "vu-channel";
col.dataset.track = t.id;
const segs = document.createElement("div");
segs.className = "vu-segs";
meterState.segs[t.id] = [];
for (let i = 0; i < METER_SEGS; i++) {
const seg = document.createElement("div");
const u = i / (METER_SEGS - 1);
seg.className = "vu-seg " + (u < 0.6 ? "g" : u < 0.85 ? "y" : "r");
segs.appendChild(seg);
meterState.segs[t.id].push(seg);
}
const segsCv = document.createElement("canvas");
segsCv.className = "vu-segs";
const entry = makeMeterCanvas(segsCv);
meterState.segs[t.id] = entry;
paintMeterSegs(entry, 0, -1); // start fully off
const label = document.createElement("span");
label.className = "vu-label";
label.textContent = t.id;
col.appendChild(segs);
col.appendChild(segsCv);
col.appendChild(label);
row.appendChild(col);
}
Expand Down Expand Up @@ -2800,15 +2875,19 @@ function updateMeterUI(dt) {
}

const segs = meterState.segs[id];
if (!segs || !segs.length) continue;
if (!segs) continue;
const level = (next - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN);
const lit = Math.round(clamp(level, 0, 1) * METER_SEGS);
const hold = Math.round(clamp(
((meterState.peakHold[id] ?? METER_DB_MIN) - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN), 0, 1
) * (METER_SEGS - 1));
for (let i = 0; i < METER_SEGS; i++) {
segs[i].classList.toggle("on", i < lit || i === hold);
}
// The ballistics above still run every frame (needed for smooth decay),
// but the canvas only needs repainting when the result actually differs —
// skips a redraw for most tracks most frames.
if (meterState.lastLit[id] === lit && meterState.lastHold[id] === hold) continue;
meterState.lastLit[id] = lit;
meterState.lastHold[id] = hold;
paintMeterSegs(segs, lit, hold);
}
}

Expand Down Expand Up @@ -2986,10 +3065,13 @@ function syncMedia() {
if (c.kind === "text" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") continue;
const el = getClipEl(c); if (!el) continue;
const enabled = isTrackEnabled(c.track);
const p = evalProps(c, t);
const sp = clamp(+p.speed || 1, 0.1, 8);
const mt = mediaTimeAt(c, t);
if (state.playing && enabled && activeAt(c, t)) {
// Only the active-under-playhead branch needs the full evaluated props
// (speed/volume incl. keyframes+transitions) — skip that work for every
// other clip on the timeline, which is the common case each frame.
const p = evalProps(c, t);
const sp = clamp(+p.speed || 1, 0.1, 8);
const eff = clamp(sp * playRate(), 0.0625, 16); // preview speed rides on top of clip speed
if (el.playbackRate !== eff) { try { el.playbackRate = eff; } catch {} }
if (el.paused) el.play().catch(() => {});
Expand Down Expand Up @@ -3034,7 +3116,10 @@ const EASE = {
/* Effective properties of a clip at timeline time t: static props, overridden by
keyframe curves, then shaped by in/out transition envelopes. */
function evalProps(c, t) {
const p = { ...DEFAULT_PROPS, ...c.props };
// c.props is always fully populated with DEFAULT_PROPS's keys already (on
// load and at every clip-creation site), so a plain shallow clone suffices —
// merging DEFAULT_PROPS in again here would just double the copy work.
const p = { ...c.props };
const local = t - c.start;
if (c.keyframes) {
for (const [k, kfs] of Object.entries(c.keyframes)) {
Expand Down Expand Up @@ -4246,9 +4331,12 @@ function loop(ts) {
if (lastTs == null) lastTs = ts;
const dt = Math.min(0.1, (ts - lastTs) / 1000);
lastTs = ts;
// projDur() is an O(clips) scan — compute it once per tick and reuse below
// instead of the 2-4 independent recomputations this loop used to trigger.
const dur = projDur();
if (state.playing) {
state.time += dt * playRate();
const end = playStopAt();
const end = playStopAt(dur);
if (state.time >= end) {
state.time = end;
if (state.exporting) finishExport(true);
Expand All @@ -4269,9 +4357,9 @@ function loop(ts) {
updateSafeOverlay();
updateMeterUI(dt);
els.tcCurrent.textContent = fmt(state.time);
els.tcTotal.textContent = fmt(projDur());
els.tcTotal.textContent = fmt(dur);
if (state.exporting && !state.rendering) {
const pct = projDur() ? (state.time / projDur()) * 100 : 0;
const pct = dur ? (state.time / dur) * 100 : 0;
els.exportProgress.style.width = pct.toFixed(1) + "%";
els.exportTitle.textContent = `Exporting… ${pct.toFixed(0)}%`;
}
Expand Down
101 changes: 101 additions & 0 deletions ruler-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* FableCut timeline-ruler worker.
Draws the ruler canvas (ticks, time labels, IN/OUT flags, beat markers,
playhead) off the main thread via a transferred OffscreenCanvas. This is a
clean Worker candidate — unlike the program-monitor compositor (app.js
drawFrame/drawClip), the ruler never touches <video>/<audio> elements or
any other main-thread-only DOM state, only the small set of numbers/arrays
sent in each "draw" message. Keep this in sync with app.js's drawRuler()
fallback (used when OffscreenCanvas/transferControlToOffscreen isn't
available) if the look changes. */
let cv = null, g = null;

function fmt(t, fps) {
t = Math.max(0, t);
const m = Math.floor(t / 60), s = Math.floor(t % 60),
f = Math.floor((t % 1) * (fps || 30));
const p = (n) => String(n).padStart(2, "0");
return `${p(m)}:${p(s)}:${p(f)}`;
}

function draw(msg) {
if (!g) return;
const { w, h, dpr, sl, pps, markers, inPoint, outPoint, time, fps } = msg;
const bw = Math.round(w * dpr), bh = Math.round(h * dpr);
if (cv.width !== bw || cv.height !== bh) { cv.width = bw; cv.height = bh; }
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
const step = steps.find((s) => s * pps >= 70) || 600;
const minor = step / 5;
const i0 = Math.max(0, Math.floor(sl / pps / minor));
// ticks + time labels first so IN/OUT can difference-blend over them
g.strokeStyle = "#4a4a55";
g.fillStyle = "#9a9aa6";
g.font = "10px Consolas, monospace";
g.beginPath();
for (let i = i0; i * minor * pps < sl + w; i++) {
const t = i * minor;
const x = Math.round(t * pps - sl) + 0.5;
const isMajor = i % 5 === 0;
g.moveTo(x, isMajor ? 8 : 17); g.lineTo(x, h);
if (isMajor) g.fillText(fmt(Math.round(t * 1000) / 1000, fps).slice(0, 5), x + 4, 12);
}
g.stroke();
// dim timeline outside the IN–OUT work area
if (inPoint != null && outPoint != null && outPoint > inPoint) {
const x0 = inPoint * pps - sl, x1 = outPoint * pps - sl;
g.fillStyle = "#00000055";
if (x0 > 0) g.fillRect(0, 0, Math.min(w, x0), h);
if (x1 < w) g.fillRect(Math.max(0, x1), 0, w - Math.max(0, x1), h);
}
// beat/cue markers
for (const mk of markers || []) {
const x = mk.t * pps - sl;
if (x < -6 || x > w + 6) continue;
g.fillStyle = "#ffd166";
g.beginPath();
g.moveTo(x, h - 9); g.lineTo(x + 4, h - 5); g.lineTo(x, h - 1); g.lineTo(x - 4, h - 5);
g.closePath(); g.fill();
}
// IN / OUT — bottom-aligned; `difference` keeps time glyphs readable where they overlap
const mkH = (h - 4) * 0.75, bot = h - 1, top = bot - mkH, mid = (top + bot) / 2;
g.globalCompositeOperation = "difference";
if (inPoint != null) {
const x = inPoint * pps - sl;
if (x >= -10 && x <= w + 10) {
g.fillStyle = "#5eead4";
g.beginPath();
g.moveTo(x, top); g.lineTo(x, bot); g.lineTo(x + 8, mid);
g.closePath(); g.fill();
}
}
if (outPoint != null) {
const x = outPoint * pps - sl;
if (x >= -10 && x <= w + 10) {
g.fillStyle = "#fb923c";
g.beginPath();
g.moveTo(x, top); g.lineTo(x, bot); g.lineTo(x - 8, mid);
g.closePath(); g.fill();
}
}
g.globalCompositeOperation = "source-over";
// playhead marker on ruler
const px = time * pps - sl;
if (px >= -8 && px <= w + 8) {
g.fillStyle = "#ff4d6a";
g.beginPath();
g.moveTo(px - 6, 12); g.lineTo(px + 6, 12); g.lineTo(px + 6, 19); g.lineTo(px, 25); g.lineTo(px - 6, 19);
g.closePath(); g.fill();
}
}

self.onmessage = (ev) => {
const msg = ev.data;
if (!msg) return;
if (msg.type === "init") {
cv = msg.canvas;
g = cv.getContext("2d");
} else if (msg.type === "draw") {
draw(msg);
Comment on lines +95 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 'transferControlToOffscreen|new Worker|rulerWorker\s*=|onerror|onmessage|getContext\("2d"\)' app.js ruler-worker.js

Repository: ronak-create/FableCut

Length of output: 7170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app.js ruler section =="
sed -n '1585,1705p' app.js | nl -ba -v1585

echo
echo "== ruler-worker.js =="
sed -n '1,130p' ruler-worker.js | nl -ba -v1

echo
echo "== current git diff stat and ruler app.js/workers changes =="
git diff --stat -- app.js ruler-worker.js || true
git diff -- app.js ruler-worker.js | sed -n '1,260p' || true

echo
echo "== all ruler-related occurrences =="
rg -n -C2 'drawRuler|ensureRulerWorker|rulerWorker|ruler|OffscreenCanvas|transferControlToOffscreen' app.js ruler-worker.js

Repository: ronak-create/FableCut

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app.js ruler section =="
sed -n '1585,1705p' app.js | cat -n | sed 's#^`#app.js`:'

echo
echo "== ruler-worker.js =="
sed -n '1,145p' ruler-worker.js | cat -n | sed 's#^`#ruler-worker.js`:'

echo
echo "== current git diff stat and ruler app.js/workers changes =="
git diff --stat -- app.js ruler-worker.js || true
git diff -- app.js ruler-worker.js | cat -n | sed 's#^`#diff`:' || true

echo
echo "== all ruler-related occurrences =="
rg -n -C3 'drawRuler|ensureRulerWorker|rulerWorker|ruler|OffscreenCanvas|transferControlToOffscreen' app.js ruler-worker.js

Repository: ronak-create/FableCut

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app.js ruler section =="
sed -n '1585,1705p' app.js

echo
echo "== ruler-worker.js =="
sed -n '1,145p' ruler-worker.js

echo
echo "== current git diff stat and ruler app.js/workers changes =="
git diff --stat -- app.js ruler-worker.js || true
git diff -- app.js ruler-worker.js || true

echo
echo "== all ruler-related occurrences =="
rg -n -C3 'drawRuler|ensureRulerWorker|rulerWorker|ruler|OffscreenCanvas|transferControlToOffscreen' app.js ruler-worker.js

Repository: ronak-create/FableCut

Length of output: 14459


Add worker readiness/error signaling before disabling the fallback.

ensureRulerWorker() treats rulerWorker as active immediately after creating the worker and posting the transferred canvas. If the worker fails to load, or cv.getContext("2d") fails inside ruler-worker.js, g remains null, draw messages are dropped, and the fallback can no longer render because the main canvas was already transferred. Add init acknowledgment or error signaling, queue/drop worker-only draws until the worker is ready, and remove/marked-recreate the worker when an error occurs before falling back.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ruler-worker.js` around lines 95 - 99, Update ensureRulerWorker and the
ruler-worker init/draw protocol so worker activation is confirmed only after
successful canvas transfer and 2D context initialization. Add explicit init
acknowledgment/error signaling, defer or discard worker-only draw messages until
readiness, and clear or recreate rulerWorker on startup or runtime errors so
fallback rendering remains available.

}
};
15 changes: 2 additions & 13 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -628,20 +628,9 @@ body.track-size-s .clip.selected {
gap: 3px;
}
.vu-segs {
display: flex;
flex-direction: column-reverse;
gap: 2px;
}
.vu-seg {
width: 8px;
height: 12px;
border-radius: 1px;
background: #2a2a33;
box-shadow: inset 0 0 0 1px #0006;
display: block;
/* size is set inline via JS (makeMeterCanvas) to match devicePixelRatio */
}
.vu-seg.on.g { background: #3dd68c; box-shadow: none; }
.vu-seg.on.y { background: #f0c14a; box-shadow: none; }
.vu-seg.on.r { background: #e5484d; box-shadow: none; }
.vu-label {
font-size: 9px;
font-weight: 700;
Expand Down
Loading