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
30 changes: 30 additions & 0 deletions packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatSnapshotTimestamp,
parseZoomScale,
requireSnapshotFfmpeg,
resolveSnapshotVideoClipStart,
resolveSnapshotVideoFrameTime,
tailFrameTime,
} from "./snapshot.js";
Expand Down Expand Up @@ -154,6 +155,35 @@ describe("resolveSnapshotVideoFrameTime", () => {
});
});

describe("resolveSnapshotVideoClipStart", () => {
it("offsets a scene-local video start by its later template host", () => {
expect(
resolveSnapshotVideoClipStart({
authoredStart: 0,
runtimeResolvedStart: 3,
}),
).toBe(3);
});

it("uses the runtime's recursively resolved start for deeply nested media", () => {
expect(
resolveSnapshotVideoClipStart({
authoredStart: 1,
runtimeResolvedStart: 8,
}),
).toBe(8);
});

it("keeps authored starts as a compatibility fallback", () => {
expect(
resolveSnapshotVideoClipStart({
authoredStart: 3,
runtimeResolvedStart: null,
}),
).toBe(3);
});
});

describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
it("default frames: last point is the readable tail, never exact duration", () => {
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 });
Expand Down
52 changes: 39 additions & 13 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export function resolveSnapshotVideoFrameTime(input: {
return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30));
}

/** Prefer the runtime's canonical absolute media start. The authored value is
* only a compatibility fallback for pages built with an older runtime. */
export function resolveSnapshotVideoClipStart(input: {
authoredStart: number;
runtimeResolvedStart: number | null;
}): number {
return input.runtimeResolvedStart ?? input.authoredStart;
}

export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
if (ffmpegPath) return ffmpegPath;
throw new Error(
Expand Down Expand Up @@ -399,10 +408,14 @@ async function captureSnapshots(
if (cameraExpr) await page.evaluate(cameraExpr);

if (injectVideoFramesBatch && syncVideoFrameVisibility) {
const candidates = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]")).map((el) => {
const candidates = await page.evaluate(() => {
const runtimeWindow = window as Window & {
__hfResolveMediaStartSeconds?: (element: Element) => number;
};
return Array.from(document.querySelectorAll("video")).map((el) => {
const v = el as HTMLVideoElement;
const start = parseFloat(v.dataset.start ?? "0") || 0;
const authoredStart = parseFloat(v.dataset.start ?? "0") || 0;
const runtimeResolvedStart = runtimeWindow.__hfResolveMediaStartSeconds?.(v);
const rawRate = v.defaultPlaybackRate;
const playbackRate =
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
Expand All @@ -416,30 +429,43 @@ async function captureSnapshots(
: srcDur > 0
? Math.max(0, (srcDur - mediaStart) / playbackRate)
: Number.POSITIVE_INFINITY;
let relTime = (t - start) * playbackRate + mediaStart;
if (v.loop && srcDur > mediaStart && relTime >= srcDur) {
relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart));
}
return {
id: v.id,
src: v.currentSrc || v.src,
start,
authoredStart,
runtimeResolvedStart:
runtimeResolvedStart !== undefined && Number.isFinite(runtimeResolvedStart)
? runtimeResolvedStart
: null,
duration,
srcDuration: srcDur,
relTime,
playbackRate,
mediaStart,
loop: v.loop,
};
});
}, time);
});
const active = candidates.flatMap((candidate) => {
const start = resolveSnapshotVideoClipStart(candidate);
let relTime = (time - start) * candidate.playbackRate + candidate.mediaStart;
if (
candidate.loop &&
candidate.srcDuration > candidate.mediaStart &&
relTime >= candidate.srcDuration
) {
relTime =
candidate.mediaStart +
((relTime - candidate.mediaStart) % (candidate.srcDuration - candidate.mediaStart));
}
if (!candidate.id || !candidate.src) return [];
const frameTime = resolveSnapshotVideoFrameTime({
globalTime: time,
clipStart: candidate.start,
clipStart: start,
clipDuration: candidate.duration,
relativeTime: candidate.relTime,
relativeTime: relTime,
sourceDuration: candidate.srcDuration,
});
return frameTime === null ? [] : [{ ...candidate, relTime: frameTime }];
return frameTime === null ? [] : [{ ...candidate, start, relTime: frameTime }];
});

const updates: Array<{ videoId: string; dataUri: string }> = [];
Expand Down
127 changes: 127 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,130 @@ describe("initSandboxRuntimeModular", () => {
expect(video.currentTime).toBe(5);
});

