@laplace.live/persona-sdk 0.7.0 → 0.8.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.
@@ -18,5 +18,13 @@ export type ToggleEffectKey = {
18
18
  }[keyof SceneEffects];
19
19
  export declare const EFFECT_SPECS: Record<ToggleEffectKey, Readonly<Record<string, EffectParamSpec>>>;
20
20
  export declare const TOGGLE_EFFECT_KEYS: readonly ToggleEffectKey[];
21
+ /**
22
+ * Registry effects that render as scene geometry inside the scene pass rather
23
+ * than composing into the post chain. A new scene-space effect must join this
24
+ * list, or its toggle needlessly forces the post path on and rebuilds the graph.
25
+ */
26
+ export declare const SCENE_SPACE_EFFECT_KEYS: readonly ToggleEffectKey[];
27
+ /** Registry effects the post chain composes — {@link TOGGLE_EFFECT_KEYS} minus the scene-space ones. */
28
+ export declare const POST_EFFECT_KEYS: readonly ToggleEffectKey[];
21
29
  /** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
22
30
  export declare function defaultToggleEffects(): Pick<SceneEffects, ToggleEffectKey>;
@@ -4,6 +4,8 @@
4
4
  // 1. Type its values in `SceneEffects` (types.ts) + its interface.
5
5
  // 2. Spec it here; label it in the panel's effect-labels.ts (parity-checked).
6
6
  // 3. Implement its stage under apps/desktop/src/renderer/vrm/webgpu/effects/.
7
+ // Scene-space effects (geometry, not a post stage — e.g. snow) also join
8
+ // SCENE_SPACE_EFFECT_KEYS below.
7
9
  // 4. Drop one `<EffectSection>` line into a panel section.
8
10
  // Effects that don't fit the pattern (tone mapping, exposure, LUT, shockwave)
9
11
  // stay bespoke — extend the pattern before special-casing a third shape.
@@ -72,9 +74,28 @@ export const EFFECT_SPECS = {
72
74
  staticDrops: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
73
75
  glints: { default: 0.6, min: 0, max: 1, step: 0.05 },
74
76
  },
77
+ // three's webgpu_compute_particles_snow, rescaled to the stage's metre-scale
78
+ // world. `amount` is the live instance count; buffers allocate the max once.
79
+ snow: {
80
+ amount: { default: 2000, min: 100, max: 10000, step: 100 },
81
+ speed: { default: 1, min: 0, max: 3, step: 0.1, digits: 1, unit: '×' },
82
+ wind: { default: 0, min: -3, max: 3, step: 0.1, digits: 1, unit: 'm/s' },
83
+ sway: { default: 1, min: 0, max: 3, step: 0.1, digits: 1 },
84
+ size: { default: 1, min: 0.4, max: 3, step: 0.05 },
85
+ melt: { default: 6, min: 0.5, max: 30, step: 0.5, digits: 1, unit: 's' },
86
+ opacity: { default: 1, min: 0.1, max: 1, step: 0.05 },
87
+ },
75
88
  };
76
89
  // Object.keys widens to string[]; the annotation above pins the keys to exactly ToggleEffectKey.
77
90
  export const TOGGLE_EFFECT_KEYS = Object.keys(EFFECT_SPECS);
91
+ /**
92
+ * Registry effects that render as scene geometry inside the scene pass rather
93
+ * than composing into the post chain. A new scene-space effect must join this
94
+ * list, or its toggle needlessly forces the post path on and rebuilds the graph.
95
+ */
96
+ export const SCENE_SPACE_EFFECT_KEYS = ['snow'];
97
+ /** Registry effects the post chain composes — {@link TOGGLE_EFFECT_KEYS} minus the scene-space ones. */
98
+ export const POST_EFFECT_KEYS = TOGGLE_EFFECT_KEYS.filter(key => !SCENE_SPACE_EFFECT_KEYS.includes(key));
78
99
  /** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
79
100
  export function defaultToggleEffects() {
80
101
  const out = {};
@@ -24,6 +24,10 @@ export declare const VRM_SCALE_MAX = 10;
24
24
  export declare const SCENE_LIGHT_RANGE_MAX = 20;
25
25
  /** Past this the 5-tap kernel spreads thin enough that its dither reads as noise. */
26
26
  export declare const SCENE_LIGHT_SHADOW_RADIUS_MAX = 16;
