@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 +110 -0
- package/NOTICE +68 -0
- package/README.md +78 -2
- 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 +56 -3
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,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xplainer/motion",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"homepage": "https://xplainer.video",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/BrewMyTech/xplainer.video.git",
|
|
10
|
+
"directory": "packages/motion"
|
|
11
|
+
},
|
|
12
|
+
"author": "Rishav Anand <rishav@brewmytech.com>",
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"xplainer": {
|
|
18
|
+
"tier": "open-later"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"./dist/harness.js"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./harness": {
|
|
29
|
+
"types": "./dist/harness.d.ts",
|
|
30
|
+
"default": "./dist/harness.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/**/*.js",
|
|
35
|
+
"dist/**/*.d.ts",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"NOTICE"
|
|
38
|
+
],
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^19.2.0",
|
|
41
|
+
"react-dom": "^19.2.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "26.4.1",
|
|
45
|
+
"@types/react": "19.2.18",
|
|
46
|
+
"@types/react-dom": "19.2.7",
|
|
47
|
+
"@xplainer/config": "0.0.0",
|
|
48
|
+
"react": "19.2.8",
|
|
49
|
+
"react-dom": "19.2.8",
|
|
50
|
+
"typescript": "7.0.2",
|
|
51
|
+
"vitest": "5.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.build.json && xplainer-minify-dist",
|
|
55
|
+
"lint": "biome check .",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run"
|
|
58
|
+
}
|
|
6
59
|
}
|