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
78 changes: 78 additions & 0 deletions docs/contracts/2026-10-07-composition-probe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Composition probe contract</title><style>
:root{color-scheme:light dark;
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
.strip .sep{opacity:.5}
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
.pill b{font-size:13px}
section{margin-top:56px}
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
.breath b{color:var(--go)}
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
td.k{font:13px var(--mono);white-space:nowrap}
.decision{border-left:4px solid var(--info)}
.decision h3{margin:0 0 8px;font-size:17px}
.decision .ask{color:var(--info);font-weight:600}
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
ul,ol{padding-left:22px}li{margin:6px 0}
.mermaid{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
.mermaid svg{max-width:100%}
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
</style></head><body><div class="wrap"><header><h1>Load compositions without a GSAP timeline</h1><p class="what">A valid document still loads. A real script failure keeps its own message.</p><div class="strip">Audited 3 boundaries · Not exercised 4 composition classes · No new authoring format<br>hyperframes @ 9701f0da61ddbf32d0c9d7bbf4fabc02b3ee3263 · 2026-10-07</div></header>
<section><div class="card breath"><b>In one breath.</b> The document already owns duration and Studio already records early script errors, but the probe accepts only a positive adapter duration.</div></section>
<section><h2>Where the value travels</h2><div class="mermaid">flowchart LR
D[Document and scripts] --> P[CompositionProbe]
P --> H[Player error event]
H --> R[React Player]
R --> N[NLE shadow]
N --> S[Shadow discard]
S --> T[Editor toast]</div></section>
<section><h2>The contract, per boundary</h2>
<div class="card"><h3>Document duration</h3><p class="plain">Use the existing root declaration or timed clip ends. Do not invent a timeline length.</p><pre>readStaticCompositionMeta(doc: Document):
{ width: number | null; height: number | null;
fps: number; durationSeconds: number } | null</pre><span class="cite">Producer: packages/core/src/runtime/compositionLength.ts:141, declaration read at152; package-subpaths.json:323 exposes runtime/composition-length. Current probe consumer composition-probe.ts:118 requires adapter.getDuration() &gt;0. Proposed reuse is not exercised.</span><div class="states"><span class="pill go">exists</span><span class="pill go">export reachable</span><span class="pill bad">probe does not read</span></div></div>
<div class="card"><h3>Early script failures</h3><p class="plain">The preview server captures browser errors before author scripts execute. Read that same buffer before reporting readiness or a timeout.</p><pre>var seen=window.__hfPreviewErrors=[];
addEventListener("error",function(e){seen.push(e.message||String(e))});</pre><span class="cite">Producer: packages/studio-server/src/routes/preview.ts:326, inserted at338 before scripts. Constant: packages/core/src/studioPreviewMark.ts:4. Existing reader: packages/studio/src/hooks/useConsoleErrorCapture.ts:68. New probe read not exercised. Resource events may yield generic Event text; unhandled rejection is not captured by this producer.</span><div class="states"><span class="pill go">exists</span><span class="pill go">same-origin reachable</span><span class="pill go">server writes; console reads</span></div></div>
<div class="card"><h3>Probe result and failure</h3><p class="plain">A probe error discards the hidden edit preview while the live preview stays visible.</p><pre>onReady: (result: ProbeResult) =&gt; void;
onError: (message: string) =&gt; void;
new CustomEvent("error", { detail: { message } })</pre><span class="cite">Producer: composition-probe.ts:123/136; hyperframes-player.ts:208. Accepting lines: studio/player/components/Player.tsx:241, components/nle/NLEPreview.tsx:556, player/hooks/useShadowPreviewReload.ts:116, components/EditorShell.tsx:186. Ready accepts duration and adapter at hyperframes-player.ts:1305.</span><div class="states"><span class="pill go">exists</span><span class="pill go">wired through every layer</span><span class="pill go">probe writes; toast reads</span></div></div></section>
<section><h2>How it says no</h2><table><tr><th>Cause</th><th>Current observable</th><th>Evidence</th></tr><tr><td>No GSAP or no positive adapter duration</td><td>Composition timeline not found after 8s</td><td>composition-probe.ts:118/134</td></tr><tr><td>Real early script error</td><td>Same timeout if no adapter appears</td><td>Probe has no early-error reader; error buffer reader search bounded to all packages excluding generated/dist</td></tr><tr><td>Injected runtime never appears</td><td>Probe can wait indefinitely</td><td>Early return at115 bypasses timeout at134</td></tr></table></section>
<section><h2>Decisions only you can make</h2><div class="card"><h3>1. Slow initialization with no usable document duration</h3><p>The task already decides valid document duration and real script errors. The remaining wait policy is unknown: a probe failure at8s discards a shadow whose outer budget allows up to45s.</p><table><tr><th>Option</th><th>Consequence</th></tr><tr><td>Keep finite8s failure</td><td>Preserves the existing probe budget. Slow unresolved initialization still fails, with an accurate readiness cause.</td></tr><tr><td>Let the outer shadow budget own waiting</td><td>Studio can accept initialization after8s; embeddable player needs its own finite bound. Changes lifecycle scope.</td></tr></table><p>Recommendation: first prove the slow class with real browser fixtures. Preserve the existing finite probe budget unless that evidence calls for a separately owned wait policy.</p></div></section>
<section><h2>Coverage, honestly</h2><p><b>Audited:</b> producer bodies and accepting lines above at the pinned main ref.</p><p><b>Trusting:</b> no runtime claims credited from mocks.</p><p><b>Not exercised:</b> CSS-only, bare render(t), WAAPI, zero-duration timeline, script failure, and delayed initialization still need red tests and real reload captures. No implementation has been written.</p></section>
<section><h2>What would prove this page wrong</h2><ul><li>A served CSS-only document with positive declared duration already completes the current probe.</li><li>An early throwing author script reaches the reload toast with its own message on this main head.</li><li>A pending runtime injection reaches the existing8s timeout despite the early return.</li></ul></section></div><script type="module">
try {
const m = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
m.default.initialize({ startOnLoad: false, theme: dark ? "dark" : "neutral", securityLevel: "loose",
themeVariables: { fontFamily: "ui-monospace, Menlo, monospace", fontSize: "13px" } });
await m.default.run({ querySelector: ".mermaid" });
} catch (e) {
for (const el of document.querySelectorAll(".mermaid")) {
const pre = document.createElement("pre"); pre.textContent = el.textContent.trim(); el.replaceWith(pre);
}
}
</script></body></html>
131 changes: 86 additions & 45 deletions packages/player/src/composition-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
* and detect whether the HyperFrames runtime needs to be injected.
*
* The probe interval polls every 200 ms until one of:
* - A `PlaybackDurationAdapter` resolves with a positive duration, or
* - An adapter or the document resolves with a positive duration, or
* - 40 attempts (~8 s) expire without a result.
*
* The `CompositionProbe` class owns the interval; the caller must call
* `stop()` on disconnect or src change.
*/

import { readStaticCompositionMeta } from "@hyperframes/core/runtime/composition-length";
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
import {
type DirectTimelineAdapter,
Expand Down Expand Up @@ -63,9 +65,29 @@ export function readCompositionSizeFromDocument(
return width !== null && height !== null ? { width, height } : null;
}

type ProbeOutcome =
| { kind: "ready"; result: ProbeResult }
| { kind: "error"; message: string }
| null;

function firstAuthorError(errors: unknown): string | null {
if (!Array.isArray(errors)) return null;
return (
errors.find(
(value): value is string =>
typeof value === "string" && value.trim() !== "" && value !== "[object Event]",
) ?? null
);
}

export class CompositionProbe {
private _interval: ReturnType<typeof setInterval> | null = null;
private _runtimeInjected = false;
private _failure: { document: Document | null } | null = null;

get failed(): boolean {
return this._failure !== null && this._failure.document === this._iframe.contentDocument;
}

constructor(
private readonly _iframe: HTMLIFrameElement,
Expand All @@ -77,68 +99,87 @@ export class CompositionProbe {
return this._runtimeInjected;
}

/** Start (or restart) the probe. Stops any previously running probe first. */
/** Start or restart the probe, stopping the active interval first. */
start(): void {
this.stop();
this._failure = null;
this._runtimeInjected = false;
let attempts = 0;

// fallow-ignore-next-line complexity
this._interval = setInterval(() => {
attempts++;
let outcome: ProbeOutcome = null;
try {
const win = this._iframe.contentWindow as Window & {
__player?: { getDuration: () => number };
__timelines?: Record<string, { duration: () => number }>;
__hf?: unknown;
};
if (!win) return;

const hasRuntime = !!(win.__hf || win.__player);
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
const hasNestedCompositions =
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");

if (
shouldInjectRuntime({
hasRuntime,
hasTimelines,
hasNestedCompositions,
runtimeInjected: this._runtimeInjected,
attempts,
})
) {
this._injectRuntime();
return;
}

if (this._runtimeInjected && !hasRuntime) return;

const adapter = this._resolvePlaybackDurationAdapter(win);
if (adapter && adapter.getDuration() > 0) {
this.stop();

const compositionSize = readCompositionSizeFromDocument(this._iframe.contentDocument);

this._callbacks.onReady({
duration: adapter.getDuration(),
adapter,
compositionSize,
});
return;
outcome = this._poll(attempts);
} catch (error) {
if (!(error instanceof DOMException && error.name === "SecurityError")) {
outcome = {
kind: "error",
message: error instanceof Error ? error.message : String(error),
};
}
} catch {
/* cross-origin */
}

if (outcome) {
this.stop();
if (outcome.kind === "error") {
this._failure = { document: this._iframe.contentDocument };
this._callbacks.onError(outcome.message);
} else this._callbacks.onReady(outcome.result);
return;
}
if (attempts >= 40) {
this.stop();
this._failure = { document: this._iframe.contentDocument };
this._callbacks.onError("Composition timeline not found after 8s");
}
}, 200);
}

private _poll(attempts: number): ProbeOutcome {
const win = this._iframe.contentWindow;
if (!win) return null;
const message = firstAuthorError(Reflect.get(win, STUDIO_PREVIEW_ERRORS));
if (message !== null) return { kind: "error", message };
const doc = this._iframe.contentDocument;
const timelines = Reflect.get(win, "__timelines");
const hasRuntime = this.hasRuntimeBridge(win);
if (
shouldInjectRuntime({
hasRuntime,
hasTimelines: isObjectRecord(timelines) && Object.keys(timelines).length > 0,
hasNestedCompositions: !!doc?.querySelector("[data-composition-src]"),
runtimeInjected: this._runtimeInjected,
attempts,
})
) {
this._injectRuntime();
return null;
}
if (this._runtimeInjected && !hasRuntime) return null;
const result = this._resolveReadyResult(win, doc);
return result ? { kind: "ready", result } : null;
}

private _resolveReadyResult(win: Window, doc: Document | null): ProbeResult | null {
const adapter: PlaybackDurationAdapter = this._resolvePlaybackDurationAdapter(win) ?? {
kind: "document",
getDuration: () => 0,
};
let duration = adapter.getDuration();
if (!Number.isFinite(duration) || duration <= 0) {
if (adapter.kind === "direct-timeline") return null;
duration = doc ? (readStaticCompositionMeta(doc)?.durationSeconds ?? 0) : 0;
}
if (duration <= 0) return null;
return {
duration,
adapter: { ...adapter, getDuration: () => duration },
compositionSize: readCompositionSizeFromDocument(doc),
};
}

stop(): void {
if (!this.failed) this._failure = null;
if (this._interval !== null) {
clearInterval(this._interval);
this._interval = null;
Expand Down
Loading
Loading