Skip to content
Closed
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
7 changes: 6 additions & 1 deletion packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,14 @@ export {
type ExtractedFrames,
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
} from "./services/videoFrameExtractor.js";

export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
export {
createVideoFrameInjector,
createEmptyInjectorCacheStats,
type InjectorCacheStats,
} from "./services/videoFrameInjector.js";

export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
export type { AudioElement, AudioTrack, MixResult } from "./services/audioMixer.types.js";
Expand Down
40 changes: 40 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,46 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
// (116/300); on the actual reporter's ScreenCaptureKit clip, 18–44% across
// segments. <10% threshold leaves margin across ffmpeg versions without
// letting a regression slip through.
it("populates phaseBreakdown with timings for resolve, probe, VFR preflight, and extract", async () => {
const outputDir = join(FIXTURE_DIR, "out-phase-breakdown");
mkdirSync(outputDir, { recursive: true });

const video: VideoElement = {
id: "vbreak",
src: VFR_FIXTURE,
start: 0,
end: 2,
mediaStart: 0,
hasAudio: false,
};

const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
fps: 30,
outputDir,
});

expect(result.errors).toEqual([]);
const pb = result.phaseBreakdown;
// Each phase ran; non-negative is the only universal invariant since
// resolveMs can round to 0 on fast local paths.
expect(pb.resolveMs).toBeGreaterThanOrEqual(0);
expect(pb.probeMs).toBeGreaterThanOrEqual(0);
expect(pb.hdrPreflightMs).toBeGreaterThanOrEqual(0);
expect(pb.vfrPreflightMs).toBeGreaterThanOrEqual(0);
expect(pb.extractMs).toBeGreaterThan(0);
// The VFR fixture is synthesized with irregular timestamps, so the VFR
// preflight must have actually run and been counted.
expect(pb.vfrPreflightCount).toBe(1);
expect(pb.vfrPreflightMs).toBeGreaterThan(0);
// No HDR source, so the HDR preflight is skipped entirely.
expect(pb.hdrPreflightCount).toBe(0);
expect(pb.hdrPreflightMs).toBe(0);
// Phases are bounded by total wall time (allow 50ms slack for timer
// resolution + overhead between the Date.now() samples).
const phaseSum = pb.resolveMs + pb.probeMs + pb.vfrPreflightMs + pb.extractMs;
expect(phaseSum).toBeLessThanOrEqual(result.durationMs + 50);
}, 60_000);

