1// ffmpeg/ffprobe/magick-based processors: duration, dimensions, optimized
2// image subsets, av1+opus+dash streaming variants, and h264 hls. bodies are
3// ported unchanged from the old file-scan.ts.
4const ffprobeBin = testProgram("ffprobe", "--help");
5const ffmpegBin = testProgram("ffmpeg", "--help");
6
7const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];
8
9const procDuration: Processor = {
10 name: "duration",
11 version: 1,
12 title: "calculate duration",
13 enable: ffprobeBin !== null,
14 include: rules.extsDuration,
15 cores: 1,
16 async run({ path, mediaFile, signal }) {
17 const { stdout } = await subprocess.exec(ffprobeBin!, [
18 "-v",
19 "error",
20 "-show_entries",
21 "format=duration",
22 "-of",
23 "default=noprint_wrappers=1:nokey=1",
24 path.toString(),
25 ], { signal });
26
27 const duration = parseFloat(stdout.trim());
28 if (Number.isNaN(duration)) {
29 throw new Error("Could not extract duration from " + stdout);
30 }
31 mediaFile.setDuration(Math.ceil(duration));
32 },
33};
34
35const procDimensions: Processor = {
36 name: "dimensions",
37 version: 2,
38 title: "calculate dimensions",
39 enable: ffprobeBin != null,
40 include: rules.extsDimensions,
41 cores: 1,
42 async run({ path, mediaFile, signal }) {
43 const { ext } = path;
44
45 let dimensions;
46
47 if (ext === ".svg") {
48 // Parse out of text data
49 const content = await path.read("utf-8");
50 const widthMatch = content.match(/width="(\d+)"/);
51 const heightMatch = content.match(/height="(\d+)"/);
52
53 if (widthMatch && heightMatch) {
54 dimensions = `${widthMatch[1]}x${heightMatch[1]}`;
55 }
56 } else if (rules.extsImage.has(ext)) {
57 // Use magick to observe streams
58 const { stdout } = await subprocess.exec("magick", [
59 "identify",
60 "-auto-orient",
61 "-format",
62 "%w %h",
63 path.toString(),
64 ], { signal });
65 const [w, h] = stdout.split(" ").map((x) => Number(x));
66 if (w && h) {
67 dimensions = w + "x" + h;
68 }
69 } else {
70 // Use ffprobe to observe streams
71 const { stdout } = await subprocess.exec("ffprobe", [
72 "-v",
73 "error",
74 "-select_streams",
75 "v:0",
76 "-show_entries",
77 "stream=width,height:stream_side_data=rotation",
78 "-of",
79 "json",
80 path.toString(),
81 ], { signal });
82 const result = JSON.parse(stdout);
83 const stream = result.streams[0];
84 if (stream) {
85 let width = UNWRAP(stream.width);
86 let height = UNWRAP(stream.height);
87 // phone videos store the sensor's landscape frame plus a display
88 // matrix; everything downstream (scale filters, aspect-ratio css)
89 // wants the rotated display dimensions.
90 const rotation = (stream.side_data_list ?? [])
91 .find((s: any) => typeof s.rotation === "number")?.rotation ?? 0;
92 if (Math.abs(rotation) % 180 === 90) {
93 [width, height] = [height, width];
94 }
95 dimensions = width + "x" + height;
96 }
97 }
98
99 mediaFile.setDimensions(dimensions ?? "");
100 },
101};
102
103const procImageSubsets: Processor = {
104 name: "image-subsets",
105 version: 3,
106 title: "encode image subsets",
107 include: rules.extsImage,
108 depends: [procDimensions.name],
109 async run({ path, mediaFile, node, signal }) {
110 const dims = mediaFile.parseDimensions();
111 // dimensions could not be extracted; nothing to scale.
112 if (!dims) return;
113 const { width, height } = dims;
114 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
115 const baseStatus = node.text;
116
117 using stack = new DisposableStack();
118 for (const size of targetSizes) {
119 const { w, h } = resizeDimensions(width, height, size);
120 for (const { ext, args } of transcodeRules.imagePresets) {
121 node.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
122
123 stack.use(
124 await derived.produce({
125 mediaFile,
126 node,
127 cores: 2,
128 subkey: `${size}${ext}`,
129 async producer(dir) {
130 await subprocess.exec(ffmpegBin!, [
131 ...ffmpegOptions,
132 "-i",
133 path.toString(),
134 "-vf",
135 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
136 ...args,
137 dir.join(`${size}${ext}`).toString(),
138 ], { signal });
139 },
140 }),
141 );
142 }
143 }
144
145 stack.move();
146 },
147};
148
149const videoArgsCache = new async.OnceMap(transcodeRules.getVideoInputArgs);
150
151const qualityMap: Record<string, string> = {
152 u: "ultra-high",
153 h: "high",
154 m: "medium",
155 l: "low",
156 d: "data-saving",
157};
158
159function skipVideo(mediaFile: MediaFile) {
160 if ((mediaFile.duration ?? 0) < 5) return true;
161 if (!mediaFile.dimensions) return true;
162 if (rules.processDenyList.has(mediaFile.path)) return true;
163 return false;
164}
165
166const procVideos = transcodeRules.videoFormats.map<Processor>((preset) => ({
167 name: `av1-${preset.id}`,
168 version: 4,
169 title: `encode av1 ${UNWRAP(qualityMap[UNWRAP(preset.id[1])])}`,
170 include: rules.extsVideo,
171 enable: ffmpegBin != null,
172 depends: [procDuration.name, procDimensions.name],
173 async run({ path, mediaFile, node, signal }) {
174 if (skipVideo(mediaFile)) return;
175
176 await derived.produce({
177 mediaFile,
178 node,
179 cores: 4,
180 subkey: `av1-${preset.id}`,
181 async producer(dir) {
182 const input = await videoArgsCache.getOrRun(path);
183 const dimensions = mediaFile.parseDimensions();
184 ASSERT(dimensions);
185 const args = transcodeRules.getAv1VideoArgs(
186 preset,
187 dimensions,
188 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),
189 dir,
190 );
191
192 await ffmpeg.spawn({
193 ffmpeg: ffmpegBin!,
194 progress: node,
195 args,
196 cwd: dir.toString(),
197 signal,
198 });
199 },
200 });
201 },
202}));
203
204const procVideoAudios = transcodeRules.audioFormats.map<Processor>((preset) => ({
205 name: `opus-${preset.id}`,
206 version: 3,
207 title: `encode opus ${UNWRAP(qualityMap[UNWRAP(preset.id)])}`,
208 include: rules.extsVideo,
209 enable: ffmpegBin != null,
210 depends: [procDuration.name, procDimensions.name],
211 async run({ path, mediaFile, node, signal }) {
212 if (skipVideo(mediaFile)) return;
213 await derived.produce({
214 mediaFile,
215 node,
216 cores: 4,
217 subkey: `opus-${preset.id}`,
218 async producer(dir) {
219 const input = await videoArgsCache.getOrRun(path);
220 ASSERT(input.video);
221 if (!input.audio) return;
222 const args = transcodeRules.getOpusAudioArgs(preset, input.audio, dir);
223
224 await ffmpeg.spawn({
225 ffmpeg: ffmpegBin!,
226 progress: node,
227 args,
228 cwd: dir.toString(),
229 signal,
230 });
231 },
232 });
233 },
234}));
235
236const procDash: Processor = {
237 name: "dash",
238 version: 7,
239 title: "encode mpeg-dash",
240 include: rules.extsVideo,
241 enable: ffmpegBin != null,
242 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
243 async run({ path, mediaFile, node, signal }) {
244 if (skipVideo(mediaFile)) return;
245 await derived.produce({
246 mediaFile,
247 node,
248 cores: 1,
249 subkey: `dash-av1`,
250 async producer(dir) {
251 const input = await videoArgsCache.getOrRun(path);
252 const videos = transcodeRules.videoFormats.map(
253 (preset) =>
254 UNWRAP(dir.parent).join(
255 `av1-${preset.id}`,
256 transcodeRules.av1FileName,
257 ),
258 );
259 const audios = input.audio
260 ? transcodeRules.audioFormats.map(
261 (preset) =>
262 UNWRAP(dir.parent).join(
263 `opus-${preset.id}`,
264 transcodeRules.opusFileName,
265 ),
266 )
267 : [];
268 const args = transcodeRules.getMpegDashArgs(
269 videos,
270 audios,
271 dir,
272 );
273
274 await dir.join("d").makeDir();
275
276 await ffmpeg.spawn({
277 ffmpeg: ffmpegBin!,
278 progress: node,
279 args,
280 cwd: dir.toString(),
281 signal,
282 });
283 },
284 });
285 },
286};
287
288const procH264Hls: Processor = {
289 name: "h264-hls",
290 version: 1,
291 title: "encode h.264 hls",
292 include: rules.extsVideo,
293 enable: ffmpegBin != null,
294 depends: [procDuration.name, procDimensions.name],
295 async run({ path, mediaFile, node, signal }) {
296 if (skipVideo(mediaFile)) return;
297
298 await derived.produce({
299 mediaFile,
300 node,
301 cores: 8,
302 subkey: `hls`,
303 async producer(dir) {
304 const input = await videoArgsCache.getOrRun(path);
305 const args = transcodeRules.getH264HlsArgs(input, dir);
306
307 await ffmpeg.spawn({
308 ffmpeg: ffmpegBin!,
309 progress: node,
310 args,
311 cwd: dir.toString(),
312 signal,
313 });
314 },
315 });
316 },
317};
318
319function resizeDimensions(w: number, h: number, desiredWidth: number) {
320 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
321 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
322}
323
324function testProgram(name: string, helpArgument: string) {
325 // spawnSync does not throw on a missing binary; it reports `error`
326 const result = child_process.spawnSync(name, [helpArgument]);
327 if (!result.error) return name;
328 console.warn(`Missing or corrupt executable '${name}'`);
329 return null;
330}
331
332export const processors: Processor[] = [
333 procDimensions,
334 procDuration,
335 procImageSubsets,
336 ...procVideos,
337 ...procVideoAudios,
338 procDash,
339 procH264Hls,
340];
341
342import { ASSERT, UNWRAP } from "@clo/lib/assert";
343import * as async from "@clo/lib/async";
344import * as subprocess from "@clo/lib/subprocess";
345import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
346import * as child_process from "node:child_process";
347
348import * as derived from "#src/file-viewer/models/derived.ts";
349import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
350import * as rules from "#src/file-viewer/rules.ts";
351import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
352import type { Processor } from "../registry.ts";