Skip to content

Commit 11a24b8

Browse files
committed
fix: offset nested template video timing
1 parent 4f344c5 commit 11a24b8

4 files changed

Lines changed: 118 additions & 16 deletions

File tree

packages/cli/src/commands/snapshot.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatSnapshotTimestamp,
66
parseZoomScale,
77
requireSnapshotFfmpeg,
8+
resolveSnapshotVideoClipStart,
89
resolveSnapshotVideoFrameTime,
910
tailFrameTime,
1011
} from "./snapshot.js";
@@ -154,6 +155,26 @@ describe("resolveSnapshotVideoFrameTime", () => {
154155
});
155156
});
156157

158+
describe("resolveSnapshotVideoClipStart", () => {
159+
it("offsets a scene-local video start by its later template host", () => {
160+
expect(
161+
resolveSnapshotVideoClipStart({
162+
authoredStart: 0,
163+
templateHostStart: 3,
164+
}),
165+
).toBe(3);
166+
});
167+
168+
it("keeps top-level video starts unchanged", () => {
169+
expect(
170+
resolveSnapshotVideoClipStart({
171+
authoredStart: 3,
172+
templateHostStart: null,
173+
}),
174+
).toBe(3);
175+
});
176+
});
177+
157178
describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
158179
it("default frames: last point is the readable tail, never exact duration", () => {
159180
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 });

packages/cli/src/commands/snapshot.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ export function resolveSnapshotVideoFrameTime(input: {
9393
return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30));
9494
}
9595