it("produces the full frame count and no duplicate-frame runs on the full VFR file", async () => {
const outputDir = join(FIXTURE_DIR, "out-full");
mkdirSync(outputDir, { recursive: true });
Expand Down
44 changes: 44 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,29 @@ export interface ExtractionOptions {
format?: "jpg" | "png";
}

export interface ExtractionPhaseBreakdown {
/** Resolve relative paths + download remote inputs. */
resolveMs: number;
/** ffprobe passes across all inputs (color-space probe + VFR metadata). */
probeMs: number;
/** Sum of per-input `convertSdrToHdr` re-encodes. */
hdrPreflightMs: number;
/** Sum of per-input `convertVfrToCfr` re-encodes. */
vfrPreflightMs: number;
/** Phase 3 — parallel frame extraction (wall time, not summed). */
extractMs: number;
/** Counts of inputs hitting each preflight, for ratio analysis. */
hdrPreflightCount: number;
vfrPreflightCount: number;
}

export interface ExtractionResult {
success: boolean;
extracted: ExtractedFrames[];
errors: Array<{ videoId: string; error: string }>;
totalFramesExtracted: number;
durationMs: number;
phaseBreakdown: ExtractionPhaseBreakdown;
}

export function parseVideoElements(html: string): VideoElement[] {
Expand Down Expand Up @@ -364,8 +381,18 @@ export async function extractAllVideoFrames(
const extracted: ExtractedFrames[] = [];
const errors: Array<{ videoId: string; error: string }> = [];
let totalFramesExtracted = 0;
const phaseBreakdown: ExtractionPhaseBreakdown = {
resolveMs: 0,
probeMs: 0,
hdrPreflightMs: 0,
vfrPreflightMs: 0,
extractMs: 0,
hdrPreflightCount: 0,
vfrPreflightCount: 0,
};

// Phase 1: Resolve paths and download remote videos
const resolveStart = Date.now();
const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
for (const video of videos) {
if (signal?.aborted) break;
Expand All @@ -392,14 +419,17 @@ export async function extractAllVideoFrames(
errors.push({ videoId: video.id, error: err instanceof Error ? err.message : String(err) });
}
}
phaseBreakdown.resolveMs = Date.now() - resolveStart;

// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
const probeStart = Date.now();
const videoColorSpaces = await Promise.all(
resolvedVideos.map(async ({ videoPath }) => {
const metadata = await extractVideoMetadata(videoPath);
return metadata.colorSpace;
}),
);
phaseBreakdown.probeMs += Date.now() - probeStart;

const hasAnyHdr = videoColorSpaces.some(isHdrColorSpaceUtil);
if (hasAnyHdr) {
Expand All @@ -414,14 +444,18 @@ export async function extractAllVideoFrames(
const entry = resolvedVideos[i];
if (!entry) continue;
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
const hdrStart = Date.now();
try {
await convertSdrToHdr(entry.videoPath, convertedPath, signal, config);
entry.videoPath = convertedPath;
phaseBreakdown.hdrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
phaseBreakdown.hdrPreflightMs += Date.now() - hdrStart;
}
}
}
Expand All @@ -434,7 +468,9 @@ export async function extractAllVideoFrames(
if (signal?.aborted) break;
const entry = resolvedVideos[i];
if (!entry) continue;
const vfrProbeStart = Date.now();
const metadata = await extractVideoMetadata(entry.videoPath);
phaseBreakdown.probeMs += Date.now() - vfrProbeStart;
if (!metadata.isVFR) continue;

let segDuration = entry.video.end - entry.video.start;
Expand All @@ -445,6 +481,7 @@ export async function extractAllVideoFrames(

mkdirSync(vfrNormDir, { recursive: true });
const normalizedPath = join(vfrNormDir, `${entry.video.id}_cfr.mp4`);
const vfrStart = Date.now();
try {
await convertVfrToCfr(
entry.videoPath,
Expand All @@ -460,15 +497,19 @@ export async function extractAllVideoFrames(
// extraction must seek from 0, not the original mediaStart. Shallow-copy
// to avoid mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 };
phaseBreakdown.vfrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: err instanceof Error ? err.message : String(err),
});
} finally {
phaseBreakdown.vfrPreflightMs += Date.now() - vfrStart;
}
}

// Phase 3: Extract frames (parallel)
const extractStart = Date.now();
const results = await Promise.all(
resolvedVideos.map(async ({ video, videoPath }) => {
if (signal?.aborted) {
Expand Down Expand Up @@ -508,6 +549,8 @@ export async function extractAllVideoFrames(
}),
);

phaseBreakdown.extractMs = Date.now() - extractStart;

// Collect results and errors
for (const item of results) {
if ("error" in item && item.error) {
Expand All @@ -524,6 +567,7 @@ export async function extractAllVideoFrames(
errors,
totalFramesExtracted,
durationMs: Date.now() - startTime,
phaseBreakdown,
};
}

Expand Down
97 changes: 97 additions & 0 deletions packages/engine/src/services/videoFrameInjector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
__createFrameDataUriCacheForTests as createCache,
createEmptyInjectorCacheStats,
} from "./videoFrameInjector.js";

// The injector's frame-dataURI LRU is what the performance review flags as
// bottleneck #8 (disk reads per frame). These tests validate the cumulative
// counters that the producer exposes via `RenderPerfSummary.injectorStats`
// so future PRs — the extraction-cache work in particular — have a trustable
// hit-rate signal to compare against.
describe("InjectorCacheStats via frame-dataURI LRU", () => {
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-injector-stats-"));
const FRAME_A = join(FIXTURE_DIR, "a.jpg");
const FRAME_B = join(FIXTURE_DIR, "b.jpg");
const FRAME_C = join(FIXTURE_DIR, "c.jpg");

beforeAll(() => {
// Content is irrelevant — the cache keys on path and base64-encodes the bytes.
writeFileSync(FRAME_A, Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
writeFileSync(FRAME_B, Buffer.from([0xff, 0xd8, 0xff, 0xe1]));
writeFileSync(FRAME_C, Buffer.from([0xff, 0xd8, 0xff, 0xe2]));
});

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

it("counts a miss on first read and a hit on re-read", async () => {
const stats = createEmptyInjectorCacheStats();
const cache = createCache(32, stats);

await cache.get(FRAME_A);
await cache.get(FRAME_A);

expect(stats.misses).toBe(1);
expect(stats.hits).toBe(1);
expect(stats.inFlightCoalesced).toBe(0);
expect(stats.peakEntries).toBe(1);
});

it("coalesces concurrent reads of the same path into one miss", async () => {
const stats = createEmptyInjectorCacheStats();
const cache = createCache(32, stats);

const [a, b, c] = await Promise.all([
cache.get(FRAME_B),
cache.get(FRAME_B),
cache.get(FRAME_B),
]);

expect(a).toBe(b);
expect(b).toBe(c);
// Exactly one disk read is issued, and the other two concurrent requests
// are tallied as coalesced.
expect(stats.misses).toBe(1);
expect(stats.inFlightCoalesced).toBe(2);
expect(stats.hits).toBe(0);
});

it("tracks peakEntries as the LRU fills", async () => {
const stats = createEmptyInjectorCacheStats();
const cache = createCache(32, stats);

await cache.get(FRAME_A);
await cache.get(FRAME_B);
await cache.get(FRAME_C);

expect(stats.peakEntries).toBe(3);
});

it("evicts under pressure without inflating peakEntries past the limit", async () => {
const stats = createEmptyInjectorCacheStats();
// Tiny limit forces eviction after 2 inserts.
const cache = createCache(2, stats);

await cache.get(FRAME_A);
await cache.get(FRAME_B);
await cache.get(FRAME_C);

expect(stats.peakEntries).toBe(2);
// FRAME_A was evicted when FRAME_C was inserted — re-reading it is a miss.
await cache.get(FRAME_A);
expect(stats.misses).toBe(4);
});

it("leaves counters untouched when no stats object is provided", async () => {
// Regression guard: the sentinel check in the cache (stats != null) is the
// only thing keeping existing callers' performance unchanged. This test
// simply exercises the path and confirms nothing throws.
const cache = createCache(32);
await expect(cache.get(FRAME_A)).resolves.toMatch(/^data:image\/jpeg;base64,/);
});
});
41 changes: 39 additions & 2 deletions packages/engine/src/services/videoFrameInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,37 @@ import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotSe
import { type BeforeCaptureHook } from "./frameCapture.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";

function createFrameDataUriCache(cacheLimit: number) {
/**
* Per-render metrics for the frame-dataURI LRU. Populated by the injector
* when the caller passes a stats object to `createVideoFrameInjector`.
*
* Designed to be read once at end-of-render; values are cumulative counters
* plus a peak watermark.
*/
export interface InjectorCacheStats {
/** Cache returned a previously-loaded data URI without touching disk. */
hits: number;
/** Cache miss — a new disk read was issued. */
misses: number;
/** Concurrent request for an in-flight read was coalesced onto the pending promise. */
inFlightCoalesced: number;
/** Max size the LRU reached during the render (counts of data-URI entries). */
peakEntries: number;
}

export function createEmptyInjectorCacheStats(): InjectorCacheStats {
return { hits: 0, misses: 0, inFlightCoalesced: 0, peakEntries: 0 };
}

/**
* Exported for unit tests only — not part of the package's public API.
* Used to validate the LRU / stats behavior without spinning up a Chrome page.
*/
export function __createFrameDataUriCacheForTests(cacheLimit: number, stats?: InjectorCacheStats) {
return createFrameDataUriCache(cacheLimit, stats);
}

function createFrameDataUriCache(cacheLimit: number, stats?: InjectorCacheStats) {
const cache = new Map<string, string>();
const inFlight = new Map<string, Promise<string>>();

Expand All @@ -29,21 +59,27 @@ function createFrameDataUriCache(cacheLimit: number) {
cache.delete(oldestKey);
}
}
if (stats && cache.size > stats.peakEntries) {
stats.peakEntries = cache.size;
}
return dataUri;
}

async function get(framePath: string): Promise<string> {
const cached = cache.get(framePath);
if (cached) {
if (stats) stats.hits += 1;
remember(framePath, cached);
return cached;
}

const existing = inFlight.get(framePath);
if (existing) {
if (stats) stats.inFlightCoalesced += 1;
return existing;
}

if (stats) stats.misses += 1;
const pending = fs
.readFile(framePath)
.then((frameData) => {
Expand All @@ -68,14 +104,15 @@ function createFrameDataUriCache(cacheLimit: number) {
export function createVideoFrameInjector(
frameLookup: FrameLookupTable | null,
config?: Partial<Pick<EngineConfig, "frameDataUriCacheLimit">>,
stats?: InjectorCacheStats,
): BeforeCaptureHook | null {
if (!frameLookup) return null;

const cacheLimit = Math.max(
32,
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
);
const frameCache = createFrameDataUriCache(cacheLimit);
const frameCache = createFrameDataUriCache(cacheLimit, stats);
const lastInjectedFrameByVideo = new Map<string, number>();

return async (page: Page, time: number) => {
Expand Down
Loading
Loading