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.
- package/CLAUDE.md +1198 -0
- package/LICENSE +21 -0
- package/README.md +571 -2
- package/analyze.js +289 -0
- package/app.js +13339 -0
- package/audio-fx.js +241 -0
- package/cli.js +31 -0
- package/color.js +1043 -0
- package/denoise.js +120 -0
- package/ducking.js +127 -0
- package/edit-ops.js +962 -0
- package/encode-profiles.js +302 -0
- package/encoding-profiles.json +68 -0
- package/export-jobs.js +163 -0
- package/favicon.svg +14 -0
- package/fx-worklet.js +162 -0
- package/icons/favicon-32.png +0 -0
- package/import-url.js +293 -0
- package/index.html +420 -0
- package/library/README.md +15 -0
- package/library/elements/.gitkeep +0 -0
- package/library/elements/bokeh-drift.svg +19 -0
- package/library/elements/light-leak-cool.svg +16 -0
- package/library/elements/light-leak-warm.svg +22 -0
- package/library/elements/vhs-scanlines.svg +18 -0
- package/library/elements/vignette-frame.svg +14 -0
- package/library/fonts/.gitkeep +0 -0
- package/library/fonts/Abril Fatface.woff2 +0 -0
- package/library/fonts/Anton.woff2 +0 -0
- package/library/fonts/Archivo Black.woff2 +0 -0
- package/library/fonts/Bangers.woff2 +0 -0
- package/library/fonts/Barlow.woff2 +0 -0
- package/library/fonts/Bebas Neue.woff2 +0 -0
- package/library/fonts/Bungee.woff2 +0 -0
- package/library/fonts/Caveat.woff2 +0 -0
- package/library/fonts/DM Sans.woff2 +0 -0
- package/library/fonts/Inter.woff2 +0 -0
- package/library/fonts/LICENSES.md +31 -0
- package/library/fonts/Lato.woff2 +0 -0
- package/library/fonts/Montserrat.woff2 +0 -0
- package/library/fonts/Oswald.woff2 +0 -0
- package/library/fonts/Pacifico.woff2 +0 -0
- package/library/fonts/Playfair Display.woff2 +0 -0
- package/library/fonts/Poppins.woff2 +0 -0
- package/library/fonts/Righteous.woff2 +0 -0
- package/library/fonts/Roboto.woff2 +0 -0
- package/library/fonts/Rubik.woff2 +0 -0
- package/library/fonts/Teko.woff2 +0 -0
- package/library/sfx/README.md +18 -0
- package/library/svg/ai-star.svg +17 -0
- package/library/svg/arrow-bounce.svg +18 -0
- package/library/svg/arrow-scribble.svg +13 -0
- package/library/svg/burst-lines.svg +30 -0
- package/library/svg/checkmark-success.svg +19 -0
- package/library/svg/confetti-burst.svg +23 -0
- package/library/svg/countdown-ring.svg +92 -0
- package/library/svg/equalizer.svg +18 -0
- package/library/svg/glow-orbs.svg +29 -0
- package/library/svg/heart-pop.svg +28 -0
- package/library/svg/loading-dots.svg +16 -0
- package/library/svg/location-pin.svg +25 -0
- package/library/svg/lower-third.svg +17 -0
- package/library/svg/new-badge.svg +18 -0
- package/library/svg/progress-bar.svg +16 -0
- package/library/svg/radial-pulse.svg +19 -0
- package/library/svg/rating-stars.svg +25 -0
- package/library/svg/rec-indicator.svg +41 -0
- package/library/svg/sparkles.svg +17 -0
- package/library/svg/spawning-hearts.svg +39 -0
- package/library/svg/speech-bubble.svg +23 -0
- package/library/svg/subscribe-bell.svg +29 -0
- package/library/svg/subscribe-pill.svg +22 -0
- package/library/svg/swipe-up.svg +46 -0
- package/library/svg/underline-swoosh.svg +15 -0
- package/loudness.js +135 -0
- package/mcp-server.js +1421 -0
- package/meter-worklet.js +229 -0
- package/package.json +45 -4
- package/paths.js +106 -0
- package/ruler-worker.js +120 -0
- package/server.js +775 -0
- 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
|
+
});
|