| 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. |
| 4 | const ffprobeBin = testProgram("ffprobe", "--help"); |
| 5 | const ffmpegBin = testProgram("ffmpeg", "--help"); |
| 6 | |
| 7 | const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"]; |
| 8 | |
| 9 | const 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 | |
| 35 | const 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 | |
| 103 | const 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 | |
| 149 | const videoArgsCache = new async.OnceMap(transcodeRules.getVideoInputArgs); |
| 150 | |
| 151 | const qualityMap: Record<string, string> = { |
| 152 | u: "ultra-high", |
| 153 | h: "high", |
| 154 | m: "medium", |
| 155 | l: "low", |
| 156 | d: "data-saving", |
| 157 | }; |
| 158 | |
| 159 | function 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 | |
| 166 | const 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 | |
| 204 | const 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 | |
| 236 | const 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 | |
| 288 | const 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 | |
| 319 | function 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 | |
| 324 | function 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 | |
| 332 | export const processors: Processor[] = [ |
| 333 | procDimensions, |
| 334 | procDuration, |
| 335 | procImageSubsets, |
| 336 | ...procVideos, |
| 337 | ...procVideoAudios, |
| 338 | procDash, |
| 339 | procH264Hls, |
| 340 | ]; |
| 341 | |
| 342 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 343 | import * as async from "@clo/lib/async"; |
| 344 | import * as subprocess from "@clo/lib/subprocess"; |
| 345 | import * as ffmpeg from "@clo/lib/subprocess/ffmpeg"; |
| 346 | import * as child_process from "node:child_process"; |
| 347 | |
| 348 | import * as derived from "#src/file-viewer/models/derived.ts"; |
| 349 | import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; |
| 350 | import * as rules from "#src/file-viewer/rules.ts"; |
| 351 | import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; |
| 352 | import type { Processor } from "../registry.ts"; |