27
+ /** The point disk's 32 fixed taps stay dense across a far wider penumbra than 5 dithered ones. */
28
+ export declare const SCENE_LIGHT_POINT_SHADOW_RADIUS_MAX = 32;
29
+ /** Shadow softness (PCF disk radius, in shadow-map texels) ceiling for a light of `type`. */
30
+ export declare function sceneLightShadowRadiusMax(type: SceneLightType): number;
27
31
  /**
28
32
  * Exp2 falloff is squared, so a stage measured in metres is fully socked in well
29
33
  * before Unity's nominal 1 — this keeps the slider's useful band across its width.
@@ -60,3 +64,10 @@ export declare const DEFAULT_ELASTICITY: NonNullable<Attach['elasticity']>;
60
64
  export declare const ELASTICITY_STIFFNESS_MAX = 100;
61
65
  export declare const ELASTICITY_DAMPING_MAX = 10;
62
66
  export declare const ELASTICITY_MAX_SPEED_MAX = 100;
67
+ export declare const STORAGE_KEY_MAX_LENGTH = 128;
68
+ /** Ceiling on one value's JSON-serialized length (UTF-16 units, `JSON.stringify(v).length`). */
69
+ export declare const STORAGE_VALUE_MAX_LENGTH: number;
70
+ /** Keys one API key may hold; a `storage.set` that would exceed it answers `invalid-state`. */
71
+ export declare const STORAGE_KEYS_MAX = 256;
72
+ /** `speech.play` URL ceiling — sized for a ~40 s WAV as a base64 `data:audio/*` payload. */
73
+ export declare const SPEECH_URL_MAX_LENGTH = 8000000;
@@ -34,6 +34,12 @@ export const VRM_SCALE_MAX = 10;
34
34
  export const SCENE_LIGHT_RANGE_MAX = 20;
35
35
  /** Past this the 5-tap kernel spreads thin enough that its dither reads as noise. */
36
36
  export const SCENE_LIGHT_SHADOW_RADIUS_MAX = 16;
37
+ /** The point disk's 32 fixed taps stay dense across a far wider penumbra than 5 dithered ones. */
38
+ export const SCENE_LIGHT_POINT_SHADOW_RADIUS_MAX = 32;
39
+ /** Shadow softness (PCF disk radius, in shadow-map texels) ceiling for a light of `type`. */
40
+ export function sceneLightShadowRadiusMax(type) {
41
+ return type === 'point' ? SCENE_LIGHT_POINT_SHADOW_RADIUS_MAX : SCENE_LIGHT_SHADOW_RADIUS_MAX;
42
+ }
37
43
  /**
38
44
  * Exp2 falloff is squared, so a stage measured in metres is fully socked in well
39
45
  * before Unity's nominal 1 — this keeps the slider's useful band across its width.
@@ -96,3 +102,11 @@ export const DEFAULT_ELASTICITY = { stiffness: 2, damping: 3, maxSpeed: 2 };
96
102
  export const ELASTICITY_STIFFNESS_MAX = 100;
97
103
  export const ELASTICITY_DAMPING_MAX = 10;
98
104
  export const ELASTICITY_MAX_SPEED_MAX = 100;
105
+ // Plugin storage (`storage.*`) — the wire schemas and the server enforce these.
106
+ export const STORAGE_KEY_MAX_LENGTH = 128;
107
+ /** Ceiling on one value's JSON-serialized length (UTF-16 units, `JSON.stringify(v).length`). */
108
+ export const STORAGE_VALUE_MAX_LENGTH = 64 * 1024;
109
+ /** Keys one API key may hold; a `storage.set` that would exceed it answers `invalid-state`. */
110
+ export const STORAGE_KEYS_MAX = 256;
111
+ /** `speech.play` URL ceiling — sized for a ~40 s WAV as a base64 `data:audio/*` payload. */
112
+ export const SPEECH_URL_MAX_LENGTH = 8_000_000;
@@ -1,6 +1,7 @@
1
1
  import type { ApiErrorCode } from './errors.ts';
2
2
  import type { EventData, EventName } from './events.ts';
3
3
  import type { MethodName, MethodRequest } from './methods.ts';
