fix: stabilize apple master timeline and playback

This commit is contained in:
Miguel Ángel
2026-04-22 21:30:43 -04:00
parent 80e7cd2844
commit cb910cd287
7 changed files with 300 additions and 21 deletions
+7 -3
View File
@@ -282,9 +282,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
const serve = async () => {
const runtimeSource = existsSync(runtimePath)
? readFileSync(runtimePath, "utf-8")
: await loadRuntimeSourceFallback();
// Prefer the runtime generated from the current core source over a
// potentially stale copied artifact. This keeps local studio/preview
// sessions aligned with source edits without requiring a manual
// rebuild of the CLI runtime bundle first.
const runtimeSource =
(await loadRuntimeSourceFallback()) ??
(existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
"Content-Type": "text/javascript",
+15 -1
View File
@@ -16,6 +16,9 @@ import { applyCaptionOverrides } from "./captionOverrides";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
const runtimeWindow = window as Window & {
@@ -237,7 +240,18 @@ export function initSandboxRuntimeModular(): void {
// Preserve explicit root duration so timeline payload can distinguish
// authored finite duration from loop-inflated timeline duration.
if (rootEl && node === rootEl) continue;
// Non-root compositions derive duration from timeline.
// Preserve authored timing for reference-start resolution in Studio and
// timeline payload generation. The runtime still strips the public attrs
// so visibility/parity continues to derive from the live sub-timeline.
const authoredDuration = node.getAttribute("data-duration");
const authoredEnd = node.getAttribute("data-end");
if (authoredDuration != null && !node.hasAttribute(AUTHORED_DURATION_ATTR)) {
node.setAttribute(AUTHORED_DURATION_ATTR, authoredDuration);
}
if (authoredEnd != null && !node.hasAttribute(AUTHORED_END_ATTR)) {
node.setAttribute(AUTHORED_END_ATTR, authoredEnd);
}
// Non-root compositions derive visible duration from timeline.
// Strip both data-duration AND data-end so the visibility system
// falls back to the GSAP timeline duration (parity with preview).
node.removeAttribute("data-duration");
@@ -135,6 +135,112 @@ describe("createRuntimeStartTimeResolver", () => {
expect(resolver.resolveStartForElement(after)).toBe(5);
});
it("uses preserved authored duration when live composition duration was sanitized", () => {
const slide1 = document.createElement("div");
slide1.id = "slide-1";
slide1.setAttribute("data-composition-id", "slide-1");
slide1.setAttribute("data-start", "0");
slide1.setAttribute("data-hf-authored-duration", "14");
document.body.appendChild(slide1);
const slide2 = document.createElement("div");
slide2.id = "slide-2";
slide2.setAttribute("data-start", "slide-1");
slide2.setAttribute("data-hf-authored-duration", "12");
document.body.appendChild(slide2);
const slide3 = document.createElement("div");
slide3.setAttribute("data-start", "slide-2");
document.body.appendChild(slide3);
const resolver = createRuntimeStartTimeResolver({ includeAuthoredTimingAttrs: true });
expect(resolver.resolveStartForElement(slide2)).toBe(14);
expect(resolver.resolveStartForElement(slide3)).toBe(26);
});
it("adds composition host offset for nested absolute starts", () => {
const host = document.createElement("div");
host.id = "slide-5";
host.setAttribute("data-composition-id", "slide-video-agent");
host.setAttribute("data-start", "54");
host.setAttribute("data-duration", "45");
document.body.appendChild(host);
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "slide-video-agent");
host.appendChild(innerRoot);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
innerRoot.appendChild(video);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveStartForElement(video)).toBe(54);
});
it("keeps nested references in the host composition timeline", () => {
const host = document.createElement("div");
host.id = "slide-5";
host.setAttribute("data-composition-id", "slide-video-agent");
host.setAttribute("data-start", "54");
host.setAttribute("data-duration", "45");
document.body.appendChild(host);
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "slide-video-agent");
host.appendChild(innerRoot);
const firstClip = document.createElement("div");
firstClip.id = "bullet-reveal";
firstClip.setAttribute("data-start", "1");
firstClip.setAttribute("data-duration", "2");
innerRoot.appendChild(firstClip);
const secondClip = document.createElement("div");
secondClip.setAttribute("data-start", "bullet-reveal + 1");
innerRoot.appendChild(secondClip);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveStartForElement(firstClip)).toBe(55);
expect(resolver.resolveStartForElement(secondClip)).toBe(58);
});
it("adds the nearest composition root start for nested absolute media in inlined compositions", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
document.body.appendChild(root);
const slide1 = document.createElement("div");
slide1.id = "slide-1";
slide1.setAttribute("data-composition-id", "slide-core-conviction");
slide1.setAttribute("data-start", "0");
slide1.setAttribute("data-hf-authored-duration", "14");
root.appendChild(slide1);
const slide2 = document.createElement("div");
slide2.id = "slide-2";
slide2.setAttribute("data-composition-id", "slide-avatar-v");
slide2.setAttribute("data-start", "slide-1");
slide2.setAttribute("data-hf-authored-duration", "12");
root.appendChild(slide2);
const slide3 = document.createElement("div");
slide3.id = "slide-3";
slide3.setAttribute("data-composition-id", "slide-translation");
slide3.setAttribute("data-start", "slide-2");
slide3.setAttribute("data-hf-authored-duration", "16");
root.appendChild(slide3);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
slide3.appendChild(video);
const resolver = createRuntimeStartTimeResolver({ includeAuthoredTimingAttrs: true });
expect(resolver.resolveStartForElement(slide2)).toBe(14);
expect(resolver.resolveStartForElement(slide3)).toBe(26);
expect(resolver.resolveStartForElement(video)).toBe(26);
});
it("returns fallback when reference target not found", () => {
const el = document.createElement("div");
el.setAttribute("data-start", "nonexistent");
@@ -228,6 +334,26 @@ describe("createRuntimeStartTimeResolver", () => {
expect(resolver.resolveDurationForElement(el)).toBe(5);
});
it("resolves preserved authored duration when runtime stripped the public attr", () => {
const el = document.createElement("div");
el.setAttribute("data-composition-id", "comp-1");
el.setAttribute("data-hf-authored-duration", "9");
document.body.appendChild(el);
const resolver = createRuntimeStartTimeResolver({ includeAuthoredTimingAttrs: true });
expect(resolver.resolveDurationForElement(el)).toBe(9);
});
it("ignores preserved authored duration by default", () => {
const el = document.createElement("div");
el.setAttribute("data-composition-id", "comp-1");
el.setAttribute("data-hf-authored-duration", "9");
document.body.appendChild(el);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveDurationForElement(el)).toBeNull();
});
it("caches duration results", () => {
const el = document.createElement("div");
el.setAttribute("data-duration", "4");
+42 -4
View File
@@ -1,5 +1,8 @@
import type { RuntimeTimelineLike } from "./types";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
type ReferenceExpression =
| {
kind: "absolute";
@@ -12,10 +15,27 @@ type ReferenceExpression =
};
function parseNumeric(value: string | null | undefined): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-duration"));
}
function parseEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-end"));
}
function parseAuthoredDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_DURATION_ATTR));
}
function parseAuthoredEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_END_ATTR));
}
function parseStartExpression(raw: string | null | undefined): ReferenceExpression | null {
const normalized = (raw ?? "").trim();
if (!normalized) return null;
@@ -37,11 +57,13 @@ function parseStartExpression(raw: string | null | undefined): ReferenceExpressi
export function createRuntimeStartTimeResolver(params: {
timelineRegistry?: Record<string, RuntimeTimelineLike | undefined>;
includeAuthoredTimingAttrs?: boolean;
}): {
resolveStartForElement: (element: Element, fallback?: number) => number;
resolveDurationForElement: (element: Element) => number | null;
} {
const timelineRegistry = params.timelineRegistry ?? {};
const includeAuthoredTimingAttrs = params.includeAuthoredTimingAttrs ?? false;
const startCache = new WeakMap<Element, number | null>();
const durationCache = new WeakMap<Element, number | null>();
const visiting = new Set<Element>();
@@ -59,12 +81,16 @@ export function createRuntimeStartTimeResolver(params: {
const cached = durationCache.get(element);
if (cached !== undefined) return cached;
let resolved: number | null = null;
const durationAttr = parseNumeric(element.getAttribute("data-duration"));
const durationAttr =
parseDurationAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredDurationAttr(element) : null);
if (durationAttr != null && durationAttr > 0) {
resolved = durationAttr;
}
if (resolved == null || resolved <= 0) {
const endAttr = parseNumeric(element.getAttribute("data-end"));
const endAttr =
parseEndAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredEndAttr(element) : null);
if (endAttr != null) {
const start = resolveStartForElementInternal(element, 0);
const delta = endAttr - start;
@@ -106,6 +132,17 @@ export function createRuntimeStartTimeResolver(params: {
return null;
};
const resolveHostOffsetForElement = (element: Element, fallback: number): number => {
if (element.hasAttribute("data-composition-id")) {
const parentComposition = element.parentElement?.closest("[data-composition-id]");
if (!parentComposition) return 0;
return resolveStartForElementInternal(parentComposition, fallback);
}
const compositionRoot = element.closest("[data-composition-id]");
if (!compositionRoot) return 0;
return resolveStartForElementInternal(compositionRoot, fallback);
};
const resolveStartForElementInternal = (element: Element, fallback: number): number => {
const cached = startCache.get(element);
if (cached !== undefined) {
@@ -141,8 +178,9 @@ export function createRuntimeStartTimeResolver(params: {
}
if (expression.kind === "absolute") {
const absolute = Math.max(0, expression.value);
startCache.set(element, absolute);
return absolute;
const resolved = Math.max(0, resolveHostOffsetForElement(element, fallback) + absolute);
startCache.set(element, resolved);
return resolved;
}
const target = findReferenceTarget(expression.refId);
if (!target) {
@@ -311,6 +311,40 @@ describe("collectRuntimeTimelinePayload", () => {
expect(sceneClip?.duration).toBe(8);
});
it("keeps composition clips sequential when authored durations were preserved privately", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
document.body.appendChild(root);
const slide1 = document.createElement("div");
slide1.id = "slide-1";
slide1.setAttribute("data-composition-id", "slide-1");
slide1.setAttribute("data-start", "0");
slide1.setAttribute("data-hf-authored-duration", "14");
root.appendChild(slide1);
const slide2 = document.createElement("div");
slide2.id = "slide-2";
slide2.setAttribute("data-composition-id", "slide-2");
slide2.setAttribute("data-start", "slide-1");
slide2.setAttribute("data-hf-authored-duration", "12");
root.appendChild(slide2);
const slide3 = document.createElement("div");
slide3.id = "slide-3";
slide3.setAttribute("data-composition-id", "slide-3");
slide3.setAttribute("data-start", "slide-2");
slide3.setAttribute("data-hf-authored-duration", "16");
root.appendChild(slide3);
const result = collectRuntimeTimelinePayload(defaultParams);
const starts = Object.fromEntries(result.clips.map((clip) => [clip.id, clip.start]));
expect(starts["slide-1"]).toBe(0);
expect(starts["slide-2"]).toBe(14);
expect(starts["slide-3"]).toBe(26);
expect(result.durationInFrames).toBe(42 * 30);
});
it("discovers GSAP-animated scene elements via timeline introspection", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+70 -8
View File
@@ -6,11 +6,34 @@ import type {
} from "./types";
import { createRuntimeStartTimeResolver } from "./startResolver";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
function parseNum(value: string | null | undefined): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseElementDurationAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-duration")) ??
parseNum(element.getAttribute(AUTHORED_DURATION_ATTR))
);
}
function parseElementEndAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-end")) ?? parseNum(element.getAttribute(AUTHORED_END_ATTR))
);
}
function maxDefinedNumber(...values: Array<number | null>): number | null {
const finite = values.filter((value): value is number => Number.isFinite(value ?? null));
if (finite.length === 0) return null;
return Math.max(...finite);
}
/**
* When multiple content kinds share the same track number, split them
* onto separate tracks so the timeline UI shows distinct rows.
@@ -97,6 +120,7 @@ export function collectRuntimeTimelinePayload(params: {
const timelineRegistry = runtimeWindow.__timelines ?? {};
const startResolver = createRuntimeStartTimeResolver({
timelineRegistry,
includeAuthoredTimingAttrs: true,
});
const resolveTimelineDurationSeconds = (compositionId: string | null): number | null => {
if (!compositionId) return null;
@@ -189,13 +213,31 @@ export function collectRuntimeTimelinePayload(params: {
};
const root = document.querySelector("[data-composition-id]") as Element | null;
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]"));
const rootCompositionId = root?.getAttribute("data-composition-id") ?? null;
const rootCompositionStart = root ? startResolver.resolveStartForElement(root, 0) : 0;
const mediaWindowEnd = resolveMediaWindowEndSeconds();
const mediaWindowDuration =
mediaWindowEnd != null ? Math.max(0, mediaWindowEnd - Math.max(0, rootCompositionStart)) : null;
const rootDurationFromTimeline = resolveTimelineDurationSeconds(rootCompositionId);
const rootDurationFromAttr = parseNum(root?.getAttribute("data-duration"));
const rootDurationFromAttr = parseElementDurationAttr(root ?? document.body);
const compositionWindowEnd = maxDefinedNumber(
...compositionNodes
.filter((node) => node !== root)
.map((node) => {
const start = startResolver.resolveStartForElement(node, 0);
const duration =
startResolver.resolveDurationForElement(node) ??
resolveTimelineDurationSeconds(node.getAttribute("data-composition-id")) ??
null;
if (!Number.isFinite(start) || duration == null || duration <= 0) return null;
return Math.max(0, start) + duration;
}),
);
const compositionWindowDuration =
compositionWindowEnd != null
? Math.max(0, compositionWindowEnd - Math.max(0, rootCompositionStart))
: null;
const timelineDurationCandidate =
typeof rootDurationFromTimeline === "number" &&
Number.isFinite(rootDurationFromTimeline) &&
@@ -214,17 +256,31 @@ export function collectRuntimeTimelinePayload(params: {
mediaWindowDuration > 0
? mediaWindowDuration
: null;
const compositionWindowDurationCandidate =
typeof compositionWindowDuration === "number" &&
Number.isFinite(compositionWindowDuration) &&
compositionWindowDuration > 0
? compositionWindowDuration
: null;
const finiteWindowFloor = maxDefinedNumber(
mediaWindowDurationCandidate,
compositionWindowDurationCandidate,
);
const timelineLooksLoopInflated =
timelineDurationCandidate != null &&
mediaWindowDurationCandidate != null &&
timelineDurationCandidate > mediaWindowDurationCandidate + 1;
finiteWindowFloor != null &&
timelineDurationCandidate > finiteWindowFloor + 1;
// Prefer explicit authored root duration first.
// If absent, guard against loop-inflated GSAP durations by trusting finite media window.
const preferredRootDuration =
attrDurationCandidate ??
(timelineLooksLoopInflated
? mediaWindowDurationCandidate
: (timelineDurationCandidate ?? mediaWindowDurationCandidate));
? finiteWindowFloor
: maxDefinedNumber(
timelineDurationCandidate,
mediaWindowDurationCandidate,
compositionWindowDurationCandidate,
));
const rootCompositionDuration =
preferredRootDuration != null
? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds)
@@ -242,7 +298,6 @@ export function collectRuntimeTimelinePayload(params: {
if (!Number.isFinite(start) || start >= timelineWindowEnd) return 0;
return Math.max(0, Math.min(duration, timelineWindowEnd - start));
};
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]"));
const clips: RuntimeTimelineClip[] = [];
const scenes: RuntimeTimelineScene[] = [];
// Only collect elements that are explicitly part of the timeline:
@@ -270,7 +325,7 @@ export function collectRuntimeTimelinePayload(params: {
compositionContext.inheritedStart ?? 0,
);
const nodeCompositionId = node.getAttribute("data-composition-id");
let duration = parseNum(node.getAttribute("data-duration"));
let duration = parseElementDurationAttr(node);
if (
(duration == null || duration <= 0) &&
nodeCompositionId &&
@@ -523,7 +578,14 @@ export function collectRuntimeTimelinePayload(params: {
const compositionId = compositionNode.getAttribute("data-composition-id");
if (!compositionId || !isSceneLikeCompositionId(compositionId)) continue;
const start = startResolver.resolveStartForElement(compositionNode, 0);
const durationFromAttr = parseNum(compositionNode.getAttribute("data-duration"));
let durationFromAttr = parseElementDurationAttr(compositionNode);
if (
(durationFromAttr == null || durationFromAttr <= 0) &&
parseElementEndAttr(compositionNode) != null
) {
const end = parseElementEndAttr(compositionNode)!;
durationFromAttr = Math.max(0, end - start);
}
const durationFromTimeline = resolveTimelineDurationSeconds(compositionId);
const duration =
durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
+6 -5
View File
@@ -370,16 +370,17 @@ function devProjectApi(): Plugin {
return _api;
};
// Serve the local runtime IIFE so compositions don't depend on CDN
// In dev, prefer the runtime built from source over a checked-in dist
// artifact. Otherwise Studio can silently serve a stale runtime bundle
// after source edits in packages/core, which makes browser behavior lag
// behind the code under test until someone manually rebuilds core/dist.
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
server.middlewares.use((req, res, next) => {
if (req.url !== "/api/runtime.js") return next();
const serve = async () => {
let runtimeSource: string | null = null;
if (existsSync(runtimePath)) {
let runtimeSource = await loadRuntimeSourceForDev(server);
if (!runtimeSource && existsSync(runtimePath)) {
runtimeSource = readFileSync(runtimePath, "utf-8");
} else {
runtimeSource = await loadRuntimeSourceForDev(server);
}
if (!runtimeSource) {