fablecut 0.0.0-stage → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CLAUDE.md +1198 -0
  2. package/LICENSE +21 -0
  3. package/README.md +571 -2
  4. package/analyze.js +289 -0
  5. package/app.js +13339 -0
  6. package/audio-fx.js +241 -0
  7. package/cli.js +31 -0
  8. package/color.js +1043 -0
  9. package/denoise.js +120 -0
  10. package/ducking.js +127 -0
  11. package/edit-ops.js +962 -0
  12. package/encode-profiles.js +302 -0
  13. package/encoding-profiles.json +68 -0
  14. package/export-jobs.js +163 -0
  15. package/favicon.svg +14 -0
  16. package/fx-worklet.js +162 -0
  17. package/icons/favicon-32.png +0 -0
  18. package/import-url.js +293 -0
  19. package/index.html +420 -0
  20. package/library/README.md +15 -0
  21. package/library/elements/.gitkeep +0 -0
  22. package/library/elements/bokeh-drift.svg +19 -0
  23. package/library/elements/light-leak-cool.svg +16 -0
  24. package/library/elements/light-leak-warm.svg +22 -0
  25. package/library/elements/vhs-scanlines.svg +18 -0
  26. package/library/elements/vignette-frame.svg +14 -0
  27. package/library/fonts/.gitkeep +0 -0
  28. package/library/fonts/Abril Fatface.woff2 +0 -0
  29. package/library/fonts/Anton.woff2 +0 -0
  30. package/library/fonts/Archivo Black.woff2 +0 -0
  31. package/library/fonts/Bangers.woff2 +0 -0
  32. package/library/fonts/Barlow.woff2 +0 -0
  33. package/library/fonts/Bebas Neue.woff2 +0 -0
  34. package/library/fonts/Bungee.woff2 +0 -0
  35. package/library/fonts/Caveat.woff2 +0 -0
  36. package/library/fonts/DM Sans.woff2 +0 -0
  37. package/library/fonts/Inter.woff2 +0 -0
  38. package/library/fonts/LICENSES.md +31 -0
  39. package/library/fonts/Lato.woff2 +0 -0
  40. package/library/fonts/Montserrat.woff2 +0 -0
  41. package/library/fonts/Oswald.woff2 +0 -0
  42. package/library/fonts/Pacifico.woff2 +0 -0
  43. package/library/fonts/Playfair Display.woff2 +0 -0
  44. package/library/fonts/Poppins.woff2 +0 -0
  45. package/library/fonts/Righteous.woff2 +0 -0
  46. package/library/fonts/Roboto.woff2 +0 -0
  47. package/library/fonts/Rubik.woff2 +0 -0
  48. package/library/fonts/Teko.woff2 +0 -0
  49. package/library/sfx/README.md +18 -0
  50. package/library/svg/ai-star.svg +17 -0
  51. package/library/svg/arrow-bounce.svg +18 -0
  52. package/library/svg/arrow-scribble.svg +13 -0
  53. package/library/svg/burst-lines.svg +30 -0
  54. package/library/svg/checkmark-success.svg +19 -0
  55. package/library/svg/confetti-burst.svg +23 -0
  56. package/library/svg/countdown-ring.svg +92 -0
  57. package/library/svg/equalizer.svg +18 -0
  58. package/library/svg/glow-orbs.svg +29 -0
  59. package/library/svg/heart-pop.svg +28 -0
  60. package/library/svg/loading-dots.svg +16 -0
  61. package/library/svg/location-pin.svg +25 -0
  62. package/library/svg/lower-third.svg +17 -0
  63. package/library/svg/new-badge.svg +18 -0
  64. package/library/svg/progress-bar.svg +16 -0
  65. package/library/svg/radial-pulse.svg +19 -0
  66. package/library/svg/rating-stars.svg +25 -0
  67. package/library/svg/rec-indicator.svg +41 -0
  68. package/library/svg/sparkles.svg +17 -0
  69. package/library/svg/spawning-hearts.svg +39 -0
  70. package/library/svg/speech-bubble.svg +23 -0
  71. package/library/svg/subscribe-bell.svg +29 -0
  72. package/library/svg/subscribe-pill.svg +22 -0
  73. package/library/svg/swipe-up.svg +46 -0
  74. package/library/svg/underline-swoosh.svg +15 -0
  75. package/loudness.js +135 -0
  76. package/mcp-server.js +1421 -0
  77. package/meter-worklet.js +229 -0
  78. package/package.json +45 -4
  79. package/paths.js +106 -0
  80. package/ruler-worker.js +120 -0
  81. package/server.js +775 -0
  82. package/style.css +3563 -0
