Initial Commit
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# `FluentFfmpegRunner`
|
||||
|
||||
## Purpose
|
||||
|
||||
Default `FfmpegRunner` implementation used by `ThumbnailGenerator` and
|
||||
`SpriteSheetGenerator` when no other runner is injected. It wraps `fluent-ffmpeg` and pins
|
||||
ffmpeg to the binary shipped by `@ffmpeg-installer/ffmpeg`, and ffprobe to
|
||||
`@ffprobe-installer/ffprobe`, so the package works on any host without requiring system
|
||||
ffmpeg/ffprobe to be on `PATH`. Every operation respects an optional `AbortSignal` by killing
|
||||
the underlying child process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bundled ffmpeg + ffprobe binaries — zero system dependency.
|
||||
- Override-friendly: pass `ffmpegPath` and/or `ffprobePath` to point at a custom binary.
|
||||
- `extract` issues a single-frame ffmpeg pipeline with the canonical filter from
|
||||
`buildScaleFilter` and quality from `buildQualityArgs`.
|
||||
- `tile` issues the canonical `fps=…,scale=…,tile=COLSxROWS` sprite-sheet pipeline.
|
||||
- `probe` returns a strict `ProbeResult` with duration, dimensions, codec, fps, bitrate.
|
||||
- `isAvailable` spawns `ffmpeg -version` and resolves to `true` only on exit code 0.
|
||||
- Every call honours `AbortSignal`: `command.kill("SIGKILL")` on abort, then reject with
|
||||
`ABORTED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
const runner = new FluentFfmpegRunner();
|
||||
const ok = await runner.isAvailable();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FluentFfmpegRunner } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
const runner = new FluentFfmpegRunner({
|
||||
ffmpegPath: "/usr/local/bin/ffmpeg-hwaccel",
|
||||
ffprobePath: "/usr/local/bin/ffprobe-hwaccel",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 250);
|
||||
try {
|
||||
await runner.extract(
|
||||
{
|
||||
inputPath: "/data/big.mkv",
|
||||
outputPath: "/tmp/thumb.jpg",
|
||||
timestamp: "00:00:10",
|
||||
width: 640,
|
||||
height: 360,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
// VideoThumbnailsError { code: "VIDEO_THUMBNAILS.ABORTED" } when aborted.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FluentFfmpegRunner implements FfmpegRunner`
|
||||
- `constructor(options?: { ffmpegPath?: string; ffprobePath?: string })`
|
||||
- `isAvailable(signal?): Promise<boolean>` — exit code 0 from `ffmpeg -version`.
|
||||
- `extract(input, signal?): Promise<void>` — single-frame extraction.
|
||||
- `tile(input, signal?): Promise<void>` — N×M sprite-sheet render.
|
||||
- `probe(inputPath, signal?): Promise<ProbeResult>` — ffprobe metadata.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`run` listens for `end` and `error` on the `FfmpegCommand`, tracks a `settled` flag so a late
|
||||
abort cannot double-resolve, and attaches the abort listener with `{ once: true }` to keep
|
||||
event-emitter memory bounded. `probe` re-pins the ffprobe path on the module-level
|
||||
`fluent-ffmpeg` singleton before each call because `ffmpeg.ffprobe` (the top-level helper)
|
||||
does not consult per-instance state — without the re-pin the singleton would happily fall
|
||||
back to system `ffprobe`. The probe failure paths split into two: a real ffprobe crash
|
||||
(`PROBE_FAILED`) and a structurally invalid response (`VIDEO_INVALID`, raised when the file
|
||||
has no video stream or a non-positive duration).
|
||||
@@ -0,0 +1,512 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getFixtureVideo,
|
||||
makeOutputDir,
|
||||
safeRm,
|
||||
} from "../../tests/setup/fixtures.js";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
FluentFfmpegRunner,
|
||||
makeSettler,
|
||||
toProbeResult,
|
||||
} from "./fluent-ffmpeg-runner.js";
|
||||
|
||||
let runner: FluentFfmpegRunner;
|
||||
let outDir: string;
|
||||
let fixture: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
runner = new FluentFfmpegRunner();
|
||||
outDir = await makeOutputDir("runner");
|
||||
fixture = await getFixtureVideo();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await safeRm(outDir);
|
||||
});
|
||||
|
||||
describe("FluentFfmpegRunner", () => {
|
||||
it("reports ffmpeg available", { timeout: 30000 }, async () => {
|
||||
expect(await runner.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await runner.isAvailable(controller.signal)).toBe(false);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when signal aborts mid-spawn", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = runner.isAvailable(controller.signal);
|
||||
// Fire abort on the next microtask, after spawn has hooked the listener.
|
||||
queueMicrotask(() => controller.abort());
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("isAvailable returns false when the binary path is invalid", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const broken = new FluentFfmpegRunner({
|
||||
ffmpegPath: "/nonexistent/ffmpeg",
|
||||
});
|
||||
expect(await broken.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts a JPEG frame from the real fixture", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const output = join(outDir, "extract.jpg");
|
||||
await runner.extract({
|
||||
inputPath: fixture,
|
||||
outputPath: output,
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
});
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const fileStat = await stat(output);
|
||||
expect(fileStat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects extract with ABORTED when signal aborts pre-call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.extract(
|
||||
{
|
||||
inputPath: fixture,
|
||||
outputPath: join(outDir, "aborted.jpg"),
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects extract with EXTRACT_FAILED when the source is bogus", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
runner.extract({
|
||||
inputPath: "/this/file/does/not/exist.mp4",
|
||||
outputPath: join(outDir, "missing.jpg"),
|
||||
timestamp: "00:00:01",
|
||||
width: 160,
|
||||
height: 120,
|
||||
fit: "cover",
|
||||
format: "jpg",
|
||||
quality: 80,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a tile sprite sheet", { timeout: 30000 }, async () => {
|
||||
const output = join(outDir, "tile.jpg");
|
||||
await runner.tile({
|
||||
inputPath: fixture,
|
||||
outputPath: output,
|
||||
columns: 3,
|
||||
rows: 2,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
});
|
||||
const { stat } = await import("node:fs/promises");
|
||||
expect((await stat(output)).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects tile with ABORTED when signal aborts pre-call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.tile(
|
||||
{
|
||||
inputPath: fixture,
|
||||
outputPath: join(outDir, "tile-abort.jpg"),
|
||||
columns: 3,
|
||||
rows: 2,
|
||||
thumbnailWidth: 80,
|
||||
thumbnailHeight: 60,
|
||||
interval: 1,
|
||||
format: "jpg",
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("probe returns sensible metadata for the fixture", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const probed = await runner.probe(fixture);
|
||||
expect(probed.duration).toBeGreaterThan(0);
|
||||
expect(probed.width).toBeGreaterThan(0);
|
||||
expect(probed.height).toBeGreaterThan(0);
|
||||
expect(probed.codec).toBeTypeOf("string");
|
||||
expect(probed.fps).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("probe rejects with ABORTED when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
runner.probe(fixture, controller.signal)
|
||||
).rejects.toMatchObject({ code: VIDEO_THUMBNAILS_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("probe rejects with PROBE_FAILED on a missing file", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(runner.probe("/no/such/file.mp4")).rejects.toMatchObject({
|
||||
code: VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("probe rejects with VIDEO_INVALID on a non-video container", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const audioOnly = join(outDir, "audio-only.mp4");
|
||||
const ffmpegInstaller = (await import("@ffmpeg-installer/ffmpeg"))
|
||||
.default;
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(ffmpegInstaller.path, [
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
audioOnly,
|
||||
]);
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error("audio fixture failed"))
|
||||
);
|
||||
});
|
||||
await expect(runner.probe(audioOnly)).rejects.toBeInstanceOf(
|
||||
VideoThumbnailsError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toProbeResult", () => {
|
||||
it("throws VIDEO_INVALID when no video stream is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
toProbeResult({
|
||||
streams: [],
|
||||
format: {},
|
||||
} as never)
|
||||
).toThrow(
|
||||
expect.objectContaining({ code: "VIDEO_THUMBNAILS.VIDEO_INVALID" })
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when streams is missing entirely", { timeout: 5000 }, () => {
|
||||
expect(() => toProbeResult({ format: {} } as never)).toThrow();
|
||||
});
|
||||
|
||||
it("falls back to 30/1 when r_frame_rate is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
],
|
||||
format: { duration: "5.0", bit_rate: "12345" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(30);
|
||||
});
|
||||
|
||||
it("yields fps = num when denominator is zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "vp9",
|
||||
width: 100,
|
||||
height: 100,
|
||||
r_frame_rate: "24/0",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(24);
|
||||
});
|
||||
|
||||
it("handles non-finite duration as 0", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 1,
|
||||
height: 1,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "N/A" },
|
||||
} as never);
|
||||
expect(result.duration).toBe(0);
|
||||
});
|
||||
|
||||
it("substitutes 0 for missing width/height", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
expect(result.codec).toBe("unknown");
|
||||
});
|
||||
|
||||
it("treats missing format object as duration/bitrate 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 1,
|
||||
height: 1,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
expect(result.duration).toBe(0);
|
||||
expect(result.bitrate).toBe(0);
|
||||
});
|
||||
|
||||
it("returns bitrate 0 when not present", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "30/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.bitrate).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-numeric fps numerator", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "abc/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-numeric fps denominator", { timeout: 5000 }, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "24/xyz",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(24);
|
||||
});
|
||||
|
||||
it("handles missing fps numerator (empty string)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = toProbeResult({
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
width: 16,
|
||||
height: 9,
|
||||
r_frame_rate: "/1",
|
||||
},
|
||||
],
|
||||
format: { duration: "1" },
|
||||
} as never);
|
||||
expect(result.fps).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeSettler", () => {
|
||||
it("completes the promise on first complete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let resolved = false;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
resolved = true;
|
||||
},
|
||||
() => {
|
||||
throw new Error("should not reject");
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores complete after settled", { timeout: 5000 }, () => {
|
||||
let count = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
count += 1;
|
||||
},
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
settler.complete();
|
||||
settler.fail(new Error("late"));
|
||||
settler.abort(() => {
|
||||
/* unused */
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("fail rejects with the supplied error", { timeout: 5000 }, () => {
|
||||
let rejection: unknown;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
(reason) => {
|
||||
rejection = reason;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
const cause = new Error("boom");
|
||||
settler.fail(cause);
|
||||
expect(rejection).toBe(cause);
|
||||
});
|
||||
|
||||
it("ignores fail after settled", { timeout: 5000 }, () => {
|
||||
let calls = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
() => {
|
||||
calls += 1;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.fail(new Error("first"));
|
||||
settler.fail(new Error("ignored"));
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("abort invokes the cancel callback then rejects with ABORTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("user-action");
|
||||
let rejection: unknown;
|
||||
let cancelCalled = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
(reason) => {
|
||||
rejection = reason;
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
settler.abort(() => {
|
||||
cancelCalled += 1;
|
||||
});
|
||||
expect(cancelCalled).toBe(1);
|
||||
expect(rejection).toBeInstanceOf(VideoThumbnailsError);
|
||||
expect((rejection as VideoThumbnailsError).code).toBe(
|
||||
"VIDEO_THUMBNAILS.ABORTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("abort no-ops when already settled", { timeout: 5000 }, () => {
|
||||
let cancelCalled = 0;
|
||||
const settler = makeSettler(
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
() => {
|
||||
/* unused */
|
||||
},
|
||||
undefined
|
||||
);
|
||||
settler.complete();
|
||||
settler.abort(() => {
|
||||
cancelCalled += 1;
|
||||
});
|
||||
expect(cancelCalled).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
|
||||
import ffprobeInstaller from "@ffprobe-installer/ffprobe";
|
||||
import ffmpeg, { type FfmpegCommand } from "fluent-ffmpeg";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
import type {
|
||||
ExtractInput,
|
||||
FfmpegRunner,
|
||||
ProbeResult,
|
||||
TileInput,
|
||||
} from "./runner.types.js";
|
||||
|
||||
/** Construction options for {@link FluentFfmpegRunner}. */
|
||||
export interface FluentFfmpegRunnerOptions {
|
||||
/** Override the ffmpeg binary path. Defaults to `@ffmpeg-installer/ffmpeg`. */
|
||||
readonly ffmpegPath?: string;
|
||||
/** Override the ffprobe binary path. Defaults to `@ffprobe-installer/ffprobe`. */
|
||||
readonly ffprobePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `FfmpegRunner` implementation backed by `fluent-ffmpeg` and the
|
||||
* binary shipped via `@ffmpeg-installer/ffmpeg`. Consumers can supply a
|
||||
* different binary by passing `ffmpegPath`.
|
||||
*/
|
||||
export class FluentFfmpegRunner implements FfmpegRunner {
|
||||
private readonly ffmpegPath: string;
|
||||
private readonly ffprobePath: string;
|
||||
|
||||
public constructor(options: FluentFfmpegRunnerOptions = {}) {
|
||||
this.ffmpegPath = options.ffmpegPath ?? ffmpegInstaller.path;
|
||||
this.ffprobePath = options.ffprobePath ?? ffprobeInstaller.path;
|
||||
}
|
||||
|
||||
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(this.ffmpegPath, ["-version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
const onAbort = (): void => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
child.once("error", () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(false);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async extract(
|
||||
input: ExtractInput,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const filter = buildScaleFilter(input.width, input.height, input.fit);
|
||||
const qualityArgs = buildQualityArgs(input.format, input.quality);
|
||||
const outputOptions = [
|
||||
"-ss",
|
||||
input.timestamp,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
filter,
|
||||
...qualityArgs,
|
||||
"-y",
|
||||
];
|
||||
const command = this.command()
|
||||
.input(input.inputPath)
|
||||
.outputOptions(outputOptions)
|
||||
.output(input.outputPath);
|
||||
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
|
||||
}
|
||||
|
||||
public async tile(input: TileInput, signal?: AbortSignal): Promise<void> {
|
||||
const vfFilter = [
|
||||
`fps=1/${input.interval}`,
|
||||
`scale=${input.thumbnailWidth}:${input.thumbnailHeight}`,
|
||||
`tile=${input.columns}x${input.rows}`,
|
||||
].join(",");
|
||||
const command = this.command()
|
||||
.input(input.inputPath)
|
||||
.outputOptions(["-vf", vfFilter, "-frames:v", "1", "-y"])
|
||||
.output(input.outputPath);
|
||||
await this.run(command, signal, VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED);
|
||||
}
|
||||
|
||||
public async probe(
|
||||
inputPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ProbeResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
ffmpeg.setFfprobePath(this.ffprobePath);
|
||||
return new Promise<ProbeResult>((resolve, reject) => {
|
||||
ffmpeg.ffprobe(inputPath, (error, data) => {
|
||||
if (error != null) {
|
||||
reject(
|
||||
classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
"videoThumbnails.errors.probeFailed"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(toProbeResult(data));
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private command(): FfmpegCommand {
|
||||
const command = ffmpeg();
|
||||
command.setFfmpegPath(this.ffmpegPath);
|
||||
command.setFfprobePath(this.ffprobePath);
|
||||
return command;
|
||||
}
|
||||
|
||||
private run(
|
||||
command: FfmpegCommand,
|
||||
signal: AbortSignal | undefined,
|
||||
failureCode: typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const settler = makeSettler(resolve, reject, signal);
|
||||
const onAbort = (): void =>
|
||||
settler.abort(() => {
|
||||
try {
|
||||
command.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignored — the command may already have exited
|
||||
}
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
command.on("end", () => settler.complete());
|
||||
command.on("error", (error: unknown) => {
|
||||
settler.fail(
|
||||
classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
failureCode,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
)
|
||||
);
|
||||
});
|
||||
command.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal — single-shot settle helper used by the run pipeline. Exported for testability. */
|
||||
export interface Settler {
|
||||
complete(): void;
|
||||
fail(error: unknown): void;
|
||||
abort(onFirstAbort: () => void): void;
|
||||
}
|
||||
|
||||
/** Internal — exported for testability. */
|
||||
export function makeSettler(
|
||||
resolve: () => void,
|
||||
reject: (reason: unknown) => void,
|
||||
signal: AbortSignal | undefined
|
||||
): Settler {
|
||||
let settled = false;
|
||||
return {
|
||||
complete(): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve();
|
||||
},
|
||||
fail(error: unknown): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(error);
|
||||
},
|
||||
abort(onFirstAbort: () => void): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
onFirstAbort();
|
||||
reject(
|
||||
new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{
|
||||
context: {
|
||||
reason: String(signal?.reason),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Internal — exported for testability. */
|
||||
export function toProbeResult(data: ffmpeg.FfprobeData): ProbeResult {
|
||||
const videoStream = (data.streams ?? []).find(
|
||||
(stream) => stream.codec_type === "video"
|
||||
);
|
||||
if (videoStream === undefined) {
|
||||
throw new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.VIDEO_INVALID,
|
||||
"videoThumbnails.errors.videoInvalid",
|
||||
{ context: { reason: "no-video-stream" } }
|
||||
);
|
||||
}
|
||||
const fpsRatio = videoStream.r_frame_rate ?? "30/1";
|
||||
const parts = fpsRatio.split("/");
|
||||
const rawNum = Number(parts[0]);
|
||||
const rawDen = Number(parts[1]);
|
||||
const num = Number.isFinite(rawNum) ? rawNum : 0;
|
||||
const den = Number.isFinite(rawDen) ? rawDen : 0;
|
||||
const fps = den === 0 ? num : num / den;
|
||||
const rawDuration = Number(data.format?.duration ?? 0);
|
||||
const duration =
|
||||
Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
|
||||
return {
|
||||
duration,
|
||||
width: typeof videoStream.width === "number" ? videoStream.width : 0,
|
||||
height: typeof videoStream.height === "number" ? videoStream.height : 0,
|
||||
codec: videoStream.codec_name ?? "unknown",
|
||||
fps,
|
||||
bitrate: Number(data.format?.bit_rate ?? 0),
|
||||
};
|
||||
}
|
||||
15
packages/media/video/video-thumbnails/src/runner/index.ts
Normal file
15
packages/media/video/video-thumbnails/src/runner/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FluentFfmpegRunner,
|
||||
type FluentFfmpegRunnerOptions,
|
||||
} from "./fluent-ffmpeg-runner.js";
|
||||
export {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
export type {
|
||||
ExtractInput,
|
||||
FfmpegRunner,
|
||||
ProbeResult,
|
||||
TileInput,
|
||||
} from "./runner.types.js";
|
||||
91
packages/media/video/video-thumbnails/src/runner/runner.md
Normal file
91
packages/media/video/video-thumbnails/src/runner/runner.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `runner`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The runner module defines the `FfmpegRunner` interface plus a handful of pure helpers used by
|
||||
every implementation: `buildScaleFilter`, `buildQualityArgs`, and `classifyRunnerError`.
|
||||
Centralising filter construction means the generator classes stay focused on orchestration
|
||||
while every implementation (the bundled `FluentFfmpegRunner` and any future native or browser
|
||||
variant) shares the exact same algorithmic shape. `classifyRunnerError` is the single point
|
||||
where an `AbortSignal` distinguishes an `ABORTED` failure from a real ffmpeg crash.
|
||||
|
||||
## Features
|
||||
|
||||
- `FfmpegRunner` interface — four methods (`isAvailable`, `extract`, `tile`, `probe`), each
|
||||
taking an optional `AbortSignal`.
|
||||
- Strong typed payloads: `ExtractInput`, `TileInput`, `ProbeResult`.
|
||||
- `buildScaleFilter` — encodes the `cover | contain | fill` fit semantics into ffmpeg
|
||||
filter-graph syntax once and only once.
|
||||
- `buildQualityArgs` — translates the 1–100 quality scale into ffmpeg's inverted 1–31 `-q:v`
|
||||
scale (PNG returns an empty arg list).
|
||||
- `classifyRunnerError` — single guarded helper that turns any raw ffmpeg failure into a
|
||||
typed `VideoThumbnailsError`, distinguishing `ABORTED` from `EXTRACT_FAILED` /
|
||||
`PROBE_FAILED` based on the supplied `AbortSignal`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildScaleFilter, buildQualityArgs } from "@suaveplan/video-thumbnails/runner";
|
||||
|
||||
buildScaleFilter(320, 180, "cover");
|
||||
// "scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
|
||||
|
||||
buildQualityArgs("jpg", 80);
|
||||
// ["-q:v", "7"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyRunnerError,
|
||||
type FfmpegRunner,
|
||||
} from "@suaveplan/video-thumbnails/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "@suaveplan/video-thumbnails/error-codes";
|
||||
|
||||
class CustomRunner implements FfmpegRunner {
|
||||
async extract(input, signal) {
|
||||
try {
|
||||
await runViaCustomTransport(input, signal);
|
||||
} catch (error) {
|
||||
throw classifyRunnerError(
|
||||
error,
|
||||
signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed",
|
||||
);
|
||||
}
|
||||
}
|
||||
// ... isAvailable, tile, probe
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FfmpegRunner` — `{ isAvailable(signal?), extract(input, signal?), tile(input, signal?),
|
||||
probe(inputPath, signal?) }`.
|
||||
- `ExtractInput` — `{ inputPath, outputPath, timestamp, width, height, fit, format,
|
||||
quality }`.
|
||||
- `TileInput` — `{ inputPath, outputPath, columns, rows, thumbnailWidth, thumbnailHeight,
|
||||
interval, format }`.
|
||||
- `ProbeResult` — `{ duration, width, height, codec, fps, bitrate }`.
|
||||
- `buildScaleFilter(width, height, fit): string`.
|
||||
- `buildQualityArgs(format, quality): readonly string[]`.
|
||||
- `classifyRunnerError(cause, signal, failureCode, failureKey): VideoThumbnailsError` — when
|
||||
`signal.aborted`, returns `ABORTED`; otherwise the supplied `failureCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Quality mapping uses ffmpeg's inverted scale where `1` is best and `31` is worst — the
|
||||
inverse arithmetic (`31 - quality/100 × 30`) keeps the round-trip stable for the canonical
|
||||
80% quality (returning `-q:v 7`, the most commonly cited "good MJPEG" value). The fit
|
||||
strategies are deliberately limited to ffmpeg-native filter chains so no second-pass image
|
||||
processing is needed; consumers wanting more exotic compositing (gravity, focal point) can
|
||||
implement a custom `FfmpegRunner` and reuse `buildQualityArgs` while ignoring
|
||||
`buildScaleFilter`. `classifyRunnerError` reads `signal.aborted` lazily and only after the
|
||||
underlying call has thrown — this avoids a race where the signal aborts after the work
|
||||
already finished successfully.
|
||||
104
packages/media/video/video-thumbnails/src/runner/runner.test.ts
Normal file
104
packages/media/video/video-thumbnails/src/runner/runner.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
import {
|
||||
buildQualityArgs,
|
||||
buildScaleFilter,
|
||||
classifyRunnerError,
|
||||
} from "./runner.js";
|
||||
|
||||
describe("buildScaleFilter", () => {
|
||||
it("emits the cover filter chain by default", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "cover")).toBe(
|
||||
"scale=320:180:force_original_aspect_ratio=increase,crop=320:180"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a contain (decrease) filter", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "contain")).toBe(
|
||||
"scale=320:180:force_original_aspect_ratio=decrease"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a fill (stretch) filter", { timeout: 5000 }, () => {
|
||||
expect(buildScaleFilter(320, 180, "fill")).toBe("scale=320:180");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildQualityArgs", () => {
|
||||
it("returns empty args for png", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("png", 80)).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps quality 80 to -q:v 7 for jpg", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("jpg", 80)).toEqual(["-q:v", "7"]);
|
||||
});
|
||||
|
||||
it("maps quality 1 to -q:v 31 (worst) for webp", { timeout: 5000 }, () => {
|
||||
expect(buildQualityArgs("webp", 1)).toEqual(["-q:v", "31"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyRunnerError", () => {
|
||||
it("returns ABORTED when signal is aborted", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("manual-cancel");
|
||||
const error = classifyRunnerError(
|
||||
new Error("boom"),
|
||||
controller.signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error).toBeInstanceOf(VideoThumbnailsError);
|
||||
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.ABORTED);
|
||||
});
|
||||
|
||||
it("returns the requested failure code otherwise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
new Error("ffmpeg crashed"),
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
"videoThumbnails.errors.probeFailed"
|
||||
);
|
||||
expect(error.code).toBe(VIDEO_THUMBNAILS_CODES.PROBE_FAILED);
|
||||
expect(error.context?.stderr).toBe("ffmpeg crashed");
|
||||
});
|
||||
|
||||
it("stringifies non-Error causes for the stderr context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
"raw string failure",
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error.context?.stderr).toBe("raw string failure");
|
||||
});
|
||||
|
||||
it("stringifies object causes whose message is non-string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = classifyRunnerError(
|
||||
{ message: 42 },
|
||||
undefined,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(typeof error.context?.stderr).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves the abort reason in context", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("user-clicked-cancel");
|
||||
const error = classifyRunnerError(
|
||||
new Error("anything"),
|
||||
controller.signal,
|
||||
VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED,
|
||||
"videoThumbnails.errors.extractFailed"
|
||||
);
|
||||
expect(error.context?.reason).toBe("user-clicked-cancel");
|
||||
});
|
||||
});
|
||||
74
packages/media/video/video-thumbnails/src/runner/runner.ts
Normal file
74
packages/media/video/video-thumbnails/src/runner/runner.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { VIDEO_THUMBNAILS_CODES } from "../error-codes/error-codes.js";
|
||||
import { VideoThumbnailsError } from "../errors/video-thumbnails-error.js";
|
||||
|
||||
/**
|
||||
* Build the ffmpeg `scale=...` filter expression for the requested fit strategy.
|
||||
*
|
||||
* - `cover` upscales to cover the target then crops to the exact dimensions.
|
||||
* - `contain` scales down preserving aspect ratio, leaving letterbox padding.
|
||||
* - `fill` stretches the frame to the exact target dimensions.
|
||||
*/
|
||||
export function buildScaleFilter(
|
||||
width: number,
|
||||
height: number,
|
||||
fit: "cover" | "contain" | "fill"
|
||||
): string {
|
||||
if (fit === "contain") {
|
||||
return `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
|
||||
}
|
||||
if (fit === "fill") {
|
||||
return `scale=${width}:${height}`;
|
||||
}
|
||||
return `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `quality` (1–100) into ffmpeg's inverted `-q:v` 1–31 scale for codecs
|
||||
* that use it (MJPEG/JPEG/WebP). PNG ignores `-q:v`.
|
||||
*/
|
||||
export function buildQualityArgs(
|
||||
format: "jpg" | "png" | "webp",
|
||||
quality: number
|
||||
): readonly string[] {
|
||||
if (format === "png") {
|
||||
return [];
|
||||
}
|
||||
const q = Math.round(31 - (quality / 100) * 30);
|
||||
return ["-q:v", String(q)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper: convert a raw `unknown` thrown from ffmpeg into the appropriate
|
||||
* `VideoThumbnailsError`. Used by every runner implementation to keep the
|
||||
* abort-vs-failure decision in one place.
|
||||
*/
|
||||
export function classifyRunnerError(
|
||||
cause: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
failureCode:
|
||||
| typeof VIDEO_THUMBNAILS_CODES.EXTRACT_FAILED
|
||||
| typeof VIDEO_THUMBNAILS_CODES.PROBE_FAILED,
|
||||
failureKey: string
|
||||
): VideoThumbnailsError {
|
||||
if (signal?.aborted) {
|
||||
return new VideoThumbnailsError(
|
||||
VIDEO_THUMBNAILS_CODES.ABORTED,
|
||||
"videoThumbnails.errors.aborted",
|
||||
{ cause, context: { reason: String(signal.reason) } }
|
||||
);
|
||||
}
|
||||
return new VideoThumbnailsError(failureCode, failureKey, {
|
||||
cause,
|
||||
context: { stderr: extractStderr(cause) },
|
||||
});
|
||||
}
|
||||
|
||||
function extractStderr(cause: unknown): string {
|
||||
if (typeof cause === "object" && cause !== null) {
|
||||
const message = (cause as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(cause);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Arguments accepted by `FfmpegRunner.extract`. */
|
||||
export interface ExtractInput {
|
||||
readonly inputPath: string;
|
||||
readonly outputPath: string;
|
||||
readonly timestamp: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly fit: "cover" | "contain" | "fill";
|
||||
readonly format: "jpg" | "png" | "webp";
|
||||
readonly quality: number;
|
||||
}
|
||||
|
||||
/** Arguments accepted by `FfmpegRunner.tile`. */
|
||||
export interface TileInput {
|
||||
readonly inputPath: string;
|
||||
readonly outputPath: string;
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
readonly thumbnailWidth: number;
|
||||
readonly thumbnailHeight: number;
|
||||
readonly interval: number;
|
||||
readonly format: "jpg" | "png";
|
||||
}
|
||||
|
||||
/** Raw probe payload returned by `FfmpegRunner.probe`. */
|
||||
export interface ProbeResult {
|
||||
readonly duration: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly codec: string;
|
||||
readonly fps: number;
|
||||
readonly bitrate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction over ffmpeg / ffprobe used by every generator.
|
||||
*
|
||||
* Implementations MUST honour the supplied `AbortSignal` by terminating the
|
||||
* underlying ffmpeg process and rejecting with `VIDEO_THUMBNAILS.ABORTED`.
|
||||
*/
|
||||
export interface FfmpegRunner {
|
||||
isAvailable(signal?: AbortSignal): Promise<boolean>;
|
||||
extract(input: ExtractInput, signal?: AbortSignal): Promise<void>;
|
||||
tile(input: TileInput, signal?: AbortSignal): Promise<void>;
|
||||
probe(inputPath: string, signal?: AbortSignal): Promise<ProbeResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user