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/denoise.js ADDED
@@ -0,0 +1,120 @@
1
+ /* Noise reduction — render and replace. The file's noise floor is measured
2
+ first (astats: the level of its quietest stretches), then ffmpeg's FFT
3
+ denoiser (afftdn) pulls everything near that floor down and writes a FLAC
4
+ beside it in media/. Clips then play that file instead; the original
5
+ stays registered, so switching back is instant. The whole file is
6
+ processed, not just a clip's window, so `in` points stay valid and linked
7
+ stems keep their timing (and their channel layout). Used by server.js
8
+ (POST /api/denoise) and the MCP server (fablecut_denoise). */
9
+ "use strict";
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { spawn, spawnSync } = require("child_process");
13
+
14
+ /* nr: how far the noise is pulled down, dB. The floor is measured per file —
15
+ afftdn's own noise tracking (tn) left loud hiss untouched in testing. */
16
+ const AMOUNTS = {
17
+ light: { nr: 10, label: "Light" },
18
+ medium: { nr: 18, label: "Medium" },
19
+ strong: { nr: 30, label: "Strong" },
20
+ };
21
+ const AMOUNT_IDS = Object.keys(AMOUNTS);
22
+
23
+ /** media/ file name for a denoised copy, e.g. "take 3.denoise-medium.flac". */
24
+ function denoiseName(srcName, amount) {
25
+ const base = path.basename(decodeURIComponent(String(srcName).split("?")[0]));
26
+ const stem = base.replace(/\.[^.]+$/, "").replace(/\.denoise-(light|medium|strong)$/, "");
27
+ return `${stem}.denoise-${amount}.flac`;
28
+ }
29
+ function probe(file) {
30
+ const r = spawnSync("ffprobe", ["-v", "error", "-select_streams", "a:0",
31
+ "-show_entries", "stream=channels:format=duration", "-of", "json", file], { encoding: "utf8" });
32
+ if (r.error) throw new Error("ffprobe not found on PATH — needed for noise reduction");
33
+ try {
34
+ const j = JSON.parse(r.stdout);
35
+ return { channels: j.streams?.[0]?.channels || 0, duration: Math.round(+j.format?.duration * 1000) / 1000 || undefined };
36
+ } catch { return { channels: 0, duration: undefined }; }
37
+ }
38
+ /** The file's noise floor, dB RMS: the 10th-percentile level of its 50 ms
39
+ * windows (mono, 16 kHz) — the room tone between words, steady from run to
40
+ * run where a single quietest window is not. Digital silence is skipped. */
41
+ function measureNoiseFloor(file) {
42
+ return new Promise((resolve, reject) => {
43
+ const SR = 16000, WIN = SR / 20;
44
+ const proc = spawn("ffmpeg", ["-v", "error", "-i", file, "-vn", "-map", "0:a:0", "-ac", "1", "-ar", String(SR), "-f", "f32le", "-"]);
45
+ const levels = [];
46
+ let sum = 0, n = 0, rest = Buffer.alloc(0);
47
+ proc.stdout.on("data", (chunk) => {
48
+ const buf = rest.length ? Buffer.concat([rest, chunk]) : chunk;
49
+ const count = Math.floor(buf.length / 4);
50
+ for (let i = 0; i < count; i++) {
51
+ const x = buf.readFloatLE(i * 4);
52
+ sum += x * x;
53
+ if (++n === WIN) { if (sum > 1e-12) levels.push(sum / WIN); sum = 0; n = 0; }
54
+ }
55
+ rest = buf.subarray(count * 4);
56
+ });
57
+ proc.stderr.resume();
58
+ proc.on("error", () => reject(new Error("ffmpeg not found on PATH — needed for noise reduction")));
59
+ proc.on("close", () => {
60
+ if (!levels.length) return resolve(-80);
61
+ levels.sort((a, b) => a - b);
62
+ resolve(10 * Math.log10(levels[Math.floor(levels.length * 0.1)]));
63
+ });
64
+ });
65
+ }
66
+ /* afftdn's nf sits a little above the measured RMS floor (its noise model
67
+ reads louder than plain RMS); the result is clamped to its −80…−20 range. */
68
+ const FLOOR_OFFSET_DB = 4;
69
+ /** Denoise `file` into `outDir`. Resolves {file, name, duration, channels, cached}.
70
+ * An existing output with the same name is reused. */
71
+ function denoiseFile(file, outDir, amount) {
72
+ const a = AMOUNTS[amount];
73
+ if (!a) return Promise.reject(new Error(`amount must be one of ${AMOUNT_IDS.join(", ")}`));
74
+ const name = denoiseName(file, amount);
75
+ const out = path.join(outDir, name);
76
+ if (fs.existsSync(out) && fs.statSync(out).size > 0)
77
+ return Promise.resolve({ file: out, name, cached: true, ...probe(out) });
78
+ const src = probe(file);
79
+ if (!src.channels) return Promise.reject(new Error(`${path.basename(file)} has no audio to clean up`));
80
+ const part = out + ".part.flac";
81
+ return measureNoiseFloor(file).then((floor) => new Promise((resolve, reject) => {
82
+ const nf = Math.round(Math.min(-20, Math.max(-80, floor + FLOOR_OFFSET_DB)) * 10) / 10;
83
+ const proc = spawn("ffmpeg", ["-v", "error", "-y", "-i", file, "-vn", "-map", "0:a:0",
84
+ "-af", `afftdn=nr=${a.nr}:nf=${nf}`, "-c:a", "flac", part]);
85
+ let err = "";
86
+ proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
87
+ proc.on("error", () => reject(new Error("ffmpeg not found on PATH — needed for noise reduction")));
88
+ proc.on("close", (code) => {
89
+ if (code !== 0) {
90
+ try { fs.rmSync(part, { force: true }); } catch {}
91
+ return reject(new Error("ffmpeg denoise failed: " + (err.trim().split("\n").pop() || code)));
92
+ }
93
+ fs.renameSync(part, out);
94
+ resolve({ file: out, name, cached: false, noiseFloor: nf, ...probe(out) });
95
+ });
96
+ }));
97
+ }
98
+
99
+ /** The media a clip's sound comes from originally (a denoised copy points back). */
100
+ function baseMediaId(doc, id) {
101
+ return doc.media.find((m) => m.id === id)?.derivedFrom || id;
102
+ }
103
+ /** The audio clips a selection's noise reduction applies to: an audio clip and
104
+ * its linked stems, or a video's stems. Throws for a picture that carries its
105
+ * own sound (no stems) — its file can't be swapped without the picture. */
106
+ function denoiseTargets(doc, clips) {
107
+ const out = new Map();
108
+ for (const c of clips) {
109
+ const group = c.linkGroup ? doc.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
110
+ const stems = group.filter((x) => x.kind === "audio" && x.mediaId);
111
+ if (!stems.length) {
112
+ if (c.kind === "video") throw new Error(`clip ${c.id}: its sound plays from the picture (no linked audio stems) — noise reduction works on audio clips`);
113
+ throw new Error(`clip ${c.id} is ${c.kind} — no audio to clean up`);
114
+ }
115
+ for (const s of stems) out.set(s.id, s);
116
+ }
117
+ return [...out.values()];
118
+ }
119
+
120
+ module.exports = { AMOUNTS, AMOUNT_IDS, denoiseName, denoiseFile, measureNoiseFloor, baseMediaId, denoiseTargets };
package/ducking.js ADDED
@@ -0,0 +1,127 @@
1
+ /* FableCut auto-duck — find where the voice is and turn that into `duck`
2
+ keyframes (dB) on the music. Zero dependencies; loaded by the editor as a
3
+ plain script (global `FableCutDucking`) and required by the MCP server, so
4
+ the inspector's Auto-duck and `fablecut_auto_duck` write identical keys.
5
+
6
+ Pipeline: rmsEnvelope (per voice clip) → activeRegions (timeline seconds,
7
+ above a threshold) → mergeRegions (all voices, short gaps bridged) →
8
+ duckKeyframes (ramps down before speech, back up after). */
9
+ (function (root, factory) {
10
+ const api = factory();
11
+ if (typeof module === "object" && module.exports) module.exports = api;
12
+ else root.FableCutDucking = api;
13
+ })(typeof self !== "undefined" ? self : this, function () {
14
+ "use strict";
15
+
16
+ const HOP = 0.05; // s between envelope points
17
+ const WIN = 0.1; // s RMS window
18
+
19
+ /** Streaming RMS envelope over one or more channels (power summed, so a
20
+ * stereo voice reads like its loudness). push() as audio arrives. */
21
+ class EnvelopeMeter {
22
+ constructor(channels, sampleRate, hop = HOP, win = WIN) {
23
+ this.c = channels;
24
+ this.hopN = Math.max(1, Math.round(hop * sampleRate));
25
+ this.winN = Math.max(this.hopN, Math.round(win * sampleRate));
26
+ this.ring = new Float64Array(this.winN); // per-sample power, last winN
27
+ this.idx = 0; this.sum = 0; this.n = 0;
28
+ this.out = [];
29
+ }
30
+ push(chs) {
31
+ const len = chs.length ? chs[0].length : 0;
32
+ for (let i = 0; i < len; i++) {
33
+ let e = 0;
34
+ for (let k = 0; k < chs.length; k++) { const x = chs[k][i]; e += x * x; }
35
+ this.sum += e - this.ring[this.idx];
36
+ this.ring[this.idx] = e;
37
+ this.idx = (this.idx + 1) % this.winN;
38
+ if (++this.n % this.hopN === 0)
39
+ this.out.push(Math.sqrt(Math.max(0, this.sum) / Math.min(this.n, this.winN)));
40
+ }
41
+ }
42
+ /** Linear RMS per hop; point i covers the window ending at (i + 1) × hop. */
43
+ result() { return Float32Array.from(this.out); }
44
+ }
45
+ function rmsEnvelope(chs, sampleRate, hop = HOP) {
46
+ const m = new EnvelopeMeter(chs.length, sampleRate, hop);
47
+ m.push(chs);
48
+ return m.result();
49
+ }
50
+
51
+ /** Timeline [start, end] spans where the envelope is above threshold.
52
+ * env[i] maps to timeline t0 + (i + 1) × hop / speed; `gain` (linear) is the
53
+ * clip's own gain × volume, so a quiet take still counts as voice. */
54
+ function activeRegions(env, { t0 = 0, hop = HOP, speed = 1, gain = 1, threshold = -40, minLen = 0.25 } = {}) {
55
+ const lim = Math.pow(10, threshold / 20);
56
+ const step = hop / Math.max(0.01, speed);
57
+ const out = [];
58
+ let a = -1;
59
+ for (let i = 0; i <= env.length; i++) {
60
+ const on = i < env.length && env[i] * gain >= lim;
61
+ if (on && a < 0) a = i;
62
+ if (!on && a >= 0) {
63
+ // the window ending at point a started WIN earlier — begin there
64
+ const s = t0 + Math.max(0, (a + 1) * step - WIN / Math.max(0.01, speed));
65
+ const e = t0 + i * step;
66
+ if (e - s >= minLen) out.push([s, e]);
67
+ a = -1;
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Union of region lists; gaps shorter than `gap` seconds are bridged so the
74
+ * music doesn't pump between words. */
75
+ function mergeRegions(lists, gap = 0.6) {
76
+ const all = lists.flat().filter((r) => r[1] > r[0]).sort((x, y) => x[0] - y[0]);
77
+ const out = [];
78
+ for (const [s, e] of all) {
79
+ const last = out[out.length - 1];
80
+ if (last && s <= last[1] + gap) last[1] = Math.max(last[1], e);
81
+ else out.push([s, e]);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** `duck` keyframes (clip-local seconds, dB) for one music clip.
87
+ * Ramps start `attack` s before each region and recover over `release` s
88
+ * after it; ramps that would meet stay ducked. Returns [] when no region
89
+ * touches the clip. */
90
+ function duckKeyframes(regions, { start, duration }, { amount = -12, attack = 0.3, release = 0.6 } = {}) {
91
+ amount = Math.min(0, +amount || 0);
92
+ const end = start + duration;
93
+ const spans = mergeRegions([regions.map(([s, e]) => [s - attack, e + release])], 0)
94
+ .filter(([s, e]) => e > start && s < end);
95
+ if (!spans.length || !amount) return [];
96
+ // The envelope in timeline time: each span is 0 → amount (attack) → hold →
97
+ // amount → 0 (release). A merged span always has attack-end ≤ release-start.
98
+ const pts = [];
99
+ for (const [s, e] of spans) pts.push([s, 0], [s + attack, amount], [e - release, amount], [e, 0]);
100
+ const valAt = (t) => {
101
+ if (t <= pts[0][0]) return pts[0][1];
102
+ for (let i = 1; i < pts.length; i++) {
103
+ const [t1, v1] = pts[i], [t0, v0] = pts[i - 1];
104
+ if (t <= t1) return t1 > t0 ? v0 + (v1 - v0) * (t - t0) / (t1 - t0) : v1;
105
+ }
106
+ return pts[pts.length - 1][1];
107
+ };
108
+ // Cut it to the clip: interpolated values on the edges, corners inside.
109
+ const r3 = (x) => Math.round(x * 1000) / 1000;
110
+ const keys = [];
111
+ const put = (t, v) => {
112
+ t = r3(t - start); v = r3(v) || 0;
113
+ const last = keys[keys.length - 1];
114
+ if (last && t <= last.t) { last.v = v; return; } // same instant: latest wins
115
+ keys.push({ t, v, ease: "linear" });
116
+ };
117
+ const v0 = valAt(start), v1 = valAt(end);
118
+ if (v0) put(start, v0);
119
+ for (const [t, v] of pts) if (t > start && t < end) put(t, v);
120
+ if (v1) put(end, v1);
121
+ // collapse runs of equal values to their two ends
122
+ return keys.filter((k, i) => !(i > 0 && i < keys.length - 1 &&
123
+ keys[i - 1].v === k.v && keys[i + 1].v === k.v));
124
+ }
125
+
126
+ return { HOP, WIN, EnvelopeMeter, rmsEnvelope, activeRegions, mergeRegions, duckKeyframes };
127
+ });