@laplace.live/persona-sdk 0.1.1 → 0.2.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/README.md CHANGED
@@ -43,11 +43,27 @@ import WebSocket from "ws";
43
43
  const persona = new PersonaClient({
44
44
  token,
45
45
  auth: "header",
46
- createWebSocket: (url, headers) =>
47
- new WebSocket(url, { headers }) as unknown as globalThis.WebSocket,
46
+ createWebSocket: (url, headers) => new WebSocket(url, { headers }),
48
47
  });
49
48
  ```
50
49
 
50
+ ## Identifying your app
51
+
52
+ Pass `clientInfo` so your app shows up by name under **Connected Clients** in Persona's
53
+ settings (optional — unidentified clients work the same). The SDK re-declares it on every
54
+ reconnect; it is self-declared and display-only, never authorization:
55
+
56
+ ```ts
57
+ const persona = new PersonaClient({
58
+ token,
59
+ clientInfo: { name: "My Overlay", version: "1.2.0", developer: "You" },
60
+ });
61
+ ```
62
+
63
+ Two server close codes are terminal and stop the reconnect loop: `CLOSE_KEY_REVOKED` (4001,
64
+ the key was revoked) and `CLOSE_FORCE_DISCONNECTED` (4002, the user disconnected the session
65
+ in Persona). The client reports them via `onWarning` and goes `closed`.
66
+
51
67
  Treat the token like a password: it grants control of the app, including loading registered
52
68
  models and web overlays. The server listens on loopback unless LAN access is enabled in
53
69
  Persona's settings.
package/dist/client.d.ts CHANGED
@@ -1,7 +1,25 @@
1
1
  import type { EventData, EventName } from './events.ts';
2
- import type { MethodName, MethodRequest, MethodResponse } from './methods.ts';
2
+ import type { MethodName, MethodRequest, MethodResponse, SessionIdentifyRequest } from './methods.ts';
3
3
  import type { InjectTarget } from './types.ts';
4
4
  export type PersonaClientState = 'closed' | 'connecting' | 'open' | 'reconnecting';
5
+ /**
6
+ * The subset of the WebSocket API the client uses. The global `WebSocket`
7
+ * (browsers, Node ≥22, Bun) and the `ws` package's client both satisfy it
8
+ * structurally, so `createWebSocket` implementations need no casts.
9
+ */
10
+ export interface WebSocketLike {
11
+ readonly readyState: number;
12
+ send(data: string): void;
13
+ close(code?: number, reason?: string): void;
14
+ addEventListener(type: 'message', listener: (event: {
15
+ data?: unknown;
16
+ }) => void): void;
17
+ addEventListener(type: 'close', listener: (event?: {
18
+ code?: number;
19
+ reason?: string;
20
+ }) => void): void;
21
+ addEventListener(type: 'error', listener: () => void): void;
22
+ }
5
23
  export interface PersonaClientOptions {
6
24
  /** An API key created in Persona's settings. */
7
25
  token: string;
@@ -18,8 +36,13 @@ export interface PersonaClientOptions {
18
36
  reconnectDelayMs?: number;
19
37
  reconnectDelayMaxMs?: number;
20
38
  requestTimeoutMs?: number;
39
+ /**
40
+ * Identifies your app in Persona's settings (Connected Clients). Declarative and
41
+ * display-only, never authorization; sent automatically on every connection.
42
+ */
43
+ clientInfo?: SessionIdentifyRequest;
21
44
  /** Custom socket factory — for `ws` with headers, or tests. `headers` is set only for `auth: 'header'`. */
22
- createWebSocket?: (url: string, headers: Record<string, string> | undefined) => WebSocket;
45
+ createWebSocket?: (url: string, headers: Record<string, string> | undefined) => WebSocketLike;
23
46
  onStateChange?: (state: PersonaClientState) => void;
24
47
  /** Non-fatal notices (protocol version mismatch). Default `console.warn`. */
25
48
  onWarning?: (message: string) => void;
@@ -84,6 +107,14 @@ export declare class PersonaClient {
84
107
  private buildUrl;
85
108
  private open;
86
109
  private onHello;
110
+ /**
111
+ * Subscribe, falling back to one request per event when the *server* refuses the batch:
112
+ * servers before v0.15 reject the whole array over a single unknown event name.
113
+ * A dropped socket rejects too, and is not a refusal — the next open re-subscribes.
114
+ */
115
+ private subscribe;
116
+ private subscribeIndividually;
117
+ private warnUnsupported;
87
118
  private onServerMessage;
88
119
  private failPending;
89
120
  private scheduleReconnect;
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseServerMessage } from "./envelope.js";
2
2
  import { PersonaApiError } from "./errors.js";
3
- import { DEFAULT_API_PORT, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION } from "./protocol.js";
3
+ import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, DEFAULT_API_PORT, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, } from "./protocol.js";
4
4
  const DEFAULTS = {
5
5
  url: `ws://127.0.0.1:${String(DEFAULT_API_PORT)}`,
