fablecut 1.13.0 → 1.15.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/proxy.js ADDED
@@ -0,0 +1,180 @@
1
+ /* Proxies: light copies of heavy footage (4K, HEVC, ProRes, long-GOP phone
2
+ video) that the editor plays while you cut. Export always reads the
3
+ original, so a proxy only ever changes how smoothly the preview runs.
4
+
5
+ A proxy is H.264 at 540p / 720p / 1080p (never larger than the source),
6
+ with a short GOP (a keyframe every 12 frames) so scrubbing seeks fast, the
7
+ source's own timestamps (no frames added or dropped) and its first audio
8
+ stream. It lives in analysis/proxies/ — a disposable cache, like the frame
9
+ grabs — named after the source file *and its size and mtime*: when the
10
+ original changes, its old proxy simply stops matching.
11
+
12
+ createProxies({dir, resolveSrc, probe, broadcast, ffmpeg?}) → { (probe: async, media-probe.js)
13
+ status(src) → Promise of {state:"ready", src:"/proxies/…", height, width?, size}
14
+ | {state:"queued"|"running", progress 0…1, height}
15
+ | {state:"failed", error} | {state:"none"} | null (no such file)
16
+ start(src, height?) → Promise of a status (queued, or ready when one exists at that height)
17
+ remove(src) → number of proxy files deleted (a running job is stopped)
18
+ file(name) → the path of a proxy file to serve, or null
19
+ }
20
+ Jobs run one at a time; each broadcasts SSE event "proxy" ({src, status})
21
+ as it starts, every ~0.5 s of progress, and when it ends. */
22
+ "use strict";
23
+ const crypto = require("crypto");
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const { spawn } = require("child_process");
27
+
28
+ const HEIGHTS = [540, 720, 1080];
29
+ const DEFAULT_HEIGHT = 540;
30
+
31
+ /** "<stem>-<hash of path, size and mtime>" — every proxy of this version of the file starts with it. */
32
+ function baseName(file) {
33
+ const st = fs.statSync(file);
34
+ const h = crypto.createHash("sha1").update(path.resolve(file) + "|" + st.size + "|" + Math.round(st.mtimeMs)).digest("hex").slice(0, 10);
35
+ const stem = path.basename(file, path.extname(file)).replace(/[^\w\-]+/g, "_").slice(0, 40) || "media";
36
+ return `${stem}-${h}`;
37
+ }
38
+ /** Existing proxies of this version of the file in dir, best (largest) first: [{name, height}]. */
39
+ function findProxies(dir, file) {
40
+ let base;
41
+ try { base = baseName(file) + "-"; } catch { return []; }
42
+ let names = [];
43
+ try { names = fs.readdirSync(dir); } catch { }
44
+ return names.map((n) => { const m = n.startsWith(base) && /-(\d+)p\.mp4$/.exec(n); return m ? { name: n, height: +m[1] } : null; })
45
+ .filter(Boolean).sort((a, b) => b.height - a.height);
46
+ }
47
+
48
+ function createProxies({ dir, resolveSrc, probe, broadcast = () => { }, ffmpeg = "ffmpeg" }) {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ const jobs = new Map(); // src → job {src, file, out, height, state, progress, error, proc}
51
+ const queue = [];
52
+ let running = null;
53
+ let fpsModeFlag = ["-fps_mode", "passthrough"]; // ffmpeg ≥ 5.1; older builds get -vsync 0
54
+ const existing = (file) => findProxies(dir, file);
55
+ async function readyStatus(e) {
56
+ const full = path.join(dir, e.name);
57
+ let size = 0; try { size = fs.statSync(full).size; } catch { }
58
+ const info = probe ? await probe(full) : null;
59
+ return { state: "ready", src: "/proxies/" + encodeURIComponent(e.name), height: (info && info.height) || e.height, width: info && info.width, size };
60
+ }
61
+ function jobStatus(j) {
62
+ if (j.state === "failed") return { state: "failed", error: j.error, height: j.height };
63
+ return { state: j.state, progress: Math.round((j.progress || 0) * 1000) / 1000, height: j.height };
64
+ }
65
+ async function status(src) {
66
+ const file = resolveSrc(src);
67
+ if (!file) return null;
68
+ const j = jobs.get(src);
69
+ if (j && j.file === file && (j.state === "queued" || j.state === "running" || j.state === "failed")) return jobStatus(j);
70
+ const e = existing(file)[0];
71
+ return e ? readyStatus(e) : { state: "none" };
72
+ }
73
+ async function tell(j) {
74
+ try { broadcast("proxy", JSON.stringify({ src: j.src, status: j.state === "done" ? await status(j.src) : jobStatus(j) })); } catch { }
75
+ }
76
+
77
+ async function start(src, height = DEFAULT_HEIGHT) {
78
+ const file = resolveSrc(src);
79
+ if (!file) return null;
80
+ height = HEIGHTS.includes(+height) ? +height : DEFAULT_HEIGHT;
81
+ const have = existing(file).find((e) => e.height === height);
82
+ if (have) return readyStatus(have);
83
+ const cur = jobs.get(src);
84
+ if (cur && (cur.state === "queued" || cur.state === "running") && cur.height === height) return jobStatus(cur);
85
+ if (cur && (cur.state === "queued" || cur.state === "running")) cancel(src);
86
+ const j = { src, file, height, out: path.join(dir, `${baseName(file)}-${height}p.mp4`), state: "queued", progress: 0 };
87
+ jobs.set(src, j);
88
+ queue.push(j);
89
+ tell(j);
90
+ pump();
91
+ return jobStatus(j);
92
+ }
93
+
94
+ function args(j, part, fpsMode) {
95
+ return ["-y", "-hide_banner", "-v", "error", "-nostdin", "-i", j.file,
96
+ "-map", "0:v:0", "-map", "0:a:0?",
97
+ "-vf", `scale=-2:'min(ih,${j.height})':flags=bicubic`,
98
+ ...fpsMode,
99
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
100
+ "-g", "12", "-keyint_min", "12", "-sc_threshold", "0",
101
+ "-c:a", "aac", "-b:a", "160k",
102
+ "-movflags", "+faststart", "-map_metadata", "-1",
103
+ "-progress", "pipe:1", "-nostats", "-f", "mp4", part];
104
+ }
105
+ async function pump() {
106
+ if (running || !queue.length) return;
107
+ const j = queue.shift();
108
+ if (j.state !== "queued") return pump();
109
+ running = j;
110
+ j.state = "running";
111
+ const info = probe ? await probe(j.file) : null;
112
+ // cancelled (deleted, or asked again at another size) while the probe ran
113
+ if (j.state !== "running") { finish(); return; }
114
+ const dur = (info && info.duration) || 0;
115
+ const part = j.out + ".part";
116
+ let err = "", last = 0;
117
+ const run = (fpsMode, retried) => {
118
+ const proc = j.proc = spawn(ffmpeg, args(j, part, fpsMode), { windowsHide: true });
119
+ proc.stdout.on("data", (d) => {
120
+ const m = /out_time_(?:us|ms)=(\d+)/.exec(String(d).split("\n").reverse().join("\n"));
121
+ if (m && dur > 0) {
122
+ j.progress = Math.min(0.99, +m[1] / 1e6 / dur);
123
+ const now = Date.now();
124
+ if (now - last > 500) { last = now; tell(j); }
125
+ }
126
+ });
127
+ proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
128
+ proc.on("error", (e) => { err = String(e.message || e); });
129
+ proc.on("close", (code) => {
130
+ j.proc = null;
131
+ if (j.state === "cancelled") { try { fs.unlinkSync(part); } catch { } finish(); return; }
132
+ if (code !== 0 && !retried && /fps_mode|Unrecognized option/i.test(err)) {
133
+ fpsModeFlag = ["-vsync", "0"]; err = "";
134
+ return run(fpsModeFlag, true);
135
+ }
136
+ if (code === 0) {
137
+ try { fs.renameSync(part, j.out); j.state = "done"; j.progress = 1; }
138
+ catch (e) { j.state = "failed"; j.error = String(e.message || e); }
139
+ } else {
140
+ try { fs.unlinkSync(part); } catch { }
141
+ j.state = "failed";
142
+ j.error = (err.trim().split("\n").pop() || `ffmpeg exited with ${code}`).slice(0, 300);
143
+ }
144
+ tell(j);
145
+ if (j.state === "done") jobs.delete(j.src);
146
+ finish();
147
+ });
148
+ };
149
+ tell(j);
150
+ run(fpsModeFlag, false);
151
+ }
152
+ function finish() { running = null; pump(); }
153
+
154
+ function cancel(src) {
155
+ const j = jobs.get(src);
156
+ if (!j) return;
157
+ if (j.state === "queued") { j.state = "cancelled"; jobs.delete(src); }
158
+ else if (j.state === "running") { j.state = "cancelled"; try { j.proc && j.proc.kill(); } catch { } jobs.delete(src); }
159
+ else jobs.delete(src);
160
+ }
161
+ function remove(src) {
162
+ cancel(src);
163
+ const file = resolveSrc(src);
164
+ if (!file) return 0;
165
+ let n = 0;
166
+ for (const e of existing(file)) { try { fs.unlinkSync(path.join(dir, e.name)); n++; } catch { } }
167
+ broadcast("proxy", JSON.stringify({ src, status: { state: "none" } }));
168
+ return n;
169
+ }
170
+ /** A proxy file to serve, by its name — only straight names inside dir. */
171
+ function file(name) {
172
+ if (!name || /[\\/]/.test(name) || name.startsWith(".") || !/\.mp4$/i.test(name)) return null;
173
+ const full = path.join(dir, name);
174
+ try { return fs.statSync(full).isFile() ? full : null; } catch { return null; }
175
+ }
176
+
177
+ return { status, start, remove, cancel, file, HEIGHTS, DEFAULT_HEIGHT };
178
+ }
179
+
180
+ module.exports = { createProxies, findProxies, HEIGHTS, DEFAULT_HEIGHT };