@xplainer/motion 0.1.0 → 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,40 @@
1
+ /**
2
+ * Web Animations on film time.
3
+ *
4
+ * CSS animations, CSS transitions and `element.animate()` all run on the document timeline, which
5
+ * is wall time and is not something the harness can replace. So instead of replacing the clock,
6
+ * the harness pauses every animation on the document timeline after each frame commits and sets
7
+ * its `currentTime` itself: film time now, minus the film time at which the harness first saw that
8
+ * animation. An animation that starts when its element mounts at frame 40 is therefore at 0 ms on
9
+ * frame 40 and at 1000 ms on frame 70 at 30 fps, however long the capture of the frames between
10
+ * took.
11
+ *
12
+ * **The start is when the harness first saw the animation, so the order of `setFrame` calls
13
+ * matters.** A video rendered front to back sees every animation start at the frame its element
14
+ * mounted. A page asked for frame 70 alone, of an element that mounts at frame 40, has no earlier
15
+ * frame to have seen it on, and shows it at 0 ms — which is why the renderer steps a still's page
16
+ * through every frame before the one it captures, at the cost of setting each of them. A scene
17
+ * whose animation is `interpolate()` on `useCurrentFrame()` is a pure function of the frame and
18
+ * needs none of this.
19
+ *
20
+ * Scroll- and view-driven animations (`animation-timeline: scroll()` / `view()`) are not on the
21
+ * document timeline and are not touched: their progress is the scroll position, not time.
22
+ */
23
+ /** The part of `Animation` the seeker touches, so Node tests can hand it plain objects. */
24
+ export type SeekableAnimation = {
25
+ pause(): void;
26
+ currentTime: CSSNumberish | null;
27
+ /** Compared by identity with the document's own timeline; nothing else is read from it. */
28
+ readonly timeline: unknown;
29
+ };
30
+ export declare class AnimationSeeker {
31
+ #private;
32
+ /**
33
+ * Pause and seek each animation on `timeline` — the document's — to film time; returns how many
34
+ * were seeked. Any other animation is left alone: one driven by a scroll or view timeline
35
+ * (`animation-timeline: scroll()`) measures its time in percentages of a scroll range, not in
36
+ * milliseconds, and setting a number of milliseconds on it throws. Its progress follows the
37
+ * scroll position, which a frame's own render already decides.
38
+ */
39
+ seek(animations: Iterable<SeekableAnimation>, filmMs: number, timeline: unknown): number;
40
+ }
@@ -0,0 +1 @@
1
+ export class AnimationSeeker{#firstSeen=new WeakMap;seek(animations,filmMs,timeline){let count=0;for(const animation of animations){if(animation.timeline!==timeline)continue;let start=this.#firstSeen.get(animation);start===void 0&&(start=filmMs,this.#firstSeen.set(animation,start)),animation.pause(),animation.currentTime=filmMs-start,count+=1}return count}}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Caption paging: word-level `captions.json` into the pages the caption track shows one at a time.
3
+ *
4
+ * **The rule, which `packages/render-core` already writes `captions.json` against.** Its
5
+ * `buildCaptions()` gives every word but the first of the whole track a leading space and never
6
+ * gives one to folded punctuation (`packages/render-core/src/narrate/captions.ts`). The pager is
7
+ * the other half of that agreement:
8
+ *
9
+ * 1. Captions are taken in order. The first one opens the first page.
10
+ * 2. A caption **opens a new page only if its `text` begins with whitespace** and at least
11
+ * `combineWithinMs` have elapsed from the current page's start to the caption's `startMs`. The
12
+ * window is half-open: a caption exactly `combineWithinMs` after the page start opens the next
13
+ * page. A caption with no leading space never opens a page however late it is — it is the tail
14
+ * of the word before it, and splitting there would strand it.
15
+ * 3. The caption that opens a page has its leading whitespace trimmed, in the page `text` and in
16
+ * its token, so no page begins with a space. Every other caption is appended verbatim: page
17
+ * `text` is the plain concatenation of its tokens' `text`.
18
+ * 4. A page lasts until the next page starts; the last page lasts until the latest `endMs` among
19
+ * its own tokens. So consecutive pages tile the time between them with no gap for the caption
20
+ * band to flicker through, and the track ends when the speech does.
21
+ *
22
+ * Grouping is by elapsed time only; nothing here knows where a sentence or a narration segment
23
+ * ends. The scaffold's `Captions.tsx` gets sentence-aware pages by calling this once per sentence,
24
+ * which is why this function stays this small.
25
+ *
26
+ * Pure: no clock, no frame, no order dependence, so a frame rendered out of order or a still taken
27
+ * alone pages exactly as the full render does.
28
+ */
29
+ /** One word of `captions.json`, in the shape `packages/protocol/schemas/captions.json` defines. */
30
+ export type Caption = {
31
+ text: string;
32
+ startMs: number;
33
+ endMs: number;
34
+ timestampMs: number | null;
35
+ confidence: number | null;
36
+ };
37
+ /** One caption inside a page, with its own span so the spoken word can be highlighted. */
38
+ export type TikTokToken = {
39
+ text: string;
40
+ fromMs: number;
41
+ toMs: number;
42
+ };
43
+ /** One page of caption text and the tokens it was built from. */
44
+ export type TikTokPage = {
45
+ text: string;
46
+ startMs: number;
47
+ durationMs: number;
48
+ tokens: TikTokToken[];
49
+ };
50
+ /** The fields of a caption the pager reads. Every `Caption` is one. */
51
+ export type PageableCaption = Pick<Caption, "text" | "startMs" | "endMs">;
52
+ export type PaginateCaptionsOptions = {
53
+ /** A caption starting this long after its page's start (or later) may open the next page. */
54
+ combineWithinMs: number;
55
+ };
56
+ export declare function paginateCaptions(captions: readonly PageableCaption[], options: PaginateCaptionsOptions): TikTokPage[];
57
+ export type CreateTikTokStyleCaptionsInput = {
58
+ captions: readonly PageableCaption[];
59
+ combineTokensWithinMilliseconds: number;
60
+ };
61
+ /**
62
+ * `paginateCaptions` under its older name and argument shape.
63
+ *
64
+ * It exists so scenes written before this package — which imported the pager from a
65
+ * captions-specific module under this name — keep compiling through the renderer's import alias
66
+ * for one release. New code should call `paginateCaptions`.
67
+ */
68
+ export declare function createTikTokStyleCaptions(input: CreateTikTokStyleCaptionsInput): {
69
+ pages: TikTokPage[];
70
+ };
@@ -0,0 +1 @@
1
+ const LEADING_WHITESPACE=/^\s/u;export function paginateCaptions(captions,options){const{combineWithinMs}=options;if(!(Number.isFinite(combineWithinMs)&&combineWithinMs>=0))throw new RangeError(`paginateCaptions: combineWithinMs must be zero or a positive number, got ${String(combineWithinMs)}.`);const pages=[];let page;for(const caption of captions){if(page===void 0||LEADING_WHITESPACE.test(caption.text)&&caption.startMs-page.startMs>=combineWithinMs||page===void 0){const text=caption.text.trimStart();page={text,startMs:caption.startMs,durationMs:0,tokens:[{text,fromMs:caption.startMs,toMs:caption.endMs}]},pages.push(page);continue}page.text+=caption.text,page.tokens.push({text:caption.text,fromMs:caption.startMs,toMs:caption.endMs})}for(const[index,current]of pages.entries()){const next=pages[index+1],end=next===void 0?Math.max(...current.tokens.map(token=>token.toMs)):next.startMs;current.durationMs=Math.max(0,end-current.startMs)}return pages}export function createTikTokStyleCaptions(input){return{pages:paginateCaptions(input.captions,{combineWithinMs:input.combineTokensWithinMilliseconds})}}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Film time: the clock a captured page runs on instead of the wall clock.
3
+ *
4
+ * A browser animates against wall time — `Date.now()`, `performance.now()`, `setTimeout`,
5
+ * `requestAnimationFrame` — and a capture that takes 40 ms per frame would otherwise see a scene
6
+ * whose timers ran 40 ms per frame instead of 33. The harness replaces all four with **film
7
+ * time**: `frame / fps · 1000` milliseconds, which only moves when the renderer asks for a frame.
8
+ * These are the pieces of that replacement that need no `window`, so they are tested in Node; the
9
+ * harness installs them.
10
+ *
11
+ * Three rules, each of which the spike found by being wrong first:
12
+ *
13
+ * - **Due timers fire in due order, ties in creation order** — the same order a browser would have
14
+ * fired them in, given time.
15
+ * - **A timer created while the queue is draining waits for the next drain**, even with delay 0.
16
+ * Otherwise `setTimeout(tick, 0)` inside `tick` is an infinite loop inside one frame.
17
+ * - **An interval fires as many times as fit**, so a 10 ms interval fires three times across one
18
+ * 33 ms frame rather than once.
19
+ */
20
+ /** 2020-01-01T00:00:00Z: what `Date.now()` reads at frame 0, so dates are stable across renders. */
21
+ export declare const FILM_EPOCH_MS: number;
22
+ /** Film milliseconds at a frame. Multiplies first, so whole frames at integer fps stay exact. */
23
+ export declare function frameToFilmMs(frame: number, fps: number): number;
24
+ /**
25
+ * A `Date` that reads film time when asked for "now" and is the native `Date` otherwise.
26
+ *
27
+ * A `Proxy` rather than a subclass because `Date()` called without `new` must keep working (it
28
+ * returns a string), and `instanceof Date` must stay true for dates made either way; both fall out
29
+ * of forwarding everything but the three "now" paths to the native constructor.
30
+ */
31
+ export declare function createFrozenDate(NativeDate: DateConstructor, filmMs: () => number): DateConstructor;
32
+ /** `setTimeout` / `setInterval` against a clock somebody else advances. */
33
+ export declare class TimerQueue {
34
+ #private;
35
+ /**
36
+ * Schedule `callback` `delayMs` after `nowMs`. The delay is whatever the page passed, converted
37
+ * with `Number()` as a browser converts it — `setTimeout(fn, "100")` waits 100 ms — and one that
38
+ * converts to a non-finite or negative number is 0.
39
+ */
40
+ add(callback: () => void, delayMs: unknown, repeat: boolean, nowMs: number): number;
41
+ /**
42
+ * Reschedule every pending timer with an id of at least `fromId` as if it had been added at
43
+ * `nowMs`, keeping what has happened to it since: a one-shot timer becomes due its own delay
44
+ * after `nowMs`, and an interval that has fired `n` times becomes due where the same interval
45
+ * added at `nowMs` would be after `n` firings — the same phase, on the new origin. The due time is
46
+ * recomputed rather than shifted, so it is the same number to the last bit whatever it was
47
+ * scheduled from; a shift could land a timer due exactly on a frame one ulp past it. Returns how
48
+ * many it moved.
49
+ *
50
+ * This is how the harness takes the wall clock back out of a render-hold wait: a timer the wait
51
+ * created was scheduled from film time plus however long the wait had run, and once the hold is
52
+ * released it is rebased onto the frame's film instant, so when it fires no longer depends on how
53
+ * long the page took to release the hold.
54
+ */
55
+ rebase(fromId: number, nowMs: number): number;
56
+ /** Cancel a timer or an interval. Anything that is not a live id is ignored, as in a browser. */
57
+ clear(id: unknown): void;
58
+ /**
59
+ * The id the next timer will get. Every timer created from now on has an id at least this
60
+ * large, which is how a caller marks "timers created after this point" for `runDue`.
61
+ */
62
+ get nextId(): number;
63
+ /**
64
+ * The earliest due time among timers with an id of at least `fromId` (all of them by default),
65
+ * or `undefined` when there is none.
66
+ */
67
+ nextDue(fromId?: number): number | undefined;
68
+ get size(): number;
69
+ /**
70
+ * Fire every timer due at or before `targetMs`, earliest first, ties in creation order. Timers
71
+ * created while this runs are left for the next call, and timers with an id below `fromId` are
72
+ * left alone entirely. Returns how many fired. `invoke` is how a callback is called, so the
73
+ * caller decides what a throwing one means.
74
+ */
75
+ runDue(targetMs: number, invoke: (callback: () => void) => void, fromId?: number): number;
76
+ }
77
+ /** `requestAnimationFrame` as a queue the harness drains once per captured frame. */
78
+ export declare class FrameCallbackQueue {
79
+ #private;
80
+ request(callback: (timestamp: number) => void): number;
81
+ cancel(id: unknown): void;
82
+ get size(): number;
83
+ /** The id the next request will get; the same "requested after this point" mark as `TimerQueue`'s. */
84
+ get nextId(): number;
85
+ /** How many queued callbacks have an id of at least `fromId`. */
86
+ pending(fromId?: number): number;
87
+ /**
88
+ * Run every queued callback with an id of at least `fromId` (all of them by default) with
89
+ * `timestamp`, in request order, and leave the rest queued. A callback requested while this runs
90
+ * belongs to the next drain, and one cancelled by an earlier callback in the same drain does not
91
+ * run — both exactly as a browser runs a frame's callbacks.
92
+ */
93
+ drain(timestamp: number, invoke: (callback: () => void) => void, fromId?: number): number;
94
+ }
package/dist/clock.js ADDED
@@ -0,0 +1 @@
1
+ export const FILM_EPOCH_MS=Date.UTC(2020,0,1);export function frameToFilmMs(frame,fps){return frame*1e3/fps}export function createFrozenDate(NativeDate,filmMs){const now=()=>FILM_EPOCH_MS+Math.floor(filmMs());return new Proxy(NativeDate,{construct(target,args,newTarget){return Reflect.construct(target,args.length===0?[now()]:args,newTarget)},apply(){return new NativeDate(now()).toString()},get(target,property,receiver){return property==="now"?now:Reflect.get(target,property,receiver)}})}export class TimerQueue{#timers=new Map;#nextId=1;add(callback,delayMs,repeat,nowMs){const requested=Number(delayMs),delay=Number.isFinite(requested)&&requested>0?requested:0,id=this.#nextId;return this.#nextId+=1,this.#timers.set(id,{id,due:nowMs+delay,delay,ticks:0,callback,interval:repeat?Math.max(1,delay):null}),id}rebase(fromId,nowMs){let moved=0;for(const timer of this.#timers.values()){if(timer.id<fromId)continue;let due=nowMs+timer.delay;if(timer.interval!==null)for(let tick=0;tick<timer.ticks;tick+=1)due+=timer.interval;timer.due=due,moved+=1}return moved}clear(id){typeof id=="number"&&this.#timers.delete(id)}get nextId(){return this.#nextId}nextDue(fromId=1){let earliest;for(const timer of this.#timers.values())timer.id<fromId||(earliest===void 0||timer.due<earliest)&&(earliest=timer.due);return earliest}get size(){return this.#timers.size}runDue(targetMs,invoke,fromId=1){const watermark=this.#nextId;let fired=0;for(;;){let next;for(const timer of this.#timers.values())timer.id<fromId||timer.id>=watermark||timer.due>targetMs||(next===void 0||timer.due<next.due||timer.due===next.due&&timer.id<next.id)&&(next=timer);if(next===void 0)return fired;next.interval===null?this.#timers.delete(next.id):(next.due+=next.interval,next.ticks+=1),fired+=1,invoke(next.callback)}}}export class FrameCallbackQueue{#callbacks=new Map;#nextId=1;request(callback){const id=this.#nextId;return this.#nextId+=1,this.#callbacks.set(id,callback),id}cancel(id){typeof id=="number"&&this.#callbacks.delete(id)}get size(){return this.#callbacks.size}get nextId(){return this.#nextId}pending(fromId=1){let count=0;for(const id of this.#callbacks.keys())id>=fromId&&(count+=1);return count}drain(timestamp,invoke,fromId=1){const handles=[...this.#callbacks.keys()].filter(id=>id>=fromId);let ran=0;for(const id of handles){const callback=this.#callbacks.get(id);callback!==void 0&&(this.#callbacks.delete(id),ran+=1,invoke(()=>callback(timestamp)))}return ran}}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The layout and timing components a scene is built from.
3
+ *
4
+ * Timing is one rule applied at every level (see `context.ts`): a component reads the frame its
5
+ * parent sees, decides whether its children exist at that frame, and re-provides the frame
6
+ * relative to itself. Children outside their window are **not mounted**, rather than hidden, so a
7
+ * scene's effects and `delayRender` calls run only while it is on screen.
8
+ *
9
+ * Media is deliberately narrow in this version. `<Img>` is a plain `<img>` and the harness waits
10
+ * for every image in the document before a frame is captured. `<Audio>` is a marker: the renderer
11
+ * muxes the narration track itself, so the element only exists to keep the engine-owned
12
+ * `Video.tsx` declarative about it, and a second one mounted beside it throws
13
+ * `NOT_SUPPORTED_IN_V1`, because the narration is the only audio track the video gets. `<Video>`
14
+ * and `<OffthreadVideo>` throw `NOT_SUPPORTED_IN_V1` the moment they render, because a
15
+ * frame-accurate video decoder is not part of this renderer and a silently blank rectangle would be
16
+ * worse than a named refusal. A silently missing sound would be worse in the same way.
17
+ */
18
+ import { type ComponentProps, type CSSProperties, type ReactElement, type ReactNode } from "react";
19
+ export type AbsoluteFillProps = ComponentProps<"div">;
20
+ /** A `<div>` covering its positioned parent, laid out as a flex column. `style` wins over the fill. */
21
+ export declare function AbsoluteFill({ style, ...rest }: AbsoluteFillProps): ReactElement;
22
+ /** `absolute-fill` wraps the children in an `AbsoluteFill`; `none` renders them as they are. */
23
+ export type SequenceLayout = "absolute-fill" | "none";
24
+ export type SequenceProps = {
25
+ /** The frame, in the parent's timeline, at which this sequence's frame 0 falls. Default 0. */
26
+ from?: number;
27
+ /** How many frames the children stay mounted. Default `Infinity`. */
28
+ durationInFrames?: number;
29
+ /** A label for people reading the tree. The renderer ignores it. */
30
+ name?: string;
31
+ layout?: SequenceLayout;
32
+ /** Applied to the `AbsoluteFill` wrapper; ignored with `layout="none"`. */
33
+ style?: CSSProperties;
34
+ /** Applied to the `AbsoluteFill` wrapper; ignored with `layout="none"`. */
35
+ className?: string;
36
+ children?: ReactNode;
37
+ };
38
+ /**
39
+ * Mount the children for frames `from ≤ f < from + durationInFrames` of the parent's timeline,
40
+ * and show them their own frame, `f − from`.
41
+ *
42
+ * `useVideoConfig().durationInFrames` inside is how long the children can actually be on screen:
43
+ * this sequence's own duration, clipped to what is left of the parent's window from `from` on — so
44
+ * an open sequence at frame 100 of a 300-frame parent reports 200, and a 50-frame sequence placed
45
+ * 20 frames before its parent's end reports 20.
46
+ */
47
+ export declare function Sequence({ from, durationInFrames, layout, style, className, children, }: SequenceProps): ReactElement | null;
48
+ export type SeriesSequenceProps = Omit<SequenceProps, "from" | "durationInFrames"> & {
49
+ /** Required: the series places the next sequence where this one ends. */
50
+ durationInFrames: number;
51
+ /** Shift this sequence, and every one after it, by this many frames. Default 0. */
52
+ offset?: number;
53
+ };
54
+ export type SeriesProps = {
55
+ children?: ReactNode;
56
+ };
57
+ /**
58
+ * The sequences placed back to back. `Series.Sequence` is the only child it accepts, directly or
59
+ * inside Fragments (`<>…</>`, or an array from `.map`), which are opened in place.
60
+ */
61
+ export declare const Series: ((props: SeriesProps) => ReactElement) & {
62
+ Sequence: (props: SeriesSequenceProps) => never;
63
+ };
64
+ export type LoopProps = {
65
+ /** The length of one iteration. */
66
+ durationInFrames: number;
67
+ /** How many iterations to play before unmounting. Default `Infinity`. */
68
+ times?: number;
69
+ name?: string;
70
+ layout?: SequenceLayout;
71
+ style?: CSSProperties;
72
+ className?: string;
73
+ children?: ReactNode;
74
+ };
75
+ /** Replay the children every `durationInFrames`; they see `frame mod durationInFrames`. */
76
+ export declare function Loop({ durationInFrames, times, layout, style, className, children, }: LoopProps): ReactElement | null;
77
+ export type FreezeProps = {
78
+ /** The frame the children see while frozen. */
79
+ frame: number;
80
+ /** Whether the freeze applies, or a test of the parent's frame. Default `true`. */
81
+ active?: boolean | ((frame: number) => boolean);
82
+ children?: ReactNode;
83
+ };
84
+ /** Show the children a fixed frame. Adds no wrapper element. */
85
+ export declare function Freeze({ frame, active, children }: FreezeProps): ReactElement;
86
+ export type ImgProps = ComponentProps<"img">;
87
+ /** An `<img>`. The harness holds every frame until each image in the document has loaded. */
88
+ export declare function Img(props: ImgProps): ReactElement;
89
+ export type AudioProps = {
90
+ src: string;
91
+ volume?: number | ((frame: number) => number);
92
+ muted?: boolean;
93
+ loop?: boolean;
94
+ name?: string;
95
+ };
96
+ /**
97
+ * A marker for the narration track. Renders nothing; the renderer muxes the narration itself.
98
+ * A second concurrently mounted `<Audio>` throws `NOT_SUPPORTED_IN_V1` when it mounts, because the
99
+ * narration is the only audio track the video gets and the second would be silently missing.
100
+ */
101
+ export declare function Audio(_props: AudioProps): null;
102
+ export type VideoProps = {
103
+ src: string;
104
+ [prop: string]: unknown;
105
+ };
106
+ /** Refused in this version: throws `NOT_SUPPORTED_IN_V1` when rendered. */
107
+ export declare function Video(_props: VideoProps): never;
108
+ /** Refused in this version: throws `NOT_SUPPORTED_IN_V1` when rendered. */
109
+ export declare function OffthreadVideo(_props: VideoProps): never;
@@ -0,0 +1 @@
1
+ import{Fragment as _Fragment,jsx as _jsx}from"react/jsx-runtime";import{Children,Fragment,isValidElement,useEffect}from"react";import{TimelineContext}from"./context.js";import{useTimeline}from"./hooks.js";import{mountAudio}from"./runtime.js";const FILL={position:"absolute",top:0,left:0,right:0,bottom:0,width:"100%",height:"100%",display:"flex",flexDirection:"column"};export function AbsoluteFill({style,...rest}){return _jsx("div",{...rest,style:{...FILL,...style}})}function assertFrom(from,caller){if(typeof from!="number"||!Number.isFinite(from))throw new TypeError(`${caller}: from must be a finite number, got ${String(from)}.`)}function assertDuration(duration,caller){if(typeof duration!="number"||Number.isNaN(duration)||duration<=0)throw new RangeError(`${caller}: durationInFrames must be a positive number or Infinity, got ${String(duration)}.`)}function Layout({layout,style,className,children}){return layout==="none"?_jsx(_Fragment,{children}):_jsx(AbsoluteFill,{style,className,children})}export function Sequence({from=0,durationInFrames=Number.POSITIVE_INFINITY,layout="absolute-fill",style,className,children}){const parent=useTimeline("<Sequence>");assertFrom(from,"<Sequence>"),assertDuration(durationInFrames,"<Sequence>");const frame=parent.frame-from;if(frame<0||frame>=durationInFrames)return null;const left=parent.durationInFrames-from,timeline={frame,durationInFrames:Math.min(durationInFrames,left>0?left:parent.durationInFrames)};return _jsx(TimelineContext.Provider,{value:timeline,children:_jsx(Layout,{layout,style,className,children})})}function SeriesSequence(_props){throw new Error("<Series.Sequence> was rendered on its own. It only has a meaning as a direct child of <Series>.")}function seriesChildren(children,prefix=""){return Children.toArray(children).flatMap(child=>{const key=`${prefix}${isValidElement(child)?String(child.key):""}`;return isValidElement(child)&&child.type===Fragment?seriesChildren(child.props.children,`${key}/`):[{child,key}]})}function SeriesRoot({children}){const items=seriesChildren(children);let cursor=0;const sequences=items.map(({child,key},index)=>{if(!isValidElement(child)||child.type!==SeriesSequence)throw new TypeError("<Series> accepts only <Series.Sequence> elements as its children.");const{durationInFrames,offset=0,...rest}=child.props;if(assertFrom(offset,"<Series.Sequence> offset"),assertDuration(durationInFrames,"<Series.Sequence>"),!Number.isFinite(durationInFrames)&&index!==items.length-1)throw new RangeError("<Series.Sequence>: only the last sequence of a <Series> may have an infinite durationInFrames.");const from=cursor+offset;return cursor=from+durationInFrames,_jsx(Sequence,{...rest,from,durationInFrames},key)});return _jsx(_Fragment,{children:sequences})}export const Series=Object.assign(SeriesRoot,{Sequence:SeriesSequence});export function Loop({durationInFrames,times=Number.POSITIVE_INFINITY,layout="absolute-fill",style,className,children}){const parent=useTimeline("<Loop>");if(!(Number.isFinite(durationInFrames)&&durationInFrames>0))throw new RangeError(`<Loop>: durationInFrames must be a positive finite number, got ${String(durationInFrames)}.`);if(typeof times!="number"||Number.isNaN(times)||times<0)throw new RangeError(`<Loop>: times must be zero or a positive number, got ${String(times)}.`);if(parent.frame<0)return null;const iteration=Math.floor(parent.frame/durationInFrames);if(iteration>=times)return null;const timeline={frame:parent.frame-iteration*durationInFrames,durationInFrames};return _jsx(TimelineContext.Provider,{value:timeline,children:_jsx(Layout,{layout,style,className,children})})}export function Freeze({frame,active=!0,children}){const parent=useTimeline("<Freeze>");if(typeof frame!="number"||Number.isNaN(frame))throw new TypeError(`<Freeze>: frame must be a number, got ${String(frame)}.`);if(!(typeof active=="function"?active(parent.frame):active))return _jsx(_Fragment,{children});const timeline={frame,durationInFrames:parent.durationInFrames};return _jsx(TimelineContext.Provider,{value:timeline,children})}export function Img(props){return _jsx("img",{alt:"",...props})}export function Audio(_props){return useEffect(()=>mountAudio(),[]),null}const VIDEO_NOT_SUPPORTED="NOT_SUPPORTED_IN_V1: video assets are not supported by the xplainer renderer; use <Img> stills instead";export function Video(_props){throw new Error(VIDEO_NOT_SUPPORTED)}export function OffthreadVideo(_props){throw new Error(VIDEO_NOT_SUPPORTED)}
@@ -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}}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `spring()` and `measureSpring()` — physical motion as a pure function of the frame.
3
+ *
4
+ * **The curve is the closed-form solution of a damped harmonic oscillator**, not a step-by-step
5
+ * simulation. A mass `m` on a spring of stiffness `k` with damping `c` starts at rest at 0 and is
6
+ * released towards 1:
7
+ *
8
+ * m·x″ + c·x′ + k·(x − 1) = 0, x(0) = 0, x′(0) = 0
9
+ *
10
+ * With `ω₀ = √(k/m)` and `ζ = c / (2·√(k·m))` the textbook solution has three regimes —
11
+ * under-damped (`ζ < 1`, it overshoots and rings), critically damped (`ζ = 1`) and over-damped
12
+ * (`ζ > 1`, it creeps in without overshooting) — and each is evaluated directly at the frame's
13
+ * time. Two consequences matter for a renderer that captures frames in any order: the value at
14
+ * frame 90 does not depend on frames 0–89 having been computed first, and it is identical whether
15
+ * the video is rendered or a single still is taken. `math.test.ts` holds the curve against an
16
+ * independent RK4 integration of the equation above at 1e-9.
17
+ *
18
+ * The curve is normalised (0 → 1) and then scaled onto `from → to`, so `overshootClamping`, the
19
+ * rest threshold and the duration stretch all mean the same thing whatever the two ends are.
20
+ *
21
+ * **`overshootClamping` stops the motion at the target.** It is React Native Animated's flag of
22
+ * the same name: once the value first reaches `to`, it stays there. That is the first root of
23
+ * `x(t) = 1`, at `ω_d·t* = π − atan2(ω_d, ζ·ω₀)`, and only an under-damped spring has one —
24
+ * critically and over-damped motion never reaches the target, so the flag changes nothing for them.
25
+ * Clamping each sample to `min(x, 1)` instead would not be the same thing: after the first swing
26
+ * the oscillator falls back below the target, so a clamped sample sinks away from `to` and climbs
27
+ * back — the default spring at 30 fps would hold 1.000 from frame 8 to 18, dip to 0.973 at 22 and
28
+ * return at 29.
29
+ *
30
+ * The values follow the equation, not any other library's numerical scheme, so a scene ported
31
+ * from elsewhere can differ in the last few hundredths of its overshoot.
32
+ */
33
+ export type SpringConfig = {
34
+ /** Default 1. */
35
+ mass: number;
36
+ /** Default 10. 0 never settles; `measureSpring` refuses it. */
37
+ damping: number;
38
+ /** Default 100. */
39
+ stiffness: number;
40
+ /** Default false. When true the value stops at `to` the first time it reaches it. */
41
+ overshootClamping: boolean;
42
+ };
43
+ export type SpringOptions = {
44
+ /** The frame to evaluate, usually `useCurrentFrame()`. */
45
+ frame: number;
46
+ /** Frames per second, usually `useVideoConfig().fps`. */
47
+ fps: number;
48
+ config?: Partial<SpringConfig>;
49
+ /** Default 0. */
50
+ from?: number;
51
+ /** Default 1. */
52
+ to?: number;
53
+ /** Stretch time so the spring's natural settle (`measureSpring`) lands on this many frames. */
54
+ durationInFrames?: number;
55
+ /** Frames to hold at the start value before the spring is released. Default 0. */
56
+ delay?: number;
57
+ /** Run the motion backwards in time: settled at the start, back at `from` at the end. */
58
+ reverse?: boolean;
59
+ /** The threshold `durationInFrames` and `reverse` measure the natural settle with. Default 0.005. */
60
+ durationRestThreshold?: number;
61
+ };
62
+ export type MeasureSpringOptions = {
63
+ fps: number;
64
+ config?: Partial<SpringConfig>;
65
+ /** How close to the target, in normalised units (0 → 1), counts as settled. Default 0.005. */
66
+ threshold?: number;
67
+ };
68
+ /**
69
+ * The number of frames until the spring stays within `threshold` of its target for good: the
70
+ * first frame `F` such that every frame from `F` on is inside. A spring with no damping never
71
+ * settles, and is refused rather than looped over; so is one that takes more than an hour to
72
+ * settle — so lightly damped it rings for that long, or so heavily that it creeps — with a
73
+ * `RangeError` that says which.
74
+ */
75
+ export declare function measureSpring(options: MeasureSpringOptions): number;
76
+ export declare function spring(options: SpringOptions): number;
package/dist/spring.js ADDED
@@ -0,0 +1 @@
1
+ const DEFAULT_CONFIG={mass:1,damping:10,stiffness:100,overshootClamping:!1},DEFAULT_THRESHOLD=.005;function resolveConfig(config){const resolved={...DEFAULT_CONFIG,...config},{mass,damping,stiffness}=resolved;if(!(Number.isFinite(mass)&&mass>0))throw new RangeError(`spring: config.mass must be a positive number, got ${String(mass)}.`);if(!(Number.isFinite(stiffness)&&stiffness>0))throw new RangeError(`spring: config.stiffness must be a positive number, got ${String(stiffness)}.`);if(!(Number.isFinite(damping)&&damping>=0))throw new RangeError(`spring: config.damping must be zero or a positive number, got ${String(damping)}.`);return resolved}function assertFps(fps,caller){if(!(Number.isFinite(fps)&&fps>0))throw new RangeError(`${caller}: fps must be a positive number, got ${String(fps)}.`)}function position(seconds,config){const omega=Math.sqrt(config.stiffness/config.mass),zeta=config.damping/(2*Math.sqrt(config.stiffness*config.mass));if(zeta<1){const omegaD=omega*Math.sqrt(1-zeta*zeta);return 1-Math.exp(-zeta*omega*seconds)*(Math.cos(omegaD*seconds)+zeta*omega/omegaD*Math.sin(omegaD*seconds))}if(zeta===1)return 1-Math.exp(-omega*seconds)*(1+omega*seconds);const root=Math.sqrt(zeta*zeta-1),r1=-omega/(zeta+root),r2=-omega*(zeta+root);return 1+(r2*Math.exp(r1*seconds)-r1*Math.exp(r2*seconds))/(r1-r2)}function firstArrival(config){const omega=Math.sqrt(config.stiffness/config.mass),zeta=config.damping/(2*Math.sqrt(config.stiffness*config.mass));if(zeta>=1)return Number.POSITIVE_INFINITY;const omegaD=omega*Math.sqrt(1-zeta*zeta);return(Math.PI-Math.atan2(omegaD,zeta*omega))/omegaD}function shown(seconds,config){return seconds<=0?0:config.overshootClamping&&seconds>=firstArrival(config)?1:position(seconds,config)}const settleCache=new Map,MAX_SETTLE_SECONDS=3600;function tooSlowToSettle(fps,zeta,threshold){return new RangeError(`measureSpring: this spring takes more than ${MAX_SETTLE_SECONDS} s to stay within ${threshold} of its target at ${fps} fps (damping ratio \u03B6 = ${zeta}); ${zeta<1?"give it more damping":"give it less damping or more stiffness"}.`)}function settleFrames(fps,config,threshold){const key=`${fps}|${config.mass}|${config.damping}|${config.stiffness}|${config.overshootClamping}|${threshold}`,cached=settleCache.get(key);if(cached!==void 0)return cached;const distance=frame=>Math.abs(shown(frame/fps,config)-1),omega=Math.sqrt(config.stiffness/config.mass),zeta=config.damping/(2*Math.sqrt(config.stiffness*config.mass)),limit=Math.ceil(MAX_SETTLE_SECONDS*fps);let frames=0;if(zeta<1&&!config.overshootClamping){const amplitude=1/Math.sqrt(1-zeta*zeta),horizon=amplitude<=threshold?0:Math.ceil(Math.log(amplitude/threshold)/(zeta*omega)*fps);if(horizon>limit)throw tooSlowToSettle(fps,zeta,threshold);for(let frame=0;frame<=horizon;frame+=1)distance(frame)>=threshold&&(frames=frame+1)}else for(;distance(frames)>=threshold;)if(frames+=1,frames>limit)throw tooSlowToSettle(fps,zeta,threshold);return settleCache.set(key,frames),frames}export function measureSpring(options){const{fps,threshold=DEFAULT_THRESHOLD}=options;assertFps(fps,"measureSpring");const config=resolveConfig(options.config);if(config.damping===0)throw new RangeError("measureSpring: a spring with damping 0 oscillates forever and never settles; give config.damping a positive value.");if(!(Number.isFinite(threshold)&&threshold>0))throw new RangeError(`measureSpring: threshold must be a positive number, got ${String(threshold)}.`);return settleFrames(fps,config,threshold)}export function spring(options){const{frame,fps,from=0,to=1,delay=0,reverse=!1,durationInFrames,durationRestThreshold=DEFAULT_THRESHOLD}=options;if(typeof frame!="number"||Number.isNaN(frame))throw new TypeError(`spring: frame must be a number, got ${String(frame)}.`);assertFps(fps,"spring");const config=resolveConfig(options.config);if(!Number.isFinite(delay))throw new RangeError(`spring: delay must be a finite number, got ${String(delay)}.`);if(durationInFrames!==void 0&&!(Number.isFinite(durationInFrames)&&durationInFrames>0))throw new RangeError(`spring: durationInFrames must be a positive number, got ${String(durationInFrames)}.`);let local=frame-delay;if(durationInFrames!==void 0||reverse){const natural=measureSpring({fps,config,threshold:durationRestThreshold});durationInFrames!==void 0&&(local=local*natural/durationInFrames),reverse&&(local=natural-local)}return from+(to-from)*shown(local/fps,config)}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `fetch(staticFile(name))` answered from memory.
3
+ *
4
+ * The scaffold's `Root.tsx` fetches `timings.json` and `captions.json` with `fetch(staticFile(…))`
5
+ * at metadata time, and is a fixture that must not change. The renderer serves the page over
6
+ * loopback HTTP, which would answer those requests too, but the two documents are what decides the
7
+ * video's length, so they travel in the bundle instead and never depend on a request. The
8
+ * bundle's classic `static-data.js` carries both files' text in `window.__XPLAINER_STATIC__`, and
9
+ * the harness's `fetch` answers a request for one of those names before the network ever sees it.
10
+ * A URL matches when it resolves to the same absolute URL as `staticFile(name)` does; anything else
11
+ * goes to the real `fetch`.
12
+ */
13
+ /** The URL of whatever `fetch` was handed: a string, a `URL` or a `Request`. */
14
+ export declare function requestUrl(input: RequestInfo | URL): string;
15
+ /** The entry of `table` that `url` names, or `undefined`. `toAbsolute` resolves against the page. */
16
+ export declare function findStaticText(url: string, table: Readonly<Record<string, string>> | undefined, toAbsolute: (url: string) => string): {
17
+ name: string;
18
+ text: string;
19
+ } | undefined;
20
+ /** The `content-type` the answer carries: JSON for `.json`, plain text otherwise. */
21
+ export declare function contentTypeFor(name: string): string;
@@ -0,0 +1 @@
1
+ import{staticFile}from"./static-file.js";export function requestUrl(input){return typeof input=="string"?input:input instanceof URL?input.href:input.url}export function findStaticText(url,table,toAbsolute){if(table===void 0)return;const target=toAbsolute(url);for(const[name,text]of Object.entries(table))if(toAbsolute(staticFile(name))===target)return{name,text}}export function contentTypeFor(name){return name.toLowerCase().endsWith(".json")?"application/json":"text/plain; charset=utf-8"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `staticFile(name)` — the URL of a file the narration step wrote beside the video.
3
+ *
4
+ * A scene never knows where its bundle will be opened from: the renderer writes it to a per-job
5
+ * directory and serves it from a loopback address on a port of its own. So the base is not baked
6
+ * into the scene; the bundle's
7
+ * classic `static-data.js` sets `globalThis.__XPLAINER_PUBLIC_BASE__` (a URL ending in `/`, pointing
8
+ * at `public/<slug>/`) before any module runs, and this function appends the name to it. In Node,
9
+ * where nothing sets it, the base is empty and the result is the encoded name alone — which is
10
+ * what makes this function testable without a page.
11
+ *
12
+ * Each path segment is `encodeURIComponent`-encoded and the slashes between them are kept, so
13
+ * `staticFile("shots/step 1.png")` is `<base>shots/step%201.png`. A name that is already encoded is
14
+ * encoded again; pass the name as it is on disk.
15
+ *
16
+ * **The name stays inside `public/<slug>/`, or it is refused.** A URL is refused rather than
17
+ * mangled, and so are the two spellings of a path that would resolve somewhere else: one starting
18
+ * with `/`, and one with a `..` segment, which the browser resolves against the base and walks out
19
+ * of the video's own directory. Each is a `TypeError` naming the name and the rule.
20
+ */
21
+ export declare function staticFile(name: string): string;
@@ -0,0 +1 @@
1
+ const URL_SCHEME=/^[a-z][a-z0-9+.-]*:/i;function publicBase(){const value=Reflect.get(globalThis,"__XPLAINER_PUBLIC_BASE__");return typeof value=="string"?value:""}export function staticFile(name){if(typeof name!="string"||name.length===0)throw new TypeError(`staticFile: pass the name of a file in this video's public directory, such as "logo.png".`);if(URL_SCHEME.test(name))throw new TypeError(`staticFile(${JSON.stringify(name)}): pass a file name inside this video's public directory, not a URL.`);if(name.startsWith("/"))throw new TypeError(`staticFile(${JSON.stringify(name)}): pass the name relative to this video's public directory, without a leading "/".`);const segments=name.split("/");if(segments.includes(".."))throw new TypeError(`staticFile(${JSON.stringify(name)}): a ".." segment would leave this video's public directory; pass a file inside it.`);const path=segments.map(segment=>encodeURIComponent(segment)).join("/");return`${publicBase()}${path}`}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xplainer/motion",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "The authoring API explainer scenes are written against — frame hooks, interpolate, Easing, spring, Sequence — and the page-side capture harness the xplainer renderer drives one frame at a time.",
6
6
  "homepage": "https://xplainer.video",