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
53 changes: 53 additions & 0 deletions packages/cli/src/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
runAndParseJsonEnvelope,
} from "./deprecationTestHarness.js";
import {
auditClipDurations,
extractCompositionErrorsFromLint,
navigationTimeoutHint,
raceMediaReady,
Expand Down Expand Up @@ -49,6 +50,58 @@ vi.mock("../utils/producer.js", () => ({
vi.mock("../utils/project.js", () => resolveProjectMock());
vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());

describe("auditClipDurations", () => {
it("audits audio only because explicit video slots hold their final frame", async () => {
let selector = "";
const originalDocument = globalThis.document;
const audio = {
duration: 1,
id: "voice",
loop: false,
tagName: "AUDIO",
getAttribute: (name: string) =>
name === "data-duration" ? "5" : name === "data-media-start" ? "0" : null,
};
const page = {
evaluate: async (fn: (waitMs: number) => unknown, waitMs: number) => {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
querySelectorAll: (query: string) => {
selector = query;
return [audio];
},
},
});
return fn(waitMs);
},
};

try {
const warnings = await auditClipDurations(
page as never,
({ slotSeconds, mediaSeconds }) => ({
shortfallSeconds: slotSeconds - mediaSeconds,
toleranceSeconds: 0.05,
}),
10,
);
expect(selector).toBe("audio[data-duration]");
expect(warnings).toHaveLength(1);
expect(warnings[0]?.text).toContain('Audio "voice"');
} finally {
if (originalDocument === undefined) {
Reflect.deleteProperty(globalThis, "document");
} else {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: originalDocument,
});
}
}
});
});

// Regression for the validate audio-duration-probe timeout: a slow-loading
// media element's duration was snapshotted once, at a fixed point in time,
// and any element still mid-load was permanently misreported as unreadable.
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ export function raceMediaReady(
}

