@xplainer/motion 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,110 @@
1
+ Copyright (c) 2026 BrewMyTech.
2
+
3
+ This repository is NOT under a single licence. Part of it is open source and
4
+ part of it is proprietary, and the line between them is drawn per directory
5
+ below. Read the whole of this file before copying anything out of here.
6
+
7
+
8
+ PART ONE — OPEN SOURCE, APACHE LICENCE 2.0
9
+
10
+ The eight packages published to npm — seven under the `@xplainer/` scope, plus
11
+ the unscoped `xplainer`, which is an alias for `@xplainer/cli` — are licensed
12
+ under the Apache Licence, Version 2.0:
13
+
14
+ apps/cli @xplainer/cli
15
+ packages/alias xplainer
16
+ packages/mcp-server @xplainer/mcp-server
17
+ packages/motion @xplainer/motion
18
+ packages/protocol @xplainer/protocol
19
+ packages/render-core @xplainer/render-core
20
+ packages/skill @xplainer/skill
21
+ packages/tts-client @xplainer/tts-client
22
+
23
+ Everything inside those eight directories is Apache-2.0, including the workspace
24
+ template and the scaffold templates under `packages/render-core`.
25
+ The full licence text is in LICENSE-APACHE-2.0 at the root of this repository,
26
+ and a copy of it is committed as `LICENSE` inside each of the eight package
27
+ directories so that it also travels inside every published tarball. The
28
+ attribution notice Apache-2.0 section 4(d) propagates is the NOTICE file, at
29
+ the root and inside each of the eight.
30
+
31
+ Each of those eight manifests declares `"license": "Apache-2.0"`. That SPDX
32
+ identifier is the authoritative statement for machine consumers.
33
+
34
+ Licensed under the Apache Licence, Version 2.0 (the "Licence"); you may
35
+ not use these files except in compliance with the Licence. You may obtain
36
+ a copy of the Licence at
37
+
38
+ http://www.apache.org/licenses/LICENSE-2.0
39
+
40
+ Unless required by applicable law or agreed to in writing, software
41
+ distributed under the Licence is distributed on an "AS IS" BASIS, WITHOUT
42
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
43
+ Licence for the specific language governing permissions and limitations
44
+ under the Licence.
45
+
46
+
47
+ PART TWO — PROPRIETARY, ALL RIGHTS RESERVED
48
+
49
+ Every file that neither Part One nor Part Three names is proprietary. No
50
+ permission is granted to use, copy, modify, merge, publish, distribute,
51
+ sublicense, or sell any part of it. This covers, without being limited to:
52
+
53
+ apps/desktop the desktop client (tier: open-later)
54
+ packages/config shared build presets and tooling (tier: open-later)
55
+ services/tts-sidecar the packaged TTS sidecar (tier: open-later)
56
+
57
+ infra/ (but not infra/ffmpeg/, which is Part Three), scripts/, docs/,
58
+ .github/ (but not .github/workflows/ffmpeg-macos.yml, also Part Three),
59
+ and every other file at the root
60
+
61
+ No `hosted` member remains in this repository. `apps/api`, `apps/web` and
62
+ `services/media-service` were relocated to a private repository on 2026-09-06
63
+ (ADR 0023) and this file stopped listing them when they went; that tier stays
64
+ proprietary wherever it lives.
65
+
66
+ The three `open-later` members listed above are on a path to being opened but
67
+ have NOT been relicensed: opening the published packages did not open them,
68
+ and each still declares `"license": "UNLICENSED"` in its own manifest. Nothing
69
+ in Part One extends to them. Making this repository public did not change that
70
+ either — it made them readable, not usable.
71
+
72
+ The tier each package belongs to is declared in its own package.json as
73
+ `"xplainer": { "tier": "hosted" | "open-later" }`, and the boundary between
74
+ the tiers is machine-checked. The tier field describes intent; this file
75
+ describes the grant. Where they appear to disagree, this file and the
76
+ `license` field of the package's own manifest govern.
77
+
78
+
79
+ PART THREE — THE macOS FFMPEG BUILD SCRIPTS, MIT LICENCE
80
+
81
+ The scripts that build the macOS FFmpeg which `xplainer setup` downloads, and
82
+ the workflow that runs them, are licensed under the MIT Licence:
83
+
84
+ infra/ffmpeg/ except infra/ffmpeg/keys/
85
+ .github/workflows/ffmpeg-macos.yml
86
+
87
+ They are published beside every build as part of its complete corresponding
88
+ source ("the scripts used to control compilation and installation of the
89
+ executable", GPLv2 section 3), so that whoever receives the binary can rebuild
90
+ it. The full licence text is in infra/ffmpeg/LICENSE. Each of those files
91
+ carries `SPDX-License-Identifier: MIT`.
92
+
93
+ infra/ffmpeg/keys/ holds the OpenPGP public keys of FFmpeg's release signer and
94
+ of zlib's maintainer, unmodified, so that the build can check their signatures.
95
+ They are their owners'; nothing in this file licenses them.
96
+
97
+ The FFmpeg binary those scripts produce is not covered by any part of this
98
+ file. It is FFmpeg, x264 and zlib under the GNU General Public License version
99
+ 2 or later, and every download carries their licences in its LICENSE file.
100
+
101
+
102
+ THIRD PARTY COMPONENTS
103
+
104
+ This software depends on components that others own, and `xplainer setup`
105
+ downloads more of them onto the machine it runs on, each under its own licence.
106
+ The Apache-2.0 grant in Part One covers this software only and grants you
107
+ nothing in respect of any of them. The NOTICE file names them and their terms.
108
+
109
+
110
+ Questions: rishav@brewmytech.com
package/NOTICE ADDED
@@ -0,0 +1,68 @@
1
+ xplainer.video
2
+ Copyright 2026 BrewMyTech
3
+
4
+ This product includes software developed by BrewMyTech (https://xplainer.video).
5
+
6
+ The packages published to npm — those under the `@xplainer/` scope and the
7
+ unscoped `xplainer`, which is an alias for `@xplainer/cli` — are licensed under
8
+ the Apache Licence, Version 2.0. See the LICENSE file distributed with each
9
+ package, or LICENSE-APACHE-2.0 in the source repository.
10
+
11
+ --------------------------------------------------------------------------------
12
+
13
+ THIRD PARTY COMPONENTS
14
+
15
+ This software depends on components owned by others, each under its own
16
+ licence. Those licences govern those components.
17
+
18
+ `@xplainer/render-core` redistributes the CMU Pronouncing Dictionary
19
+ (`cmudict.dict`), Copyright (C) 1993-2015 Carnegie Mellon University, under the
20
+ 2-clause BSD licence reproduced verbatim beside it at
21
+ `packages/render-core/src/g2p/data/cmudict.LICENSE` and shipped in the package
22
+ at `dist/g2p/data/cmudict.LICENSE`. That licence's clause 1 requires the notice
23
+ and the disclaimer to travel with the source, which is why the file is copied
24
+ into the tarball rather than referenced.
25
+
26
+ `@xplainer/render-core` also redistributes the tokenizer of
27
+ `onnx-community/Kokoro-82M-v1.0-ONNX-timestamped` (`kokoro-tokenizer.json`),
28
+ Apache-2.0, as the authoritative list of the symbols the speech model accepts.
29
+ The model weights themselves are neither bundled nor redistributed here; they
30
+ are fetched from their upstream home, pinned by revision and digest.
31
+
32
+ `@xplainer/cli` depends on xz-decompress
33
+ (https://github.com/httptoolkit/xz-decompress), MIT, to unpack the `.tar.xz`
34
+ FFmpeg builds `xplainer setup` downloads on Linux. It is installed from the npm
35
+ registry as a declared dependency, not bundled. It contains, compiled to
36
+ WebAssembly, xzwasm (Copyright (c) Steve Sanderson, MIT), XZ Embedded by Lasse
37
+ Collin and Igor Pavlov (public domain) and walloc (Copyright (c) 2020 Igalia,
38
+ S.L., MIT). Its package carries no licence file; those are the terms its
39
+ `package.json` and the header of its own code state.
40
+
41
+ `xplainer setup` fetches two more components onto the machine it runs on, and
42
+ neither is bundled in or redistributed by any package here. The speech model
43
+ weights and voice pack of `onnx-community/Kokoro-82M-v1.0-ONNX-timestamped` are
44
+ Apache-2.0 and come from that repository, pinned by revision and digest. ONNX
45
+ Runtime (`onnxruntime-node` and `onnxruntime-common`, Copyright (c) Microsoft
46
+ Corporation) is MIT and comes from the npm registry, pinned by version and
47
+ digest; only the host platform's build is kept. `setup` is the one command that
48
+ downloads, it says what it is fetching and what each artefact must hash to, and
49
+ nothing is installed behind your back.
50
+
51
+ `xplainer setup` also acquires FFmpeg (https://ffmpeg.org), which xplainer
52
+ runs as a separate program and does not link, modify or bundle. On macOS,
53
+ `xplainer setup` downloads an FFmpeg program built by xplainer.video from
54
+ unmodified FFmpeg 9.0.2, x264 b35605ace3ddf7c1a5d67a2eb553f034aef41d55 and zlib
55
+ 1.3.2, licensed under the GNU General Public License version 2 or later. Its
56
+ complete corresponding source is listed at
57
+ https://cdn.xplainer.video/toolchain/v1/ffmpeg/9.0.2/source/README.txt. On
58
+ Linux and Windows it downloads the GPL build BtbN publishes as FFmpeg-Builds
59
+ (https://github.com/FFmpeg-Builds/FFmpeg-Builds), licensed under the GNU
60
+ General Public License version 3 or later, from that project's own release.
61
+ The GPL texts travel with each download, not in these packages.
62
+ docs/third-party-components.md in the source repository lists every
63
+ component `setup` acquires. FFmpeg is a trademark of Fabrice Bellard,
64
+ originator of the FFmpeg project.
65
+
66
+ Apache-2.0 governs this software. It does not govern the components named above
67
+ — the FFmpeg builds and everything else `xplainer setup` acquires among them —
68
+ and nothing here grants you any right to them.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # @xplainer/motion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The API explainer scenes are written against, and the page-side harness the xplainer renderer
4
+ drives one frame at a time.** A scene is ordinary React: it reads the frame, computes its picture
5
+ from it, and the renderer captures that picture once per frame.
6
+
7
+ ```bash
8
+ npm i @xplainer/motion react react-dom
9
+ ```
10
+
11
+ ```tsx
12
+ import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "@xplainer/motion";
13
+
14
+ export const Title: React.FC<{ text: string }> = ({ text }) => {
15
+ const frame = useCurrentFrame();
16
+ const { fps } = useVideoConfig();
17
+ const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
18
+ const scale = spring({ frame, fps, config: { damping: 12 } });
19
+ const y = interpolate(frame, [0, 30], [40, 0], {
20
+ easing: Easing.out(Easing.cubic),
21
+ extrapolateRight: "clamp",
22
+ });
23
+ return (
24
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
25
+ <h1 style={{ opacity, transform: `translateY(${y}px) scale(${scale})` }}>{text}</h1>
26
+ </AbsoluteFill>
27
+ );
28
+ };
29
+ ```
30
+
31
+ ## What is in it
32
+
33
+ | Kind | Exports |
34
+ |---|---|
35
+ | Frame and video | `useCurrentFrame`, `useVideoConfig` |
36
+ | Motion | `interpolate`, `Easing`, `spring`, `measureSpring`, `random` |
37
+ | Timing and layout | `Sequence`, `Series`, `Loop`, `Freeze`, `AbsoluteFill` |
38
+ | Media | `Img`, `Audio` (the narration marker), `staticFile` |
39
+ | Render holds | `delayRender`, `continueRender`, `cancelRender` |
40
+ | The video itself | `registerRoot`, `Composition`, `registerVideo`, `CalculateMetadataFunction` |
41
+ | Captions | `paginateCaptions`, `Caption`, `TikTokPage` |
42
+
43
+ Every motion function is a pure function of the frame, so a frame rendered out of order, or a
44
+ single still, is the same picture the full render shows. `spring` is the closed-form solution of a
45
+ damped harmonic oscillator; `Easing` holds the textbook closed forms; `random(seed)` is
46
+ deterministic per seed.
47
+
48
+ `<Video>` and `<OffthreadVideo>` throw `NOT_SUPPORTED_IN_V1` when rendered: video assets are not
49
+ supported by this renderer yet, so use `<Img>` stills. A second `<Audio>` beside the narration fails
50
+ the same way, because the narration is the only audio track the video gets. `staticFile()` takes a
51
+ name inside the video's public directory and refuses one that starts with `/` or climbs out of it
52
+ with `..`.
53
+
54
+ ## The harness
55
+
56
+ `@xplainer/motion/harness` is a separate entry point with side effects, imported only by the
57
+ renderer's generated bundle, before the scene code. It runs the page on film time — `Date`,
58
+ `performance.now`, `setTimeout`, `setInterval` and `requestAnimationFrame` all advance only when the
59
+ renderer asks for the next frame — and before each frame is captured it waits for render holds,
60
+ images and fonts, and for the page to stop changing, so an image a scene's effects mount a few
61
+ React updates after the frame is still in that frame; a frame that never stops changing is released
62
+ with a warning in the job log. `Math.random` in the page is seeded, so it repeats from run to run.
63
+ A scene never imports it.
64
+
65
+ The exported surface of the main entry point is recorded in `api/motion.api.md` in the repository.
66
+
67
+ ## Docs
68
+
69
+ - [Architecture][architecture] — the members and the dependency direction
70
+ - [Decision records][adr]
71
+ - [Roadmap][roadmap] — what is built and what is not
72
+
73
+ ## Licence
74
+
75
+ Apache-2.0. See `LICENSE` and `NOTICE`, both shipped inside this package.
76
+
77
+ [architecture]: https://github.com/BrewMyTech/xplainer.video/blob/main/docs/ARCHITECTURE.md
78
+ [adr]: https://github.com/BrewMyTech/xplainer.video/blob/main/docs/adr/README.md
79
+ [roadmap]: https://github.com/BrewMyTech/xplainer.video/blob/main/docs/ROADMAP.md
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Web Animations on film time.
3
+ *
4
+ * CSS animations, CSS transitions and `element.animate()` all run on the document timeline, which
5
+ * is wall time and is not something the harness can replace. So instead of replacing the clock,
6
+ * the harness pauses every animation on the document timeline after each frame commits and sets
7
+ * its `currentTime` itself: film time now, minus the film time at which the harness first saw that
8
+ * animation. An animation that starts when its element mounts at frame 40 is therefore at 0 ms on
9
+ * frame 40 and at 1000 ms on frame 70 at 30 fps, however long the capture of the frames between
10
+ * took.
11
+ *
12
+ * **The start is when the harness first saw the animation, so the order of `setFrame` calls
13
+ * matters.** A video rendered front to back sees every animation start at the frame its element
14
+ * mounted. A page asked for frame 70 alone, of an element that mounts at frame 40, has no earlier
15
+ * frame to have seen it on, and shows it at 0 ms — which is why the renderer steps a still's page
16
+ * through every frame before the one it captures, at the cost of setting each of them. A scene
17
+ * whose animation is `interpolate()` on `useCurrentFrame()` is a pure function of the frame and
18
+ * needs none of this.
19
+ *
20
+ * Scroll- and view-driven animations (`animation-timeline: scroll()` / `view()`) are not on the
21
+ * document timeline and are not touched: their progress is the scroll position, not time.
22
+ */
23
+ /** The part of `Animation` the seeker touches, so Node tests can hand it plain objects. */
24
+ export type SeekableAnimation = {
25
+ pause(): void;
26
+ currentTime: CSSNumberish | null;
27
+ /** Compared by identity with the document's own timeline; nothing else is read from it. */
28
+ readonly timeline: unknown;
29
+ };
30
+ export declare class AnimationSeeker {
31
+ #private;
32
+ /**
33
+ * Pause and seek each animation on `timeline` — the document's — to film time; returns how many
34
+ * were seeked. Any other animation is left alone: one driven by a scroll or view timeline
35
+ * (`animation-timeline: scroll()`) measures its time in percentages of a scroll range, not in
36
+ * milliseconds, and setting a number of milliseconds on it throws. Its progress follows the
37
+ * scroll position, which a frame's own render already decides.
38
+ */
39
+ seek(animations: Iterable<SeekableAnimation>, filmMs: number, timeline: unknown): number;
40
+ }
@@ -0,0 +1 @@
1
+ export class AnimationSeeker{#firstSeen=new WeakMap;seek(animations,filmMs,timeline){let count=0;for(const animation of animations){if(animation.timeline!==timeline)continue;let start=this.#firstSeen.get(animation);start===void 0&&(start=filmMs,this.#firstSeen.set(animation,start)),animation.pause(),animation.currentTime=filmMs-start,count+=1}return count}}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Caption paging: word-level `captions.json` into the pages the caption track shows one at a time.
3
+ *
4
+ * **The rule, which `packages/render-core` already writes `captions.json` against.** Its
5
+ * `buildCaptions()` gives every word but the first of the whole track a leading space and never
6
+ * gives one to folded punctuation (`packages/render-core/src/narrate/captions.ts`). The pager is
7
+ * the other half of that agreement:
8
+ *
9
+ * 1. Captions are taken in order. The first one opens the first page.
10
+ * 2. A caption **opens a new page only if its `text` begins with whitespace** and at least
11
+ * `combineWithinMs` have elapsed from the current page's start to the caption's `startMs`. The
12
+ * window is half-open: a caption exactly `combineWithinMs` after the page start opens the next
13
+ * page. A caption with no leading space never opens a page however late it is — it is the tail
14
+ * of the word before it, and splitting there would strand it.
15
+ * 3. The caption that opens a page has its leading whitespace trimmed, in the page `text` and in
16
+ * its token, so no page begins with a space. Every other caption is appended verbatim: page
17
+ * `text` is the plain concatenation of its tokens' `text`.
18
+ * 4. A page lasts until the next page starts; the last page lasts until the latest `endMs` among
19
+ * its own tokens. So consecutive pages tile the time between them with no gap for the caption
20
+ * band to flicker through, and the track ends when the speech does.
21
+ *
22
+ * Grouping is by elapsed time only; nothing here knows where a sentence or a narration segment
23
+ * ends. The scaffold's `Captions.tsx` gets sentence-aware pages by calling this once per sentence,
24
+ * which is why this function stays this small.
25
+ *
26
+ * Pure: no clock, no frame, no order dependence, so a frame rendered out of order or a still taken
27
+ * alone pages exactly as the full render does.
28
+ */
29
+ /** One word of `captions.json`, in the shape `packages/protocol/schemas/captions.json` defines. */
30
+ export type Caption = {
31
+ text: string;
32
+ startMs: number;
33
+ endMs: number;
34
+ timestampMs: number | null;
35
+ confidence: number | null;
36
+ };
37
+ /** One caption inside a page, with its own span so the spoken word can be highlighted. */
38
+ export type TikTokToken = {
39
+ text: string;
40
+ fromMs: number;
41
+ toMs: number;
42
+ };
43
+ /** One page of caption text and the tokens it was built from. */
44
+ export type TikTokPage = {
45
+ text: string;
46
+ startMs: number;
47
+ durationMs: number;
48
+ tokens: TikTokToken[];
49
+ };
50
+ /** The fields of a caption the pager reads. Every `Caption` is one. */
51
+ export type PageableCaption = Pick<Caption, "text" | "startMs" | "endMs">;
52
+ export type PaginateCaptionsOptions = {
53
+ /** A caption starting this long after its page's start (or later) may open the next page. */
54
+ combineWithinMs: number;
55
+ };
56
+ export declare function paginateCaptions(captions: readonly PageableCaption[], options: PaginateCaptionsOptions): TikTokPage[];
57
+ export type CreateTikTokStyleCaptionsInput = {
58
+ captions: readonly PageableCaption[];
59
+ combineTokensWithinMilliseconds: number;
60
+ };
61
+ /**
62
+ * `paginateCaptions` under its older name and argument shape.
63
+ *
64
+ * It exists so scenes written before this package — which imported the pager from a
65
+ * captions-specific module under this name — keep compiling through the renderer's import alias
66
+ * for one release. New code should call `paginateCaptions`.
67
+ */
68
+ export declare function createTikTokStyleCaptions(input: CreateTikTokStyleCaptionsInput): {
69
+ pages: TikTokPage[];
70
+ };
@@ -0,0 +1 @@
1
+ const LEADING_WHITESPACE=/^\s/u;export function paginateCaptions(captions,options){const{combineWithinMs}=options;if(!(Number.isFinite(combineWithinMs)&&combineWithinMs>=0))throw new RangeError(`paginateCaptions: combineWithinMs must be zero or a positive number, got ${String(combineWithinMs)}.`);const pages=[];let page;for(const caption of captions){if(page===void 0||LEADING_WHITESPACE.test(caption.text)&&caption.startMs-page.startMs>=combineWithinMs||page===void 0){const text=caption.text.trimStart();page={text,startMs:caption.startMs,durationMs:0,tokens:[{text,fromMs:caption.startMs,toMs:caption.endMs}]},pages.push(page);continue}page.text+=caption.text,page.tokens.push({text:caption.text,fromMs:caption.startMs,toMs:caption.endMs})}for(const[index,current]of pages.entries()){const next=pages[index+1],end=next===void 0?Math.max(...current.tokens.map(token=>token.toMs)):next.startMs;current.durationMs=Math.max(0,end-current.startMs)}return pages}export function createTikTokStyleCaptions(input){return{pages:paginateCaptions(input.captions,{combineWithinMs:input.combineTokensWithinMilliseconds})}}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Film time: the clock a captured page runs on instead of the wall clock.
3
+ *
4
+ * A browser animates against wall time — `Date.now()`, `performance.now()`, `setTimeout`,
5
+ * `requestAnimationFrame` — and a capture that takes 40 ms per frame would otherwise see a scene
6
+ * whose timers ran 40 ms per frame instead of 33. The harness replaces all four with **film
7
+ * time**: `frame / fps · 1000` milliseconds, which only moves when the renderer asks for a frame.
8
+ * These are the pieces of that replacement that need no `window`, so they are tested in Node; the
9
+ * harness installs them.
10
+ *
11
+ * Three rules, each of which the spike found by being wrong first:
12
+ *
13
+ * - **Due timers fire in due order, ties in creation order** — the same order a browser would have
14
+ * fired them in, given time.
15
+ * - **A timer created while the queue is draining waits for the next drain**, even with delay 0.
16
+ * Otherwise `setTimeout(tick, 0)` inside `tick` is an infinite loop inside one frame.
17
+ * - **An interval fires as many times as fit**, so a 10 ms interval fires three times across one
18
+ * 33 ms frame rather than once.
19
+ */
20
+ /** 2020-01-01T00:00:00Z: what `Date.now()` reads at frame 0, so dates are stable across renders. */
21
+ export declare const FILM_EPOCH_MS: number;
22
+ /** Film milliseconds at a frame. Multiplies first, so whole frames at integer fps stay exact. */
23
+ export declare function frameToFilmMs(frame: number, fps: number): number;
24
+ /**
25
+ * A `Date` that reads film time when asked for "now" and is the native `Date` otherwise.
26
+ *
27
+ * A `Proxy` rather than a subclass because `Date()` called without `new` must keep working (it
28
+ * returns a string), and `instanceof Date` must stay true for dates made either way; both fall out
29
+ * of forwarding everything but the three "now" paths to the native constructor.
30
+ */
31
+ export declare function createFrozenDate(NativeDate: DateConstructor, filmMs: () => number): DateConstructor;
32
+ /** `setTimeout` / `setInterval` against a clock somebody else advances. */
33
+ export declare class TimerQueue {
34
+ #private;
35
+ /**
36
+ * Schedule `callback` `delayMs` after `nowMs`. The delay is whatever the page passed, converted
37
+ * with `Number()` as a browser converts it — `setTimeout(fn, "100")` waits 100 ms — and one that
38
+ * converts to a non-finite or negative number is 0.
39
+ */
40
+ add(callback: () => void, delayMs: unknown, repeat: boolean, nowMs: number): number;
41
+ /**
42
+ * Reschedule every pending timer with an id of at least `fromId` as if it had been added at
43
+ * `nowMs`, keeping what has happened to it since: a one-shot timer becomes due its own delay
44
+ * after `nowMs`, and an interval that has fired `n` times becomes due where the same interval
45
+ * added at `nowMs` would be after `n` firings — the same phase, on the new origin. The due time is
46
+ * recomputed rather than shifted, so it is the same number to the last bit whatever it was
47
+ * scheduled from; a shift could land a timer due exactly on a frame one ulp past it. Returns how
48
+ * many it moved.
49
+ *
50
+ * This is how the harness takes the wall clock back out of a render-hold wait: a timer the wait
51
+ * created was scheduled from film time plus however long the wait had run, and once the hold is
52
+ * released it is rebased onto the frame's film instant, so when it fires no longer depends on how
53
+ * long the page took to release the hold.
54
+ */
55
+ rebase(fromId: number, nowMs: number): number;
56
+ /** Cancel a timer or an interval. Anything that is not a live id is ignored, as in a browser. */
57
+ clear(id: unknown): void;
58
+ /**
59
+ * The id the next timer will get. Every timer created from now on has an id at least this
60
+ * large, which is how a caller marks "timers created after this point" for `runDue`.
61
+ */
62
+ get nextId(): number;
63
+ /**
64
+ * The earliest due time among timers with an id of at least `fromId` (all of them by default),
65
+ * or `undefined` when there is none.
66
+ */
67
+ nextDue(fromId?: number): number | undefined;
68
+ get size(): number;
69
+ /**
70
+ * Fire every timer due at or before `targetMs`, earliest first, ties in creation order. Timers
71
+ * created while this runs are left for the next call, and timers with an id below `fromId` are
72
+ * left alone entirely. Returns how many fired. `invoke` is how a callback is called, so the
73
+ * caller decides what a throwing one means.
74
+ */
75
+ runDue(targetMs: number, invoke: (callback: () => void) => void, fromId?: number): number;
76
+ }
77
+ /** `requestAnimationFrame` as a queue the harness drains once per captured frame. */
78
+ export declare class FrameCallbackQueue {
79
+ #private;
80
+ request(callback: (timestamp: number) => void): number;
81
+ cancel(id: unknown): void;
82
+ get size(): number;
83
+ /** The id the next request will get; the same "requested after this point" mark as `TimerQueue`'s. */
84
+ get nextId(): number;
85
+ /** How many queued callbacks have an id of at least `fromId`. */
86
+ pending(fromId?: number): number;
87
+ /**
88
+ * Run every queued callback with an id of at least `fromId` (all of them by default) with
89
+ * `timestamp`, in request order, and leave the rest queued. A callback requested while this runs
90
+ * belongs to the next drain, and one cancelled by an earlier callback in the same drain does not
91
+ * run — both exactly as a browser runs a frame's callbacks.
92
+ */
93
+ drain(timestamp: number, invoke: (callback: () => void) => void, fromId?: number): number;
94
+ }
package/dist/clock.js ADDED
@@ -0,0 +1 @@
1
+ export const FILM_EPOCH_MS=Date.UTC(2020,0,1);export function frameToFilmMs(frame,fps){return frame*1e3/fps}export function createFrozenDate(NativeDate,filmMs){const now=()=>FILM_EPOCH_MS+Math.floor(filmMs());return new Proxy(NativeDate,{construct(target,args,newTarget){return Reflect.construct(target,args.length===0?[now()]:args,newTarget)},apply(){return new NativeDate(now()).toString()},get(target,property,receiver){return property==="now"?now:Reflect.get(target,property,receiver)}})}export class TimerQueue{#timers=new Map;#nextId=1;add(callback,delayMs,repeat,nowMs){const requested=Number(delayMs),delay=Number.isFinite(requested)&&requested>0?requested:0,id=this.#nextId;return this.#nextId+=1,this.#timers.set(id,{id,due:nowMs+delay,delay,ticks:0,callback,interval:repeat?Math.max(1,delay):null}),id}rebase(fromId,nowMs){let moved=0;for(const timer of this.#timers.values()){if(timer.id<fromId)continue;let due=nowMs+timer.delay;if(timer.interval!==null)for(let tick=0;tick<timer.ticks;tick+=1)due+=timer.interval;timer.due=due,moved+=1}return moved}clear(id){typeof id=="number"&&this.#timers.delete(id)}get nextId(){return this.#nextId}nextDue(fromId=1){let earliest;for(const timer of this.#timers.values())timer.id<fromId||(earliest===void 0||timer.due<earliest)&&(earliest=timer.due);return earliest}get size(){return this.#timers.size}runDue(targetMs,invoke,fromId=1){const watermark=this.#nextId;let fired=0;for(;;){let next;for(const timer of this.#timers.values())timer.id<fromId||timer.id>=watermark||timer.due>targetMs||(next===void 0||timer.due<next.due||timer.due===next.due&&timer.id<next.id)&&(next=timer);if(next===void 0)return fired;next.interval===null?this.#timers.delete(next.id):(next.due+=next.interval,next.ticks+=1),fired+=1,invoke(next.callback)}}}export class FrameCallbackQueue{#callbacks=new Map;#nextId=1;request(callback){const id=this.#nextId;return this.#nextId+=1,this.#callbacks.set(id,callback),id}cancel(id){typeof id=="number"&&this.#callbacks.delete(id)}get size(){return this.#callbacks.size}get nextId(){return this.#nextId}pending(fromId=1){let count=0;for(const id of this.#callbacks.keys())id>=fromId&&(count+=1);return count}drain(timestamp,invoke,fromId=1){const handles=[...this.#callbacks.keys()].filter(id=>id>=fromId);let ran=0;for(const id of handles){const callback=this.#callbacks.get(id);callback!==void 0&&(this.#callbacks.delete(id),ran+=1,invoke(()=>callback(timestamp)))}return ran}}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The layout and timing components a scene is built from.
3
+ *
4
+ * Timing is one rule applied at every level (see `context.ts`): a component reads the frame its
5
+ * parent sees, decides whether its children exist at that frame, and re-provides the frame
6
+ * relative to itself. Children outside their window are **not mounted**, rather than hidden, so a
7
+ * scene's effects and `delayRender` calls run only while it is on screen.
8
+ *
9
+ * Media is deliberately narrow in this version. `<Img>` is a plain `<img>` and the harness waits
10
+ * for every image in the document before a frame is captured. `<Audio>` is a marker: the renderer
11
+ * muxes the narration track itself, so the element only exists to keep the engine-owned
12
+ * `Video.tsx` declarative about it, and a second one mounted beside it throws
13
+ * `NOT_SUPPORTED_IN_V1`, because the narration is the only audio track the video gets. `<Video>`
14
+ * and `<OffthreadVideo>` throw `NOT_SUPPORTED_IN_V1` the moment they render, because a
15
+ * frame-accurate video decoder is not part of this renderer and a silently blank rectangle would be
16
+ * worse than a named refusal. A silently missing sound would be worse in the same way.
17
+ */
18
+ import { type ComponentProps, type CSSProperties, type ReactElement, type ReactNode } from "react";
19
+ export type AbsoluteFillProps = ComponentProps<"div">;
20
+ /** A `<div>` covering its positioned parent, laid out as a flex column. `style` wins over the fill. */
21
+ export declare function AbsoluteFill({ style, ...rest }: AbsoluteFillProps): ReactElement;
22
+ /** `absolute-fill` wraps the children in an `AbsoluteFill`; `none` renders them as they are. */
23
+ export type SequenceLayout = "absolute-fill" | "none";
24
+ export type SequenceProps = {
25
+ /** The frame, in the parent's timeline, at which this sequence's frame 0 falls. Default 0. */
26
+ from?: number;
27
+ /** How many frames the children stay mounted. Default `Infinity`. */
28
+ durationInFrames?: number;
29
+ /** A label for people reading the tree. The renderer ignores it. */
30
+ name?: string;
31
+ layout?: SequenceLayout;
32
+ /** Applied to the `AbsoluteFill` wrapper; ignored with `layout="none"`. */
33
+ style?: CSSProperties;
34
+ /** Applied to the `AbsoluteFill` wrapper; ignored with `layout="none"`. */
35
+ className?: string;
36
+ children?: ReactNode;
37
+ };
38
+ /**
39
+ * Mount the children for frames `from ≤ f < from + durationInFrames` of the parent's timeline,
40
+ * and show them their own frame, `f − from`.
41
+ *
42
+ * `useVideoConfig().durationInFrames` inside is how long the children can actually be on screen:
43
+ * this sequence's own duration, clipped to what is left of the parent's window from `from` on — so
44
+ * an open sequence at frame 100 of a 300-frame parent reports 200, and a 50-frame sequence placed
45
+ * 20 frames before its parent's end reports 20.
46
+ */
47
+ export declare function Sequence({ from, durationInFrames, layout, style, className, children, }: SequenceProps): ReactElement | null;
48
+ export type SeriesSequenceProps = Omit<SequenceProps, "from" | "durationInFrames"> & {
49
+ /** Required: the series places the next sequence where this one ends. */
50
+ durationInFrames: number;
51
+ /** Shift this sequence, and every one after it, by this many frames. Default 0. */
52
+ offset?: number;
53
+ };
54
+ export type SeriesProps = {
55
+ children?: ReactNode;
56
+ };
57
+ /**
58
+ * The sequences placed back to back. `Series.Sequence` is the only child it accepts, directly or
59
+ * inside Fragments (`<>…</>`, or an array from `.map`), which are opened in place.
60
+ */
61
+ export declare const Series: ((props: SeriesProps) => ReactElement) & {
62
+ Sequence: (props: SeriesSequenceProps) => never;
63
+ };
64
+ export type LoopProps = {
65
+ /** The length of one iteration. */
66
+ durationInFrames: number;
67
+ /** How many iterations to play before unmounting. Default `Infinity`. */
68
+ times?: number;
69
+ name?: string;
70
+ layout?: SequenceLayout;
71
+ style?: CSSProperties;
72
+ className?: string;
73
+ children?: ReactNode;
74
+ };
75
+ /** Replay the children every `durationInFrames`; they see `frame mod durationInFrames`. */
76
+ export declare function Loop({ durationInFrames, times, layout, style, className, children, }: LoopProps): ReactElement | null;
77
+ export type FreezeProps = {
78
+ /** The frame the children see while frozen. */
79
+ frame: number;
80
+ /** Whether the freeze applies, or a test of the parent's frame. Default `true`. */
81
+ active?: boolean | ((frame: number) => boolean);
82
+ children?: ReactNode;
83
+ };
84
+ /** Show the children a fixed frame. Adds no wrapper element. */
85
+ export declare function Freeze({ frame, active, children }: FreezeProps): ReactElement;
86
+ export type ImgProps = ComponentProps<"img">;
87
+ /** An `<img>`. The harness holds every frame until each image in the document has loaded. */
88
+ export declare function Img(props: ImgProps): ReactElement;
89
+ export type AudioProps = {
90
+ src: string;
91
+ volume?: number | ((frame: number) => number);
92
+ muted?: boolean;
93
+ loop?: boolean;
94
+ name?: string;
95
+ };
96
+ /**
97
+ * A marker for the narration track. Renders nothing; the renderer muxes the narration itself.
98
+ * A second concurrently mounted `<Audio>` throws `NOT_SUPPORTED_IN_V1` when it mounts, because the
99
+ * narration is the only audio track the video gets and the second would be silently missing.
100
+ */
101
+ export declare function Audio(_props: AudioProps): null;
102
+ export type VideoProps = {
103
+ src: string;
104
+ [prop: string]: unknown;
105
+ };
106
+ /** Refused in this version: throws `NOT_SUPPORTED_IN_V1` when rendered. */
107
+ export declare function Video(_props: VideoProps): never;
108
+ /** Refused in this version: throws `NOT_SUPPORTED_IN_V1` when rendered. */
109
+ export declare function OffthreadVideo(_props: VideoProps): never;
@@ -0,0 +1 @@
1
+ import{Fragment as _Fragment,jsx as _jsx}from"react/jsx-runtime";import{Children,Fragment,isValidElement,useEffect}from"react";import{TimelineContext}from"./context.js";import{useTimeline}from"./hooks.js";import{mountAudio}from"./runtime.js";const FILL={position:"absolute",top:0,left:0,right:0,bottom:0,width:"100%",height:"100%",display:"flex",flexDirection:"column"};export function AbsoluteFill({style,...rest}){return _jsx("div",{...rest,style:{...FILL,...style}})}function assertFrom(from,caller){if(typeof from!="number"||!Number.isFinite(from))throw new TypeError(`${caller}: from must be a finite number, got ${String(from)}.`)}function assertDuration(duration,caller){if(typeof duration!="number"||Number.isNaN(duration)||duration<=0)throw new RangeError(`${caller}: durationInFrames must be a positive number or Infinity, got ${String(duration)}.`)}function Layout({layout,style,className,children}){return layout==="none"?_jsx(_Fragment,{children}):_jsx(AbsoluteFill,{style,className,children})}export function Sequence({from=0,durationInFrames=Number.POSITIVE_INFINITY,layout="absolute-fill",style,className,children}){const parent=useTimeline("<Sequence>");assertFrom(from,"<Sequence>"),assertDuration(durationInFrames,"<Sequence>");const frame=parent.frame-from;if(frame<0||frame>=durationInFrames)return null;const left=parent.durationInFrames-from,timeline={frame,durationInFrames:Math.min(durationInFrames,left>0?left:parent.durationInFrames)};return _jsx(TimelineContext.Provider,{value:timeline,children:_jsx(Layout,{layout,style,className,children})})}function SeriesSequence(_props){throw new Error("<Series.Sequence> was rendered on its own. It only has a meaning as a direct child of <Series>.")}function seriesChildren(children,prefix=""){return Children.toArray(children).flatMap(child=>{const key=`${prefix}${isValidElement(child)?String(child.key):""}`;return isValidElement(child)&&child.type===Fragment?seriesChildren(child.props.children,`${key}/`):[{child,key}]})}function SeriesRoot({children}){const items=seriesChildren(children);let cursor=0;const sequences=items.map(({child,key},index)=>{if(!isValidElement(child)||child.type!==SeriesSequence)throw new TypeError("<Series> accepts only <Series.Sequence> elements as its children.");const{durationInFrames,offset=0,...rest}=child.props;if(assertFrom(offset,"<Series.Sequence> offset"),assertDuration(durationInFrames,"<Series.Sequence>"),!Number.isFinite(durationInFrames)&&index!==items.length-1)throw new RangeError("<Series.Sequence>: only the last sequence of a <Series> may have an infinite durationInFrames.");const from=cursor+offset;return cursor=from+durationInFrames,_jsx(Sequence,{...rest,from,durationInFrames},key)});return _jsx(_Fragment,{children:sequences})}export const Series=Object.assign(SeriesRoot,{Sequence:SeriesSequence});export function Loop({durationInFrames,times=Number.POSITIVE_INFINITY,layout="absolute-fill",style,className,children}){const parent=useTimeline("<Loop>");if(!(Number.isFinite(durationInFrames)&&durationInFrames>0))throw new RangeError(`<Loop>: durationInFrames must be a positive finite number, got ${String(durationInFrames)}.`);if(typeof times!="number"||Number.isNaN(times)||times<0)throw new RangeError(`<Loop>: times must be zero or a positive number, got ${String(times)}.`);if(parent.frame<0)return null;const iteration=Math.floor(parent.frame/durationInFrames);if(iteration>=times)return null;const timeline={frame:parent.frame-iteration*durationInFrames,durationInFrames};return _jsx(TimelineContext.Provider,{value:timeline,children:_jsx(Layout,{layout,style,className,children})})}export function Freeze({frame,active=!0,children}){const parent=useTimeline("<Freeze>");if(typeof frame!="number"||Number.isNaN(frame))throw new TypeError(`<Freeze>: frame must be a number, got ${String(frame)}.`);if(!(typeof active=="function"?active(parent.frame):active))return _jsx(_Fragment,{children});const timeline={frame,durationInFrames:parent.durationInFrames};return _jsx(TimelineContext.Provider,{value:timeline,children})}export function Img(props){return _jsx("img",{alt:"",...props})}export function Audio(_props){return useEffect(()=>mountAudio(),[]),null}const VIDEO_NOT_SUPPORTED="NOT_SUPPORTED_IN_V1: video assets are not supported by the xplainer renderer; use <Img> stills instead";export function Video(_props){throw new Error(VIDEO_NOT_SUPPORTED)}export function OffthreadVideo(_props){throw new Error(VIDEO_NOT_SUPPORTED)}