From 84e167ecbb6c50934447ea047e9b355685cf058e Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 15:19:21 +0800 Subject: [PATCH 1/3] fix(producer): report unavailable frame checks in debug logs --- docs/packages/producer.mdx | 13 ++ .../render/artifactTransaction.test.ts | 172 +++++++++++++++++- .../services/render/artifactTransaction.ts | 32 +++- .../src/services/renderOrchestrator.ts | 3 + 4 files changed, 202 insertions(+), 18 deletions(-) diff --git a/docs/packages/producer.mdx b/docs/packages/producer.mdx index 50ab5666a10..37763181728 100644 --- a/docs/packages/producer.mdx +++ b/docs/packages/producer.mdx @@ -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: diff --git a/packages/producer/src/services/render/artifactTransaction.test.ts b/packages/producer/src/services/render/artifactTransaction.test.ts index 854766994ec..0e4f3a2a059 100644 --- a/packages/producer/src/services/render/artifactTransaction.test.ts +++ b/packages/producer/src/services/render/artifactTransaction.test.ts @@ -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(); }); @@ -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(() => {}); @@ -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(); @@ -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 @@ -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( @@ -393,6 +535,7 @@ describe("ArtifactTransaction", () => { expectedFrames: 1566, }), ).rejects.toThrow(/truncated/); + expect(debug).not.toHaveBeenCalled(); transaction.rollback(); }); @@ -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(); }); @@ -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 { @@ -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(); diff --git a/packages/producer/src/services/render/artifactTransaction.ts b/packages/producer/src/services/render/artifactTransaction.ts index d6325a89a50..401b82dac7c 100644 --- a/packages/producer/src/services/render/artifactTransaction.ts +++ b/packages/producer/src/services/render/artifactTransaction.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; import { HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST } 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"; @@ -60,11 +61,9 @@ export type ArtifactDurationProbe = (path: string) => Promise { 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, @@ -233,13 +232,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; @@ -297,6 +300,7 @@ export class ArtifactTransaction { private readonly kind: ArtifactKind, private readonly fileSystem: ArtifactTransactionFileSystem = defaultFileSystem, durationProbe: ArtifactDurationProbe = defaultArtifactDurationProbe, + private readonly logger: Pick = defaultLogger, ) { this.destinationPath = resolve(destinationPath); this.transactionDirectory = createSiblingTransactionDirectory(this.destinationPath); @@ -391,8 +395,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); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index d1f9160c740..1c2293fa281 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -2899,6 +2899,9 @@ async function executeRenderPipeline(input: { const artifactTransaction = new ArtifactTransaction( outputPath, isPngSequence || isHls ? "directory" : "file", + undefined, + undefined, + log, ); const stagedOutputPath = artifactTransaction.stagingPath; const needsAlpha = outputNeedsAlpha(outputFormat); From bce3a94ddd0a8a4ac5d48a8a4585080782e9a5fd Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sun, 25 Oct 2026 02:22:58 +0800 Subject: [PATCH 2/3] chore: prepare conflict resolution against current main --- .../services/render/artifactTransaction.ts | 47 +++++-------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/packages/producer/src/services/render/artifactTransaction.ts b/packages/producer/src/services/render/artifactTransaction.ts index 401b82dac7c..9bc905ede0c 100644 --- a/packages/producer/src/services/render/artifactTransaction.ts +++ b/packages/producer/src/services/render/artifactTransaction.ts @@ -1,16 +1,6 @@ -import { - closeSync, - existsSync, - fstatSync, - openSync, - readSync, - readdirSync, - renameSync, - rmSync, -} from "node:fs"; +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 } from "@hyperframes/engine"; -import { defaultLogger, type ProducerLogger } from "../../logger.js"; +import { HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, renameWithRetry } from "@hyperframes/engine"; import { extractMediaMetadata } from "../../utils/ffprobe.js"; import type { RenderOutputFormat } from "./renderFormat.js"; import { TRANSACTION_BACKUP, createOwnedRenderDir } from "./renderDirOwner.js"; @@ -25,7 +15,7 @@ export interface ArtifactTransactionFileSystem { const defaultFileSystem: ArtifactTransactionFileSystem = { existsSync, - renameSync, + renameSync: (source, destination) => renameWithRetry(source, destination), rmSync, }; @@ -61,9 +51,11 @@ export type ArtifactDurationProbe = (path: string) => Promise { const meta = await extractMediaMetadata(path); - // 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. + // 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. return { durationSeconds: meta.durationSeconds, frames: meta.frames, @@ -232,17 +224,13 @@ 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 (!hasUsableFrameCount(probedFrames)) { + if (probedFrames === undefined || !Number.isFinite(probedFrames) || probedFrames <= 0) { return; } const shortfall = expectedFrames - probedFrames; @@ -300,7 +288,6 @@ export class ArtifactTransaction { private readonly kind: ArtifactKind, private readonly fileSystem: ArtifactTransactionFileSystem = defaultFileSystem, durationProbe: ArtifactDurationProbe = defaultArtifactDurationProbe, - private readonly logger: Pick = defaultLogger, ) { this.destinationPath = resolve(destinationPath); this.transactionDirectory = createSiblingTransactionDirectory(this.destinationPath); @@ -395,20 +382,8 @@ export class ArtifactTransaction { ); if (expected.expectedFrames !== undefined) { - 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. - } - } + // ffprobe reports `nb_frames=N/A` through the hls demuxer, so a playlist + // without frame metadata relies on the duration check above. assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, probePath); } assertSizeMatches(expected, probed, probePath); From f267ed49a39885c4c840a6855846cdb0091808b7 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sun, 25 Oct 2026 02:23:06 +0800 Subject: [PATCH 3/3] fix(producer): retain scoped diagnostics with current artifact handling --- .../services/render/artifactTransaction.ts | 32 ++++++++++++++----- .../render/hlsRender.integration.test.ts | 19 +++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/producer/src/services/render/artifactTransaction.ts b/packages/producer/src/services/render/artifactTransaction.ts index 9bc905ede0c..929cce6b7aa 100644 --- a/packages/producer/src/services/render/artifactTransaction.ts +++ b/packages/producer/src/services/render/artifactTransaction.ts @@ -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"; @@ -51,11 +52,9 @@ export type ArtifactDurationProbe = (path: string) => Promise { 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, @@ -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; @@ -288,6 +291,7 @@ export class ArtifactTransaction { private readonly kind: ArtifactKind, private readonly fileSystem: ArtifactTransactionFileSystem = defaultFileSystem, durationProbe: ArtifactDurationProbe = defaultArtifactDurationProbe, + private readonly logger: Pick = defaultLogger, ) { this.destinationPath = resolve(destinationPath); this.transactionDirectory = createSiblingTransactionDirectory(this.destinationPath); @@ -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); diff --git a/packages/producer/src/services/render/hlsRender.integration.test.ts b/packages/producer/src/services/render/hlsRender.integration.test.ts index 6ad43804aed..0ec722e9641 100644 --- a/packages/producer/src/services/render/hlsRender.integration.test.ts +++ b/packages/producer/src/services/render/hlsRender.integration.test.ts @@ -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"; @@ -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-")); @@ -130,9 +132,10 @@ 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); @@ -140,6 +143,18 @@ describe.skipIf(!HAS_FFMPEG)("format: hls — real render", () => { 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");