@xplainer/motion 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * How a bundle names the one video it renders: `registerRoot(Root)` with a `<Composition>` inside,
3
+ * or `registerVideo({...})` directly.
4
+ *
5
+ * **Why `<Composition>` survives at all.** The engine-owned `Root.tsx` is a byte-identity fixture,
6
+ * and it declares its video as `<Composition id component durationInFrames fps width height
7
+ * defaultProps calculateMetadata />` inside a `Root` passed to `registerRoot`. Keeping that shape
8
+ * means the scaffold changes by its import line and nothing else. `registerVideo` is the same
9
+ * thing without the indirection, for a bundle that has no `Root.tsx`.
10
+ *
11
+ * **`<Composition>` renders nothing, ever.** It is a declaration. The harness renders `Root` once,
12
+ * off-screen, under a collector context, and each `<Composition>` hands its props to that
13
+ * collector during render. Outside that probe — in a test, say — the context is empty and the
14
+ * component is inert.
15
+ *
16
+ * **Metadata is resolved before the first frame.** `calculateMetadata` runs once, with the default
17
+ * props as both `defaultProps` and `props` (the renderer passes no input props), and whatever it
18
+ * returns overrides the declared `width`, `height`, `fps`, `durationInFrames` and `props`. The
19
+ * scaffold uses it to read `timings.json` and `captions.json`, so no component ever has to hold a
20
+ * frame open to load them.
21
+ */
22
+ import { type ComponentType } from "react";
23
+ import { type VideoRegistration } from "./context.js";
24
+ export type CalculateMetadataResult<Props> = {
25
+ durationInFrames?: number;
26
+ fps?: number;
27
+ width?: number;
28
+ height?: number;
29
+ props?: Props;
30
+ };
31
+ export type CalculateMetadataOptions<Props> = {
32
+ defaultProps: Props;
33
+ props: Props;
34
+ abortSignal: AbortSignal;
35
+ compositionId: string;
36
+ };
37
+ /** Resolve the video's real metadata — typically its duration — before the first frame. */
38
+ export type CalculateMetadataFunction<Props> = (options: CalculateMetadataOptions<Props>) => Promise<CalculateMetadataResult<Props>> | CalculateMetadataResult<Props>;
39
+ export type CompositionProps<Props extends Record<string, unknown>> = {
40
+ id: string;
41
+ component: ComponentType<Props>;
42
+ width: number;
43
+ height: number;
44
+ fps: number;
45
+ durationInFrames: number;
46
+ defaultProps: Props;
47
+ calculateMetadata?: CalculateMetadataFunction<Props> | null | undefined;
48
+ };
49
+ export type RegisterVideoOptions<Props extends Record<string, unknown>> = Omit<CompositionProps<Props>, "id"> & {
50
+ /** Default `"Explainer"`, the id the scaffold's `Root.tsx` uses. */
51
+ id?: string;
52
+ };
53
+ /**
54
+ * Erase `Props` behind a closure, so the runtime can hold any video in one slot.
55
+ * Exported for the harness and the tests; not part of the package's public surface.
56
+ */
57
+ export declare function toRegistration<Props extends Record<string, unknown>>(declared: CompositionProps<Props>): VideoRegistration;
58
+ /** Declare the video. Renders nothing; the harness collects the props. */
59
+ export declare function Composition<Props extends Record<string, unknown>>(props: CompositionProps<Props>): null;
60
+ /** Name the component whose `<Composition>` declares this bundle's video. Call once, from `index.ts`. */
61
+ export declare function registerRoot(Root: ComponentType): void;
62
+ /** Register the video directly, without a `Root` component. Call once, from `index.ts`. */
63
+ export declare function registerVideo<Props extends Record<string, unknown>>(options: RegisterVideoOptions<Props>): void;
@@ -0,0 +1 @@
1
+ import{createElement,useContext}from"react";import{CompositionCollectorContext}from"./context.js";import{registerEntry}from"./runtime.js";function assertDimension(name,value,id){if(typeof value!="number"||!Number.isInteger(value)||value<1)throw new RangeError(`video "${id}": ${name} must be a positive whole number, got ${String(value)}.`);return value}function assertFps(value,id){if(typeof value!="number"||!Number.isFinite(value)||value<=0)throw new RangeError(`video "${id}": fps must be a positive number, got ${String(value)}.`);return value}export function toRegistration(declared){const{id,component,defaultProps,calculateMetadata}=declared;return{id,resolve:async abortSignal=>{const computed=calculateMetadata==null?{}:await calculateMetadata({defaultProps,props:defaultProps,abortSignal,compositionId:id}),props=computed.props??defaultProps;return{config:{id,width:assertDimension("width",computed.width??declared.width,id),height:assertDimension("height",computed.height??declared.height,id),fps:assertFps(computed.fps??declared.fps,id),durationInFrames:assertDimension("durationInFrames",computed.durationInFrames??declared.durationInFrames,id),defaultProps,props},render:()=>createElement(component,props)}}}}export function Composition(props){const collect=useContext(CompositionCollectorContext);return collect!==null&&collect(toRegistration(props)),null}export function registerRoot(Root){registerEntry({kind:"root",Root})}export function registerVideo(options){registerEntry({kind:"video",registration:toRegistration({...options,id:options.id??"Explainer"})})}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The three React contexts the whole package hangs off.
3
+ *
4
+ * - `VideoConfigContext` — the resolved video (size, fps, duration, props). Set once, at the root.
5
+ * - `TimelineContext` — **the frame as this subtree sees it**, plus the duration of the window it
6
+ * lives in. The root sets it to the absolute frame; every `Sequence`, `Loop` and `Freeze`
7
+ * re-provides it relative to itself. That single idea is the whole timing model: nested offsets
8
+ * add because each level subtracts its own `from` from what its parent saw, and
9
+ * `useCurrentFrame()` is nothing more than reading the nearest value.
10
+ * - `CompositionCollectorContext` — how `registerRoot()` learns what `<Composition>` declared
11
+ * without the composition rendering anything (see `composition.tsx`).
12
+ */
13
+ import { type Context, type ReactElement } from "react";
14
+ /** What `useVideoConfig()` returns. */
15
+ export type VideoConfig = {
16
+ id: string;
17
+ width: number;
18
+ height: number;
19
+ fps: number;
20
+ /** The video's length, or the enclosing `Sequence`'s, clipped to its parent's window. */
21
+ durationInFrames: number;
22
+ defaultProps: Record<string, unknown>;
23
+ /** The props the component was rendered with: `calculateMetadata`'s, else `defaultProps`. */
24
+ props: Record<string, unknown>;
25
+ };
26
+ /** The frame a subtree sees, and the length of the window it is mounted in. */
27
+ export type Timeline = {
28
+ frame: number;
29
+ durationInFrames: number;
30
+ };
31
+ /** A video's metadata after `calculateMetadata` ran, and how to render it. */
32
+ export type ResolvedVideo = {
33
+ config: VideoConfig;
34
+ render: () => ReactElement;
35
+ };
36
+ /**
37
+ * A `<Composition>` or `registerVideo()` call with its props type erased.
38
+ *
39
+ * The generic `Props` never leaves the closure `resolve` was built in, which is how a video with
40
+ * any props shape fits one runtime slot without a cast.
41
+ */
42
+ export type VideoRegistration = {
43
+ id: string;
44
+ resolve: (abortSignal: AbortSignal) => Promise<ResolvedVideo>;
45
+ };
46
+ export declare const VideoConfigContext: Context<VideoConfig | null>;
47
+ export declare const TimelineContext: Context<Timeline | null>;
48
+ export declare const CompositionCollectorContext: Context<((registration: VideoRegistration) => void) | null>;
@@ -0,0 +1 @@
1
+ import{createContext}from"react";export const VideoConfigContext=createContext(null),TimelineContext=createContext(null),CompositionCollectorContext=createContext(null);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `Easing` — the curves a scene hands to `interpolate()` to shape a transition.
3
+ *
4
+ * **Every curve here is a documented formula, written from the formula.** The set and the
5
+ * names follow React Native's `Easing` module, because that is the vocabulary explainer scenes
6
+ * are already written in; the bodies are the textbook closed forms (Penner's equations and the
7
+ * CSS cubic-Bézier definition), not a port of anybody's source. `packages/motion/src/math.test.ts`
8
+ * holds each one against a separate evaluation of the same formula, so a curve that drifts is a
9
+ * red test rather than a scene that looks slightly wrong.
10
+ *
11
+ * **The domain is not clamped.** `interpolate()` with `extrapolate*: "extend"` feeds a curve
12
+ * positions below 0 and above 1, and every polynomial curve here simply continues. The one curve
13
+ * that cannot continue on its own is `bezier`, whose parametric form is not a function outside
14
+ * `[0, 1]`; it extends along the tangent at the nearer endpoint, which is what the CSS Easing
15
+ * specification prescribes for the same situation.
16
+ */
17
+ /** A curve over normalised time: 0 at the start of a segment, 1 at its end. */
18
+ export type EasingFunction = (t: number) => number;
19
+ /** The shape of `Easing`, written out because a const this public carries its type explicitly. */
20
+ export type EasingModule = {
21
+ /** `t`. */
22
+ readonly linear: EasingFunction;
23
+ /** `bezier(0.42, 0, 1, 1)` — a gentle start. */
24
+ readonly ease: EasingFunction;
25
+ /** `t²`. */
26
+ readonly quad: EasingFunction;
27
+ /** `t³`. */
28
+ readonly cubic: EasingFunction;
29
+ /** `tⁿ`. */
30
+ readonly poly: (n: number) => EasingFunction;
31
+ /** `1 − cos(t·π/2)`. */
32
+ readonly sin: EasingFunction;
33
+ /** `1 − √(1 − t²)`. */
34
+ readonly circle: EasingFunction;
35
+ /** `2^(10·(t − 1))`. */
36
+ readonly exp: EasingFunction;
37
+ /** `1 − cos(t·π/2)³ · cos(t·bounciness·π)`; `bounciness` defaults to 1. */
38
+ readonly elastic: (bounciness?: number) => EasingFunction;
39
+ /** `t²·((s + 1)·t − s)`; `s` defaults to 1.70158, a 10 % overshoot. */
40
+ readonly back: (s?: number) => EasingFunction;
41
+ /** Penner's four-arc bounce. */
42
+ readonly bounce: EasingFunction;
43
+ /** The CSS `cubic-bezier(x1, y1, x2, y2)` curve. */
44
+ readonly bezier: (x1: number, y1: number, x2: number, y2: number) => EasingFunction;
45
+ /** The curve as given: the "in" half is the curve itself. */
46
+ readonly in: (easing: EasingFunction) => EasingFunction;
47
+ /** The curve mirrored in time and value: `1 − f(1 − t)`. */
48
+ readonly out: (easing: EasingFunction) => EasingFunction;
49
+ /** "in" for the first half, "out" for the second, meeting at (0.5, 0.5). */
50
+ readonly inOut: (easing: EasingFunction) => EasingFunction;
51
+ /** 0 at `t ≤ 0`, then 1. */
52
+ readonly step0: EasingFunction;
53
+ /** 0 until `t ≥ 1`, then 1. */
54
+ readonly step1: EasingFunction;
55
+ };
56
+ export declare const Easing: EasingModule;
package/dist/easing.js ADDED
@@ -0,0 +1 @@
1
+ const NEWTON_ITERATIONS=8,SOLVE_EPSILON=1e-14,MIN_SLOPE=1e-12,BISECTION_ITERATIONS=64;function linear(t){return t}function bezier(x1,y1,x2,y2){for(const[name,value]of[["x1",x1],["y1",y1],["x2",x2],["y2",y2]])if(typeof value!="number"||!Number.isFinite(value))throw new TypeError(`Easing.bezier: ${name} must be a finite number, got ${String(value)}.`);if(x1<0||x1>1||x2<0||x2>1)throw new RangeError(`Easing.bezier: x1 and x2 must lie within [0, 1] so the curve is a function of time; got x1=${x1}, x2=${x2}.`);if(x1===y1&&x2===y2)return linear;const cx=3*x1,bx=3*(x2-x1)-cx,ax=1-cx-bx,cy=3*y1,by=3*(y2-y1)-cy,ay=1-cy-by,sampleX=t=>((ax*t+bx)*t+cx)*t,sampleY=t=>((ay*t+by)*t+cy)*t,slopeX=t=>(3*ax*t+2*bx)*t+cx,solveT=x=>{let t=x;for(let index=0;index<8;index+=1){const error=sampleX(t)-x;if(Math.abs(error)<1e-14)return t;const slope=slopeX(t);if(Math.abs(slope)<1e-12||(t-=error/slope,t<0||t>1))break}let low=0,high=1;t=x;for(let index=0;index<64;index+=1){t=(low+high)/2;const value=sampleX(t);if(Math.abs(value-x)<1e-14)return t;value<x?low=t:high=t}return t},startSlope=x1>0?y1/x1:x2>0?y2/x2:0,endSlope=x2<1?(y2-1)/(x2-1):x1<1?(y1-1)/(x1-1):0;return x=>x<=0?x===0?0:startSlope*x:x>=1?x===1?1:1+endSlope*(x-1):sampleY(solveT(x))}function bounce(t){if(t<1/2.75)return 7.5625*t*t;if(t<2/2.75){const u2=t-.5454545454545454;return 7.5625*u2*u2+.75}if(t<2.5/2.75){const u2=t-.8181818181818182;return 7.5625*u2*u2+.9375}const u=t-2.625/2.75;return 7.5625*u*u+.984375}export const Easing={linear,ease:bezier(.42,0,1,1),quad:t=>t*t,cubic:t=>t*t*t,poly:n=>t=>t**n,sin:t=>1-Math.cos(t*Math.PI/2),circle:t=>1-Math.sqrt(1-t*t),exp:t=>2**(10*(t-1)),elastic:(bounciness=1)=>{const p=bounciness*Math.PI;return t=>1-Math.cos(t*Math.PI/2)**3*Math.cos(t*p)},back:(s=1.70158)=>t=>t*t*((s+1)*t-s),bounce,bezier,in:easing=>easing,out:easing=>t=>1-easing(1-t),inOut:easing=>t=>t<.5?easing(t*2)/2:1-easing((1-t)*2)/2,step0:t=>t>0?1:0,step1:t=>t>=1?1:0};
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How a page failure is named: `<CODE>: <detail>`, always with the code first.
3
+ *
4
+ * The renderer reads only the message (CDP's `Runtime.evaluate` reports a rejection's `message`),
5
+ * so the code has to be in the text. The harness produces four of them itself; anything else the
6
+ * page throws — a component's `throw`, an error in a timer, a rejected promise nobody caught — is a
7
+ * `PAGE_ERROR`. One exception: an error whose message **already** starts with one of the codes
8
+ * below keeps it. That is how `<Video>`'s render-time refusal reaches the renderer as
9
+ * `NOT_SUPPORTED_IN_V1: …` rather than `PAGE_ERROR: NOT_SUPPORTED_IN_V1: …` — the code a caller
10
+ * branches on is the first token either way, and only one of the two spellings needs documenting.
11
+ */
12
+ export declare const FAILURE_CODES: readonly [
13
+ "PAGE_ERROR",
14
+ "DELAY_RENDER_TIMEOUT",
15
+ "IMAGE_BROKEN",
16
+ "IMAGE_TIMEOUT",
17
+ "NOT_SUPPORTED_IN_V1"
18
+ ];
19
+ export type FailureCode = (typeof FAILURE_CODES)[number];
20
+ /** `<code>: <detail>`. */
21
+ export declare function failure(code: FailureCode, detail: string): string;
22
+ /** The message of anything thrown: an `Error`'s message, a string as itself, else its `String()`. */
23
+ export declare function messageOf(error: unknown): string;
24
+ /** The failure line for anything thrown: its own code if it carries one, else `PAGE_ERROR`. */
25
+ export declare function describeFailure(error: unknown): string;
@@ -0,0 +1 @@
1
+ export const FAILURE_CODES=["PAGE_ERROR","DELAY_RENDER_TIMEOUT","IMAGE_BROKEN","IMAGE_TIMEOUT","NOT_SUPPORTED_IN_V1"];export function failure(code,detail){return`${code}: ${detail}`}export function messageOf(error){if(error instanceof Error)return error.message;if(typeof error=="string")return error;if(typeof error=="object"&&error!==null){const message=Reflect.get(error,"message");if(typeof message=="string")return message}return String(error)}export function describeFailure(error){const message=messageOf(error);return FAILURE_CODES.some(code=>message.startsWith(`${code}: `))?message:failure("PAGE_ERROR",message)}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `@xplainer/motion/harness` — the page-side half of the renderer. Browser only; side effects on
3
+ * import.
4
+ *
5
+ * The renderer's generated bundle entry imports this module **before** the video's `index.ts`, so
6
+ * everything below is installed before any scene module runs. It does five things:
7
+ *
8
+ * 1. **Freezes the clock.** `Date` (no-argument construction, `Date()` and `Date.now`) and
9
+ * `performance.now` read film time — `frame / fps · 1000` ms, from 2020-01-01 for `Date` — which
10
+ * moves only when the renderer calls `setFrame`. `setTimeout`/`setInterval` run on film time,
11
+ * and `requestAnimationFrame` callbacks are queued until the next frame (`clock.ts`). React and
12
+ * its scheduler keep the native timers: `react-dom` is imported above the patches, and both
13
+ * capture the timer functions when their modules evaluate. They do not capture the clock: the
14
+ * scheduler calls `performance.now()` each time it asks, so it reads film time — its work loop
15
+ * never sees its time slice run out while a frame is held (it still yields to paint after a
16
+ * commit), and every throttle React measures with it, a Suspense fallback's included, is
17
+ * measured in film time. `Math.random` is mulberry32 from a fixed seed (`random.ts`), reset once
18
+ * when the video mounts and never per frame, so a scene that draws from it draws the same
19
+ * sequence on every run. The sequence is still consumed in the order frames are set: a render
20
+ * goes front to back, and the renderer steps a still's page through every earlier frame first,
21
+ * so a still draws what the render drew on that frame.
22
+ * 2. **Answers `fetch` for the static files** the bundle carries in `window.__XPLAINER_STATIC__`
23
+ * (`static-fetch.ts`), so the composition's metadata never waits on the network.
24
+ * 3. **Mounts the registered video** once `calculateMetadata` has run, into `#root` sized to the
25
+ * video and clipped to it.
26
+ * 4. **Drives frames.** `setFrame(n)` sets film time and, inside one `flushSync`, fires the timers
27
+ * due by then (in due order; those they create wait for the next frame), drains the queued
28
+ * animation-frame callbacks with the film timestamp and commits the new frame — so everything a
29
+ * timer sets lands in the same commit as the frame it belongs to. Then every Web Animation is
30
+ * paused and seeked to film time (`animations.ts`), and the frame settles.
31
+ * 5. **Settles before a capture**, in passes. A pass waits for render holds, then images, then
32
+ * fonts, then forces a layout — or, once the page has made a `ResizeObserver` or an
33
+ * `IntersectionObserver`, waits one native animation frame, which is where those report — and
34
+ * yields two `MessageChannel` tasks, which is one React effect → setState hop (`settleTick`).
35
+ * Another pass follows while the one before it changed the DOM (a `MutationObserver` over the
36
+ * whole document), left a font loading, or left a hold or an image outstanding, up to
37
+ * `MAX_SETTLE_PASSES`. A page still changing after the last pass is released as it stands, with
38
+ * one `console.warn` naming the frame — the one frame the harness knowingly releases unsettled,
39
+ * and a "page warning" line in the job log.
40
+ *
41
+ * **Render holds, timers and animation frames — the one place film time yields to the wall
42
+ * clock.** A scene that writes `const h = delayRender(); setTimeout(() => continueRender(h), 100)`
43
+ * deadlocks on a pure film clock: the timer waits for the next frame and the frame waits for the
44
+ * timer. So while any hold is outstanding, the timers created **since the previous frame was
45
+ * released** run on the wall clock instead: such a timer due Δ ms after the current film time
46
+ * fires Δ wall-clock ms after the wait began, and a timer created during the wait is scheduled from
47
+ * that same advancing point. `requestAnimationFrame(() => continueRender(h))` deadlocks the same
48
+ * way, so the animation-frame callbacks requested since the previous release are drained too, on
49
+ * every poll of the wait, inside `flushSync` and with the frame's film timestamp. Film time itself,
50
+ * `Date` and `performance.now` stay frozen throughout.
51
+ *
52
+ * **When the hold is released, the wall clock is taken back out.** Every timer the wait created
53
+ * that is still pending is rebased onto the frame's film instant (`TimerQueue.rebase`): a timeout
54
+ * becomes due its own delay after the frame, and an interval keeps its period and the ticks it has
55
+ * already made, so it stays in film phase. `img.onload = () => { setTimeout(show, 100);
56
+ * continueRender(h); }` therefore shows 100 ms of film after the frame whether the image took
57
+ * 20 ms or two seconds: how long a release took never reaches the video.
58
+ *
59
+ * Timers and animation-frame callbacks from earlier frames — a scene's animation interval — stay
60
+ * on film time even during a hold. Letting them run on the wall clock too would fire them early by
61
+ * however long the hold happened to last, which differs from run to run: measured in Chrome, a
62
+ * 100 ms interval created at frame 0 ticked a frame early on the one frame that took a 100 ms hold.
63
+ * The narrower set assumes a release timer is created in the same frame as its hold (or between
64
+ * frames, by whatever async work took the hold), and **that is the documented trade-off rather
65
+ * than a guarantee**: a hold that only an earlier frame's `setInterval` would release is not
66
+ * released by this rule — the interval stays on film time, the frame cannot advance, and the hold
67
+ * fails `DELAY_RENDER_TIMEOUT` after its budget. A scene that waits on something takes the hold
68
+ * where it starts the wait. "Since the previous release" includes this frame's own drain, so a
69
+ * timeout chain the drain re-armed and a `requestAnimationFrame` loop do run during a hold — the
70
+ * loop once per poll, every time with the same film timestamp, which a loop that draws from its
71
+ * timestamp cannot tell apart from one call.
72
+ *
73
+ * **Images: what is waited for, and why there is no late-image failure.** Every `<img>` in the
74
+ * document must be complete and decoded before a frame is released — the frame waits, up to the
75
+ * image budget, and fails `IMAGE_TIMEOUT` past it; one found complete and empty at the end of any
76
+ * pass fails `IMAGE_BROKEN` on this frame, though it failed too fast ever to be pending, rather
77
+ * than on the next frame's first look — and the settle passes again while the DOM is
78
+ * still changing. So an image the frame's own work mounts is in the frame: in the commit, after
79
+ * effect → setState hops that each change the DOM, or behind a render hold. What the settle cannot
80
+ * see is work that changes nothing it watches until later — a `fetch` with no `delayRender`, a run
81
+ * of React renders that touch no node, a chain longer than `MAX_SETTLE_PASSES`. An image that work
82
+ * mounts can arrive after its frame was captured, and no check on a later frame could tell that
83
+ * frame was meant to have it; holding the frame with `delayRender` is how a scene says so. CSS
84
+ * `background-image` is not an `<img>` and is not waited for; a scene that needs a picture to be
85
+ * present uses `<Img>`.
86
+ *
87
+ * Every failure is an `Error` whose message begins with its code (`failures.ts`):
88
+ * `PAGE_ERROR`, `DELAY_RENDER_TIMEOUT`, `IMAGE_BROKEN`, `IMAGE_TIMEOUT`, and `NOT_SUPPORTED_IN_V1`
89
+ * for `<Video>`/`<OffthreadVideo>` and a second `<Audio>`, which keeps its own code rather than
90
+ * nesting under `PAGE_ERROR`. Once one is recorded, every later `setFrame` rejects with the first.
91
+ */
92
+ /** What `window.__xplainer.metadata()` resolves to. */
93
+ export type XplainerMetadata = {
94
+ fps: number;
95
+ durationInFrames: number;
96
+ width: number;
97
+ height: number;
98
+ };
99
+ /** What `window.__xplainer.readiness()` returns. */
100
+ export type XplainerReadiness = {
101
+ /** True once mounted and settled, with nothing pending and nothing failed. */
102
+ ready: boolean;
103
+ /** `metadata`, `first-settle`, `delayRender:<label>`, `img:<src>`, `fonts`. */
104
+ pending: string[];
105
+ /** Every failure so far, each beginning with its code. */
106
+ failed: string[];
107
+ };
108
+ /** The driver the renderer calls over CDP. */
109
+ export type XplainerHarness = {
110
+ /** Resolves once `calculateMetadata` has run; rejects with the failure if mounting failed. */
111
+ metadata(): Promise<XplainerMetadata>;
112
+ /** Show frame `n` and resolve once it has settled and may be captured. */
113
+ setFrame(frame: number): Promise<void>;
114
+ readiness(): XplainerReadiness;
115
+ /** The frame most recently set. */
116
+ readonly frame: number;
117
+ };
118
+ /** Budgets the renderer may set on `window.__XPLAINER_RENDER__` before the bundle runs. */
119
+ export type XplainerRenderOptions = {
120
+ /** How long a `delayRender` hold may stay outstanding. Default 30 000. */
121
+ delayRenderTimeoutMs?: number;
122
+ /** How long a frame waits for its images. Default 30 000. */
123
+ imageTimeoutMs?: number;
124
+ };
125
+ declare global {
126
+ interface Window {
127
+ __xplainer: XplainerHarness;
128
+ /** Static file name → text, set by the bundle's `static-data.js`. */
129
+ __XPLAINER_STATIC__?: Readonly<Record<string, string>>;
130
+ /** The URL `staticFile()` prefixes, ending in `/`; set by the bundle's `static-data.js`. */
131
+ __XPLAINER_PUBLIC_BASE__?: string;
132
+ __XPLAINER_RENDER__?: XplainerRenderOptions;
133
+ }
134
+ }
@@ -0,0 +1 @@
1
+ import{createElement,useSyncExternalStore}from"react";import{flushSync}from"react-dom";import{createRoot}from"react-dom/client";import{AnimationSeeker}from"./animations.js";import{createFrozenDate,FrameCallbackQueue,frameToFilmMs,TimerQueue}from"./clock.js";import{CompositionCollectorContext}from"./context.js";import{describeFailure,failure}from"./failures.js";import{VideoProvider}from"./provider.js";import{mulberry32Sequence}from"./random.js";import{onRegistration,recordedFailures,recordFailure,renderHolds}from"./runtime.js";import{contentTypeFor,findStaticText,requestUrl}from"./static-fetch.js";const DEFAULT_DELAY_RENDER_TIMEOUT_MS=3e4,DEFAULT_IMAGE_TIMEOUT_MS=3e4,HOLD_POLL_MS=2,IMAGE_POLL_MS=5,MATH_RANDOM_SEED=1481657422,MAX_SETTLE_PASSES=10,host=window,native={setTimeout:host.setTimeout.bind(host),requestAnimationFrame:host.requestAnimationFrame.bind(host),fetch:host.fetch.bind(host),now:performance.now.bind(performance)};let filmMs=0,holdWaitStartedAt,releasedAtTimerId=1,releasedAtFrameCallbackId=1;function markReleased(){releasedAtTimerId=timers.nextId,releasedAtFrameCallbackId=frameCallbacks.nextId}function scheduleBase(){return holdWaitStartedAt===void 0?filmMs:filmMs+(native.now()-holdWaitStartedAt)}function run(callback){try{callback()}catch(error){recordFailure(describeFailure(error))}}const timers=new TimerQueue,frameCallbacks=new FrameCallbackQueue,seeker=new AnimationSeeker;function timerCallback(handler,args){return typeof handler=="function"?()=>{handler(...args)}:()=>{recordFailure(failure("PAGE_ERROR","setTimeout/setInterval with a string of code is not supported"))}}const NativeDate=Date;globalThis.Date=createFrozenDate(NativeDate,()=>filmMs),Object.defineProperty(NativeDate.prototype,"constructor",{value:globalThis.Date,writable:!0,configurable:!0}),Object.defineProperty(performance,"now",{configurable:!0,writable:!0,value:()=>filmMs}),host.setTimeout=(handler,timeout,...args)=>timers.add(timerCallback(handler,args),timeout??0,!1,scheduleBase()),host.setInterval=(handler,timeout,...args)=>timers.add(timerCallback(handler,args),timeout??0,!0,scheduleBase()),host.clearTimeout=id=>timers.clear(id),host.clearInterval=id=>timers.clear(id),host.requestAnimationFrame=callback=>frameCallbacks.request(callback),host.cancelAnimationFrame=id=>frameCallbacks.cancel(id);let nextRandom=mulberry32Sequence(MATH_RANDOM_SEED);Math.random=()=>nextRandom();let observersInUse=!1;const NativeResizeObserver=globalThis.ResizeObserver;globalThis.ResizeObserver=class extends NativeResizeObserver{constructor(callback){super(callback),observersInUse=!0}};const NativeIntersectionObserver=globalThis.IntersectionObserver;globalThis.IntersectionObserver=class extends NativeIntersectionObserver{constructor(callback,options){super(callback,options),observersInUse=!0}};let domChanged=!1;const domWatcher=new MutationObserver(()=>{domChanged=!0});domWatcher.observe(document,{subtree:!0,childList:!0,attributes:!0,characterData:!0});function forgetDomChanges(){domWatcher.takeRecords(),domChanged=!1}function domChangedSinceForget(){return domWatcher.takeRecords().length>0||domChanged}host.fetch=(input,init)=>{const hit=findStaticText(requestUrl(input),host.__XPLAINER_STATIC__,url=>new URL(url,host.location.href).href);return hit===void 0?native.fetch(input,init):Promise.resolve(new Response(hit.text,{status:200,headers:{"content-type":contentTypeFor(hit.name)}}))},host.addEventListener("error",event=>{recordFailure(describeFailure(event.error??event.message))}),host.addEventListener("unhandledrejection",event=>{recordFailure(describeFailure(event.reason))});function throwIfFailed(){const first=recordedFailures()[0];if(first!==void 0)throw new Error(first)}function failWith(message){return recordFailure(message),new Error(recordedFailures()[0]??message)}let currentFrame=0;const frameListeners=new Set,frameStore={subscribe(listener){return frameListeners.add(listener),()=>{frameListeners.delete(listener)}},read(){return currentFrame}};function publishFrame(frame){currentFrame=frame;for(const listener of frameListeners)listener()}function HarnessApp({config,children}){const frame=useSyncExternalStore(frameStore.subscribe,frameStore.read,frameStore.read);return createElement(VideoProvider,{config,frame},children)}function seekAnimations(){seeker.seek(document.getAnimations(),filmMs,document.timeline)}function budget(value,fallback){return typeof value=="number"&&Number.isFinite(value)&&value>0?value:fallback}function nativeSleep(ms){return new Promise(resolve=>{native.setTimeout(resolve,ms)})}function settleTick(){return new Promise(resolve=>{const task=then=>{const channel=new MessageChannel;channel.port1.onmessage=()=>{channel.port1.close(),then()},channel.port2.postMessage(0)},twoTasks=()=>{task(()=>{task(resolve)})};if(observersInUse){native.requestAnimationFrame(twoTasks);return}document.documentElement.getBoundingClientRect(),twoTasks()})}const holdSeenAt=new Map;async function settleHolds(){if(renderHolds().size===0)return;const startedAt=native.now(),createdDuringWait=timers.nextId;holdWaitStartedAt=startedAt;try{for(;;){const holds=renderHolds();if(holds.size===0){holdSeenAt.clear();return}const now=native.now(),fallback=budget(host.__XPLAINER_RENDER__?.delayRenderTimeoutMs,DEFAULT_DELAY_RENDER_TIMEOUT_MS);for(const[handle,hold]of holds){const seen=holdSeenAt.get(handle)??now;if(holdSeenAt.set(handle,seen),now-seen>(hold.timeoutMs??fallback)){const labels=[...holds.values()].map(held=>held.label).join(", ");throw failWith(failure("DELAY_RENDER_TIMEOUT",`still held: ${labels}`))}}const waitedTo=filmMs+(now-startedAt),due=timers.nextDue(releasedAtTimerId);(due!==void 0&&due<=waitedTo||frameCallbacks.pending(releasedAtFrameCallbackId)>0)&&flushSync(()=>{timers.runDue(waitedTo,run,releasedAtTimerId),frameCallbacks.drain(filmMs,run,releasedAtFrameCallbackId)}),throwIfFailed(),await nativeSleep(HOLD_POLL_MS)}}finally{holdWaitStartedAt=void 0,timers.rebase(createdDuringWait,filmMs)}}function imageSource(image){return image.currentSrc||image.src||"(an <img> with no src)"}function brokenImage(){return Array.from(document.images).find(image=>image.complete&&image.naturalWidth===0)}function pendingImages(){return Array.from(document.images).filter(image=>!image.complete).map(imageSource)}const decodedSources=new Map;async function settleImages(frame){const startedAt=native.now(),limit=budget(host.__XPLAINER_RENDER__?.imageTimeoutMs,DEFAULT_IMAGE_TIMEOUT_MS);for(const image of Array.from(document.images)){for(;!image.complete;){if(native.now()-startedAt>limit)throw failWith(failure("IMAGE_TIMEOUT",`${imageSource(image)} at frame ${frame}`));await nativeSleep(IMAGE_POLL_MS)}const source=imageSource(image);if(image.naturalWidth===0)throw failWith(failure("IMAGE_BROKEN",`${source} at frame ${frame}`));let decoded=decodedSources.get(source);if(decoded===void 0&&(decoded=image.decode().then(()=>!0,()=>!1),decodedSources.set(source,decoded)),!await decoded)throw failWith(failure("IMAGE_BROKEN",`${source} at frame ${frame}`))}}async function settle(frame){for(let pass=1;;pass+=1){forgetDomChanges(),await settleHolds(),await settleImages(frame),await document.fonts.ready,await settleTick(),seekAnimations(),throwIfFailed();const holds=renderHolds(),images=pendingImages(),broken=brokenImage();if(broken!==void 0)throw failWith(failure("IMAGE_BROKEN",`${imageSource(broken)} at frame ${frame}`));const changed=domChangedSinceForget();if(holds.size===0&&images.length===0&&!changed&&document.fonts.status==="loaded")return;if(pass===MAX_SETTLE_PASSES){if(holds.size>0)throw failWith(failure("DELAY_RENDER_TIMEOUT",`still held: ${[...holds.values()].map(hold=>hold.label).join(", ")} (taken again on each of ${MAX_SETTLE_PASSES} settle passes)`));if(images.length>0)throw failWith(failure("IMAGE_TIMEOUT",`${images[0]??""} at frame ${frame}`));const reasons=[changed?"the DOM changed on the last pass":"",document.fonts.status==="loaded"?"":"a font is still loading"].filter(reason=>reason!=="");console.warn(`frame ${frame} was still changing after ${MAX_SETTLE_PASSES} settle passes (${reasons.join(", ")}), so it is released as it stands.`);return}}}class Deferred{promise;resolve=()=>{};reject=()=>{};constructor(){this.promise=new Promise((resolve,reject)=>{this.resolve=resolve,this.reject=reject}),this.promise.catch(()=>{})}}const metadataReady=new Deferred,mounted=new Deferred;let video,metadataDone=!1,firstSettleDone=!1,busy=!1;function rootContainer(){const existing=document.getElementById("root");if(existing!==null)return existing;const created=document.createElement("div");return created.id="root",document.body.append(created),created}function onReactError(error){recordFailure(describeFailure(error))}function collectComposition(Root){const found=new Map,collect=registration=>{found.set(registration.id,registration)},probe=createRoot(document.createElement("div"),{onUncaughtError:onReactError,onCaughtError:onReactError});flushSync(()=>{probe.render(createElement(CompositionCollectorContext.Provider,{value:collect},createElement(Root)))}),probe.unmount(),throwIfFailed();const registrations=[...found.values()],[only]=registrations;if(only===void 0)throw new Error("registerRoot(): the root component rendered no <Composition>, so there is no video to render.");if(registrations.length>1)throw new Error(`registerRoot(): the root declares ${registrations.length} compositions (${[...found.keys()].join(", ")}); the xplainer renderer renders exactly one.`);return only}function mount(resolved){nextRandom=mulberry32Sequence(MATH_RANDOM_SEED);const{width,height}=resolved.config,container=rootContainer();container.style.cssText=`position:relative;width:${width}px;height:${height}px;overflow:hidden;`;const root=createRoot(container,{onUncaughtError:onReactError,onCaughtError:onReactError});flushSync(()=>{root.render(createElement(HarnessApp,{config:resolved.config},resolved.render()))}),seekAnimations()}async function boot(entry){await Promise.resolve();try{const resolved=await(entry.kind==="video"?entry.registration:collectComposition(entry.Root)).resolve(new AbortController().signal);video=resolved.config,metadataDone=!0,metadataReady.resolve({fps:video.fps,durationInFrames:video.durationInFrames,width:video.width,height:video.height}),mount(resolved),throwIfFailed(),await settle(0),markReleased(),firstSettleDone=!0,mounted.resolve()}catch(error){const reason=failWith(describeFailure(error));metadataReady.reject(reason),mounted.reject(reason)}}onRegistration(entry=>{boot(entry)});async function setFrame(frame){await mounted.promise,throwIfFailed();const config=video;if(config===void 0)throw new Error("setFrame: the video has not mounted.");if(!Number.isInteger(frame)||frame<0||frame>=config.durationInFrames)throw new RangeError(`setFrame(${String(frame)}): the frame must be a whole number from 0 to ${config.durationInFrames-1}.`);if(busy)throw new Error(`setFrame(${frame}) was called while frame ${currentFrame} was still settling; await each setFrame before the next.`);busy=!0;try{filmMs=frameToFilmMs(frame,config.fps),flushSync(()=>{timers.runDue(filmMs,run),frameCallbacks.drain(filmMs,run),publishFrame(frame)}),seekAnimations(),throwIfFailed(),await settle(frame),throwIfFailed(),markReleased()}catch(error){throw failWith(describeFailure(error))}finally{busy=!1}}function readiness(){const pending=[];metadataDone||pending.push("metadata"),firstSettleDone||pending.push("first-settle");for(const hold of renderHolds().values())pending.push(`delayRender:${hold.label}`);for(const source of pendingImages())pending.push(`img:${source}`);document.fonts.status!=="loaded"&&pending.push("fonts");const failed=[...recordedFailures()];return{ready:pending.length===0&&failed.length===0,pending,failed}}host.__xplainer={metadata:()=>metadataReady.promise,setFrame,readiness,get frame(){return currentFrame}};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `useCurrentFrame()` and `useVideoConfig()` — the two reads every scene makes.
3
+ *
4
+ * Both throw outside a video rather than returning a default. A component rendered outside the
5
+ * tree the harness mounts would otherwise animate as if it were permanently at frame 0, which is
6
+ * a bug that looks exactly like a design choice.
7
+ */
8
+ import { type Timeline, type VideoConfig } from "./context.js";
9
+ /** The timeline this component sees; shared by the hooks and the timing components. */
10
+ export declare function useTimeline(caller: string): Timeline;
11
+ /**
12
+ * The current frame, relative to the nearest enclosing `Sequence` (0 at its `from`), wrapped by
13
+ * the nearest `Loop` and pinned by the nearest active `Freeze`.
14
+ */
15
+ export declare function useCurrentFrame(): number;
16
+ /**
17
+ * The video's size, fps, duration and props. Inside a `Sequence`, `durationInFrames` is how long
18
+ * that sequence can be on screen — its own duration clipped to what is left of its parent's window,
19
+ * open or not — and inside a `Loop`, one iteration's.
20
+ */
21
+ export declare function useVideoConfig(): VideoConfig;
package/dist/hooks.js ADDED
@@ -0,0 +1 @@
1
+ import{useContext}from"react";import{TimelineContext,VideoConfigContext}from"./context.js";function outside(hook){return new Error(`${hook} was called outside a video. Call it from a component rendered under the <Composition> (or registerVideo) component.`)}export function useTimeline(caller){const timeline=useContext(TimelineContext);if(timeline===null)throw outside(caller);return timeline}export function useCurrentFrame(){return useTimeline("useCurrentFrame()").frame}export function useVideoConfig(){const video=useContext(VideoConfigContext),timeline=useContext(TimelineContext);if(video===null||timeline===null)throw outside("useVideoConfig()");return video.durationInFrames===timeline.durationInFrames?video:{...video,durationInFrames:timeline.durationInFrames}}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@xplainer/motion` — the authoring API explainer scenes are written against.
3
+ *
4
+ * Side-effect free and importable in Node with no DOM: nothing here touches `window` until a
5
+ * component renders. The page-side half — the frozen clock, the film-time timers and the
6
+ * `window.__xplainer` driver the renderer calls — is `@xplainer/motion/harness`, a separate entry
7
+ * point with side effects, which only the renderer's generated bundle entry imports.
8
+ */
9
+ export { type Caption, type CreateTikTokStyleCaptionsInput, createTikTokStyleCaptions, type PageableCaption, type PaginateCaptionsOptions, paginateCaptions, type TikTokPage, type TikTokToken, } from "./captions.js";
10
+ export { AbsoluteFill, type AbsoluteFillProps, Audio, type AudioProps, Freeze, type FreezeProps, Img, type ImgProps, Loop, type LoopProps, OffthreadVideo, Sequence, type SequenceLayout, type SequenceProps, Series, type SeriesProps, type SeriesSequenceProps, Video, type VideoProps, } from "./components.js";
11
+ export { type CalculateMetadataFunction, type CalculateMetadataOptions, type CalculateMetadataResult, Composition, type CompositionProps, type RegisterVideoOptions, registerRoot, registerVideo, } from "./composition.js";
12
+ export type { VideoConfig } from "./context.js";
13
+ export { Easing, type EasingFunction, type EasingModule } from "./easing.js";
14
+ export { useCurrentFrame, useVideoConfig } from "./hooks.js";
15
+ export { type ExtrapolateType, type InterpolateOptions, interpolate } from "./interpolate.js";
16
+ export { random } from "./random.js";
17
+ export { cancelRender, continueRender, type DelayRenderOptions, delayRender } from "./runtime.js";
18
+ export { type MeasureSpringOptions, measureSpring, type SpringConfig, type SpringOptions, spring, } from "./spring.js";
19
+ export { staticFile } from "./static-file.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export{createTikTokStyleCaptions,paginateCaptions}from"./captions.js";export{AbsoluteFill,Audio,Freeze,Img,Loop,OffthreadVideo,Sequence,Series,Video}from"./components.js";export{Composition,registerRoot,registerVideo}from"./composition.js";export{Easing}from"./easing.js";export{useCurrentFrame,useVideoConfig}from"./hooks.js";export{interpolate}from"./interpolate.js";export{random}from"./random.js";export{cancelRender,continueRender,delayRender}from"./runtime.js";export{measureSpring,spring}from"./spring.js";export{staticFile}from"./static-file.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `interpolate()` — map a frame (or any number) through a piecewise-linear table.
3
+ *
4
+ * The one function nearly every scene calls, so its edges are decided here rather than left to
5
+ * whatever the arithmetic happens to do:
6
+ *
7
+ * - **The table is validated, loudly.** `inputRange` must be strictly increasing and finite, and
8
+ * the two ranges must be the same length, at least two. A scene with `[0, 30, 30, 60]` gets an
9
+ * `Error` naming the problem on its first frame, instead of a `NaN` that renders as an element
10
+ * silently missing from the picture.
11
+ * - **The easing applies per segment, to the normalised position inside that segment.** A
12
+ * three-point table with `Easing.quad` eases into each of its two breakpoints, not once across
13
+ * the whole range. **An input exactly on a breakpoint returns that breakpoint's output**, to the
14
+ * last bit and whatever the easing does at a segment's ends — `Easing.exp` starts at `2⁻¹⁰`, not
15
+ * 0, and `Easing.sin` ends one ulp short of 1 — so a table always passes through its points. An
16
+ * interior breakpoint belongs to the segment ending there.
17
+ * - **Each side extrapolates independently.** `extend` (the default) continues the outer segment's
18
+ * formula, easing included; `clamp` holds the endpoint value; `identity` returns the input
19
+ * unchanged; `wrap` folds the input back into the whole input range, so a three-segment table
20
+ * loops as a unit.
21
+ *
22
+ * **`wrap`, precisely.** With `first = inputRange[0]`, `last` the final entry and
23
+ * `span = last − first`, an input beyond the end whose side says `wrap` is replaced by
24
+ * `first + ((input − first) mod span)`, the modulus taken non-negative, and the table is then read
25
+ * at that point, easing and all. The period is the **whole** table, not the outer segment, and the
26
+ * fold is a sawtooth, not a mirror: the result lies in `[first, last)`, so the input runs forward
27
+ * through the table again on every period. An input exactly at `first` or `last` is inside the
28
+ * range and is not folded — `last` reads the last output — while one a hair past `last` folds to a
29
+ * hair past `first` and reads close to the **first** output. So unless the first and last outputs
30
+ * are equal, a wrapped table jumps at each period boundary rather than looping smoothly; a loop
31
+ * that must be continuous ends its table on the value it starts with. A whole number of periods
32
+ * past either end (`last + span`, `first − span`, …) folds onto `first` and reads the first output.
33
+ */
34
+ import type { EasingFunction } from "./easing.js";
35
+ /** What happens to an input beyond one end of `inputRange`. */
36
+ export type ExtrapolateType = "extend" | "clamp" | "identity" | "wrap";
37
+ export type InterpolateOptions = {
38
+ /** Applied to the normalised position within each segment. Defaults to linear. */
39
+ easing?: EasingFunction;
40
+ /** Below `inputRange[0]`. Defaults to `"extend"`. */
41
+ extrapolateLeft?: ExtrapolateType;
42
+ /** Above the last `inputRange` entry. Defaults to `"extend"`. */
43
+ extrapolateRight?: ExtrapolateType;
44
+ };
45
+ export declare function interpolate(input: number, inputRange: readonly number[], outputRange: readonly number[], options?: InterpolateOptions): number;
@@ -0,0 +1 @@
1
+ const EXTRAPOLATIONS=new Set(["extend","clamp","identity","wrap"]);function assertRanges(inputRange,outputRange){if(!Array.isArray(inputRange)||!Array.isArray(outputRange))throw new TypeError("interpolate: inputRange and outputRange must be arrays of numbers.");if(inputRange.length<2)throw new RangeError(`interpolate: inputRange needs at least 2 entries, got ${inputRange.length}.`);if(inputRange.length!==outputRange.length)throw new RangeError(`interpolate: inputRange has ${inputRange.length} entries and outputRange has ${outputRange.length}; they must be the same length.`);for(const[name,range]of[["inputRange",inputRange],["outputRange",outputRange]])for(const[index,value]of range.entries())if(typeof value!="number"||!Number.isFinite(value))throw new TypeError(`interpolate: ${name}[${index}] must be a finite number, got ${String(value)}.`);for(let index=1;index<inputRange.length;index+=1){const previous=inputRange[index-1]??0,current=inputRange[index]??0;if(current<=previous)throw new RangeError(`interpolate: inputRange must be strictly increasing, but [${index-1}]=${previous} and [${index}]=${current}.`)}}function assertExtrapolation(side,value){if(!EXTRAPOLATIONS.has(value))throw new RangeError(`interpolate: ${side} must be "extend", "clamp", "identity" or "wrap", got ${JSON.stringify(value)}.`)}function wrapInto(value,low,high){const span=high-low;return low+((value-low)%span+span)%span}export function interpolate(input,inputRange,outputRange,options={}){if(typeof input!="number"||Number.isNaN(input))throw new TypeError(`interpolate: input must be a number, got ${String(input)}.`);assertRanges(inputRange,outputRange);const{easing=t2=>t2,extrapolateLeft="extend",extrapolateRight="extend"}=options;assertExtrapolation("extrapolateLeft",extrapolateLeft),assertExtrapolation("extrapolateRight",extrapolateRight);const last=inputRange.length-1,first=inputRange[0]??0,end=inputRange[last]??0;let x=input;if(x<first){if(extrapolateLeft==="identity")return x;if(extrapolateLeft==="clamp")return outputRange[0]??0;extrapolateLeft==="wrap"&&(x=wrapInto(x,first,end))}else if(x>end){if(extrapolateRight==="identity")return x;if(extrapolateRight==="clamp")return outputRange[last]??0;extrapolateRight==="wrap"&&(x=wrapInto(x,first,end))}let segment=0;for(;segment<last-1&&x>(inputRange[segment+1]??0);)segment+=1;const inMin=inputRange[segment]??0,inMax=inputRange[segment+1]??0,outMin=outputRange[segment]??0,outMax=outputRange[segment+1]??0;if(outMin===outMax)return outMin;const t=(x-inMin)/(inMax-inMin);return t===1?outMax:t===0?outMin:outMin+easing(t)*(outMax-outMin)}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The provider every video renders under: the resolved config, and the absolute frame as the
3
+ * root timeline.
4
+ *
5
+ * The harness mounts it around the registered component, and `src/testing/` mounts the same one
6
+ * around a component under test — so the tests exercise the provider the page uses, not a copy.
7
+ */
8
+ import type { ReactElement, ReactNode } from "react";
9
+ import { type VideoConfig } from "./context.js";
10
+ export type VideoProviderProps = {
11
+ config: VideoConfig;
12
+ frame: number;
13
+ children?: ReactNode;
14
+ };
15
+ export declare function VideoProvider({ config, frame, children }: VideoProviderProps): ReactElement;
@@ -0,0 +1 @@
1
+ import{jsx as _jsx}from"react/jsx-runtime";import{TimelineContext,VideoConfigContext}from"./context.js";export function VideoProvider({config,frame,children}){const timeline={frame,durationInFrames:config.durationInFrames};return _jsx(VideoConfigContext.Provider,{value:config,children:_jsx(TimelineContext.Provider,{value:timeline,children})})}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `random(seed)` — a deterministic number in `[0, 1)` per seed.
3
+ *
4
+ * A scene that scatters particles with `Math.random()` draws a different picture on every frame:
5
+ * a render seeds `Math.random` once, so every run draws the same sequence, but each frame's render
6
+ * takes the next values from it and the particles jump. Keyed by a seed, the same particle lands
7
+ * in the same place on every frame, in every process.
8
+ *
9
+ * The seed is hashed with 32-bit FNV-1a over its UTF-16 code units (a number seed is hashed as
10
+ * its decimal string, so `random(7)` equals `random("7")`) and the hash seeds one step of
11
+ * mulberry32. Both are public-domain, well-studied and a few lines long. The values are stable
12
+ * across releases of this package but are **not** the values any other library returns for the
13
+ * same seed: a scene ported from elsewhere gets a different, equally deterministic, scatter.
14
+ *
15
+ * `null` opts out of the seed and returns `Math.random()`: in a render, the next value of the
16
+ * sequence the harness seeds, which differs from frame to frame but not from run to run.
17
+ */
18
+ export declare function random(seed: number | string | null): number;
19
+ /**
20
+ * mulberry32 as a stream from `seed`: call `n` returns the step from `seed + n · 0x6d2b79f5`, which
21
+ * is mulberry32's own sequence. Not exported from the package entry — `harness.ts` installs it as
22
+ * the page's `Math.random`.
23
+ */
24
+ export declare function mulberry32Sequence(seed: number): () => number;
package/dist/random.js ADDED
@@ -0,0 +1 @@
1
+ const FNV_OFFSET=2166136261,FNV_PRIME=16777619;function fnv1a(text){let hash=2166136261;for(let index=0;index<text.length;index+=1)hash^=text.charCodeAt(index),hash=Math.imul(hash,16777619);return hash>>>0}function mulberry32(state){let t=state+1831565813|0;return t=Math.imul(t^t>>>15,t|1),t^=t+Math.imul(t^t>>>7,t|61),((t^t>>>14)>>>0)/4294967296}export function random(seed){if(seed===null)return Math.random();if(typeof seed=="number"){if(!Number.isFinite(seed))throw new TypeError(`random: a number seed must be finite, got ${String(seed)}.`);return mulberry32(fnv1a(String(seed)))}if(typeof seed=="string")return mulberry32(fnv1a(seed));throw new TypeError(`random: the seed must be a number, a string or null, got ${typeof seed}. Pass null to draw from Math.random() instead.`)}export function mulberry32Sequence(seed){let state=seed|0;return()=>{const value=mulberry32(state);return state=state+1831565813|0,value}}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The state a scene shares with the page harness: the registered video, the render holds, the
3
+ * recorded failures and the mounted `<Audio>` count.
4
+ *
5
+ * **Why it lives here and not in the harness.** Scenes import `@xplainer/motion` and the bundle's
6
+ * entry imports `@xplainer/motion/harness`; both resolve into this package's `dist/`, so this
7
+ * module is evaluated once and both sides hold the same maps. The harness reads and drives it;
8
+ * everything a scene calls (`delayRender`, `cancelRender`, `registerRoot`) only writes to it. That
9
+ * split is what keeps `src/index.ts` free of side effects and importable in Node with no DOM: in a
10
+ * test, `delayRender()` simply records a hold that nothing ever waits on.
11
+ *
12
+ * Nothing in this file is part of the public surface except the three render-hold functions, which
13
+ * `src/index.ts` re-exports.
14
+ */
15
+ import type { ComponentType } from "react";
16
+ import type { VideoRegistration } from "./context.js";
17
+ /** What `registerRoot()` or `registerVideo()` handed over. */
18
+ export type RegisteredEntry = {
19
+ kind: "root";
20
+ Root: ComponentType;
21
+ } | {
22
+ kind: "video";
23
+ registration: VideoRegistration;
24
+ };
25
+ /** One outstanding `delayRender()`. */
26
+ export type RenderHold = {
27
+ label: string;
28
+ /** Per-hold override of the harness's budget, from `timeoutInMilliseconds`. */
29
+ timeoutMs: number | undefined;
30
+ };
31
+ export type DelayRenderOptions = {
32
+ /** How long the renderer waits for this hold before failing the frame. Default 30 000. */
33
+ timeoutInMilliseconds?: number;
34
+ };
35
+ /**
36
+ * Record the one video this bundle renders, and hand it to the harness if one is listening.
37
+ * A second call is refused: a bundle renders exactly one video, and silently keeping either would
38
+ * render something other than what one of the two calls asked for.
39
+ */
40
+ export declare function registerEntry(entry: RegisteredEntry): void;
41
+ /** The harness's subscription. Called at once if a video registered before it subscribed. */
42
+ export declare function onRegistration(listener: (entry: RegisteredEntry) => void): void;
43
+ /** The registered entry, for tests and for a harness that subscribes late. */
44
+ export declare function registeredEntry(): RegisteredEntry | undefined;
45
+ /**
46
+ * Hold the current frame open until `continueRender(handle)`.
47
+ *
48
+ * The renderer will not capture while any hold is outstanding, so a scene can wait for something
49
+ * it fetched or decoded itself. The `label` is what a timeout names, so give one that says what
50
+ * is being waited for.
51
+ */
52
+ export declare function delayRender(label?: string, options?: DelayRenderOptions): number;
53
+ /**
54
+ * Release a hold. Releasing one that is no longer held is a no-op, so an effect cleanup and a
55
+ * load handler may both release the same handle.
56
+ */
57
+ export declare function continueRender(handle: number): void;
58
+ /** Fail the render on purpose. The renderer stops and reports `PAGE_ERROR: cancelRender: …`. */
59
+ export declare function cancelRender(error: unknown): void;
60
+ /** The outstanding holds, keyed by handle. */
61
+ export declare function renderHolds(): ReadonlyMap<number, RenderHold>;
62
+ /** Record a failure, already prefixed with its code. A repeated message is kept once. */
63
+ export declare function recordFailure(message: string): string;
64
+ /** Every failure recorded so far, in order. */
65
+ export declare function recordedFailures(): readonly string[];
66
+ /**
67
+ * Count one mounted `<Audio>` and return its unmount. A second concurrent mount throws
68
+ * `NOT_SUPPORTED_IN_V1`, and is not counted.
69
+ *
70
+ * The renderer muxes exactly one track — the narration the engine's own `Video.tsx` mounts — so any
71
+ * further `<Audio>` would be absent from the output with nothing to say so. That is refused by
72
+ * name, the way `<Video>` is, rather than warned about in a job log nobody reads.
73
+ */
74
+ export declare function mountAudio(): () => void;
@@ -0,0 +1 @@
1
+ let registered,registrationListener;const holds=new Map;let nextHandle=1;const failures=[];let audioMounts=0;export function registerEntry(entry){if(registered!==void 0)throw new Error("registerRoot()/registerVideo() was called twice. A bundle renders exactly one video, so call one of them once, from the video's index.ts.");registered=entry,registrationListener?.(entry)}export function onRegistration(listener){registrationListener=listener,registered!==void 0&&listener(registered)}export function registeredEntry(){return registered}export function delayRender(label,options){const timeoutMs=options?.timeoutInMilliseconds;if(timeoutMs!==void 0&&!(Number.isFinite(timeoutMs)&&timeoutMs>0))throw new RangeError(`delayRender: timeoutInMilliseconds must be a positive number, got ${String(timeoutMs)}.`);const handle=nextHandle;return nextHandle+=1,holds.set(handle,{label:label===void 0||label===""?`unlabelled #${handle}`:label,timeoutMs}),handle}export function continueRender(handle){if(typeof handle!="number")throw new TypeError(`continueRender: pass the number delayRender() returned, got ${typeof handle}.`);holds.delete(handle)}export function cancelRender(error){const message=error instanceof Error?error.message:String(error);recordFailure(`PAGE_ERROR: cancelRender: ${message}`)}export function renderHolds(){return holds}export function recordFailure(message){return failures.includes(message)||failures.push(message),message}export function recordedFailures(){return failures}const SECOND_AUDIO_NOT_SUPPORTED="NOT_SUPPORTED_IN_V1: a second <Audio> is mounted, and the narration is the only audio track the xplainer renderer puts in the video; any other <Audio> would be silently missing from the MP4, so remove it";export function mountAudio(){if(audioMounts>0)throw new Error(SECOND_AUDIO_NOT_SUPPORTED);return audioMounts+=1,()=>{audioMounts-=1}}