Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/packages/producer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ segment starts on a keyframe. HLS is SDR only (`hdrMode: "force-hdr"` is
rejected), refuses GPU encoding (GPU encoders ignore the keyframe lock), and is
not available in distributed rendering, Lambda, or Cloud Run.

## Inspect output validation

A matching output duration does not prove that every expected frame is present.
WebM and HLS often omit the frame count from their metadata. When no usable count
is available, the producer skips the frame-count check and continues checking
duration and requested dimensions.

Set `debug: true` in `createRenderJob()` or use `npx hyperframes render --debug`
to retain `render.log` in the render's working directory. Its
`artifact_frame_count_unavailable` debug event records the output path and
expected frame count whenever this check is skipped. Normal output stays quiet;
a custom `logger` can also collect the event through its `debug` method.

## Cancel a render

Pass an abort signal to the final argument:
Expand Down
172 changes: 162 additions & 10 deletions packages/producer/src/services/render/artifactTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,14 @@ describe("ArtifactTransaction", () => {
it("does not probe duration when the caller passes no expectation", async () => {
const dir = tempDir();
const destination = join(dir, "render.mp4");
const transaction = new ArtifactTransaction(destination, "file", undefined, neverCalled);
const debug = vi.fn();
const transaction = new ArtifactTransaction(destination, "file", undefined, neverCalled, {
debug,
});
writeFileSync(transaction.stagingPath, "any-non-empty-bytes");

await expect(transaction.validate()).resolves.toBeUndefined();
expect(debug).not.toHaveBeenCalled();

transaction.rollback();
});
Expand Down Expand Up @@ -346,16 +350,18 @@ describe("ArtifactTransaction", () => {
}
});

it.each([undefined, 0, -1, Number.NaN])(
"validates video duration quietly when the probe returns %s frames",
it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"reports an unavailable frame-count check at debug level for %s frames",
async (frames) => {
const dir = tempDir();
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4, frames });
const debug = vi.fn();
const transaction = new ArtifactTransaction(
join(dir, "render.webm"),
"file",
undefined,
probe,
{ debug },
);
writeFileSync(transaction.stagingPath, "video");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
Expand All @@ -364,6 +370,14 @@ describe("ArtifactTransaction", () => {
await expect(
transaction.validate({ expectedDurationSeconds: 4, fps: 30, expectedFrames: 120 }),
).resolves.toBeUndefined();
expect(debug).toHaveBeenCalledExactlyOnceWith(
"[Render] Frame-count validation skipped: no usable frame count was reported.",
{
event: "artifact_frame_count_unavailable",
artifact: transaction.destinationPath,
expectedFrames: 120,
},
);
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
Expand All @@ -373,6 +387,131 @@ describe("ArtifactTransaction", () => {
},
);

it("keeps stdout and stderr quiet when frame counts are unavailable with the default logger", async () => {
const dir = tempDir();
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4 });
const transaction = new ArtifactTransaction(join(dir, "render.webm"), "file", undefined, probe);
writeFileSync(transaction.stagingPath, "video");
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const consoleWrites = (["log", "info", "debug", "warn", "error"] as const).map((method) =>
vi.spyOn(console, method).mockImplementation(() => {}),
);

try {
await expect(
transaction.validate({ expectedDurationSeconds: 4, fps: 30, expectedFrames: 120 }),
).resolves.toBeUndefined();
expect(stdout).not.toHaveBeenCalled();
expect(stderr).not.toHaveBeenCalled();
for (const write of consoleWrites) expect(write).not.toHaveBeenCalled();
} finally {
stdout.mockRestore();
stderr.mockRestore();
for (const write of consoleWrites) write.mockRestore();
transaction.rollback();
rmSync(dir, { recursive: true, force: true });
}
});

it("does not report unavailable frame counts when only duration is expected", async () => {
const dir = tempDir();
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4 });
const debug = vi.fn();
const transaction = new ArtifactTransaction(
join(dir, "render.webm"),
"file",
undefined,
probe,
{
debug,
},
);
writeFileSync(transaction.stagingPath, "video");

try {
await expect(transaction.validate({ expectedDurationSeconds: 4 })).resolves.toBeUndefined();
expect(debug).not.toHaveBeenCalled();
} finally {
transaction.rollback();
rmSync(dir, { recursive: true, force: true });
}
});

it.each([120, 119, 90])(
"checks a reported count of %s without an unavailable-count event",
async (frames) => {
const dir = tempDir();
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4, frames });
const debug = vi.fn();
const transaction = new ArtifactTransaction(
join(dir, "render.mp4"),
"file",
undefined,
probe,
{
debug,
},
);
writeFileSync(transaction.stagingPath, "video");

