mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix: stabilize apple master timeline and playback (#419)
## Summary - preserve authored non-root composition timing before runtime sanitization so Studio can build the correct master timeline for chained subcompositions - prefer the fresh runtime source in Studio dev so local preview does not serve a stale `/api/runtime.js` - restrict preserved authored timing inference to the Studio timeline payload instead of the general runtime resolver ## What this fixes This PR fixes the Apple presentation class of failures where the root `index.html` / `Master` view looked correct at first and then collapsed into an incorrect short timeline. Before this change: - the master transport could report a short duration like `0:12` instead of the real deck length (`2:21` in the Apple project) - composition clips bunched near the start instead of laying out sequentially across the deck - seeking into later parts of the deck would land in the wrong place or show the wrong active composition - local Studio debugging could be misleading because dev sometimes served a stale runtime bundle After this change: - the master transport reflects the authored composition-chain duration - master clips resolve linearly across the whole deck - late seeks land on the correct slide window - Studio dev uses the current runtime implementation, so local preview matches the branch you are testing ## Root cause There were two related issues: 1. Studio/master timeline inference lost authored composition timing - missing timing attrs were treated like `0` instead of `null` - non-root composition `data-duration` / `data-end` were stripped before Studio timing resolution could use them - root duration inference trusted an incomplete live timeline window instead of the authored composition chain 2. Preserved authored timing leaked into the general runtime resolver - preserving authored timing was correct for Studio timeline payload generation - but using those preserved attrs for normal runtime playback/render resolution caused visual regressions in producer CI - the follow-up fix keeps authored timing available only for Studio payload collection while normal runtime playback continues to resolve from the real live timeline/media state ## Why the later regression fix was needed The initial runtime change fixed the Apple master timeline, but it also widened timing inference in the core runtime too far. That caused Dockerized producer regressions because rendered visibility started respecting preserved authored timing where it should have relied on the live resolved runtime state. The latest commit fixes that by splitting the behavior: - Studio timeline payload: authored timing allowed - general runtime resolver: authored timing ignored by default That preserves the Apple master timeline fix without changing producer render semantics. ## Verification ### Local checks - `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts` - `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts` - `bun run --filter @hyperframes/core typecheck` - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/cli typecheck` - `cd packages/core && bun run test src/runtime/startResolver.test.ts src/runtime/timeline.test.ts` - `bun test packages/cli/src/server/studioServer.test.ts --timeout 20000` ### Browser proof Tested in Studio with `agent-browser` against the Apple presentation project. - root/master transport now shows `0:00 / 2:21` - master clip manifest resolves sequentially (`slide-1 -> slide-2 -> slide-3 ...`) - seeking to `120s` lands on a late slide instead of a collapsed early timeline state - after refreshing onto the fresh runtime source, the visible later-slide media advanced correctly in local Studio playback ### CI-equivalent regression proof on devbox The previously failing producer regressions were rerun on devbox using the same Dockerized path GitHub Actions uses: - `docker build -f Dockerfile.test -t hyperframes-producer:test .` - `docker run ... hyperframes-producer:test style-1-prod style-5-prod style-9-prod style-12-prod --sequential` Those previously failing suites all passed after the runtime split fix: - `style-1-prod` - `style-5-prod` - `style-9-prod` - `style-12-prod` ## Notes - the Apple project volume tweak stayed local-only for testing and is not part of this PR - this PR fixes the master/root timeline bug and the runtime regression it introduced; it does not add general subtimeline authoring support
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user