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/CLAUDE.md +373 -11
- package/README.md +48 -5
- package/analyze.js +161 -9
- package/app.js +1894 -109
- package/delivery.js +779 -0
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +136 -4
- package/mcp-server.js +318 -1070
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/package.json +1 -1
- package/paths.js +9 -2
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +498 -15
- package/style.css +361 -0
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
|
+
});
|