4
+ import type { AppCapability } from './types.ts';
4
5
  export interface RequestMessage<M extends MethodName = MethodName> {
5
6
  kind: 'request';
6
7
  /** Correlation id, echoed on the response. Non-empty, client-chosen. */
@@ -38,6 +39,8 @@ export interface AppInfo {
38
39
  name: string;
39
40
  version: string;
40
41
  platform: string;
42
+ /** Features to gate on — never version-sniff. Empty when the server predates capability reporting. */
43
+ capabilities: AppCapability[];
41
44
  }
42
45
  /**
43
46
  * Parse one inbound client frame. Returns the request, or an error code telling
@@ -1,6 +1,7 @@
1
1
  import { isRecord } from "../values/guards.js";
2
2
  import { isApiErrorCode } from "./errors.js";
3
3
  import { isEventName } from "./events.js";
4
+ import { isAppCapability } from "./types.js";
4
5
  /**
5
6
  * Parse one inbound client frame. Returns the request, or an error code telling
6
7
  * the server what to answer: `parse-error` for junk bytes, `invalid-request`
@@ -46,7 +47,9 @@ export function parseServerMessage(raw) {
46
47
  const { name, version, platform } = v.app;
47
48
  if (typeof name !== 'string' || typeof version !== 'string' || typeof platform !== 'string')
48
49
  return null;
49
- return { kind: 'hello', protocol: v.protocol, app: { name, version, platform } };
50
+ // Tolerant both ways: absent on older servers, unknown names from newer ones dropped.
51
+ const capabilities = Array.isArray(v.app.capabilities) ? v.app.capabilities.filter(isAppCapability) : [];
52
+ return { kind: 'hello', protocol: v.protocol, app: { name, version, platform, capabilities } };
50
53
  }
51
54
  case 'response':
52
55
  if (typeof v.id !== 'string' || v.id === '')
@@ -10,9 +10,11 @@ export interface EventMap {
10
10
  modelId: string | null;
11
11
  config: HotkeyState;
12
12
  };
13
+ /** `sources` maps source id → status; optional so pre-multi-source servers still parse. */
13
14
  'tracking.status': {
14
15
  tracking: TrackingStatus;
15
16
  pose: PoseStatus;
17
+ sources?: Record<string, TrackingStatus>;
16
18
  };
17
19
  /** A model instance finished loading on stage. */
18
20
  'instance.loaded': {
@@ -31,6 +33,14 @@ export interface EventMap {
31
33
  'motion.ended': {
32
34
  instanceId: string;
33
35
  };
36
+ /** A `speech.play` utterance began on this instance. */
37
+ 'speech.started': {
38
+ instanceId: string;
39
+ };
40
+ /** The utterance finished, failed, was stopped, or was superseded — one `ended` per `started`. */
41
+ 'speech.ended': {
42
+ instanceId: string;
43
+ };
34
44
  /** True while a scene apply or pipeline warm-up is in flight. */
35
45
  'scene.loading': {
36
46
  loading: boolean;
@@ -9,6 +9,8 @@ export const EVENT_NAMES = Object.keys({
9
9
  'expression.changed': true,
10
10
  'motion.started': true,
11
11
  'motion.ended': true,
12
+ 'speech.started': true,
13
+ 'speech.ended': true,
12
14
  'scene.loading': true,
13
15
  'selection.changed': true,
14
16
  'registry.changed': true,
@@ -1,5 +1,5 @@
1
1
  import type { EventName } from './events.ts';
2
- import type { AnchorOption, AssetKind, AssetRef, Attach, Expression, ExpressionPersistence, HotkeyConfig, HotkeyState, InjectEntry, InjectTarget, InstanceRuntime, ModelInfo, ModelRef, MotionGroup, MToonTuning, ObjectContent, ObjectLightOverride, ObjectSpace, Place2D, Place3D, PlayingMotion, PoseSourceId, PoseStatus, Scene, SceneItem, ScenePatch, SceneState, ScreenPlacement, Settings, SettingsPatch, TrackingSourceId, TrackingStatus, VrmPlacement } from './types.ts';
2
+ import type { AnchorOption, AppCapability, AssetKind, AssetRef, Attach, Expression, ExpressionPersistence, HotkeyConfig, HotkeyState, InjectEntry, InjectTarget, InstanceRuntime, JsonValue, ModelInfo, ModelRef, MotionGroup, MToonTuning, ObjectContent, ObjectLightOverride, ObjectSpace, Place2D, Place3D, PlayingMotion, PoseSourceId, PoseStatus, Scene, SceneItem, ScenePatch, SceneState, ScreenPlacement, Settings, SettingsPatch, TrackingSourceConfig, TrackingSourceId, TrackingSourceKind, TrackingStatus, VrmPlacement } from './types.ts';
3
3
  /** Marker for methods that take no parameters; the client lets you omit the argument. */
4
4
  export type EmptyRequest = Record<never, never>;
5
5
  /** Marker for methods whose success response carries no data. */
@@ -73,6 +73,13 @@ export interface InstanceSetVisibleRequest {
73
73
  visible: boolean;
74
74
  }
75
75
  export type InstanceSetVisibleResponse = EmptyResponse;
76
+ /** Omitted fields keep their binding; null stops tracking that channel for this instance. */
77
+ export interface InstanceSetTrackingSourcesRequest {
78
+ instanceId: string;
79
+ faceSourceId?: string | null;
80
+ poseSourceId?: string | null;
81
+ }
82
+ export type InstanceSetTrackingSourcesResponse = EmptyResponse;
76
83
  export interface InstanceReorderRequest {
77
84
  /** Full z-order within the scene; ids not listed keep their relative order at the end. */
78
85
  orderedInstanceIds: string[];
@@ -206,6 +213,20 @@ export interface MotionPlayingRequest {
206
213
  export interface MotionPlayingResponse {
207
214
  playing: PlayingMotion | null;
208
215
  }
216
+ /** Play a clip with lip-sync. One utterance per model: a new play supersedes the current one. */
217
+ export interface SpeechPlayRequest {
218
+ instanceId?: string;
219
+ /** `https:`/`http:` (local TTS bridges) or an inline `data:audio/*` payload. */
220
+ url: string;
221
+ /** 0..1; the backend's own default when omitted. */
222
+ volume?: number;
223
+ }
224
+ export type SpeechPlayResponse = EmptyResponse;
225
+ /** Stopping a silent model is a silent success. */
226
+ export interface SpeechStopRequest {
227
+ instanceId?: string;
228
+ }
229
+ export type SpeechStopResponse = EmptyResponse;
209
230
  export interface HotkeyListRequest {
210
231
  modelId?: string;
211
232
  }
@@ -279,10 +300,37 @@ export interface PoseSetPortRequest {
279
300
  port: number;
280
301
  }
281
302
  export type PoseSetPortResponse = EmptyResponse;
303
+ export interface TrackingAddSourceRequest {
304
+ kind: TrackingSourceKind;
305
+ name?: string;
306
+ /** Face kinds: pin to a device IP. */
307
+ phoneIp?: string | null;
308
+ /** vmc: the UDP port to listen on; defaults to the next free VMC port. */
309
+ port?: number;
310
+ }
311
+ export interface TrackingAddSourceResponse {
312
+ source: TrackingSourceConfig;
313
+ }
314
+ export interface TrackingUpdateSourceRequest {
315
+ id: string;
316
+ name?: string;
317
+ enabled?: boolean;
318
+ phoneIp?: string | null;
319
+ port?: number;
320
+ }
321
+ export interface TrackingUpdateSourceResponse {
322
+ source: TrackingSourceConfig;
323
+ }
324
+ export interface TrackingRemoveSourceRequest {
325
+ id: string;
326
+ }
327
+ export type TrackingRemoveSourceResponse = EmptyResponse;
282
328
  export type TrackingStatusRequest = EmptyRequest;
283
329
  export interface TrackingStatusResponse {
284
330
  tracking: TrackingStatus;
285
331
  pose: PoseStatus;
332
+ /** Per-source status by {@link TrackingSourceConfig.id}. */
333
+ sources: Record<string, TrackingStatus>;
286
334
  }
287
335
  export interface StageResetTransformRequest {
288
336
  instanceId?: string;
@@ -298,6 +346,8 @@ export interface AppInfoResponse {
298
346
  version: string;
299
347
  platform: string;
300
348
  protocol: number;
349
+ /** Same list `hello` carries; gate features on it, never on `version`. */
350
+ capabilities: AppCapability[];
301
351
  }
302
352
  export type AppLocalAddressesRequest = EmptyRequest;
303
353
  export interface AppLocalAddressesResponse {
@@ -323,6 +373,28 @@ export interface SessionIdentifyRequest {
323
373
  developer?: string;
324
374
  }
325
375
  export type SessionIdentifyResponse = EmptyResponse;
376
+ export interface StorageGetRequest {
377
+ key: string;
378
+ }
379
+ export interface StorageGetResponse {
380
+ /** Null when the key was never set (a stored null is indistinguishable by design). */
381
+ value: JsonValue | null;
382
+ }
383
+ export interface StorageSetRequest {
384
+ key: string;
385
+ value: JsonValue;
386
+ }
387
+ export type StorageSetResponse = EmptyResponse;
388
+ /** Deleting an absent key succeeds silently. */
389
+ export interface StorageDeleteRequest {
390
+ key: string;
391
+ }
392
+ export type StorageDeleteResponse = EmptyResponse;
393
+ export type StorageListRequest = EmptyRequest;
394
+ export interface StorageListResponse {
395
+ /** Sorted for stable output. */
396
+ keys: string[];
397
+ }
326
398
  export interface EventsSubscribeRequest {
327
399
  events: EventName[];
328
400
  }
@@ -413,6 +485,11 @@ export interface MethodMap {
413
485
  request: InstanceSetVisibleRequest;
414
486
  response: InstanceSetVisibleResponse;
415
487
  };
488
+ /** Bind this instance's face/pose channels to tracking sources (null stops tracking that channel). */
489
+ 'instance.setTrackingSources': {
490
+ request: InstanceSetTrackingSourcesRequest;
491
+ response: InstanceSetTrackingSourcesResponse;
492
+ };
416
493
  'instance.reorder': {
417
494
  request: InstanceReorderRequest;
418
495
  response: InstanceReorderResponse;
@@ -511,6 +588,18 @@ export interface MethodMap {
511
588
  request: MotionPlayingRequest;
512
589
  response: MotionPlayingResponse;
513
590
  };
591
+ /**
592
+ * Lip-synced audio on models advertising the `speech` capability. The response means
593
+ * playback started (else `invalid-state`); `speech.started`/`speech.ended` bracket it.
594
+ */
595
+ 'speech.play': {
596
+ request: SpeechPlayRequest;
597
+ response: SpeechPlayResponse;
598
+ };
599
+ 'speech.stop': {
600
+ request: SpeechStopRequest;
601
+ response: SpeechStopResponse;
602
+ };
514
603
  'hotkey.list': {
515
604
  request: HotkeyListRequest;
516
605
  response: HotkeyListResponse;
@@ -551,18 +640,33 @@ export interface MethodMap {
551
640
  request: TrackingSetEnabledRequest;
552
641
  response: TrackingSetEnabledResponse;
553
642
  };
643
+ /** Legacy single-source setter: rewrites the first face source's kind. Prefer the source CRUD below. */
554
644
  'tracking.setSource': {
555
645
  request: TrackingSetSourceRequest;
556
646
  response: TrackingSetSourceResponse;
557
647
  };
648
+ 'tracking.addSource': {
649
+ request: TrackingAddSourceRequest;
650
+ response: TrackingAddSourceResponse;
651
+ };
652
+ 'tracking.updateSource': {
653
+ request: TrackingUpdateSourceRequest;
654
+ response: TrackingUpdateSourceResponse;
655
+ };
656
+ 'tracking.removeSource': {
657
+ request: TrackingRemoveSourceRequest;
658
+ response: TrackingRemoveSourceResponse;
659
+ };
558
660
  'pose.setEnabled': {
559
661
  request: PoseSetEnabledRequest;
560
662
  response: PoseSetEnabledResponse;
561
663
  };
664
+ /** Legacy: operates on the first vmc source. Prefer the tracking.* source CRUD. */
562
665
  'pose.setSource': {
563
666
  request: PoseSetSourceRequest;
564
667
  response: PoseSetSourceResponse;
565
668
  };
669
+ /** Legacy: sets the first vmc source's port. */
566
670
  'pose.setPort': {
567
671
  request: PoseSetPortRequest;
568
672
  response: PoseSetPortResponse;
@@ -603,6 +707,27 @@ export interface MethodMap {
603
707
  request: SessionIdentifyRequest;
604
708
  response: SessionIdentifyResponse;
605
709
  };
710
+ /**
711
+ * Durable key–value storage namespaced by the session's API key; revoking the key deletes
712
+ * it. Limits: `STORAGE_KEY_MAX_LENGTH`, `STORAGE_VALUE_MAX_LENGTH`, `STORAGE_KEYS_MAX`
713
+ * keys (over-quota answers `invalid-state`).
714
+ */
715
+ 'storage.get': {
716
+ request: StorageGetRequest;
717
+ response: StorageGetResponse;
718
+ };
719
+ 'storage.set': {
720
+ request: StorageSetRequest;
721
+ response: StorageSetResponse;
722
+ };
723
+ 'storage.delete': {
724
+ request: StorageDeleteRequest;
725
+ response: StorageDeleteResponse;
726
+ };
727
+ 'storage.list': {
728
+ request: StorageListRequest;
729
+ response: StorageListResponse;
730
+ };
606
731
  'events.subscribe': {
607
732
  request: EventsSubscribeRequest;
608
733
  response: EventsSubscribeResponse;
@@ -93,6 +93,27 @@ export declare const requestSchemas: {
93
93
  'hotkey.trigger': z.ZodObject<{
94
94
  hotkeyId: z.ZodString;
95
95
  }, z.core.$strip>;
96
+ 'tracking.addSource': z.ZodObject<{
97
+ kind: z.ZodEnum<{
98
+ "vts-ios": "vts-ios";
99
+ "vts-ios-native": "vts-ios-native";
100
+ ifacialmocap: "ifacialmocap";
101
+ vmc: "vmc";
102
+ }>;
103
+ name: z.ZodOptional<z.ZodString>;
104
+ phoneIp: z.ZodOptional<z.ZodNullable<z.ZodString>>;
105
+ port: z.ZodOptional<z.ZodNumber>;
106
+ }, z.core.$strip>;
107
+ 'tracking.updateSource': z.ZodObject<{
108
+ id: z.ZodString;
109
+ name: z.ZodOptional<z.ZodString>;
110
+ enabled: z.ZodOptional<z.ZodBoolean>;
111
+ phoneIp: z.ZodOptional<z.ZodNullable<z.ZodString>>;
112
+ port: z.ZodOptional<z.ZodNumber>;
113
+ }, z.core.$strip>;
114
+ 'tracking.removeSource': z.ZodObject<{
115
+ id: z.ZodString;
116
+ }, z.core.$strip>;
96
117
  'model.register': z.ZodObject<{
97
118
  path: z.ZodString;
98
119
  }, z.core.$strip>;
@@ -153,6 +174,24 @@ export declare const requestSchemas: {
153
174
  version: z.ZodOptional<z.ZodString>;
154
175
  developer: z.ZodOptional<z.ZodString>;
155
176
  }, z.core.$strip>;
177
+ 'storage.get': z.ZodObject<{
178
+ key: z.ZodString;
179
+ }, z.core.$strip>;
180
+ 'storage.set': z.ZodObject<{
181
+ key: z.ZodString;
182
+ value: z.ZodJSONSchema;
183
+ }, z.core.$strip>;
184
+ 'storage.delete': z.ZodObject<{
185
+ key: z.ZodString;
186
+ }, z.core.$strip>;
187
+ 'speech.play': z.ZodObject<{
188
+ instanceId: z.ZodOptional<z.ZodString>;
189
+ url: z.ZodString;
190
+ volume: z.ZodOptional<z.ZodNumber>;
191
+ }, z.core.$strip>;
192
+ 'speech.stop': z.ZodObject<{
193
+ instanceId: z.ZodOptional<z.ZodString>;
194
+ }, z.core.$strip>;
156
195
  'events.subscribe': z.ZodObject<{
157
196
  events: z.ZodArray<z.ZodCustom<keyof import("./events.ts").EventMap, keyof import("./events.ts").EventMap>>;
158
197
  }, z.core.$strip>;
@@ -1,11 +1,13 @@
1
1
  import * as z from 'zod';
2
2
  import { isRecord } from "../values/guards.js";
3
+ import { SPEECH_URL_MAX_LENGTH, STORAGE_KEY_MAX_LENGTH, STORAGE_VALUE_MAX_LENGTH } from "../values/limits.js";
3
4
  // Runtime validation for the request side of the wire. Schemas exist for the
4
5
  // methods whose params the app's main process consumes directly; methods without
5
6
  // one are stage-owned — the renderer validates and heals them (same rules as the
6
7
  // panel). Fields marked z.custom are deliberately gated shallowly here because
7
8
  // the app heals them into shape server-side.
8
9
  const nonEmpty = z.string().min(1);
10
+ const port = z.number().int().min(1).max(65535);
9
11
  export const InjectTargetSchema = z.object({
10
12
  type: z.enum(['input', 'live2d-param', 'vrm-expression']),
11
13
  id: nonEmpty,
@@ -30,6 +32,25 @@ export const SettingsPatchSchema = z.object({
30
32
  });
31
33
  /** Tolerates unknown names for version skew; the server filters them and reports what took. */
32
34
  const lenientEventNameSchema = z.custom(v => typeof v === 'string', 'event names must be strings');
35
+ const storageKey = z.string().min(1).max(STORAGE_KEY_MAX_LENGTH);
36
+ /** JSON-serializable and bounded; the serialized-length cap is what the server persists by. */
37
+ const storageValue = z
38
+ .json()
39
+ .refine(v => JSON.stringify(v).length <= STORAGE_VALUE_MAX_LENGTH, `value exceeds ${STORAGE_VALUE_MAX_LENGTH} serialized chars`);
40
+ /** http(s) covers hosted clips and local TTS bridges; data: carries inline audio only. */
41
+ const speechUrl = z
42
+ .string()
43
+ .min(1)
44
+ .max(SPEECH_URL_MAX_LENGTH)
45
+ .refine(v => {
46
+ try {
47
+ const p = new URL(v).protocol;
48
+ return p === 'https:' || p === 'http:' || (p === 'data:' && v.slice(5, 11) === 'audio/');
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }, 'url must be http(s) or data:audio/*');
33
54
  export const requestSchemas = {
34
55
  'scene.get': z.object({ sceneId: nonEmpty.optional() }),
35
56
  'scene.activate': z.object({ sceneId: nonEmpty }),
@@ -55,6 +76,20 @@ export const requestSchemas = {
55
76
  config: z.custom(isRecord, 'config must be a hotkey config'),
56
77
  }),
57
78
  'hotkey.trigger': z.object({ hotkeyId: nonEmpty }),
79
+ 'tracking.addSource': z.object({
80
+ kind: z.enum(['vts-ios', 'vts-ios-native', 'ifacialmocap', 'vmc']),
81
+ name: z.string().optional(),
82
+ phoneIp: nonEmpty.nullable().optional(),
83
+ port: port.optional(),
84
+ }),
85
+ 'tracking.updateSource': z.object({
86
+ id: nonEmpty,
87
+ name: z.string().optional(),
88
+ enabled: z.boolean().optional(),
89
+ phoneIp: nonEmpty.nullable().optional(),
90
+ port: port.optional(),
91
+ }),
92
+ 'tracking.removeSource': z.object({ id: nonEmpty }),
58
93
  'model.register': z.object({ path: nonEmpty }),
59
94
  'asset.register': z.object({ path: nonEmpty, want: z.enum(['image', 'video', 'prop', 'ibl', 'lut']).optional() }),
60
95
  'settings.patch': z.object({ settings: SettingsPatchSchema }),
@@ -62,12 +97,21 @@ export const requestSchemas = {
62
97
  'tracking.setSource': z.object({ source: z.enum(['vts-ios', 'vts-ios-native', 'ifacialmocap']) }),
63
98
  'pose.setEnabled': z.object({ enabled: z.boolean() }),
64
99
  'pose.setSource': z.object({ source: z.enum(['vmc']) }),
65
- 'pose.setPort': z.object({ port: z.number().int().min(1).max(65535) }),
100
+ 'pose.setPort': z.object({ port }),
66
101
  'session.identify': z.object({
67
102
  name: z.string().trim().min(1).max(64),
68
103
  version: z.string().max(32).optional(),
69
104
  developer: z.string().max(64).optional(),
70
105
  }),
106
+ 'storage.get': z.object({ key: storageKey }),
107
+ 'storage.set': z.object({ key: storageKey, value: storageValue }),
108
+ 'storage.delete': z.object({ key: storageKey }),
109
+ 'speech.play': z.object({
110
+ instanceId: nonEmpty.optional(),
111
+ url: speechUrl,
112
+ volume: z.number().min(0).max(1).optional(),
113
+ }),
114
+ 'speech.stop': z.object({ instanceId: nonEmpty.optional() }),
71
115
  'events.subscribe': z.object({ events: z.array(lenientEventNameSchema).min(1) }),
72
116
  'events.unsubscribe': z.object({ events: z.array(lenientEventNameSchema).optional() }),
73
117
  'param.inject': z.object({ entries: z.array(InjectEntrySchema).min(1) }),
@@ -109,6 +109,13 @@ export interface SceneModelItem {
109
109
  instanceId: string;
110
110
  ref: ModelRef;
111
111
  visible: boolean;
112
+ /**
113
+ * Face source tracking this instance ({@link TrackingSourceConfig.id}); null = its face is untracked.
114
+ * A dangling or disabled id behaves as null. One source may track several instances (mirroring).
115
+ */
116
+ faceSourceId: string | null;
117
+ /** Body source (vmc) tracking this instance; null = its pose is untracked. New instances bind to each channel's default source. */
118
+ poseSourceId: string | null;
112
119
  live2d: ScreenPlacement;
113
120
  vrm: VrmPlacement;
114
121
  idleAnimation: boolean;
@@ -371,6 +378,24 @@ export interface SceneDroplets {
371
378
  /** Specular glint on drops where the frame is transparent, so rain reads over the desktop (0 to 1). */
372
379
  glints: number;
373
380
  }
381
+ /** Compute-driven snowfall in the 3D scene: flakes drift down, settle on models and props, then melt. */
382
+ export interface SceneSnow {
383
+ enabled: boolean;
384
+ /** How many flakes are alive at once. */
385
+ amount: number;
386
+ /** Fall and drift clock multiplier. */
387
+ speed: number;
388
+ /** Steady sideways drift, in metres per second along the stage's x axis. */
389
+ wind: number;
390
+ /** Horizontal wobble while falling. */
391
+ sway: number;
392
+ /** Flake size multiplier. */
393
+ size: number;
394
+ /** Seconds a settled flake rests before it finishes melting away. */
395
+ melt: number;
396
+ /** Flake opacity. */
397
+ opacity: number;
398
+ }
374
399
  /**
375
400
  * Post-processing over the rendered 3D frame. Everything off skips the effect
376
401
  * chain entirely. Deliberately flat: every toggle-plus-numbers effect sits at
@@ -391,6 +416,7 @@ export interface SceneEffects {
391
416
  pixelate: ScenePixelate;
392
417
  glitch: SceneGlitch;
393
418
  droplets: SceneDroplets;
419
+ snow: SceneSnow;
394
420
  }
395
421
  /**
396
422
  * Image-based lighting for the 3D stage: an environment map, whether to show it
@@ -438,8 +464,17 @@ export interface ScenePatch {
438
464
  lights?: SceneLight[];
439
465
  environment?: SceneEnvironment;
440
466
  }
467
+ /**
468
+ * App-level features a client gates on (never version-sniff): `hello` and
469
+ * `app.info` report them — the per-app mirror of {@link InstanceRuntime.capabilities}.
470
+ */
471
+ export declare const APP_CAPABILITIES: readonly ["storage", "speech"];
472
+ export type AppCapability = (typeof APP_CAPABILITIES)[number];
473
+ export declare function isAppCapability(v: unknown): v is AppCapability;
441
474
  /** What a loaded model instance can do; absent capabilities answer `unsupported-for-format`. */
442
- export type InstanceCapability = 'motions' | 'expressions' | 'placement-2d' | 'placement-3d' | 'mtoon' | 'idle-clips' | 'pose' | 'live2d-params';
475
+ export type InstanceCapability = 'motions' | 'expressions' | 'placement-2d' | 'placement-3d' | 'mtoon' | 'idle-clips' | 'pose' | 'live2d-params'
476
+ /** `speech.play` lip-sync; engine-dependent, so gate on the runtime, not the format. */
477
+ | 'speech';
443
478
  export interface InstanceRuntime {
444
479
  instanceId: string;
445
480
  kind: 'model' | 'object';
@@ -503,6 +538,10 @@ export interface ExpressionPersistence {
503
538
  enabled: boolean;
504
539
  saved: string[];
505
540
  }
541
+ /** Any JSON-serializable value — what `storage.set` accepts and `storage.get` returns. */
542
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
543
+ [key: string]: JsonValue;
544
+ };
506
545
  export declare const TRACKING_SOURCE_IDS: readonly ["vts-ios", "vts-ios-native", "ifacialmocap"];
507
546
  export type TrackingSourceId = (typeof TRACKING_SOURCE_IDS)[number];
508
547
  /** Body-pose protocols. Every one so far is UDP with a configurable port. */
@@ -510,6 +549,29 @@ export declare const POSE_SOURCE_IDS: readonly ["vmc"];
510
549
  export type PoseSourceId = (typeof POSE_SOURCE_IDS)[number];
511
550
  export type TrackingStatus = 'off' | 'waiting' | 'tracking' | 'no-face';
512
551
  export type PoseStatus = 'off' | 'waiting' | 'tracking';
552
+ /** Every protocol a tracking source instance can speak; face kinds plus the body (vmc) kind. */
553
+ export declare const TRACKING_SOURCE_KINDS: readonly ["vts-ios", "vts-ios-native", "ifacialmocap", "vmc"];
554
+ export type TrackingSourceKind = (typeof TRACKING_SOURCE_KINDS)[number];
555
+ /** Whether a source kind feeds the face channel (vmc is the body channel). */
556
+ export declare function isFaceSourceKind(kind: TrackingSourceKind): kind is TrackingSourceId;
557
+ /**
558
+ * One configured tracking source instance. Several can run at once — one per person —
559
+ * and scene model instances bind to them by id (`SceneModelItem.faceSourceId`/`poseSourceId`).
560
+ */
561
+ export interface TrackingSourceConfig {
562
+ id: string;
563
+ kind: TrackingSourceKind;
564
+ /** User label shown in lists; '' renders as the kind's own label. */
565
+ name: string;
566
+ enabled: boolean;
567
+ /**
568
+ * Face kinds: accept only this device/sender IP; null accepts any sender not claimed
569
+ * by a pinned instance of the same kind. Ignored by vmc (its senders pick the port).
570
+ */
571
+ phoneIp: string | null;
572
+ /** vmc only: the UDP port this instance listens on. Null on face kinds — they bind per protocol. */
573
+ port: number | null;
574
+ }
513
575
  export declare const EFFECTS_QUALITY_LEVELS: readonly ["low", "medium", "high"];
514
576
  export type EffectsQuality = (typeof EFFECTS_QUALITY_LEVELS)[number];
515
577
  /**
@@ -532,10 +594,13 @@ export interface Settings {
532
594
  selectionOutline: boolean;
533
595
  effectsQuality: EffectsQuality;
534
596
  };
597
+ /** `source` mirrors the first face source's kind — the pre-multi-source field, kept for old clients. */
535
598
  tracking: {
536
599
  enabled: boolean;
537
600
  source: TrackingSourceId;
601
+ sources: TrackingSourceConfig[];
538
602
  };
603
+ /** `source`/`port` mirror the first vmc source — the pre-multi-source fields, kept for old clients. */
539
604
  pose: {
540
605
  enabled: boolean;
541
606
  source: PoseSourceId;
@@ -7,10 +7,25 @@
7
7
  * directional light covers with one map, so it gets the smaller of the pair.
8
8
  */
9
9
  export const SHADOW_QUALITY_LEVELS = ['off', 'low', 'medium', 'high', 'extra'];
10
+ // ---- Runtime state -------------------------------------------------------------
11
+ /**
12
+ * App-level features a client gates on (never version-sniff): `hello` and
13
+ * `app.info` report them — the per-app mirror of {@link InstanceRuntime.capabilities}.
14
+ */
15
+ export const APP_CAPABILITIES = ['storage', 'speech'];
16
+ export function isAppCapability(v) {
17
+ return typeof v === 'string' && APP_CAPABILITIES.includes(v);
18
+ }
10
19
  // ---- Settings ------------------------------------------------------------------
11
20
  export const TRACKING_SOURCE_IDS = ['vts-ios', 'vts-ios-native', 'ifacialmocap'];
12
21
  /** Body-pose protocols. Every one so far is UDP with a configurable port. */
13
22
  export const POSE_SOURCE_IDS = ['vmc'];
23
+ /** Every protocol a tracking source instance can speak; face kinds plus the body (vmc) kind. */
24
+ export const TRACKING_SOURCE_KINDS = [...TRACKING_SOURCE_IDS, ...POSE_SOURCE_IDS];
25
+ /** Whether a source kind feeds the face channel (vmc is the body channel). */
26
+ export function isFaceSourceKind(kind) {
27
+ return TRACKING_SOURCE_IDS.includes(kind);
28
+ }
14
29
  export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
15
30
  /**
16
31
  * Accepted values for `performance.fpsLimit`; 0 = unlimited. Anything else is snapped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/persona-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "devDependencies": {
29
29
  "rimraf": "^6.1.3",
30
30
  "typescript": "~6.0.3",
31
- "vitest": "^4.1.10"
31
+ "vitest": "^4.1.11"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=22"