diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index b74860fb3..a97ad3669 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -165,7 +165,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, entryFile: "compositions/intro.html", @@ -181,7 +181,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); diff --git a/packages/core/src/runtime/adapters/animejs.ts b/packages/core/src/runtime/adapters/animejs.ts index 759f281a9..5e4986ad2 100644 --- a/packages/core/src/runtime/adapters/animejs.ts +++ b/packages/core/src/runtime/adapters/animejs.ts @@ -1,4 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; +import { swallow } from "../diagnostics"; /** * anime.js adapter for HyperFrames @@ -61,8 +62,9 @@ export function createAnimeJsAdapter(): RuntimeDeterministicAdapter { } } (window as AnimeWindow).__hfAnime = existing; - } catch { + } catch (err) { // ignore discovery failures + swallow("runtime.adapters.animejs.site1", err); } }, @@ -76,8 +78,9 @@ export function createAnimeJsAdapter(): RuntimeDeterministicAdapter { if (typeof instance.seek === "function") { instance.seek(timeMs); } - } catch { + } catch (err) { // ignore per-instance failures — keep going for other instances + swallow("runtime.adapters.animejs.site2", err); } } }, @@ -91,8 +94,9 @@ export function createAnimeJsAdapter(): RuntimeDeterministicAdapter { if (typeof instance.pause === "function") { instance.pause(); } - } catch { + } catch (err) { // ignore + swallow("runtime.adapters.animejs.site3", err); } } }, @@ -106,8 +110,9 @@ export function createAnimeJsAdapter(): RuntimeDeterministicAdapter { if (typeof instance.play === "function") { instance.play(); } - } catch { + } catch (err) { // ignore + swallow("runtime.adapters.animejs.site4", err); } } }, diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index 8c2ff6904..0649c1473 100644 --- a/packages/core/src/runtime/adapters/css.ts +++ b/packages/core/src/runtime/adapters/css.ts @@ -1,4 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; +import { swallow } from "../diagnostics"; export function createCssAdapter(params?: { resolveStartSeconds?: (element: Element) => number; @@ -22,13 +23,15 @@ export function createCssAdapter(params?: { for (const animation of animations) { try { animation.currentTime = timeMs; - } catch { + } catch (err) { // ignore animations that reject currentTime writes + swallow("runtime.adapters.css.site1", err); } try { animation.pause(); - } catch { + } catch (err) { // infinite unresolved animations can throw on pause before currentTime sticks + swallow("runtime.adapters.css.site2", err); } } }; @@ -37,8 +40,9 @@ export function createCssAdapter(params?: { for (const animation of animations) { try { animation.play(); - } catch { + } catch (err) { // ignore animation edge-cases + swallow("runtime.adapters.css.site3", err); } } }; @@ -47,8 +51,9 @@ export function createCssAdapter(params?: { for (const animation of animations) { try { animation.pause(); - } catch { + } catch (err) { // ignore animation edge-cases + swallow("runtime.adapters.css.site4", err); } } }; diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index 5b7f6aa4c..74da88a16 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -1,4 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; +import { swallow } from "../diagnostics"; export { isLottieAnimationLoaded } from "./lottieReadiness"; /** @@ -71,8 +72,9 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter { (window as LottieWindow).__hfLottie = existing; } } - } catch { + } catch (err) { // ignore discovery failures + swallow("runtime.adapters.lottie.site1", err); } }, @@ -107,8 +109,9 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter { anim.seek(percentage); } } - } catch { + } catch (err) { // ignore per-animation failures — keep going for other instances + swallow("runtime.adapters.lottie.site2", err); } } }, @@ -124,8 +127,9 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter { } else if (isDotLottiePlayer(anim)) { anim.pause(); } - } catch { + } catch (err) { // ignore + swallow("runtime.adapters.lottie.site3", err); } } }, diff --git a/packages/core/src/runtime/adapters/three.ts b/packages/core/src/runtime/adapters/three.ts index 49704e50c..600749a71 100644 --- a/packages/core/src/runtime/adapters/three.ts +++ b/packages/core/src/runtime/adapters/three.ts @@ -1,4 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; +import { swallow } from "../diagnostics"; export function createThreeAdapter(): RuntimeDeterministicAdapter { let forcedTime: number | null = null; @@ -13,8 +14,9 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter { window.__hfThreeTime = forcedTime; try { window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: forcedTime } })); - } catch { + } catch (err) { // ignore custom event failures + swallow("runtime.adapters.three.site1", err); } }, pause: () => { diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index 4421d3fb3..64ea6b03b 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -1,4 +1,5 @@ import type { RuntimeDeterministicAdapter } from "../types"; +import { swallow } from "../diagnostics"; export function createWaapiAdapter(): RuntimeDeterministicAdapter { return { @@ -10,13 +11,15 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { for (const animation of document.getAnimations()) { try { animation.currentTime = timeMs; - } catch { + } catch (err) { // ignore animations that reject currentTime writes + swallow("runtime.adapters.waapi.site1", err); } try { animation.pause(); - } catch { + } catch (err) { // infinite unresolved animations can throw here until currentTime resolves + swallow("runtime.adapters.waapi.site2", err); } } }, @@ -25,8 +28,9 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { for (const animation of document.getAnimations()) { try { animation.pause(); - } catch { + } catch (err) { // ignore animation edge-cases + swallow("runtime.adapters.waapi.site3", err); } } }, diff --git a/packages/core/src/runtime/analytics.ts b/packages/core/src/runtime/analytics.ts index 845970157..7746823fb 100644 --- a/packages/core/src/runtime/analytics.ts +++ b/packages/core/src/runtime/analytics.ts @@ -1,3 +1,4 @@ +import { swallow } from "./diagnostics"; /** * Runtime analytics & performance telemetry — vendor-agnostic event emission. * @@ -75,8 +76,9 @@ export function emitAnalyticsEvent( event, properties: properties ?? {}, }); - } catch { + } catch (err) { // Never let analytics failures affect the runtime + swallow("runtime.analytics.site1", err); } } @@ -107,8 +109,9 @@ export function emitPerformanceMetric( if (typeof performance !== "undefined" && typeof performance.mark === "function") { performance.mark(name, { detail: { value, tags: tags ?? {} } }); } - } catch { + } catch (err) { // performance API unavailable or rejected — keep going + swallow("runtime.analytics.site2", err); } if (!_postMessage) return; @@ -120,7 +123,8 @@ export function emitPerformanceMetric( value, tags: tags ?? {}, }); - } catch { + } catch (err) { // Never let telemetry failures affect the runtime + swallow("runtime.analytics.site3", err); } } diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 73fcdbd4b..d6f95ef18 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -1,3 +1,4 @@ +import { swallow } from "./diagnostics"; import type { RuntimeBridgeControlMessage, RuntimeOutboundMessage } from "./types"; type BridgeDeps = { @@ -15,8 +16,9 @@ type BridgeDeps = { export function postRuntimeMessage(payload: RuntimeOutboundMessage): void { try { window.parent.postMessage(payload, "*"); - } catch { - // Ignore cross-frame posting failures. + } catch (err) { + // Cross-frame posting can throw if the parent is gone or origin-isolated. + swallow("bridge.postMessage", err); } } @@ -104,8 +106,9 @@ function flashElements(selectors: string[], duration: number): void { el.classList.add("__hf-flash"); setTimeout(() => el.classList.remove("__hf-flash"), duration); }); - } catch { + } catch (err) { // Invalid selector — skip + swallow("bridge.flashElements.querySelector", err); } } } diff --git a/packages/core/src/runtime/diagnostics.test.ts b/packages/core/src/runtime/diagnostics.test.ts new file mode 100644 index 000000000..66024712b --- /dev/null +++ b/packages/core/src/runtime/diagnostics.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { swallow } from "./diagnostics"; + +interface HFTestWindow { + __hfDebug?: boolean; + __HYPERFRAMES_DEBUG?: boolean; + __hf?: { + onSwallowed?: (e: { label: string; error: unknown }) => void; + }; +} + +describe("swallow", () => { + const w = window as unknown as HFTestWindow; + const originalDebug = console.debug; + + beforeEach(() => { + delete w.__hfDebug; + delete w.__HYPERFRAMES_DEBUG; + delete w.__hf; + console.debug = vi.fn(); + }); + + afterEach(() => { + console.debug = originalDebug; + delete w.__hfDebug; + delete w.__HYPERFRAMES_DEBUG; + delete w.__hf; + }); + + it("is silent by default — no console output, no handler call", () => { + swallow("test.silent", new Error("boom")); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it("logs to console.debug when window.__hfDebug is true", () => { + w.__hfDebug = true; + const err = new Error("boom"); + swallow("test.debug", err); + expect(console.debug).toHaveBeenCalledWith("[hyperframes] test.debug swallowed:", err); + }); + + it("also honors window.__HYPERFRAMES_DEBUG (legacy flag)", () => { + w.__HYPERFRAMES_DEBUG = true; + swallow("test.legacy", "string-error"); + expect(console.debug).toHaveBeenCalledWith( + "[hyperframes] test.legacy swallowed:", + "string-error", + ); + }); + + it("dispatches to window.__hf.onSwallowed when installed", () => { + const handler = vi.fn(); + w.__hf = { onSwallowed: handler }; + const err = new Error("from handler"); + swallow("test.handler", err); + expect(handler).toHaveBeenCalledWith({ label: "test.handler", error: err }); + }); + + it("does not propagate errors from the user-installed handler", () => { + w.__hf = { + onSwallowed: () => { + throw new Error("handler exploded"); + }, + }; + expect(() => swallow("test.handler-throws", new Error("real"))).not.toThrow(); + }); + + it("can run with both handler AND debug flag set", () => { + w.__hfDebug = true; + const handler = vi.fn(); + w.__hf = { onSwallowed: handler }; + swallow("test.both", "err"); + expect(handler).toHaveBeenCalled(); + expect(console.debug).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/runtime/diagnostics.ts b/packages/core/src/runtime/diagnostics.ts new file mode 100644 index 000000000..7aac85a16 --- /dev/null +++ b/packages/core/src/runtime/diagnostics.ts @@ -0,0 +1,66 @@ +/** + * Runtime diagnostic helpers for best-effort operations. + * + * Many runtime operations (postMessage to a parent frame, `media.play()` / + * `pause()` / `currentTime=`, timeline `seek()`, anime.js feature detection, + * etc.) can throw under perfectly normal conditions: the parent frame is + * cross-origin, autoplay is denied, the media element was just removed from + * the DOM, the timeline has been disposed, the host page does not include + * anime.js. The right behaviour in each case is "tried, didn't work, move + * on" — but emitting nothing makes silent failures invisible to anyone + * debugging a genuinely broken composition, and the bare `catch {}` shape + * also trips strict lint configurations on the inlined runtime IIFE. + * + * `swallow(label, err)` is the single funnel for these intentional silences. + * It dispatches to: + * + * - `console.debug` with the label, the error, and a `[hyperframes]` prefix + * when `window.__hfDebug === true` (or the legacy `__HYPERFRAMES_DEBUG` + * env-style global). Quiet by default; flip the flag in DevTools when + * hunting a regression. + * - A custom `__hf.onSwallowed` handler if installed — lets the studio / + * embeddings collect runtime swallow events without polluting the page + * console. + * + * Production behaviour without either flag set: completely silent, just + * like the original empty `catch {}`. The shape is also lint-clean — the + * helper call is a real statement, so no `no-empty` warnings ship in the + * inlined IIFE. + */ +export interface SwallowedEvent { + /** Short, descriptive label naming the operation that failed. */ + label: string; + /** The thrown value (often an Error, but JS allows anything). */ + error: unknown; +} + +interface HFDebugSurface { + __hfDebug?: boolean; + __HYPERFRAMES_DEBUG?: boolean; + __hf?: { + onSwallowed?: (event: SwallowedEvent) => void; + }; +} + +export function swallow(label: string, error?: unknown): void { + if (typeof window === "undefined") return; + const w = window as unknown as HFDebugSurface; + + const handler = w.__hf?.onSwallowed; + if (handler) { + try { + handler({ label, error }); + } catch (handlerError) { + // Don't recurse into swallow() — a consumer hook that throws + // shouldn't be allowed to take down the runtime, and routing the + // failure back through swallow() would loop. Drop on the floor; + // the original error already had its surface above. + void handlerError; + } + } + + if (w.__hfDebug || w.__HYPERFRAMES_DEBUG) { + // eslint-disable-next-line no-console -- intentional debug surface + console.debug(`[hyperframes] ${label} swallowed:`, error); + } +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 25e685a8b..689126b2f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -16,6 +16,7 @@ import { loadExternalCompositions, loadInlineTemplateCompositions } from "./comp import { applyCaptionOverrides } from "./captionOverrides"; import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types"; import type { PlayerAPI } from "../core.types"; +import { swallow } from "./diagnostics"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; @@ -33,8 +34,9 @@ export function initSandboxRuntimeModular(): void { if (typeof runtimeWindow.__hfRuntimeTeardown === "function") { try { runtimeWindow.__hfRuntimeTeardown(); - } catch { + } catch (err) { // keep runtime resilient across reinits + swallow("runtime.init.site1", err); } } // Normalize html/body so browser defaults (8px margin, white background) never @@ -583,8 +585,9 @@ export function initSandboxRuntimeModular(): void { if (existingRootTimeline) { try { fallbackTimeline.add(existingRootTimeline, 0); - } catch { + } catch (err) { // keep fallback resilient if root add fails + swallow("runtime.init.site2", err); } } const withTween = fallbackTimeline as RuntimeTimelineLike & { @@ -593,8 +596,9 @@ export function initSandboxRuntimeModular(): void { if (typeof withTween.to === "function") { try { withTween.to({}, { duration: durationSeconds }); - } catch { + } catch (err) { // no-op; if tween creation fails, caller will discard by unusable duration + swallow("runtime.init.site3", err); } } return fallbackTimeline; @@ -622,8 +626,9 @@ export function initSandboxRuntimeModular(): void { const startSec = resolveCompositionStartSeconds(candidate.compositionId); rootTimeline.add(candidate.timeline, startSec); addedIds.push(candidate.compositionId); - } catch { + } catch (err) { // ignore broken child add attempts + swallow("runtime.init.site4", err); } } return addedIds; @@ -687,8 +692,9 @@ export function initSandboxRuntimeModular(): void { if (typeof timelineWithPaused.paused !== "function") continue; try { timelineWithPaused.paused(false); - } catch { + } catch (err) { // keep runtime resilient against timeline API quirks + swallow("runtime.init.site5", err); } } }; @@ -828,8 +834,9 @@ export function initSandboxRuntimeModular(): void { // Placing a zero-duration tween at the floor extends // timeline.duration() to exactly that point. tlWithTo.to({}, { duration: 0 }, rootDurationFloorSeconds); - } catch { + } catch (err) { // keep runtime resilient + swallow("runtime.init.site6", err); } } const newDur = getTimelineDurationSeconds(rootTimeline); @@ -1144,8 +1151,9 @@ export function initSandboxRuntimeModular(): void { if (wasPlaying) { state.capturedTimeline.play(); } - } catch { + } catch (err) { // keep runtime resilient even if a timeline implementation throws + swallow("runtime.init.site7", err); } postRuntimeMessage({ source: "hf-preview", @@ -1410,14 +1418,16 @@ export function initSandboxRuntimeModular(): void { if (method === "discover") adapter.discover(); if (method === "pause") adapter.pause(); if (method === "play" && adapter.play) adapter.play(); - } catch { + } catch (err) { // keep runtime resilient against adapter-specific failures + swallow("runtime.init.site8", err); } if (method === "discover") { try { adapter.seek({ time: timeSeconds }); - } catch { + } catch (err) { // ignore seek bootstrap failures + swallow("runtime.init.site9", err); } } } @@ -1479,8 +1489,9 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLMediaElement)) continue; try { el.playbackRate = state.playbackRate; - } catch { + } catch (err) { // ignore unsupported values + swallow("runtime.init.site10", err); } } }; @@ -1509,8 +1520,9 @@ export function initSandboxRuntimeModular(): void { for (const adapter of state.deterministicAdapters) { try { adapter.seek({ time: Number(timeSeconds) || 0 }); - } catch { + } catch (err) { // ignore adapter failure + swallow("runtime.init.site11", err); } } }, @@ -1739,31 +1751,35 @@ export function initSandboxRuntimeModular(): void { if (!adapter || typeof adapter.revert !== "function") continue; try { adapter.revert(); - } catch { + } catch (err) { // keep runtime resilient against adapter cleanup failures + swallow("runtime.init.site12", err); } } state.deterministicAdapters = []; for (const cleanup of runtimeCleanupCallbacks.splice(0)) { try { cleanup(); - } catch { + } catch (err) { // ignore cleanup failures + swallow("runtime.init.site13", err); } } for (const styleEl of state.injectedCompStyles) { try { styleEl.remove(); - } catch { + } catch (err) { // ignore cleanup failures + swallow("runtime.init.site14", err); } } state.injectedCompStyles = []; for (const scriptEl of state.injectedCompScripts) { try { scriptEl.remove(); - } catch { + } catch (err) { // ignore cleanup failures + swallow("runtime.init.site15", err); } } state.injectedCompScripts = []; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 710999cb0..d4c0039e7 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -1,3 +1,4 @@ +import { swallow } from "./diagnostics"; export type RuntimeMediaClip = { el: HTMLVideoElement | HTMLAudioElement; start: number; @@ -160,8 +161,9 @@ export function syncRuntimeMedia(params: { try { // Per-element rate × global transport rate el.playbackRate = clip.playbackRate * params.playbackRate; - } catch { + } catch (err) { // ignore unsupported playbackRate + swallow("runtime.media.site1", err); } // Drift correction. Forcing `el.currentTime = relTime` every frame // causes an audible seek+rebuffer hiccup (readyState drops briefly). @@ -194,8 +196,9 @@ export function syncRuntimeMedia(params: { if (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)) { try { el.currentTime = relTime; - } catch { + } catch (err) { // ignore browser seek restrictions + swallow("runtime.media.site2", err); } // Detect failed seek: if currentTime didn't reach the target, // the browser can't seek past its buffered range. Common with @@ -208,8 +211,9 @@ export function syncRuntimeMedia(params: { el.load(); try { el.currentTime = relTime; - } catch { + } catch (err) { // ignore — the seek will be retried on the next tick + swallow("runtime.media.site3", err); } } // After a hard seek, clear the in-flight play guard so the next tick diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index 259db5747..733c01a40 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -1,4 +1,5 @@ import type { RuntimeJson, RuntimeOutboundMessage, RuntimePickerElementInfo } from "./types"; +import { swallow } from "./diagnostics"; type PickerModuleDeps = { postMessage: (payload: RuntimeOutboundMessage) => void; @@ -33,8 +34,9 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule { function emitPickerRuntimeEvent(eventName: string, detail: RuntimeJson): void { try { window.dispatchEvent(new CustomEvent(eventName, { detail })); - } catch { + } catch (err) { // no-op in unsupported contexts + swallow("runtime.picker.site1", err); } } diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index d14776b0f..8da664d5d 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -1,5 +1,6 @@ import type { RuntimePlayer, RuntimeTimelineLike } from "./types"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; +import { swallow } from "./diagnostics"; type PlayerDeps = { getTimeline: () => RuntimeTimelineLike | null; @@ -38,8 +39,9 @@ function forEachSiblingTimeline( if (!tl || tl === master) continue; try { fn(tl); - } catch { + } catch (err) { // ignore sibling failures — one broken timeline shouldn't poison play/pause + swallow("runtime.player.site1", err); } } } @@ -76,8 +78,9 @@ function seekMasterAndSiblingTimelinesDeterministically( for (const tl of rearmedSiblings) { try { tl.pause(); - } catch { + } catch (err) { // ignore sibling failures — one broken timeline shouldn't poison seek + swallow("runtime.player.site2", err); } } } diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index 214e57c31..20f872813 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -1,4 +1,5 @@ import type { RuntimeTimelineLike } from "./types"; +import { swallow } from "./diagnostics"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; @@ -118,8 +119,9 @@ export function createRuntimeStartTimeResolver(params: { if (Number.isFinite(timelineDuration) && timelineDuration > 0) { resolved = timelineDuration; } - } catch { + } catch (err) { // ignore broken timeline impls + swallow("runtime.startResolver.site1", err); } } } diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 67e7ab0c3..f8246f2e8 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -4,6 +4,7 @@ import type { RuntimeTimelineScene, RuntimeTimelineLike, } from "./types"; +import { swallow } from "./diagnostics"; import { createRuntimeStartTimeResolver } from "./startResolver"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; @@ -565,8 +566,9 @@ export function collectRuntimeTimelinePayload(params: { }); gsapClipIds.add(el.id); } - } catch { + } catch (err) { // GSAP introspection is best-effort — don't break timeline if it fails + swallow("runtime.timeline.site1", err); } } }