import { link, mkdir, copyFile } from "node:fs/promises"; import { dirname } from "node:path"; import { run } from "./util/exec"; export interface Tools { ffmpeg: string; ffprobe: string } export async function probeDuration(t: Tools, file: string): Promise { try { const out = await run([t.ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]); const s = Number(out.trim()); return Number.isFinite(s) && s > 0 ? Math.round(s) : undefined; } catch { return undefined; } } /** One JPEG frame at `fraction` of the clip, 640px wide. */ export async function extractFrame(t: Tools, file: string, out: string, fraction = 0.2): Promise { await mkdir(dirname(out), { recursive: true }); const dur = (await probeDuration(t, file)) ?? 0; const at = Math.max(0, dur * fraction); await run([t.ffmpeg, "-v", "error", "-y", "-ss", String(at), "-i", file, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "4", out]); } /** * Puts a large file into the course. "copy" uses `cp --reflink=auto` (a CoW clone where the filesystem supports it, * a plain copy elsewhere); "link" makes a hard link and falls back to copying across filesystems. * Either way the original is only read, and the course folder stays self-contained (the media route refuses symlinks that leave it). */ export async function putFile(src: string, dest: string, mode: "copy" | "link" = "copy"): Promise { await mkdir(dirname(dest), { recursive: true }); if (mode === "link") { try { await link(src, dest); return; } catch { /* cross-device or unsupported: copy */ } } try { await run(["cp", "--reflink=auto", "--", src, dest]); } catch { await copyFile(src, dest); } }