/** * Video attachments via system ffmpeg/ffprobe. * * Vision models only see images, so videos enter context as a PNG contact * sheet (evenly spaced frames tiled into one image) plus a text block with * the usual metadata (resolution, codecs, duration, fps). A `:selector` * suffix extracts a single frame instead: a bare number is a frame index, * anything timestamp-shaped is a seek position. */ import * as path from "node:path"; import { rasterizeSvg } from "@oh-my-pi/pi-natives"; import { isVideoPath } from "@oh-my-pi/pi-tui/prompt/video"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; import { TempDir } from "@oh-my-pi/pi-utils/temp"; import { $which } from "@oh-my-pi/pi-utils/which"; const VIDEO_MIME_BY_EXT: Record = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".webm": "video/webm", ".m4v": "video/x-m4v", ".avi": "video/x-msvideo", ".wmv": "video/x-ms-wmv", }; /** Container MIME for a video path, or undefined for non-video. */ export function videoMimeForPath(filePath: string): string | undefined { return VIDEO_MIME_BY_EXT[path.extname(filePath).toLowerCase()]; } /** Frame (`clip.mp4:412`) or timestamp (`clip.mp4:1h5m42s`) subread selector. */ export type VideoSelector = { kind: "frame"; frame: number } | { kind: "time"; seconds: number; raw: string }; const FRAME_SELECTOR_RE = /^(?:f|frame)?(\d+)$/i; const COLON_TIMESTAMP_RE = /^(?:(\d+):)?([0-5]?\d):([0-5]?\d(?:\.\d+)?)$/; const UNIT_TIMESTAMP_RE = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$/i; const BARE_SECONDS_RE = /^\d+\.\d+$/; /** * Parse a read-selector suffix for a video path. Bare integers are frame * indices; colon/unit timestamps and fractional seconds are seek positions. * Returns null when the suffix is not video-selector-shaped. */ export function parseVideoSelector(sel: string | undefined): VideoSelector | null { if (sel === undefined || sel.length === 0) return null; const trimmed = sel.trim(); const frameMatch = FRAME_SELECTOR_RE.exec(trimmed); // A bare integer is always a frame index — timestamps need a unit, a colon, // or a fraction, so `:412` means frame 412 while `:42s` seeks to 42 seconds. if (frameMatch && !trimmed.includes(":") && !trimmed.toLowerCase().endsWith("s")) { const frame = Number.parseInt(frameMatch[1]!, 10); return Number.isSafeInteger(frame) ? { kind: "frame", frame } : null; } const seconds = parseVideoTimestamp(trimmed); return seconds === null ? null : { kind: "time", seconds, raw: trimmed }; } /** Parse a timestamp suffix into seconds, or null when not timestamp-shaped. */ export function parseVideoTimestamp(value: string): number | null { const colon = COLON_TIMESTAMP_RE.exec(value); if (colon) { const hours = colon[1] === undefined ? 0 : Number.parseInt(colon[1], 10); const minutes = Number.parseInt(colon[2]!, 10); const seconds = Number.parseFloat(colon[3]!); return hours * 3600 + minutes * 60 + seconds; } const unit = UNIT_TIMESTAMP_RE.exec(value); if (unit && (unit[1] !== undefined || unit[2] !== undefined || unit[3] !== undefined)) { const hours = unit[1] === undefined ? 0 : Number.parseFloat(unit[1]); const minutes = unit[2] === undefined ? 0 : Number.parseFloat(unit[2]); const seconds = unit[3] === undefined ? 0 : Number.parseFloat(unit[3]); if (value.toLowerCase().endsWith("m") && unit[3] === undefined) return hours * 3600 + minutes * 60; return hours * 3600 + minutes * 60 + seconds; } if (BARE_SECONDS_RE.test(value)) return Number.parseFloat(value); return null; } /** * Split a trailing video selector off a raw read path (`clip.mp4:1h5m42s`). * Pure string split — the caller checks the base resolves to a video file so * real filenames containing colons keep precedence. */ const VIDEO_PATH_SELECTOR_RE = /^(.*\.(?:mp4|mov|mkv|webm|m4v|avi|wmv)):(.+)$/is; export function splitVideoReadTarget(rawPath: string): { path: string; sel: string } | null { const match = VIDEO_PATH_SELECTOR_RE.exec(rawPath); if (!match) return null; const base = match[1]!; const sel = match[2]!; if (!isVideoPath(base) || parseVideoSelector(sel) === null) return null; return { path: base, sel }; } /** Stream-level facts surfaced in the text details block. */ export interface VideoMetadata { readonly durationSec: number | undefined; readonly videoCodec: string | undefined; readonly audioCodec: string | undefined; readonly width: number | undefined; readonly height: number | undefined; readonly fps: number | undefined; readonly formatName: string | undefined; } /** Raised when ffmpeg/ffprobe is missing or a video operation fails. Message is user-facing. */ export class VideoError extends Error {} interface FfmpegRelease { readonly major: number; readonly minor: number; } let ffmpegReleaseProbe: { binary: string; release: Promise } | undefined; /** Major and minor release from `ffmpeg -version`, probed once per binary; undefined for unnumbered (git) builds. */ function ffmpegRelease(): Promise { const binary = requireMediaBinary("ffmpeg"); if (ffmpegReleaseProbe?.binary !== binary) { ffmpegReleaseProbe = { binary, release: probeFfmpegRelease(binary) }; } return ffmpegReleaseProbe.release; } async function probeFfmpegRelease(binary: string): Promise { try { const child = Bun.spawn([binary, "-version"], { stdout: "pipe", stderr: "pipe" }); const [stdout] = await Promise.all([readStream(child.stdout), readStream(child.stderr), child.exited]); const match = /^ffmpeg version n?(\d+)\.(\d+)/.exec(stdout); return match ? { major: Number(match[1]), minor: Number(match[2]) } : undefined; } catch { return undefined; } } /** * Frame-passthrough flag across the ffmpeg 5.1 rename (`-vsync 0` became * `-fps_mode passthrough`, and ffmpeg 9 removed `-vsync` entirely). Unknown * releases keep the modern spelling. */ async function framePassthroughFlag(): Promise { try { const release = await ffmpegRelease(); if (release && release.major < 5) return ["-vsync", "0"]; } catch {} return ["-fps_mode", "passthrough"]; } /** Resolve a system media binary or throw a user-facing install hint. */ export function requireMediaBinary(name: "ffmpeg" | "ffprobe"): string { const found = $which(name); if (!found) { throw new VideoError( `Video operations require ${name}, which was not found on PATH. Install it (e.g. \`brew install ffmpeg\`) and retry.`, ); } return found; } interface FfprobeStream { codec_type?: string; codec_name?: string; width?: number; height?: number; avg_frame_rate?: string; r_frame_rate?: string; } interface FfprobeFormat { duration?: string; format_name?: string; } function parseFrameRate(value: string | undefined): number | undefined { if (!value || value === "0/0") return undefined; const slash = value.indexOf("/"); if (slash === -1) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } const num = Number.parseFloat(value.slice(0, slash)); const den = Number.parseFloat(value.slice(slash + 1)); if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0 || num <= 0) return undefined; return num / den; } /** * Probe container metadata with ffprobe. Never throws on unparseable output — * unknown fields come back undefined so the read still yields a contact sheet. */ export async function probeVideo(absolutePath: string, signal?: AbortSignal): Promise { const ffprobe = requireMediaBinary("ffprobe"); if (signal?.aborted) throw new VideoError("Video operation aborted."); const child = Bun.spawn( [ ffprobe, "-v", "error", "-show_entries", "format=duration,format_name:stream=index,codec_type,codec_name,width,height,avg_frame_rate,r_frame_rate", "-of", "json", absolutePath, ], { stdout: "pipe", stderr: "pipe" }, ); const killOnAbort = () => { child.kill(); }; signal?.addEventListener("abort", killOnAbort, { once: true }); let stdout: string; let stderr: string; let exitCode: number; try { [stdout, stderr, exitCode] = await untilAborted( signal, Promise.all([readStream(child.stdout), readStream(child.stderr), child.exited]), ); } catch (error) { if (signal?.aborted) throw new VideoError("Video operation aborted."); throw error; } finally { signal?.removeEventListener("abort", killOnAbort); } if (exitCode !== 0) { throw new VideoError( `Could not probe video '${path.basename(absolutePath)}': ${stderr.trim().slice(-300) || `ffprobe exited ${exitCode}`}`, ); } let parsed: { streams?: FfprobeStream[]; format?: FfprobeFormat }; try { parsed = JSON.parse(stdout); } catch { throw new VideoError(`Could not probe video '${path.basename(absolutePath)}': unexpected ffprobe output.`); } const video = parsed.streams?.find(stream => stream.codec_type === "video"); const audio = parsed.streams?.find(stream => stream.codec_type === "audio"); const duration = parsed.format?.duration === undefined ? undefined : Number.parseFloat(parsed.format.duration); return { durationSec: duration !== undefined && Number.isFinite(duration) && duration > 0 ? duration : undefined, videoCodec: video?.codec_name, audioCodec: audio?.codec_name, width: video?.width, height: video?.height, fps: parseFrameRate(video?.avg_frame_rate) ?? parseFrameRate(video?.r_frame_rate), formatName: parsed.format?.format_name, }; } async function readStream(stream: ReadableStream | null): Promise { if (!stream) return ""; const reader = stream.getReader(); const chunks: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } return Buffer.concat(chunks).toString("utf-8"); } /** Format seconds as `1h5m42s` (or `m:ss` below an hour) for details blocks. */ export function formatVideoTimestamp(totalSeconds: number): string { const floored = Math.max(0, Math.floor(totalSeconds)); const hours = Math.floor(floored / 3600); const minutes = Math.floor((floored % 3600) / 60); const seconds = floored % 60; if (hours > 0) return `${hours}h${minutes}m${String(seconds).padStart(2, "0")}s`; return `${minutes}:${String(seconds).padStart(2, "0")}`; } function formatDurationLong(totalSeconds: number): string { const floored = Math.max(0, Math.floor(totalSeconds)); const hours = Math.floor(floored / 3600); const minutes = Math.floor((floored % 3600) / 60); const seconds = floored % 60; const parts: string[] = []; if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || hours > 0) parts.push(`${minutes}m`); parts.push(`${seconds}s`); return parts.join(" "); } /** * One-line text details for a video: name, container, duration, resolution, * fps, codecs, file size. Shared by reads and @mention/CLI attachments. */ export function formatVideoDetails( displayPath: string, meta: VideoMetadata, fileSize: number, mimeType: string | undefined, ): string { const lines = [`Video: ${displayPath}`]; if (mimeType) lines.push(`- Container: ${mimeType}${meta.formatName ? ` (${meta.formatName})` : ""}`); else if (meta.formatName) lines.push(`- Container: ${meta.formatName}`); lines.push( `- Duration: ${meta.durationSec !== undefined ? `${formatDurationLong(meta.durationSec)} (${meta.durationSec.toFixed(1)}s)` : "unknown"}`, `- Resolution: ${meta.width !== undefined && meta.height !== undefined ? `${meta.width}x${meta.height}` : "unknown"}`, `- FPS: ${meta.fps !== undefined ? meta.fps.toFixed(2) : "unknown"}`, `- Video codec: ${meta.videoCodec ?? "unknown"}`, `- Audio codec: ${meta.audioCodec ?? "none"}`, `- Size: ${formatByteSize(fileSize)}`, ); return lines.join("\n"); } /** Binary-unit size (`512 B`, `1.5 KiB`, `3.2 MiB`) for resource details. */ export function formatByteSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const kib = bytes / 1024; if (kib < 1024) return `${kib.toFixed(1)} KiB`; const mib = kib / 1024; if (mib < 1024) return `${mib.toFixed(1)} MiB`; return `${(mib / 1024).toFixed(1)} GiB`; } /** Base64 PNG bytes plus its MIME, ready to attach as an image block. */ export interface VideoPng { readonly data: string; readonly mimeType: "image/png"; } /** Run ffmpeg with bounded, abort-aware process cleanup and a user-facing failure. */ export async function runFfmpeg(args: string[], signal?: AbortSignal): Promise { const ffmpeg = requireMediaBinary("ffmpeg"); if (signal?.aborted) throw new VideoError("Video operation aborted."); const child = Bun.spawn([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", ...args], { stdout: "pipe", stderr: "pipe", }); const killOnAbort = () => { child.kill(); }; signal?.addEventListener("abort", killOnAbort, { once: true }); let stderr: string; let exitCode: number; try { [, stderr, exitCode] = await untilAborted( signal, Promise.all([readStream(child.stdout), readStream(child.stderr), child.exited]), ); } catch (error) { if (signal?.aborted) throw new VideoError("Video operation aborted."); throw error; } finally { signal?.removeEventListener("abort", killOnAbort); } if (exitCode !== 0) throw new VideoError(`ffmpeg failed (exit ${exitCode}): ${stderr.trim().slice(-300)}`); } /** * Extract one frame as PNG: timestamp selectors seek (`-ss` before `-i`), * frame indices use the `select` filter from the start. */ export async function extractVideoFramePng( absolutePath: string, selector: VideoSelector, signal?: AbortSignal, ): Promise { const tmp = await TempDir.create("@omp-video-frame-"); try { const out = tmp.join("frame.png"); if (selector.kind === "time") { await runFfmpeg(["-ss", String(selector.seconds), "-i", absolutePath, "-frames:v", "1", out], signal); } else { await runFfmpeg( [ "-i", absolutePath, "-vf", `select='eq(n\\,${selector.frame})'`, ...(await framePassthroughFlag()), "-frames:v", "1", out, ], signal, ); } const bytes = await Bun.file(out) .bytes() .catch(() => null); if (!bytes || bytes.length === 0) { const at = selector.kind === "time" ? `timestamp ${selector.raw}` : `frame ${selector.frame}`; throw new VideoError(`No frame at ${at} — it may be past the end of the video.`); } return { data: Buffer.from(bytes).toBase64(), mimeType: "image/png" }; } finally { await tmp.remove().catch(() => {}); } } /** ffmpeg `-q:v` JPEG qualities for HEIF stills: near-lossless, then a pass under half that size. */ const STILL_IMAGE_JPEG_QUALITIES = [3, 12] as const; /** * Decode a HEIC, HEIF or AVIF still, which the image pipeline cannot read, to a full-resolution * JPEG of at most `maxBytes`, so the resize note keeps the photo's true size. A dense * high-megapixel photo over the cap at near-lossless quality gets one lower-quality pass. Camera * photos are tiled, and before 8.1 the ffmpeg CLI decodes only one tile of them. */ export async function decodeStillImageJpeg( absolutePath: string, maxBytes: number, signal?: AbortSignal, ): Promise { const release = await ffmpegRelease(); if (release && (release.major < 8 || (release.major === 8 && release.minor < 1))) { throw new VideoError( `ffmpeg ${release.major}.${release.minor} cannot decode tiled HEIF images such as camera photos; ffmpeg 8.1 or newer can.`, ); } const tmp = await TempDir.create("@omp-still-image-"); try { const out = tmp.join("image.jpg"); let decodedBytes = 0; for (const quality of STILL_IMAGE_JPEG_QUALITIES) { await runFfmpeg(["-i", absolutePath, "-frames:v", "1", "-q:v", String(quality), out], signal); const bytes = await Bun.file(out) .bytes() .catch(() => null); if (!bytes || bytes.length === 0) throw new VideoError("ffmpeg wrote no image."); if (bytes.length <= maxBytes) return bytes; decodedBytes = bytes.length; } throw new VideoError( `It decodes to ${formatByteSize(decodedBytes)} of JPEG, over the ${formatByteSize(maxBytes)} image limit.`, ); } finally { await tmp.remove().catch(() => {}); } } /** Contact-sheet layout: thumbnails, grid columns/rows. */ export interface ContactSheet { readonly png: VideoPng; readonly thumbs: number; readonly cols: number; readonly rows: number; } const CONTACT_SHEET_THUMBS = 6; const CONTACT_SHEET_COLS = 3; const CONTACT_SHEET_THUMB_WIDTH = 320; /** * Build a contact-sheet preview: evenly spaced frames across the duration, * tiled into one PNG. Input seeks keep each extraction cheap even on long * files; the tile pass only ever sees thumbnail-sized inputs. */ export async function buildVideoContactSheetPng( absolutePath: string, meta: VideoMetadata, signal?: AbortSignal, ): Promise { const thumbs = CONTACT_SHEET_THUMBS; const cols = CONTACT_SHEET_COLS; const rows = Math.ceil(thumbs / cols); const tmp = await TempDir.create("@omp-video-sheet-"); try { const duration = meta.durationSec; const times: number[] = duration !== undefined && duration > 0 ? Array.from({ length: thumbs }, (_, i) => (duration * (i + 0.5)) / thumbs) : Array.from({ length: thumbs }, (_, i) => i); const thumbPaths = times.map((_, i) => tmp.join(`thumb-${i}.png`)); await Promise.all( times.map((time, i) => runFfmpeg( [ "-ss", String(time), "-i", absolutePath, "-frames:v", "1", "-vf", `scale=${CONTACT_SHEET_THUMB_WIDTH}:-1`, thumbPaths[i]!, ], signal, ).catch(error => { // Past-the-end seeks on short/odd files leave a hole; the tile // pass runs over whatever thumbs materialized instead. Cancellation // is not a missing frame and must still stop every worker promptly. if (signal?.aborted) throw error; }), ), ); const available = thumbPaths.filter(thumb => Bun.file(thumb).size > 0); if (available.length === 0) throw new VideoError("Could not extract any preview frames from this video."); const sheet = tmp.join("sheet.png"); const inputs = available.flatMap(thumb => ["-i", thumb]); // Tile lays out consecutive frames of one stream, so concat the stills // first — without it only the first thumb fills the grid. const concat = available.map((_, i) => `[${i}:v]`).join(""); await runFfmpeg( [ ...inputs, "-filter_complex", `${concat}concat=n=${available.length}:v=1:a=0,tile=${cols}x${rows}`, "-frames:v", "1", sheet, ], signal, ); const bytes = await Bun.file(sheet) .bytes() .catch(() => null); if (!bytes || bytes.length === 0) throw new VideoError("Could not build a video preview grid for this file."); return { png: { data: Buffer.from(bytes).toBase64(), mimeType: "image/png" }, thumbs: available.length, cols, rows, }; } finally { await tmp.remove().catch(() => {}); } } /** Options for selecting changed frames and composing a labeled recording contact sheet. */ export interface ChangedFrameContactSheetOptions { /** Destination PNG path. */ readonly outputPath: string; /** Constant frame rate of the encoded source. */ readonly fps: number; /** Minimum changed-pixel ratio needed to retain an intermediate frame. */ readonly threshold: number; /** Maximum number of tiles, including the first and last frames. */ readonly maxTiles?: number; } /** A labeled contact sheet built from visually changed frames. */ export interface ChangedFrameContactSheet { /** Absolute destination path. */ readonly path: string; /** PNG bytes ready for a vision image block. */ readonly png: VideoPng; /** Number of rendered thumbnails. */ readonly thumbs: number; /** Tile-grid columns. */ readonly cols: number; /** Tile-grid rows. */ readonly rows: number; /** Output PNG width. */ readonly width: number; /** Output PNG height. */ readonly height: number; } interface ScoredFrame { index: number; score: number; } const CHANGE_SAMPLE_WIDTH = 64; const CHANGE_SAMPLE_HEIGHT = 64; const CHANGE_PIXEL_DELTA = 16; const RECORDING_CONTACT_THUMB_WIDTH = 320; function formatContactSheetTimestamp(seconds: number): string { const tenths = Math.max(0, Math.round(seconds * 10)); const minutes = Math.floor(tenths / 600); const secondsWithinMinute = Math.floor((tenths % 600) / 10); return `${String(minutes).padStart(2, "0")}:${String(secondsWithinMinute).padStart(2, "0")}.${tenths % 10}`; } async function selectChangedFrames( rawPath: string, threshold: number, maxTiles: number, signal?: AbortSignal, ): Promise { const frameBytes = CHANGE_SAMPLE_WIDTH * CHANGE_SAMPLE_HEIGHT; const reader = Bun.file(rawPath).stream().getReader(); let pending = Buffer.alloc(0); let previous: Buffer | undefined; let frameIndex = 0; let lastIndex = -1; const changed: ScoredFrame[] = []; try { for (;;) { if (signal?.aborted) throw new VideoError("Video operation aborted."); const { done, value } = await reader.read(); if (done) break; pending = pending.length === 0 ? Buffer.from(value) : Buffer.concat([pending, Buffer.from(value)], pending.length + value.length); while (pending.length >= frameBytes) { const frame = pending.subarray(0, frameBytes); pending = pending.subarray(frameBytes); if (previous) { let changedPixels = 0; for (let i = 0; i < frameBytes; i++) { if (Math.abs(frame[i]! - previous[i]!) > CHANGE_PIXEL_DELTA) changedPixels++; } const score = changedPixels / frameBytes; if (score >= threshold) { changed.push({ index: frameIndex, score }); changed.sort((a, b) => b.score - a.score || a.index - b.index); if (changed.length > Math.max(0, maxTiles - 2)) changed.pop(); } } previous = Buffer.from(frame); lastIndex = frameIndex; frameIndex++; } } } finally { reader.releaseLock(); } if (lastIndex < 0) throw new VideoError("Could not decode frames for the recording contact sheet."); const selected = new Set([0, lastIndex]); for (const frame of changed) selected.add(frame.index); return [...selected].sort((a, b) => a - b); } /** * Select visually changed frames from a constant-frame-rate video and write a * timestamp-labeled PNG contact sheet. Analysis is disk-backed and streamed. */ export async function buildChangedFrameContactSheetPng( absolutePath: string, options: ChangedFrameContactSheetOptions, signal?: AbortSignal, ): Promise { const maxTiles = Math.max(2, Math.min(12, Math.floor(options.maxTiles ?? 12))); const tmp = await TempDir.create("@omp-video-change-sheet-"); try { const raw = tmp.join("frames.gray"); await runFfmpeg( [ "-i", absolutePath, "-vf", `scale=${CHANGE_SAMPLE_WIDTH}:${CHANGE_SAMPLE_HEIGHT},format=gray`, "-f", "rawvideo", raw, ], signal, ); const indexes = await selectChangedFrames(raw, options.threshold, maxTiles, signal); const rawThumbPaths = indexes.map((_, i) => tmp.join(`thumb-raw-${i}.png`)); const labelPaths = indexes.map((_, i) => tmp.join(`label-${i}.png`)); const thumbPaths = indexes.map((_, i) => tmp.join(`thumb-${i}.png`)); await Promise.all( indexes.map(async (index, i) => { const seconds = index / options.fps; const label = formatContactSheetTimestamp(seconds); try { await runFfmpeg( [ "-ss", String(Math.max(0, seconds - 0.001)), "-i", absolutePath, "-frames:v", "1", "-vf", `scale=${RECORDING_CONTACT_THUMB_WIDTH}:-2`, rawThumbPaths[i]!, ], signal, ); const labelSvg = `${label}`; const labelPng = await rasterizeSvg(Buffer.from(labelSvg), RECORDING_CONTACT_THUMB_WIDTH, 30); await Bun.write(labelPaths[i]!, labelPng); await runFfmpeg( [ "-i", rawThumbPaths[i]!, "-i", labelPaths[i]!, "-filter_complex", "[0:v][1:v]overlay=0:H-h", "-frames:v", "1", thumbPaths[i]!, ], signal, ); } catch (error) { if (signal?.aborted) throw error; } }), ); const available = thumbPaths.filter(thumb => Bun.file(thumb).size > 0); if (available.length === 0) throw new VideoError("Could not extract contact-sheet frames from the recording."); const cols = Math.min(CONTACT_SHEET_COLS, available.length); const rows = Math.ceil(available.length / cols); const inputs = available.flatMap(thumb => ["-i", thumb]); const concat = available.map((_, i) => `[${i}:v]`).join(""); await runFfmpeg( [ ...inputs, "-filter_complex", `${concat}concat=n=${available.length}:v=1:a=0,tile=${cols}x${rows}:padding=0:margin=0:color=black`, "-frames:v", "1", options.outputPath, ], signal, ); const bytes = await Bun.file(options.outputPath) .bytes() .catch(() => null); if (!bytes || bytes.length === 0) throw new VideoError("Could not build the recording contact sheet."); const metadata = await new Bun.Image(bytes).metadata(); return { path: options.outputPath, png: { data: Buffer.from(bytes).toBase64(), mimeType: "image/png" }, thumbs: available.length, cols, rows, width: metadata.width, height: metadata.height, }; } finally { await tmp.remove().catch(() => {}); } }