it("keeps a scene-local video visible inside a later template-mounted host", () => {
Comment thread
miguel-heygen marked this conversation as resolved.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "6");
root.setAttribute("data-width", "360");
root.setAttribute("data-height", "640");
document.body.appendChild(root);

const firstHost = document.createElement("div");
firstHost.setAttribute("data-composition-id", "first");
firstHost.setAttribute("data-composition-file", "compositions/first.html");
firstHost.setAttribute("data-start", "0");
firstHost.setAttribute("data-duration", "3");
root.appendChild(firstHost);

const firstVideo = document.createElement("video");
firstVideo.setAttribute("data-start", "0");
firstVideo.setAttribute("data-duration", "3");
firstHost.appendChild(firstVideo);

const secondHost = document.createElement("div");
secondHost.setAttribute("data-composition-id", "second");
secondHost.setAttribute("data-composition-file", "compositions/second.html");
secondHost.setAttribute("data-start", "3");
secondHost.setAttribute("data-duration", "3");
root.appendChild(secondHost);

const secondVideo = document.createElement("video");
secondVideo.setAttribute("data-start", "0");
secondVideo.setAttribute("data-duration", "3");
secondHost.appendChild(secondVideo);

window.__timelines = {
main: createMockTimeline(6),
first: createMockTimeline(3),
second: createMockTimeline(3),
};

initSandboxRuntimeModular();
window.__player?.renderSeek(4);

expect(firstHost.style.visibility).toBe("hidden");
expect(firstVideo.style.visibility).toBe("hidden");
expect(secondHost.style.visibility).toBe("visible");
expect(secondVideo.style.visibility).toBe("visible");
});

it("resolves media starts through arbitrarily nested composition hosts", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const outerHost = document.createElement("div");
outerHost.setAttribute("data-composition-id", "outer");
outerHost.setAttribute("data-composition-file", "outer.html");
outerHost.setAttribute("data-start", "2");
outerHost.setAttribute("data-duration", "6");
root.appendChild(outerHost);

const innerHost = document.createElement("div");
innerHost.setAttribute("data-composition-id", "inner");
innerHost.setAttribute("data-composition-file", "inner.html");
innerHost.setAttribute("data-start", "3");
innerHost.setAttribute("data-duration", "3");
outerHost.appendChild(innerHost);

const video = document.createElement("video");
video.setAttribute("data-start", "1");
video.setAttribute("data-duration", "1");
innerHost.appendChild(video);

window.__timelines = {
main: createMockTimeline(10),
outer: createMockTimeline(6),
inner: createMockTimeline(3),
};

initSandboxRuntimeModular();

expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(6);
window.__player?.renderSeek(5.5);
expect(video.style.visibility).toBe("hidden");
window.__player?.renderSeek(6.5);
expect(video.style.visibility).toBe("visible");
});

it("uses the canonical resolver for reference starts, auto-start media, and inline hosts", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const intro = document.createElement("section");
intro.id = "intro";
intro.setAttribute("data-start", "0");
intro.setAttribute("data-duration", "2");
root.appendChild(intro);

const inlineHost = document.createElement("div");
inlineHost.setAttribute("data-composition-id", "inline");
inlineHost.setAttribute("data-start", "intro + 1");
inlineHost.setAttribute("data-duration", "2");
root.appendChild(inlineHost);

const video = document.createElement("video");
video.setAttribute("data-hf-auto-start", "true");
video.setAttribute("data-duration", "2");
inlineHost.appendChild(video);

window.__timelines = {
main: createMockTimeline(10),
inline: createMockTimeline(2),
};

initSandboxRuntimeModular();

expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(3);
});

it("updates visibility for timed elements inside nested compositions", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down Expand Up @@ -1431,6 +1555,7 @@ describe("initSandboxRuntimeModular", () => {

const host = document.createElement("div");
host.setAttribute("data-composition-id", "scene-pip");
host.setAttribute("data-composition-file", "compositions/pip.html");
host.setAttribute("data-start", "45.40");
host.setAttribute("data-duration", "7.06");
root.appendChild(host);
Expand Down Expand Up @@ -1460,6 +1585,8 @@ describe("initSandboxRuntimeModular", () => {

initSandboxRuntimeModular();

expect(window.__hfResolveMediaStartSeconds?.(pipVideo)).toBeCloseTo(45.4);

const player = (
window as Window & {
__player?: { seek: (timeSeconds: number) => void };
Expand Down
Loading
Loading