fablecut 1.14.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/delivery.js ADDED
@@ -0,0 +1,779 @@
1
+ /* FableCut delivery — export presets and encode settings as data. Zero
2
+ dependencies. Loaded by the editor as a plain script (global
3
+ `FableCutDelivery`) and required by the server, the MCP server and the tests.
4
+
5
+ A delivery spec says what one output file should be:
6
+
7
+ { preset: "youtube", // where it started (label only)
8
+ width: 1920, height: 1080, // null = the picture's own size
9
+ fps: null, // null = the project's frame rate
10
+ reframe: "fill", // fill (crop) · fit (pad) · stretch
11
+ container: "mp4",
12
+ video: { codec: "h264", encoder: null (= best available software),
13
+ rate: "crf" | "vbr" | "cbr", crf: 18, bitrate: 12000 (kbps),
14
+ maxrate: null (kbps, caps a CRF encode), keyint: 2 (s, 0 = encoder default),
15
+ pixFmt: "yuv420p", speed: "fast" | "balanced" | "best",
16
+ profile: null (ProRes / DNxHR flavour) },
17
+ audio: { codec: "aac", bitrate: 320, sampleRate: 48000, channels: 2,
18
+ loudness: null (LUFS target — the mix is turned up / down to it),
19
+ truePeak: -1 (dBTP ceiling while normalizing) },
20
+ color: "bt709", // a COLORS key
21
+ filename: "{project}_{preset}_{res}",
22
+ metadata: { title, artist, description, copyright, date } }
23
+
24
+ normalize() turns anything (a preset id, a partial spec, an agent's guess)
25
+ into a complete, self-consistent spec, saying what it changed. ffmpegArgs()
26
+ turns a spec into one ffmpeg pass that reads the browser's frames on stdin.
27
+ Every value that reaches ffmpeg comes from a table here, never from the
28
+ caller, except the metadata strings (passed as separate argv entries, no
29
+ shell) — unlike encoding-profiles.json, which is raw args the user owns. */
30
+ (function (root, factory) {
31
+ const node = typeof module === "object" && module.exports;
32
+ const api = factory();
33
+ if (node) module.exports = api;
34
+ else root.FableCutDelivery = api;
35
+ })(typeof self !== "undefined" ? self : this, function () {
36
+ "use strict";
37
+
38
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
39
+ const num = (v, d) => (v != null && v !== "" && Number.isFinite(+v) ? +v : d);
40
+ const even = (v) => Math.max(2, Math.round(v / 2) * 2);
41
+
42
+ /* ── Tables ─────────────────────────────────────────────────────────── */
43
+
44
+ const CONTAINERS = {
45
+ mp4: { label: "MP4", ext: ".mp4", mov: true },
46
+ mov: { label: "QuickTime MOV", ext: ".mov", mov: true },
47
+ mkv: { label: "Matroska MKV", ext: ".mkv" },
48
+ webm: { label: "WebM", ext: ".webm" },
49
+ mxf: { label: "MXF (OP1a)", ext: ".mxf" },
50
+ };
51
+
52
+ /* rate: which rate controls the codec offers ("fixed" = set by the codec's
53
+ profile / spec, no bitrate or CRF). crf: [min, max, default]. */
54
+ const CODECS = {
55
+ h264: {
56
+ label: "H.264", encoders: ["libx264", "h264_nvenc", "h264_qsv", "h264_amf"],
57
+ containers: ["mp4", "mov", "mkv"], rate: ["crf", "vbr", "cbr"], crf: [0, 51, 18],
58
+ pixFmts: ["yuv420p", "yuv422p", "yuv444p", "yuv420p10le", "yuv422p10le"],
59
+ },
60
+ hevc: {
61
+ label: "H.265 / HEVC", encoders: ["libx265", "hevc_nvenc", "hevc_qsv", "hevc_amf"],
62
+ containers: ["mp4", "mov", "mkv"], rate: ["crf", "vbr", "cbr"], crf: [0, 51, 22],
63
+ pixFmts: ["yuv420p", "yuv420p10le", "yuv422p10le", "yuv444p"],
64
+ },
65
+ av1: {
66
+ label: "AV1", encoders: ["libsvtav1", "libaom-av1", "av1_nvenc", "av1_qsv", "av1_amf"],
67
+ containers: ["mp4", "mkv", "webm"], rate: ["crf", "vbr", "cbr"], crf: [0, 63, 30],
68
+ pixFmts: ["yuv420p", "yuv420p10le"],
69
+ },
70
+ vp9: {
71
+ label: "VP9", encoders: ["libvpx-vp9"],
72
+ containers: ["webm", "mkv", "mp4"], rate: ["crf", "vbr", "cbr"], crf: [0, 63, 31],
73
+ pixFmts: ["yuv420p", "yuv420p10le"],
74
+ },
75
+ prores: {
76
+ label: "Apple ProRes", encoders: ["prores_ks"], containers: ["mov"], rate: ["fixed"],
77
+ profiles: { proxy: "Proxy", lt: "LT", standard: "422", hq: "422 HQ", 4444: "4444", "4444xq": "4444 XQ" },
78
+ defaultProfile: "hq", pixFmts: ["yuv422p10le", "yuv444p10le"],
79
+ },
80
+ dnxhr: {
81
+ label: "Avid DNxHR", encoders: ["dnxhd"], containers: ["mov", "mxf"], rate: ["fixed"],
82
+ profiles: { lb: "LB", sq: "SQ", hq: "HQ", hqx: "HQX 10-bit", 444: "444 10-bit" },
83
+ defaultProfile: "hq", pixFmts: ["yuv422p", "yuv422p10le", "yuv444p10le"],
84
+ },
85
+ mpeg2: {
86
+ label: "XDCAM HD422 (MPEG-2 50 Mb/s)", encoders: ["mpeg2video"], containers: ["mxf", "mov"],
87
+ rate: ["fixed"], pixFmts: ["yuv422p"],
88
+ },
89
+ ffv1: {
90
+ label: "FFV1 (lossless)", encoders: ["ffv1"], containers: ["mkv"], rate: ["fixed"],
91
+ pixFmts: ["yuv422p10le", "yuv444p10le", "yuv420p"],
92
+ },
93
+ };
94
+
95
+ /* Which pixel formats an encoder takes, as ffmpeg names. A spec keeps the
96
+ generic name (yuv420p10le); hardware encoders get their own (p010le). */
97
+ const HW_PIX = { yuv420p: "nv12", yuv420p10le: "p010le", yuv444p: "yuv444p" };
98
+ const ENCODERS = {
99
+ libx264: { label: "x264 (software)" },
100
+ h264_nvenc: { label: "NVIDIA NVENC", hw: "nvenc", pix: ["yuv420p", "yuv444p", "yuv420p10le"] },
101
+ h264_qsv: { label: "Intel Quick Sync", hw: "qsv", pix: ["yuv420p"] },
102
+ h264_amf: { label: "AMD AMF", hw: "amf", pix: ["yuv420p"] },
103
+ libx265: { label: "x265 (software)" },
104
+ hevc_nvenc: { label: "NVIDIA NVENC", hw: "nvenc", pix: ["yuv420p", "yuv420p10le", "yuv444p"] },
105
+ hevc_qsv: { label: "Intel Quick Sync", hw: "qsv", pix: ["yuv420p", "yuv420p10le"] },
106
+ hevc_amf: { label: "AMD AMF", hw: "amf", pix: ["yuv420p", "yuv420p10le"] },
107
+ libsvtav1: { label: "SVT-AV1 (software)" },
108
+ "libaom-av1": { label: "libaom (software, slow)" },
109
+ av1_nvenc: { label: "NVIDIA NVENC", hw: "nvenc", pix: ["yuv420p", "yuv420p10le"] },
110
+ av1_qsv: { label: "Intel Quick Sync", hw: "qsv", pix: ["yuv420p", "yuv420p10le"] },
111
+ av1_amf: { label: "AMD AMF", hw: "amf", pix: ["yuv420p"] },
112
+ "libvpx-vp9": { label: "libvpx (software)" },
113
+ prores_ks: { label: "prores_ks" },
114
+ dnxhd: { label: "dnxhd" },
115
+ mpeg2video: { label: "mpeg2video" },
116
+ ffv1: { label: "ffv1" },
117
+ };
118
+
119
+ const AUDIO = {
120
+ aac: { label: "AAC", encoder: "aac", bitrate: [64, 512, 320], containers: ["mp4", "mov", "mkv"] },
121
+ opus: { label: "Opus", encoder: "libopus", bitrate: [32, 512, 192], containers: ["webm", "mkv", "mp4"] },
122
+ mp3: { label: "MP3", encoder: "libmp3lame", bitrate: [64, 320, 320], containers: ["mp4", "mov", "mkv"] },
123
+ pcm16: { label: "PCM 16-bit", encoder: "pcm_s16le", containers: ["mov", "mkv", "mxf"] },
124
+ pcm24: { label: "PCM 24-bit", encoder: "pcm_s24le", containers: ["mov", "mkv", "mxf"] },
125
+ flac: { label: "FLAC (lossless)", encoder: "flac", containers: ["mkv"] },
126
+ none: { label: "No audio", containers: ["mp4", "mov", "mkv", "webm", "mxf"] },
127
+ };
128
+ const SAMPLE_RATES = [44100, 48000];
129
+
130
+ /* Output colour. The canvas is sRGB / BT.709 RGB; these set the YUV matrix,
131
+ range and the tags. No gamut conversion — BT.601 primaries are close
132
+ enough to BT.709 for SD, and wide-gamut / HDR is not offered because the
133
+ compositor never produces it. `scale` is the matrix name the scale filter
134
+ takes, which differs from the tag name for PAL. */
135
+ const COLORS = {
136
+ bt709: { label: "Rec. 709 (limited)", matrix: "bt709", primaries: "bt709", trc: "bt709", range: "tv", scale: "bt709" },
137
+ "bt709-full": { label: "Rec. 709 (full range)", matrix: "bt709", primaries: "bt709", trc: "bt709", range: "pc", scale: "bt709" },
138
+ "bt601-625": { label: "Rec. 601 PAL (limited)", matrix: "bt470bg", primaries: "bt470bg", trc: "smpte170m", range: "tv", scale: "bt470" },
139
+ "bt601-525": { label: "Rec. 601 NTSC (limited)", matrix: "smpte170m", primaries: "smpte170m", trc: "smpte170m", range: "tv", scale: "smpte170m" },
140
+ };
141
+
142
+ const FPS_CHOICES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60];
143
+ /** ffmpeg rate for a frame rate: the NTSC ones as exact fractions. */
144
+ function fpsRational(fps) {
145
+ const f = +fps;
146
+ for (const base of [24, 30, 60, 120]) if (Math.abs(f - base * 1000 / 1001) < 0.01) return `${base * 1000}/1001`;
147
+ return String(Math.round(f * 1000) / 1000);
148
+ }
149
+
150
+ const RESOLUTIONS = [
151
+ [3840, 2160, "4K UHD"], [2560, 1440, "1440p"], [1920, 1080, "1080p"], [1280, 720, "720p"], [854, 480, "480p"],
152
+ [4096, 2160, "DCI 4K"], [2160, 3840, "4K vertical"], [1080, 1920, "9:16 vertical"], [720, 1280, "9:16 720"],
153
+ [1080, 1350, "4:5 portrait"], [1080, 1080, "1:1 square"],
154
+ ];
155
+
156
+ const SPEEDS = ["fast", "balanced", "best"];
157
+
158
+ /* ── Presets ─────────────────────────────────────────────────────────
159
+ Settings follow each platform's published upload guidance where there is
160
+ one; `limits` are soft checks (platforms change them — the warning says
161
+ so). `group` orders the dropdown. */
162
+ const H264 = (crf, extra) => ({ codec: "h264", rate: "crf", crf, keyint: 2, pixFmt: "yuv420p", speed: "balanced", ...extra });
163
+ const PRESETS = {
164
+ youtube: {
165
+ label: "YouTube", group: "Platforms", width: 1920, height: 1080, container: "mp4",
166
+ // YouTube: High profile, closed GOP of half the frame rate, AAC-LC 384k stereo 48 kHz
167
+ video: H264(18, { keyint: 0.5 }), audio: { codec: "aac", bitrate: 384, sampleRate: 48000, channels: 2 },
168
+ filename: "{project}_youtube_{res}", note: "16:9 · H.264 High · CRF 18 · AAC 384k",
169
+ },
170
+ "youtube-shorts": {
171
+ label: "YouTube Shorts", group: "Platforms", width: 1080, height: 1920, container: "mp4",
172
+ video: H264(18, { keyint: 0.5 }), audio: { codec: "aac", bitrate: 384, sampleRate: 48000, channels: 2 },
173
+ filename: "{project}_shorts_{res}", limits: { duration: 180, vertical: true },
174
+ note: "9:16 · up to 3 min",
175
+ },
176
+ "instagram-reels": {
177
+ label: "Instagram Reels", group: "Platforms", width: 1080, height: 1920, container: "mp4",
178
+ video: H264(19, { maxrate: 25000, keyint: 1 }), audio: { codec: "aac", bitrate: 256, sampleRate: 48000, channels: 2 },
179
+ filename: "{project}_reel_{res}", limits: { duration: 180, vertical: true, fps: 60 },
180
+ note: "9:16 · ≤ 60 fps · capped at 25 Mb/s",
181
+ },
182
+ tiktok: {
183
+ label: "TikTok", group: "Platforms", width: 1080, height: 1920, container: "mp4",
184
+ video: H264(19, { maxrate: 20000, keyint: 1 }), audio: { codec: "aac", bitrate: 256, sampleRate: 48000, channels: 2 },
185
+ filename: "{project}_tiktok_{res}", limits: { duration: 600, vertical: true, fps: 60 },
186
+ note: "9:16 · ≤ 60 fps",
187
+ },
188
+ x: {
189
+ label: "X (Twitter)", group: "Platforms", width: 1920, height: 1080, container: "mp4",
190
+ video: H264(20, { maxrate: 25000, keyint: 1 }), audio: { codec: "aac", bitrate: 192, sampleRate: 48000, channels: 2 },
191
+ filename: "{project}_x_{res}", limits: { duration: 140, size: 512 * 1024 * 1024, fps: 60 },
192
+ note: "≤ 2 min 20 s and 512 MB on a free account",
193
+ },
194
+ linkedin: {
195
+ label: "LinkedIn", group: "Platforms", width: 1920, height: 1080, container: "mp4",
196
+ video: H264(20, { maxrate: 20000, keyint: 1 }), audio: { codec: "aac", bitrate: 192, sampleRate: 48000, channels: 2 },
197
+ filename: "{project}_linkedin_{res}", limits: { duration: 900, size: 5 * 1024 ** 3, fps: 60 },
198
+ note: "≤ 15 min and 5 GB",
199
+ },
200
+ master: {
201
+ label: "Master", group: "Delivery", width: null, height: null, container: "mp4",
202
+ video: H264(16, { speed: "best", keyint: 2 }), audio: { codec: "aac", bitrate: 320, sampleRate: 48000, channels: 2 },
203
+ filename: "{project}_master_{res}", note: "Project size · H.264 CRF 16 · AAC 320k",
204
+ },
205
+ broadcast: {
206
+ label: "Broadcast", group: "Delivery", width: 1920, height: 1080, container: "mov",
207
+ video: { codec: "prores", profile: "hq", pixFmt: "yuv422p10le" },
208
+ audio: { codec: "pcm24", sampleRate: 48000, channels: 2, loudness: -23, truePeak: -1 },
209
+ filename: "{project}_broadcast_{res}_{fps}", limits: { fpsList: [25, 29.97, 50, 59.94] },
210
+ note: "ProRes 422 HQ · PCM 24-bit 48 kHz · Rec. 709 · −23 LUFS (EBU R128)",
211
+ },
212
+ web: {
213
+ label: "Web", group: "Delivery", width: 1280, height: 720, container: "mp4",
214
+ video: H264(23, { speed: "balanced" }), audio: { codec: "aac", bitrate: 128, sampleRate: 48000, channels: 2 },
215
+ filename: "{project}_web_{res}", note: "Small, plays everywhere · fast start",
216
+ },
217
+ archive: {
218
+ label: "Archive", group: "Delivery", width: null, height: null, container: "mkv",
219
+ video: { codec: "ffv1", pixFmt: "yuv422p10le" }, audio: { codec: "pcm24", sampleRate: 48000, channels: 2 },
220
+ filename: "{project}_archive_{date}", note: "Lossless FFV1 · PCM 24-bit · source resolution",
221
+ },
222
+ custom: {
223
+ label: "Custom", group: "Custom", width: null, height: null, container: "mp4",
224
+ video: H264(18), audio: { codec: "aac", bitrate: 320, sampleRate: 48000, channels: 2 },
225
+ filename: "{project}_{res}", note: "Your own settings",
226
+ },
227
+ };
228
+ const PRESET_IDS = Object.keys(PRESETS);
229
+ /* Where each platform plays a mix (streaming players turn louder ones down).
230
+ Only a reference for the report; a spec normalizes only when audio.loudness is set. */
231
+ const PLATFORM_LOUDNESS = { youtube: -14, "youtube-shorts": -14, "instagram-reels": -14, tiktok: -14, x: -14, linkedin: -14, broadcast: -23 };
232
+ const LOUDNESS_TARGETS = [[-14, "−14 LUFS · streaming and social"], [-16, "−16 LUFS · podcast, dialogue"], [-23, "−23 LUFS · broadcast (EBU R128)"], [-24, "−24 LUFS · broadcast (ATSC A/85)"]];
233
+ const DEFAULT_PRESET = "master";
234
+
235
+ /* ── Normalize ──────────────────────────────────────────────────────── */
236
+
237
+ function pick(list, v, d) { return list.includes(v) ? v : d; }
238
+ function cleanText(v, max) {
239
+ // eslint-disable-next-line no-control-regex
240
+ return String(v == null ? "" : v).replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max);
241
+ }
242
+
243
+ /** Encoders to choose from for a codec, best first: software unless the
244
+ * caller asked for hardware. `available` (a Set of ffmpeg encoder names)
245
+ * filters to what this ffmpeg has; null = assume all. */
246
+ function encodersFor(codec, available) {
247
+ const list = (CODECS[codec] || CODECS.h264).encoders;
248
+ return available ? list.filter((e) => available.has(e)) : list.slice();
249
+ }
250
+
251
+ /** opts: {available: Set|null, presets: {id: spec} (user presets)}.
252
+ * Returns {spec, notes:[…what was changed and why]}. */
253
+ function normalize(input, opts = {}) {
254
+ const notes = [];
255
+ const userPresets = opts.presets || {};
256
+ let raw = input;
257
+ if (typeof raw === "string") raw = { preset: raw };
258
+ raw = raw && typeof raw === "object" ? raw : {};
259
+ const pid = typeof raw.preset === "string" ? raw.preset : null;
260
+ const base = (pid && (PRESETS[pid] || userPresets[pid])) || null;
261
+ if (pid && !base) notes.push(`unknown preset "${pid}" — using Custom`);
262
+ const b = base || PRESETS.custom;
263
+ const rv = raw.video && typeof raw.video === "object" ? raw.video : {};
264
+ // a different codec than the preset's starts from that codec's defaults
265
+ const bv = { ...(b.video || {}) };
266
+ if (rv.codec && rv.codec !== bv.codec) for (const k of ["crf", "pixFmt", "profile", "encoder", "rate"]) delete bv[k];
267
+ const v = { ...bv, ...rv };
268
+ const a = { ...(b.audio || {}), ...(raw.audio || {}) };
269
+ const has = (k) => Object.prototype.hasOwnProperty.call(raw, k);
270
+
271
+ const spec = { preset: base ? pid : "custom" };
272
+ // resolution: both or neither; even numbers; 16…8192
273
+ let w = has("width") ? raw.width : b.width, h = has("height") ? raw.height : b.height;
274
+ w = w == null || w === "" ? null : num(w, null); h = h == null || h === "" ? null : num(h, null);
275
+ if ((w == null) !== (h == null)) { notes.push("width and height go together — using the picture's own size"); w = h = null; }
276
+ if (w != null) {
277
+ const cw = even(clamp(w, 16, 8192)), ch = even(clamp(h, 16, 8192));
278
+ if (cw !== w || ch !== h) notes.push(`resolution ${w}×${h} → ${cw}×${ch} (even, 16…8192)`);
279
+ w = cw; h = ch;
280
+ }
281
+ spec.width = w; spec.height = h;
282
+ let fps = has("fps") ? raw.fps : b.fps;
283
+ fps = fps == null || fps === "" || fps === "project" ? null : num(fps, null);
284
+ if (fps != null) {
285
+ const f = clamp(fps, 1, 120);
286
+ const snap = FPS_CHOICES.find((c) => Math.abs(c - f) < 0.02);
287
+ spec.fps = snap ?? Math.round(f * 1000) / 1000;
288
+ } else spec.fps = null;
289
+ spec.reframe = pick(["fill", "fit", "stretch"], has("reframe") ? raw.reframe : b.reframe, "fill");
290
+
291
+ // video codec → encoder → container
292
+ const codec = CODECS[v.codec] ? v.codec : "h264";
293
+ if (v.codec && !CODECS[v.codec]) notes.push(`unknown codec "${v.codec}" — using H.264`);
294
+ const C = CODECS[codec];
295
+ const avail = encodersFor(codec, opts.available);
296
+ let encoder = v.encoder && C.encoders.includes(v.encoder) ? v.encoder : null;
297
+ if (v.encoder && !encoder) notes.push(`encoder "${v.encoder}" does not make ${C.label}`);
298
+ if (encoder && opts.available && !opts.available.has(encoder)) { notes.push(`this ffmpeg has no ${encoder}`); encoder = null; }
299
+ if (!encoder) encoder = avail[0] || C.encoders[0];
300
+ if (opts.available && !avail.length) notes.push(`this ffmpeg has no ${C.label} encoder — the export will fail`);
301
+ const E = ENCODERS[encoder] || {};
302
+
303
+ let container = has("container") ? raw.container : b.container;
304
+ if (!C.containers.includes(container)) {
305
+ if (container) notes.push(`${C.label} does not go in ${String(container).toUpperCase()} — using ${C.containers[0].toUpperCase()}`);
306
+ container = C.containers[0];
307
+ }
308
+ spec.container = container;
309
+
310
+ const video = { codec, encoder };
311
+ video.rate = C.rate.includes(v.rate) ? v.rate : C.rate[0];
312
+ if (v.rate && v.rate !== video.rate && C.rate[0] !== "fixed") notes.push(`rate "${v.rate}" is not offered for ${C.label}`);
313
+ if (video.rate !== "fixed") {
314
+ const [lo, hi, dflt] = C.crf;
315
+ video.crf = Math.round(clamp(num(v.crf, dflt), lo, hi));
316
+ video.bitrate = Math.round(clamp(num(v.bitrate, 12000), 100, 400000));
317
+ // 0, empty or not a number = no cap (clamping first would turn 0 into a 100 kb/s cap)
318
+ video.maxrate = +v.maxrate > 0 ? Math.round(clamp(+v.maxrate, 100, 800000)) : null;
319
+ video.keyint = Math.round(clamp(num(v.keyint, 2), 0, 20) * 100) / 100;
320
+ video.speed = pick(SPEEDS, v.speed, "balanced");
321
+ if (encoder === "libsvtav1" && video.rate === "cbr") { video.rate = "vbr"; notes.push("SVT-AV1 has no CBR here — using VBR"); }
322
+ }
323
+ if (C.profiles) video.profile = C.profiles[v.profile] ? String(v.profile) : C.defaultProfile;
324
+ // pixel format: the codec's list, then what the encoder takes
325
+ let pf = C.pixFmts.includes(v.pixFmt) ? v.pixFmt : C.pixFmts[0];
326
+ if (codec === "prores") pf = /^4444/.test(video.profile) ? "yuv444p10le" : "yuv422p10le";
327
+ if (codec === "dnxhr") pf = video.profile === "444" ? "yuv444p10le" : video.profile === "hqx" ? "yuv422p10le" : "yuv422p";
328
+ if (E.pix && !E.pix.includes(pf)) {
329
+ notes.push(`${E.label} cannot encode ${pf} — using ${E.pix[0]}`);
330
+ pf = E.pix[0];
331
+ }
332
+ if (rv.pixFmt && rv.pixFmt !== pf && !notes.some((n) => n.includes(pf))) notes.push(`pixel format ${rv.pixFmt} → ${pf} for ${C.label}${video.profile ? " " + C.profiles[video.profile] : ""}`);
333
+ video.pixFmt = pf;
334
+ spec.video = video;
335
+
336
+ // audio
337
+ let ac = AUDIO[a.codec] ? a.codec : "aac";
338
+ if (!AUDIO[ac].containers.includes(container)) {
339
+ const alt = Object.keys(AUDIO).find((k) => k !== "none" && AUDIO[k].containers.includes(container)) || "none";
340
+ notes.push(`${AUDIO[ac].label} audio does not go in ${container.toUpperCase()} — using ${AUDIO[alt].label}`);
341
+ ac = alt;
342
+ }
343
+ const A = AUDIO[ac];
344
+ const audio = { codec: ac };
345
+ if (ac !== "none") {
346
+ if (A.bitrate) audio.bitrate = Math.round(clamp(num(a.bitrate, A.bitrate[2]), A.bitrate[0], A.bitrate[1]));
347
+ audio.sampleRate = pick(SAMPLE_RATES, +a.sampleRate, 48000);
348
+ if (ac === "opus") audio.sampleRate = 48000;
349
+ audio.channels = +a.channels === 1 ? 1 : 2;
350
+ // loudness: null = leave the mix as it is
351
+ const lu = a.loudness == null || a.loudness === "" || a.loudness === "off" ? null : +a.loudness;
352
+ audio.loudness = Number.isFinite(lu) ? Math.round(clamp(lu, -36, -5) * 10) / 10 : null;
353
+ if (audio.loudness != null) audio.truePeak = Math.round(clamp(num(a.truePeak, -1), -9, 0) * 10) / 10;
354
+ }
355
+ spec.audio = audio;
356
+
357
+ spec.color = COLORS[has("color") ? raw.color : b.color] ? (has("color") ? raw.color : b.color) : "bt709";
358
+ spec.filename = cleanText(has("filename") ? raw.filename : b.filename, 160) || "{project}_{res}";
359
+ const md = raw.metadata && typeof raw.metadata === "object" ? raw.metadata : {};
360
+ spec.metadata = {};
361
+ for (const k of META_KEYS) { const s = cleanText(md[k], k === "description" ? 2000 : 300); if (s) spec.metadata[k] = s; }
362
+ return { spec, notes };
363
+ }
364
+ const META_KEYS = ["title", "artist", "description", "copyright", "date", "keywords"];
365
+
366
+ /* ── Reframe ────────────────────────────────────────────────────────── */
367
+
368
+ /** Where the output's picture comes from on the canvas, and how big the
369
+ * scaled picture is inside the output. region = the export frame when one
370
+ * is set, else the whole canvas. Returns
371
+ * {crop:{x,y,w,h}|null, scaleW, scaleH, outW, outH, padX, padY, upscale}. */
372
+ function reframe(spec, canvasW, canvasH, region) {
373
+ /* The export frame is the picture for outputs of its own shape (and for
374
+ "the picture's size"); an output of another shape — the 16:9 file of a
375
+ project framed for 9:16 — reframes the whole canvas instead. */
376
+ const full = { x: 0, y: 0, w: canvasW | 0, h: canvasH | 0 };
377
+ let r = region && region.w > 0 && region.h > 0
378
+ ? { x: region.x | 0, y: region.y | 0, w: region.w | 0, h: region.h | 0 } : full;
379
+ if (r !== full && spec.width && Math.abs(spec.width / spec.height - r.w / r.h) > 0.01) r = full;
380
+ // the picture's own size rounds down to even, so it is never scaled up by a pixel
381
+ const outW = spec.width || Math.max(2, r.w - (r.w & 1)), outH = spec.height || Math.max(2, r.h - (r.h & 1));
382
+ let crop = { ...r }, scaleW = outW, scaleH = outH, padX = 0, padY = 0;
383
+ const want = outW / outH, have = r.w / r.h;
384
+ if (spec.width && Math.abs(want - have) > 0.002) {
385
+ if (spec.reframe === "fill") {
386
+ // largest rect of the output's shape inside the region, centred
387
+ if (have > want) { crop.w = Math.round(r.h * want); crop.x = r.x + Math.round((r.w - crop.w) / 2); }
388
+ else { crop.h = Math.round(r.w / want); crop.y = r.y + Math.round((r.h - crop.h) / 2); }
389
+ } else if (spec.reframe === "fit") {
390
+ if (have > want) scaleH = even(outW / have); else scaleW = even(outH * have);
391
+ padX = (outW - scaleW) >> 1; padY = (outH - scaleH) >> 1;
392
+ padX -= padX & 1; padY -= padY & 1;
393
+ }
394
+ }
395
+ crop.w = Math.max(2, crop.w - (crop.w & 1)); crop.h = Math.max(2, crop.h - (crop.h & 1));
396
+ const whole = crop.x === 0 && crop.y === 0 && crop.w === canvasW && crop.h === canvasH;
397
+ const upscale = scaleW > crop.w * 1.01 || scaleH > crop.h * 1.01;
398
+ return { crop: whole ? null : crop, scaleW, scaleH, outW, outH, padX, padY, upscale, from: { w: crop.w, h: crop.h } };
399
+ }
400
+
401
+ /* ── ffmpeg ─────────────────────────────────────────────────────────── */
402
+
403
+ const SPEED_MAP = {
404
+ libx264: { fast: ["-preset", "veryfast"], balanced: ["-preset", "medium"], best: ["-preset", "slow"] },
405
+ libx265: { fast: ["-preset", "fast"], balanced: ["-preset", "medium"], best: ["-preset", "slow"] },
406
+ nvenc: { fast: ["-preset", "p3"], balanced: ["-preset", "p5"], best: ["-preset", "p7"] },
407
+ qsv: { fast: ["-preset", "veryfast"], balanced: ["-preset", "medium"], best: ["-preset", "veryslow"] },
408
+ amf: { fast: ["-quality", "speed"], balanced: ["-quality", "balanced"], best: ["-quality", "quality"] },
409
+ "libvpx-vp9": { fast: ["-deadline", "good", "-cpu-used", "4"], balanced: ["-deadline", "good", "-cpu-used", "2"], best: ["-deadline", "good", "-cpu-used", "1"] },
410
+ "libaom-av1": { fast: ["-cpu-used", "8"], balanced: ["-cpu-used", "6"], best: ["-cpu-used", "4"] },
411
+ libsvtav1: { fast: ["-preset", "10"], balanced: ["-preset", "8"], best: ["-preset", "5"] },
412
+ };
413
+ const PRORES_PROFILE = { proxy: "0", lt: "1", standard: "2", hq: "3", 4444: "4", "4444xq": "5" };
414
+
415
+ function videoArgs(spec, fps) {
416
+ const v = spec.video, enc = v.encoder, E = ENCODERS[enc] || {};
417
+ const out = ["-c:v", enc];
418
+ const k = (n) => `${Math.round(n)}k`;
419
+ if (v.codec === "prores") return out.concat(["-profile:v", PRORES_PROFILE[v.profile] || "3", "-vendor", "apl0", "-pix_fmt", v.pixFmt]);
420
+ if (v.codec === "dnxhr") return out.concat(["-profile:v", `dnxhr_${v.profile}`, "-pix_fmt", v.pixFmt]);
421
+ if (v.codec === "ffv1") return out.concat(["-level", "3", "-g", "1", "-slices", "16", "-slicecrc", "1", "-coder", "1", "-context", "1", "-pix_fmt", v.pixFmt]);
422
+ if (v.codec === "mpeg2") {
423
+ // XDCAM HD422: 4:2:2 long-GOP MPEG-2 at a constant 50 Mb/s
424
+ return out.concat(["-pix_fmt", "yuv422p", "-b:v", "50M", "-minrate", "50M", "-maxrate", "50M", "-bufsize", "17825792",
425
+ "-rc_init_occupancy", "17825792", "-g", "12", "-bf", "2", "-intra_vlc", "1", "-non_linear_quant", "1",
426
+ "-dc", "10", "-qmin", "1", "-qmax", "12", "-lmin", "1*QP2LAMBDA", "-rc_max_vbv_use", "1", "-rc_min_vbv_use", "1"]);
427
+ }
428
+ out.push(...((SPEED_MAP[E.hw || enc] || {})[v.speed] || []));
429
+ const br = v.bitrate, max = v.maxrate;
430
+ if (v.rate === "crf") {
431
+ if (enc === "libx264" || enc === "libx265" || enc === "libsvtav1") out.push("-crf", String(v.crf));
432
+ else if (enc === "libvpx-vp9" || enc === "libaom-av1") out.push("-crf", String(v.crf), "-b:v", "0");
433
+ else if (E.hw === "nvenc") out.push("-rc", "vbr", "-cq", String(v.crf), "-b:v", "0");
434
+ else if (E.hw === "qsv") out.push("-global_quality", String(v.crf));
435
+ else if (E.hw === "amf") out.push("-rc", "cqp", "-qp_i", String(v.crf), "-qp_p", String(v.crf), ...(v.codec === "h264" ? ["-qp_b", String(v.crf)] : []));
436
+ if (max && E.hw !== "amf") {
437
+ if (enc === "libsvtav1") out.push("-svtav1-params", `mbr=${max}`);
438
+ else out.push("-maxrate", k(max), "-bufsize", k(max * 2));
439
+ }
440
+ } else if (v.rate === "vbr") {
441
+ const peak = max || Math.round(br * 1.5);
442
+ if (E.hw === "nvenc") out.push("-rc", "vbr");
443
+ if (E.hw === "amf") out.push("-rc", "vbr_peak");
444
+ out.push("-b:v", k(br), "-maxrate", k(peak), "-bufsize", k(peak * 2));
445
+ } else if (v.rate === "cbr") {
446
+ if (E.hw === "nvenc") out.push("-rc", "cbr");
447
+ if (E.hw === "amf") out.push("-rc", "cbr");
448
+ out.push("-b:v", k(br), "-minrate", k(br), "-maxrate", k(br), "-bufsize", k(br));
449
+ if (enc === "libx264") out.push("-x264-params", "nal-hrd=cbr");
450
+ }
451
+ if (v.keyint > 0) {
452
+ const g = Math.max(1, Math.round(v.keyint * fps));
453
+ out.push("-g", String(g));
454
+ if (enc === "libx264" || enc === "libx265") out.push("-keyint_min", String(Math.min(g, Math.max(1, Math.round(fps / 2)))));
455
+ }
456
+ if (enc === "libvpx-vp9" || enc === "libaom-av1") out.push("-row-mt", "1");
457
+ if (enc === "libx265") out.push("-x265-params", "log-level=error");
458
+ if (v.codec === "hevc" && (spec.container === "mp4" || spec.container === "mov")) out.push("-tag:v", "hvc1");
459
+ out.push("-pix_fmt", E.hw ? (HW_PIX[v.pixFmt] || v.pixFmt) : v.pixFmt);
460
+ return out;
461
+ }
462
+
463
+ function audioArgs(spec) {
464
+ const a = spec.audio;
465
+ if (a.codec === "none") return ["-an"];
466
+ const out = ["-c:a", AUDIO[a.codec].encoder];
467
+ if (a.bitrate) out.push("-b:a", `${a.bitrate}k`);
468
+ out.push("-ar", String(a.sampleRate), "-ac", String(a.channels));
469
+ return out;
470
+ }
471
+
472
+ /** The filter chain: crop → scale (with the RGB/JPEG → YUV matrix) → tags → pad. */
473
+ function videoFilter(spec, frame, inMatrix) {
474
+ const c = COLORS[spec.color] || COLORS.bt709;
475
+ const parts = [];
476
+ if (frame.crop) parts.push(`crop=${frame.crop.w}:${frame.crop.h}:${frame.crop.x}:${frame.crop.y}`);
477
+ parts.push(`scale=${frame.scaleW}:${frame.scaleH}:flags=lanczos:in_range=full:in_color_matrix=${inMatrix}` +
478
+ `:out_range=${c.range}:out_color_matrix=${c.scale}`);
479
+ parts.push(`setparams=color_primaries=${c.primaries}:color_trc=${c.trc}:colorspace=${c.matrix}:range=${c.range === "pc" ? "pc" : "tv"}`);
480
+ if (frame.padX || frame.padY || frame.scaleW !== frame.outW || frame.scaleH !== frame.outH)
481
+ parts.push(`pad=${frame.outW}:${frame.outH}:${frame.padX}:${frame.padY}:color=black`);
482
+ return parts.join(",");
483
+ }
484
+
485
+ /** One ffmpeg pass. o: {fps, input:"jpeg"|"rgba", canvasW, canvasH, frame
486
+ * (from reframe), wavPath|null, outPath, duration (s, frames ÷ fps),
487
+ * loudnorm (pass-one measurement, when the spec has a loudness target), dry}. `dry` swaps stdin for a
488
+ * 0.1 s synthetic picture of the canvas size, to check the settings
489
+ * before anything is rendered. */
490
+ function ffmpegArgs(spec, o) {
491
+ const rate = fpsRational(o.fps);
492
+ const args = ["-y", "-hide_banner"];
493
+ const rgba = o.input === "rgba";
494
+ if (o.dry) args.push("-f", "lavfi", "-i", `color=c=black:s=${o.canvasW}x${o.canvasH}:r=${rate}:d=0.1,format=${rgba ? "rgba" : "yuvj420p"}`);
495
+ else if (rgba) args.push("-thread_queue_size", "8", "-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${o.canvasW}x${o.canvasH}`, "-framerate", rate, "-i", "-");
496
+ else args.push("-thread_queue_size", "64", "-f", "image2pipe", "-framerate", rate, "-c:v", "mjpeg", "-i", "-");
497
+ const audio = spec.audio.codec !== "none" && (o.dry ? o.hasAudio !== false : !!o.wavPath);
498
+ if (audio) {
499
+ if (o.dry) args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo");
500
+ else args.push("-i", o.wavPath);
501
+ }
502
+ args.push("-map", "0:v:0");
503
+ if (audio) args.push("-map", "1:a:0");
504
+ args.push("-vf", videoFilter(spec, o.frame, rgba ? "bt709" : "bt601"));
505
+ args.push(...videoArgs(spec, +o.fps));
506
+ const c = COLORS[spec.color] || COLORS.bt709;
507
+ args.push("-colorspace", c.matrix, "-color_primaries", c.primaries, "-color_trc", c.trc, "-color_range", c.range);
508
+ // The audio is cut to exactly the frames' length (o.duration): padded when
509
+ // the mix ends a fraction of a frame early (29.97 fps), trimmed when it runs
510
+ // long. -shortest alone overshoots by up to half a second on slow inputs.
511
+ const dur = +o.duration > 0 ? (+o.duration).toFixed(6) : null;
512
+ if (audio && !o.dry) args.push("-af", [o.loudnorm ? loudnormFilter(spec, o.loudnorm) : "", dur ? `apad=whole_dur=${dur},atrim=0:${dur}` : "apad"].filter(Boolean).join(","));
513
+ args.push(...(audio ? audioArgs(spec) : ["-an"]));
514
+ for (const [k, v] of Object.entries(spec.metadata || {})) {
515
+ if (k === "description") args.push("-metadata", `description=${v}`, "-metadata", `comment=${v}`);
516
+ else args.push("-metadata", `${k}=${v}`);
517
+ }
518
+ if (CONTAINERS[spec.container].mov) args.push("-movflags", "+faststart" + (spec.metadata && spec.metadata.keywords ? "+use_metadata_tags" : ""));
519
+ if (o.dry) args.push("-t", "0.1");
520
+ else if (audio && !dur) args.push("-shortest");
521
+ args.push(o.outPath);
522
+ return args;
523
+ }
524
+
525
+ /* Loudness normalization is ffmpeg's two-pass loudnorm (EBU R128): pass one
526
+ measures the mix (LOUDNORM_ARGS → parseLoudnorm), pass two — this filter —
527
+ applies one linear gain to the target, with a true-peak limiter (4×
528
+ oversampled inside loudnorm) at the ceiling. When the peaks leave no room
529
+ for the gain, loudnorm compresses instead, and may land under the target:
530
+ the check after the export says so. */
531
+ const LOUDNORM_ARGS = (file, spec) => ["-hide_banner", "-nostats", "-i", file, "-map", "0:a:0",
532
+ "-af", `loudnorm=I=${spec.audio.loudness}:TP=${spec.audio.truePeak ?? -1}:LRA=11:print_format=json`, "-f", "null", "-"];
533
+ /** loudnorm's pass-one JSON (the last {...} in its stderr) → numbers, or null. */
534
+ function parseLoudnorm(stderr) {
535
+ const m = /\{[^{}]*"input_i"[^{}]*\}/.exec(String(stderr || "").split(/Parsed_loudnorm/).pop() || "");
536
+ if (!m) return null;
537
+ try {
538
+ const j = JSON.parse(m[0]);
539
+ const out = {};
540
+ for (const k of ["input_i", "input_tp", "input_lra", "input_thresh", "target_offset"]) out[k] = Number(j[k]);
541
+ return Object.values(out).every(Number.isFinite) && out.input_i > -70 ? out : null; // silence: nothing to normalize
542
+ } catch { return null; }
543
+ }
544
+ function loudnormFilter(spec, m) {
545
+ const a = spec.audio;
546
+ if (a.loudness == null || !m) return "";
547
+ // linear mode needs the range to fit; 20 LU is the most every ffmpeg build takes
548
+ // (a wider mix makes loudnorm compress instead — the check reports where it lands)
549
+ const lra = Math.min(20, Math.max(11, Math.ceil(m.input_lra + 1)));
550
+ return `loudnorm=I=${a.loudness}:TP=${a.truePeak ?? -1}:LRA=${lra}:measured_I=${m.input_i}:measured_TP=${m.input_tp}` +
551
+ `:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true`;
552
+ }
553
+ /** `-af ebur128=peak=true` summary → {lufs, truePeak} (null where ffmpeg said
554
+ * -inf), plus silent:true when it measured and found nothing (I: -inf). */
555
+ function parseEbur128(stderr) {
556
+ const txt = String(stderr || "");
557
+ const i = [...txt.matchAll(/\bI:\s+(-?[\d.]+|-inf)\s+LUFS/g)].pop();
558
+ const p = [...txt.matchAll(/True peak:\s*[\r\n]+\s*Peak:\s+(-?[\d.]+|-inf)\s+dBFS/g)].pop();
559
+ const val = (m) => (m && m[1] !== "-inf" ? +m[1] : null);
560
+ const out = { lufs: val(i), truePeak: val(p) };
561
+ // nothing above the -70 LUFS gate: ffmpeg prints -70.0 (older builds -inf)
562
+ if (i && (i[1] === "-inf" || +i[1] <= -69.95)) { out.lufs = null; out.silent = true; }
563
+ return out;
564
+ }
565
+ const LOUDNESS_ARGS = (file) => ["-hide_banner", "-nostats", "-i", file, "-map", "0:a:0", "-af", "ebur128=peak=true:framelog=quiet", "-f", "null", "-"];
566
+
567
+ /** What came out vs what was asked: ffprobe JSON of the file (+ its loudness)
568
+ * against the spec. ctx: {fps, frames, duration, size (bytes), loud:{lufs,
569
+ * truePeak}|null, frame (from reframe)}. → {ok, checks:[{ok, text}], line}. */
570
+ function verify(spec, probe, ctx = {}) {
571
+ const checks = [];
572
+ const add = (ok, text) => checks.push({ ok: !!ok, text });
573
+ const streams = (probe && probe.streams) || [];
574
+ const v = streams.find((x) => x.codec_type === "video");
575
+ const a = streams.find((x) => x.codec_type === "audio");
576
+ const fr = ctx.frame || {};
577
+ const near = (x, y, tol) => Math.abs(x - y) <= tol;
578
+ const rate = (r) => { const [n, d] = String(r || "0/1").split("/").map(Number); return d ? n / d : 0; };
579
+ const CODEC = { h264: "h264", hevc: "hevc", av1: "av1", vp9: "vp9", prores: "prores", dnxhr: "dnxhd", mpeg2: "mpeg2video", ffv1: "ffv1" };
580
+ if (!v) add(false, "no video stream in the file");
581
+ else {
582
+ if (fr.outW) add(v.width === fr.outW && v.height === fr.outH, `${v.width}×${v.height}${v.width === fr.outW && v.height === fr.outH ? "" : ` (asked ${fr.outW}×${fr.outH})`}`);
583
+ const got = rate(v.avg_frame_rate) || rate(v.r_frame_rate);
584
+ if (ctx.fps) add(near(got, ctx.fps, 0.01), `${Math.round(got * 1000) / 1000} fps${near(got, ctx.fps, 0.01) ? "" : ` (asked ${ctx.fps})`}`);
585
+ const n = +v.nb_frames;
586
+ if (ctx.frames && Number.isFinite(n) && n > 0) add(n === ctx.frames, `${n} frames${n === ctx.frames ? "" : ` (rendered ${ctx.frames})`}`);
587
+ add(v.codec_name === CODEC[spec.video.codec], `${v.codec_name}${v.codec_name === CODEC[spec.video.codec] ? "" : ` (asked ${spec.video.codec})`}`);
588
+ const c = COLORS[spec.color];
589
+ if (c && v.color_space && v.color_space !== "unknown") add(v.color_space === c.matrix, `color ${v.color_space}${v.color_space === c.matrix ? "" : ` (asked ${c.matrix})`}`);
590
+ }
591
+ if (spec.audio.codec === "none") add(!a, a ? "has an audio stream (asked none)" : "no audio");
592
+ else if (!a) add(!ctx.hasAudio, ctx.hasAudio ? "no audio stream" : "no audio (the timeline is silent)");
593
+ else {
594
+ const A = AUDIO[spec.audio.codec];
595
+ add(a.codec_name === A.encoder || (A.encoder === "libopus" && a.codec_name === "opus") || (A.encoder === "libmp3lame" && a.codec_name === "mp3"),
596
+ `${a.codec_name} ${Math.round(+a.sample_rate / 100) / 10} kHz ${+a.channels === 1 ? "mono" : +a.channels === 2 ? "stereo" : a.channels + " ch"}`);
597
+ if (+a.sample_rate !== spec.audio.sampleRate) add(false, `sample rate ${a.sample_rate} (asked ${spec.audio.sampleRate})`);
598
+ if (+a.channels !== spec.audio.channels) add(false, `${a.channels} channel${+a.channels === 1 ? "" : "s"} (asked ${spec.audio.channels})`);
599
+ }
600
+ const dur = +(probe && probe.format && probe.format.duration);
601
+ if (ctx.duration && Number.isFinite(dur)) add(near(dur, ctx.duration, Math.max(0.05, 1.5 / (ctx.fps || 30))), `${fmtClock(dur)}${near(dur, ctx.duration, Math.max(0.05, 1.5 / (ctx.fps || 30))) ? "" : ` (timeline ${fmtClock(ctx.duration)})`}`);
602
+ const L = ctx.loud;
603
+ if (ctx.loudWanted && a && !(L && L.lufs != null) && !(L && L.silent)) add(false, "loudness could not be measured");
604
+ // silence was measured: fine as mixed, but it cannot meet a loudness target
605
+ if (L && L.silent && a) add(spec.audio.loudness == null, `silent${spec.audio.loudness != null ? ` (target ${spec.audio.loudness} LUFS)` : ""}`);
606
+ if (L && L.lufs != null) {
607
+ const target = spec.audio.loudness;
608
+ const tp = L.truePeak;
609
+ if (target != null) {
610
+ add(near(L.lufs, target, 1), `${L.lufs.toFixed(1)} LUFS${near(L.lufs, target, 1) ? "" : ` (target ${target})`}`);
611
+ if (tp != null) add(tp <= (spec.audio.truePeak ?? -1) + 0.5, `${tp.toFixed(1)} dBTP${tp <= (spec.audio.truePeak ?? -1) + 0.5 ? "" : ` (ceiling ${spec.audio.truePeak ?? -1})`}`);
612
+ } else {
613
+ const ref = PLATFORM_LOUDNESS[spec.preset];
614
+ const loudNote = ref != null && L.lufs > ref + 1 ? ` — ${PRESETS[spec.preset].label} plays it ${(L.lufs - ref).toFixed(1)} dB quieter (set Loudness to ${ref} LUFS to match)` : "";
615
+ add(true, `${L.lufs.toFixed(1)} LUFS${tp != null ? ` · ${tp.toFixed(1)} dBTP` : ""}${loudNote}`);
616
+ if (tp != null && tp > 0) add(false, `true peak ${tp.toFixed(1)} dBTP — over 0, it will clip after encoding`);
617
+ }
618
+ }
619
+ const lim = (PRESETS[spec.preset] && PRESETS[spec.preset].limits) || {};
620
+ if (ctx.size) add(!(lim.size && ctx.size > lim.size), `${fmtBytes(ctx.size)}${lim.size && ctx.size > lim.size ? ` (over ${PRESETS[spec.preset].label}'s ${fmtBytes(lim.size)})` : ""}`);
621
+ const bad = checks.filter((x) => !x.ok);
622
+ return { ok: !bad.length, checks, line: checks.map((x) => (x.ok ? "" : "⚠ ") + x.text).join(" · ") };
623
+ }
624
+ function fmtClock(s) {
625
+ const m = Math.floor(s / 60), r = s - m * 60;
626
+ return `${m}:${r < 10 ? "0" : ""}${r.toFixed(2)}`;
627
+ }
628
+
629
+ /** How the browser should hand frames over: JPEG is 4:2:0 8-bit, so
630
+ * anything that keeps more chroma or bit depth gets raw RGBA instead. */
631
+ function frameInput(spec) {
632
+ const v = spec.video;
633
+ return v.codec === "ffv1" || /422|444|10le/.test(v.pixFmt) ? "rgba" : "jpeg";
634
+ }
635
+ function jpegQuality(spec) {
636
+ const v = spec.video;
637
+ if (v.rate === "crf") return v.crf <= 16 ? 0.98 : v.crf <= 20 ? 0.95 : 0.9;
638
+ return 0.95;
639
+ }
640
+
641
+ /* ── Names, summaries, checks ───────────────────────────────────────── */
642
+
643
+ function pad2(n) { return String(n).padStart(2, "0"); }
644
+ /** ctx: {project, preset (label or id), width, height, fps, range, date: Date, index} */
645
+ function renderFilename(template, spec, ctx = {}) {
646
+ const d = ctx.date instanceof Date ? ctx.date : new Date();
647
+ const w = ctx.width || spec.width || 0, h = ctx.height || spec.height || 0;
648
+ const fps = ctx.fps || spec.fps || 0;
649
+ const res = w && h ? (w > h ? `${h}p` : w === h ? `${w}sq` : `${w}x${h}`) : "source";
650
+ const vals = {
651
+ project: ctx.project || "export",
652
+ preset: spec.preset || "custom",
653
+ w, h, width: w, height: h, res,
654
+ fps: fps ? String(Math.round(fps * 100) / 100).replace(".", "_") : "",
655
+ codec: spec.video.codec,
656
+ date: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
657
+ time: `${pad2(d.getHours())}${pad2(d.getMinutes())}`,
658
+ range: ctx.range === "in-out" ? "inout" : "full",
659
+ n: ctx.index != null ? String(ctx.index) : "1",
660
+ };
661
+ let s = String(template || "{project}").replace(/\{(\w+)\}/g, (m, k) => (k in vals ? String(vals[k]) : m));
662
+ s = s.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_").replace(/\s+/g, " ").trim().replace(/^\.+/, "");
663
+ return s.slice(0, 120) || "export";
664
+ }
665
+ /** The file name an output gets in a project (its size, export frame and fps). */
666
+ function projectFilename(spec, proj = {}) {
667
+ const fr = reframe(spec, proj.width || 1920, proj.height || 1080, proj.exportFrame || null);
668
+ return renderFilename(spec.filename, spec, { project: proj.name, width: fr.outW, height: fr.outH, fps: spec.fps || proj.fps });
669
+ }
670
+ const FILENAME_TOKENS = ["project", "preset", "res", "w", "h", "fps", "codec", "date", "time", "range", "n"];
671
+
672
+ function fmtRate(kbps) { return kbps >= 1000 ? `${Math.round(kbps / 100) / 10} Mb/s` : `${kbps} kb/s`; }
673
+ function describe(spec, ctx = {}) {
674
+ const v = spec.video, a = spec.audio, C = CODECS[v.codec];
675
+ const w = spec.width || ctx.width, h = spec.height || ctx.height;
676
+ const parts = [w && h ? `${w}×${h}` : "source size", `${spec.fps || ctx.fps || "project"} fps`];
677
+ let vid = C.label.replace(/ \(.+\)$/, "");
678
+ if (v.profile && C.profiles) vid += " " + C.profiles[v.profile];
679
+ if (v.rate === "crf") vid += ` CRF ${v.crf}` + (v.maxrate ? ` ≤ ${fmtRate(v.maxrate)}` : "");
680
+ else if (v.rate === "vbr") vid += ` VBR ${fmtRate(v.bitrate)}`;
681
+ else if (v.rate === "cbr") vid += ` CBR ${fmtRate(v.bitrate)}`;
682
+ parts.push(vid);
683
+ parts.push(a.codec === "none" ? "no audio" : `${AUDIO[a.codec].label}${a.bitrate ? " " + a.bitrate + "k" : ""}${a.loudness != null ? ` · ${a.loudness} LUFS` : ""}`);
684
+ parts.push(spec.container.toUpperCase());
685
+ return parts.join(" · ");
686
+ }
687
+
688
+ /* Rough Mb/s at 1920×1080 30 fps for the fixed-rate codecs (Apple's and
689
+ Avid's published figures), scaled by pixels × fps. */
690
+ const FIXED_MBPS = { prores: { proxy: 45, lt: 102, standard: 147, hq: 220, 4444: 330, "4444xq": 500 }, dnxhr: { lb: 45, sq: 145, hq: 220, hqx: 220, 444: 440 } };
691
+ /** Bytes, or null when it cannot be known before encoding (CRF, lossless). */
692
+ function estimateSize(spec, ctx) {
693
+ const v = spec.video, a = spec.audio, dur = ctx.duration || 0;
694
+ const w = spec.width || ctx.width || 1920, h = spec.height || ctx.height || 1080, fps = spec.fps || ctx.fps || 30;
695
+ let vk = null;
696
+ if (v.rate === "vbr" || v.rate === "cbr") vk = v.bitrate;
697
+ else if (v.codec === "mpeg2") vk = 50000;
698
+ else if (FIXED_MBPS[v.codec]) vk = FIXED_MBPS[v.codec][v.profile] * 1000 * (w * h * fps) / (1920 * 1080 * 30);
699
+ if (vk == null) return null;
700
+ const ak = a.codec === "none" ? 0 : a.bitrate || (a.codec === "pcm24" ? 24 : 16) * a.sampleRate * a.channels / 1000;
701
+ return Math.round((vk + ak) * 1000 / 8 * dur);
702
+ }
703
+
704
+ /** Warnings worth reading before a long render.
705
+ * ctx: {duration, fps (output), canvasW, canvasH, region, presets} */
706
+ function checks(spec, ctx = {}) {
707
+ const out = [];
708
+ const preset = PRESETS[spec.preset];
709
+ const lim = (preset && preset.limits) || {};
710
+ const fr = reframe(spec, ctx.canvasW || 1920, ctx.canvasH || 1080, ctx.region);
711
+ const fps = spec.fps || ctx.fps || 30;
712
+ if (fr.upscale) out.push(`Upscaled: the part of the picture used is ${fr.from.w}×${fr.from.h} and the output ${fr.outW}×${fr.outH} — a larger${spec.width && spec.height > spec.width ? " or vertical" : ""} project size keeps it sharp`);
713
+ if (spec.width && spec.reframe === "fill" && fr.crop && ctx.canvasW && (fr.crop.w < (ctx.region ? ctx.region.w : ctx.canvasW) - 2 || fr.crop.h < (ctx.region ? ctx.region.h : ctx.canvasH) - 2))
714
+ out.push(`Cropped to ${fr.crop.w}×${fr.crop.h} from the middle of the picture — set an export frame (⬒ Frame) to choose the part, or use Fit to keep everything`);
715
+ if (lim.vertical && spec.width && spec.width > spec.height) out.push(`${preset.label} expects a vertical picture; this output is landscape`);
716
+ if (lim.duration && ctx.duration > lim.duration + 0.05) out.push(`${preset.label}: ${fmtDur(ctx.duration)} is longer than ${fmtDur(lim.duration)} — check the platform's current limit`);
717
+ if (lim.fps && fps > lim.fps + 0.01) out.push(`${preset.label} takes up to ${lim.fps} fps; this output is ${fps}`);
718
+ if (lim.fpsList && !lim.fpsList.some((f) => Math.abs(f - fps) < 0.02)) out.push(`Broadcast usually wants ${lim.fpsList.join(" / ")} fps; this output is ${fps}`);
719
+ const size = estimateSize(spec, { duration: ctx.duration, width: fr.outW, height: fr.outH, fps });
720
+ if (lim.size && size && size > lim.size) out.push(`${preset.label} takes files up to ${fmtBytes(lim.size)}; this one will be about ${fmtBytes(size)}`);
721
+ if (spec.video.codec === "mpeg2" && (fr.outW !== 1920 || fr.outH !== 1080)) out.push("XDCAM HD422 is 1920×1080 — other sizes may be refused by playout servers");
722
+ return out;
723
+ }
724
+ function fmtDur(s) { s = Math.round(s); const m = Math.floor(s / 60); return m ? `${m} min${s % 60 ? ` ${s % 60} s` : ""}` : `${s} s`; }
725
+ function fmtBytes(b) { return b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GB` : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(b >= 100 * 1024 ** 2 ? 0 : 1)} MB` : `${Math.max(1, Math.round(b / 1024))} KB`; }
726
+
727
+ /** Presets in dropdown order, grouped: [{group, items:[{id, label, note}]}].
728
+ * User presets (id → spec with a label) come under "Saved". */
729
+ function presetGroups(userPresets = {}) {
730
+ const groups = [];
731
+ const add = (g, id, p) => {
732
+ let grp = groups.find((x) => x.group === g);
733
+ if (!grp) groups.push(grp = { group: g, items: [] });
734
+ grp.items.push({ id, label: p.label || id, note: p.note || "" });
735
+ };
736
+ for (const id of PRESET_IDS) if (PRESETS[id].group !== "Custom") add(PRESETS[id].group, id, PRESETS[id]);
737
+ for (const [id, p] of Object.entries(userPresets)) add("Saved", id, p);
738
+ add("Custom", "custom", PRESETS.custom);
739
+ return groups;
740
+ }
741
+
742
+ /** A user preset id from its name: "My client 4K" → "my-client-4k". */
743
+ function presetId(name) {
744
+ return String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
745
+ }
746
+
747
+ /** Saved presets from library/delivery/*.json (paths.readSaved output:
748
+ * [{file, data}]) → {id: spec + label + note}. Built-in ids cannot be shadowed. */
749
+ function savedPresets(list) {
750
+ const out = {};
751
+ for (const { file, data } of list || []) {
752
+ if (!data || typeof data !== "object") continue;
753
+ const id = presetId(data.id || String(file || "").replace(/\.json$/i, ""));
754
+ if (!id || PRESETS[id]) continue;
755
+ const { spec } = normalize({ ...data, preset: "custom" });
756
+ out[id] = { ...spec, preset: id, label: cleanText(data.label || id, 60), note: describe(spec) };
757
+ }
758
+ return out;
759
+ }
760
+
761
+ /** Parse `ffmpeg -encoders` output into a Set of encoder names. */
762
+ function parseEncoders(text) {
763
+ const set = new Set();
764
+ for (const line of String(text || "").split(/\r?\n/)) {
765
+ const m = /^\s*[VAS][.A-Z]{5}\s+(\S+)/.exec(line);
766
+ if (m && m[1] !== "=") set.add(m[1]);
767
+ }
768
+ return set;
769
+ }
770
+
771
+ return {
772
+ CONTAINERS, CODECS, ENCODERS, AUDIO, COLORS, PRESETS, PRESET_IDS, DEFAULT_PRESET,
773
+ FPS_CHOICES, RESOLUTIONS, SPEEDS, SAMPLE_RATES, META_KEYS, FILENAME_TOKENS,
774
+ normalize, encodersFor, reframe, ffmpegArgs, videoFilter, frameInput, jpegQuality, fpsRational,
775
+ renderFilename, projectFilename, describe, estimateSize, checks, presetGroups, presetId, savedPresets, parseEncoders,
776
+ PLATFORM_LOUDNESS, LOUDNESS_TARGETS, LOUDNESS_ARGS, LOUDNORM_ARGS, parseLoudnorm, loudnormFilter, parseEbur128, verify,
777
+ fmtBytes, fmtDur,
778
+ };
779
+ });