Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {
normalizeVp9CpuUsed,
} from "./services/vp9Options.js";
export {
getCgroupMemoryLimitMb,
getSystemTotalMb,
isLowMemorySystem,
LOW_MEMORY_TOTAL_MB_THRESHOLD,
Expand Down Expand Up @@ -179,6 +180,7 @@ export {
parseImageElements,
extractVideoFramesRange,
extractAllVideoFrames,
resolveVideoExtractionDuration,
resolveProjectRelativeSrc,
getFrameAtTime,
createFrameLookupTable,
Expand Down
25 changes: 25 additions & 0 deletions packages/engine/src/services/systemMemory.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import {
_resetCgroupLimitCacheForTests,
Expand Down Expand Up @@ -154,6 +155,30 @@ describe("parseCgroupLimitMb", () => {
});
});

describe("getCgroupMemoryLimitMb", () => {
it("returns only an actual cgroup limit and never host RAM", async () => {
await withSystemMemoryMocks(
{
files: { [CGROUP_V2_MEMORY_MAX_PATH]: `${24576 * BYTES_PER_MIB}` },
hostTotalMb: 65536,
},
({ getCgroupMemoryLimitMb }) => {
expect(getCgroupMemoryLimitMb()).toBe(24576);
},
);

await withSystemMemoryMocks(
{
files: { [CGROUP_V2_MEMORY_MAX_PATH]: "max" },
hostTotalMb: 65536,
},
({ getCgroupMemoryLimitMb }) => {
expect(getCgroupMemoryLimitMb()).toBeNull();
},
);
});
});

describe("getSystemTotalMb", () => {
it("caches cgroup probes until the test reset hook clears the cache", async () => {
const readCalls: string[] = [];
Expand Down
8 changes: 6 additions & 2 deletions packages/engine/src/services/systemMemory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export function _resetCgroupLimitCacheForTests(): void {
_warnedCgroupReadFailure = false;
}

function getCgroupLimitMb(): number | null {
/**
* Actual Linux cgroup memory ceiling in MiB, or null when the process is not
* cgroup-limited. Unlike getSystemTotalMb this never falls back to host RAM.
*/
export function getCgroupMemoryLimitMb(): number | null {
if (_cachedCgroupLimitMb !== undefined) return _cachedCgroupLimitMb;

if (process.platform !== "linux") {
Expand Down Expand Up @@ -142,7 +146,7 @@ function warnCgroupReadFailure(path: string, error: unknown): void {
/** Total physical RAM in MiB. */
export function getSystemTotalMb(): number {
const hostTotalMb = Math.floor(totalmem() / BYTES_PER_MIB);
const cgroupLimitMb = getCgroupLimitMb();
const cgroupLimitMb = getCgroupMemoryLimitMb();

return cgroupLimitMb === null ? hostTotalMb : Math.min(hostTotalMb, cgroupLimitMb);
}
Expand Down
50 changes: 50 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolveFrameFormat,
codecMayHaveAlpha,
decoderForCodec,
resolveVideoExtractionDuration,
getFrameAtTime,
analyzeClipMediaFit,
classifyVideoExtractionError,
Expand All @@ -45,6 +46,55 @@ import { COMPLETE_SENTINEL, GC_MARKER, SCHEMA_PREFIX } from "./extractionCache.j
// synthesized VFR fixture.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;

describe("resolveVideoExtractionDuration", () => {
const metadata = (durationSeconds: number): VideoMetadata => ({
durationSeconds,
videoStreamDurationSeconds: durationSeconds,
width: 1920,
height: 1080,
fps: 30,
videoCodec: "h264",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: null,
});
const video = (overrides: Partial<VideoElement> = {}): VideoElement => ({
id: "root-video",
src: "video.mp4",
start: 0,
end: Number.POSITIVE_INFINITY,
mediaStart: 0,
loop: false,
hasAudio: false,
...overrides,
});

it("caps an open 60-second root source to a two-second composition", () => {
expect(resolveVideoExtractionDuration(video(), metadata(60), 2)).toBe(2);
});

it("keeps a shorter natural source duration inside a longer composition", () => {
expect(resolveVideoExtractionDuration(video(), metadata(2), 10)).toBe(2);
});

it("preserves explicit bounds and loop flags while applying the timeline ceiling", () => {
const explicitLoop = video({ end: 8, loop: true });
expect(resolveVideoExtractionDuration(explicitLoop, metadata(60), 10)).toBe(8);
expect(explicitLoop.loop).toBe(true);
});

it("preserves negative start and mediaStart semantics", () => {
const preroll = video({ start: -3, mediaStart: 5 });
expect(resolveVideoExtractionDuration(preroll, metadata(60), 2)).toBe(5);
expect(preroll).toMatchObject({ start: -3, mediaStart: 5 });
});

it("retains legacy behavior when no timeline end is supplied", () => {
expect(resolveVideoExtractionDuration(video(), metadata(60))).toBe(60);
});
});

describe("video extraction failure taxonomy and bounded retry", () => {
it("classifies missing and transient HTTP sources without exposing retry ambiguity", () => {
expect(classifyVideoExtractionError(new Error("HTTP 404: Not Found"))).toMatchObject({
Expand Down
54 changes: 38 additions & 16 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export interface ExtractionOptions {
quality?: number;
format?: VideoFrameFormat;
sdrToHdrTransfer?: HdrTransfer;
/**
* Absolute composition/timeline end in seconds. Applied only after source
* metadata resolves open-ended/natural-duration media, so authored start,
* mediaStart, loop, and sentinel semantics remain unchanged.
*/
timelineEnd?: number;
/**
* Bounded per-source FFmpeg retries. Default 0 preserves stable behavior;
* the producer may canary at most one retry after observing typed failures.
Expand Down Expand Up @@ -444,7 +450,10 @@ export function parseVideoElements(html: string): VideoElement[] {
// reference; the resolver handles both.
const start = startAttr ? resolveReferencedStart(document, el, startCache, visiting) : 0;
// Derive end from data-end → data-start+data-duration → Infinity (natural duration).
// The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
// Static compilation cannot always clamp root media because GSAP may supply
// the root duration at runtime. The producer passes the resolved timeline
// end into frame extraction, which caps the source duration only after the
// natural duration is known without rewriting authored timing metadata.
let end = 0;
if (endAttr) {
end = parseFloat(endAttr);
Expand Down Expand Up @@ -725,6 +734,24 @@ function resolveSegmentDuration(
return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
}

/** Resolve source duration first, then cap it at the remaining render timeline. */
export function resolveVideoExtractionDuration(
video: Pick<VideoElement, "start" | "end" | "mediaStart">,
metadata: VideoMetadata,
timelineEnd?: number,
): number {
const resolvedDuration = resolveSegmentDuration(
video.end - video.start,
video.mediaStart,
metadata,
);
if (timelineEnd === undefined) return resolvedDuration;
if (!Number.isFinite(timelineEnd)) {
throw new Error(`Video extraction timelineEnd must be finite; got ${String(timelineEnd)}`);
}
return Math.min(resolvedDuration, Math.max(0, timelineEnd - video.start));
}

/**
* Codecs whose bitstream is allowed to carry an alpha channel. Default the
* extraction path to PNG output for these regardless of `metadata.hasAlpha`
Expand Down Expand Up @@ -1029,6 +1056,11 @@ export async function extractAllVideoFrames(
>,
compiledDir?: string,
): Promise<ExtractionResult> {
if (options.timelineEnd !== undefined && !Number.isFinite(options.timelineEnd)) {
throw new Error(
`Video extraction timelineEnd must be finite; got ${String(options.timelineEnd)}`,
);
}
const startTime = Date.now();
const extracted: ExtractedFrames[] = [];
const errors: VideoExtractionFailure[] = [];
Expand Down Expand Up @@ -1062,6 +1094,7 @@ export async function extractAllVideoFrames(
const warnedSrcs = new Set<string>();
for (const video of videos) {
if (signal?.aborted) break;
if (options.timelineEnd !== undefined && video.start >= options.timelineEnd) continue;
try {
let videoPath = video.src;
if (!isHttpUrl(videoPath)) {
Expand Down Expand Up @@ -1113,8 +1146,8 @@ export async function extractAllVideoFrames(
breakdown.resolveMs = Date.now() - phase1Start;

// Snapshot the pre-preflight key inputs so the extraction cache keys on the
// user-visible source (original path, original mediaStart, original segment
// bounds) rather than the workDir-local normalized file produced by the
// user-visible source (original path and mediaStart) rather than the
// workDir-local normalized file produced by the
// HDR preflight. Without this, every render would write a new
// normalized file with a fresh mtime → fresh cache key → perpetual misses.
const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
Expand All @@ -1129,8 +1162,6 @@ export async function extractAllVideoFrames(
mtimeMs: stat.mtimeMs,
size: stat.size,
mediaStart: video.mediaStart,
start: video.start,
end: video.end,
};
});

Expand Down Expand Up @@ -1316,17 +1347,12 @@ export async function extractAllVideoFrames(
? sdrToHdrTransformKey(work.sdrToHdrTransfer)
: undefined;

const keyDuration = resolveSegmentDuration(
keyInput.end - keyInput.start,
keyInput.mediaStart,
work.metadata,
);
const lookup = lookupCacheEntry(cacheRootDir, {
videoPath: keyInput.videoPath,
mtimeMs: keyInput.mtimeMs,
size: keyInput.size,
mediaStart: keyInput.mediaStart,
duration: keyDuration,
duration: work.videoDuration,
fps: options.fps,
format: work.format,
transform,
Expand Down Expand Up @@ -1516,11 +1542,7 @@ export async function extractAllVideoFrames(
}
try {
const metadata = videoMetadata[index] ?? (await extractMediaMetadata(videoPath));
const videoDuration = resolveSegmentDuration(
video.end - video.start,
video.mediaStart,
metadata,
);
const videoDuration = resolveVideoExtractionDuration(video, metadata, options.timelineEnd);
if (video.end - video.start !== videoDuration) {
video.end = video.start + videoDuration;
}
Expand Down
73 changes: 73 additions & 0 deletions packages/producer/src/server.hdrMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Hono } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";

const capturedRenderConfigs = vi.hoisted(() => new Array<Record<string, unknown>>());

vi.mock("./services/renderOrchestrator.js", () => {
class RenderCancelledError extends Error {}

return {
RenderCancelledError,
createRenderJob: (config: Record<string, unknown>) => {
capturedRenderConfigs.push(config);
return {
config,
progress: 0,
currentStage: "queued",
framesRendered: 0,
totalFrames: 0,
warnings: [],
};
},
executeRenderJob: async (job: Record<string, unknown>) => {
job.outcome = "completed";
job.currentStage = "complete";
},
};
});

import { createRenderHandlers } from "./server.js";

function createInternalStreamingApp(): Hono {
const app = new Hono();
const handlers = createRenderHandlers({
getRequestId: () => "hdr-mode-test",
maxConcurrentRenders: 1,
});
app.post("/v1/render-stream", handlers.renderStream);
return app;
}

describe("POST /v1/render-stream — hdrMode", () => {
beforeEach(() => capturedRenderConfigs.splice(0));

it.each(["auto", "force-hdr", "force-sdr"] as const)(
"forwards %s through createRenderRequest into RenderConfig",
async (hdrMode) => {
const response = await createInternalStreamingApp().request("/v1/render-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ html: "<html><body></body></html>", hdrMode }),
});

expect(response.status).toBe(200);
expect(await response.text()).toContain('"type":"complete"');
expect(capturedRenderConfigs).toHaveLength(1);
expect(capturedRenderConfigs[0]?.hdrMode).toBe(hdrMode);
},
);

it("rejects an invalid mode before creating a render job", async () => {
const response = await createInternalStreamingApp().request("/v1/render-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ html: "<html><body></body></html>", hdrMode: "hdr" }),
});

expect(response.status).toBe(200);
expect(await response.text()).toContain(
'hdrMode must be one of: \\"auto\\", \\"force-hdr\\", \\"force-sdr\\"',
);
expect(capturedRenderConfigs).toHaveLength(0);
});
});
19 changes: 19 additions & 0 deletions packages/producer/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ describe("parseRenderOptions — render strictness", () => {
});
});

describe("parseRenderOptions — hdrMode", () => {
it.each(["auto", "force-hdr", "force-sdr"] as const)("forwards %s", (hdrMode) => {
expect(parseRenderOptions({ hdrMode }).hdrMode).toBe(hdrMode);
});

it("drops invalid values from the lenient parser", () => {
expect(parseRenderOptions({ hdrMode: "hdr" }).hdrMode).toBeUndefined();
expect(parseRenderOptions({ hdrMode: true }).hdrMode).toBeUndefined();
});
});

describe("prepareRenderBody — validation", () => {
it.each(["", " "])(
"treats an empty projectDir as absent and uses inline HTML",
Expand All @@ -67,6 +78,14 @@ describe("prepareRenderBody — validation", () => {
expect((result as { error: string }).error).toContain("variables must be a JSON object");
});

it("rejects an explicitly-supplied invalid hdrMode", async () => {
const result = await prepareRenderBody({ hdrMode: "hdr", html: "<html></html>" });
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain(
'hdrMode must be one of: "auto", "force-hdr", "force-sdr"',
);
});

it("rejects an explicitly-supplied invalid outputResolution", async () => {
const result = await prepareRenderBody({ outputResolution: "8k", html: "<html></html>" });
expect(result).toHaveProperty("error");
Expand Down
Loading
Loading