@laplace.live/persona-sdk 0.17.0 → 0.18.0

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/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from './client/address.ts';
2
2
  export * from './client/client.ts';
3
+ export * from './values/arkit.ts';
4
+ export * from './values/curve.ts';
3
5
  export * from './values/custom-effect.ts';
4
6
  export * from './values/effect-schema.ts';
5
7
  export * from './values/guards.ts';
@@ -7,6 +9,7 @@ export * from './values/hotkeys.ts';
7
9
  export * from './values/labels.ts';
8
10
  export * from './values/limits.ts';
9
11
  export * from './values/locale.ts';
12
+ export * from './values/terms.ts';
10
13
  export * from './wire/envelope.ts';
11
14
  export * from './wire/errors.ts';
12
15
  export * from './wire/events.ts';
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // and the values/registries every Persona app must agree on.
3
3
  export * from "./client/address.js";
4
4
  export * from "./client/client.js";
5
+ export * from "./values/arkit.js";
6
+ export * from "./values/curve.js";
5
7
  export * from "./values/custom-effect.js";
6
8
  export * from "./values/effect-schema.js";
7
9
  export * from "./values/guards.js";
@@ -9,6 +11,7 @@ export * from "./values/hotkeys.js";
9
11
  export * from "./values/labels.js";
10
12
  export * from "./values/limits.js";
11
13
  export * from "./values/locale.js";
14
+ export * from "./values/terms.js";
12
15
  export * from "./wire/envelope.js";
13
16
  export * from "./wire/errors.js";
14
17
  export * from "./wire/events.js";
