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/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
|
+
};
|