try {
const validation = transaction.validate({
expectedDurationSeconds: 4,
fps: 30,
expectedFrames: 120,
});
if (frames >= 119) await expect(validation).resolves.toBeUndefined();
else await expect(validation).rejects.toThrow(/truncated.*120 frames.*90 frames/);
expect(debug).not.toHaveBeenCalled();
} finally {
transaction.rollback();
rmSync(dir, { recursive: true, force: true });
}
},
);

it.each([1920, 1280])(
"preserves validation with a throwing logger and a probed width of %s",
async (width) => {
const dir = tempDir();
const probe: ArtifactDurationProbe = async () => ({
durationSeconds: 4,
width,
height: 1080,
});
const debug = vi.fn(() => {
throw new Error("debug sink unavailable");
});
const transaction = new ArtifactTransaction(
join(dir, "render.webm"),
"file",
undefined,
probe,
{
debug,
},
);
writeFileSync(transaction.stagingPath, "video");

try {
const validation = transaction.validate({
expectedDurationSeconds: 4,
fps: 30,
expectedFrames: 120,
expectedWidth: 1920,
expectedHeight: 1080,
});
if (width === 1920) await expect(validation).resolves.toBeUndefined();
else await expect(validation).rejects.toThrow("expected 1920x1080, probed 1280x1080");
expect(debug).toHaveBeenCalledOnce();
} finally {
transaction.rollback();
rmSync(dir, { recursive: true, force: true });
}
},
);

