mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user