6
6
  reconnectDelayMs: 500,
@@ -89,9 +89,8 @@ export class PersonaClient {
89
89
  this.listeners.set(event, set);
90
90
  }
91
91
  set.add(cb);
92
- if (isNew && this.state === 'open') {
93
- void this.call('events.subscribe', { events: [event] }).catch(() => undefined);
94
- }
92
+ if (isNew && this.state === 'open')
93
+ this.subscribe([event]);
95
94
  return () => {
96
95
  const s = this.listeners.get(event);
97
96
  if (!s)
@@ -117,7 +116,10 @@ export class PersonaClient {
117
116
  ...(opts?.onError === undefined ? {} : { onError: opts.onError }),
118
117
  };
119
118
  this.leases.set(key, lease);
120
- this.startHeartbeat();
119
+ // Only while open — onHello resumes held leases, so a lease taken on a closed
120
+ // client must not start a referenced interval (it would pin a Node process).
121
+ if (this.state === 'open')
122
+ this.startHeartbeat();
121
123
  this.queueFlush();
122
124
  return {
123
125
  set: (v) => {
@@ -196,7 +198,7 @@ export class PersonaClient {
196
198
  }
197
199
  this.onServerMessage(msg);
198
200
  });
199
- ws.addEventListener('close', () => {
201
+ ws.addEventListener('close', ev => {
200
202
  if (this.ws !== ws)
201
203
  return; // an intentional close already moved on
202
204
  this.ws = null;
@@ -207,6 +209,19 @@ export class PersonaClient {
207
209
  this.setState('closed');
208
210
  return;
209
211
  }
212
+ const code = ev?.code;
213
+ if (code === CLOSE_KEY_REVOKED || code === CLOSE_FORCE_DISCONNECTED) {
214
+ // Terminal by protocol: redialing would either fail auth forever or undo the user's action.
215
+ if (this.reconnectTimer !== null)
216
+ clearTimeout(this.reconnectTimer);
217
+ this.reconnectTimer = null;
218
+ this.stopHeartbeat();
219
+ this.warn(code === CLOSE_KEY_REVOKED
220
+ ? 'the server revoked this API key — create a new one in Persona and reconnect'
221
+ : 'the server disconnected this session — not reconnecting');
222
+ this.setState('closed');
223
+ return;
224
+ }
210
225
  if (!this.opts.reconnect || this.state === 'connecting') {
211
226
  // Initial connect failed: report to the caller instead of retrying forever.
212
227
  this.setState('closed');
@@ -225,12 +240,64 @@ export class PersonaClient {
225
240
  this.warn(`protocol version mismatch: server speaks v${String(protocol)}, SDK speaks v${String(PROTOCOL_VERSION)}`);
226
241
  }
227
242
  this.reconnectAttempt = 0;
228
- this.setState('open');
243
+ const info = this.opts.clientInfo;
244
+ // Identity is session-scoped, so every (re)connect re-declares it. Swallow errors:
245
+ // pre-feature servers answer `unknown-method`, and a mid-flight drop is not news.
246
+ if (info)
247
+ void this.call('session.identify', info).catch(() => undefined);
229
248
  const events = [...this.listeners.keys()];
249
+ // Before `open`, so a listener registered by an onStateChange handler does not
250
+ // race a second subscribe for the same event.
230
251
  if (events.length > 0)
231
- void this.call('events.subscribe', { events }).catch(() => undefined);
232
- if (this.leases.size > 0)
252
+ this.subscribe(events);
253
+ this.setState('open');
254
+ // Held leases resume here; restart the heartbeat that close()/a terminal close stopped.
255
+ if (this.leases.size > 0) {
256
+ this.startHeartbeat();
233
257
  this.queueFlush();
258
+ }
259
+ }
260
+ /**
261
+ * Subscribe, falling back to one request per event when the *server* refuses the batch:
262
+ * servers before v0.15 reject the whole array over a single unknown event name.
263
+ * A dropped socket rejects too, and is not a refusal — the next open re-subscribes.
264
+ */
265
+ subscribe(events) {
266
+ void this.call('events.subscribe', { events })
267
+ .then(r => {
268
+ const missing = events.filter(e => !r.subscribed.includes(e));
269
+ if (missing.length > 0)
270
+ this.warnUnsupported(missing);
271
+ })
272
+ .catch((err) => {
273
+ if (!(err instanceof PersonaApiError))
274
+ return;
275
+ if (events.length === 1)
276
+ this.warnUnsupported(events);
277
+ else
278
+ this.subscribeIndividually(events);
279
+ });
280
+ }
281
+ subscribeIndividually(events) {
282
+ const refused = [];
283
+ void Promise.all(events.map(e => this.call('events.subscribe', { events: [e] })
284
+ .then(r => {
285
+ // A v0.15+ server answers per-event by omission, not rejection.
286
+ if (!r.subscribed.includes(e))
287
+ refused.push(e);
288
+ })
289
+ .catch((err) => {
290
+ if (err instanceof PersonaApiError)
291
+ refused.push(e);
292
+ }))).then(() => {
293
+ if (refused.length > 0)
294
+ this.warnUnsupported(refused);
295
+ });
296
+ }
297
+ warnUnsupported(events) {
298
+ const app = this.serverInfo?.app;
299
+ const server = app ? `${app.name} ${app.version}` : 'the connected Persona app';
300
+ this.warn(`${server} does not support these events: ${events.join(', ')} — update the desktop app to receive them`);
234
301
  }
235
302
  onServerMessage(msg) {
236
303
  if (msg.kind === 'event') {
package/dist/events.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HotkeyState, ModelFormat, PoseStatus, SceneState, Settings, TrackingStatus } from './types.ts';
1
+ import type { ExpressionPersistence, HotkeyState, ModelFormat, PoseStatus, SceneState, Settings, TrackingStatus } from './types.ts';
2
2
  /** Every push event a session can subscribe to, with its payload. */
3
3
  export interface EventMap {
4
4
  /** Any scene mutation: create/delete/rename/activate/shortcut and persisted scene edits. */
@@ -35,6 +35,19 @@ export interface EventMap {
35
35
  'scene.loading': {
36
36
  loading: boolean;
37
37
  };
38
+ /** The desktop's editing selection moved (any source: stage click, panel, or a plugin); null = cleared. */
39
+ 'selection.changed': {
40
+ instanceId: string | null;
41
+ };
42
+ /** A model or asset was registered: re-run `model.list`/`asset.list` for that kind. */
43
+ 'registry.changed': {
44
+ kind: 'model' | 'asset';
45
+ };
46
+ /** The per-model "remember expressions" flag changed. */
47
+ 'expression.persistence': {
48
+ modelId: string;
49
+ persistence: ExpressionPersistence;
50
+ };
38
51
  }
39
52
  export type EventName = keyof EventMap;
40
53
  export type EventData<E extends EventName = EventName> = EventMap[E];
package/dist/events.js CHANGED
@@ -10,6 +10,9 @@ export const EVENT_NAMES = Object.keys({
10
10
  'motion.started': true,
11
11
  'motion.ended': true,
12
12
  'scene.loading': true,
13
+ 'selection.changed': true,
14
+ 'registry.changed': true,
15
+ 'expression.persistence': true,
13
16
  });
14
17
  export function isEventName(v) {
15
18
  return typeof v === 'string' && EVENT_NAMES.includes(v);
package/dist/methods.d.ts CHANGED
@@ -77,6 +77,15 @@ export interface InstanceSetPrimaryRequest {
77
77
  instanceId: string;
78
78
  }
79
79
  export type InstanceSetPrimaryResponse = EmptyResponse;
80
+ export interface SelectionSetRequest {
81
+ /** Scene item (model or object) to select on the desktop; null clears the selection. */
82
+ instanceId: string | null;
83
+ }
84
+ export type SelectionSetResponse = EmptyResponse;
85
+ export type SelectionGetRequest = EmptyRequest;
86
+ export interface SelectionGetResponse {
87
+ instanceId: string | null;
88
+ }
80
89
  export interface InstanceSetPlacementRequest {
81
90
  instanceId?: string;
82
91
  live2d?: Partial<ScreenPlacement>;
@@ -296,6 +305,19 @@ export type AppStatsRequest = EmptyRequest;
296
305
  export interface AppStatsResponse {
297
306
  fps: number | null;
298
307
  }
308
+ /**
309
+ * Self-declared identity of the connecting app, shown in Persona's settings.
310
+ * Declarative and unverified — display/attribution only, never authorization.
311
+ */
312
+ export interface SessionIdentifyRequest {
313
+ /** Display name of the connecting application. 1–64 chars. */
314
+ name: string;
315
+ /** Version of the connecting application. Up to 32 chars. */
316
+ version?: string;
317
+ /** Developer/vendor shown alongside the name. Up to 64 chars. */
318
+ developer?: string;
319
+ }
320
+ export type SessionIdentifyResponse = EmptyResponse;
299
321
  export interface EventsSubscribeRequest {
300
322
  events: EventName[];
301
323
  }
@@ -389,6 +411,16 @@ export interface MethodMap {
389
411
  request: InstanceSetPrimaryRequest;
390
412
  response: InstanceSetPrimaryResponse;
391
413
  };
414
+ /** Drives the desktop's editing selection (outline + Model tab); ephemeral, never persisted. */
415
+ 'selection.set': {
416
+ request: SelectionSetRequest;
417
+ response: SelectionSetResponse;
418
+ };
419
+ /** Current editing selection; changes stream as `selection.changed`. */
420
+ 'selection.get': {
421
+ request: SelectionGetRequest;
422
+ response: SelectionGetResponse;
423
+ };
392
424
  'instance.setPlacement': {
393
425
  request: InstanceSetPlacementRequest;
394
426
  response: InstanceSetPlacementResponse;
@@ -553,6 +585,14 @@ export interface MethodMap {
553
585
  request: AppStatsRequest;
554
586
  response: AppStatsResponse;
555
587
  };
588
+ /**
589
+ * Declare who this client is (display only, never authorization). Session-scoped:
590
+ * re-send on every connection — the SDK's `clientInfo` option does it automatically.
591
+ */
592
+ 'session.identify': {
593
+ request: SessionIdentifyRequest;
594
+ response: SessionIdentifyResponse;
595
+ };
556
596
  'events.subscribe': {
557
597
  request: EventsSubscribeRequest;
558
598
  response: EventsSubscribeResponse;
@@ -3,6 +3,10 @@ import type { InjectTarget } from './types.ts';
3
3
  export declare const PROTOCOL_VERSION = 1;
4
4
  /** Default port the Persona API server listens on (user-configurable in the app). */
5
5
  export declare const DEFAULT_API_PORT = 25034;
6
+ /** Server close code: the session's API key was revoked. Terminal — the client must not redial. */
7
+ export declare const CLOSE_KEY_REVOKED = 4001;
8
+ /** Server close code: the user disconnected this session from Persona's settings. Terminal — the client must not redial. */
9
+ export declare const CLOSE_FORCE_DISCONNECTED = 4002;
6
10
  /** An injected parameter reverts this long after its last write — the lease, not a setting. */
7
11
  export declare const INJECT_LEASE_TTL_MS = 1000;
8
12
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
package/dist/protocol.js CHANGED
@@ -2,6 +2,10 @@
2
2
  export const PROTOCOL_VERSION = 1;
3
3
  /** Default port the Persona API server listens on (user-configurable in the app). */
4
4
  export const DEFAULT_API_PORT = 25034;
5
+ /** Server close code: the session's API key was revoked. Terminal — the client must not redial. */
6
+ export const CLOSE_KEY_REVOKED = 4001;
7
+ /** Server close code: the user disconnected this session from Persona's settings. Terminal — the client must not redial. */
8
+ export const CLOSE_FORCE_DISCONNECTED = 4002;
5
9
  /** An injected parameter reverts this long after its last write — the lease, not a setting. */
6
10
  export const INJECT_LEASE_TTL_MS = 1000;
7
11
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
package/dist/schemas.d.ts CHANGED
@@ -34,9 +34,9 @@ export declare const SettingsPatchSchema: z.ZodObject<{
34
34
  fpsLimit: z.ZodOptional<z.ZodNumber>;
35
35
  selectionOutline: z.ZodOptional<z.ZodBoolean>;
36
36
  effectsQuality: z.ZodOptional<z.ZodEnum<{
37
- high: "high";
38
- medium: "medium";
39
37
  low: "low";
38
+ medium: "medium";
39
+ high: "high";
40
40
  }>>;
41
41
  }, z.core.$strip>>;
42
42
  }, z.core.$strip>;
@@ -116,9 +116,9 @@ export declare const requestSchemas: {
116
116
  fpsLimit: z.ZodOptional<z.ZodNumber>;
117
117
  selectionOutline: z.ZodOptional<z.ZodBoolean>;
118
118
  effectsQuality: z.ZodOptional<z.ZodEnum<{
119
- high: "high";
120
- medium: "medium";
121
119
  low: "low";
120
+ medium: "medium";
121
+ high: "high";
122
122
  }>>;
123
123
  }, z.core.$strip>>;
124
124
  }, z.core.$strip>;
@@ -144,6 +144,11 @@ export declare const requestSchemas: {
144
144
  'pose.setPort': z.ZodObject<{
145
145
  port: z.ZodNumber;
146
146
  }, z.core.$strip>;
147
+ 'session.identify': z.ZodObject<{
148
+ name: z.ZodString;
149
+ version: z.ZodOptional<z.ZodString>;
150
+ developer: z.ZodOptional<z.ZodString>;
151
+ }, z.core.$strip>;
147
152
  'events.subscribe': z.ZodObject<{
148
153
  events: z.ZodArray<z.ZodCustom<keyof import("./events.ts").EventMap, keyof import("./events.ts").EventMap>>;
149
154
  }, z.core.$strip>;
package/dist/schemas.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import * as z from 'zod';
2
- import { isEventName } from "./events.js";
3
2
  // Runtime validation for the request side of the wire. Schemas exist for the
4
3
  // methods whose params the app's main process consumes directly; methods without
5
4
  // one are stage-owned — the renderer validates and heals them (same rules as the
@@ -31,8 +30,7 @@ export const SettingsPatchSchema = z.object({
31
30
  })
32
31
  .optional(),
33
32
  });
34
- const eventNameSchema = z.custom(isEventName, 'unknown event name');
35
- /** Tolerates unknown names (version skew); the server ignores what it cannot map. */
33
+ /** Tolerates unknown names for version skew; the server filters them and reports what took. */
36
34
  const lenientEventNameSchema = z.custom(v => typeof v === 'string', 'event names must be strings');
37
35
  export const requestSchemas = {
38
36
  'scene.get': z.object({ sceneId: nonEmpty.optional() }),
@@ -66,7 +64,12 @@ export const requestSchemas = {
66
64
  'pose.setEnabled': z.object({ enabled: z.boolean() }),
67
65
  'pose.setSource': z.object({ source: z.enum(['vmc']) }),
68
66
  'pose.setPort': z.object({ port: z.number().int().min(1).max(65535) }),
69
- 'events.subscribe': z.object({ events: z.array(eventNameSchema).min(1) }),
67
+ 'session.identify': z.object({
68
+ name: z.string().trim().min(1).max(64),
69
+ version: z.string().max(32).optional(),
70
+ developer: z.string().max(64).optional(),
71
+ }),
72
+ 'events.subscribe': z.object({ events: z.array(lenientEventNameSchema).min(1) }),
70
73
  'events.unsubscribe': z.object({ events: z.array(lenientEventNameSchema).optional() }),
71
74
  'param.inject': z.object({ entries: z.array(InjectEntrySchema).min(1) }),
72
75
  'param.release': z.object({ targets: z.array(InjectTargetSchema).optional() }),
package/dist/types.d.ts CHANGED
@@ -2,16 +2,19 @@ export type ModelFormat = 'live2d' | 'vrm';
2
2
  export type ModelKind = 'bundled' | 'user';
3
3
  /** A model as the registry lists it. Paths deliberately never cross the wire. */
4
4
  export interface ModelRef {
5
+ /** Stable slug, unique per installed model. */
5
6
  id: string;
6
7
  name: string;
7
8
  kind: ModelKind;
8
9
  format: ModelFormat;
9
10
  }
11
+ /** How a registered file is labelled. Wider than an object's content kinds: `.hdr` is only ever an environment map. */
10
12
  export type AssetKind = 'image' | 'video' | 'prop' | 'ibl' | 'lut';
11
13
  /** A registered object-source file. `exists` is false once the file is gone from disk. */
12
14
  export interface AssetRef {
13
15
  id: string;
14
16
  name: string;
17
+ /** What the extension makes it — an object created from it starts on this kind. */
15
18
  kind: AssetKind;
16
19
  exists: boolean;
17
20
  }
@@ -22,7 +25,7 @@ export interface ScreenPlacement {
22
25
  scale: number;
23
26
  rotation: number;
24
27
  }
25
- /** World-space VRM placement. Rotations in radians. */
28
+ /** World-space placement. Rotations in radians, applied YXZ; `rotY` composes over the model's base yaw. */
26
29
  export interface VrmPlacement {
27
30
  x: number;
28
31
  y: number;
@@ -32,20 +35,33 @@ export interface VrmPlacement {
32
35
  rotZ: number;
33
36
  scale: number;
34
37
  }
38
+ /** {@link ScreenPlacement} plus the per-object fade. */
35
39
  export interface Place2D extends ScreenPlacement {
36
40
  opacity: number;
37
41
  }
42
+ /** {@link VrmPlacement} plus the per-object fade. */
38
43
  export interface Place3D extends VrmPlacement {
39
44
  opacity: number;
40
45
  }
41
- /** Per-instance MToon fine-tuning, layered over each material's authored values. */
46
+ /**
47
+ * Per-instance MToon fine-tuning, layered over each material's authored values —
48
+ * offsets add, multipliers scale, so materials keep their relative differences.
49
+ * All-neutral values (1, 0, 0, 0, 1, 1, 1) render the model exactly as authored.
50
+ */
42
51
  export interface MToonTuning {
52
+ /** 0..1 strength of the authored shading: 1 keeps it, 0 lifts every shade color to its lit color. */
43
53
  shade: number;
54
+ /** -1..1 added to authored shading shift; positive pushes the terminator so less of the model is shaded. */
44
55
  shadingShift: number;
56
+ /** -1..1 added to authored toony; positive hardens the lit/shade edge, negative softens it. */
45
57
  shadingToony: number;
58
+ /** -1..1 added to authored GI equalization: how evenly ambient/IBL light wraps the model. */
46
59
  giEqualization: number;
60
+ /** Multiplier on the parametric rim color. */
47
61
  rim: number;
62
+ /** Multiplier on outline width. */
48
63
  outlineWidth: number;
64
+ /** Multiplier on emissive intensity; >1 pairs with bloom. */
49
65
  emissive: number;
50
66
  }
51
67
  export interface SceneModelItem {
@@ -57,11 +73,14 @@ export interface SceneModelItem {
57
73
  vrm: VrmPlacement;
58
74
  idleAnimation: boolean;
59
75
  idleClip: string;
76
+ /** MToon material fine-tuning, VRM only. */
60
77
  mtoon: MToonTuning;
61
78
  }
62
79
  export type ObjectSpace = '2d' | '3d';
80
+ /** Where a webpage overlay renders relative to the stage. */
63
81
  export type WebLayer = 'behind' | 'front';
64
82
  export type CaptureKind = 'display' | 'window';
83
+ /** What an object renders. Mirrors VTube Studio's items and Warudo's screen/prop assets. */
65
84
  export type ObjectContent = {
66
85
  kind: 'image';
67
86
  assetId: string;
@@ -89,36 +108,49 @@ export type ObjectContent = {
89
108
  sourceId: string;
90
109
  label: string;
91
110
  };
92
- /** Where on a parent model an object rides. */
111
+ /** Where on a parent model an object rides. `root` is the model's own transform. */
93
112
  export type AttachAnchor = {
94
113
  kind: 'root';
95
- } | {
114
+ }
115
+ /** VRM humanoid bone name (`hips`, `head`, `leftHand`, …). */
116
+ | {
96
117
  kind: 'bone';
97
118
  bone: string;
98
- } | {
119
+ }
120
+ /** Live2D drawable + the triangle and barycentric weights of the pin: it deforms with the mesh. */
121
+ | {
99
122
  kind: 'artMesh';
100
123
  id: string;
101
124
  verts: [number, number, number];
102
125
  weights: [number, number, number];
103
126
  };
127
+ /** Live2D only: smoothed ParamAngleZ × multiplier turns the pinned item (X/Y ride the mesh). */
128
+ export interface AttachHeadAngle {
129
+ multiplier: number;
130
+ /** VTS-style 0..50; ~frames of lag at 60 fps (0 = instant). */
131
+ smoothing: number;
132
+ }
133
+ /** 3D only: spring lag on the follow; absent is rigid. */
134
+ export interface AttachElasticity {
135
+ stiffness: number;
136
+ damping: number;
137
+ maxSpeed: number;
138
+ }
104
139
  export interface Attach {
140
+ /** Always a model instance in the same space; anything else is cleared on load. */
105
141
  parentInstanceId: string;
106
142
  anchor: AttachAnchor;
107
143
  followRotation: boolean;
108
- headAngle: {
109
- multiplier: number;
110
- smoothing: number;
111
- } | null;
112
- elasticity: {
113
- stiffness: number;
114
- damping: number;
115
- maxSpeed: number;
116
- } | null;
144
+ headAngle: AttachHeadAngle | null;
145
+ elasticity: AttachElasticity | null;
117
146
  }
147
+ /** User override for one light baked into a prop asset. */
118
148
  export interface ObjectLightOverride {
119
149
  enabled: boolean;
150
+ /** Scene-slider units, or null to keep the asset's own (normalized) intensity. */
120
151
  intensity: number | null;
121
152
  }
153
+ /** A non-avatar thing on stage: image, video, screen capture, webpage, or 3D prop. */
122
154
  export interface SceneObjectItem {
123
155
  kind: 'object';
124
156
  instanceId: string;
@@ -128,9 +160,16 @@ export interface SceneObjectItem {
128
160
  content: ObjectContent;
129
161
  place2d: Place2D;
130
162
  place3d: Place3D;
163
+ /** Riding a model in the same space, with the placement read as an offset; null stands alone. */
131
164
  attach: Attach | null;
165
+ /**
166
+ * Overrides for lights baked into prop content, keyed by traversal index —
167
+ * the only stable handle a glb gives. A re-exported asset that reorders its
168
+ * lights shifts them; they degrade, never break.
169
+ */
132
170
  lightOverrides: Record<string, ObjectLightOverride>;
133
171
  }
172
+ /** Anything the stage renders. Array order in {@link Scene.items} is z-order within each space. */
134
173
  export type SceneItem = SceneModelItem | SceneObjectItem;
135
174
  export type BackgroundMode = 'transparent' | 'color' | 'image';
136
175
  /** `imagePath` is accepted but survives only when the app has allowlisted it via its own picker. */
@@ -142,6 +181,7 @@ export interface SceneBackground {
142
181
  export interface SceneBehavior {
143
182
  lookAtCursor: boolean;
144
183
  }
184
+ /** VRM stage framing: the camera moves, the model does not. Angles in radians, distance in world units. */
145
185
  export interface OrbitTransform {
146
186
  azimuth: number;
147
187
  elevation: number;
@@ -149,11 +189,18 @@ export interface OrbitTransform {
149
189
  targetX: number;
150
190
  targetY: number;
151
191
  }
192
+ /** Scene-level VRM camera. `orbit: null` = never framed — the first VRM load frames it from model height. */
152
193
  export interface SceneCamera {
153
194
  orbit: OrbitTransform | null;
154
195
  fov: number;
155
196
  }
156
197
  export type SceneLightType = 'directional' | 'point' | 'ambient';
198
+ /**
199
+ * One scene light. Angles are degrees. Directional lights aim with
200
+ * azimuth/elevation and sit at x/y/z (which moves their handle and shadow
201
+ * coverage, not their parallel shading), point lights use x/y/z + range,
202
+ * ambient uses none — all fields stay so a type switch keeps them.
203
+ */
157
204
  export interface SceneLight {
158
205
  id: string;
159
206
  type: SceneLightType;
@@ -165,64 +212,95 @@ export interface SceneLight {
165
212
  y: number;
166
213
  z: number;
167
214
  range: number;
215
+ /** Ambient light is directionless, so it never casts whatever this says. */
168
216
  castShadow: boolean;
217
+ /** Penumbra width in shadow-map texels — a stylistic dial; the filter widens the edge uniformly. */
169
218
  shadowRadius: number;
170
219
  }
220
+ /** Distance haze. Only geometry fogs — empty space keeps the window's transparency. */
171
221
  export interface SceneFog {
172
222
  enabled: boolean;
173
223
  color: string;
224
+ /** Exponential-squared falloff; the useful band is well under 1 at avatar scale. */
174
225
  density: number;
175
226
  }
227
+ /** Display transform applied after the scene renders. `none` keeps colors exactly as authored. */
176
228
  export type SceneToneMapping = 'none' | 'neutral' | 'aces' | 'agx';
177
- export interface SceneEffects {
178
- toneMapping: SceneToneMapping;
179
- exposure: number;
180
- bloom: {
181
- enabled: boolean;
182
- intensity: number;
183
- threshold: number;
184
- radius: number;
185
- };
186
- vignette: {
187
- enabled: boolean;
188
- darkness: number;
189
- offset: number;
190
- };
191
- color: {
192
- enabled: boolean;
193
- hue: number;
194
- saturation: number;
195
- brightness: number;
196
- contrast: number;
197
- };
198
- chromaticAberration: {
199
- enabled: boolean;
200
- strength: number;
201
- };
202
- grain: {
229
+ /** Glow around bright pixels. `threshold` is the luminance floor; `radius` widens the halo. */
230
+ export interface SceneBloom {
231
+ enabled: boolean;
232
+ intensity: number;
233
+ threshold: number;
234
+ radius: number;
235
+ }
236
+ /** Darkened frame corners. `offset` pushes the falloff outward. */
237
+ export interface SceneVignette {
238
+ enabled: boolean;
239
+ darkness: number;
240
+ offset: number;
241
+ }
242
+ /** Parametric grading. Hue in degrees; the rest are -1..1 around neutral 0. */
243
+ export interface SceneColorGrade {
244
+ enabled: boolean;
245
+ hue: number;
246
+ saturation: number;
247
+ brightness: number;
248
+ contrast: number;
249
+ }
250
+ /** RGB fringing toward frame edges. `strength` 0..1. */
251
+ export interface SceneChromaticAberration {
252
+ enabled: boolean;
253
+ strength: number;
254
+ }
255
+ /** Animated film grain. `strength` is blend opacity 0..1. */
256
+ export interface SceneFilmGrain {
257
+ enabled: boolean;
258
+ strength: number;
259
+ }
260
+ /** Color-grading lookup table (.cube/.3dl asset). Active whenever an asset is set. */
261
+ export interface SceneLut {
262
+ assetId: string | null;
263
+ intensity: number;
264
+ }
265
+ /**
266
+ * Bokeh blur away from the focus plane. Focus tracks the camera's orbit target,
267
+ * so whatever the user framed stays sharp; `focusRange` widens the sharp band.
268
+ */
269
+ export interface SceneDepthOfField {
270
+ enabled: boolean;
271
+ bokehScale: number;
272
+ focusRange: number;
273
+ }
274
+ /** Overlay/stylization gimmicks, each independently switchable. */
275
+ export interface SceneStylize {
276
+ pixelate: {
203
277
  enabled: boolean;
204
- strength: number;
205
- };
206
- lut: {
207
- assetId: string | null;
208
- intensity: number;
278
+ granularity: number;
209
279
  };
210
- dof: {
280
+ glitch: {
211
281
  enabled: boolean;
212
- bokehScale: number;
213
- focusRange: number;
214
- };
215
- stylize: {
216
- pixelate: {
217
- enabled: boolean;
218
- granularity: number;
219
- };
220
- glitch: {
221
- enabled: boolean;
222
- };
223
282
  };
224
283
  }
284
+ /** Post-processing over the rendered 3D frame. Everything off skips the effect chain entirely. */
285
+ export interface SceneEffects {
286
+ toneMapping: SceneToneMapping;
287
+ /** Scene brightness multiplied in before the tone curve; 1 is neutral. Works in every mode, including `none`. */
288
+ exposure: number;
289
+ bloom: SceneBloom;
290
+ vignette: SceneVignette;
291
+ color: SceneColorGrade;
292
+ chromaticAberration: SceneChromaticAberration;
293
+ grain: SceneFilmGrain;
294
+ lut: SceneLut;
295
+ dof: SceneDepthOfField;
296
+ stylize: SceneStylize;
297
+ }
298
+ /**
299
+ * Image-based lighting for the 3D stage: an environment map, whether to show it
300
+ * behind the scene, the scene-global fog, and post-processing.
301
+ */
225
302
  export interface SceneEnvironment {
303
+ /** Equirectangular `.hdr` or image driving the environment lighting, or null. */
226
304
  iblAssetId: string | null;
227
305
  iblIntensity: number;
228
306
  showSkybox: boolean;
@@ -232,13 +310,17 @@ export interface SceneEnvironment {
232
310
  export interface Scene {
233
311
  id: string;
234
312
  name: string;
313
+ /** Models and objects in one list; array order = z-order (bottom to top) within each space. */
235
314
  items: SceneItem[];
315
+ /** Tracking/pose/hotkey/expression target. null only when `items` holds no model. */
236
316
  primaryInstanceId: string | null;
237
317
  background: SceneBackground;
238
318
  behavior: SceneBehavior;
239
319
  vrmCamera: SceneCamera;
320
+ /** Array order is display order only; live lights are keyed by id. */
240
321
  lights: SceneLight[];
241
322
  environment: SceneEnvironment;
323
+ /** Electron accelerator that applies this scene, or null. */
242
324
  shortcut: string | null;
243
325
  }
244
326
  export interface SceneState {
@@ -322,9 +404,15 @@ export interface ExpressionPersistence {
322
404
  }
323
405
  export type TrackingSourceId = 'vts-ios' | 'vts-ios-native' | 'ifacialmocap';
324
406
  export type PoseSourceId = 'vmc';
325
- export type EffectsQuality = 'high' | 'medium' | 'low';
326
407
  export type TrackingStatus = 'off' | 'waiting' | 'tracking' | 'no-face';
327
408
  export type PoseStatus = 'off' | 'waiting' | 'tracking';
409
+ export declare const EFFECTS_QUALITY_LEVELS: readonly ["low", "medium", "high"];
410
+ export type EffectsQuality = (typeof EFFECTS_QUALITY_LEVELS)[number];
411
+ /**
412
+ * Accepted values for `performance.fpsLimit`; 0 = unlimited. Anything else is snapped
413
+ * to the nearest preset by the app, so a picker offering other values would lie.
414
+ */
415
+ export declare const FPS_LIMIT_PRESETS: readonly [0, 15, 30, 60, 90];
328
416
  /** The curated settings surface the API exposes — never the raw store shape. */
329
417
  export interface Settings {
330
418
  window: {
package/dist/types.js CHANGED
@@ -1,6 +1,12 @@
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
+ export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
5
+ /**
6
+ * Accepted values for `performance.fpsLimit`; 0 = unlimited. Anything else is snapped
7
+ * to the nearest preset by the app, so a picker offering other values would lie.
8
+ */
9
+ export const FPS_LIMIT_PRESETS = [0, 15, 30, 60, 90];
4
10
  // ---- Injection -----------------------------------------------------------------
5
11
  /** VTS's input vocabulary — the valid `id`s for `input` inject targets (and the names a model's `.vtube.json` references). */
6
12
  export const INPUT_NAMES = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/persona-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",