/**
* Flag `<video>`/`<audio>` clips whose source is meaningfully shorter than their
* `data-duration` slot (the slot gets silently shortened in renders). Runs in
* Flag `<audio>` clips whose source is meaningfully shorter than their
* `data-duration` slot (the slot gets silently shortened in renders). Videos
* intentionally hold their final frame through an explicit longer slot. Runs in
* the live page to read each element's intrinsic `.duration`, which static lint
* can't see.
*/
Expand All @@ -140,7 +141,7 @@ export async function auditClipDurations(
// fallow-ignore-next-line complexity
const clips = await page.evaluate(async (maxWaitMs: number) => {
const nodes = Array.from(
document.querySelectorAll("video[data-duration], audio[data-duration]"),
document.querySelectorAll("audio[data-duration]"),
) as HTMLMediaElement[];

// The caller's page-settle sleep is a flat, unconditional wait shared with
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/compiler/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,39 @@ describe("compileHtml", () => {
expect(compiled).toContain('data-end="4"');
});

it("still clamps non-looping media durations to source duration", async () => {
it("preserves an explicit non-looping video slot past source end", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4">';

const compiled = await compileHtml(html, "/project", async () => 3.125);

expect(compiled).toContain('data-duration="4"');
expect(compiled).toContain('data-end="4"');
});

it("uses natural duration for a video without an explicit slot inside a composition", async () => {
const html =
'<div data-composition-id="root" data-start="0" data-duration="5">' +
'<video id="hero" src="hero.webm" data-start="0">' +
"</div>";

const compiled = await compileHtml(html, "/project", async () => 1);

expect(compiled).toContain('data-duration="1"');
expect(compiled).toContain('data-end="1"');
});

it("uses natural duration for a standalone video without a composition window", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0">';
const compiled = await compileHtml(html, "/project", async () => 1);
expect(compiled).toContain('data-duration="1"');
expect(compiled).toContain('data-end="1"');
});

it("still clamps non-looping audio durations to source duration", async () => {
const html = '<audio id="voice" src="voice.wav" data-start="0" data-duration="4" data-end="4">';

const compiled = await compileHtml(html, "/project", async () => 3.125);

expect(compiled).toContain('data-duration="3.125"');
expect(compiled).toContain('data-end="3.125"');
});
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/compiler/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
shouldClampResolvedMediaDuration,
type ResolvedDuration,
} from "./timingCompiler";

Expand All @@ -23,7 +23,8 @@ function resolveMediaSrc(src: string, projectDir: string): string {
*
* 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists
* 2. For unresolved video/audio (no data-duration): probe via probeMediaDuration, inject durations
* 3. For pre-resolved video/audio: validate data-duration against actual source, clamp if needed
* 3. For pre-resolved audio: clamp data-duration to playable source when needed.
* Explicit video slots are preserved and hold their final frame.
*
* @param rawHtml - The raw HTML string
* @param projectDir - The project directory for resolving relative paths
Expand Down Expand Up @@ -54,18 +55,17 @@ export async function compileHtml(
if (fileDuration <= 0) continue;

const effectiveDuration = fileDuration - el.mediaStart;
resolutions.push({
id: el.id,
duration: effectiveDuration > 0 ? effectiveDuration : fileDuration,
});
const sourceDuration = effectiveDuration > 0 ? effectiveDuration : fileDuration;
resolutions.push({ id: el.id, duration: sourceDuration });
}

if (resolutions.length > 0) {
html = injectDurations(html, resolutions);
}
}

// Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration
// Phase 2: Bound authored audio to playable source. Explicit video slots may
// outlive their source and render by holding the final frame.
const preResolved = extractResolvedMedia(html);
const clampList: ResolvedDuration[] = [];

Expand All @@ -77,7 +77,7 @@ export async function compileHtml(
if (fileDuration <= 0) continue;

const maxDuration = fileDuration - el.mediaStart;
if (maxDuration > 0 && shouldClampMediaDuration(el.duration, maxDuration)) {
if (maxDuration > 0 && shouldClampResolvedMediaDuration(el.tagName, el.duration, maxDuration)) {
clampList.push({ id: el.id, duration: maxDuration });
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
shouldClampResolvedMediaDuration,
type UnresolvedElement,
type ResolvedDuration,
type ResolvedMediaElement,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampResolvedMediaDuration,
} from "./timingCompiler.js";

// Raw 0x00 bytes in the HFMASK delimiters shipped once and broke every render
Expand Down Expand Up @@ -264,3 +265,10 @@ describe("clampDurations", () => {
expect(result).toContain('data-end="7"');
});
});

describe("shouldClampResolvedMediaDuration", () => {
it("preserves an explicit video slot but keeps audio source-bounded", () => {
expect(shouldClampResolvedMediaDuration("video", 5, 1)).toBe(false);
expect(shouldClampResolvedMediaDuration("audio", 5, 1)).toBe(true);
});
});
20 changes: 17 additions & 3 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,29 @@ export interface CompilationResult {
unresolved: UnresolvedElement[];
}

// ffprobe precision can differ slightly across local and CI media stacks. Also
// the floor for the engine's hold-last-frame tolerance (a slot left unclamped is
// short by at most this), so they must move together.
// ffprobe precision can differ slightly across local and CI media stacks, so
// avoid shortening authored audio for insignificant probe drift.
export const MEDIA_DURATION_CLAMP_EPSILON_SECONDS = 0.05;

export function shouldClampMediaDuration(declaredDuration: number, maxDuration: number): boolean {
return declaredDuration > maxDuration + MEDIA_DURATION_CLAMP_EPSILON_SECONDS;
}

/**
* Whether compilation should shorten an authored media slot to its source.
*
* Non-looping video intentionally keeps an explicit longer slot: browsers and
* the render frame injector hold its final frame until that authored slot ends.
* Audio has no frame to hold, so its slot remains bounded by playable source.
*/
export function shouldClampResolvedMediaDuration(
tagName: ResolvedMediaElement["tagName"],
declaredDuration: number,
maxDuration: number,
): boolean {
return tagName === "audio" && shouldClampMediaDuration(declaredDuration, maxDuration);
}

// ── Helpers ──────────────────────────────────────────────────────────────

function getAttr(tag: string, attr: string): string | null {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("@hyperframes/core public API exports", () => {
expect(typeof core.extractResolvedMedia).toBe("function");
expect(typeof core.clampDurations).toBe("function");
expect(typeof core.shouldClampMediaDuration).toBe("function");
expect(typeof core.shouldClampResolvedMediaDuration).toBe("function");
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export {
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
shouldClampResolvedMediaDuration,
MEDIA_DURATION_CLAMP_EPSILON_SECONDS,
} from "./compiler/timingCompiler";

Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {
} from "./adapters/video-texture-compat";
import { forceDispatchSeekEvent } from "./adapters/seek-dispatch";
import { createWaapiAdapter } from "./adapters/waapi";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import {
refreshRuntimeMediaCache,
resolveRuntimeMediaClipDuration,
syncRuntimeMedia,
} from "./media";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { createPickerModule } from "./picker";
import { createRuntimePlayer, type RuntimePlayerTransport } from "./player";
Expand Down Expand Up @@ -1807,10 +1811,12 @@ export function initSandboxRuntimeModular(): void {
const ownDuration = Number.parseFloat(element.dataset.duration ?? "");
const explicitDuration =
Number.isFinite(ownDuration) && ownDuration > 0 ? ownDuration : null;
const candidates = [sourceDuration, hostRemaining, explicitDuration].filter(
(value): value is number => value != null,
);
return candidates.length > 0 ? Math.min(...candidates) : null;
return resolveRuntimeMediaClipDuration({
isVideo: element.tagName === "VIDEO",
sourceDuration,
hostRemaining,
explicitDuration,
});
},
});
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
Expand Down
Loading
Loading