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.
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/fx-worklet.js ADDED
@@ -0,0 +1,162 @@
1
+ /* FableCut audio-effect processors the Web Audio API has no node for.
2
+ Loaded into the live AudioContext and into each export's
3
+ OfflineAudioContext, so preview and export run the same code.
4
+
5
+ fablecut-limiter — brickwall peak limiter: 5 ms lookahead, stereo-linked.
6
+ The audio is delayed by the lookahead; the gain needed for every sample
7
+ in that window is known before the sample plays, so peaks never pass the
8
+ ceiling. Gain recovers with an exponential release.
9
+ fablecut-pitch — pitch shift (semitones), two-tap delay line (below).
10
+ fablecut-gate — noise gate: opens (attack) when the input peak rises over
11
+ the threshold, stays open for `hold`, then closes (release) down to
12
+ `range` dB. */
13
+
14
+ const dbToLin = (db) => Math.pow(10, db / 20);
15
+ const coef = (ms) => (ms <= 0 ? 0 : Math.exp(-1 / (sampleRate * ms / 1000)));
16
+
17
+ class FableCutLimiter extends AudioWorkletProcessor {
18
+ static get parameterDescriptors() {
19
+ return [
20
+ { name: "ceiling", defaultValue: -1, minValue: -24, maxValue: 0, automationRate: "k-rate" },
21
+ { name: "release", defaultValue: 80, minValue: 5, maxValue: 1000, automationRate: "k-rate" },
22
+ ];
23
+ }
24
+ constructor() {
25
+ super();
26
+ this.la = Math.max(1, Math.round(0.005 * sampleRate));
27
+ this.delay = []; // per-channel ring of delayed samples
28
+ this.need = new Float32Array(this.la).fill(1); // gain each sample in the window needs
29
+ this.pos = 0;
30
+ this.g = 1;
31
+ }
32
+ process(inputs, outputs, params) {
33
+ const inp = inputs[0], out = outputs[0];
34
+ if (!inp || !inp.length) return true;
35
+ const ceil = dbToLin(params.ceiling[0]);
36
+ const rel = coef(params.release[0]);
37
+ const nCh = Math.min(inp.length, out.length);
38
+ while (this.delay.length < nCh) this.delay.push(new Float32Array(this.la));
39
+ const n = inp[0].length;
40
+ for (let i = 0; i < n; i++) {
41
+ let pk = 0;
42
+ for (let c = 0; c < nCh; c++) { const a = Math.abs(inp[c][i]); if (a > pk) pk = a; }
43
+ this.need[this.pos] = pk > ceil ? ceil / pk : 1;
44
+ let lo = 1;
45
+ for (let k = 0; k < this.la; k++) if (this.need[k] < lo) lo = this.need[k];
46
+ // Fall instantly to what the window needs; recover smoothly.
47
+ this.g = lo < this.g ? lo : lo + (this.g - lo) * rel;
48
+ for (let c = 0; c < nCh; c++) {
49
+ const d = this.delay[c];
50
+ out[c][i] = d[this.pos] * this.g;
51
+ d[this.pos] = inp[c][i];
52
+ }
53
+ this.pos = (this.pos + 1) % this.la;
54
+ }
55
+ return true;
56
+ }
57
+ }
58
+
59
+ class FableCutGate extends AudioWorkletProcessor {
60
+ static get parameterDescriptors() {
61
+ const k = "k-rate";
62
+ return [
63
+ { name: "threshold", defaultValue: -50, minValue: -80, maxValue: -10, automationRate: k },
64
+ { name: "range", defaultValue: -40, minValue: -80, maxValue: 0, automationRate: k },
65
+ { name: "attack", defaultValue: 2, minValue: 0, maxValue: 50, automationRate: k },
66
+ { name: "hold", defaultValue: 80, minValue: 0, maxValue: 500, automationRate: k },
67
+ { name: "release", defaultValue: 150, minValue: 5, maxValue: 1000, automationRate: k },
68
+ ];
69
+ }
70
+ constructor() {
71
+ super();
72
+ this.env = 0; // peak follower
73
+ this.g = 1; // applied gain
74
+ this.holdLeft = 0; // samples the gate stays open after the signal drops
75
+ }
76
+ process(inputs, outputs, params) {
77
+ const inp = inputs[0], out = outputs[0];
78
+ if (!inp || !inp.length) return true;
79
+ const thr = dbToLin(params.threshold[0]);
80
+ const floor = dbToLin(params.range[0]);
81
+ const att = coef(params.attack[0]), rel = coef(params.release[0]);
82
+ const holdN = Math.round(sampleRate * params.hold[0] / 1000);
83
+ const envRel = coef(10);
84
+ const nCh = Math.min(inp.length, out.length);
85
+ const n = inp[0].length;
86
+ for (let i = 0; i < n; i++) {
87
+ let pk = 0;
88
+ for (let c = 0; c < nCh; c++) { const a = Math.abs(inp[c][i]); if (a > pk) pk = a; }
89
+ this.env = pk > this.env ? pk : this.env * envRel;
90
+ if (this.env >= thr) this.holdLeft = holdN;
91
+ else if (this.holdLeft > 0) this.holdLeft--;
92
+ const target = this.env >= thr || this.holdLeft > 0 ? 1 : floor;
93
+ const k = target > this.g ? att : rel;
94
+ this.g = target + (this.g - target) * k;
95
+ for (let c = 0; c < nCh; c++) out[c][i] = inp[c][i] * this.g;
96
+ }
97
+ return true;
98
+ }
99
+ }
100
+
101
+ /* fablecut-pitch — pitch shift without changing speed. A delay line read by
102
+ two taps half a window apart: each tap's delay sweeps through the window
103
+ at (1 − ratio) samples per sample, so it plays the input back faster or
104
+ slower, and a sin² crossfade hides the jump when a tap wraps (the two
105
+ weights always sum to 1). Adds about half a window (~30 ms) of latency. */
106
+ class FableCutPitch extends AudioWorkletProcessor {
107
+ static get parameterDescriptors() {
108
+ const k = "k-rate";
109
+ return [
110
+ { name: "semitones", defaultValue: 0, minValue: -12, maxValue: 12, automationRate: k },
111
+ { name: "mix", defaultValue: 1, minValue: 0, maxValue: 1, automationRate: k },
112
+ ];
113
+ }
114
+ constructor() {
115
+ super();
116
+ this.W = Math.max(64, Math.round(0.06 * sampleRate)); // window, samples
117
+ this.N = this.W * 2 + 4; // ring length
118
+ this.buf = [];
119
+ this.w = 0; // write index
120
+ this.d = 0; // tap A's delay, 0…W
121
+ }
122
+ read(b, delay) {
123
+ let p = this.w - delay;
124
+ while (p < 0) p += this.N;
125
+ const i = Math.floor(p), f = p - i;
126
+ return b[i % this.N] * (1 - f) + b[(i + 1) % this.N] * f;
127
+ }
128
+ process(inputs, outputs, params) {
129
+ const inp = inputs[0], out = outputs[0];
130
+ if (!inp || !inp.length) return true;
131
+ const semi = params.semitones[0], mix = params.mix[0];
132
+ const nCh = Math.min(inp.length, out.length);
133
+ while (this.buf.length < nCh) this.buf.push(new Float32Array(this.N));
134
+ const n = inp[0].length;
135
+ if (Math.abs(semi) < 0.01 || mix <= 0) { // exact pass-through, keep the line warm
136
+ for (let i = 0; i < n; i++) {
137
+ for (let c = 0; c < nCh; c++) { this.buf[c][this.w] = inp[c][i]; out[c][i] = inp[c][i]; }
138
+ this.w = (this.w + 1) % this.N;
139
+ }
140
+ return true;
141
+ }
142
+ const step = 1 - Math.pow(2, semi / 12), W = this.W, half = W / 2;
143
+ for (let i = 0; i < n; i++) {
144
+ for (let c = 0; c < nCh; c++) this.buf[c][this.w] = inp[c][i];
145
+ this.d += step;
146
+ if (this.d >= W) this.d -= W; else if (this.d < 0) this.d += W;
147
+ const d2 = this.d >= half ? this.d - half : this.d + half;
148
+ const s = Math.sin(Math.PI * this.d / W), g1 = s * s, g2 = 1 - g1;
149
+ for (let c = 0; c < nCh; c++) {
150
+ const b = this.buf[c];
151
+ const wet = g1 * this.read(b, this.d) + g2 * this.read(b, d2);
152
+ out[c][i] = inp[c][i] * (1 - mix) + wet * mix;
153
+ }
154
+ this.w = (this.w + 1) % this.N;
155
+ }
156
+ return true;
157
+ }
158
+ }
159
+
160
+ registerProcessor("fablecut-limiter", FableCutLimiter);
161
+ registerProcessor("fablecut-gate", FableCutGate);
162
+ registerProcessor("fablecut-pitch", FableCutPitch);
Binary file
package/import-url.js ADDED
@@ -0,0 +1,293 @@
1
+ /* Download a remote HTTPS file into ./media with SSRF guards.
2
+ Used by POST /api/import-url and fablecut_import_media. The stored src is
3
+ always a local /media/… path — the URL is not kept as the playback source.
4
+ Remote SVG is refused (same-origin image/svg+xml would be a script sink).
5
+ allowLoopback is a test-only carve-out for HTTP(S) to 127.0.0.1. */
6
+ "use strict";
7
+ const http = require("http");
8
+ const https = require("https");
9
+ const dns = require("dns");
10
+ const net = require("net");
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { spawnSync, execFile } = require("child_process");
14
+
15
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
16
+ const TIMEOUT_MS = 180_000;
17
+ const MAX_REDIRECTS = 5;
18
+
19
+ const KIND_BY_EXT = {
20
+ ".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video", ".m4v": "video", ".avi": "video",
21
+ ".mp3": "audio", ".wav": "audio", ".ogg": "audio", ".m4a": "audio", ".aac": "audio", ".flac": "audio",
22
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "svg",
23
+ };
24
+ const EXT_BY_MIME = {
25
+ "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov",
26
+ "video/x-matroska": ".mkv", "video/x-m4v": ".m4v",
27
+ "audio/mpeg": ".mp3", "audio/mp3": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav",
28
+ "audio/ogg": ".ogg", "audio/mp4": ".m4a", "audio/aac": ".aac", "audio/flac": ".flac",
29
+ "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp",
30
+ "image/svg+xml": ".svg",
31
+ };
32
+ const FASTSTART_EXT = new Set([".mp4", ".mov", ".m4v"]);
33
+
34
+ function safeName(name) {
35
+ return String(name).replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 120) || "file";
36
+ }
37
+ function kindFromName(name) {
38
+ return KIND_BY_EXT[path.extname(name).toLowerCase()] || null;
39
+ }
40
+ /** Create `name` under dir with exclusive `wx`. On EEXIST, try name_1, name_2, …
41
+ * so two concurrent imports cannot both pick the same free path. */
42
+ function openUniqueFile(dir, name) {
43
+ const ext = path.extname(name), base = path.basename(name, ext);
44
+ let i = 0;
45
+ for (;;) {
46
+ const file = i === 0 ? name : `${base}_${i}${ext}`;
47
+ i++;
48
+ const target = path.join(dir, file);
49
+ try { return { target, fd: fs.openSync(target, "wx") }; }
50
+ catch (e) { if (e.code === "EEXIST") continue; throw e; }
51
+ }
52
+ }
53
+
54
+ function isBlockedIp(ip) {
55
+ if (!ip) return true;
56
+ let addr = String(ip).toLowerCase();
57
+ if (addr.startsWith("::ffff:")) addr = addr.slice(7);
58
+ if (net.isIPv4(addr)) {
59
+ const p = addr.split(".").map(Number);
60
+ if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true;
61
+ if (p[0] === 169 && p[1] === 254) return true;
62
+ if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
63
+ if (p[0] === 192 && p[1] === 168) return true;
64
+ if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
65
+ if (p[0] >= 224) return true;
66
+ return false;
67
+ }
68
+ if (net.isIPv6(addr)) {
69
+ if (addr === "::1" || addr === "::") return true;
70
+ if (addr.startsWith("fe8") || addr.startsWith("fe9") ||
71
+ addr.startsWith("fea") || addr.startsWith("feb")) return true;
72
+ if (addr.startsWith("fc") || addr.startsWith("fd")) return true;
73
+ if (addr.startsWith("ff")) return true;
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ function isBlockedHostname(host) {
80
+ const h = String(host || "").replace(/^\[|\]$/g, "").toLowerCase();
81
+ if (!h) return true;
82
+ if (h === "localhost" || h.endsWith(".localhost") || h === "0.0.0.0") return true;
83
+ if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".lan")) return true;
84
+ if (h === "metadata.google.internal") return true;
85
+ if (net.isIP(h)) return isBlockedIp(h);
86
+ return false;
87
+ }
88
+
89
+ /* Test hook: HTTP(S) to 127.0.0.1 only. Does not open LAN, CGNAT, metadata, or
90
+ other loopback addresses (127.0.0.2, ::1, localhost). */
91
+ function isTestLoopback(host) {
92
+ return String(host || "").replace(/^\[|\]$/g, "").toLowerCase() === "127.0.0.1";
93
+ }
94
+
95
+ function parseImportUrl(raw, { allowLoopback = false } = {}) {
96
+ let u;
97
+ try { u = new URL(String(raw || "").trim()); } catch { throw new Error("invalid URL"); }
98
+ const loopback = allowLoopback && isTestLoopback(u.hostname);
99
+ if (loopback) {
100
+ if (u.protocol !== "https:" && u.protocol !== "http:")
101
+ throw new Error("URL must be http(s)");
102
+ } else if (u.protocol !== "https:") {
103
+ throw new Error("URL must be https");
104
+ }
105
+ if (u.username || u.password) throw new Error("URL must not include credentials");
106
+ if (!u.hostname) throw new Error("invalid URL");
107
+ if (!loopback && isBlockedHostname(u.hostname))
108
+ throw new Error("blocked: local or private host");
109
+ return u;
110
+ }
111
+
112
+ async function assertPublicTarget(u, { allowLoopback = false } = {}) {
113
+ const host = u.hostname.replace(/^\[|\]$/g, "");
114
+ if (allowLoopback && isTestLoopback(host)) return;
115
+ if (isBlockedHostname(u.hostname)) throw new Error("blocked: local or private host");
116
+ if (net.isIP(host)) {
117
+ if (isBlockedIp(host)) throw new Error("blocked: local or private address");
118
+ return;
119
+ }
120
+ let addrs;
121
+ try { addrs = await dns.promises.lookup(host, { all: true, verbatim: true }); }
122
+ catch { throw new Error("could not resolve host"); }
123
+ if (!addrs.length) throw new Error("could not resolve host");
124
+ for (const a of addrs) {
125
+ if (isBlockedIp(a.address)) throw new Error("blocked: host resolves to a private address");
126
+ }
127
+ }
128
+
129
+ function filenameFrom(u, headers) {
130
+ const cd = headers["content-disposition"] || "";
131
+ const star = /filename\*\s*=\s*(?:UTF-8''|utf-8'')([^;]+)/i.exec(cd);
132
+ if (star) {
133
+ try { return safeName(path.basename(decodeURIComponent(star[1].trim().replace(/^"+|"+$/g, "")))); }
134
+ catch { /* fall through */ }
135
+ }
136
+ const quoted = /filename\s*=\s*"((?:\\.|[^"])+)"/i.exec(cd);
137
+ if (quoted) return safeName(path.basename(quoted[1].replace(/\\"/g, '"')));
138
+ const bare = /filename\s*=\s*([^;]+)/i.exec(cd);
139
+ if (bare) return safeName(path.basename(bare[1].trim().replace(/^"+|"+$/g, "")));
140
+
141
+ let base = "";
142
+ try { base = path.basename(decodeURIComponent(u.pathname)); } catch { base = path.basename(u.pathname); }
143
+ if (base === "/" || base === "." || base === "..") base = "";
144
+ const mime = (headers["content-type"] || "").split(";")[0].trim().toLowerCase();
145
+ const mimeExt = EXT_BY_MIME[mime] || "";
146
+ if (!base) base = "download" + mimeExt;
147
+ else if (!path.extname(base) && mimeExt) base += mimeExt;
148
+ return safeName(base);
149
+ }
150
+
151
+ function requestOnce(u, { signal, timeoutMs }) {
152
+ const lib = u.protocol === "https:" ? https : http;
153
+ const host = u.hostname.replace(/^\[|\]$/g, "");
154
+ const opts = {
155
+ protocol: u.protocol,
156
+ hostname: host,
157
+ port: u.port || undefined,
158
+ path: u.pathname + u.search,
159
+ method: "GET",
160
+ headers: { "User-Agent": "FableCut", Accept: "*/*" },
161
+ };
162
+ if (u.protocol === "https:" && !net.isIP(host)) opts.servername = host;
163
+ return new Promise((resolve, reject) => {
164
+ const req = lib.request(opts, resolve);
165
+ const fail = (err) => { try { req.destroy(); } catch {} reject(err); };
166
+ req.on("error", fail);
167
+ req.setTimeout(timeoutMs, () => fail(new Error("download timed out")));
168
+ if (signal) {
169
+ if (signal.aborted) return fail(Object.assign(new Error("aborted"), { code: "ABORT_ERR" }));
170
+ const onAbort = () => fail(Object.assign(new Error("aborted"), { code: "ABORT_ERR" }));
171
+ signal.addEventListener("abort", onAbort, { once: true });
172
+ req.on("close", () => signal.removeEventListener("abort", onAbort));
173
+ }
174
+ req.end();
175
+ });
176
+ }
177
+
178
+ function saveResponse(res, u, destDir, { signal, maxBytes }) {
179
+ const mime = (res.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
180
+ if (mime === "text/html" || mime === "application/json") {
181
+ res.resume();
182
+ return Promise.reject(new Error("URL did not return a media file"));
183
+ }
184
+ const name = filenameFrom(u, res.headers);
185
+ const kind = kindFromName(name);
186
+ // /media/*.svg is served same-origin as image/svg+xml. A remote file with
187
+ // <script> opened as a document would run on the editor origin.
188
+ if (kind === "svg" || mime === "image/svg+xml" || mime === "image/svg") {
189
+ res.resume();
190
+ return Promise.reject(new Error("unsupported media type (remote SVG is not imported)"));
191
+ }
192
+ if (!kind) {
193
+ res.resume();
194
+ return Promise.reject(new Error("unsupported media type (need a video, audio, or image URL)"));
195
+ }
196
+ const declared = parseInt(res.headers["content-length"], 10);
197
+ if (declared > maxBytes) {
198
+ res.resume();
199
+ return Promise.reject(new Error("file too large"));
200
+ }
201
+ fs.mkdirSync(destDir, { recursive: true });
202
+ let target, owned = false, out, fd;
203
+ try {
204
+ const opened = openUniqueFile(destDir, name);
205
+ target = opened.target;
206
+ fd = opened.fd;
207
+ owned = true;
208
+ out = fs.createWriteStream(target, { fd });
209
+ } catch (e) {
210
+ res.resume();
211
+ if (fd != null && !out) try { fs.closeSync(fd); } catch {}
212
+ if (owned) try { fs.rmSync(target, { force: true }); } catch {}
213
+ return Promise.reject(e);
214
+ }
215
+ return new Promise((resolve, reject) => {
216
+ let bytes = 0;
217
+ const cleanup = (err) => {
218
+ try { res.destroy(); } catch {}
219
+ try { out.destroy(); } catch {}
220
+ if (owned) try { fs.rmSync(target, { force: true }); } catch {}
221
+ reject(err);
222
+ };
223
+ if (signal) {
224
+ if (signal.aborted) return cleanup(Object.assign(new Error("aborted"), { code: "ABORT_ERR" }));
225
+ signal.addEventListener("abort", () => cleanup(Object.assign(new Error("aborted"), { code: "ABORT_ERR" })), { once: true });
226
+ }
227
+ res.on("data", (chunk) => {
228
+ bytes += chunk.length;
229
+ if (bytes > maxBytes) cleanup(new Error("file too large"));
230
+ });
231
+ res.on("error", cleanup);
232
+ out.on("error", cleanup);
233
+ res.pipe(out);
234
+ out.on("finish", () => {
235
+ owned = false; // complete file belongs to the caller now
236
+ resolve({ target, name: path.basename(target) });
237
+ });
238
+ });
239
+ }
240
+
241
+ async function downloadImportUrl(raw, destDir, opts = {}) {
242
+ const allowLoopback = !!opts.allowLoopback;
243
+ const maxBytes = opts.maxBytes || MAX_BYTES;
244
+ const timeoutMs = opts.timeoutMs || TIMEOUT_MS;
245
+ const signal = opts.signal;
246
+ let current = parseImportUrl(raw, { allowLoopback });
247
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
248
+ if (signal?.aborted) throw Object.assign(new Error("aborted"), { code: "ABORT_ERR" });
249
+ await assertPublicTarget(current, { allowLoopback });
250
+ const res = await requestOnce(current, { signal, timeoutMs });
251
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
252
+ res.resume();
253
+ let next;
254
+ try { next = new URL(res.headers.location, current); }
255
+ catch { throw new Error("invalid redirect"); }
256
+ current = parseImportUrl(next.href, { allowLoopback });
257
+ continue;
258
+ }
259
+ if (res.statusCode !== 200) {
260
+ res.resume();
261
+ throw new Error("download failed: HTTP " + res.statusCode);
262
+ }
263
+ return saveResponse(res, current, destDir, { signal, maxBytes });
264
+ }
265
+ throw new Error("too many redirects");
266
+ }
267
+
268
+ let hasFfmpeg = null;
269
+ function ffmpegAvailable() {
270
+ if (hasFfmpeg == null) {
271
+ try { hasFfmpeg = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; }
272
+ catch { hasFfmpeg = false; }
273
+ }
274
+ return hasFfmpeg;
275
+ }
276
+ function maybeFaststart(file) {
277
+ if (!ffmpegAvailable() || !FASTSTART_EXT.has(path.extname(file).toLowerCase()))
278
+ return Promise.resolve();
279
+ const tmp = file + ".fs" + path.extname(file);
280
+ return new Promise((resolve) => {
281
+ execFile("ffmpeg", ["-y", "-i", file, "-c", "copy", "-movflags", "+faststart", tmp],
282
+ { maxBuffer: 1 << 24 }, (err) => {
283
+ if (err) { try { fs.rmSync(tmp, { force: true }); } catch {} return resolve(); }
284
+ try { fs.rmSync(file); fs.renameSync(tmp, file); } catch { try { fs.rmSync(tmp, { force: true }); } catch {} }
285
+ resolve();
286
+ });
287
+ });
288
+ }
289
+
290
+ module.exports = {
291
+ downloadImportUrl, parseImportUrl, isBlockedIp, isBlockedHostname,
292
+ filenameFrom, kindFromName, KIND_BY_EXT, maybeFaststart, MAX_BYTES,
293
+ };