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:
Miguel Ángel
2026-04-23 04:05:11 +02:00
committed by GitHub
parent 80e7cd2844
commit 95bf333895
10 changed files with 487 additions and 70 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",
@@ -106,4 +106,55 @@ describe("media rules", () => {
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
it("reports imperative play() control on managed media ids", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const video = document.getElementById("demo-video");
video.play();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("reports imperative currentTime writes on query-selected managed media", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const demo = document.querySelector("#demo-video");
demo.currentTime = 1.5;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag play() on non-media elements", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="panel"></div>
</div>
<script>
const panel = document.getElementById("panel");
panel.play?.();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
});
+136
View File
@@ -1,6 +1,139 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorTargetsManagedMedia(selector: string, mediaIds: Set<string>): boolean {
const normalized = selector.trim();
if (!normalized) return false;
if (/\b(video|audio)\b/i.test(normalized)) return true;
for (const mediaId of mediaIds) {
if (
normalized.includes(`#${mediaId}`) ||
normalized.includes(`[id="${mediaId}"]`) ||
normalized.includes(`[id='${mediaId}']`)
) {
return true;
}
}
return false;
}
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const managedMediaIds = new Set(
ctx.tags
.filter((tag) => tag.name === "video" || tag.name === "audio")
.map((tag) => readAttr(tag.raw, "id"))
.filter((id): id is string => Boolean(id)),
);
if (managedMediaIds.size === 0 || ctx.scripts.length === 0) return findings;
for (const script of ctx.scripts) {
const mediaVars = new Map<string, string | undefined>();
const assignmentPatterns = [
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of assignmentPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const variableName = match[1];
const target = match[2];
if (!variableName || !target) continue;
if (managedMediaIds.has(target) || selectorTargetsManagedMedia(target, managedMediaIds)) {
mediaVars.set(variableName, managedMediaIds.has(target) ? target : undefined);
}
}
}
const directIdPatterns = [
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
},
];
for (const { pattern, kind } of directIdPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const target = match[1];
if (!target) continue;
const elementId = managedMediaIds.has(target)
? target
: selectorTargetsManagedMedia(target, managedMediaIds)
? undefined
: null;
if (elementId === null) continue;
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId: elementId || undefined,
fixHint:
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
for (const [variableName, elementId] of mediaVars) {
const escapedVar = escapeRegExp(variableName);
const variablePatterns = [
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
];
for (const { pattern, kind } of variablePatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId,
fixHint:
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
}
}
return findings;
}
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => {
@@ -243,4 +376,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
}
return findings;
},
// imperative_media_control
findImperativeMediaControlFindings,
];
+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;
@@ -10,7 +10,6 @@ import { formatTime } from "../lib/time";
import { TimelineClip } from "./TimelineClip";
import { EditPopover } from "./EditModal";
import {
buildTimelineElementAgentPrompt,
getTimelineEditCapabilities,
resolveTimelineAutoScroll,
resolveTimelineMove,
@@ -246,7 +245,6 @@ export const Timeline = memo(function Timeline({
onResizeElementRef.current = onResizeElement;
const suppressClickRef = useRef(false);
const [showPopover, setShowPopover] = useState(false);
const [copiedAgentElementKey, setCopiedAgentElementKey] = useState<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
@@ -898,46 +896,6 @@ export const Timeline = memo(function Timeline({
}
: null;
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => {
const capabilities = getTimelineEditCapabilities(element);
const elementKey = element.key ?? element.id;
const needsAgentFallback =
!capabilities.canMove && !capabilities.canTrimStart && !capabilities.canTrimEnd;
const agentButton = needsAgentFallback ? (
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.stopPropagation();
const text = buildTimelineElementAgentPrompt(element);
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopiedAgentElementKey(elementKey);
window.setTimeout(() => {
setCopiedAgentElementKey((current) => (current === elementKey ? null : current));
}, 900);
}}
className="absolute bottom-2 right-2 z-[5] rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none transition-colors"
style={{
color: copiedAgentElementKey === elementKey ? "#86efac" : clipStyle.label,
background:
copiedAgentElementKey === elementKey ? "rgba(34,197,94,0.16)" : `${clipStyle.accent}1e`,
boxShadow:
copiedAgentElementKey === elementKey
? "inset 0 0 0 1px rgba(34,197,94,0.28)"
: `inset 0 0 0 1px ${clipStyle.accent}33`,
}}
>
{copiedAgentElementKey === elementKey ? "Copied!" : "Copy to Agent"}
</button>
) : null;
return (
<>
{renderClipOverlay?.(element)}
@@ -962,12 +920,6 @@ export const Timeline = memo(function Timeline({
{element.tag}
</span>
</div>
<span
className="text-[14px] font-semibold truncate leading-none tracking-[-0.02em]"
style={{ color: theme.textPrimary }}
>
{element.id || element.tag}
</span>
<div className="flex items-center">
<span
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
@@ -983,7 +935,6 @@ export const Timeline = memo(function Timeline({
</div>
)}
</div>
{agentButton}
</>
);
};
+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) {