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/server.js
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
FableCut server — zero-dependency Node.js
|
|
3
|
+
Run: node server.js → http://localhost:7777
|
|
4
|
+
|
|
5
|
+
Adds to the browser editor:
|
|
6
|
+
• persistent project ./project.json (GET/PUT /api/project)
|
|
7
|
+
• media library folder ./media/ (served at /media/*, POST /api/upload,
|
|
8
|
+
POST /api/import-url)
|
|
9
|
+
• live reload GET /api/events (SSE: event "change" for
|
|
10
|
+
project/media/library;
|
|
11
|
+
event "profiles" for
|
|
12
|
+
encoding-profiles.json)
|
|
13
|
+
|
|
14
|
+
Automation: any tool (e.g. Claude Code) can edit project.json or drop files
|
|
15
|
+
into ./media — the browser UI reloads instantly. Schema: see CLAUDE.md.
|
|
16
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
17
|
+
"use strict";
|
|
18
|
+
const http = require("http");
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const { spawn, spawnSync, execFile } = require("child_process");
|
|
22
|
+
|
|
23
|
+
const { analyze } = require("./analyze");
|
|
24
|
+
const {
|
|
25
|
+
PROFILES_FILE,
|
|
26
|
+
loadEncodeProfiles,
|
|
27
|
+
invalidateEncodeProfiles,
|
|
28
|
+
resolveProfile,
|
|
29
|
+
listProfilesPublic,
|
|
30
|
+
profileSummary,
|
|
31
|
+
buildExportArgs,
|
|
32
|
+
dryRunProfile,
|
|
33
|
+
} = require("./encode-profiles");
|
|
34
|
+
const { downloadImportUrl, maybeFaststart } = require("./import-url");
|
|
35
|
+
const { createExportJobs } = require("./export-jobs");
|
|
36
|
+
const { denoiseFile, AMOUNT_IDS: DENOISE_AMOUNTS } = require("./denoise");
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR,
|
|
40
|
+
PROJECT_FILE, LIBRARY_SUBDIRS, ensureDirs,
|
|
41
|
+
} = require("./paths");
|
|
42
|
+
|
|
43
|
+
/* Static app files are served from the install dir; everything the user creates
|
|
44
|
+
lives under DATA_DIR. The two are the same unless FABLECUT_DATA_DIR is set. */
|
|
45
|
+
const ROOT = APP_DIR;
|
|
46
|
+
const PORT = process.env.PORT || 7777;
|
|
47
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
48
|
+
|
|
49
|
+
/* Requests must come from the local machine (or an explicitly allowed host).
|
|
50
|
+
The Host check stops DNS rebinding; the Origin check stops malicious web
|
|
51
|
+
pages firing blind cross-origin writes at the API. Opt into LAN use with
|
|
52
|
+
HOST=0.0.0.0 and FABLECUT_ALLOWED_HOSTS=192.168.1.20,mybox.local */
|
|
53
|
+
const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1", HOST.toLowerCase()]);
|
|
54
|
+
for (const h of (process.env.FABLECUT_ALLOWED_HOSTS || "").split(","))
|
|
55
|
+
if (h.trim()) ALLOWED_HOSTS.add(h.trim().toLowerCase());
|
|
56
|
+
|
|
57
|
+
function hostAllowed(value) {
|
|
58
|
+
if (!value) return false;
|
|
59
|
+
// strip a :port suffix, but not the colons inside a bare IPv6 address
|
|
60
|
+
const host = value.replace(/^(\[[^\]]*\]|[^:]+)(:\d+)?$/, "$1").toLowerCase();
|
|
61
|
+
return ALLOWED_HOSTS.has(host);
|
|
62
|
+
}
|
|
63
|
+
function requestAllowed(req) {
|
|
64
|
+
if (!hostAllowed(req.headers.host)) return false;
|
|
65
|
+
const origin = req.headers.origin;
|
|
66
|
+
if (origin) {
|
|
67
|
+
try { return hostAllowed(new URL(origin).host); } catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ffmpeg powers optional niceties (faststart remux on upload, fast export).
|
|
73
|
+
Everything else works without it. */
|
|
74
|
+
let HAS_FFMPEG = false;
|
|
75
|
+
try { HAS_FFMPEG = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; } catch {}
|
|
76
|
+
|
|
77
|
+
const MIME = {
|
|
78
|
+
".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
|
79
|
+
".js": "text/javascript; charset=utf-8", ".json": "application/json",
|
|
80
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
81
|
+
".mkv": "video/x-matroska", ".m4v": "video/mp4",
|
|
82
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
83
|
+
".m4a": "audio/mp4", ".aac": "audio/aac", ".flac": "audio/flac",
|
|
84
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
85
|
+
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
86
|
+
".ico": "image/x-icon",
|
|
87
|
+
".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
ensureDirs();
|
|
91
|
+
if (!fs.existsSync(PROJECT_FILE)) {
|
|
92
|
+
fs.writeFileSync(PROJECT_FILE, JSON.stringify({
|
|
93
|
+
name: "Untitled Project", width: 1280, height: 720, fps: 30,
|
|
94
|
+
revision: 0, media: [], clips: [],
|
|
95
|
+
}, null, 2));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── SSE clients + file watching ── */
|
|
99
|
+
const sseClients = new Set();
|
|
100
|
+
function broadcast(event = "change", data = event) {
|
|
101
|
+
const payload = `event: ${event}\ndata: ${data}\n\n`;
|
|
102
|
+
for (const res of sseClients) res.write(payload);
|
|
103
|
+
}
|
|
104
|
+
let debounce = null;
|
|
105
|
+
let profilesDebounce = null;
|
|
106
|
+
function onFsChange() {
|
|
107
|
+
clearTimeout(debounce);
|
|
108
|
+
debounce = setTimeout(() => broadcast("change"), 150);
|
|
109
|
+
}
|
|
110
|
+
function onProfilesChange() {
|
|
111
|
+
invalidateEncodeProfiles();
|
|
112
|
+
clearTimeout(profilesDebounce);
|
|
113
|
+
profilesDebounce = setTimeout(() => broadcast("profiles"), 150);
|
|
114
|
+
}
|
|
115
|
+
/* watch the directory, not the file — atomic tmp+rename writes would detach a
|
|
116
|
+
direct file watcher on Windows */
|
|
117
|
+
if (process.env.FABLECUT_NO_FS_WATCH !== "1") {
|
|
118
|
+
try { fs.watch(DATA_DIR, (ev, f) => { if (f === "project.json") onFsChange(); }); } catch {}
|
|
119
|
+
try {
|
|
120
|
+
fs.watch(ROOT, (ev, f) => {
|
|
121
|
+
if (f === path.basename(PROFILES_FILE)) onProfilesChange();
|
|
122
|
+
});
|
|
123
|
+
} catch {}
|
|
124
|
+
try { fs.watch(MEDIA_DIR, onFsChange); } catch {}
|
|
125
|
+
for (const d of LIBRARY_SUBDIRS) {
|
|
126
|
+
try { fs.watch(path.join(LIBRARY_DIR, d), onFsChange); } catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ── Helpers ── */
|
|
131
|
+
function safeName(name) {
|
|
132
|
+
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 120) || "file";
|
|
133
|
+
}
|
|
134
|
+
function sendJSON(res, code, obj) {
|
|
135
|
+
const body = JSON.stringify(obj);
|
|
136
|
+
res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
137
|
+
res.end(body);
|
|
138
|
+
}
|
|
139
|
+
function readBody(req) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
req.on("data", (c) => chunks.push(c));
|
|
143
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
144
|
+
req.on("error", reject);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Claim final + sibling `.part` paths with exclusive create (`wx`) so concurrent
|
|
148
|
+
* exports cannot pick the same free name before either file exists. */
|
|
149
|
+
function reserveExportPaths(dir, baseName, ext) {
|
|
150
|
+
const base = baseName.replace(/\.(mp4|mov|m4v|mkv|webm)$/i, "");
|
|
151
|
+
let i = 0;
|
|
152
|
+
for (;;) {
|
|
153
|
+
const stem = i === 0 ? base : `${base}_${i}`;
|
|
154
|
+
i++;
|
|
155
|
+
const outPath = path.join(dir, stem + ext);
|
|
156
|
+
const partPath = path.join(dir, stem + ".part" + ext);
|
|
157
|
+
if (fs.existsSync(outPath)) continue;
|
|
158
|
+
try {
|
|
159
|
+
fs.closeSync(fs.openSync(partPath, "wx")); // exclusive create — claim the name
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.code === "EEXIST") continue;
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
if (fs.existsSync(outPath)) {
|
|
165
|
+
try { fs.rmSync(partPath, { force: true }); } catch { }
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
return { outPath, partPath };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function run(cmd, args) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
execFile(cmd, args, { maxBuffer: 1 << 24 }, (err, _out, stderr) =>
|
|
174
|
+
err ? reject(new Error((stderr || String(err)).slice(-800))) : resolve());
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Remux MP4-family uploads with `+faststart` so the moov atom leads the file —
|
|
179
|
+
without it <video> stalls for seconds probing over Range requests. */
|
|
180
|
+
function faststart(file) { return maybeFaststart(file); }
|
|
181
|
+
|
|
182
|
+
/* ── Export sessions ──
|
|
183
|
+
Two modes share the same HTTP session API:
|
|
184
|
+
jpeg — browser streams JPEGs (one POST may concatenate several);
|
|
185
|
+
ffmpeg encodes via an encoding profile (Fast)
|
|
186
|
+
annexb — browser streams Annex-B H.264 (one POST may concatenate several AUs);
|
|
187
|
+
ffmpeg stream-copies (WebCodecs)
|
|
188
|
+
Both spawn on the FIRST frame, not here: the audio mix is uploaded between
|
|
189
|
+
/begin and the first frame, and a one-pass encode needs it on disk. */
|
|
190
|
+
const exportSessions = new Map();
|
|
191
|
+
/* Container + bitstream BT.709/tv for the WebCodecs (annexb) path.
|
|
192
|
+
`-c copy` alone drops the colr atom; the bsf writes VUI so players see
|
|
193
|
+
primaries/transfer, not "unknown". JPEG Fast export uses profile.color instead. */
|
|
194
|
+
const COLOR_TAGS = [
|
|
195
|
+
"-colorspace", "bt709", "-color_primaries", "bt709",
|
|
196
|
+
"-color_trc", "bt709", "-color_range", "tv",
|
|
197
|
+
];
|
|
198
|
+
const COLOR_BSF = "h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag=0";
|
|
199
|
+
const EXPORT_IDLE_MS = 10 * 60 * 1000; // abandon sessions with no successful activity
|
|
200
|
+
const EXPORT_SWEEP_MS = 60 * 1000;
|
|
201
|
+
let exportSweepTimer = null;
|
|
202
|
+
function touchExport(sess) {
|
|
203
|
+
if (sess) sess.lastTouch = Date.now();
|
|
204
|
+
}
|
|
205
|
+
function attachProc(sess, proc) {
|
|
206
|
+
sess.proc = proc;
|
|
207
|
+
sess.stderr = "";
|
|
208
|
+
proc.stderr.on("data", (d) => { sess.stderr = (sess.stderr + d).slice(-2000); });
|
|
209
|
+
proc.stdin.on("error", () => {}); // EPIPE if ffmpeg dies mid-stream
|
|
210
|
+
sess.done = new Promise((res) => proc.on("close", res));
|
|
211
|
+
}
|
|
212
|
+
async function beginExport(fps, name, profileId, hasAudio, mode, extra = {}) {
|
|
213
|
+
const m = mode === "annexb" ? "annexb" : "jpeg";
|
|
214
|
+
const rate = Number(fps);
|
|
215
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
216
|
+
throw new Error("export fps required (pass project.fps)");
|
|
217
|
+
}
|
|
218
|
+
const pixelFormat = extra.pixelFormat === "rgba" ? "rgba" : "jpeg";
|
|
219
|
+
const width = Math.max(0, extra.width | 0);
|
|
220
|
+
const height = Math.max(0, extra.height | 0);
|
|
221
|
+
if (pixelFormat === "rgba" && (width < 2 || height < 2)) {
|
|
222
|
+
throw new Error("export width/height required for rgba");
|
|
223
|
+
}
|
|
224
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
225
|
+
const safe = safeName(name || "export");
|
|
226
|
+
// Same filesystem as the finished file so renameSync(partPath, out) cannot EXDEV.
|
|
227
|
+
const dir = fs.mkdtempSync(path.join(EXPORTS_DIR, "fablecut-"));
|
|
228
|
+
|
|
229
|
+
if (m === "annexb") {
|
|
230
|
+
const { outPath, partPath } = reserveExportPaths(EXPORTS_DIR, safe, ".mp4");
|
|
231
|
+
const sess = {
|
|
232
|
+
mode: m, fps: rate, proc: null, dir,
|
|
233
|
+
name: safe, hasAudio: !!hasAudio,
|
|
234
|
+
wav: null, partPath, outPath,
|
|
235
|
+
stderr: "", done: null, lastTouch: Date.now(),
|
|
236
|
+
err: () => sess.stderr.trim().split("\n").filter(Boolean).slice(-3)
|
|
237
|
+
.map((l) => l.trim()).join(" · "),
|
|
238
|
+
};
|
|
239
|
+
sess.writeLock = Promise.resolve();
|
|
240
|
+
exportSessions.set(id, sess);
|
|
241
|
+
return { id, mode: m };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const profile = resolveProfile(profileId);
|
|
245
|
+
const dry = await dryRunProfile(profile, { fps: rate, hasAudio });
|
|
246
|
+
if (!dry.ok) throw new Error(`profile "${profile.id}" was rejected by ffmpeg: ${dry.error}`);
|
|
247
|
+
// Reserve the output name now (not at first frame) so concurrent exports
|
|
248
|
+
// cannot both see the same free path. The empty .part file is overwritten by ffmpeg (-y).
|
|
249
|
+
const { outPath, partPath } = reserveExportPaths(EXPORTS_DIR, safe, profile.extension);
|
|
250
|
+
const sess = {
|
|
251
|
+
mode: m, proc: null, fps: rate, profile, name: safe, hasAudio: !!hasAudio,
|
|
252
|
+
pixelFormat, width, height,
|
|
253
|
+
dir, wav: null, partPath, outPath,
|
|
254
|
+
stderr: "", done: null, lastTouch: Date.now(),
|
|
255
|
+
err: () => sess.stderr.trim().split("\n").filter(Boolean).slice(-3)
|
|
256
|
+
.map((l) => l.trim()).join(" · "),
|
|
257
|
+
};
|
|
258
|
+
sess.writeLock = Promise.resolve();
|
|
259
|
+
exportSessions.set(id, sess);
|
|
260
|
+
return { id, mode: m, profile: profile.id, label: profile.label, summary: profileSummary(profile) };
|
|
261
|
+
}
|
|
262
|
+
/* Encode into the paths reserved at /begin; rename .part → final on clean exit
|
|
263
|
+
so an aborted render never leaves something that looks like a finished file. */
|
|
264
|
+
function startEncoder(sess) {
|
|
265
|
+
const proc = spawn("ffmpeg", buildExportArgs(sess.profile, {
|
|
266
|
+
fps: sess.fps, wavPath: sess.wav, outPath: sess.partPath,
|
|
267
|
+
pixelFormat: sess.pixelFormat, width: sess.width, height: sess.height,
|
|
268
|
+
}), { stdio: ["pipe", "ignore", "pipe"] });
|
|
269
|
+
proc.stderr.on("data", (d) => { sess.stderr = (sess.stderr + d).slice(-2000); });
|
|
270
|
+
// EPIPE on end()/late writes is normal once ffmpeg has exited; writeExportFrame
|
|
271
|
+
// attaches its own error listener while a backpressured write is in flight.
|
|
272
|
+
proc.stdin.on("error", () => { });
|
|
273
|
+
sess.proc = proc;
|
|
274
|
+
sess.done = new Promise((res) => proc.on("close", res));
|
|
275
|
+
return proc;
|
|
276
|
+
}
|
|
277
|
+
/** Write one frame (JPEG or Annex-B NAL) to ffmpeg stdin. If the pipe is full,
|
|
278
|
+
* wait for drain — but also reject if ffmpeg exits or the stdin errors, so the
|
|
279
|
+
* HTTP request cannot hang forever after a failed encode. */
|
|
280
|
+
function writeExportFrame(sess, body) {
|
|
281
|
+
const proc = sess.proc;
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
if (!proc || proc.exitCode !== null || proc.killed)
|
|
284
|
+
return reject(new Error("ffmpeg exited: " + sess.err()));
|
|
285
|
+
|
|
286
|
+
let settled = false;
|
|
287
|
+
const finish = (fn, arg) => {
|
|
288
|
+
if (settled) return;
|
|
289
|
+
settled = true;
|
|
290
|
+
proc.off("close", onClose);
|
|
291
|
+
proc.stdin.off("error", onErr);
|
|
292
|
+
proc.stdin.off("drain", onDrain);
|
|
293
|
+
fn(arg);
|
|
294
|
+
};
|
|
295
|
+
const onClose = () => finish(reject, new Error("ffmpeg exited: " + (sess.err() || "closed")));
|
|
296
|
+
const onErr = (e) => finish(reject, new Error(e?.message || "ffmpeg stdin error"));
|
|
297
|
+
const onDrain = () => finish(resolve);
|
|
298
|
+
|
|
299
|
+
proc.once("close", onClose);
|
|
300
|
+
proc.stdin.once("error", onErr);
|
|
301
|
+
let ok;
|
|
302
|
+
try { ok = proc.stdin.write(body); }
|
|
303
|
+
catch (e) { return finish(reject, e); }
|
|
304
|
+
if (proc.exitCode !== null)
|
|
305
|
+
return finish(reject, new Error("ffmpeg exited: " + sess.err()));
|
|
306
|
+
if (ok) finish(resolve);
|
|
307
|
+
else proc.stdin.once("drain", onDrain);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/* One-pass mux for WebCodecs: H.264 elementary stream on stdin + optional WAV.
|
|
311
|
+
Use input `-r` (not only `-framerate`): HW encoders stamp AUs with µs-rounded
|
|
312
|
+
durations (e.g. 33333µs ≈ 1/30), which otherwise become avg_frame_rate
|
|
313
|
+
1000000/33333. `-r` forces CFR PTS so the MP4 matches project.fps exactly. */
|
|
314
|
+
function startAnnexbEncoder(sess) {
|
|
315
|
+
const fps = sess.fps;
|
|
316
|
+
const args = [
|
|
317
|
+
"-y", "-hide_banner",
|
|
318
|
+
"-fflags", "+genpts",
|
|
319
|
+
"-f", "h264", "-r", String(fps), "-i", "pipe:0",
|
|
320
|
+
];
|
|
321
|
+
if (sess.wav) args.push("-i", sess.wav);
|
|
322
|
+
args.push("-map", "0:v:0", "-c:v", "copy", "-bsf:v", COLOR_BSF, ...COLOR_TAGS);
|
|
323
|
+
// do not use -shortest: with unset/generated PTS it drops the audio track
|
|
324
|
+
if (sess.wav) args.push("-map", "1:a:0", "-c:a", "aac", "-b:a", "192k");
|
|
325
|
+
args.push("-movflags", "+faststart+write_colr", sess.partPath);
|
|
326
|
+
attachProc(sess, spawn("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe"] }));
|
|
327
|
+
return sess.proc;
|
|
328
|
+
}
|
|
329
|
+
function cleanupExport(id) {
|
|
330
|
+
const s = exportSessions.get(id);
|
|
331
|
+
if (!s) return;
|
|
332
|
+
exportSessions.delete(id);
|
|
333
|
+
try { s.proc?.kill(); } catch {}
|
|
334
|
+
try { fs.rmSync(s.dir, { recursive: true, force: true }); } catch {}
|
|
335
|
+
if (s.partPath) try { fs.rmSync(s.partPath, { force: true }); } catch {}
|
|
336
|
+
}
|
|
337
|
+
function sweepIdleExports() {
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
for (const [id, s] of [...exportSessions]) {
|
|
340
|
+
if (now - (s.lastTouch || 0) > EXPORT_IDLE_MS) cleanupExport(id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function startExportSweep() {
|
|
344
|
+
if (exportSweepTimer) return;
|
|
345
|
+
exportSweepTimer = setInterval(sweepIdleExports, EXPORT_SWEEP_MS);
|
|
346
|
+
}
|
|
347
|
+
function stopExportSweep() {
|
|
348
|
+
if (!exportSweepTimer) return;
|
|
349
|
+
clearInterval(exportSweepTimer);
|
|
350
|
+
exportSweepTimer = null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* Agent-requested exports: handed to an open editor tab over SSE, or to a
|
|
354
|
+
headless browser the server starts (export-jobs.js). */
|
|
355
|
+
const exportJobs = createExportJobs({
|
|
356
|
+
url: (id) => `http://localhost:${PORT}/?exportJob=${encodeURIComponent(id)}`,
|
|
357
|
+
broadcast,
|
|
358
|
+
editors: () => sseClients.size,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/* Static file with HTTP Range support (required for <video> seeking) */
|
|
362
|
+
function serveFile(req, res, filePath) {
|
|
363
|
+
fs.stat(filePath, (err, st) => {
|
|
364
|
+
if (err || !st.isFile()) { res.writeHead(404); res.end("Not found"); return; }
|
|
365
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
366
|
+
const type = MIME[ext] || "application/octet-stream";
|
|
367
|
+
const extra = {};
|
|
368
|
+
if (ext === ".svg") {
|
|
369
|
+
extra["Content-Security-Policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'";
|
|
370
|
+
extra["X-Content-Type-Options"] = "nosniff";
|
|
371
|
+
}
|
|
372
|
+
const range = req.headers.range;
|
|
373
|
+
if (range) {
|
|
374
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
375
|
+
let start = m && m[1] ? parseInt(m[1]) : 0;
|
|
376
|
+
let end = m && m[2] ? parseInt(m[2]) : st.size - 1;
|
|
377
|
+
start = Math.min(start, st.size - 1); end = Math.min(end, st.size - 1);
|
|
378
|
+
res.writeHead(206, {
|
|
379
|
+
"Content-Type": type, "Accept-Ranges": "bytes",
|
|
380
|
+
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
|
381
|
+
"Content-Length": end - start + 1, ...extra,
|
|
382
|
+
});
|
|
383
|
+
fs.createReadStream(filePath, { start, end }).pipe(res);
|
|
384
|
+
} else {
|
|
385
|
+
res.writeHead(200, {
|
|
386
|
+
"Content-Type": type, "Content-Length": st.size,
|
|
387
|
+
"Accept-Ranges": "bytes", "Cache-Control": "no-cache", ...extra,
|
|
388
|
+
});
|
|
389
|
+
fs.createReadStream(filePath).pipe(res);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* ── Server ── */
|
|
395
|
+
const server = http.createServer(async (req, res) => {
|
|
396
|
+
if (!requestAllowed(req)) {
|
|
397
|
+
sendJSON(res, 403, { error: "forbidden: request must come from this machine (bad Host or Origin header)" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const url = new URL(req.url, "http://localhost");
|
|
401
|
+
const p = decodeURIComponent(url.pathname);
|
|
402
|
+
// never serve dotfiles/dot-directories (.git, .gitignore, …)
|
|
403
|
+
if (p.split(/[\\/]/).some((seg) => seg.startsWith("."))) { res.writeHead(403); res.end(); return; }
|
|
404
|
+
|
|
405
|
+
/* API: project */
|
|
406
|
+
if (p === "/api/project" && req.method === "GET") {
|
|
407
|
+
// strip UTF-8 BOM some editors/PowerShell prepend, which breaks JSON.parse
|
|
408
|
+
try { sendJSON(res, 200, JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(new RegExp("^\\uFEFF"), ""))); }
|
|
409
|
+
catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (p === "/api/project" && req.method === "PUT") {
|
|
413
|
+
try {
|
|
414
|
+
const body = await readBody(req);
|
|
415
|
+
const data = JSON.parse(body.toString("utf8")); // validate JSON
|
|
416
|
+
/* Optimistic concurrency: a write whose revision isn't newer than what's
|
|
417
|
+
on disk was based on a stale read (someone else — the UI or an external
|
|
418
|
+
tool — saved in between). Reject it instead of clobbering their work.
|
|
419
|
+
?force=1 skips the check for deliberate overwrites. */
|
|
420
|
+
let cur = {};
|
|
421
|
+
try { cur = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(new RegExp("^\\uFEFF"), "")); } catch {}
|
|
422
|
+
if ((data.revision || 0) <= (cur.revision || 0) && url.searchParams.get("force") !== "1") {
|
|
423
|
+
sendJSON(res, 409, { error: "stale revision — project changed since it was read", revision: cur.revision || 0 });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const tmp = PROJECT_FILE + ".tmp";
|
|
427
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
428
|
+
fs.renameSync(tmp, PROJECT_FILE);
|
|
429
|
+
sendJSON(res, 200, { ok: true, revision: data.revision });
|
|
430
|
+
} catch (e) { sendJSON(res, 400, { error: String(e) }); }
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* API: media library listing */
|
|
435
|
+
if (p === "/api/media" && req.method === "GET") {
|
|
436
|
+
try {
|
|
437
|
+
const files = fs.readdirSync(MEDIA_DIR)
|
|
438
|
+
.filter((f) => fs.statSync(path.join(MEDIA_DIR, f)).isFile())
|
|
439
|
+
.map((f) => ({ name: f, src: "/media/" + encodeURIComponent(f), size: fs.statSync(path.join(MEDIA_DIR, f)).size }));
|
|
440
|
+
sendJSON(res, 200, files);
|
|
441
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* API: default-asset library listing (./library/{sfx,elements,svg,fonts}) */
|
|
446
|
+
if (p === "/api/library" && req.method === "GET") {
|
|
447
|
+
const dir = url.searchParams.get("dir");
|
|
448
|
+
if (!LIBRARY_SUBDIRS.includes(dir)) { sendJSON(res, 400, { error: "dir must be one of " + LIBRARY_SUBDIRS.join("|") }); return; }
|
|
449
|
+
try {
|
|
450
|
+
const base = path.join(LIBRARY_DIR, dir);
|
|
451
|
+
const out = [];
|
|
452
|
+
const walk = (d, rel) => {
|
|
453
|
+
for (const f of fs.readdirSync(d)) {
|
|
454
|
+
const full = path.join(d, f), r = rel ? rel + "/" + f : f;
|
|
455
|
+
const st = fs.statSync(full);
|
|
456
|
+
if (st.isDirectory()) walk(full, r);
|
|
457
|
+
else out.push({
|
|
458
|
+
name: f, rel: r, size: st.size,
|
|
459
|
+
src: "/library/" + dir + "/" + r.split("/").map(encodeURIComponent).join("/"),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
walk(base, "");
|
|
464
|
+
sendJSON(res, 200, out);
|
|
465
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* API: upload → saved into ./media */
|
|
470
|
+
if (p === "/api/upload" && req.method === "POST") {
|
|
471
|
+
try {
|
|
472
|
+
let name = safeName(url.searchParams.get("name") || "upload.bin");
|
|
473
|
+
let target = path.join(MEDIA_DIR, name);
|
|
474
|
+
let i = 1;
|
|
475
|
+
const ext = path.extname(name), base = path.basename(name, ext);
|
|
476
|
+
while (fs.existsSync(target)) target = path.join(MEDIA_DIR, `${base}_${i++}${ext}`);
|
|
477
|
+
const body = await readBody(req);
|
|
478
|
+
fs.writeFileSync(target, body);
|
|
479
|
+
await faststart(target);
|
|
480
|
+
sendJSON(res, 200, { ok: true, src: "/media/" + encodeURIComponent(path.basename(target)) });
|
|
481
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* API: download an HTTPS URL into ./media (same-origin src after import).
|
|
486
|
+
Body {url}. Rejects http/file/local/private targets. Does not register
|
|
487
|
+
project media — the client / MCP tool does that, matching /api/upload. */
|
|
488
|
+
if (p === "/api/import-url" && req.method === "POST") {
|
|
489
|
+
try {
|
|
490
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
491
|
+
const ac = new AbortController();
|
|
492
|
+
// IncomingMessage "close" also fires when the request body finishes, which
|
|
493
|
+
// would abort a successful download. ServerResponse "close" with
|
|
494
|
+
// writableEnded still false means the client dropped the connection.
|
|
495
|
+
res.on("close", () => { if (!res.writableEnded) ac.abort(); });
|
|
496
|
+
const { target, name } = await downloadImportUrl(opts.url, MEDIA_DIR, {
|
|
497
|
+
signal: ac.signal,
|
|
498
|
+
// test/rest-api.test.js: HTTP to 127.0.0.1 only (loopback fixture).
|
|
499
|
+
// Does not disable SSRF for LAN / metadata / other private ranges.
|
|
500
|
+
allowLoopback: process.env.FABLECUT_TEST_IMPORT_ALLOW_PRIVATE === "1",
|
|
501
|
+
});
|
|
502
|
+
await faststart(target);
|
|
503
|
+
sendJSON(res, 200, { ok: true, src: "/media/" + encodeURIComponent(name), name });
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e && e.code === "ABORT_ERR") { try { res.end(); } catch {} return; }
|
|
506
|
+
const msg = e && e.message ? e.message : String(e);
|
|
507
|
+
const code = /must be https|invalid URL|blocked:|credentials|unsupported|too large|did not return/i.test(msg) ? 400 : 502;
|
|
508
|
+
sendJSON(res, code, { error: msg });
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* API: agent-requested export jobs (see export-jobs.js).
|
|
514
|
+
POST /api/export/request {range?, profile?, where?} → job; GET /api/export/job?id=
|
|
515
|
+
polls it; the editor tab claims, reports and finishes it. */
|
|
516
|
+
if (p === "/api/export/request" && req.method === "POST") {
|
|
517
|
+
try {
|
|
518
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
519
|
+
if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH — export needs it" }); return; }
|
|
520
|
+
if (opts.profile) resolveProfile(String(opts.profile));
|
|
521
|
+
let revision = null;
|
|
522
|
+
try { revision = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^\uFEFF/, "")).revision ?? null; } catch {}
|
|
523
|
+
sendJSON(res, 200, exportJobs.request({ range: opts.range, profile: opts.profile, where: opts.where, revision }));
|
|
524
|
+
} catch (e) {
|
|
525
|
+
const code = e.code === 409 || e.code === 400 ? e.code : /^Unknown encoding profile/.test(e.message || "") ? 400 : 500;
|
|
526
|
+
sendJSON(res, code, { error: String(e.message || e) });
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
/* API: measure the graded program frame (fablecut_scopes) — a "scopes"
|
|
531
|
+
job on the export-job queue, polled with GET /api/export/job?id=. */
|
|
532
|
+
if (p === "/api/scopes/request" && req.method === "POST") {
|
|
533
|
+
try {
|
|
534
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
535
|
+
let revision = null;
|
|
536
|
+
try { revision = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^/, "")).revision ?? null; } catch {}
|
|
537
|
+
const ok = typeof opts.time === "number" || (typeof opts.time === "string" && opts.time.trim() !== "" && Number.isFinite(Number(opts.time)));
|
|
538
|
+
if (opts.time != null && !ok) { sendJSON(res, 400, { error: "time must be seconds ≥ 0" }); return; }
|
|
539
|
+
const time = opts.time == null ? null : Number(opts.time);
|
|
540
|
+
const m = opts.matte;
|
|
541
|
+
if (m != null && (typeof m !== "object" || typeof m.clip !== "string" || !Number.isInteger(m.layer) || m.layer < 0)) {
|
|
542
|
+
sendJSON(res, 400, { error: "matte must be {clip: \"<clip id>\", layer: <index ≥ 0>}" }); return;
|
|
543
|
+
}
|
|
544
|
+
sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null }));
|
|
545
|
+
} catch (e) {
|
|
546
|
+
sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (p.startsWith("/api/export/job")) {
|
|
551
|
+
const id = url.searchParams.get("id") || "";
|
|
552
|
+
const action = p.slice("/api/export/job".length);
|
|
553
|
+
let got = false;
|
|
554
|
+
try {
|
|
555
|
+
got = action === "" && req.method === "GET" ? exportJobs.get(id)
|
|
556
|
+
: action === "/ticket" && req.method === "GET" ? exportJobs.ticket(id)
|
|
557
|
+
: action === "/claim" && req.method === "POST" ? (exportJobs.claim(id) ? { ok: true } : undefined)
|
|
558
|
+
: action === "/report" && req.method === "POST" ? exportJobs.report(id, JSON.parse((await readBody(req)).toString("utf8") || "{}"))
|
|
559
|
+
: action === "/cancel" && req.method === "POST" ? exportJobs.cancel(id)
|
|
560
|
+
: false;
|
|
561
|
+
} catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); return; }
|
|
562
|
+
if (got === false) { sendJSON(res, 404, { error: "unknown export job endpoint" }); return; }
|
|
563
|
+
if (got === undefined) { sendJSON(res, 409, { error: "job is not pending — another tab took it, or it ended" }); return; }
|
|
564
|
+
if (got === null) { sendJSON(res, 404, { error: "no such export job" }); return; }
|
|
565
|
+
sendJSON(res, 200, got);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* API: fast export (browser-rendered frames → ffmpeg encode) */
|
|
570
|
+
if (p === "/api/export/ffmpeg" && req.method === "GET") {
|
|
571
|
+
sendJSON(res, 200, { available: HAS_FFMPEG });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (p === "/api/export/profiles" && req.method === "GET") {
|
|
575
|
+
try {
|
|
576
|
+
const detail = url.searchParams.get("detail") === "1";
|
|
577
|
+
sendJSON(res, 200, listProfilesPublic(detail));
|
|
578
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (p === "/api/export/begin" && req.method === "POST") {
|
|
582
|
+
try {
|
|
583
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
584
|
+
const mode = opts.mode === "annexb" ? "annexb" : "jpeg";
|
|
585
|
+
if (mode !== "annexb" && opts.profile) resolveProfile(opts.profile); // 400, not 500, on a bad id — even without ffmpeg
|
|
586
|
+
if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH" }); return; }
|
|
587
|
+
sendJSON(res, 200, await beginExport(opts.fps, opts.name, opts.profile, opts.hasAudio !== false, mode, opts));
|
|
588
|
+
} catch (e) {
|
|
589
|
+
// an unusable profile is the caller's problem, not a server fault
|
|
590
|
+
const bad = /^Unknown encoding profile|was rejected by ffmpeg|export fps required|export width\/height required/.test(e.message || "");
|
|
591
|
+
sendJSON(res, bad ? 400 : 500, { error: String(e.message || e) });
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (p === "/api/export/frame" && req.method === "POST") {
|
|
596
|
+
const id = url.searchParams.get("id");
|
|
597
|
+
const sess = exportSessions.get(id);
|
|
598
|
+
if (!sess) { sendJSON(res, 404, { error: "no such export session" }); return; }
|
|
599
|
+
try {
|
|
600
|
+
const body = await readBody(req);
|
|
601
|
+
if (!body || !body.length) throw new Error("empty frame");
|
|
602
|
+
if (sess.hasAudio && !sess.wav) {
|
|
603
|
+
sendJSON(res, 409, { error: "audio mix has not been uploaded yet" });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// queue behind any in-flight write so concurrent POSTs cannot interleave stdin
|
|
607
|
+
const run = async () => {
|
|
608
|
+
if (!sess.proc) sess.mode === "annexb" ? startAnnexbEncoder(sess) : startEncoder(sess);
|
|
609
|
+
await writeExportFrame(sess, body);
|
|
610
|
+
};
|
|
611
|
+
const writeJob = sess.writeLock.then(run, run);
|
|
612
|
+
sess.writeLock = writeJob.catch(() => {}); // keep the chain alive after a failed write
|
|
613
|
+
await writeJob;
|
|
614
|
+
touchExport(sess);
|
|
615
|
+
sendJSON(res, 200, { ok: true });
|
|
616
|
+
} catch (e) {
|
|
617
|
+
cleanupExport(id);
|
|
618
|
+
sendJSON(res, 500, { error: String(e.message || e) });
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (p === "/api/export/audio" && req.method === "POST") {
|
|
623
|
+
const sess = exportSessions.get(url.searchParams.get("id"));
|
|
624
|
+
if (!sess) { sendJSON(res, 404, { error: "no such export session" }); return; }
|
|
625
|
+
try {
|
|
626
|
+
const wavPath = path.join(sess.dir, "audio.wav");
|
|
627
|
+
fs.writeFileSync(wavPath, await readBody(req));
|
|
628
|
+
sess.wav = wavPath;
|
|
629
|
+
touchExport(sess);
|
|
630
|
+
sendJSON(res, 200, { ok: true });
|
|
631
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (p === "/api/export/end" && req.method === "POST") {
|
|
635
|
+
const id = url.searchParams.get("id");
|
|
636
|
+
const sess = exportSessions.get(id);
|
|
637
|
+
if (!sess) { sendJSON(res, 404, { error: "no such export session" }); return; }
|
|
638
|
+
try {
|
|
639
|
+
if (url.searchParams.get("discard")) { cleanupExport(id); sendJSON(res, 200, { ok: true }); return; }
|
|
640
|
+
touchExport(sess); // keep alive through final mux
|
|
641
|
+
if (!sess.proc) throw new Error("no frames were uploaded");
|
|
642
|
+
sess.proc.stdin.end();
|
|
643
|
+
const code = await sess.done;
|
|
644
|
+
if (code !== 0) throw new Error("ffmpeg encode failed: " + sess.err());
|
|
645
|
+
const out = sess.outPath;
|
|
646
|
+
fs.renameSync(sess.partPath, out);
|
|
647
|
+
sess.partPath = null; // renamed — cleanup must not delete the finished file
|
|
648
|
+
cleanupExport(id);
|
|
649
|
+
sendJSON(res, 200, { ok: true, src: "/exports/" + encodeURIComponent(path.basename(out)) });
|
|
650
|
+
} catch (e) { cleanupExport(id); sendJSON(res, 500, { error: String(e) }); }
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* API: reference analysis → edit blueprint (shots, beats, BPM, energy, music).
|
|
655
|
+
POST body {src:"/media/ref.mp4", threshold?, music?} runs the analysis
|
|
656
|
+
(seconds to ~a minute — decode-bound); GET ?src= returns the cached result. */
|
|
657
|
+
if (p === "/api/analyze" && req.method === "GET") {
|
|
658
|
+
const src = decodeURIComponent(url.searchParams.get("src") || "");
|
|
659
|
+
const f = path.join(ANALYSIS_DIR, path.basename(src, path.extname(src)) + ".json");
|
|
660
|
+
if (!src || !fs.existsSync(f)) { sendJSON(res, 404, { error: "no cached analysis for that src — POST /api/analyze first" }); return; }
|
|
661
|
+
try { sendJSON(res, 200, JSON.parse(fs.readFileSync(f, "utf8"))); }
|
|
662
|
+
catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (p === "/api/analyze" && req.method === "POST") {
|
|
666
|
+
if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH" }); return; }
|
|
667
|
+
try {
|
|
668
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
669
|
+
const name = path.basename(decodeURIComponent(opts.src || ""));
|
|
670
|
+
const file = path.join(MEDIA_DIR, name);
|
|
671
|
+
if (!name || !fs.existsSync(file)) { sendJSON(res, 404, { error: "src must name an existing file under /media/" }); return; }
|
|
672
|
+
const bp = await analyze(file, {
|
|
673
|
+
threshold: opts.threshold,
|
|
674
|
+
music: opts.music !== false,
|
|
675
|
+
musicDir: MEDIA_DIR,
|
|
676
|
+
srcUrl: "/media/" + encodeURIComponent(name),
|
|
677
|
+
});
|
|
678
|
+
if (bp.music) bp.music.src = "/media/" + encodeURIComponent(bp.music.name);
|
|
679
|
+
fs.writeFileSync(path.join(ANALYSIS_DIR, path.basename(name, path.extname(name)) + ".json"),
|
|
680
|
+
JSON.stringify(bp, null, 2));
|
|
681
|
+
sendJSON(res, 200, bp);
|
|
682
|
+
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* API: noise reduction — POST {src:"/media/…"|"/library/…", amount:"light"|"medium"|"strong"}
|
|
687
|
+
writes a denoised FLAC of the whole file into media/ (reused if it exists)
|
|
688
|
+
and returns {src, name, duration, channels}. Registering it is the caller's job. */
|
|
689
|
+
if (p === "/api/denoise" && req.method === "POST") {
|
|
690
|
+
if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH — noise reduction needs it" }); return; }
|
|
691
|
+
try {
|
|
692
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
693
|
+
if (!DENOISE_AMOUNTS.includes(opts.amount)) { sendJSON(res, 400, { error: `amount must be one of ${DENOISE_AMOUNTS.join(", ")}` }); return; }
|
|
694
|
+
const src = decodeURIComponent(String(opts.src || "").split("?")[0]);
|
|
695
|
+
const [root, rel] = src.startsWith("/media/") ? [MEDIA_DIR, src.slice(7)]
|
|
696
|
+
: src.startsWith("/library/") ? [LIBRARY_DIR, src.slice(9)] : [null, null];
|
|
697
|
+
const file = root && path.normalize(path.join(root, rel));
|
|
698
|
+
if (!file || !file.startsWith(path.normalize(root + path.sep)) || !fs.existsSync(file)) {
|
|
699
|
+
sendJSON(res, 404, { error: "src must name an existing file under /media/ or /library/" });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const r = await denoiseFile(file, MEDIA_DIR, opts.amount);
|
|
703
|
+
sendJSON(res, 200, { src: "/media/" + encodeURIComponent(r.name), name: r.name, duration: r.duration, channels: r.channels, cached: r.cached });
|
|
704
|
+
} catch (e) { sendJSON(res, 500, { error: String(e.message || e) }); }
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/* API: SSE live-reload channel */
|
|
709
|
+
if (p === "/api/events") {
|
|
710
|
+
res.writeHead(200, {
|
|
711
|
+
"Content-Type": "text/event-stream", "Cache-Control": "no-store",
|
|
712
|
+
Connection: "keep-alive",
|
|
713
|
+
});
|
|
714
|
+
res.write("data: hello\n\n");
|
|
715
|
+
sseClients.add(res);
|
|
716
|
+
req.on("close", () => sseClients.delete(res));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/* Media files */
|
|
721
|
+
if (p.startsWith("/media/")) {
|
|
722
|
+
const file = path.join(MEDIA_DIR, path.basename(p));
|
|
723
|
+
serveFile(req, res, file);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* Finished exports */
|
|
728
|
+
if (p.startsWith("/exports/")) {
|
|
729
|
+
serveFile(req, res, path.join(EXPORTS_DIR, path.basename(p)));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* Library assets (supports subfolders) */
|
|
734
|
+
if (p.startsWith("/library/")) {
|
|
735
|
+
const file = path.normalize(path.join(LIBRARY_DIR, p.slice("/library/".length)));
|
|
736
|
+
if (!file.startsWith(LIBRARY_DIR + path.sep)) { res.writeHead(403); res.end(); return; }
|
|
737
|
+
serveFile(req, res, file);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/* Static app files */
|
|
742
|
+
let file = p === "/" ? "/index.html" : p;
|
|
743
|
+
file = path.normalize(path.join(ROOT, file));
|
|
744
|
+
if (!file.startsWith(ROOT + path.sep)) { res.writeHead(403); res.end(); return; }
|
|
745
|
+
serveFile(req, res, file);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
server.listen(PORT, HOST, () => {
|
|
749
|
+
console.log(`\n FableCut running → http://localhost:${PORT}\n`);
|
|
750
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(HOST))
|
|
751
|
+
console.log(` ⚠ WARNING: HOST=${HOST} exposes the editor (and its file APIs) to the network.\n`);
|
|
752
|
+
console.log(` project file : ${PROJECT_FILE}`);
|
|
753
|
+
console.log(` media folder : ${MEDIA_DIR}`);
|
|
754
|
+
console.log(` library : ${LIBRARY_DIR} (${LIBRARY_SUBDIRS.join(", ")})`);
|
|
755
|
+
if (DATA_DIR !== APP_DIR) console.log(` app files : ${APP_DIR}`);
|
|
756
|
+
console.log(` ffmpeg : ${HAS_FFMPEG ? "found (fast export + faststart remux on)" : "not found (real-time export only)"}`);
|
|
757
|
+
const enc = loadEncodeProfiles(true);
|
|
758
|
+
console.log(` encode prof. : ${PROFILES_FILE} (${Object.keys(enc.profiles).join(", ")} · default ${enc.default})`);
|
|
759
|
+
for (const issue of enc.issues || []) console.log(` ⚠ ${issue}`);
|
|
760
|
+
console.log("");
|
|
761
|
+
startExportSweep();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
let shuttingDown = false;
|
|
765
|
+
function shutdown() {
|
|
766
|
+
if (shuttingDown) return;
|
|
767
|
+
shuttingDown = true;
|
|
768
|
+
stopExportSweep();
|
|
769
|
+
for (const id of [...exportSessions.keys()]) cleanupExport(id);
|
|
770
|
+
exportJobs.shutdown();
|
|
771
|
+
server.close(() => process.exit(0));
|
|
772
|
+
setTimeout(() => process.exit(0), 2000).unref?.();
|
|
773
|
+
}
|
|
774
|
+
process.on("SIGINT", shutdown);
|
|
775
|
+
process.on("SIGTERM", shutdown);
|