@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.
- package/dist/animations.d.ts +40 -0
- package/dist/animations.js +1 -0
- package/dist/captions.d.ts +70 -0
- package/dist/captions.js +1 -0
- package/dist/clock.d.ts +94 -0
- package/dist/clock.js +1 -0
- package/dist/components.d.ts +109 -0
- package/dist/components.js +1 -0
- package/dist/composition.d.ts +63 -0
- package/dist/composition.js +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +1 -0
- package/dist/easing.d.ts +56 -0
- package/dist/easing.js +1 -0
- package/dist/failures.d.ts +25 -0
- package/dist/failures.js +1 -0
- package/dist/harness.d.ts +134 -0
- package/dist/harness.js +1 -0
- package/dist/hooks.d.ts +21 -0
- package/dist/hooks.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1 -0
- package/dist/interpolate.d.ts +45 -0
- package/dist/interpolate.js +1 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +1 -0
- package/dist/random.d.ts +24 -0
- package/dist/random.js +1 -0
- package/dist/runtime.d.ts +74 -0
- package/dist/runtime.js +1 -0
- package/dist/spring.d.ts +76 -0
- package/dist/spring.js +1 -0
- package/dist/static-fetch.d.ts +21 -0
- package/dist/static-fetch.js +1 -0
- package/dist/static-file.d.ts +21 -0
- package/dist/static-file.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/captions.js
ADDED
|
@@ -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})}}
|
package/dist/clock.d.ts
ADDED
|
@@ -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>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createContext}from"react";export const VideoConfigContext=createContext(null),TimelineContext=createContext(null),CompositionCollectorContext=createContext(null);
|
package/dist/easing.d.ts
ADDED
|
@@ -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;
|
package/dist/failures.js
ADDED
|
@@ -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
|
+
}
|
package/dist/harness.js
ADDED
|
@@ -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}};
|
package/dist/hooks.d.ts
ADDED
|
@@ -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}}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/provider.js
ADDED
|
@@ -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})})}
|
package/dist/random.d.ts
ADDED
|
@@ -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;
|
package/dist/runtime.js
ADDED
|
@@ -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}}
|
package/dist/spring.d.ts
ADDED
|
@@ -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.
|
|
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",
|