96+
/** Convert a video's scene-local authored start into root timeline time when
97+
* it lives inside a template-mounted composition host. Top-level videos have
98+
* no template host and therefore retain their authored start unchanged. */
99+
export function resolveSnapshotVideoClipStart(input: {
100+
authoredStart: number;
101+
templateHostStart: number | null;
102+
}): number {
103+
return input.authoredStart + (input.templateHostStart ?? 0);
104+
}
105+
96106
export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
97107
if (ffmpegPath) return ffmpegPath;
98108
throw new Error(
@@ -399,10 +409,16 @@ async function captureSnapshots(
399409
if (cameraExpr) await page.evaluate(cameraExpr);
400410

401411
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
402-
const candidates = await page.evaluate((t: number) => {
412+
const candidates = await page.evaluate(() => {
403413
return Array.from(document.querySelectorAll("video[data-start]")).map((el) => {
404414
const v = el as HTMLVideoElement;
405-
const start = parseFloat(v.dataset.start ?? "0") || 0;
415+
const authoredStart = parseFloat(v.dataset.start ?? "0") || 0;
416+
const templateHost = v.closest<HTMLElement>(
417+
"[data-composition-src], [data-composition-file]",
418+
);
419+
const templateHostStart = templateHost
420+
? parseFloat(templateHost.dataset.start ?? "0") || 0
421+
: null;
406422
const rawRate = v.defaultPlaybackRate;
407423
const playbackRate =
408424
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
@@ -416,30 +432,40 @@ async function captureSnapshots(
416432
: srcDur > 0
417433
? Math.max(0, (srcDur - mediaStart) / playbackRate)
418434
: Number.POSITIVE_INFINITY;
419-
let relTime = (t - start) * playbackRate + mediaStart;
420-
if (v.loop && srcDur > mediaStart && relTime >= srcDur) {
421-
relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart));
422-
}
423435
return {
424436
id: v.id,
425437
src: v.currentSrc || v.src,
426-
start,
438+
authoredStart,
439+
templateHostStart,
427440
duration,
428441
srcDuration: srcDur,
429-
relTime,
442+
playbackRate,
443+
mediaStart,
444+
loop: v.loop,
430445
};
431446
});
432-
}, time);
447+
});
433448
const active = candidates.flatMap((candidate) => {
449+
const start = resolveSnapshotVideoClipStart(candidate);
450+
let relTime = (time - start) * candidate.playbackRate + candidate.mediaStart;
451+
if (
452+
candidate.loop &&
453+
candidate.srcDuration > candidate.mediaStart &&
454+
relTime >= candidate.srcDuration
455+
) {
456+
relTime =
457+
candidate.mediaStart +
458+
((relTime - candidate.mediaStart) % (candidate.srcDuration - candidate.mediaStart));
459+
}
434460
if (!candidate.id || !candidate.src) return [];
435461
const frameTime = resolveSnapshotVideoFrameTime({
436462
globalTime: time,
437-
clipStart: candidate.start,
463+
clipStart: start,
438464
clipDuration: candidate.duration,
439-
relativeTime: candidate.relTime,
465+
relativeTime: relTime,
440466
sourceDuration: candidate.srcDuration,
441467
});
442-
return frameTime === null ? [] : [{ ...candidate, relTime: frameTime }];
468+
return frameTime === null ? [] : [{ ...candidate, start, relTime: frameTime }];
443469
});
444470

445471
const updates: Array<{ videoId: string; dataUri: string }> = [];

packages/core/src/runtime/init.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,55 @@ describe("initSandboxRuntimeModular", () => {
915915
expect(video.currentTime).toBe(5);
916916
});
917917

918+
it("keeps a scene-local video visible inside a later template-mounted host", () => {
919+
const root = document.createElement("div");
920+
root.setAttribute("data-composition-id", "main");
921+
root.setAttribute("data-root", "true");
922+
root.setAttribute("data-start", "0");
923+
root.setAttribute("data-duration", "6");
924+
root.setAttribute("data-width", "360");
925+
root.setAttribute("data-height", "640");
926+
document.body.appendChild(root);
927+
928+
const firstHost = document.createElement("div");
929+
firstHost.setAttribute("data-composition-id", "first");
930+
firstHost.setAttribute("data-composition-file", "compositions/first.html");
931+
firstHost.setAttribute("data-start", "0");
932+
firstHost.setAttribute("data-duration", "3");
933+
root.appendChild(firstHost);
934+
935+
const firstVideo = document.createElement("video");
936+
firstVideo.setAttribute("data-start", "0");
937+
firstVideo.setAttribute("data-duration", "3");
938+
firstHost.appendChild(firstVideo);
939+
940+
const secondHost = document.createElement("div");
941+
secondHost.setAttribute("data-composition-id", "second");
942+
secondHost.setAttribute("data-composition-file", "compositions/second.html");
943+
secondHost.setAttribute("data-start", "3");
944+
secondHost.setAttribute("data-duration", "3");
945+
root.appendChild(secondHost);
946+
947+
const secondVideo = document.createElement("video");
948+
secondVideo.setAttribute("data-start", "0");
949+
secondVideo.setAttribute("data-duration", "3");
950+
secondHost.appendChild(secondVideo);
951+
952+
window.__timelines = {
953+
main: createMockTimeline(6),
954+
first: createMockTimeline(3),
955+
second: createMockTimeline(3),
956+
};
957+
958+
initSandboxRuntimeModular();
959+
window.__player?.renderSeek(4);
960+
961+
expect(firstHost.style.visibility).toBe("hidden");
962+
expect(firstVideo.style.visibility).toBe("hidden");
963+
expect(secondHost.style.visibility).toBe("visible");
964+
expect(secondVideo.style.visibility).toBe("visible");
965+
});
966+
918967
it("updates visibility for timed elements inside nested compositions", () => {
919968
const root = document.createElement("div");
920969
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -628,10 +628,16 @@ export function initSandboxRuntimeModular(): void {
628628
return false;
629629
}
630630

631-
const start =
632-
tag === "video" || tag === "audio"
633-
? resolveMediaStartSeconds(rawNode, 0)
634-
: resolveStartForElement(rawNode, 0);
631+
const isMedia = tag === "video" || tag === "audio";
632+
const mediaCompositionHost = isMedia
633+
? rawNode.closest("[data-composition-src], [data-composition-file]")
634+
: null;
635+
const mediaCompositionStart = mediaCompositionHost
636+
? resolveStartForElement(mediaCompositionHost, 0)
637+
: 0;
638+
const start = isMedia
639+
? resolveMediaStartSeconds(rawNode, mediaCompositionStart)
640+
: resolveStartForElement(rawNode, 0);
635641
let duration = resolveDurationForElement(rawNode);
636642
const compId = rawNode.getAttribute("data-composition-id");
637643
if (compId) {

0 commit comments

Comments
 (0)