Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/lint/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 136 additions & 0 deletions packages/core/src/lint/rules/media.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -243,4 +376,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
}
return findings;
},

// imperative_media_control
findImperativeMediaControlFindings,
];
16 changes: 15 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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");
Expand Down
126 changes: 126 additions & 0 deletions packages/core/src/runtime/startResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading