From cb910cd28780626b27aa08fca1ab13efd414aea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 21:30:43 -0400 Subject: [PATCH 1/2] fix: stabilize apple master timeline and playback --- packages/cli/src/server/studioServer.ts | 10 +- packages/core/src/runtime/init.ts | 16 ++- .../core/src/runtime/startResolver.test.ts | 126 ++++++++++++++++++ packages/core/src/runtime/startResolver.ts | 46 ++++++- packages/core/src/runtime/timeline.test.ts | 34 +++++ packages/core/src/runtime/timeline.ts | 78 +++++++++-- packages/studio/vite.config.ts | 11 +- 7 files changed, 300 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index bb9a05d0..758215fb 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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", diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fd5a9c43..7b1eed6e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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"); diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index e35f3e08..272fb78b 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -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"); diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index bcbe1d94..214e57c3 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -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; + 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(); const durationCache = new WeakMap(); const visiting = new Set(); @@ -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) { diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 2381568a..803a684d 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -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"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 1262d58c..d56d55ab 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -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 { + 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; diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 6da4dcf0..ece14897 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -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) { From aaae13de1b2b8af57de5086f2c93799d849e83b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 21:44:21 -0400 Subject: [PATCH 2/2] fix: remove timeline agent fallback and restore media lint rule --- packages/core/src/lint/rules/media.test.ts | 51 +++++++ packages/core/src/lint/rules/media.ts | 136 ++++++++++++++++++ .../studio/src/player/components/Timeline.tsx | 49 ------- 3 files changed, 187 insertions(+), 49 deletions(-) diff --git a/packages/core/src/lint/rules/media.test.ts b/packages/core/src/lint/rules/media.test.ts index 2d52cc96..684d5867 100644 --- a/packages/core/src/lint/rules/media.test.ts +++ b/packages/core/src/lint/rules/media.test.ts @@ -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 = ` + +
+ +
+ +`; + 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 = ` + +
+ +
+ +`; + 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 = ` + +
+
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "imperative_media_control"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/rules/media.ts b/packages/core/src/lint/rules/media.ts index 5aeb7bf3..33066b75 100644 --- a/packages/core/src/lint/rules/media.ts +++ b/packages/core/src/lint/rules/media.ts @@ -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): 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(); + 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