@@ -0,0 +1,16 @@
1
+ /** Canonical ARKit blendshape names, as `ARFaceAnchor` spells them. */
2
+ export declare const ARKIT_SHAPES: readonly ["browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight", "cheekPuff", "cheekSquintLeft", "cheekSquintRight", "eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight", "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", "eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight", "eyeWideLeft", "eyeWideRight", "jawForward", "jawLeft", "jawOpen", "jawRight", "mouthClose", "mouthDimpleLeft", "mouthDimpleRight", "mouthFrownLeft", "mouthFrownRight", "mouthFunnel", "mouthLeft", "mouthLowerDownLeft", "mouthLowerDownRight", "mouthPressLeft", "mouthPressRight", "mouthPucker", "mouthRight", "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", "mouthSmileLeft", "mouthSmileRight", "mouthStretchLeft", "mouthStretchRight", "mouthUpperUpLeft", "mouthUpperUpRight", "noseSneerLeft", "noseSneerRight", "tongueOut"];
3
+ export type ArkitShape = (typeof ARKIT_SHAPES)[number];
4
+ /** Full perfect-sync coverage: all {@link ARKIT_SHAPES}. */
5
+ export declare const ARKIT_TOTAL: 52;
6
+ /**
7
+ * An ARKit channel's id as a tracking input. Namespaced because VTS already has `JawOpen`,
8
+ * `CheekPuff` and `TongueOut`, and `.vtube.json` import case-folds.
9
+ */
10
+ export type ArkitInputName = `ARKit${Capitalize<ArkitShape>}`;
11
+ /** The input id for one ARKit channel. */
12
+ export declare function arkitInputName(shape: ArkitShape): ArkitInputName;
13
+ /** Every ARKit channel as a tracking input, in {@link ARKIT_SHAPES} order. */
14
+ export declare const ARKIT_INPUT_NAMES: readonly ArkitInputName[];
15
+ /** Whether an input id names a raw ARKit channel rather than one of VTS's derived inputs. */
16
+ export declare function isArkitInputName(v: string): v is ArkitInputName;
@@ -0,0 +1,71 @@
1
+ // The 52 ARKit blendshapes and their spelling as `ARKit*` tracking inputs: binding one reads the
2
+ // shape the phone measured instead of the lossy VTS-vocabulary derivation ("perfect sync").
3
+ /** Canonical ARKit blendshape names, as `ARFaceAnchor` spells them. */
4
+ export const ARKIT_SHAPES = [
5
+ 'browDownLeft',
6
+ 'browDownRight',
7
+ 'browInnerUp',
8
+ 'browOuterUpLeft',
9
+ 'browOuterUpRight',
10
+ 'cheekPuff',
11
+ 'cheekSquintLeft',
12
+ 'cheekSquintRight',
13
+ 'eyeBlinkLeft',
14
+ 'eyeBlinkRight',
15
+ 'eyeLookDownLeft',
16
+ 'eyeLookDownRight',
17
+ 'eyeLookInLeft',
18
+ 'eyeLookInRight',
19
+ 'eyeLookOutLeft',
20
+ 'eyeLookOutRight',
21
+ 'eyeLookUpLeft',
22
+ 'eyeLookUpRight',
23
+ 'eyeSquintLeft',
24
+ 'eyeSquintRight',
25
+ 'eyeWideLeft',
26
+ 'eyeWideRight',
27
+ 'jawForward',
28
+ 'jawLeft',
29
+ 'jawOpen',
30
+ 'jawRight',
31
+ 'mouthClose',
32
+ 'mouthDimpleLeft',
33
+ 'mouthDimpleRight',
34
+ 'mouthFrownLeft',
35
+ 'mouthFrownRight',
36
+ 'mouthFunnel',
37
+ 'mouthLeft',
38
+ 'mouthLowerDownLeft',
39
+ 'mouthLowerDownRight',
40
+ 'mouthPressLeft',
41
+ 'mouthPressRight',
42
+ 'mouthPucker',
43
+ 'mouthRight',
44
+ 'mouthRollLower',
45
+ 'mouthRollUpper',
46
+ 'mouthShrugLower',
47
+ 'mouthShrugUpper',
48
+ 'mouthSmileLeft',
49
+ 'mouthSmileRight',
50
+ 'mouthStretchLeft',
51
+ 'mouthStretchRight',
52
+ 'mouthUpperUpLeft',
53
+ 'mouthUpperUpRight',
54
+ 'noseSneerLeft',
55
+ 'noseSneerRight',
56
+ 'tongueOut',
57
+ ];
58
+ /** Full perfect-sync coverage: all {@link ARKIT_SHAPES}. */
59
+ export const ARKIT_TOTAL = ARKIT_SHAPES.length;
60
+ /** The input id for one ARKit channel. */
61
+ export function arkitInputName(shape) {
62
+ // TS cannot type a runtime capitalization as the template literal it computes.
63
+ return `ARKit${shape.charAt(0).toUpperCase()}${shape.slice(1)}`;
64
+ }
65
+ /** Every ARKit channel as a tracking input, in {@link ARKIT_SHAPES} order. */
66
+ export const ARKIT_INPUT_NAMES = ARKIT_SHAPES.map(s => arkitInputName(s));
67
+ const ARKIT_INPUT_SET = new Set(ARKIT_INPUT_NAMES);
68
+ /** Whether an input id names a raw ARKit channel rather than one of VTS's derived inputs. */
69
+ export function isArkitInputName(v) {
70
+ return ARKIT_INPUT_SET.has(v);
71
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Response curves for tracking bindings — the shape a straight `inRange`→`outRange` cannot say:
3
+ * a dead zone before a blink starts, a plateau where an eye rests, a held maximum.
4
+ *
5
+ * Modelled on nizima LIVE's `.live.json` curve, whose semantics were recovered from
6
+ * `Framework::Curve::interpolate` (see `references/nizima-desktop/disasm/curve-evaluator.txt`).
7
+ * Both axes are normalized 0..1, so a curve is portable between parameters — the binding's
8
+ * `outRange` is what puts it in Cubism units.
9
+ */
10
+ /** Governs the segment *leaving* a keyframe; the last keyframe's is never read. */
11
+ export type Interpolation = 'linear' | 'step' | 'invertStep' | 'bezier';
12
+ export interface Point {
13
+ x: number;
14
+ y: number;
15
+ }
16
+ export interface Keyframe {
17
+ /** Both axes normalized 0..1. Keyframes are sorted by `anchor.x`. */
18
+ anchor: Point;
19
+ /**
20
+ * Bezier handles. `x` is a **fraction of the segment's x-span** (0 = this segment's left
21
+ * anchor, 1 = its right), `y` is absolute — nizima's asymmetry, and worth keeping: an
22
+ * anchor dragged sideways carries its handles, while the shape stays pinned to real output.
23
+ */
24
+ next?: Point;
25
+ previous?: Point;
26
+ interpolation: Interpolation;
27
+ }
28
+ export interface Curve {
29
+ keyframes: Keyframe[];
30
+ }
31
+ /** Smallest gap kept between an interior anchor and its neighbours, so no segment is zero-span. */
32
+ export declare const EDGE_EPSILON = 0.001;
33
+ /**
34
+ * A handle's absolute x, from its segment-relative fraction.
35
+ *
36
+ * Exported so an editor draws a handle where the evaluator reads it — the clamp is the whole
37
+ * reason `tAtX`'s bisection has one root, and a second copy of it would drift.
38
+ */
39
+ export declare function handleX(from: number, to: number, fraction: number): number;
40
+ /**
41
+ * The inverse of `handleX`: a handle's stored fraction, from its absolute x.
42
+ *
43
+ * Clamped for the same reason `handleX` is — a fraction outside 0..1 is re-clamped on read, so
44
+ * storing one would hand back a shape nobody drew.
45
+ */
46
+ export declare function handleFraction(from: number, to: number, x: number): number;
47
+ /**
48
+ * The curve's output for a normalized input, both in 0..1.
49
+ *
50
+ * Outside the keyframes' span the end values are held. nizima instead returns the raw input
51
+ * below the first anchor and 0 above the last; both are unreachable for a curve spanning 0..1,
52
+ * and holding is what a caller clamping its input actually wants.
53
+ */
54
+ export declare function evaluateCurve(curve: Curve, x: number): number;
55
+ /** A handle is stored on a keyframe; `next` shapes the segment leaving it, `previous` the one arriving. */
56
+ export type HandleSide = 'next' | 'previous';
57
+ export interface CurveHandle extends Point {
58
+ /** Which of the keyframe's two handles this is; also the property it is written back to. */
59
+ side: HandleSide;
60
+ /** The x-span of the segment it shapes — where `handleX` can put it, and so where it may move. */
61
+ span: readonly [number, number];
62
+ }
63
+ /**
64
+ * The keyframe's draggable handles, in curve coordinates — its outgoing one when it governs a
65
+ * Bezier segment, its incoming one when the keyframe before it does.
66
+ *
67
+ * `handleX` rather than a second clamp-and-lerp: a handle has to read back where the evaluator
68
+ * looks for it. `y` is absolute and deliberately unclamped — `sanitizeCurve` keeps a control point
69
+ * outside 0..1 and only the cubic's *result* is clamped, so clamping here would report a
70
+ * legitimate overshoot at the wrong height.
71
+ */
72
+ export declare function handlesFor(keyframes: Keyframe[], index: number): CurveHandle[];
73
+ /**
74
+ * How far an anchor may travel in x, as `[lo, hi]`.
75
+ *
76
+ * The ends are pinned to where they are: a curve that no longer spans 0..1 leaves the evaluator
77
+ * holding an end value over the gap, which reads as the binding going dead. An interior anchor
78
+ * stops at its neighbours rather than swapping past them — a swap renames every index mid-drag,
79
+ * and re-pairs Bezier handles with endpoints they were not cut for.
80
+ *
81
+ * One statement of the rule, so an editor's drag and its typed field cannot drift apart.
82
+ */
83
+ export declare function anchorBounds(keyframes: Keyframe[], index: number): [number, number];
84
+ /**
85
+ * Every Bezier segment carries both handles, seeded collinear unless it already has both.
86
+ *
87
+ * `segmentAt` degrades a half-handled segment to the straight line, so a Bezier keyframe without
88
+ * its handles names a shape nothing can reach and an editor has nothing to draw or drag — which
89
+ * is what a hand-written config, a plugin, or an anchor added into a Bezier segment produces.
90
+ * Seeding at the thirds reproduces that straight line exactly, so this never changes what a curve
91
+ * does; dragging a handle is what shapes it. Assumes keyframes sorted by `anchor.x`.
92
+ *
93
+ * A half-handled segment is seeded on **both** ends rather than only the empty one. Handles belong
94
+ * to a segment, not a keyframe: a lone survivor was calibrated against endpoints that no longer
95
+ * pair with it — an anchor was added, removed, or dragged past its neighbour — so pairing it with
96
+ * a fresh seed would bend a curve nobody shaped. It was already evaluating as the straight line,
97
+ * and after seeding it still does.
98
+ *
99
+ * This is the *repair*, which is why it stays inert: it runs on ingestion, where a curve arriving
100
+ * from disk must come back doing exactly what it did. `easeBezierSegment` is the authoring seed.
101
+ */
102
+ export declare function withBezierHandles(keyframes: Keyframe[]): Keyframe[];
103
+ /**
104
+ * The segment leaving `index` shaped as an ease-in-out: control points at the thirds, each held
105
+ * at its own anchor's height, so the curve leaves flat and arrives flat.
106
+ *
107
+ * What an editor's Bezier button applies, and the one place seeding is *meant* to change the
108
+ * curve — pressing the button is an authoring action, and a mode switch that visibly did nothing
109
+ * reads as a broken button. `withBezierHandles` is the opposite: it repairs, so it stays inert.
110
+ *
111
+ * A segment already carrying both handles is returned untouched, so linear → bezier gives back
112
+ * the shape that was there rather than flattening it into a fresh ease.
113
+ */
114
+ export declare function easeBezierSegment(keyframes: Keyframe[], index: number): Keyframe[];
115
+ /**
116
+ * Split the Bezier segment leaving `index` at normalized `x`, inserting an anchor on the curve.
117
+ *
118
+ * de Casteljau, so the two halves together reproduce the original cubic exactly: adding a point to
119
+ * a curved stretch does not move it. Without this the halves are re-cut collinear and the shape is
120
+ * gone — an S-curve becomes a straight line the moment you click on it.
121
+ *
122
+ * The anchor lands *on* the curve rather than under the pointer, which is what preserving the shape
123
+ * means; a caller who wants it elsewhere drags it afterwards. When the segment is not a
124
+ * fully-handled Bezier this returns **the input array itself**, which is how `insertAnchor` tells
125
+ * that it declined — keep that identity if you touch the guards.
126
+ */
127
+ export declare function splitBezierSegment(keyframes: Keyframe[], index: number, x: number): Keyframe[];
128
+ /**
129
+ * A new anchor at `at`, and the index it landed on.
130
+ *
131
+ * A fully-handled Bezier segment is subdivided, which puts the anchor *on* the curve — preserving
132
+ * the shape is what a click into a curved stretch has to mean. Any other segment takes the point as
133
+ * given, and the new keyframe inherits that segment's mode either way, so a point dropped into a
134
+ * Bezier stretch carries the two handles that implies rather than straightening what follows.
135
+ *
136
+ * `at` is kept strictly inside the two ends: an anchor sharing an end's x is a zero-span segment,
137
+ * which silently changes the value the evaluator holds past that end.
138
+ */
139
+ export declare function insertAnchor(keyframes: Keyframe[], at: Point): {
140
+ keyframes: Keyframe[];
141
+ index: number;
142
+ };
143
+ /**
144
+ * A curve from an untrusted document — a config file or an IPC caller — or null when it
145
+ * cannot be made into one. Anchors are clamped into the unit square and sorted by x, which is
146
+ * what `evaluateCurve`'s scan assumes; handle y is left alone, since a control point outside
147
+ * 0..1 is legitimate and the evaluator clamps its result anyway.
148
+ */
149
+ export declare function sanitizeCurve(v: unknown): Curve | null;
150
+ /**
151
+ * The straight 0..1 line — what a binding without a curve already does, as an editable start.
152
+ *
153
+ * No handles: a `linear` keyframe's are never read, and one that outlives the segment it was cut
154
+ * for is what bends a curve on a mode switch. `withBezierHandles` cuts fresh ones against whatever
155
+ * the anchors are by then, so Bezier leaves the line where it is and only a drag shapes it.
156
+ */
157
+ export declare function identityCurve(): Curve;
158
+ /** A ready-made response shape that replaces the whole curve; `linear` is the identity. */
159
+ export type CurvePresetId = 'linear' | 'easeIn' | 'easeOut' | 'sCurve' | 'threshold' | 'steps';
160
+ export declare const CURVE_PRESET_IDS: readonly CurvePresetId[];
161
+ /**
162
+ * The shapes VBridger's shipped presets reach for, transcribed from their Unity curves
163
+ * (references/vbridger-desktop/saves/, `store[].curve.keys`). A Hermite key pair converts to a
164
+ * Bezier segment exactly — `P1 = A + Δx/3·(1, m₀)`, `P2 = B − Δx/3·(1, m₁)` — so these evaluate as
165
+ * the originals do:
166
+ *
167
+ * - `easeOut` is its mouth curve (`MouthOpen`, `JawOpen`: out-tangent 2, in-tangent 0) — opens
168
+ * twice as fast as linear and saturates; `easeIn` is the mirror.
169
+ * - `sCurve` is its head-angle curve (`FaceAngle`, `MouthPressLipOpen`: flat ends, slope 2 through
170
+ * rest) — twice as sensitive around rest, compressed at the extremes.
171
+ * - `threshold` and `steps` are what its step lists do, minus the hysteresis and hold: `steps`
172
+ * carries the `_Stepped` preset's `MouthOpen` levels (0 / .25 / .5 / 1 at .1 / .5 / .75).
173
+ */
174
+ export declare function curvePreset(id: CurvePresetId): Curve;
@@ -0,0 +1,399 @@
1
+ import { isFiniteNumber, isRecord } from "./guards.js";
2
+ import { clamp } from "./limits.js";
3
+ /** Smallest gap kept between an interior anchor and its neighbours, so no segment is zero-span. */
4
+ export const EDGE_EPSILON = 0.001;
5
+ const lerp = (a, b, t) => a + (b - a) * t;
6
+ /** Cubic Bezier at `t`, one axis. */
7
+ function cubic(p0, p1, p2, p3, t) {
8
+ const u = 1 - t;
9
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
10
+ }
11
+ /**
12
+ * `t` where the segment's x reaches `x`.
13
+ *
14
+ * Handle x-fractions are clamped to 0..1 by `handleX`, which makes x(t) monotonic and leaves
15
+ * exactly one root — so plain bisection finds it. nizima instead solves the cubic and picks the
16
+ * root nearest 0.5, which only differs for handles that overshoot their own segment; none of the
17
+ * curves it ships has one.
18
+ */
19
+ function tAtX(p0, p1, p2, p3, x) {
20
+ let lo = 0;
21
+ let hi = 1;
22
+ // 20 halvings ≈ 1e-6 on a unit domain; a fixed count keeps the cost flat and the result stable.
23
+ for (let i = 0; i < 20; i++) {
24
+ const mid = (lo + hi) / 2;
25
+ if (cubic(p0, p1, p2, p3, mid) < x)
26
+ lo = mid;
27
+ else
28
+ hi = mid;
29
+ }
30
+ return (lo + hi) / 2;
31
+ }
32
+ /**
33
+ * A handle's absolute x, from its segment-relative fraction.
34
+ *
35
+ * Exported so an editor draws a handle where the evaluator reads it — the clamp is the whole
36
+ * reason `tAtX`'s bisection has one root, and a second copy of it would drift.
37
+ */
38
+ export function handleX(from, to, fraction) {
39
+ return lerp(from, to, clamp(fraction, 0, 1));
40
+ }
41
+ /**
42
+ * The inverse of `handleX`: a handle's stored fraction, from its absolute x.
43
+ *
44
+ * Clamped for the same reason `handleX` is — a fraction outside 0..1 is re-clamped on read, so
45
+ * storing one would hand back a shape nobody drew.
46
+ */
47
+ export function handleFraction(from, to, x) {
48
+ return to === from ? 0 : clamp((x - from) / (to - from), 0, 1);
49
+ }
50
+ function segmentAt(left, right, x) {
51
+ switch (left.interpolation) {
52
+ case 'step':
53
+ return left.anchor.y;
54
+ case 'invertStep':
55
+ return right.anchor.y;
56
+ case 'bezier': {
57
+ // A bezier keyframe missing either handle has no curve to describe, so it degrades to the
58
+ // straight line rather than to nizima's (0,0) fallback, which would jump to the floor.
59
+ if (!left.next || !right.previous)
60
+ break;
61
+ const x0 = left.anchor.x;
62
+ const x3 = right.anchor.x;
63
+ const t = tAtX(x0, handleX(x0, x3, left.next.x), handleX(x0, x3, right.previous.x), x3, x);
64
+ return clamp(cubic(left.anchor.y, left.next.y, right.previous.y, right.anchor.y, t), 0, 1);
65
+ }
66
+ }
67
+ const span = right.anchor.x - left.anchor.x;
68
+ return span === 0 ? right.anchor.y : lerp(left.anchor.y, right.anchor.y, (x - left.anchor.x) / span);
69
+ }
70
+ /**
71
+ * The curve's output for a normalized input, both in 0..1.
72
+ *
73
+ * Outside the keyframes' span the end values are held. nizima instead returns the raw input
74
+ * below the first anchor and 0 above the last; both are unreachable for a curve spanning 0..1,
75
+ * and holding is what a caller clamping its input actually wants.
76
+ */
77
+ export function evaluateCurve(curve, x) {
78
+ const kf = curve.keyframes;
79
+ const first = kf[0];
80
+ const last = kf[kf.length - 1];
81
+ if (first === undefined || last === undefined)
82
+ return x;
83
+ if (x <= first.anchor.x)
84
+ return first.anchor.y;
85
+ if (x >= last.anchor.x)
86
+ return last.anchor.y;
87
+ let i = 0;
88
+ while (i < kf.length - 2 && x > (kf[i + 1]?.anchor.x ?? 1))
89
+ i++;
90
+ const left = kf[i];
91
+ const right = kf[i + 1];
92
+ return left === undefined || right === undefined ? first.anchor.y : segmentAt(left, right, x);
93
+ }
94
+ /**
95
+ * The keyframe's draggable handles, in curve coordinates — its outgoing one when it governs a
96
+ * Bezier segment, its incoming one when the keyframe before it does.
97
+ *
98
+ * `handleX` rather than a second clamp-and-lerp: a handle has to read back where the evaluator
99
+ * looks for it. `y` is absolute and deliberately unclamped — `sanitizeCurve` keeps a control point
100
+ * outside 0..1 and only the cubic's *result* is clamped, so clamping here would report a
101
+ * legitimate overshoot at the wrong height.
102
+ */
103
+ export function handlesFor(keyframes, index) {
104
+ const out = [];
105
+ const kf = keyframes[index];
106
+ if (kf === undefined)
107
+ return out;
108
+ const after = keyframes[index + 1];
109
+ if (kf.interpolation === 'bezier' && after !== undefined && kf.next !== undefined) {
110
+ const span = [kf.anchor.x, after.anchor.x];
111
+ out.push({ side: 'next', x: handleX(span[0], span[1], kf.next.x), y: kf.next.y, span });
112
+ }
113
+ const before = keyframes[index - 1];
114
+ if (before?.interpolation === 'bezier' && kf.previous !== undefined) {
115
+ const span = [before.anchor.x, kf.anchor.x];
116
+ out.push({ side: 'previous', x: handleX(span[0], span[1], kf.previous.x), y: kf.previous.y, span });
117
+ }
118
+ return out;
119
+ }
120
+ /**
121
+ * How far an anchor may travel in x, as `[lo, hi]`.
122
+ *
123
+ * The ends are pinned to where they are: a curve that no longer spans 0..1 leaves the evaluator
124
+ * holding an end value over the gap, which reads as the binding going dead. An interior anchor
125
+ * stops at its neighbours rather than swapping past them — a swap renames every index mid-drag,
126
+ * and re-pairs Bezier handles with endpoints they were not cut for.
127
+ *
128
+ * One statement of the rule, so an editor's drag and its typed field cannot drift apart.
129
+ */
130
+ export function anchorBounds(keyframes, index) {
131
+ const held = keyframes[index];
132
+ if (held === undefined)
133
+ return [0, 1];
134
+ if (index === 0 || index === keyframes.length - 1)
135
+ return [held.anchor.x, held.anchor.x];
136
+ return [(keyframes[index - 1]?.anchor.x ?? 0) + EDGE_EPSILON, (keyframes[index + 1]?.anchor.x ?? 1) - EDGE_EPSILON];
137
+ }
138
+ /**
139
+ * Every Bezier segment carries both handles, seeded collinear unless it already has both.
140
+ *
141
+ * `segmentAt` degrades a half-handled segment to the straight line, so a Bezier keyframe without
142
+ * its handles names a shape nothing can reach and an editor has nothing to draw or drag — which
143
+ * is what a hand-written config, a plugin, or an anchor added into a Bezier segment produces.
144
+ * Seeding at the thirds reproduces that straight line exactly, so this never changes what a curve
145
+ * does; dragging a handle is what shapes it. Assumes keyframes sorted by `anchor.x`.
146
+ *
147
+ * A half-handled segment is seeded on **both** ends rather than only the empty one. Handles belong
148
+ * to a segment, not a keyframe: a lone survivor was calibrated against endpoints that no longer
149
+ * pair with it — an anchor was added, removed, or dragged past its neighbour — so pairing it with
150
+ * a fresh seed would bend a curve nobody shaped. It was already evaluating as the straight line,
151
+ * and after seeding it still does.
152
+ *
153
+ * This is the *repair*, which is why it stays inert: it runs on ingestion, where a curve arriving
154
+ * from disk must come back doing exactly what it did. `easeBezierSegment` is the authoring seed.
155
+ */
156
+ export function withBezierHandles(keyframes) {
157
+ const out = [...keyframes];
158
+ for (let i = 0; i < out.length - 1; i++) {
159
+ const left = out[i];
160
+ const right = out[i + 1];
161
+ if (left === undefined || right === undefined || left.interpolation !== 'bezier')
162
+ continue;
163
+ if (left.next !== undefined && right.previous !== undefined)
164
+ continue;
165
+ out[i] = { ...left, next: { x: 1 / 3, y: lerp(left.anchor.y, right.anchor.y, 1 / 3) } };
166
+ out[i + 1] = { ...right, previous: { x: 2 / 3, y: lerp(left.anchor.y, right.anchor.y, 2 / 3) } };
167
+ }
168
+ return out;
169
+ }
170
+ /**
171
+ * The segment leaving `index` shaped as an ease-in-out: control points at the thirds, each held
172
+ * at its own anchor's height, so the curve leaves flat and arrives flat.
173
+ *
174
+ * What an editor's Bezier button applies, and the one place seeding is *meant* to change the
175
+ * curve — pressing the button is an authoring action, and a mode switch that visibly did nothing
176
+ * reads as a broken button. `withBezierHandles` is the opposite: it repairs, so it stays inert.
177
+ *
178
+ * A segment already carrying both handles is returned untouched, so linear → bezier gives back
179
+ * the shape that was there rather than flattening it into a fresh ease.
180
+ */
181
+ export function easeBezierSegment(keyframes, index) {
182
+ const left = keyframes[index];
183
+ const right = keyframes[index + 1];
184
+ if (left === undefined || right === undefined || left.interpolation !== 'bezier')
185
+ return keyframes;
186
+ if (left.next !== undefined && right.previous !== undefined)
187
+ return keyframes;
188
+ const out = [...keyframes];
189
+ out[index] = { ...left, next: { x: 1 / 3, y: left.anchor.y } };
190
+ out[index + 1] = { ...right, previous: { x: 2 / 3, y: right.anchor.y } };
191
+ return out;
192
+ }
193
+ /** Both axes of `lerp`, for the de Casteljau construction. */
194
+ function lerpPoint(a, b, t) {
195
+ return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) };
196
+ }
197
+ /**
198
+ * Split the Bezier segment leaving `index` at normalized `x`, inserting an anchor on the curve.
199
+ *
200
+ * de Casteljau, so the two halves together reproduce the original cubic exactly: adding a point to
201
+ * a curved stretch does not move it. Without this the halves are re-cut collinear and the shape is
202
+ * gone — an S-curve becomes a straight line the moment you click on it.
203
+ *
204
+ * The anchor lands *on* the curve rather than under the pointer, which is what preserving the shape
205
+ * means; a caller who wants it elsewhere drags it afterwards. When the segment is not a
206
+ * fully-handled Bezier this returns **the input array itself**, which is how `insertAnchor` tells
207
+ * that it declined — keep that identity if you touch the guards.
208
+ */
209
+ export function splitBezierSegment(keyframes, index, x) {
210
+ const left = keyframes[index];
211
+ const right = keyframes[index + 1];
212
+ if (left === undefined || right === undefined || left.interpolation !== 'bezier')
213
+ return keyframes;
214
+ if (left.next === undefined || right.previous === undefined)
215
+ return keyframes;
216
+ const p0 = left.anchor;
217
+ const p3 = right.anchor;
218
+ const p1 = { x: handleX(p0.x, p3.x, left.next.x), y: left.next.y };
219
+ const p2 = { x: handleX(p0.x, p3.x, right.previous.x), y: right.previous.y };
220
+ const t = tAtX(p0.x, p1.x, p2.x, p3.x, x);
221
+ const a = lerpPoint(p0, p1, t);
222
+ const b = lerpPoint(p1, p2, t);
223
+ const c = lerpPoint(p2, p3, t);
224
+ const d = lerpPoint(a, b, t);
225
+ const e = lerpPoint(b, c, t);
226
+ const anchor = lerpPoint(d, e, t);
227
+ const out = [...keyframes];
228
+ // The right keyframe is rewritten before the splice, while it is still at `index + 1`.
229
+ out[index] = { ...left, next: { x: handleFraction(p0.x, anchor.x, a.x), y: a.y } };
230
+ out[index + 1] = { ...right, previous: { x: handleFraction(anchor.x, p3.x, c.x), y: c.y } };
231
+ out.splice(index + 1, 0, {
232
+ anchor,
233
+ interpolation: 'bezier',
234
+ previous: { x: handleFraction(p0.x, anchor.x, d.x), y: d.y },
235
+ next: { x: handleFraction(anchor.x, p3.x, e.x), y: e.y },
236
+ });
237
+ return out;
238
+ }
239
+ /**
240
+ * A new anchor at `at`, and the index it landed on.
241
+ *
242
+ * A fully-handled Bezier segment is subdivided, which puts the anchor *on* the curve — preserving
243
+ * the shape is what a click into a curved stretch has to mean. Any other segment takes the point as
244
+ * given, and the new keyframe inherits that segment's mode either way, so a point dropped into a
245
+ * Bezier stretch carries the two handles that implies rather than straightening what follows.
246
+ *
247
+ * `at` is kept strictly inside the two ends: an anchor sharing an end's x is a zero-span segment,
248
+ * which silently changes the value the evaluator holds past that end.
249
+ */
250
+ export function insertAnchor(keyframes, at) {
251
+ const first = keyframes[0];
252
+ const last = keyframes[keyframes.length - 1];
253
+ if (first === undefined || last === undefined)
254
+ return { keyframes, index: 0 };
255
+ const x = clamp(at.x, first.anchor.x + EDGE_EPSILON, last.anchor.x - EDGE_EPSILON);
256
+ let index = 0;
257
+ for (let i = 0; i < keyframes.length - 1; i++)
258
+ if ((keyframes[i]?.anchor.x ?? 0) <= x)
259
+ index = i;
260
+ const subdivided = splitBezierSegment(keyframes, index, x);
261
+ if (subdivided !== keyframes)
262
+ return { keyframes: subdivided, index: index + 1 };
263
+ const held = {
264
+ anchor: { x, y: clamp(at.y, 0, 1) },
265
+ interpolation: keyframes[index]?.interpolation ?? 'linear',
266
+ };
267
+ // Seeded here as well as at ingestion: this is the other way a Bezier keyframe is born without
268
+ // its handles, and `x` sits strictly inside the segment, so the insert cannot reorder.
269
+ const next = withBezierHandles([...keyframes.slice(0, index + 1), held, ...keyframes.slice(index + 1)]);
270
+ return { keyframes: next, index: index + 1 };
271
+ }
272
+ function point(v, clampToUnit) {
273
+ if (!isRecord(v) || !isFiniteNumber(v.x) || !isFiniteNumber(v.y))
274
+ return null;
275
+ return clampToUnit ? { x: clamp(v.x, 0, 1), y: clamp(v.y, 0, 1) } : { x: v.x, y: v.y };
276
+ }
277
+ const INTERPOLATIONS = ['linear', 'step', 'invertStep', 'bezier'];
278
+ /**
279
+ * A curve from an untrusted document — a config file or an IPC caller — or null when it
280
+ * cannot be made into one. Anchors are clamped into the unit square and sorted by x, which is
281
+ * what `evaluateCurve`'s scan assumes; handle y is left alone, since a control point outside
282
+ * 0..1 is legitimate and the evaluator clamps its result anyway.
283
+ */
284
+ export function sanitizeCurve(v) {
285
+ if (!isRecord(v) || !Array.isArray(v.keyframes))
286
+ return null;
287
+ const keyframes = [];
288
+ for (const raw of v.keyframes) {
289
+ if (!isRecord(raw))
290
+ continue;
291
+ const anchor = point(raw.anchor, true);
292
+ if (anchor === null)
293
+ continue;
294
+ const interpolation = INTERPOLATIONS.find(i => i === raw.interpolation) ?? 'linear';
295
+ const next = point(raw.next, false);
296
+ const previous = point(raw.previous, false);
297
+ keyframes.push({
298
+ anchor,
299
+ interpolation,
300
+ ...(next === null ? {} : { next }),
301
+ ...(previous === null ? {} : { previous }),
302
+ });
303
+ }
304
+ // One anchor describes no segment, so there is nothing a curve would say that the
305
+ // binding's own ranges do not.
306
+ if (keyframes.length < 2)
307
+ return null;
308
+ keyframes.sort((a, b) => a.anchor.x - b.anchor.x);
309
+ // Seeded here rather than only in the editor: this is where curves arrive from disk and from
310
+ // the plugin API, and a Bezier keyframe that lost its handles there is invisible in both.
311
+ return { keyframes: withBezierHandles(keyframes) };
312
+ }
313
+ /**
314
+ * The straight 0..1 line — what a binding without a curve already does, as an editable start.
315
+ *
316
+ * No handles: a `linear` keyframe's are never read, and one that outlives the segment it was cut
317
+ * for is what bends a curve on a mode switch. `withBezierHandles` cuts fresh ones against whatever
318
+ * the anchors are by then, so Bezier leaves the line where it is and only a drag shapes it.
319
+ */
320
+ export function identityCurve() {
321
+ return {
322
+ keyframes: [
323
+ { anchor: { x: 0, y: 0 }, interpolation: 'linear' },
324
+ { anchor: { x: 1, y: 1 }, interpolation: 'linear' },
325
+ ],
326
+ };
327
+ }
328
+ export const CURVE_PRESET_IDS = [
329
+ 'linear',
330
+ 'easeIn',
331
+ 'easeOut',
332
+ 'sCurve',
333
+ 'threshold',
334
+ 'steps',
335
+ ];
336
+ /**
337
+ * The shapes VBridger's shipped presets reach for, transcribed from their Unity curves
338
+ * (references/vbridger-desktop/saves/, `store[].curve.keys`). A Hermite key pair converts to a
339
+ * Bezier segment exactly — `P1 = A + Δx/3·(1, m₀)`, `P2 = B − Δx/3·(1, m₁)` — so these evaluate as
340
+ * the originals do:
341
+ *
342
+ * - `easeOut` is its mouth curve (`MouthOpen`, `JawOpen`: out-tangent 2, in-tangent 0) — opens
343
+ * twice as fast as linear and saturates; `easeIn` is the mirror.
344
+ * - `sCurve` is its head-angle curve (`FaceAngle`, `MouthPressLipOpen`: flat ends, slope 2 through
345
+ * rest) — twice as sensitive around rest, compressed at the extremes.
346
+ * - `threshold` and `steps` are what its step lists do, minus the hysteresis and hold: `steps`
347
+ * carries the `_Stepped` preset's `MouthOpen` levels (0 / .25 / .5 / 1 at .1 / .5 / .75).
348
+ */
349
+ export function curvePreset(id) {
350
+ switch (id) {
351
+ case 'linear':
352
+ return identityCurve();
353
+ case 'easeIn':
354
+ return {
355
+ keyframes: [
356
+ { anchor: { x: 0, y: 0 }, next: { x: 1 / 3, y: 0 }, interpolation: 'bezier' },
357
+ { anchor: { x: 1, y: 1 }, previous: { x: 2 / 3, y: 1 / 3 }, interpolation: 'linear' },
358
+ ],
359
+ };
360
+ case 'easeOut':
361
+ return {
362
+ keyframes: [
363
+ { anchor: { x: 0, y: 0 }, next: { x: 1 / 3, y: 2 / 3 }, interpolation: 'bezier' },
364
+ { anchor: { x: 1, y: 1 }, previous: { x: 2 / 3, y: 1 }, interpolation: 'linear' },
365
+ ],
366
+ };
367
+ case 'sCurve':
368
+ return {
369
+ keyframes: [
370
+ { anchor: { x: 0, y: 0 }, next: { x: 1 / 3, y: 0 }, interpolation: 'bezier' },
371
+ {
372
+ anchor: { x: 0.5, y: 0.5 },
373
+ previous: { x: 2 / 3, y: 1 / 6 },
374
+ next: { x: 1 / 3, y: 5 / 6 },
375
+ interpolation: 'bezier',
376
+ },
377
+ { anchor: { x: 1, y: 1 }, previous: { x: 2 / 3, y: 1 }, interpolation: 'linear' },
378
+ ],
379
+ };
380
+ case 'threshold':
381
+ return {
382
+ keyframes: [
383
+ { anchor: { x: 0, y: 0 }, interpolation: 'step' },
384
+ { anchor: { x: 0.5, y: 1 }, interpolation: 'linear' },
385
+ { anchor: { x: 1, y: 1 }, interpolation: 'linear' },
386
+ ],
387
+ };
388
+ case 'steps':
389
+ return {
390
+ keyframes: [
391
+ { anchor: { x: 0, y: 0 }, interpolation: 'step' },
392
+ { anchor: { x: 0.1, y: 0.25 }, interpolation: 'step' },
393
+ { anchor: { x: 0.5, y: 0.5 }, interpolation: 'step' },
394
+ { anchor: { x: 0.75, y: 1 }, interpolation: 'linear' },
395
+ { anchor: { x: 1, y: 1 }, interpolation: 'linear' },
396
+ ],
397
+ };
398
+ }
399
+ }
@@ -12,7 +12,9 @@ const SHORTCUT_ACTION_KIND_LABELS = {
12
12
  'effect-toggle': 'Toggle Effect',
13
13
  'effect-params': 'Effect Settings',
14
14
  'camera-pose': 'Camera Position',
15
+ 'reset-camera': 'Reset Camera',
15
16
  'layer-visibility': 'Layer Visibility',
17
+ 'stream-mode': 'Stream Mode',
16
18
  };