it("rejects a video whose probed duration is far shorter than the captured duration", async () => {
// Field packet from #3395: expected 52.2s (1566 frames), artifact
// contained 41.333s (1240 frames). The gate must catch the 11s
Expand All @@ -383,7 +522,10 @@ describe("ArtifactTransaction", () => {
expect(path).toBe(transaction.stagingPath);
return { durationSeconds: 41.333 };
};
const transaction = new ArtifactTransaction(destination, "file", undefined, probedShortfall);
const debug = vi.fn();
const transaction = new ArtifactTransaction(destination, "file", undefined, probedShortfall, {
debug,
});
writeFileSync(transaction.stagingPath, "fake-mp4-bytes-that-are-non-empty");

await expect(
Expand All @@ -393,6 +535,7 @@ describe("ArtifactTransaction", () => {
expectedFrames: 1566,
}),
).rejects.toThrow(/truncated/);
expect(debug).not.toHaveBeenCalled();

transaction.rollback();
});
Expand Down Expand Up @@ -518,12 +661,14 @@ describe("ArtifactTransaction", () => {
const dir = tempDir();
const destination = join(dir, "render.mp4");
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 0 });
const transaction = new ArtifactTransaction(destination, "file", undefined, probe);
const debug = vi.fn();
const transaction = new ArtifactTransaction(destination, "file", undefined, probe, { debug });
writeFileSync(transaction.stagingPath, "fake-mp4-bytes-that-are-non-empty");

await expect(transaction.validate({ expectedDurationSeconds: 10.0, fps: 30 })).rejects.toThrow(
/no usable duration/,
);
await expect(
transaction.validate({ expectedDurationSeconds: 10.0, fps: 30, expectedFrames: 300 }),
).rejects.toThrow(/no usable duration/);
expect(debug).not.toHaveBeenCalled();

transaction.rollback();
});
Expand Down Expand Up @@ -587,11 +732,14 @@ describe("ArtifactTransaction", () => {
// ffprobe reports `nb_frames=N/A` through the hls demuxer, so the frame
// check is inert for a playlist probe. Assert it stays inert rather than
// failing a package whose duration is correct.
it("passes an HLS package whose probe reports no frame count", async () => {
it("reports a skipped HLS frame-count check without rejecting the package", async () => {
const dir = tempDir();
const destination = join(dir, "render-hls");
const probe: ArtifactDurationProbe = async () => ({ durationSeconds: 4.0 });
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe);
const debug = vi.fn();
const transaction = new ArtifactTransaction(destination, "directory", undefined, probe, {
debug,
});
stageHlsDirectory(transaction);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
Expand All @@ -604,6 +752,10 @@ describe("ArtifactTransaction", () => {
requiredFiles: ["master.m3u8"],
}),
).resolves.toBeUndefined();
expect(debug).toHaveBeenCalledExactlyOnceWith(
"[Render] Frame-count validation skipped: no usable frame count was reported.",
{ event: "artifact_frame_count_unavailable", artifact: destination, expectedFrames: 120 },
);
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
Expand Down
32 changes: 24 additions & 8 deletions packages/producer/src/services/render/artifactTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { closeSync, existsSync, fstatSync, openSync, readSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, extname, join, resolve } from "node:path";
import { HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, renameWithRetry } from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { extractMediaMetadata } from "../../utils/ffprobe.js";
import type { RenderOutputFormat } from "./renderFormat.js";
import { TRANSACTION_BACKUP, createOwnedRenderDir } from "./renderDirOwner.js";
Expand Down Expand Up @@ -51,11 +52,9 @@ export type ArtifactDurationProbe = (path: string) => Promise<ArtifactDurationPr

async function defaultArtifactDurationProbe(path: string): Promise<ArtifactDurationProbeResult> {
const meta = await extractMediaMetadata(path);
// Forward the probed frame count when ffprobe reported one. The frame
// count check (#3395) catches the multi-worker encode mode where the
// container duration is reported correctly but the decoded stream is
// shorter. When the demuxer cannot determine frames, the frame-count check
// is skipped quietly and duration validation still applies.
// WebM and HLS can omit frame counts even when duration is available.
// Keep that absence visible to validation; duration alone cannot prove
// every expected frame is present.
return {
durationSeconds: meta.durationSeconds,
frames: meta.frames,
Expand Down Expand Up @@ -224,13 +223,17 @@ function assertDurationWithinTolerance(
);
}

function hasUsableFrameCount(frames: number | undefined): frames is number {
return frames !== undefined && Number.isFinite(frames) && frames > 0;
}

function assertFrameCountWithinTolerance(
expectedFrames: number,
probedFrames: number | undefined,
stagingPath: string,
toleranceFrames = 1,
): void {
if (probedFrames === undefined || !Number.isFinite(probedFrames) || probedFrames <= 0) {
if (!hasUsableFrameCount(probedFrames)) {
return;
}
const shortfall = expectedFrames - probedFrames;
Expand Down Expand Up @@ -288,6 +291,7 @@ export class ArtifactTransaction {
private readonly kind: ArtifactKind,
private readonly fileSystem: ArtifactTransactionFileSystem = defaultFileSystem,
durationProbe: ArtifactDurationProbe = defaultArtifactDurationProbe,
private readonly logger: Pick<ProducerLogger, "debug"> = defaultLogger,
) {
this.destinationPath = resolve(destinationPath);
this.transactionDirectory = createSiblingTransactionDirectory(this.destinationPath);
Expand Down Expand Up @@ -382,8 +386,20 @@ export class ArtifactTransaction {
);

if (expected.expectedFrames !== undefined) {
// ffprobe reports `nb_frames=N/A` through the hls demuxer, so a playlist
// without frame metadata relies on the duration check above.
if (!hasUsableFrameCount(probed.frames)) {
try {
this.logger.debug(
"[Render] Frame-count validation skipped: no usable frame count was reported.",
{
event: "artifact_frame_count_unavailable",
artifact: this.destinationPath,
expectedFrames: expected.expectedFrames,
},
);
} catch {
// A diagnostic sink failure must not change the validation result.
}
}
assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, probePath);
}
assertSizeMatches(expected, probed, probePath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getFfmpegBinary, getFfprobeBinary, resolveConfig } from "@hyperframes/engine";
import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js";

Expand Down Expand Up @@ -98,6 +98,8 @@ function extinfDurations(playlistPath: string): number[] {
describe.skipIf(!HAS_FFMPEG)("format: hls — real render", () => {
let root: string;
let outputDir: string;
let renderJobId: string;
const debug = vi.fn();

beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hf-hls-render-"));
Expand Down Expand Up @@ -130,16 +132,29 @@ describe.skipIf(!HAS_FFMPEG)("format: hls — real render", () => {
hlsSegmentSeconds: SEGMENT_SECONDS,
hdrMode: "force-sdr",
workers: 1,
logger: { info() {}, warn() {}, error() {}, debug() {} },
logger: { info() {}, warn() {}, error() {}, debug },
producerConfig: resolveConfig({ browserGpuMode: "software" }),
});
renderJobId = job.id;
await executeRenderJob(job, root, outputDir);
}, 600_000);

afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});

it("reports unavailable playlist frame counts through the scoped render logger", () => {
expect(debug).toHaveBeenCalledWith(
"[Render] Frame-count validation skipped: no usable frame count was reported.",
expect.objectContaining({
event: "artifact_frame_count_unavailable",
artifact: outputDir,
expectedFrames: DURATION_SECONDS * FPS,
renderJobId,
}),
);
});

it("publishes a directory with a master playlist referencing both renditions", () => {
const master = readFileSync(join(outputDir, "master.m3u8"), "utf-8");

Expand Down
Loading
Loading