Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 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,9 @@ export {
parseImageElements,
extractVideoFramesRange,
extractAllVideoFrames,
resolveTimelineExtractionWindow,
resolveVideoExtractionWindow,
resolveVideoExtractionDuration,
resolveProjectRelativeSrc,
getFrameAtTime,
createFrameLookupTable,
Expand All @@ -194,6 +198,7 @@ export {
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
type TimelineExtractionWindow,
type VideoExtractionFailure,
type VideoExtractionFailureKind,
type VideoFrameFormat,
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
83 changes: 83 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,8 @@ import {
resolveFrameFormat,
codecMayHaveAlpha,
decoderForCodec,
resolveVideoExtractionWindow,
resolveVideoExtractionDuration,
getFrameAtTime,
analyzeClipMediaFit,
classifyVideoExtractionError,
Expand All @@ -45,6 +47,65 @@ 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("trims materially negative preroll and advances the source offset", () => {
const preroll = video({ start: -60, end: 120, mediaStart: 0 });
expect(resolveVideoExtractionWindow(preroll, metadata(120), 2)).toEqual({
compositionStart: 0,
mediaStart: 60,
durationSeconds: 2,
});
expect(resolveVideoExtractionDuration(preroll, metadata(120), 2)).toBe(2);
});

it("returns an empty window for a clip entirely before composition time zero", () => {
expect(resolveVideoExtractionWindow(video({ start: -60, end: -10 }), metadata(120), 2)).toEqual(
{ compositionStart: 0, mediaStart: 60, durationSeconds: 0 },
);
});

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 Expand Up @@ -1017,6 +1078,28 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
if (existsSync(FIXTURE_DIR)) rmSync(FIXTURE_DIR, { recursive: true, force: true });
});

it("skips a clip entirely before time zero without reporting an extraction error", async () => {
const outputDir = join(FIXTURE_DIR, "out-before-timeline");
mkdirSync(outputDir, { recursive: true });
const video: VideoElement = {
id: "before-timeline",
src: VFR_FIXTURE,
start: -2,
end: -1,
mediaStart: 0,
loop: false,
hasAudio: false,
};

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

expect(result).toMatchObject({ success: true, extracted: [], errors: [] });
});

it("detects the synthesized fixture as VFR", async () => {
const md = await extractVideoMetadata(VFR_FIXTURE);
expect(md.isVFR).toBe(true);
Expand Down
Loading
Loading