Project Files
src / helpers / sequenceExtractor.ts
import fs from "fs";
import { loadCore } from "./videoAssembler.js";
import { encodeJpegPreviewFromBuffer, type PreviewOptions } from "../core-bundle.mjs";
export type SequenceFrame = {
jpegBuf: Buffer;
/** 1-based frame number in the extracted sequence (not source frame index). */
frameIndex: number;
/** Timecode string in MM:SS:FF format relative to source fps. */
timecodeStr: string;
};
function pad2(n: number): string {
return String(Math.floor(n)).padStart(2, "0");
}
/**
* Format a wall-clock time as `MM:SS:FF` where FF is the frame offset within
* the current second at sourceFps.
* Example: timeSeconds=5.48, sourceFps=25 → "00:05:12"
*/
function formatTimecode(timeSeconds: number, sourceFps: number): string {
const fps = Math.max(1, Math.round(sourceFps));
const totalFrames = Math.round(timeSeconds * fps);
const ff = totalFrames % fps;
const totalSecs = Math.floor(totalFrames / fps);
const ss = totalSecs % 60;
const mm = Math.floor(totalSecs / 60);
return `${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
}
/**
* Parse source fps from ffmpeg stderr output.
* Looks for patterns like "25 fps", "25.00 fps", "23.976 fps".
* Falls back to 25 (standard LTX output fps).
*/
function parseSourceFps(stderrLines: string[]): number {
for (const line of stderrLines) {
const m = /(\d+(?:\.\d+)?)\s+fps/.exec(line);
if (m) {
const fps = parseFloat(m[1]);
if (Number.isFinite(fps) && fps > 0) return fps;
}
}
return 25;
}
/**
* Extract sample frames from a MOV file at the given target fps.
*
* - Uses the shared @ffmpeg/core WASM singleton (no process spawn).
* - Normalises each frame to JPEG using the same PreviewOptions as Vision Promotion.
* - Timecodes are formatted as MM:SS:FF relative to the detected source fps.
*
* @param movAbs Absolute path to the source .mov file.
* @param targetFps Desired frame sampling rate (e.g. 2).
* @param previewOpts JPEG normalisation options (maxDim, quality) — use VP defaults.
*/
export async function extractSequenceFrames(
movAbs: string,
targetFps: number,
previewOpts: PreviewOptions
): Promise<SequenceFrame[]> {
const movBuf = await fs.promises.readFile(movAbs);
const ffmpeg = await loadCore();
ffmpeg.reset();
const stderrLines: string[] = [];
ffmpeg.setLogger((msg) => {
if (msg.type === "stderr") stderrLines.push(msg.message);
});
const inputName = "seqext_input.mov";
const probeFrame = "seqext_probe.png";
const outputPattern = "seqext_frame_%04d.png";
let extractedCount = 0;
try {
ffmpeg.FS.writeFile(inputName, new Uint8Array(movBuf));
// ── Step 1: probe — extract one frame so ffmpeg logs stream info (fps) to stderr ──
ffmpeg.exec("-i", inputName, "-frames:v", "1", probeFrame);
const sourceFps = parseSourceFps(stderrLines);
// ── Step 2: extract frames at target fps ──────────────────────────────────────────
stderrLines.length = 0;
const exitCode = ffmpeg.exec(
"-i", inputName,
"-vf", `fps=${targetFps}`,
"-pix_fmt", "rgb24",
outputPattern,
);
if (exitCode !== 0) {
throw new Error(
`ffmpeg frame extraction failed (exit ${exitCode}): ` +
stderrLines.slice(-10).join("\n"),
);
}
// ── Step 3: collect output frames ────────────────────────────────────────────────
const frames: SequenceFrame[] = [];
while (true) {
const name = `seqext_frame_${String(extractedCount + 1).padStart(4, "0")}.png`;
let rawPng: Buffer;
try {
const data = ffmpeg.FS.readFile(name, { encoding: "binary" });
rawPng = Buffer.from(data);
} catch {
break; // no more frames
}
extractedCount++;
const timeSeconds = (extractedCount - 1) / targetFps;
const timecodeStr = formatTimecode(timeSeconds, sourceFps);
const { data: jpegBuf } = await encodeJpegPreviewFromBuffer(rawPng, previewOpts);
frames.push({ jpegBuf, frameIndex: extractedCount, timecodeStr });
}
return frames;
} finally {
ffmpeg.setLogger(() => {});
try { ffmpeg.FS.unlink(inputName); } catch {}
try { ffmpeg.FS.unlink(probeFrame); } catch {}
for (let j = 1; j <= extractedCount; j++) {
try {
ffmpeg.FS.unlink(`seqext_frame_${String(j).padStart(4, "0")}.png`);
} catch {}
}
}
}