fablecut 0.0.0-stage → 1.12.0

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
package/audio-fx.js ADDED
@@ -0,0 +1,241 @@
1
+ /* FableCut audio effects — the data side: what each effect is, its
2
+ parameters and ranges, validation, and the named presets. Zero
3
+ dependencies. Loaded by the editor as a plain script (global `FableCutFx`)
4
+ and required by the MCP server, so `setFx` from an agent and the editor's
5
+ effects panel accept exactly the same chains. The audio nodes themselves
6
+ are built in app.js (buildFxChain) and fx-worklet.js (gate, limiter, pitch).
7
+
8
+ A chain is an array, processed in order:
9
+ [{ type: "highpass", freq: 80 }, { type: "compressor", threshold: -20, ratio: 4 }, …]
10
+ Every parameter is optional (defaults below); `on: false` bypasses an
11
+ effect without losing its settings.
12
+
13
+ Automation: an effect may carry `keys`, keyframes per parameter —
14
+ { type: "lowpass", freq: 8000, keys: { freq: [{ t: 0, v: 8000 }, { t: 4, v: 400, ease: "linear" }] } }
15
+ `t` is seconds from the clip's start for a clip's effects, and timeline
16
+ seconds for a track's, a bus's or the master's. While a parameter has keys
17
+ they decide its value; the plain value is used again once they are gone. */
18
+ (function (root, factory) {
19
+ const api = factory();
20
+ if (typeof module === "object" && module.exports) module.exports = api;
21
+ else root.FableCutFx = api;
22
+ })(typeof self !== "undefined" ? self : this, function () {
23
+ "use strict";
24
+
25
+ // [default, min, max, step, unit, label]
26
+ const P = (def, min, max, step, unit, label) => ({ def, min, max, step, unit, label });
27
+ const FX_DEFS = {
28
+ eq: {
29
+ label: "EQ (3-band)",
30
+ params: {
31
+ lowFreq: P(120, 20, 1000, 1, "Hz", "Low freq"), lowGain: P(0, -18, 18, 0.5, "dB", "Low"),
32
+ midFreq: P(1000, 100, 8000, 10, "Hz", "Mid freq"), midGain: P(0, -18, 18, 0.5, "dB", "Mid"),
33
+ midQ: P(1, 0.1, 10, 0.1, "", "Mid Q"),
34
+ highFreq: P(8000, 1000, 18000, 100, "Hz", "High freq"), highGain: P(0, -18, 18, 0.5, "dB", "High"),
35
+ },
36
+ },
37
+ highpass: { label: "High-pass", params: { freq: P(80, 20, 2000, 1, "Hz", "Cutoff"), q: P(0.71, 0.1, 10, 0.01, "", "Q") } },
38
+ lowpass: { label: "Low-pass", params: { freq: P(8000, 200, 20000, 10, "Hz", "Cutoff"), q: P(0.71, 0.1, 10, 0.01, "", "Q") } },
39
+ compressor: {
40
+ label: "Compressor",
41
+ params: {
42
+ threshold: P(-20, -60, 0, 0.5, "dB", "Threshold"), ratio: P(4, 1, 20, 0.1, ":1", "Ratio"),
43
+ attack: P(10, 0, 1000, 1, "ms", "Attack"), release: P(200, 10, 1000, 5, "ms", "Release"),
44
+ knee: P(6, 0, 40, 1, "dB", "Knee"), makeup: P(0, 0, 24, 0.5, "dB", "Makeup"),
45
+ },
46
+ },
47
+ limiter: {
48
+ label: "Limiter",
49
+ params: { ceiling: P(-1, -24, 0, 0.1, "dB", "Ceiling"), release: P(80, 5, 1000, 5, "ms", "Release") },
50
+ },
51
+ gate: {
52
+ label: "Noise gate",
53
+ params: {
54
+ threshold: P(-50, -80, -10, 0.5, "dB", "Threshold"), range: P(-40, -80, 0, 1, "dB", "Range"),
55
+ attack: P(2, 0, 50, 0.5, "ms", "Attack"), hold: P(80, 0, 500, 5, "ms", "Hold"),
56
+ release: P(150, 5, 1000, 5, "ms", "Release"),
57
+ },
58
+ },
59
+ delay: {
60
+ label: "Delay",
61
+ params: { time: P(0.3, 0.01, 2, 0.01, "s", "Time"), feedback: P(0.35, 0, 0.9, 0.01, "", "Feedback"), mix: P(0.25, 0, 1, 0.01, "", "Mix") },
62
+ },
63
+ reverb: {
64
+ label: "Reverb",
65
+ params: { decay: P(2, 0.2, 8, 0.1, "s", "Decay"), predelay: P(20, 0, 200, 1, "ms", "Pre-delay"), mix: P(0.25, 0, 1, 0.01, "", "Mix") },
66
+ },
67
+ distortion: {
68
+ label: "Distortion",
69
+ params: { drive: P(0.3, 0, 1, 0.01, "", "Drive"), mix: P(1, 0, 1, 0.01, "", "Mix") },
70
+ },
71
+ widener: {
72
+ label: "Stereo width",
73
+ params: { width: P(1.5, 0, 2, 0.01, "×", "Width") },
74
+ },
75
+ pitch: {
76
+ label: "Pitch shift",
77
+ params: { semitones: P(0, -12, 12, 0.5, "st", "Pitch"), mix: P(1, 0, 1, 0.01, "", "Mix") },
78
+ },
79
+ };
80
+ // A new impulse response per value — too heavy to sweep, so not automatable.
81
+ FX_DEFS.reverb.params.decay.fixed = true;
82
+ const EASES = ["linear", "ease-in", "ease-out", "ease-in-out"];
83
+ const EASE = {
84
+ linear: (u) => u,
85
+ "ease-in": (u) => u * u,
86
+ "ease-out": (u) => 1 - (1 - u) * (1 - u),
87
+ "ease-in-out": (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2),
88
+ };
89
+ const FX_TYPES = Object.keys(FX_DEFS);
90
+
91
+ /** A clean copy of one effect: known type, every param present and in
92
+ * range. Returns null for an unknown type. */
93
+ function normalizeEffect(raw, strict = false) {
94
+ if (!raw || typeof raw !== "object" || !FX_DEFS[raw.type]) return null;
95
+ const out = { type: raw.type };
96
+ for (const [k, d] of Object.entries(FX_DEFS[raw.type].params)) {
97
+ const v = +raw[k];
98
+ out[k] = Number.isFinite(v) ? Math.min(d.max, Math.max(d.min, v)) : d.def;
99
+ }
100
+ if (raw.on === false) out.on = false;
101
+ const keys = normalizeKeys(raw.type, raw.keys, strict);
102
+ if (keys) out.keys = keys;
103
+ return out;
104
+ }
105
+ /** Clean automation for one effect: known, automatable params only; points
106
+ * with numeric t ≥ 0, values clamped to the param's range, sorted, one per
107
+ * time. Returns undefined when nothing is left. */
108
+ function normalizeKeys(type, raw, strict = false) {
109
+ if (raw == null) return undefined;
110
+ const bad = (msg) => { if (strict) throw new Error(`${type} keys: ${msg}`); };
111
+ if (typeof raw !== "object" || Array.isArray(raw)) { bad("must be an object of param → [{t, v}]"); return undefined; }
112
+ const out = {};
113
+ for (const [k, list] of Object.entries(raw)) {
114
+ const d = FX_DEFS[type].params[k];
115
+ if (!d) { bad(`unknown parameter ${JSON.stringify(k)} (known: ${Object.keys(FX_DEFS[type].params).join(", ")})`); continue; }
116
+ if (d.fixed) { bad(`${k} cannot be automated`); continue; }
117
+ if (list == null) continue;
118
+ if (!Array.isArray(list)) { bad(`${k} must be an array of {t, v}`); continue; }
119
+ const byT = new Map();
120
+ for (const kf of list) {
121
+ const t = +kf?.t, v = +kf?.v;
122
+ if (!Number.isFinite(t) || t < 0 || !Number.isFinite(v)) { bad(`${k}: every key needs t ≥ 0 and a numeric v`); continue; }
123
+ const key = { t: Math.round(t * 1e4) / 1e4, v: Math.min(d.max, Math.max(d.min, v)) };
124
+ if (kf.ease != null) {
125
+ if (EASES.includes(kf.ease)) key.ease = kf.ease;
126
+ else bad(`${k}: ease must be one of ${EASES.join(", ")}`);
127
+ }
128
+ byT.set(key.t, key);
129
+ }
130
+ const arr = [...byT.values()].sort((a, b) => a.t - b.t);
131
+ if (arr.length) out[k] = arr;
132
+ }
133
+ return Object.keys(out).length ? out : undefined;
134
+ }
135
+ /** One automated parameter at time t (ease on the destination key, like clip keyframes). */
136
+ function keyValue(arr, t) {
137
+ if (t <= arr[0].t) return arr[0].v;
138
+ const last = arr[arr.length - 1];
139
+ if (t >= last.t) return last.v;
140
+ for (let i = 0; i < arr.length - 1; i++) {
141
+ const a = arr[i], b = arr[i + 1];
142
+ if (t >= a.t && t <= b.t) {
143
+ const u = (t - a.t) / Math.max(1e-6, b.t - a.t);
144
+ return a.v + (b.v - a.v) * (EASE[b.ease || "ease-in-out"] || EASE.linear)(u);
145
+ }
146
+ }
147
+ return last.v;
148
+ }
149
+ /** The effect's parameters at time t (keys applied). Same object when it has none. */
150
+ function evalEffect(e, t) {
151
+ if (!e.keys) return e;
152
+ const out = { ...e };
153
+ for (const [k, arr] of Object.entries(e.keys)) if (arr.length) out[k] = keyValue(arr, t);
154
+ return out;
155
+ }
156
+ const hasKeys = (list) => Array.isArray(list) && list.some((e) => e && e.keys && e.on !== false);
157
+ /** Validate a chain. `strict` throws on the first bad entry (MCP); otherwise
158
+ * unknown effects are dropped (loading a project from a newer version). */
159
+ function normalizeFx(list, strict = false) {
160
+ if (list == null) return [];
161
+ if (!Array.isArray(list)) {
162
+ if (strict) throw new Error("fx must be an array of effects");
163
+ return [];
164
+ }
165
+ const out = [];
166
+ list.forEach((raw, i) => {
167
+ const e = normalizeEffect(raw, strict);
168
+ if (e) out.push(e);
169
+ else if (strict) throw new Error(`fx[${i}]: unknown effect ${JSON.stringify(raw && raw.type)} (known: ${FX_TYPES.join(", ")})`);
170
+ });
171
+ return out;
172
+ }
173
+
174
+ /* Presets: starting points, written into the chain as plain effects so
175
+ they can be tweaked afterwards (and read back by an agent). */
176
+ const PRESETS = {
177
+ "clean-voice": { group: "Voice", label: "Clean voice", fx: [
178
+ { type: "highpass", freq: 80 },
179
+ { type: "eq", lowFreq: 200, lowGain: -2, midFreq: 3000, midGain: 1.5, highFreq: 10000, highGain: 1 },
180
+ { type: "compressor", threshold: -18, ratio: 3, attack: 10, release: 150, makeup: 3 },
181
+ { type: "limiter", ceiling: -1 },
182
+ ] },
183
+ podcast: { group: "Voice", label: "Podcast", fx: [
184
+ { type: "highpass", freq: 90 },
185
+ { type: "gate", threshold: -50, range: -30, hold: 100, release: 200 },
186
+ { type: "eq", lowFreq: 150, lowGain: 1.5, midFreq: 3500, midGain: 2.5, midQ: 0.8, highFreq: 10000, highGain: 2 },
187
+ { type: "compressor", threshold: -22, ratio: 4, attack: 5, release: 120, knee: 8, makeup: 6 },
188
+ { type: "limiter", ceiling: -1 },
189
+ ] },
190
+ radio: { group: "Voice", label: "Radio", fx: [
191
+ { type: "highpass", freq: 120 },
192
+ { type: "eq", lowFreq: 160, lowGain: 3, midFreq: 3000, midGain: 4, midQ: 1.2, highFreq: 9000, highGain: -2 },
193
+ { type: "compressor", threshold: -28, ratio: 8, attack: 2, release: 80, knee: 4, makeup: 9 },
194
+ { type: "distortion", drive: 0.08, mix: 0.4 },
195
+ { type: "limiter", ceiling: -1 },
196
+ ] },
197
+ "deep-voice": { group: "Voice", label: "Deep voice", fx: [
198
+ { type: "pitch", semitones: -4 },
199
+ { type: "eq", lowFreq: 140, lowGain: 5, midFreq: 2500, midGain: -2, midQ: 0.8, highFreq: 7000, highGain: -4 },
200
+ { type: "lowpass", freq: 7500 },
201
+ { type: "compressor", threshold: -20, ratio: 3, makeup: 2 },
202
+ { type: "limiter", ceiling: -1 },
203
+ ] },
204
+ telephone: { group: "Voice", label: "Telephone", fx: [
205
+ { type: "highpass", freq: 400, q: 1 },
206
+ { type: "lowpass", freq: 3400, q: 1 },
207
+ { type: "eq", midFreq: 1500, midGain: 6, midQ: 1 },
208
+ { type: "distortion", drive: 0.35, mix: 0.6 },
209
+ { type: "compressor", threshold: -24, ratio: 6, makeup: 6 },
210
+ { type: "limiter", ceiling: -2 },
211
+ ] },
212
+ cinematic: { group: "Music", label: "Cinematic", fx: [
213
+ { type: "eq", lowFreq: 80, lowGain: 3, midFreq: 400, midGain: -1.5, midQ: 0.7, highFreq: 10000, highGain: 2 },
214
+ { type: "widener", width: 1.3 },
215
+ { type: "reverb", decay: 2.8, predelay: 30, mix: 0.22 },
216
+ { type: "compressor", threshold: -16, ratio: 2, attack: 30, release: 300, makeup: 1.5 },
217
+ ] },
218
+ wide: { group: "Music", label: "Wide", fx: [{ type: "widener", width: 1.6 }] },
219
+ muffled: { group: "Music", label: "Muffled (next room)", fx: [
220
+ { type: "lowpass", freq: 700, q: 0.8 },
221
+ { type: "eq", lowFreq: 150, lowGain: 2, highFreq: 4000, highGain: -6 },
222
+ { type: "reverb", decay: 0.9, predelay: 10, mix: 0.18 },
223
+ ] },
224
+ };
225
+ const PRESET_IDS = Object.keys(PRESETS);
226
+ function presetChain(id) {
227
+ const p = PRESETS[id];
228
+ if (!p) throw new Error(`unknown preset ${JSON.stringify(id)} (known: ${PRESET_IDS.join(", ")})`);
229
+ return normalizeFx(p.fx);
230
+ }
231
+ /** Short text for a chain, e.g. "highpass·eq·compressor(off)·lowpass~freq". */
232
+ function summarizeFx(list) {
233
+ return (list || []).map((e) => e.type + (e.on === false ? "(off)" : "") +
234
+ (e.keys ? "~" + Object.keys(e.keys).join("~") : "")).join("·");
235
+ }
236
+
237
+ return {
238
+ FX_DEFS, FX_TYPES, PRESETS, PRESET_IDS, EASES,
239
+ normalizeEffect, normalizeFx, normalizeKeys, presetChain, summarizeFx, keyValue, evalEffect, hasKeys,
240
+ };
241
+ });
package/cli.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /* ═══════════════════════════════════════════════════════════════════════════
3
+ `fablecut` command (npm package).
4
+
5
+ fablecut start the editor → http://localhost:7777
6
+ fablecut mcp run the MCP server on stdio (what agents launch)
7
+
8
+ Installed from npm, the user's work lives in ~/FableCut (see paths.js), so
9
+ an update never touches it. FABLECUT_DATA_DIR still overrides.
10
+ ═══════════════════════════════════════════════════════════════════════════ */
11
+ "use strict";
12
+
13
+ const cmd = process.argv[2];
14
+
15
+ if (cmd === "mcp") {
16
+ require("./mcp-server.js");
17
+ } else if (!cmd || cmd === "start") {
18
+ require("./server.js");
19
+ } else if (cmd === "-v" || cmd === "--version") {
20
+ console.log(require("./package.json").version);
21
+ } else {
22
+ const help = cmd === "-h" || cmd === "--help";
23
+ (help ? console.log : console.error)(
24
+ "Usage:\n" +
25
+ " fablecut start the editor at http://localhost:7777\n" +
26
+ " fablecut mcp run the MCP server on stdio\n" +
27
+ " fablecut -v print the version\n\n" +
28
+ "Register with Claude Code:\n" +
29
+ " claude mcp add -s user fablecut -- npx -y fablecut mcp");
30
+ process.exit(help ? 0 : 1);
31
+ }