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.
- 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/analyze.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
FableCut reference analyzer — zero-dependency Node.js (needs ffmpeg on PATH)
|
|
3
|
+
|
|
4
|
+
Turns a reference video into an "edit blueprint": shot boundaries (cuts),
|
|
5
|
+
music beats + BPM, an audio-energy curve, the drop, and the extracted music
|
|
6
|
+
track — everything an agent needs to rebuild the same edit with new footage.
|
|
7
|
+
|
|
8
|
+
Use as a module: const { analyze } = require("./analyze");
|
|
9
|
+
Use from the CLI: node analyze.js media/ref.mp4 [--threshold=0.3] [--no-music]
|
|
10
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
11
|
+
"use strict";
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { spawn } = require("child_process");
|
|
15
|
+
|
|
16
|
+
const SR = 22050; // analysis sample rate
|
|
17
|
+
const HOP = 512; // onset-envelope hop size (~23 ms)
|
|
18
|
+
const WIN = 1024; // energy window
|
|
19
|
+
|
|
20
|
+
function run(cmd, args, { binary = false } = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const proc = spawn(cmd, args, { windowsHide: true });
|
|
23
|
+
const out = [], err = [];
|
|
24
|
+
proc.stdout.on("data", (c) => out.push(c));
|
|
25
|
+
proc.stderr.on("data", (c) => err.push(c));
|
|
26
|
+
proc.on("error", reject);
|
|
27
|
+
proc.on("close", (code) => resolve({
|
|
28
|
+
code,
|
|
29
|
+
stdout: binary ? Buffer.concat(out) : Buffer.concat(out).toString("utf8"),
|
|
30
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
31
|
+
}));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ── ffprobe: container + stream facts ── */
|
|
36
|
+
async function probe(file) {
|
|
37
|
+
const r = await run("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", file]);
|
|
38
|
+
if (r.code !== 0) throw new Error("ffprobe failed: " + r.stderr.slice(-400));
|
|
39
|
+
const info = JSON.parse(r.stdout);
|
|
40
|
+
const v = (info.streams || []).find((s) => s.codec_type === "video");
|
|
41
|
+
const a = (info.streams || []).find((s) => s.codec_type === "audio");
|
|
42
|
+
const dur = parseFloat(info.format?.duration || v?.duration || a?.duration || 0);
|
|
43
|
+
let fps = 0;
|
|
44
|
+
if (v?.avg_frame_rate && v.avg_frame_rate !== "0/0") {
|
|
45
|
+
const [n, d] = v.avg_frame_rate.split("/").map(Number);
|
|
46
|
+
if (d) fps = n / d;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
duration: Math.round(dur * 1000) / 1000,
|
|
50
|
+
fps: Math.round(fps * 100) / 100,
|
|
51
|
+
width: v?.width || 0, height: v?.height || 0,
|
|
52
|
+
hasVideo: !!v, hasAudio: !!a,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ── Shot boundaries via ffmpeg scene-change scores ── */
|
|
57
|
+
async function detectCuts(file, threshold) {
|
|
58
|
+
const r = await run("ffmpeg", [
|
|
59
|
+
"-hide_banner", "-nostats", "-i", file,
|
|
60
|
+
"-vf", `select='gt(scene,${threshold})',metadata=print:file=-`,
|
|
61
|
+
"-an", "-f", "null", "-",
|
|
62
|
+
]);
|
|
63
|
+
// stdout pairs: "frame:0 pts:… pts_time:4.100" then "lavfi.scene_score=0.482"
|
|
64
|
+
const cuts = [];
|
|
65
|
+
let t = null;
|
|
66
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
67
|
+
const mT = /pts_time:([\d.]+)/.exec(line);
|
|
68
|
+
if (mT) { t = parseFloat(mT[1]); continue; }
|
|
69
|
+
const mS = /lavfi\.scene_score=([\d.]+)/.exec(line);
|
|
70
|
+
if (mS && t !== null) {
|
|
71
|
+
cuts.push({ t: Math.round(t * 1000) / 1000, score: Math.round(parseFloat(mS[1]) * 1000) / 1000 });
|
|
72
|
+
t = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return cuts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Audio: decode → onset envelope → beats + BPM + energy curve ── */
|
|
79
|
+
async function decodePCM(file) {
|
|
80
|
+
const r = await run("ffmpeg", ["-v", "error", "-i", file, "-vn", "-ac", "1", "-ar", String(SR), "-f", "s16le", "-"], { binary: true });
|
|
81
|
+
if (r.code !== 0 || r.stdout.length < WIN * 2) return null;
|
|
82
|
+
const buf = r.stdout;
|
|
83
|
+
return new Int16Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function analyzeAudio(pcm) {
|
|
87
|
+
const nFrames = Math.max(0, Math.floor((pcm.length - WIN) / HOP));
|
|
88
|
+
if (nFrames < 20) return { beats: [], bpm: null, energy: { step: 0.5, values: [] }, drop: null };
|
|
89
|
+
|
|
90
|
+
// frame energies
|
|
91
|
+
const E = new Float64Array(nFrames);
|
|
92
|
+
for (let i = 0; i < nFrames; i++) {
|
|
93
|
+
let s = 0;
|
|
94
|
+
const o = i * HOP;
|
|
95
|
+
for (let j = 0; j < WIN; j++) { const x = pcm[o + j] / 32768; s += x * x; }
|
|
96
|
+
E[i] = s / WIN;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// onset envelope: energy rise vs. trailing average
|
|
100
|
+
const O = new Float64Array(nFrames);
|
|
101
|
+
for (let i = 1; i < nFrames; i++) {
|
|
102
|
+
let m = 0, k = 0;
|
|
103
|
+
for (let j = Math.max(0, i - 8); j < i; j++) { m += E[j]; k++; }
|
|
104
|
+
O[i] = Math.max(0, E[i] - m / k);
|
|
105
|
+
}
|
|
106
|
+
let oMax = 0;
|
|
107
|
+
for (let i = 0; i < nFrames; i++) if (O[i] > oMax) oMax = O[i];
|
|
108
|
+
if (oMax > 0) for (let i = 0; i < nFrames; i++) O[i] /= oMax;
|
|
109
|
+
|
|
110
|
+
// peak-pick beats: local max, above an adaptive floor, min 0.25 s apart
|
|
111
|
+
const frameT = (i) => Math.round(((i * HOP + WIN / 2) / SR) * 1000) / 1000;
|
|
112
|
+
const minGap = Math.round(0.25 * SR / HOP);
|
|
113
|
+
const halfWin = Math.round(1.0 * SR / HOP);
|
|
114
|
+
const beats = [];
|
|
115
|
+
let last = -minGap;
|
|
116
|
+
for (let i = 3; i < nFrames - 3; i++) {
|
|
117
|
+
if (O[i] < 0.05) continue;
|
|
118
|
+
let isMax = true;
|
|
119
|
+
for (let j = -3; j <= 3; j++) if (O[i + j] > O[i]) { isMax = false; break; }
|
|
120
|
+
if (!isMax) continue;
|
|
121
|
+
let m = 0, k = 0;
|
|
122
|
+
for (let j = Math.max(0, i - halfWin); j < Math.min(nFrames, i + halfWin); j++) { m += O[j]; k++; }
|
|
123
|
+
if (O[i] < (m / k) * 1.5) continue;
|
|
124
|
+
if (i - last < minGap) continue;
|
|
125
|
+
beats.push(frameT(i));
|
|
126
|
+
last = i;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// BPM: autocorrelation of the onset envelope over 60–200 BPM lags,
|
|
130
|
+
// with a mild prior toward ~120; fold octaves into 70–180.
|
|
131
|
+
let bpm = null;
|
|
132
|
+
const lagMin = Math.round(60 / 200 * SR / HOP), lagMax = Math.round(60 / 60 * SR / HOP);
|
|
133
|
+
let best = 0, bestLag = 0;
|
|
134
|
+
for (let lag = lagMin; lag <= Math.min(lagMax, nFrames - 1); lag++) {
|
|
135
|
+
let s = 0;
|
|
136
|
+
for (let i = 0; i + lag < nFrames; i++) s += O[i] * O[i + lag];
|
|
137
|
+
const cand = 60 / (lag * HOP / SR);
|
|
138
|
+
const w = Math.exp(-0.5 * Math.pow(Math.log2(cand / 120) / 1.0, 2));
|
|
139
|
+
if (s * w > best) { best = s * w; bestLag = lag; }
|
|
140
|
+
}
|
|
141
|
+
if (bestLag) {
|
|
142
|
+
bpm = 60 / (bestLag * HOP / SR);
|
|
143
|
+
while (bpm < 70) bpm *= 2;
|
|
144
|
+
while (bpm > 180) bpm /= 2;
|
|
145
|
+
// refine: if the picked beats are regular, the median inter-beat interval
|
|
146
|
+
// is a much finer tempo estimate than the autocorrelation lag grid
|
|
147
|
+
if (beats.length >= 6) {
|
|
148
|
+
const gaps = beats.slice(1).map((t, i) => t - beats[i]).sort((a, b) => a - b);
|
|
149
|
+
const med = gaps[Math.floor(gaps.length / 2)];
|
|
150
|
+
const spread = gaps[Math.floor(gaps.length * 0.85)] - gaps[Math.floor(gaps.length * 0.15)];
|
|
151
|
+
if (med > 0 && spread < med * 0.15) {
|
|
152
|
+
// full span / count averages out the hop-grid quantization of each beat
|
|
153
|
+
let refined = 60 / ((beats[beats.length - 1] - beats[0]) / (beats.length - 1));
|
|
154
|
+
while (refined < 70) refined *= 2;
|
|
155
|
+
while (refined > 180) refined /= 2;
|
|
156
|
+
if (Math.abs(refined - bpm) / bpm < 0.15) bpm = refined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
bpm = Math.round(bpm * 10) / 10;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// loudness curve, one value per 0.5 s, normalized 0–100
|
|
163
|
+
const step = 0.5;
|
|
164
|
+
const perStep = Math.round(step * SR / HOP);
|
|
165
|
+
const values = [];
|
|
166
|
+
for (let i = 0; i < nFrames; i += perStep) {
|
|
167
|
+
let s = 0, k = 0;
|
|
168
|
+
for (let j = i; j < Math.min(nFrames, i + perStep); j++) { s += Math.sqrt(E[j]); k++; }
|
|
169
|
+
values.push(k ? s / k : 0);
|
|
170
|
+
}
|
|
171
|
+
const vMax = Math.max(...values, 1e-9);
|
|
172
|
+
const energy = values.map((v) => Math.round(v / vMax * 100));
|
|
173
|
+
|
|
174
|
+
// the drop: biggest sustained energy rise (skip the very start)
|
|
175
|
+
let drop = null, bestRise = 0;
|
|
176
|
+
for (let i = 2; i < energy.length - 1; i++) {
|
|
177
|
+
const before = (energy[i - 2] + energy[i - 1]) / 2;
|
|
178
|
+
const after = (energy[i] + energy[Math.min(i + 1, energy.length - 1)]) / 2;
|
|
179
|
+
if (after - before > bestRise && after > 55) { bestRise = after - before; drop = Math.round(i * step * 10) / 10; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { beats, bpm, energy: { step, values: energy }, drop };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── Extract the reference's music track into its own audio file ── */
|
|
186
|
+
async function extractMusic(file, outDir) {
|
|
187
|
+
const stem = path.basename(file, path.extname(file)).replace(/[^\w.\- ()\[\]]+/g, "_");
|
|
188
|
+
let out = path.join(outDir, stem + "-music.m4a");
|
|
189
|
+
let i = 1;
|
|
190
|
+
while (fs.existsSync(out)) out = path.join(outDir, `${stem}-music_${i++}.m4a`);
|
|
191
|
+
const r = await run("ffmpeg", ["-y", "-i", file, "-vn", "-c:a", "aac", "-b:a", "192k", out]);
|
|
192
|
+
if (r.code !== 0) { try { fs.rmSync(out); } catch {} return null; }
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ── Main entry ──
|
|
197
|
+
opts: threshold — scene-cut sensitivity (default: adaptive 0.30→0.20→0.12)
|
|
198
|
+
music — extract the audio track (default true)
|
|
199
|
+
musicDir — where the extracted music file goes (default: next to file)
|
|
200
|
+
srcUrl — how `source`/`music` are reported (e.g. "/media/ref.mp4") */
|
|
201
|
+
async function analyze(file, opts = {}) {
|
|
202
|
+
if (!fs.existsSync(file)) throw new Error("File not found: " + file);
|
|
203
|
+
const info = await probe(file);
|
|
204
|
+
|
|
205
|
+
// shots — adaptive threshold: relax until the cut density looks like an edit
|
|
206
|
+
let cuts = [], threshold = null;
|
|
207
|
+
if (info.hasVideo) {
|
|
208
|
+
const tries = opts.threshold ? [opts.threshold] : [0.30, 0.20, 0.12];
|
|
209
|
+
for (const t of tries) {
|
|
210
|
+
threshold = t;
|
|
211
|
+
cuts = await detectCuts(file, t);
|
|
212
|
+
if (cuts.length >= Math.max(1, info.duration / 12)) break;
|
|
213
|
+
}
|
|
214
|
+
// drop first-frame artifacts and duplicate detections closer than 0.15 s
|
|
215
|
+
cuts = cuts.filter((c) => c.t > 0.3);
|
|
216
|
+
cuts = cuts.filter((c, i) => i === 0 || c.t - cuts[i - 1].t > 0.15);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// audio
|
|
220
|
+
let audio = { beats: [], bpm: null, energy: { step: 0.5, values: [] }, drop: null };
|
|
221
|
+
if (info.hasAudio) {
|
|
222
|
+
const pcm = await decodePCM(file);
|
|
223
|
+
if (pcm) audio = analyzeAudio(pcm);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// shot list with per-shot audio energy (intensity hint for footage mapping)
|
|
227
|
+
const bounds = [0, ...cuts.map((c) => c.t), info.duration];
|
|
228
|
+
const shots = [];
|
|
229
|
+
for (let i = 0; i + 1 < bounds.length; i++) {
|
|
230
|
+
const start = bounds[i], end = bounds[i + 1];
|
|
231
|
+
if (end - start < 0.05) continue;
|
|
232
|
+
const { step, values } = audio.energy;
|
|
233
|
+
let e = null;
|
|
234
|
+
if (values.length) {
|
|
235
|
+
let s = 0, k = 0;
|
|
236
|
+
for (let j = Math.floor(start / step); j < Math.min(values.length, Math.max(Math.floor(start / step) + 1, Math.ceil(end / step))); j++) { s += values[j]; k++; }
|
|
237
|
+
e = k ? Math.round(s / k) : null;
|
|
238
|
+
}
|
|
239
|
+
shots.push({
|
|
240
|
+
index: shots.length,
|
|
241
|
+
start: Math.round(start * 1000) / 1000,
|
|
242
|
+
end: Math.round(end * 1000) / 1000,
|
|
243
|
+
duration: Math.round((end - start) * 1000) / 1000,
|
|
244
|
+
energy: e,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// music extraction
|
|
249
|
+
let music = null;
|
|
250
|
+
if (info.hasAudio && opts.music !== false) {
|
|
251
|
+
const dir = opts.musicDir || path.dirname(file);
|
|
252
|
+
const out = await extractMusic(file, dir);
|
|
253
|
+
if (out) music = { file: out, name: path.basename(out) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const avgShotLen = shots.length ? Math.round(shots.reduce((s, x) => s + x.duration, 0) / shots.length * 100) / 100 : null;
|
|
257
|
+
return {
|
|
258
|
+
source: opts.srcUrl || file,
|
|
259
|
+
analyzedAt: new Date().toISOString(),
|
|
260
|
+
duration: info.duration, fps: info.fps, width: info.width, height: info.height,
|
|
261
|
+
hasAudio: info.hasAudio,
|
|
262
|
+
sceneThreshold: threshold,
|
|
263
|
+
cuts: cuts.map((c) => c.t),
|
|
264
|
+
cutScores: cuts.map((c) => c.score),
|
|
265
|
+
shots,
|
|
266
|
+
avgShotLen,
|
|
267
|
+
cutsPerSecond: info.duration ? Math.round(cuts.length / info.duration * 100) / 100 : 0,
|
|
268
|
+
bpm: audio.bpm,
|
|
269
|
+
beats: audio.beats,
|
|
270
|
+
energy: audio.energy,
|
|
271
|
+
drop: audio.drop,
|
|
272
|
+
music: music ? { name: music.name, file: music.file } : null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { analyze };
|
|
277
|
+
|
|
278
|
+
/* ── CLI ── */
|
|
279
|
+
if (require.main === module) {
|
|
280
|
+
const args = process.argv.slice(2);
|
|
281
|
+
const file = args.find((a) => !a.startsWith("--"));
|
|
282
|
+
if (!file) { console.error("Usage: node analyze.js <video> [--threshold=0.3] [--no-music]"); process.exit(1); }
|
|
283
|
+
const tArg = args.find((a) => a.startsWith("--threshold="));
|
|
284
|
+
analyze(path.resolve(file), {
|
|
285
|
+
threshold: tArg ? parseFloat(tArg.split("=")[1]) : undefined,
|
|
286
|
+
music: !args.includes("--no-music"),
|
|
287
|
+
}).then((bp) => console.log(JSON.stringify(bp, null, 2)))
|
|
288
|
+
.catch((e) => { console.error("analyze failed: " + e.message); process.exit(1); });
|
|
289
|
+
}
|