17
19
  /** English label for one shortcut action kind; kinds newer than this SDK read `Action`. */
18
20
  export function shortcutActionLabel(kind) {
@@ -40,6 +40,8 @@ export declare const SCENE_EXPOSURE_MAX = 4;
40
40
  /** Vertical field of view in degrees; the band either side of a portrait lens, before the framing distorts. */
41
41
  export declare const SCENE_FOV_MIN = 10;
42
42
  export declare const SCENE_FOV_MAX = 90;
43
+ /** ×2 doubles Cubism's own breath amplitude — past that the head sway reads as a nod, not a breath. */
44
+ export declare const BREATH_DEPTH_MAX = 2;
43
45
  export declare const MTOON_NORMAL_SCALE_MAX = 2;
44
46
  export declare const MTOON_RIM_MAX = 2;
45
47
  /** ×0 flattens the Fresnel to a flood fill, ×4 tightens a default power-5 rim to a hairline. */
@@ -55,6 +55,8 @@ export const SCENE_EXPOSURE_MAX = 4;
55
55
  /** Vertical field of view in degrees; the band either side of a portrait lens, before the framing distorts. */
56
56
  export const SCENE_FOV_MIN = 10;
57
57
  export const SCENE_FOV_MAX = 90;
58
+ /** ×2 doubles Cubism's own breath amplitude — past that the head sway reads as a nod, not a breath. */
59
+ export const BREATH_DEPTH_MAX = 2;
58
60
  export const MTOON_NORMAL_SCALE_MAX = 2;
59
61
  export const MTOON_RIM_MAX = 2;
60
62
  /** ×0 flattens the Fresnel to a flood fill, ×4 tightens a default power-5 rim to a hairline. */
@@ -0,0 +1,13 @@
1
+ import type { BindingInput } from '../wire/types.ts';
2
+ /** One weighted input of a binding's sum. `input` is canonical: a twin alias (`JawOpen`) is read as its ARKit id. */
3
+ export interface BindingTerm {
4
+ input: BindingInput;
5
+ weight: number;
6
+ }
7
+ /**
8
+ * The weighted sum of the terms `inputs` carries, or null when it carries none. An absent term
9
+ * reads as 0 — VBridger's convention for an unset variable — so injecting or scrubbing one input
10
+ * of a composite binding still drives it. Shared by the stage's rule evaluation and the editor's
11
+ * curve marker, so both feed the curve the same number.
12
+ */
13
+ export declare function sumTerms(terms: readonly BindingTerm[], inputs: Partial<Record<string, number>>): number | null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The weighted sum of the terms `inputs` carries, or null when it carries none. An absent term
3
+ * reads as 0 — VBridger's convention for an unset variable — so injecting or scrubbing one input
4
+ * of a composite binding still drives it. Shared by the stage's rule evaluation and the editor's
5
+ * curve marker, so both feed the curve the same number.
6
+ */
7
+ export function sumTerms(terms, inputs) {
8
+ let sum = 0;
9
+ let any = false;
10
+ for (const t of terms) {
11
+ const v = inputs[t.input];
12
+ if (v === undefined)
13
+ continue;
14
+ any = true;
15
+ sum += t.weight * v;
16
+ }
17
+ return any ? sum : null;
18
+ }
@@ -15,5 +15,17 @@ export declare const CLOSE_SERVER_STOPPING = 1001;
15
15
  export declare const INJECT_LEASE_TTL_MS = 1000;
16
16
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
17
17
  export declare const INJECT_HEARTBEAT_MS = 100;
18
+ /**
19
+ * How often the binding editor's live indicators sample the tracking inputs (~20/s) — both the
20
+ * stage's push and the editor's poll of it, which must agree. Fast enough to read as live on a
21
+ * level bar, slow enough that neither the worker port nor the panel relay carries per-frame traffic.
22
+ */
23
+ export declare const TRACKING_INPUTS_MS = 50;
24
+ /**
25
+ * How long a scrubbed tracking input holds without being re-sent. The editor re-asserts on its
26
+ * own timer, so this is pure dead-man's handle: a closed control window, a reload, or a drag
27
+ * that never gets its pointerup releases the model instead of stranding it.
28
+ */
29
+ export declare const INPUT_OVERRIDE_TTL_MS = 1000;
18
30
  /** Canonical map key for an injection target — JSON framing so ids containing spaces cannot collide. */
19
31
  export declare function injectTargetKey(t: InjectTarget): string;
@@ -14,6 +14,18 @@ export const CLOSE_SERVER_STOPPING = 1001;
14
14
  export const INJECT_LEASE_TTL_MS = 1000;
15
15
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
16
16
  export const INJECT_HEARTBEAT_MS = 100;
17
+ /**
18
+ * How often the binding editor's live indicators sample the tracking inputs (~20/s) — both the
19
+ * stage's push and the editor's poll of it, which must agree. Fast enough to read as live on a
20
+ * level bar, slow enough that neither the worker port nor the panel relay carries per-frame traffic.
21
+ */
22
+ export const TRACKING_INPUTS_MS = 50;
23
+ /**
24
+ * How long a scrubbed tracking input holds without being re-sent. The editor re-asserts on its
25
+ * own timer, so this is pure dead-man's handle: a closed control window, a reload, or a drag
26
+ * that never gets its pointerup releases the model instead of stranding it.
27
+ */
28
+ export const INPUT_OVERRIDE_TTL_MS = 1000;
17
29
  /** Canonical map key for an injection target — JSON framing so ids containing spaces cannot collide. */
18
30
  export function injectTargetKey(t) {
19
31
  return JSON.stringify([t.type, t.id, t.instanceId ?? '']);
@@ -98,7 +98,6 @@ export declare const requestSchemas: {
98
98
  'tracking.addSource': z.ZodObject<{
99
99
  kind: z.ZodEnum<{
100
100
  "vts-ios": "vts-ios";
101
- "vts-ios-native": "vts-ios-native";
102
101
  ifacialmocap: "ifacialmocap";
103
102
  vmc: "vmc";
104
103
  }>;
@@ -157,7 +156,6 @@ export declare const requestSchemas: {
157
156
  'tracking.setSource': z.ZodObject<{
158
157
  source: z.ZodEnum<{
159
158
  "vts-ios": "vts-ios";
160
- "vts-ios-native": "vts-ios-native";
161
159
  ifacialmocap: "ifacialmocap";
162
160
  }>;
163
161
  }, z.core.$strip>;
@@ -1,7 +1,7 @@
1
1
  import * as z from 'zod';
2
2
  import { isRecord } from "../values/guards.js";
3
3
  import { SPEECH_URL_MAX_LENGTH, STORAGE_KEY_MAX_LENGTH, STORAGE_VALUE_MAX_LENGTH } from "../values/limits.js";
4
- import { ASSET_KINDS } from "./types.js";
4
+ import { ASSET_KINDS, POSE_SOURCE_IDS, TRACKING_SOURCE_IDS, TRACKING_SOURCE_KINDS } from "./types.js";
5
5
  // Runtime validation for the request side of the wire. Schemas exist for the
6
6
  // methods whose params the app's main process consumes directly; methods without
7
7
  // one are stage-owned — the renderer validates and heals them (same rules as the
@@ -78,7 +78,9 @@ export const requestSchemas = {
78
78
  'hotkey.trigger': z.object({ hotkeyId: nonEmpty }),
79
79
  'shortcut.trigger': z.object({ shortcutId: nonEmpty }),
80
80
  'tracking.addSource': z.object({
81
- kind: z.enum(['vts-ios', 'vts-ios-native', 'ifacialmocap', 'vmc']),
81
+ // Straight off the kind registry, for the same reason as ASSET_KINDS below: a schema
82
+ // narrower than the constant answers invalid-params for a kind the app itself accepts.
83
+ kind: z.enum(TRACKING_SOURCE_KINDS),
82
84
  name: z.string().optional(),
83
85
  phoneIp: nonEmpty.nullable().optional(),
84
86
  port: port.optional(),
@@ -97,9 +99,9 @@ export const requestSchemas = {
97
99
  'asset.register': z.object({ path: nonEmpty, want: z.enum(ASSET_KINDS).optional() }),
98
100
  'settings.patch': z.object({ settings: SettingsPatchSchema }),
99
101
  'tracking.setEnabled': z.object({ enabled: z.boolean() }),
100
- 'tracking.setSource': z.object({ source: z.enum(['vts-ios', 'vts-ios-native', 'ifacialmocap']) }),
102
+ 'tracking.setSource': z.object({ source: z.enum(TRACKING_SOURCE_IDS) }),
101
103
  'pose.setEnabled': z.object({ enabled: z.boolean() }),
102
- 'pose.setSource': z.object({ source: z.enum(['vmc']) }),
104
+ 'pose.setSource': z.object({ source: z.enum(POSE_SOURCE_IDS) }),
103
105
  'pose.setPort': z.object({ port }),
104
106
  'session.identify': z.object({
105
107
  name: z.string().trim().min(1).max(64),
@@ -1,3 +1,4 @@
1
+ import { type ArkitInputName } from '../values/arkit.ts';
1
2
  export type ModelFormat = 'live2d' | 'vrm';
2
3
  /** Where an item came from: shipped with the app, or added by the user. */
3
4
  export type ContentOrigin = 'bundled' | 'user';
@@ -145,6 +146,13 @@ export interface SceneModelItem {
145
146
  vrm: VrmPlacement;
146
147
  idleAnimation: boolean;
147
148
  idleClip: string;
149
+ /**
150
+ * Live2D only: the engine's built-in idle breath. Beyond `ParamBreath` it also sways
151
+ * `ParamAngle*`/`ParamBodyAngleX`, so a rig whose head ranges are tuned tightly may want it off.
152
+ */
153
+ breath: boolean;
154
+ /** Multiplier on the breath's amplitude; 1 is the Cubism sample's own depth. */
155
+ breathDepth: number;
148
156
  /** MToon material fine-tuning, VRM only. */
149
157
  mtoon: MToonTuning;
150
158
  }
@@ -792,7 +800,7 @@ export interface HotkeyState extends HotkeyConfig {
792
800
  registered: string[];
793
801
  }
794
802
  /** Action kinds an app shortcut can carry today; servers may send kinds newer than this list. */
795
- export declare const SHORTCUT_ACTION_KINDS: readonly ["effect-toggle", "effect-params", "camera-pose", "layer-visibility"];
803
+ export declare const SHORTCUT_ACTION_KINDS: readonly ["effect-toggle", "effect-params", "camera-pose", "reset-camera", "layer-visibility", "stream-mode"];
796
804
  export type ShortcutActionKind = (typeof SHORTCUT_ACTION_KINDS)[number];
797
805
  /**
798
806
  * One app-level global shortcut (a multi-action macro) as clients see it. Action
@@ -818,7 +826,7 @@ export interface ExpressionPersistence {
818
826
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
819
827
  [key: string]: JsonValue;
820
828
  };
821
- export declare const TRACKING_SOURCE_IDS: readonly ["vts-ios", "vts-ios-native", "ifacialmocap"];
829
+ export declare const TRACKING_SOURCE_IDS: readonly ["vts-ios", "ifacialmocap"];
822
830
  export type TrackingSourceId = (typeof TRACKING_SOURCE_IDS)[number];
823
831
  /** Body-pose protocols. Every one so far is UDP with a configurable port. */
824
832
  export declare const POSE_SOURCE_IDS: readonly ["vmc"];
@@ -826,7 +834,7 @@ export type PoseSourceId = (typeof POSE_SOURCE_IDS)[number];
826
834
  export type TrackingStatus = 'off' | 'waiting' | 'tracking' | 'no-face';
827
835
  export type PoseStatus = 'off' | 'waiting' | 'tracking';
828
836
  /** Every protocol a tracking source instance can speak; face kinds plus the body (vmc) kind. */
829
- export declare const TRACKING_SOURCE_KINDS: readonly ["vts-ios", "vts-ios-native", "ifacialmocap", "vmc"];
837
+ export declare const TRACKING_SOURCE_KINDS: readonly ["vts-ios", "ifacialmocap", "vmc"];
830
838
  export type TrackingSourceKind = (typeof TRACKING_SOURCE_KINDS)[number];
831
839
  /** Whether a source kind feeds the face channel (vmc is the body channel). */
832
840
  export declare function isFaceSourceKind(kind: TrackingSourceKind): kind is TrackingSourceId;
@@ -897,9 +905,43 @@ export interface SettingsPatch {
897
905
  effectsQuality?: EffectsQuality;
898
906
  };
899
907
  }
900
- /** VTS's input vocabulary plus the VBridger-standard extras — the valid `id`s for `input` inject targets (and the names a model's `.vtube.json` references). */
901
- export declare const INPUT_NAMES: readonly ["FaceAngleX", "FaceAngleY", "FaceAngleZ", "FacePositionX", "FacePositionY", "FacePositionZ", "EyeOpenLeft", "EyeOpenRight", "EyeLeftX", "EyeLeftY", "EyeRightX", "EyeRightY", "Brows", "BrowLeftY", "BrowRightY", "MouthSmile", "MouthOpen", "MouthX", "CheekPuff", "JawOpen", "JawX", "TongueOut", "MouthPucker", "MouthFunnel", "MouthShrug", "MouthPress", "MouthPressLipOpen"];
902
- export type InputName = (typeof INPUT_NAMES)[number];
908
+ /** VTS's input vocabulary, plus `JawOpen` the derived half of {@link INPUT_NAMES}. */
909
+ declare const VTS_INPUT_NAMES: readonly ["FaceAngleX", "FaceAngleY", "FaceAngleZ", "FacePositionX", "FacePositionY", "FacePositionZ", "EyeOpenLeft", "EyeOpenRight", "EyeLeftX", "EyeLeftY", "EyeRightX", "EyeRightY", "Brows", "BrowLeftY", "BrowRightY", "MouthSmile", "MouthOpen", "MouthX", "CheekPuff", "JawOpen", "TongueOut"];
910
+ type VtsInputName = (typeof VTS_INPUT_NAMES)[number];
911
+ /**
912
+ * The valid `id`s for `input` inject targets, and the names a model's `.vtube.json`
913
+ * references: VTS's derived vocabulary plus every raw ARKit channel.
914
+ */
915
+ export declare const INPUT_NAMES: readonly InputName[];
916
+ export type InputName = VtsInputName | ArkitInputName;
917
+ /** Whether an untrusted string names a tracking input — the guard every wire boundary needs. */
918
+ export declare function isInputName(v: string): v is InputName;
919
+ /**
920
+ * VTS-vocabulary inputs that are the same float as one raw ARKit channel (the desktop derives
921
+ * exactly these three verbatim; everything else it averages, floors or gains). Either id is
922
+ * accepted on the wire and in files, and both are read as the ARKit one.
923
+ */
924
+ export declare const ARKIT_TWINS: {
925
+ readonly JawOpen: "ARKitJawOpen";
926
+ readonly TongueOut: "ARKitTongueOut";
927
+ readonly CheekPuff: "ARKitCheekPuff";
928
+ };
929
+ /** A wire alias in {@link ARKIT_TWINS}. */
930
+ type ArkitTwinAlias = keyof typeof ARKIT_TWINS;
931
+ /**
932
+ * What a binding may read: every input but the three wire aliases in `ARKIT_TWINS`, which are
933
+ * read as their ARKit twin. Rules, and the editor's rows, are typed over this.
934
+ */
935
+ export type BindingInput = Exclude<InputName, ArkitTwinAlias>;
936
+ /** {@link INPUT_NAMES} minus the aliases, in the same order. */
937
+ export declare const BINDING_INPUT_NAMES: readonly BindingInput[];
938
+ /** The id an input is read as: its ARKit twin when it has one, else itself. */
939
+ export declare function arkitTwinOf(input: InputName): BindingInput;
940
+ /**
941
+ * Each input's natural span — the units a sender should write, and the input range a new
942
+ * binding starts from. Head angles are **degrees**; the rest are unitless.
943
+ */
944
+ export declare const INPUT_RANGES: Record<InputName, readonly [number, number]>;
903
945
  /**
904
946
  * What an injected value drives:
905
947
  * - `input` — a VTS-vocabulary tracking input (`MouthOpen`, `FaceAngleX`, …), mapped
@@ -920,3 +962,4 @@ export interface InjectEntry extends InjectTarget {
920
962
  /** 0..1 blend over whatever else drives the parameter; default 1. */
921
963
  weight?: number;
922
964
  }
965
+ export {};
@@ -1,6 +1,7 @@
1
1
  // The API's data model: the entity shapes that requests, responses, and events
2
2
  // carry. Structural mirrors of the app's scene/settings models, minus anything
3
3
  // filesystem-shaped — model refs are sanitized to ids, never directories.
4
+ import { ARKIT_INPUT_NAMES } from "../values/arkit.js";
4
5
  /**
5
6
  * How a registered file is labelled. Wider than an object's content kinds: `.hdr` is only
6
7
  * ever an environment map, and a `.vmd` splits by content — `cameraMotion` for one that
@@ -28,9 +29,16 @@ export function isAppCapability(v) {
28
29
  }
29
30
  // ---- App shortcuts --------------------------------------------------------------
30
31
  /** Action kinds an app shortcut can carry today; servers may send kinds newer than this list. */
31
- export const SHORTCUT_ACTION_KINDS = ['effect-toggle', 'effect-params', 'camera-pose', 'layer-visibility'];
32
+ export const SHORTCUT_ACTION_KINDS = [
33
+ 'effect-toggle',
34
+ 'effect-params',
35
+ 'camera-pose',
36
+ 'reset-camera',
37
+ 'layer-visibility',
38
+ 'stream-mode',
39
+ ];
32
40
  // ---- Settings ------------------------------------------------------------------
33
- export const TRACKING_SOURCE_IDS = ['vts-ios', 'vts-ios-native', 'ifacialmocap'];
41
+ export const TRACKING_SOURCE_IDS = ['vts-ios', 'ifacialmocap'];
34
42
  /** Body-pose protocols. Every one so far is UDP with a configurable port. */
35
43
  export const POSE_SOURCE_IDS = ['vmc'];
36
44
  /** Every protocol a tracking source instance can speak; face kinds plus the body (vmc) kind. */
@@ -46,8 +54,8 @@ export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
46
54
  */
47
55
  export const FPS_LIMIT_PRESETS = [0, 15, 30, 60, 90];
48
56
  // ---- Injection -----------------------------------------------------------------
49
- /** VTS's input vocabulary plus the VBridger-standard extras — the valid `id`s for `input` inject targets (and the names a model's `.vtube.json` references). */
50
- export const INPUT_NAMES = [
57
+ /** VTS's input vocabulary, plus `JawOpen` the derived half of {@link INPUT_NAMES}. */
58
+ const VTS_INPUT_NAMES = [
51
59
  'FaceAngleX',
52
60
  'FaceAngleY',
53
61
  'FaceAngleZ',
@@ -68,11 +76,81 @@ export const INPUT_NAMES = [
68
76
  'MouthX',
69
77
  'CheekPuff',
70
78
  'JawOpen',
71
- 'JawX',
72
79
  'TongueOut',
73
- 'MouthPucker',
74
- 'MouthFunnel',
75
- 'MouthShrug',
76
- 'MouthPress',
77
- 'MouthPressLipOpen',
78
80
  ];
81
+ /**
82
+ * The valid `id`s for `input` inject targets, and the names a model's `.vtube.json`
83
+ * references: VTS's derived vocabulary plus every raw ARKit channel.
84
+ */
85
+ export const INPUT_NAMES = [...VTS_INPUT_NAMES, ...ARKIT_INPUT_NAMES];
86
+ const INPUT_NAME_SET = new Set(INPUT_NAMES);
87
+ /** Whether an untrusted string names a tracking input — the guard every wire boundary needs. */
88
+ export function isInputName(v) {
89
+ return INPUT_NAME_SET.has(v);
90
+ }
91
+ /**
92
+ * VTS-vocabulary inputs that are the same float as one raw ARKit channel (the desktop derives
93
+ * exactly these three verbatim; everything else it averages, floors or gains). Either id is
94
+ * accepted on the wire and in files, and both are read as the ARKit one.
95
+ */
96
+ export const ARKIT_TWINS = {
97
+ JawOpen: 'ARKitJawOpen',
98
+ TongueOut: 'ARKitTongueOut',
99
+ CheekPuff: 'ARKitCheekPuff',
100
+ };
101
+ const TWIN_ALIASES = new Set(Object.keys(ARKIT_TWINS));
102
+ /** Whether an input id is one of the {@link ARKIT_TWINS} aliases. */
103
+ function isArkitTwinAlias(v) {
104
+ return TWIN_ALIASES.has(v);
105
+ }
106
+ /** {@link INPUT_NAMES} minus the aliases, in the same order. */
107
+ export const BINDING_INPUT_NAMES = INPUT_NAMES.filter(n => !isArkitTwinAlias(n));
108
+ /** The id an input is read as: its ARKit twin when it has one, else itself. */
109
+ export function arkitTwinOf(input) {
110
+ return isArkitTwinAlias(input) ? ARKIT_TWINS[input] : input;
111
+ }
112
+ /**
113
+ * From VTS's own registry (`FaceTrackingParamInfo.paramNameDictionary`), which is both the
114
+ * range its editor seeds a new parameter with and what every `.vtube.json` was authored
115
+ * against. Two families differ deliberately:
116
+ *
117
+ * - **the brow inputs** are signed here. VTS lists them `0..1`; we derive them as
118
+ * `brow-up − brow-down`, so a frown needs the lower half.
119
+ * - **`JawOpen`** is absent from VTS's registry — it exists there only as a plugin-created
120
+ * parameter, which VTS then special-cases — so its span is the ARKit channel's.
121
+ */
122
+ const VTS_INPUT_RANGES = {
123
+ FaceAngleX: [-30, 30],
124
+ FaceAngleY: [-30, 30],
125
+ FaceAngleZ: [-90, 90],
126
+ FacePositionX: [-15, 15],
127
+ FacePositionY: [-15, 15],
128
+ FacePositionZ: [-10, 10],
129
+ EyeOpenLeft: [0, 1],
130
+ EyeOpenRight: [0, 1],
131
+ EyeLeftX: [-1, 1],
132
+ EyeLeftY: [-1, 1],
133
+ EyeRightX: [-1, 1],
134
+ EyeRightY: [-1, 1],
135
+ Brows: [-1, 1],
136
+ BrowLeftY: [-1, 1],
137
+ BrowRightY: [-1, 1],
138
+ MouthSmile: [0, 1],
139
+ MouthOpen: [0, 1],
140
+ MouthX: [-1, 1],
141
+ CheekPuff: [0, 1],
142
+ JawOpen: [0, 1],
143
+ TongueOut: [0, 1],
144
+ };
145
+ /** ARKit blendshapes are unit-scale by definition, so every raw channel shares one span. */
146
+ const ARKIT_RANGE = [0, 1];
147
+ /**
148
+ * Each input's natural span — the units a sender should write, and the input range a new
149
+ * binding starts from. Head angles are **degrees**; the rest are unitless.
150
+ */
151
+ export const INPUT_RANGES = {
152
+ ...VTS_INPUT_RANGES,
153
+ // `fromEntries` widens the key type back to `string`; the annotation above is what keeps
154
+ // this exhaustive, and it fails to typecheck if a name ever lacks a range.
155
+ ...Object.fromEntries(ARKIT_INPUT_NAMES.map(n => [n, ARKIT_RANGE])),
156
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/persona-sdk",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  "node": ">=22"
49
49
  },
50
50
  "dependencies": {
51
- "zod": "^4.5.2"
51
+ "zod": "^4.5.4"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "rimraf dist && tsc -p tsconfig.build.json",