@@ -0,0 +1,229 @@
1
+ /* FableCut per-track meter worklet + stereo program sum pass-through.
2
+ Inputs 0…nAudio−1 = A-track buses; input nAudio = video/other spill on master.
3
+ Pass-through sum → stereo out. Per-track RMS/LUFS/Peak via port; master L/R
4
+ RMS/Peak is metered on the main thread (AnalyserNodes on the worklet output),
5
+ while master LUFS is computed here on the true summed program output. */
6
+ function shelfCoeffs(fs) {
7
+ const f0 = 1681.974450955533;
8
+ const G = 3.999843853973347;
9
+ const Q = 0.7071752369554196;
10
+ const K = Math.tan(Math.PI * f0 / fs);
11
+ const Vh = Math.pow(10, G / 20);
12
+ const Vb = Math.pow(Vh, 0.5);
13
+ const a0 = 1 + K / Q + K * K;
14
+ return {
15
+ b0: (Vh + Vb * K / Q + K * K) / a0,
16
+ b1: 2 * (K * K - Vh) / a0,
17
+ b2: (Vh - Vb * K / Q + K * K) / a0,
18
+ a1: 2 * (K * K - 1) / a0,
19
+ a2: (1 - K / Q + K * K) / a0,
20
+ };
21
+ }
22
+ function hpfCoeffs(fs) {
23
+ const f0 = 38.13547087613982;
24
+ const Q = 0.5003270373238773;
25
+ const K = Math.tan(Math.PI * f0 / fs);
26
+ const a0 = 1 + K / Q + K * K;
27
+ return {
28
+ b0: 1 / a0,
29
+ b1: -2 / a0,
30
+ b2: 1 / a0,
31
+ a1: 2 * (K * K - 1) / a0,
32
+ a2: (1 - K / Q + K * K) / a0,
33
+ };
34
+ }
35
+ function makeBiquad(c) {
36
+ return { b0: c.b0, b1: c.b1, b2: c.b2, a1: c.a1, a2: c.a2, z1: 0, z2: 0 };
37
+ }
38
+ function biquadStep(f, x) {
39
+ const y = f.b0 * x + f.z1;
40
+ f.z1 = f.b1 * x - f.a1 * y + f.z2;
41
+ f.z2 = f.b2 * x - f.a2 * y;
42
+ return y;
43
+ }
44
+
45
+ class FableCutMeterProcessor extends AudioWorkletProcessor {
46
+ constructor(options) {
47
+ super();
48
+ const opts = (options && options.processorOptions) || {};
49
+ this._hopBlocks = Math.max(1, opts.hopBlocks || 8);
50
+ this._nTracks = Math.max(1, opts.nTracks || 1);
51
+ this._nAudio = Math.max(0, opts.nAudioTracks ?? opts.nTracks ?? 1);
52
+ // When set, that input is the finished program: only it reaches the
53
+ // output (and the master LUFS); the others are metering taps. Unset =
54
+ // every input is summed (the original layout).
55
+ this._program = Number.isInteger(opts.programInput) ? opts.programInput : -1;
56
+ this._block = 0;
57
+ this._sumSqL = new Float64Array(this._nTracks);
58
+ this._sumSqR = new Float64Array(this._nTracks);
59
+ this._peak = new Float64Array(this._nTracks);
60
+ this._sumSqK = new Float64Array(this._nTracks);
61
+ this._frames = 0;
62
+
63
+ const shelf = shelfCoeffs(sampleRate);
64
+ const hpf = hpfCoeffs(sampleRate);
65
+ this._shelfL = [];
66
+ this._hpfL = [];
67
+ this._shelfR = [];
68
+ this._hpfR = [];
69
+ for (let t = 0; t < this._nTracks; t++) {
70
+ this._shelfL.push(makeBiquad(shelf));
71
+ this._hpfL.push(makeBiquad(hpf));
72
+ this._shelfR.push(makeBiquad(shelf));
73
+ this._hpfR.push(makeBiquad(hpf));
74
+ }
75
+
76
+ // Filters for the summed program output so the master LUFS is the true
77
+ // post-mix reading, not an approximation from per-track values.
78
+ this._shelfOutL = makeBiquad(shelf);
79
+ this._hpfOutL = makeBiquad(hpf);
80
+ this._shelfOutR = makeBiquad(shelf);
81
+ this._hpfOutR = makeBiquad(hpf);
82
+ this._sumSqKOut = 0;
83
+
84
+ // Momentary = 400 ms of block mean-squares (BS.1770) — per A-track + program.
85
+ const hopFrames = 128 * this._hopBlocks;
86
+ this._lufsLen = Math.max(1, Math.round(0.4 * sampleRate / hopFrames));
87
+ this._lufsRing = [];
88
+ this._lufsIdx = [];
89
+ this._lufsFilled = [];
90
+ for (let t = 0; t < this._nTracks; t++) {
91
+ this._lufsRing.push(new Float64Array(this._lufsLen));
92
+ this._lufsIdx.push(0);
93
+ this._lufsFilled.push(0);
94
+ }
95
+ this._lufsRingOut = new Float64Array(this._lufsLen);
96
+ this._lufsIdxOut = 0;
97
+ this._lufsFilledOut = 0;
98
+ }
99
+
100
+ process(inputs, outputs) {
101
+ const output = outputs[0];
102
+ const outL = output && output[0];
103
+ const outR = output && output[1];
104
+ if (outL) outL.fill(0);
105
+ if (outR) outR.fill(0);
106
+
107
+ let frames = 0;
108
+ for (let t = 0; t < this._nTracks; t++) {
109
+ const chans = inputs[t];
110
+ const has = chans && chans[0];
111
+ const L = has ? chans[0] : null;
112
+ // Never mirror L→R: post-pan buses are stereo; mono input = one channel only.
113
+ const hasR = has && chans.length > 1 && chans[1];
114
+ const R = hasR ? chans[1] : null;
115
+ const n = L ? L.length : (outL ? outL.length : 128);
116
+ frames = n;
117
+
118
+ let sumL = this._sumSqL[t];
119
+ let sumR = this._sumSqR[t];
120
+ let peak = this._peak[t];
121
+ let sumK = this._sumSqK[t];
122
+ const sL = this._shelfL[t], hL = this._hpfL[t];
123
+ const sR = this._shelfR[t], hR = this._hpfR[t];
124
+
125
+ for (let i = 0; i < n; i++) {
126
+ const l = L ? L[i] : 0;
127
+ const r = R ? R[i] : 0;
128
+ sumL += l * l;
129
+ sumR += r * r;
130
+ const aL = l >= 0 ? l : -l;
131
+ const aR = r >= 0 ? r : -r;
132
+ if (aL > peak) peak = aL;
133
+ if (aR > peak) peak = aR;
134
+
135
+ const fl = biquadStep(hL, biquadStep(sL, l));
136
+ const fr = biquadStep(hR, biquadStep(sR, r));
137
+ sumK += fl * fl + fr * fr;
138
+
139
+ if (this._program < 0 || t === this._program) {
140
+ if (outL) outL[i] += l;
141
+ if (outR) outR[i] += r;
142
+ }
143
+ }
144
+ this._sumSqL[t] = sumL;
145
+ this._sumSqR[t] = sumR;
146
+ this._peak[t] = peak;
147
+ this._sumSqK[t] = sumK;
148
+ }
149
+
150
+ // Filter the summed program output for a true master LUFS reading.
151
+ if (outL && outR && frames > 0) {
152
+ const sL = this._shelfOutL, hL = this._hpfOutL;
153
+ const sR = this._shelfOutR, hR = this._hpfOutR;
154
+ let sumK = this._sumSqKOut;
155
+ for (let i = 0; i < frames; i++) {
156
+ const fl = biquadStep(hL, biquadStep(sL, outL[i]));
157
+ const fr = biquadStep(hR, biquadStep(sR, outR[i]));
158
+ sumK += fl * fl + fr * fr;
159
+ }
160
+ this._sumSqKOut = sumK;
161
+ }
162
+
163
+ if (frames) this._frames += frames;
164
+ this._block++;
165
+
166
+ if (this._block >= this._hopBlocks) {
167
+ const n = Math.max(1, this._frames);
168
+ const rms = new Array(this._nAudio);
169
+ const peak = new Array(this._nAudio);
170
+ const lufs = new Array(this._nAudio);
171
+ for (let t = 0; t < this._nAudio; t++) {
172
+ // Track bar follows the louder channel so a mono stem panned hard L/R
173
+ // (or any stereo track) stays in sync with the per-channel master meters.
174
+ const rmsL = Math.sqrt(this._sumSqL[t] / n);
175
+ const rmsR = Math.sqrt(this._sumSqR[t] / n);
176
+ rms[t] = Math.max(rmsL, rmsR);
177
+ peak[t] = this._peak[t];
178
+
179
+ // Channel-weighted mean square for this hop (L+R, G=1 each)
180
+ const blockMs = this._sumSqK[t] / n;
181
+ const ring = this._lufsRing[t];
182
+ const idx = this._lufsIdx[t];
183
+ ring[idx] = blockMs;
184
+ this._lufsIdx[t] = (idx + 1) % this._lufsLen;
185
+ if (this._lufsFilled[t] < this._lufsLen) this._lufsFilled[t]++;
186
+ let acc = 0;
187
+ const filled = this._lufsFilled[t];
188
+ for (let i = 0; i < filled; i++) acc += ring[i];
189
+ const meanMs = acc / Math.max(1, filled);
190
+ lufs[t] = meanMs > 1e-12 ? -0.691 + 10 * Math.log10(meanMs) : -70;
191
+
192
+ this._sumSqL[t] = 0;
193
+ this._sumSqR[t] = 0;
194
+ this._peak[t] = 0;
195
+ this._sumSqK[t] = 0;
196
+ }
197
+
198
+ // Program LUFS from the actual summed output (includes video spill).
199
+ let masterLufs = -70;
200
+ if (outL && outR) {
201
+ const blockMs = this._sumSqKOut / n;
202
+ const ring = this._lufsRingOut;
203
+ const idx = this._lufsIdxOut;
204
+ ring[idx] = blockMs;
205
+ this._lufsIdxOut = (idx + 1) % this._lufsLen;
206
+ if (this._lufsFilledOut < this._lufsLen) this._lufsFilledOut++;
207
+ let acc = 0;
208
+ for (let i = 0; i < this._lufsFilledOut; i++) acc += ring[i];
209
+ const meanMs = acc / Math.max(1, this._lufsFilledOut);
210
+ masterLufs = meanMs > 1e-12 ? -0.691 + 10 * Math.log10(meanMs) : -70;
211
+ this._sumSqKOut = 0;
212
+ }
213
+
214
+ this.port.postMessage({
215
+ type: "meter",
216
+ rms,
217
+ peak,
218
+ lufs,
219
+ masterLufs,
220
+ frames: n,
221
+ });
222
+ this._block = 0;
223
+ this._frames = 0;
224
+ }
225
+ return true;
226
+ }
227
+ }
228
+
229
+ registerProcessor("fablecut-meter", FableCutMeterProcessor);
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "fablecut",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.12.1",
4
+ "description": "Zero-dependency browser video editor that AI agents can drive — JSON timeline, MCP server, REST API, live-reloading UI.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "fablecut": "cli.js"
8
+ },
9
+ "files": [
10
+ "*.js",
11
+ "index.html",
12
+ "style.css",
13
+ "favicon.svg",
14
+ "icons/",
15
+ "encoding-profiles.json",
16
+ "CLAUDE.md",
17
+ "library/README.md",
18
+ "library/elements/",
19
+ "library/fonts/",
20
+ "library/svg/",
21
+ "library/sfx/README.md"
22
+ ],
23
+ "scripts": {
24
+ "start": "node server.js",
25
+ "test": "node --test"
26
+ },
27
+ "keywords": [
28
+ "video-editor",
29
+ "nle",
30
+ "timeline",
31
+ "canvas",
32
+ "mcp",
33
+ "claude",
34
+ "ai-agent",
35
+ "ffmpeg"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/ronak-create/FableCut.git"
40
+ },
41
+ "homepage": "https://fablecut.space",
42
+ "mcpName": "io.github.ronak-create/fablecut",
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
47
+ }
package/paths.js ADDED
@@ -0,0 +1,106 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ Where things live.
3
+
4
+ Two roots, because they have different lifetimes:
5
+
6
+ APP_DIR the checked-out code and the assets that ship with it. Read-only
7
+ in practice. When FableCut is installed as a Claude Code plugin
8
+ this directory is pinned to a commit and REPLACED on every update.
9
+
10
+ DATA_DIR the user's work — project.json, imported media, exports, cached
11
+ analysis. Must outlive an update.
12
+
13
+ Standalone (`node server.js`) they are the same directory, which is exactly
14
+ how FableCut has always behaved. Set FABLECUT_DATA_DIR to split them; the
15
+ plugin sets it to ${CLAUDE_PLUGIN_DATA}. Installed from npm (the code sits
16
+ under node_modules, e.g. the npx cache) the default is ~/FableCut.
17
+ ═══════════════════════════════════════════════════════════════════════════ */
18
+ "use strict";
19
+ const crypto = require("crypto");
20
+ const fs = require("fs");
21
+ const os = require("os");
22
+ const path = require("path");
23
+
24
+ const APP_DIR = __dirname;
25
+ const FROM_NPM = APP_DIR.split(path.sep).includes("node_modules");
26
+ const DATA_DIR = process.env.FABLECUT_DATA_DIR
27
+ ? path.resolve(process.env.FABLECUT_DATA_DIR)
28
+ : FROM_NPM ? path.join(os.homedir(), "FableCut") : APP_DIR;
29
+ const SPLIT = DATA_DIR !== APP_DIR;
30
+
31
+ const MEDIA_DIR = path.join(DATA_DIR, "media");
32
+ const EXPORTS_DIR = path.join(DATA_DIR, "exports");
33
+ const ANALYSIS_DIR = path.join(DATA_DIR, "analysis");
34
+ const LIBRARY_DIR = path.join(DATA_DIR, "library");
35
+ const PROJECT_FILE = path.join(DATA_DIR, "project.json");
36
+ const LIBRARY_SUBDIRS = ["sfx", "elements", "svg", "fonts"];
37
+
38
+ /* The asset library ships with the repo but users also drop their own files in
39
+ (library/sfx is gitignored precisely for that). When the data dir is split
40
+ off, copy the shipped assets across once so the library is populated, then
41
+ leave it alone — a later update re-copies only what the user deleted or has
42
+ never seen, and never clobbers a file they put there. */
43
+ function seedLibrary() {
44
+ if (!SPLIT) return;
45
+ const src = path.join(APP_DIR, "library");
46
+ if (!fs.existsSync(src)) return;
47
+ const walk = (from, to) => {
48
+ fs.mkdirSync(to, { recursive: true });
49
+ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
50
+ const a = path.join(from, e.name), b = path.join(to, e.name);
51
+ if (e.isDirectory()) walk(a, b);
52
+ else if (!fs.existsSync(b) || superseded(path.relative(src, a), b)) fs.copyFileSync(a, b);
53
+ }
54
+ };
55
+ walk(src, LIBRARY_DIR);
56
+ }
57
+
58
+ /* Shipped files that a later release replaced. Seeding never overwrites, so
59
+ an old copy has to be recognised to be swapped out: one still byte-identical
60
+ to what we shipped (sha256, first 16 hex) was never edited by the user.
61
+ The fonts in 1.0-1.9.0 were the wrong Unicode subset, with no basic Latin. */
62
+ const SUPERSEDED = {
63
+ "fonts/Abril Fatface.woff2": "c7cc6ab22643e87e",
64
+ "fonts/Anton.woff2": "82cc85bd416dc825",
65
+ "fonts/Archivo Black.woff2": "a69dfe4ff8ea7fd2",
66
+ "fonts/Bangers.woff2": "3275f18f37839309",
67
+ "fonts/Barlow.woff2": "d9bb523921d15e2b",
68
+ "fonts/Bebas Neue.woff2": "16c95ce45a2922f5",
69
+ "fonts/Bungee.woff2": "73d615497987a45a",
70
+ "fonts/Caveat.woff2": "f225d05db8fd2872",
71
+ "fonts/DM Sans.woff2": "6406eb05e2eb5077",
72
+ "fonts/Inter.woff2": "1c2db92d3cd9b237",
73
+ "fonts/Lato.woff2": "3a5797f440bc67b5",
74
+ "fonts/Montserrat.woff2": "5cdc3c54c076ef8c",
75
+ "fonts/Oswald.woff2": "cbcfd4dce7316ca4",
76
+ "fonts/Pacifico.woff2": "566fd7603618daf7",
77
+ "fonts/Playfair Display.woff2": "cfbfb0e36791f824",
78
+ "fonts/Poppins.woff2": "58dba357cb1e89bf",
79
+ "fonts/Righteous.woff2": "5cdd7df84db547c5",
80
+ "fonts/Roboto.woff2": "a99e7e70a8225591",
81
+ "fonts/Rubik.woff2": "f60c6ef1b75cff9c",
82
+ "fonts/Teko.woff2": "facad9bed4914ede",
83
+ };
84
+ function superseded(rel, file) {
85
+ const old = SUPERSEDED[rel.split(path.sep).join("/")];
86
+ if (!old) return false;
87
+ try {
88
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex").startsWith(old);
89
+ } catch { return false; }
90
+ }
91
+
92
+ /* Create the writable tree. Safe to call from both servers; whoever runs first
93
+ wins and the other no-ops. */
94
+ function ensureDirs() {
95
+ for (const d of [DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR])
96
+ fs.mkdirSync(d, { recursive: true });
97
+ for (const d of LIBRARY_SUBDIRS)
98
+ fs.mkdirSync(path.join(LIBRARY_DIR, d), { recursive: true });
99
+ seedLibrary();
100
+ }
101
+
102
+ module.exports = {
103
+ APP_DIR, DATA_DIR, SPLIT,
104
+ MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE,
105
+ LIBRARY_SUBDIRS, ensureDirs,
106
+ };
@@ -0,0 +1,120 @@
1
+ /* FableCut timeline-ruler worker.
2
+ Draws the ruler canvas (ticks, time labels, IN/OUT flags, beat markers,
3
+ playhead) off the main thread via a transferred OffscreenCanvas. This is a
4
+ clean Worker candidate — unlike the program-monitor compositor (app.js
5
+ drawFrame/drawClip), the ruler never touches <video>/<audio> elements or
6
+ any other main-thread-only DOM state, only the small set of numbers/arrays
7
+ sent in each "draw" message. Keep this in sync with app.js's drawRuler()
8
+ fallback (used when OffscreenCanvas/transferControlToOffscreen isn't
9
+ available) if the look changes. */
10
+ let cv = null, g = null;
11
+
12
+ function fmt(t, fps) {
13
+ t = Math.max(0, t);
14
+ const m = Math.floor(t / 60), s = Math.floor(t % 60),
15
+ f = Math.floor((t % 1) * (fps > 0 ? fps : 1));
16
+ const p = (n) => String(n).padStart(2, "0");
17
+ return `${p(m)}:${p(s)}:${p(f)}`;
18
+ }
19
+
20
+ function draw(msg) {
21
+ if (!g) return;
22
+ const { w, h, dpr, sl, pps, markers, markerColors, inPoint, outPoint, time, fps } = msg;
23
+ const bw = Math.round(w * dpr), bh = Math.round(h * dpr);
24
+ if (cv.width !== bw || cv.height !== bh) { cv.width = bw; cv.height = bh; }
25
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
26
+ g.clearRect(0, 0, w, h);
27
+ const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
28
+ const step = steps.find((s) => s * pps >= 70) || 600;
29
+ const minor = step / 5;
30
+ const i0 = Math.max(0, Math.floor(sl / pps / minor));
31
+ // ticks + time labels first so IN/OUT can difference-blend over them
32
+ g.strokeStyle = "#4a4a55";
33
+ g.fillStyle = "#9a9aa6";
34
+ g.font = "10px Consolas, monospace";
35
+ g.beginPath();
36
+ for (let i = i0; i * minor * pps < sl + w; i++) {
37
+ const t = i * minor;
38
+ const x = Math.round(t * pps - sl) + 0.5;
39
+ const isMajor = i % 5 === 0;
40
+ g.moveTo(x, isMajor ? 8 : 17); g.lineTo(x, h);
41
+ if (isMajor) g.fillText(fmt(Math.round(t * 1000) / 1000, fps).slice(0, 5), x + 4, 12);
42
+ }
43
+ g.stroke();
44
+ // dim timeline outside the IN–OUT work area
45
+ if (inPoint != null && outPoint != null && outPoint > inPoint) {
46
+ const x0 = inPoint * pps - sl, x1 = outPoint * pps - sl;
47
+ g.fillStyle = "#00000055";
48
+ if (x0 > 0) g.fillRect(0, 0, Math.min(w, x0), h);
49
+ if (x1 < w) g.fillRect(Math.max(0, x1), 0, w - Math.max(0, x1), h);
50
+ }
51
+ // beat/cue markers — coloured diamonds, name to the right (clipped at the next marker)
52
+ const mks = markers || [];
53
+ for (let i = 0; i < mks.length; i++) {
54
+ const mk = mks[i], x = mk.t * pps - sl;
55
+ if (x < -6 || x > w + 6) {
56
+ if (!mk.label || x > w) continue;
57
+ }
58
+ const col = (markerColors && markerColors[mk.color]) || "#ffd166";
59
+ g.fillStyle = col;
60
+ g.beginPath();
61
+ g.moveTo(x, h - 9); g.lineTo(x + 4, h - 5); g.lineTo(x, h - 1); g.lineTo(x - 4, h - 5);
62
+ g.closePath(); g.fill();
63
+ if (mk.label) {
64
+ const next = mks[i + 1] ? mks[i + 1].t * pps - sl : w;
65
+ const room = Math.min(next - x - 10, 160);
66
+ if (room > 14) {
67
+ g.save();
68
+ g.beginPath(); g.rect(x + 6, h - 12, room, 12); g.clip();
69
+ g.font = "9px system-ui, sans-serif";
70
+ const tw = g.measureText(mk.label).width;
71
+ g.fillStyle = "#101014cc";
72
+ g.fillRect(x + 6, h - 11, Math.min(tw + 6, room), 10);
73
+ g.fillStyle = col;
74
+ g.fillText(mk.label, x + 9, h - 3);
75
+ g.restore();
76
+ }
77
+ }
78
+ }
79
+ // IN / OUT — bottom-aligned; `difference` keeps time glyphs readable where they overlap
80
+ const mkH = (h - 4) * 0.75, bot = h - 1, top = bot - mkH, mid = (top + bot) / 2;
81
+ g.globalCompositeOperation = "difference";
82
+ if (inPoint != null) {
83
+ const x = inPoint * pps - sl;
84
+ if (x >= -10 && x <= w + 10) {
85
+ g.fillStyle = "#5eead4";
86
+ g.beginPath();
87
+ g.moveTo(x, top); g.lineTo(x, bot); g.lineTo(x + 8, mid);
88
+ g.closePath(); g.fill();
89
+ }
90
+ }
91
+ if (outPoint != null) {
92
+ const x = outPoint * pps - sl;
93
+ if (x >= -10 && x <= w + 10) {
94
+ g.fillStyle = "#fb923c";
95
+ g.beginPath();
96
+ g.moveTo(x, top); g.lineTo(x, bot); g.lineTo(x - 8, mid);
97
+ g.closePath(); g.fill();
98
+ }
99
+ }
100
+ g.globalCompositeOperation = "source-over";
101
+ // playhead marker on ruler
102
+ const px = time * pps - sl;
103
+ if (px >= -8 && px <= w + 8) {
104
+ g.fillStyle = "#ff4d6a";
105
+ g.beginPath();
106
+ g.moveTo(px - 6, 12); g.lineTo(px + 6, 12); g.lineTo(px + 6, 19); g.lineTo(px, 25); g.lineTo(px - 6, 19);
107
+ g.closePath(); g.fill();
108
+ }
109
+ }
110
+
111
+ self.onmessage = (ev) => {
112
+ const msg = ev.data;
113
+ if (!msg) return;
114
+ if (msg.type === "init") {
115
+ cv = msg.canvas;
116
+ g = cv.getContext("2d");
117
+ } else if (msg.type === "draw") {
118
+ draw(msg);
119
+ }
120
+ };