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
@@ -0,0 +1,302 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ Encoding profiles — user-editable ffmpeg settings for Fast export.
3
+
4
+ A profile is a raw ffmpeg argument list plus the things that are NOT ffmpeg
5
+ arguments: jpegQuality, extension, and optional `color` (output matrix/range).
6
+ There is deliberately no allow-list — encoding-profiles.json is a local file
7
+ the user owns, the browser only ever sends a profile *id*, and args are
8
+ passed to spawn() as an array (no shell), so validating codec names would buy
9
+ nothing but a smaller set of usable formats. Typos are caught by dryRunProfile
10
+ against the real ffmpeg build instead, which also knows which encoders it has.
11
+
12
+ Export is ONE ffmpeg pass; this module owns the input side (JPEG image2pipe
13
+ by default, or canvas RGBA when pixelFormat is "rgba") plus color conversion
14
+ and tags from profile.color; the profile owns everything in between:
15
+
16
+ ffmpeg -y -thread_queue_size 64 -f image2pipe -framerate <fps>
17
+ -c:v mjpeg -i - [-i audio.wav]
18
+ <jpeg-color vf+tags> <profile args…> <out><extension>
19
+ `pixelFormat: "rgba"` switches stdin to `-f rawvideo -pix_fmt rgba`.
20
+ ═══════════════════════════════════════════════════════════════════════════ */
21
+ "use strict";
22
+ const fs = require("fs");
23
+ const os = require("os");
24
+ const path = require("path");
25
+ const { spawn } = require("child_process");
26
+
27
+ const PROFILES_FILE = path.join(__dirname, "encoding-profiles.json");
28
+
29
+ /* Used when encoding-profiles.json is missing or unparseable, so export still
30
+ works out of the box. Not a merge base: profiles are taken as written. */
31
+ const BUILTIN_ID = "delivery";
32
+ const DEFAULT_COLOR = {
33
+ matrix: "bt709",
34
+ primaries: "bt709",
35
+ trc: "bt709",
36
+ range: "tv", // tv = limited, pc = full
37
+ };
38
+ const BUILTIN = {
39
+ label: "Delivery · H.264 balanced",
40
+ description: "Built-in fallback — good quality and compatibility.",
41
+ jpegQuality: 0.95,
42
+ extension: ".mp4",
43
+ color: { ...DEFAULT_COLOR },
44
+ args: ["-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p",
45
+ "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-shortest"],
46
+ };
47
+
48
+ /* Flags the engine injects from profile.color — strip from user args so the
49
+ JPEG vf out_* and the stream tags cannot disagree. */
50
+ const COLOR_ARG_FLAGS = new Set([
51
+ "-colorspace", "-color_primaries", "-color_trc", "-color_range",
52
+ ]);
53
+
54
+ let cache = null;
55
+ let cacheMtime = -1;
56
+
57
+ function normalizeColor(raw) {
58
+ const c = raw && typeof raw === "object" ? raw : {};
59
+ const token = (v, fallback) => {
60
+ const s = String(v == null ? "" : v).trim().toLowerCase();
61
+ return /^[a-z0-9._-]+$/.test(s) ? s : fallback;
62
+ };
63
+ let range = token(c.range, DEFAULT_COLOR.range);
64
+ if (range === "full") range = "pc";
65
+ if (range === "limited") range = "tv";
66
+ if (range !== "tv" && range !== "pc") range = DEFAULT_COLOR.range;
67
+ const matrix = token(c.matrix ?? c.colorspace, DEFAULT_COLOR.matrix);
68
+ return {
69
+ matrix,
70
+ primaries: token(c.primaries, matrix),
71
+ trc: token(c.trc ?? c.transfer, matrix),
72
+ range,
73
+ };
74
+ }
75
+
76
+ function stripColorArgs(args) {
77
+ const out = [];
78
+ for (let i = 0; i < args.length; i++) {
79
+ if (COLOR_ARG_FLAGS.has(args[i])) { i++; continue; } // skip flag + value
80
+ out.push(args[i]);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function normalizeProfile(id, raw) {
86
+ const p = raw && typeof raw === "object" ? raw : {};
87
+ // a string is split on whitespace as a convenience; the array form is
88
+ // canonical because it needs no quoting (e.g. -vf drawtext=text='hi')
89
+ const args = Array.isArray(p.args)
90
+ ? p.args.map(String)
91
+ : String(p.args || "").split(/\s+/).filter(Boolean);
92
+ let ext = String(p.extension || BUILTIN.extension).trim().toLowerCase();
93
+ if (ext && !ext.startsWith(".")) ext = `.${ext}`;
94
+ // one suffix only (".mp4") — reject paths, extra dots, empty, or odd chars
95
+ if (!/^\.[a-z0-9]+$/.test(ext)) ext = BUILTIN.extension;
96
+ const q = Number(p.jpegQuality);
97
+ return {
98
+ id,
99
+ label: String(p.label || id),
100
+ description: String(p.description || p.desc || ""),
101
+ jpegQuality: q >= 0.1 && q <= 1 ? q : BUILTIN.jpegQuality,
102
+ extension: ext,
103
+ color: normalizeColor(p.color),
104
+ args,
105
+ };
106
+ }
107
+
108
+ function profilesFileMtime() {
109
+ try { return fs.statSync(PROFILES_FILE).mtimeMs; } catch { return 0; }
110
+ }
111
+
112
+ function loadEncodeProfiles(force) {
113
+ /* mtime check instead of a plain memo: the MCP server is long-lived and has
114
+ no file watcher, so a cache-only read would serve stale profiles forever */
115
+ const mtime = profilesFileMtime();
116
+ if (cache && !force && mtime === cacheMtime) return cache;
117
+
118
+ let file = null;
119
+ const issues = [];
120
+ try {
121
+ if (fs.existsSync(PROFILES_FILE))
122
+ file = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf8").replace(/^\uFEFF/, ""));
123
+ else issues.push(`${path.basename(PROFILES_FILE)} not found — using the built-in profile`);
124
+ } catch (e) {
125
+ issues.push(`${path.basename(PROFILES_FILE)} could not be parsed (${e.message}) — using the built-in profile`);
126
+ }
127
+
128
+ const profiles = {};
129
+ if (file?.profiles && typeof file.profiles === "object")
130
+ for (const [id, raw] of Object.entries(file.profiles)) profiles[id] = normalizeProfile(id, raw);
131
+ for (const [id, p] of Object.entries(profiles))
132
+ if (!p.args.length) issues.push(`profile "${id}" has no args — ffmpeg will pick its own defaults`);
133
+ if (!Object.keys(profiles).length) profiles[BUILTIN_ID] = normalizeProfile(BUILTIN_ID, BUILTIN);
134
+
135
+ let defaultId = typeof file?.default === "string" ? file.default : BUILTIN_ID;
136
+ if (!profiles[defaultId]) {
137
+ const first = Object.keys(profiles)[0];
138
+ if (file?.default) issues.push(`default "${file.default}" is not a defined profile — using ${first}`);
139
+ defaultId = first;
140
+ }
141
+
142
+ cache = { default: defaultId, profiles, issues };
143
+ cacheMtime = mtime;
144
+ return cache;
145
+ }
146
+
147
+ function invalidateEncodeProfiles() {
148
+ cache = null;
149
+ cacheMtime = -1;
150
+ }
151
+
152
+ function resolveProfile(id) {
153
+ const cfg = loadEncodeProfiles();
154
+ const pid = id || cfg.default;
155
+ const p = cfg.profiles[pid];
156
+ if (!p) throw new Error(`Unknown encoding profile "${pid}"`);
157
+ return p;
158
+ }
159
+
160
+ function profileSummary(p, max = 120) {
161
+ const s = (p.args || []).join(" ") || "(no args — ffmpeg defaults)";
162
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
163
+ }
164
+
165
+ function listProfilesPublic(detail) {
166
+ const cfg = loadEncodeProfiles();
167
+ const out = {
168
+ default: cfg.default, profiles: {},
169
+ file: path.basename(PROFILES_FILE),
170
+ issues: cfg.issues || [],
171
+ };
172
+ for (const [id, p] of Object.entries(cfg.profiles)) {
173
+ // jpegQuality + color are needed by the UI / agents — always included
174
+ const base = {
175
+ label: p.label,
176
+ description: p.description,
177
+ jpegQuality: p.jpegQuality,
178
+ extension: p.extension,
179
+ color: p.color,
180
+ summary: profileSummary(p),
181
+ };
182
+ out.profiles[id] = detail ? { ...base, args: p.args } : base;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /* Canvas RGBA is full-range BT.709-ish (sRGB). JPEG/JFIF is full-range BT.601.
188
+ Convert to the profile's output matrix/range and tag the stream, otherwise
189
+ x264 emits bt470bg/pc/unknown and players do the wrong YUV→RGB conversion.
190
+ Independent of -pix_fmt (420 vs 422/10-bit). */
191
+ function inputColorVf(color, inMatrix) {
192
+ const c = color || DEFAULT_COLOR;
193
+ const matrix = inMatrix === "bt601" ? "bt601" : "bt709";
194
+ // Primaries / transfer are stamped on the frames too: frame colour
195
+ // properties win over the -color_* flags added below, so without this the
196
+ // file is tagged primaries/transfer "unknown". setparams (not scale's
197
+ // out_primaries / out_transfer, which need FFmpeg 7.1+) works on older builds.
198
+ return `scale=in_range=full:in_color_matrix=${matrix}:out_range=${c.range}:out_color_matrix=${c.matrix},` +
199
+ `setparams=color_primaries=${c.primaries}:color_trc=${c.trc}`;
200
+ }
201
+ function jpegColorTags(color) {
202
+ const c = color || DEFAULT_COLOR;
203
+ return [
204
+ "-colorspace", c.matrix,
205
+ "-color_primaries", c.primaries,
206
+ "-color_trc", c.trc,
207
+ "-color_range", c.range,
208
+ ];
209
+ }
210
+
211
+ function withInputColor(profile, inMatrix) {
212
+ const color = profile.color || DEFAULT_COLOR;
213
+ const vf = inputColorVf(color, inMatrix);
214
+ const args = stripColorArgs((profile.args || []).slice());
215
+ const vfAt = args.indexOf("-vf");
216
+ if (vfAt >= 0 && args[vfAt + 1] != null) args[vfAt + 1] = `${vf},${args[vfAt + 1]}`;
217
+ else args.unshift("-vf", vf);
218
+ args.push(...jpegColorTags(color));
219
+ return args;
220
+ }
221
+
222
+ /* The single export pass. Fast export sends JPEG image2pipe by default;
223
+ `pixelFormat: "rgba"` is uncompressed canvas frames. Audio mix is on disk
224
+ before spawn. */
225
+ function buildExportArgs(profile, { fps, wavPath, outPath, pixelFormat, width, height } = {}) {
226
+ const args = ["-y", "-hide_banner"];
227
+ const raw = pixelFormat === "rgba";
228
+ if (raw) {
229
+ const w = Math.max(2, width | 0), h = Math.max(2, height | 0);
230
+ // Small queue: each raw frame is width×height×4 bytes (1080p ≈ 8 MiB).
231
+ args.push(
232
+ "-thread_queue_size", "8",
233
+ "-f", "rawvideo", "-pix_fmt", "rgba",
234
+ "-s", `${w}x${h}`, "-framerate", String(fps), "-i", "-",
235
+ );
236
+ } else {
237
+ args.push(
238
+ "-thread_queue_size", "64",
239
+ "-f", "image2pipe", "-framerate", String(fps), "-c:v", "mjpeg", "-i", "-",
240
+ );
241
+ }
242
+ if (wavPath) args.push("-i", wavPath);
243
+ args.push(...withInputColor(profile, raw ? "bt709" : "bt601"), outPath);
244
+ return args;
245
+ }
246
+
247
+ const DRY_RUN_TIMEOUT_MS = 15_000;
248
+
249
+ /* Run the profile's args once against a synthetic input before the browser
250
+ renders anything. Without this a typo (or an encoder this ffmpeg build lacks)
251
+ would only surface when ffmpeg exits — i.e. after every frame was rendered.
252
+ Async spawn so /api/export/begin does not block the event loop. */
253
+ function dryRunProfile(profile, { fps = 30, hasAudio = true } = {}) {
254
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-dry-"));
255
+ const out = path.join(dir, `probe${profile.extension}`);
256
+ const args = ["-y", "-hide_banner", "-f", "lavfi",
257
+ "-i", `color=c=black:s=64x64:r=${fps}:d=0.1`];
258
+ if (hasAudio) args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo");
259
+ args.push("-t", "0.1", ...withInputColor(profile, "bt709"), out);
260
+ return new Promise((resolve) => {
261
+ let stderr = "";
262
+ let settled = false;
263
+ let proc;
264
+ let timer;
265
+ const finish = (result) => {
266
+ if (settled) return;
267
+ settled = true;
268
+ clearTimeout(timer);
269
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
270
+ resolve(result);
271
+ };
272
+ try {
273
+ proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
274
+ } catch (e) {
275
+ finish({ ok: false, error: e.message || String(e) });
276
+ return;
277
+ }
278
+ timer = setTimeout(() => {
279
+ try { proc.kill(); } catch { }
280
+ finish({ ok: false, error: `ffmpeg dry-run timed out after ${DRY_RUN_TIMEOUT_MS}ms` });
281
+ }, DRY_RUN_TIMEOUT_MS);
282
+ proc.on("error", (e) => finish({ ok: false, error: e.message || String(e) }));
283
+ proc.stderr.on("data", (d) => { stderr = (stderr + d).slice(-4000); });
284
+ proc.on("close", (code) => {
285
+ if (code === 0) { finish({ ok: true }); return; }
286
+ const lines = stderr.trim().split("\n").filter(Boolean);
287
+ finish({ ok: false, error: lines.slice(-3).join(" · ") || `ffmpeg exited ${code}` });
288
+ });
289
+ });
290
+ }
291
+
292
+ module.exports = {
293
+ PROFILES_FILE,
294
+ DEFAULT_COLOR,
295
+ loadEncodeProfiles,
296
+ invalidateEncodeProfiles,
297
+ resolveProfile,
298
+ listProfilesPublic,
299
+ profileSummary,
300
+ buildExportArgs,
301
+ dryRunProfile,
302
+ };
@@ -0,0 +1,68 @@
1
+ {
2
+ "default": "delivery",
3
+ "profiles": {
4
+ "draft": {
5
+ "label": "Draft · H.264 fast",
6
+ "description": "Quick preview — smaller file, faster encode.",
7
+ "jpegQuality": 0.85,
8
+ "extension": ".mp4",
9
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
10
+ "args": [
11
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
12
+ "-c:a", "aac", "-b:a", "128k",
13
+ "-movflags", "+faststart", "-shortest"
14
+ ]
15
+ },
16
+ "delivery": {
17
+ "label": "Delivery · H.264 balanced",
18
+ "description": "Default export — good quality and compatibility.",
19
+ "jpegQuality": 0.95,
20
+ "extension": ".mp4",
21
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
22
+ "args": [
23
+ "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p",
24
+ "-c:a", "aac", "-b:a", "192k",
25
+ "-movflags", "+faststart", "-shortest"
26
+ ]
27
+ },
28
+ "hq": {
29
+ "label": "High quality · H.264 4:2:2 10-bit slow",
30
+ "description": "Best H.264 quality — High 4:2:2 profile, 10-bit. Slower encode, larger file; for mastering and grading round-trips, not for browser or QuickTime playback.",
31
+ "jpegQuality": 0.98,
32
+ "extension": ".mp4",
33
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
34
+ "args": [
35
+ "-c:v", "libx264", "-preset", "slow", "-crf", "16", "-pix_fmt", "yuv422p10le",
36
+ "-c:a", "aac", "-b:a", "256k",
37
+ "-movflags", "+faststart", "-shortest"
38
+ ]
39
+ },
40
+ "broadcast1080i50": {
41
+ "label": "Broadcast · 1080i50 MOV",
42
+ "description": "Interlaced H.264 for broadcast-style delivery. Set the project to 1920x1080 @ 25fps (progressive); the filter chain builds the 50i field order.",
43
+ "jpegQuality": 0.98,
44
+ "extension": ".mov",
45
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
46
+ "args": [
47
+ "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p",
48
+ "-g", "10", "-maxrate", "60M", "-bufsize", "70M",
49
+ "-x264opts", "open_gop=0:interlaced=1:me=hex:subme=2:cabac=1:no-deblock:analyse=all:8x8dct=1:bframes=2:weightp=0:sliced-threads:level=41",
50
+ "-vf", "scale=1920:1080:interl=-1,fps=50,tinterlace=interleave_top:bypass_il",
51
+ "-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-strict", "-2",
52
+ "-shortest"
53
+ ]
54
+ },
55
+ "prores422": {
56
+ "label": "ProRes 422 HQ · MOV",
57
+ "description": "Mezzanine / round-trip master. Large files, edits well anywhere.",
58
+ "jpegQuality": 1,
59
+ "extension": ".mov",
60
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
61
+ "args": [
62
+ "-c:v", "prores_ks", "-profile:v", "3", "-pix_fmt", "yuv422p10le",
63
+ "-c:a", "pcm_s16le",
64
+ "-shortest"
65
+ ]
66
+ }
67
+ }
68
+ }
package/export-jobs.js ADDED
@@ -0,0 +1,163 @@
1
+ /* Agent-requested exports (POST /api/export/request, MCP fablecut_export).
2
+
3
+ The compositor lives in the browser, so an export job is handed to an
4
+ editor tab: an open one gets it over SSE, otherwise a headless Chrome /
5
+ Edge is started on `/?exportJob=<id>` and closed when the job ends. The tab
6
+ claims the job, renders it with the normal Fast export, and reports
7
+ progress and the finished file back here. A job is
8
+ pending → running → done | failed | cancelled
9
+ and stays readable for an hour after it ends. Zero dependencies. */
10
+ "use strict";
11
+ const fs = require("fs");
12
+ const os = require("os");
13
+ const path = require("path");
14
+ const { spawn, spawnSync } = require("child_process");
15
+
16
+ const PENDING_TAB_MS = 20_000; // an open tab answers SSE within a beat
17
+ const PENDING_HEADLESS_MS = 90_000; // a cold browser has to boot and load media
18
+ const STALL_MS = 120_000; // a running tab reports at least every few seconds
19
+ const KEEP_MS = 60 * 60 * 1000;
20
+
21
+ /** A Chromium-family browser to run headless: FABLECUT_CHROME, else the usual
22
+ * Chrome / Edge / Chromium install paths for this platform. Null if none. */
23
+ function findBrowser(env = process.env) {
24
+ if (env.FABLECUT_CHROME) return fs.existsSync(env.FABLECUT_CHROME) ? env.FABLECUT_CHROME : null;
25
+ const candidates = [];
26
+ if (process.platform === "win32") {
27
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
28
+ for (const r of roots) {
29
+ candidates.push(path.join(r, "Google", "Chrome", "Application", "chrome.exe"));
30
+ candidates.push(path.join(r, "Microsoft", "Edge", "Application", "msedge.exe"));
31
+ candidates.push(path.join(r, "Chromium", "Application", "chrome.exe"));
32
+ }
33
+ } else if (process.platform === "darwin") {
34
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
36
+ "/Applications/Chromium.app/Contents/MacOS/Chromium");
37
+ } else {
38
+ for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]) {
39
+ const r = spawnSync("which", [name], { encoding: "utf8" });
40
+ if (r.status === 0 && r.stdout.trim()) candidates.push(r.stdout.trim());
41
+ }
42
+ }
43
+ return candidates.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) || null;
44
+ }
45
+
46
+ /** opts: {url(id) → page to open headless, broadcast(event, data), editors() → open tab count} */
47
+ function createExportJobs(opts) {
48
+ const jobs = new Map();
49
+ let timer = null;
50
+ const isEnded = (j) => j.status === "done" || j.status === "failed" || j.status === "cancelled";
51
+ const view = (j) => ({
52
+ id: j.id, kind: j.kind, status: j.status, via: j.via, range: j.range, profile: j.profile || null,
53
+ time: j.time ?? null, progress: j.progress, src: j.src || null, result: j.result || null, error: j.error || null,
54
+ created: j.created, updated: j.updated,
55
+ });
56
+
57
+ function closeBrowser(j) {
58
+ const b = j.browser;
59
+ if (!b) return;
60
+ j.browser = null;
61
+ try { b.proc.kill(); } catch {}
62
+ // The profile dir is locked until the browser exits.
63
+ const rm = (n) => fs.rm(b.profileDir, { recursive: true, force: true }, (err) => {
64
+ if (err && n > 0) setTimeout(() => rm(n - 1), 1000);
65
+ });
66
+ b.proc.once("exit", () => rm(5));
67
+ setTimeout(() => rm(5), 3000);
68
+ }
69
+ function end(j, status, fields = {}) {
70
+ if (isEnded(j)) return;
71
+ Object.assign(j, fields, { status, updated: Date.now() });
72
+ closeBrowser(j);
73
+ }
74
+ function sweep() {
75
+ const now = Date.now();
76
+ for (const j of jobs.values()) {
77
+ if (j.status === "pending" && now - j.created > (j.via === "headless" ? PENDING_HEADLESS_MS : PENDING_TAB_MS))
78
+ end(j, "failed", { error: j.via === "headless"
79
+ ? "the headless browser never picked up the job (it may have failed to start or to load the editor)"
80
+ : "no editor tab picked up the job — is it busy exporting? Retry with where:\"headless\"" });
81
+ else if (j.status === "running" && now - j.updated > STALL_MS)
82
+ end(j, "failed", { error: "the editor stopped reporting progress (tab closed or browser crashed)" });
83
+ else if (isEnded(j) && now - j.updated > KEEP_MS) jobs.delete(j.id);
84
+ }
85
+ if (![...jobs.values()].some((j) => !isEnded(j)) && timer) { clearInterval(timer); timer = null; }
86
+ }
87
+ function launch(j) {
88
+ const exe = findBrowser();
89
+ if (!exe) throw Object.assign(new Error("no editor tab is open and no Chrome / Edge was found to export headless — " +
90
+ "open the editor in a browser, or set FABLECUT_CHROME to a Chromium-based browser"), { code: 409 });
91
+ const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-export-"));
92
+ const proc = spawn(exe, [
93
+ "--headless=new", `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check",
94
+ "--disable-extensions", "--mute-audio", "--autoplay-policy=no-user-gesture-required",
95
+ "--disable-background-timer-throttling", "--disable-renderer-backgrounding",
96
+ "--disable-backgrounding-occluded-windows", "--window-size=1600,900",
97
+ opts.url(j.id),
98
+ ], { stdio: "ignore", windowsHide: true });
99
+ j.browser = { proc, profileDir };
100
+ proc.on("error", (e) => end(j, "failed", { error: "could not start the headless browser: " + e.message }));
101
+ proc.on("exit", () => { if (j.browser) { j.browser = null; if (!isEnded(j)) end(j, "failed", { error: "the headless browser exited before the export finished" }); } });
102
+ }
103
+
104
+ return {
105
+ /** Queue an export. where: auto (open tab, else headless) · tab · headless.
106
+ * kind "scopes" measures one still at `time` instead (fablecut_scopes). */
107
+ request({ kind = "export", range, profile, time, revision, where = "auto", matte = null }) {
108
+ if (!["auto", "tab", "headless"].includes(where)) throw Object.assign(new Error("where must be auto, tab or headless"), { code: 400 });
109
+ if (kind !== "export" && kind !== "scopes") throw Object.assign(new Error("kind must be export or scopes"), { code: 400 });
110
+ if (time != null && !(Number.isFinite(time) && time >= 0)) throw Object.assign(new Error("time must be seconds ≥ 0"), { code: 400 });
111
+ if (range != null && range !== "entire" && range !== "in-out") throw Object.assign(new Error("range must be \"entire\" or \"in-out\""), { code: 400 });
112
+ const busy = [...jobs.values()].find((j) => !isEnded(j));
113
+ if (busy) throw Object.assign(new Error(`${busy.kind === "scopes" ? "scope reading" : "export"} ${busy.id} is still ${busy.status} — wait for it, or cancel it`), { code: 409 });
114
+ const tabs = opts.editors();
115
+ if (where === "tab" && !tabs) throw Object.assign(new Error("no editor tab is open — open the editor, or use where:\"headless\""), { code: 409 });
116
+ const now = Date.now();
117
+ const j = {
118
+ id: "x_" + Math.random().toString(36).slice(2, 10), kind, status: "pending", progress: 0,
119
+ range: range || null, profile: profile || null, time: time ?? null, revision: Number.isFinite(revision) ? revision : null,
120
+ matte: kind === "scopes" && matte ? { clip: String(matte.clip), layer: matte.layer } : null,
121
+ via: where === "headless" || (where === "auto" && !tabs) ? "headless" : "tab",
122
+ created: now, updated: now,
123
+ };
124
+ if (j.via === "headless") launch(j);
125
+ jobs.set(j.id, j);
126
+ if (j.via === "tab") opts.broadcast("export", JSON.stringify(this.ticket(j.id)));
127
+ if (!timer) { timer = setInterval(sweep, 1000); timer.unref?.(); }
128
+ return view(j);
129
+ },
130
+ get(id) { const j = jobs.get(id); return j ? view(j) : null; },
131
+ /** What a tab needs to run the job (headless pages fetch it by id). */
132
+ ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, status: j.status } : null; },
133
+ /** First tab to claim a pending job runs it. */
134
+ claim(id) {
135
+ const j = jobs.get(id);
136
+ if (!j || j.status !== "pending") return false;
137
+ j.status = "running"; j.updated = Date.now();
138
+ return true;
139
+ },
140
+ /** Progress / result from the running tab. Returns {cancel} so a cancel reaches it. */
141
+ report(id, { progress, status, src, result, error } = {}) {
142
+ const j = jobs.get(id);
143
+ if (!j) return null;
144
+ if (j.status === "cancelled") return { cancel: true };
145
+ if (j.status !== "running") return { cancel: false };
146
+ j.updated = Date.now();
147
+ if (Number.isFinite(progress)) j.progress = Math.max(0, Math.min(1, progress));
148
+ if (status === "done") end(j, "done", { progress: 1, src: String(src || ""), result: result && typeof result === "object" ? result : null });
149
+ else if (status === "failed") end(j, "failed", { error: String(error || "export failed") });
150
+ return { cancel: false };
151
+ },
152
+ cancel(id) {
153
+ const j = jobs.get(id);
154
+ if (!j) return null;
155
+ // A running tab hears about it on its next report and stops; pending just ends.
156
+ if (!isEnded(j)) end(j, "cancelled", { error: "cancelled" });
157
+ return view(j);
158
+ },
159
+ shutdown() { for (const j of jobs.values()) closeBrowser(j); if (timer) clearInterval(timer); },
160
+ };
161
+ }
162
+
163
+ module.exports = { createExportJobs, findBrowser };
package/favicon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#7b6cff"/>
5
+ <stop offset="1" stop-color="#4f8cff"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="512" height="512" rx="112" fill="url(#g)"/>
9
+ <!-- play triangle sliced by a cut (the "Cut" in FableCut) -->
10
+ <g fill="#fff">
11
+ <path d="M188 146 L244 176 L244 336 L188 366 Z"/>
12
+ <path d="M268 189 L392 256 L268 323 Z"/>
13
+ </g>
14
+ </svg>