@laplace.live/persona-sdk 0.5.0 → 0.7.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.
@@ -0,0 +1,18 @@
1
+ export declare const PORT_MIN = 1;
2
+ export declare const PORT_MAX = 65535;
3
+ export declare function isValidPort(port: number): boolean;
4
+ /** Strict text → port: decimal digits only — rejects the hex/exponent/sign forms `Number()` accepts. */
5
+ export declare function parsePort(text: string): number | null;
6
+ export interface PersonaAddress {
7
+ host?: string;
8
+ port?: number;
9
+ /** `wss://` instead of `ws://` — only for a TLS-terminating proxy in front of the API. */
10
+ secure?: boolean;
11
+ }
12
+ /**
13
+ * WebSocket URL for the API server, from structured fields or one free-text
14
+ * address (`host`, `host:port`, bare or bracketed IPv6, or a full `ws(s)://`
15
+ * URL taken verbatim). Zone-scoped IPv6 throws — a URL cannot carry a zone id.
16
+ * The token never belongs in it — {@link PersonaClient} appends `?token=` itself.
17
+ */
18
+ export declare function personaWsUrl(input?: string | PersonaAddress): string;
@@ -0,0 +1,56 @@
1
+ import { DEFAULT_API_HOST, DEFAULT_API_PORT } from "../wire/protocol.js";
2
+ export const PORT_MIN = 1;
3
+ export const PORT_MAX = 65535;
4
+ export function isValidPort(port) {
5
+ return Number.isInteger(port) && port >= PORT_MIN && port <= PORT_MAX;
6
+ }
7
+ /** Strict text → port: decimal digits only — rejects the hex/exponent/sign forms `Number()` accepts. */
8
+ export function parsePort(text) {
9
+ const t = text.trim();
10
+ if (!/^\d+$/.test(t))
11
+ return null;
12
+ const port = Number(t);
13
+ return isValidPort(port) ? port : null;
14
+ }
15
+ /** Bare IPv6 literals must be bracketed in a URL authority; hostnames and IPv4 pass through. */
16
+ function formatHost(host) {
17
+ const trimmed = host.trim();
18
+ // WHATWG URL parsers reject an IPv6 zone id even %25-encoded, so fail with the real cause here.
19
+ if (trimmed.includes('%'))
20
+ throw new Error(`zone-scoped IPv6 addresses cannot be used in a URL: ${trimmed}`);
21
+ if (trimmed.startsWith('['))
22
+ return trimmed;
23
+ return trimmed.includes(':') ? `[${trimmed}]` : trimmed;
24
+ }
25
+ /**
26
+ * WebSocket URL for the API server, from structured fields or one free-text
27
+ * address (`host`, `host:port`, bare or bracketed IPv6, or a full `ws(s)://`
28
+ * URL taken verbatim). Zone-scoped IPv6 throws — a URL cannot carry a zone id.
29
+ * The token never belongs in it — {@link PersonaClient} appends `?token=` itself.
30
+ */
31
+ export function personaWsUrl(input) {
32
+ if (typeof input === 'string')
33
+ return urlFromText(input);
34
+ const { host = DEFAULT_API_HOST, port = DEFAULT_API_PORT, secure = false } = input ?? {};
35
+ return `${secure ? 'wss' : 'ws'}://${formatHost(host)}:${String(port)}`;
36
+ }
37
+ function urlFromText(raw) {
38
+ const text = raw.trim();
39
+ if (text === '')
40
+ return personaWsUrl({});
41
+ if (text.includes('://'))
42
+ return text;
43
+ const closing = text.indexOf(']');
44
+ if (closing !== -1) {
45
+ // Bracketed IPv6, with or without a port.
46
+ const port = text.slice(closing + 1).replace(/^:/, '');
47
+ return `ws://${formatHost(text.slice(0, closing + 1))}:${port === '' ? String(DEFAULT_API_PORT) : port}`;
48
+ }
49
+ const colons = text.split(':').length - 1;
50
+ if (colons === 0)
51
+ return `ws://${text}:${String(DEFAULT_API_PORT)}`;
52
+ // 2+ colons is a bare IPv6 literal — a port cannot be expressed without brackets.
53
+ if (colons > 1)
54
+ return `ws://${formatHost(text.replace(/^\[/, ''))}:${String(DEFAULT_API_PORT)}`;
55
+ return `ws://${text}`;
56
+ }
@@ -1,6 +1,7 @@
1
- import type { EventData, EventName } from './events.ts';
2
- import type { MethodName, MethodRequest, MethodResponse, SessionIdentifyRequest } from './methods.ts';
3
- import type { InjectTarget } from './types.ts';
1
+ import type { AppInfo } from '../wire/envelope.ts';
2
+ import type { EventData, EventName } from '../wire/events.ts';
3
+ import type { MethodName, MethodRequest, MethodResponse, SessionIdentifyRequest } from '../wire/methods.ts';
4
+ import type { InjectTarget } from '../wire/types.ts';
4
5
  export type PersonaClientState = 'closed' | 'connecting' | 'open' | 'reconnecting';
5
6
  /**
6
7
  * The subset of the WebSocket API the client uses. The global `WebSocket`
@@ -14,12 +15,23 @@ export interface WebSocketLike {
14
15
  addEventListener(type: 'message', listener: (event: {
15
16
  data?: unknown;
16
17
  }) => void): void;
17
- addEventListener(type: 'close', listener: (event?: {
18
- code?: number;
19
- reason?: string;
18
+ addEventListener(type: 'close', listener: (event: {
19
+ code: number;
20
+ reason: string;
20
21
  }) => void): void;
21
22
  addEventListener(type: 'error', listener: () => void): void;
22
23
  }
24
+ /** A server- or network-initiated close. `terminal` marks the codes the client stops redialing on. */
25
+ export interface PersonaCloseEvent {
26
+ code: number;
27
+ reason: string;
28
+ terminal: boolean;
29
+ }
30
+ /** The server's `hello`: its protocol version and app identity. */
31
+ export interface ServerInfo {
32
+ protocol: number;
33
+ app: AppInfo;
34
+ }
23
35
  export interface PersonaClientOptions {
24
36
  /** An API key created in Persona's settings. */
25
37
  token: string;
@@ -36,6 +48,8 @@ export interface PersonaClientOptions {
36
48
  reconnectDelayMs?: number;
37
49
  reconnectDelayMaxMs?: number;
38
50
  requestTimeoutMs?: number;
51
+ /** Bound each awaited {@link PersonaClient.connect} — on expiry it rejects while the attempt keeps going. */
52
+ connectTimeoutMs?: number;
39
53
  /**
40
54
  * Identifies your app in Persona's settings (Connected Clients). Declarative and
41
55
  * display-only, never authorization; sent automatically on every connection.
@@ -44,6 +58,13 @@ export interface PersonaClientOptions {
44
58
  /** Custom socket factory — for `ws` with headers, or tests. `headers` is set only for `auth: 'header'`. */
45
59
  createWebSocket?: (url: string, headers: Record<string, string> | undefined) => WebSocketLike;
46
60
  onStateChange?: (state: PersonaClientState) => void;
61
+ /**
62
+ * Server- or network-initiated closes, with the code and reason the raw socket
63
+ * carried. Fires after the client has settled its own handling (state, pending
64
+ * calls), so calling `close()` inside it is safe; a `close()` you issued does
65
+ * not report. When set, terminal closes skip `onWarning` — this is the signal.
66
+ */
67
+ onClose?: (event: PersonaCloseEvent) => void;
47
68
  /** Non-fatal notices (protocol version mismatch). Default `console.warn`. */
48
69
  onWarning?: (message: string) => void;
49
70
  }
@@ -70,20 +91,21 @@ export declare class PersonaClient {
70
91
  private closedByUser;
71
92
  private flushQueued;
72
93
  private nextId;
94
+ private connectPromise;
95
+ private settleConnect;
73
96
  private readonly idPrefix;
74
97
  /** The server's `hello`, once connected. */
75
- serverInfo: {
76
- protocol: number;
77
- app: {
78
- name: string;
79
- version: string;
80
- platform: string;
81
- };
82
- } | null;
98
+ serverInfo: ServerInfo | null;
83
99
  constructor(options: PersonaClientOptions);
84
100
  getState(): PersonaClientState;
85
- /** Open the connection and wait for the server's `hello`. Rejects on first failure. */
101
+ /**
102
+ * Open the connection and wait for the server's `hello`. Rejects on first
103
+ * failure, and after `connectTimeoutMs` (the attempt then keeps going in the
104
+ * background). Joins an in-flight attempt instead of stacking a second socket;
105
+ * while reconnecting between backoff delays it dials immediately.
106
+ */
86
107
  connect(): Promise<void>;
108
+ private withConnectTimeout;
87
109
  /** Close for good: pending calls reject, leases drop server-side via the lease timeout. */
88
110
  close(): void;
89
111
  /** Send one request and await its typed response. */
@@ -1,8 +1,9 @@
1
- import { parseServerMessage } from "./envelope.js";
2
- import { PersonaApiError } from "./errors.js";
3
- import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, DEFAULT_API_PORT, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, } from "./protocol.js";
1
+ import { parseServerMessage } from "../wire/envelope.js";
2
+ import { PersonaApiError } from "../wire/errors.js";
3
+ import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, } from "../wire/protocol.js";
4
+ import { personaWsUrl } from "./address.js";
4
5
  const DEFAULTS = {
5
- url: `ws://127.0.0.1:${String(DEFAULT_API_PORT)}`,
6
+ url: personaWsUrl(),
6
7
  reconnectDelayMs: 500,
7
8
  reconnectDelayMaxMs: 10_000,
8
9
  requestTimeoutMs: 10_000,
@@ -25,6 +26,8 @@ export class PersonaClient {
25
26
  closedByUser = false;
26
27
  flushQueued = false;
27
28
  nextId = 1;
29
+ connectPromise = null;
30
+ settleConnect = null;
28
31
  idPrefix = Math.random().toString(36).slice(2, 10);
29
32
  /** The server's `hello`, once connected. */
30
33
  serverInfo = null;
@@ -39,13 +42,40 @@ export class PersonaClient {
39
42
  getState() {
40
43
  return this.state;
41
44
  }
42
- /** Open the connection and wait for the server's `hello`. Rejects on first failure. */
43
- async connect() {
44
- if (this.state === 'open' || this.state === 'connecting')
45
- return;
45
+ /**
46
+ * Open the connection and wait for the server's `hello`. Rejects on first
47
+ * failure, and after `connectTimeoutMs` (the attempt then keeps going in the
48
+ * background). Joins an in-flight attempt instead of stacking a second socket;
49
+ * while reconnecting between backoff delays it dials immediately.
50
+ */
51
+ connect() {
52
+ if (this.state === 'open')
53
+ return Promise.resolve();
46
54
  this.closedByUser = false;
55
+ if (this.connectPromise !== null)
56
+ return this.withConnectTimeout(this.connectPromise);
57
+ if (this.reconnectTimer !== null) {
58
+ clearTimeout(this.reconnectTimer);
59
+ this.reconnectTimer = null;
60
+ }
61
+ // Dial before announcing 'connecting': a connect() reentered from onStateChange
62
+ // must find the tracked attempt and join it, not resolve early.
63
+ const attempt = this.open();
47
64
  this.setState('connecting');
48
- await this.open();
65
+ return this.withConnectTimeout(attempt);
66
+ }
67
+ withConnectTimeout(attempt) {
68
+ const ms = this.opts.connectTimeoutMs;
69
+ if (ms === undefined)
70
+ return attempt;
71
+ return new Promise((resolve, reject) => {
72
+ const timer = setTimeout(() => {
73
+ reject(new Error(`timed out connecting after ${String(ms)} ms`));
74
+ }, ms);
75
+ attempt.then(resolve, reject).finally(() => {
76
+ clearTimeout(timer);
77
+ });
78
+ });
49
79
  }
50
80
  /** Close for good: pending calls reject, leases drop server-side via the lease timeout. */
51
81
  close() {
@@ -55,6 +85,8 @@ export class PersonaClient {
55
85
  this.reconnectTimer = null;
56
86
  this.stopHeartbeat();
57
87
  this.failPending(new Error('client closed'));
88
+ // An awaited connect() must reject now — nulling `ws` below makes its close event a no-op.
89
+ this.settleConnect?.(new Error('client closed'));
58
90
  const ws = this.ws;
59
91
  this.ws = null;
60
92
  ws?.close();
@@ -173,7 +205,7 @@ export class PersonaClient {
173
205
  throw new Error("auth: 'header' needs a createWebSocket factory that can set headers");
174
206
  })();
175
207
  this.ws = ws;
176
- return new Promise((resolve, reject) => {
208
+ const attempt = new Promise((resolve, reject) => {
177
209
  let settled = false;
178
210
  const settle = (err) => {
179
211
  if (settled)
@@ -184,6 +216,8 @@ export class PersonaClient {
184
216
  else
185
217
  resolve();
186
218
  };
219
+ // Lets close() settle an in-flight connect instead of stranding its awaiter.
220
+ this.settleConnect = settle;
187
221
  ws.addEventListener('message', ev => {
188
222
  const data = ev.data;
189
223
  if (typeof data !== 'string')
@@ -205,34 +239,47 @@ export class PersonaClient {
205
239
  this.serverInfo = null;
206
240
  this.failPending(new Error('connection closed'));
207
241
  settle(new Error('connection closed'));
242
+ const code = ev?.code ?? 1006;
243
+ const reason = ev?.reason ?? '';
244
+ const terminal = code === CLOSE_KEY_REVOKED || code === CLOSE_FORCE_DISCONNECTED;
208
245
  if (this.closedByUser) {
209
246
  this.setState('closed');
210
- return;
247
+ return; // a close() the caller issued is not news
211
248
  }
212
- const code = ev?.code;
213
- if (code === CLOSE_KEY_REVOKED || code === CLOSE_FORCE_DISCONNECTED) {
249
+ if (terminal) {
214
250
  // Terminal by protocol: redialing would either fail auth forever or undo the user's action.
215
251
  if (this.reconnectTimer !== null)
216
252
  clearTimeout(this.reconnectTimer);
217
253
  this.reconnectTimer = null;
218
254
  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');
255
+ // onClose is the richer signal; the warning stays for clients without it.
256
+ if (this.opts.onClose === undefined) {
257
+ this.warn(code === CLOSE_KEY_REVOKED
258
+ ? 'the server revoked this API key — create a new one in Persona and reconnect'
259
+ : 'the server disconnected this session — not reconnecting');
260
+ }
222
261
  this.setState('closed');
223
- return;
224
262
  }
225
- if (!this.opts.reconnect || this.state === 'connecting') {
263
+ else if (!this.opts.reconnect || this.state === 'connecting') {
226
264
  // Initial connect failed: report to the caller instead of retrying forever.
227
265
  this.setState('closed');
228
- return;
229
266
  }
230
- this.scheduleReconnect();
267
+ else {
268
+ this.scheduleReconnect();
269
+ }
270
+ // Fired last: everything is settled, so a close() inside the callback is safe.
271
+ this.opts.onClose?.({ code, reason, terminal });
231
272
  });
232
273
  ws.addEventListener('error', () => {
233
274
  // The close event follows and carries the terminal handling.
234
275
  });
235
276
  });
277
+ const tracked = attempt.finally(() => {
278
+ if (this.connectPromise === tracked)
279
+ this.connectPromise = null;
280
+ });
281
+ this.connectPromise = tracked;
282
+ return tracked;
236
283
  }
237
284
  onHello(protocol, app) {
238
285
  this.serverInfo = { protocol, app };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,14 @@
1
- export * from './client.ts';
2
- export * from './envelope.ts';
3
- export * from './errors.ts';
4
- export * from './events.ts';
5
- export * from './methods.ts';
6
- export * from './protocol.ts';
7
- export * from './schemas.ts';
8
- export * from './types.ts';
1
+ export * from './client/address.ts';
2
+ export * from './client/client.ts';
3
+ export * from './values/effect-schema.ts';
4
+ export * from './values/guards.ts';
5
+ export * from './values/labels.ts';
6
+ export * from './values/limits.ts';
7
+ export * from './values/locale.ts';
8
+ export * from './wire/envelope.ts';
9
+ export * from './wire/errors.ts';
10
+ export * from './wire/events.ts';
11
+ export * from './wire/methods.ts';
12
+ export * from './wire/protocol.ts';
13
+ export * from './wire/schemas.ts';
14
+ export * from './wire/types.ts';
package/dist/index.js CHANGED
@@ -1,8 +1,16 @@
1
- export * from "./client.js";
2
- export * from "./envelope.js";
3
- export * from "./errors.js";
4
- export * from "./events.js";
5
- export * from "./methods.js";
6
- export * from "./protocol.js";
7
- export * from "./schemas.js";
8
- export * from "./types.js";
1
+ // One flat public surface over the three layers: the wire contract, the client,
2
+ // and the values/registries every Persona app must agree on.
3
+ export * from "./client/address.js";
4
+ export * from "./client/client.js";
5
+ export * from "./values/effect-schema.js";
6
+ export * from "./values/guards.js";
7
+ export * from "./values/labels.js";
8
+ export * from "./values/limits.js";
9
+ export * from "./values/locale.js";
10
+ export * from "./wire/envelope.js";
11
+ export * from "./wire/errors.js";
12
+ export * from "./wire/events.js";
13
+ export * from "./wire/methods.js";
14
+ export * from "./wire/protocol.js";
15
+ export * from "./wire/schemas.js";
16
+ export * from "./wire/types.js";
@@ -0,0 +1,22 @@
1
+ import type { SceneEffects } from '../wire/types.ts';
2
+ /** Slider + healing metadata for one numeric effect parameter. */
3
+ export interface EffectParamSpec {
4
+ default: number;
5
+ min: number;
6
+ max: number;
7
+ step: number;
8
+ /** Fixed readout decimals; omitted follows FxSlider's step-based rule. */
9
+ digits?: number;
10
+ /** Appended to the readout (`s`, `px`, …). */
11
+ unit?: string;
12
+ }
13
+ /** Keys of {@link SceneEffects} that follow the `{ enabled } + numeric params` pattern. */
14
+ export type ToggleEffectKey = {
15
+ [K in keyof SceneEffects]: SceneEffects[K] extends {
16
+ enabled: boolean;
17
+ } ? K : never;
18
+ }[keyof SceneEffects];
19
+ export declare const EFFECT_SPECS: Record<ToggleEffectKey, Readonly<Record<string, EffectParamSpec>>>;
20
+ export declare const TOGGLE_EFFECT_KEYS: readonly ToggleEffectKey[];
21
+ /** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
22
+ export declare function defaultToggleEffects(): Pick<SceneEffects, ToggleEffectKey>;
@@ -0,0 +1,90 @@
1
+ // The scene-effect registry: one spec per toggle-plus-numeric-params effect.
2
+ // Defaults, healing clamps, active checks, structural keys, and the panel's
3
+ // sliders are all derived from this table, so adding an effect is:
4
+ // 1. Type its values in `SceneEffects` (types.ts) + its interface.
5
+ // 2. Spec it here; label it in the panel's effect-labels.ts (parity-checked).
6
+ // 3. Implement its stage under apps/desktop/src/renderer/vrm/webgpu/effects/.
7
+ // 4. Drop one `<EffectSection>` line into a panel section.
8
+ // Effects that don't fit the pattern (tone mapping, exposure, LUT, shockwave)
9
+ // stay bespoke — extend the pattern before special-casing a third shape.
10
+ //
11
+ // Plain data only — no React, no lingui (labels live in the panel's
12
+ // effect-labels.ts) — so main-process healing and API plugins import it freely.
13
+ // Declared string-indexed so generic call sites (slider loop, healing walk) iterate
14
+ // without erasure casts; `satisfies` still checks exact per-effect param parity here.
15
+ export const EFFECT_SPECS = {
16
+ color: {
17
+ hue: { default: 0, min: -180, max: 180, step: 1 },
18
+ saturation: { default: 0, min: -1, max: 1, step: 0.01 },
19
+ brightness: { default: 0, min: -1, max: 1, step: 0.01 },
20
+ contrast: { default: 0, min: -1, max: 1, step: 0.01 },
21
+ },
22
+ bloom: {
23
+ // Past ×3 the halo overwhelms the source pixels and everything reads as haze.
24
+ intensity: { default: 1, min: 0, max: 3, step: 0.01 },
25
+ // Threshold 0.8 catches highlights without hazing the whole avatar; radius is the lib default.
26
+ threshold: { default: 0.8, min: 0, max: 1, step: 0.01 },
27
+ radius: { default: 0.85, min: 0, max: 1, step: 0.01 },
28
+ },
29
+ dof: {
30
+ bokehScale: { default: 2, min: 0, max: 8, step: 0.01 },
31
+ /** World metres of acceptably-sharp depth around the focus plane. */
32
+ focusRange: { default: 2, min: 0.1, max: 10, step: 0.01 },
33
+ },
34
+ chromaticAberration: {
35
+ strength: { default: 0.2, min: 0, max: 1, step: 0.01 },
36
+ },
37
+ grain: {
38
+ strength: { default: 0.3, min: 0, max: 1, step: 0.01 },
39
+ },
40
+ vignette: {
41
+ darkness: { default: 0.5, min: 0, max: 1, step: 0.01 },
42
+ offset: { default: 0.5, min: 0, max: 1, step: 0.01 },
43
+ },
44
+ pixelate: {
45
+ // Pixels per block; past ~64 the frame is a handful of tiles.
46
+ granularity: { default: 8, min: 2, max: 64, step: 1 },
47
+ },
48
+ // Canvas UI Glitch ranges, verbatim — the effect is a port and should read the same.
49
+ glitch: {
50
+ intensity: { default: 1, min: 0, max: 2, step: 0.05 },
51
+ speed: { default: 1, min: 0.1, max: 4, step: 0.1, digits: 1, unit: '×' },
52
+ interval: { default: 3, min: 0, max: 8, step: 0.25, digits: 2, unit: 's' },
53
+ duration: { default: 0.4, min: 0.1, max: 2, step: 0.05, digits: 2, unit: 's' },
54
+ slices: { default: 24, min: 4, max: 80, step: 1 },
55
+ shift: { default: 30, min: 0, max: 120, step: 2, unit: 'px' },
56
+ rgbShift: { default: 4, min: 0, max: 20, step: 0.5, digits: 1, unit: 'px' },
57
+ blocks: { default: 0.5, min: 0, max: 1, step: 0.02 },
58
+ noise: { default: 0.35, min: 0, max: 1, step: 0.02 },
59
+ },
60
+ // Canvas UI Droplets ranges, verbatim where the option carried over; direction
61
+ // and glints are ours (rotatable field, transparent-window visibility).
62
+ droplets: {
63
+ intensity: { default: 0.5, min: 0, max: 1.25, step: 0.05 },
64
+ speed: { default: 1, min: 0, max: 3, step: 0.1, digits: 1, unit: '×' },
65
+ scale: { default: 0.4, min: 0.4, max: 2.5, step: 0.05 },
66
+ dropWidth: { default: 1, min: 0.4, max: 1.5, step: 0.05 },
67
+ dropLength: { default: 1, min: 0.4, max: 2.5, step: 0.05 },
68
+ refraction: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
69
+ fallSpeed: { default: 1, min: 0, max: 3, step: 0.1, digits: 1, unit: '×' },
70
+ direction: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
71
+ wiggle: { default: 1, min: 0, max: 2, step: 0.1, digits: 1 },
72
+ staticDrops: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
73
+ glints: { default: 0.6, min: 0, max: 1, step: 0.05 },
74
+ },
75
+ };
76
+ // Object.keys widens to string[]; the annotation above pins the keys to exactly ToggleEffectKey.
77
+ export const TOGGLE_EFFECT_KEYS = Object.keys(EFFECT_SPECS);
78
+ /** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
79
+ export function defaultToggleEffects() {
80
+ const out = {};
81
+ for (const key of TOGGLE_EFFECT_KEYS) {
82
+ const fx = { enabled: false };
83
+ for (const [param, spec] of Object.entries(EFFECT_SPECS[key]))
84
+ fx[param] = spec.default;
85
+ out[key] = fx;
86
+ }
87
+ // Runtime-built, invisible to TS; EFFECT_SPECS's satisfies check is what pins
88
+ // each entry's params to exactly SceneEffects[key].
89
+ return out;
90
+ }
@@ -0,0 +1,8 @@
1
+ /** Plain-object check: object, non-null, and not an array. */
2
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
3
+ /** Finite-number type guard (rejects NaN and ±Infinity). */
4
+ export declare function isFiniteNumber(v: unknown): v is number;
5
+ /** The value when it is a finite number, else `fallback`. */
6
+ export declare function finiteOr(v: unknown, fallback: number): number;
7
+ /** The string when it has non-whitespace content (returned untrimmed), else null. */
8
+ export declare function nonEmptyString(v: unknown): string | null;
@@ -0,0 +1,17 @@
1
+ // The tiny type guards the SDK's parsers and every healing pass share.
2
+ /** Plain-object check: object, non-null, and not an array. */
3
+ export function isRecord(v) {
4
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ }
6
+ /** Finite-number type guard (rejects NaN and ±Infinity). */
7
+ export function isFiniteNumber(v) {
8
+ return typeof v === 'number' && Number.isFinite(v);
9
+ }
10
+ /** The value when it is a finite number, else `fallback`. */
11
+ export function finiteOr(v, fallback) {
12
+ return isFiniteNumber(v) ? v : fallback;
13
+ }
14
+ /** The string when it has non-whitespace content (returned untrimmed), else null. */
15
+ export function nonEmptyString(v) {
16
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
17
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Display label for a model-relative motion/expression/animation file: the
3
+ * basename with its format extension stripped. Cubism entries carry no `Name`,
4
+ * so the basename is the only identifier there is.
5
+ */
6
+ export declare function motionLabel(file: string): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Display label for a model-relative motion/expression/animation file: the
3
+ * basename with its format extension stripped. Cubism entries carry no `Name`,
4
+ * so the basename is the only identifier there is.
5
+ */
6
+ export function motionLabel(file) {
7
+ const base = file.split(/[\\/]/).pop() ?? file;
8
+ return base.replace(/\.motion3\.json$|\.exp3\.json$|\.vrma$|\.json$/i, '') || base;
9
+ }
@@ -0,0 +1,62 @@
1
+ import type { Attach, ModelFormat, MToonTuning, ObjectContent, ObjectSpace, SceneLight, SceneLightType, ScreenPlacement, VrmPlacement } from '../wire/types.ts';
2
+ export declare function clamp(v: number, min: number, max: number): number;
3
+ /** Clamp to the unit interval. */
4
+ export declare function clamp01(v: number): number;
5
+ export declare const DEFAULT_LIVE2D_PLACEMENT: ScreenPlacement;
6
+ export declare const DEFAULT_VRM_PLACEMENT: VrmPlacement;
7
+ /** Scene colors heal to 6-digit hex. */
8
+ export declare const SCENE_COLOR_RE: RegExp;
9
+ /** Drop the alpha byte a picker's hex input can produce (`#rrggbbaa` → `#rrggbb`). */
10
+ export declare function opaqueHex(hex: string): string;
11
+ export declare const SCENE_LIGHT_INTENSITY_MAX = 2;
12
+ /**
13
+ * Point lights decay physically (1/d²), so reach rides on power the way Blender's
14
+ * does: 20 stays visible to ~10 m where 2 self-extinguishes by ~5. The other
15
+ * types have no falloff to overcome — 20 would just blow the stage out.
16
+ */
17
+ export declare const SCENE_LIGHT_POINT_INTENSITY_MAX = 20;
18
+ /** Intensity ceiling for a light of `type` — one source for the slider and scene healing. */
19
+ export declare function sceneLightIntensityMax(type: SceneLightType): number;
20
+ export declare const LIVE2D_SCALE_MIN = 0.1;
21
+ export declare const LIVE2D_SCALE_MAX = 24;
22
+ export declare const VRM_SCALE_MIN = 0.05;
23
+ export declare const VRM_SCALE_MAX = 10;
24
+ export declare const SCENE_LIGHT_RANGE_MAX = 20;
25
+ /** Past this the 5-tap kernel spreads thin enough that its dither reads as noise. */
26
+ export declare const SCENE_LIGHT_SHADOW_RADIUS_MAX = 16;
27
+ /**
28
+ * Exp2 falloff is squared, so a stage measured in metres is fully socked in well
29
+ * before Unity's nominal 1 — this keeps the slider's useful band across its width.
30
+ */
31
+ export declare const SCENE_FOG_DENSITY_MAX = 0.5;
32
+ /** ×4 is +2 stops — enough to rescue an AgX-dimmed avatar without turning the slider to mush. */
33
+ export declare const SCENE_EXPOSURE_MAX = 4;
34
+ export declare const MTOON_RIM_MAX = 2;
35
+ export declare const MTOON_OUTLINE_WIDTH_MAX = 2;
36
+ /** HDR headroom: past ×1 the point is pushing emissive parts over the bloom threshold. */
37
+ export declare const MTOON_EMISSIVE_MAX = 4;
38
+ export declare function defaultMToonTuning(): MToonTuning;
39
+ export declare const DEFAULT_LIGHT_AZIMUTH_DEG = 45;
40
+ export declare const DEFAULT_LIGHT_ELEVATION_DEG: number;
41
+ /** A fresh light of `type`, at the defaults that read sensibly for that type. */
42
+ export declare function defaultSceneLightOf(type: SceneLightType): SceneLight;
43
+ export declare const PLACE_2D_SCALE_MIN = 0.01;
44
+ export declare const PLACE_2D_SCALE_MAX = 50;
45
+ export declare const PLACE_3D_SCALE_MIN = 0.01;
46
+ export declare const PLACE_3D_SCALE_MAX = 100;
47
+ /** A prop is a mesh, so it only exists in the three.js scene; every other kind renders in both. */
48
+ export declare function objectSupportsSpace(kind: ObjectContent['kind'], space: ObjectSpace): boolean;
49
+ /**
50
+ * The one model format a space's objects can ride: the renderers never mix, and
51
+ * the three canvas always composites over the Pixi one, so a cross-space pin
52
+ * would z-fight by construction.
53
+ */
54
+ export declare function attachableParentFormat(space: ObjectSpace): ModelFormat;
55
+ export declare const DEFAULT_HEAD_ANGLE: NonNullable<Attach['headAngle']>;
56
+ export declare const ATTACH_MULTIPLIER_MIN = -2;
57
+ export declare const ATTACH_MULTIPLIER_MAX = 2;
58
+ export declare const ATTACH_SMOOTHING_MAX = 50;
59
+ export declare const DEFAULT_ELASTICITY: NonNullable<Attach['elasticity']>;
60
+ export declare const ELASTICITY_STIFFNESS_MAX = 100;
61
+ export declare const ELASTICITY_DAMPING_MAX = 10;
62
+ export declare const ELASTICITY_MAX_SPEED_MAX = 100;
@@ -0,0 +1,98 @@
1
+ export function clamp(v, min, max) {
2
+ return Math.min(max, Math.max(min, v));
3
+ }
4
+ /** Clamp to the unit interval. */
5
+ export function clamp01(v) {
6
+ return v < 0 ? 0 : v > 1 ? 1 : v;
7
+ }
8
+ export const DEFAULT_LIVE2D_PLACEMENT = { x: 0, y: 0, scale: 1, rotation: 0 };
9
+ export const DEFAULT_VRM_PLACEMENT = { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
10
+ /** Scene colors heal to 6-digit hex. */
11
+ export const SCENE_COLOR_RE = /^#[0-9a-f]{6}$/i;
12
+ /** Drop the alpha byte a picker's hex input can produce (`#rrggbbaa` → `#rrggbb`). */
13
+ export function opaqueHex(hex) {
14
+ return /^#[0-9a-f]{8}$/i.test(hex) ? hex.slice(0, 7) : hex;
15
+ }
16
+ export const SCENE_LIGHT_INTENSITY_MAX = 2;
17
+ /**
18
+ * Point lights decay physically (1/d²), so reach rides on power the way Blender's
19
+ * does: 20 stays visible to ~10 m where 2 self-extinguishes by ~5. The other
20
+ * types have no falloff to overcome — 20 would just blow the stage out.
21
+ */
22
+ export const SCENE_LIGHT_POINT_INTENSITY_MAX = 20;
23
+ /** Intensity ceiling for a light of `type` — one source for the slider and scene healing. */
24
+ export function sceneLightIntensityMax(type) {
25
+ return type === 'point' ? SCENE_LIGHT_POINT_INTENSITY_MAX : SCENE_LIGHT_INTENSITY_MAX;
26
+ }
27
+ // Live2D bounds are the zoom clamp (zoom.ts re-exports these); VRM scales a world-space root.
28
+ export const LIVE2D_SCALE_MIN = 0.1;
29
+ export const LIVE2D_SCALE_MAX = 24;
30
+ export const VRM_SCALE_MIN = 0.05;
31
+ export const VRM_SCALE_MAX = 10;
32
+ // Range only *cuts off* a point light — three's 1/d² decay has extinguished it
33
+ // by ~18 m even at max intensity, so a slider past 20 changes nothing visible.
34
+ export const SCENE_LIGHT_RANGE_MAX = 20;
35
+ /** Past this the 5-tap kernel spreads thin enough that its dither reads as noise. */
36
+ export const SCENE_LIGHT_SHADOW_RADIUS_MAX = 16;
37
+ /**
38
+ * Exp2 falloff is squared, so a stage measured in metres is fully socked in well
39
+ * before Unity's nominal 1 — this keeps the slider's useful band across its width.
40
+ */
41
+ export const SCENE_FOG_DENSITY_MAX = 0.5;
42
+ /** ×4 is +2 stops — enough to rescue an AgX-dimmed avatar without turning the slider to mush. */
43
+ export const SCENE_EXPOSURE_MAX = 4;
44
+ export const MTOON_RIM_MAX = 2;
45
+ export const MTOON_OUTLINE_WIDTH_MAX = 2;
46
+ /** HDR headroom: past ×1 the point is pushing emissive parts over the bloom threshold. */
47
+ export const MTOON_EMISSIVE_MAX = 4;
48
+ export function defaultMToonTuning() {
49
+ return { shade: 1, shadingShift: 0, shadingToony: 0, giEqualization: 0, rim: 1, outlineWidth: 1, emissive: 1 };
50
+ }
51
+ // Ranges for toggle-style effect params live in effect-schema.ts (EFFECT_SPECS).
52
+ // Angles at which lightDirection reproduces (1,1,1).normalize() — the app's original hardcoded light.
53
+ export const DEFAULT_LIGHT_AZIMUTH_DEG = 45;
54
+ export const DEFAULT_LIGHT_ELEVATION_DEG = (Math.asin(1 / Math.sqrt(3)) * 180) / Math.PI;
55
+ /** A fresh light of `type`, at the defaults that read sensibly for that type. */
56
+ export function defaultSceneLightOf(type) {
57
+ return {
58
+ id: crypto.randomUUID(),
59
+ type,
60
+ color: '#ffffff',
61
+ intensity: 1,
62
+ azimuth: DEFAULT_LIGHT_AZIMUTH_DEG,
63
+ elevation: DEFAULT_LIGHT_ELEVATION_DEG,
64
+ x: 0,
65
+ y: 1.4,
66
+ z: 1,
67
+ range: 10,
68
+ // A point light shadow is six cube faces, so it stays opt-in where a
69
+ // directional light's single depth pass can be on by default.
70
+ shadowQuality: type === 'directional' ? 'high' : 'off',
71
+ shadowRadius: 6,
72
+ };
73
+ }
74
+ export const PLACE_2D_SCALE_MIN = 0.01;
75
+ export const PLACE_2D_SCALE_MAX = 50;
76
+ export const PLACE_3D_SCALE_MIN = 0.01;
77
+ export const PLACE_3D_SCALE_MAX = 100;
78
+ /** A prop is a mesh, so it only exists in the three.js scene; every other kind renders in both. */
79
+ export function objectSupportsSpace(kind, space) {
80
+ return kind === 'prop' ? space === '3d' : true;
81
+ }
82
+ /**
83
+ * The one model format a space's objects can ride: the renderers never mix, and
84
+ * the three canvas always composites over the Pixi one, so a cross-space pin
85
+ * would z-fight by construction.
86
+ */
87
+ export function attachableParentFormat(space) {
88
+ return space === '2d' ? 'live2d' : 'vrm';
89
+ }
90
+ export const DEFAULT_HEAD_ANGLE = { multiplier: 1, smoothing: 15 };
91
+ export const ATTACH_MULTIPLIER_MIN = -2;
92
+ export const ATTACH_MULTIPLIER_MAX = 2;
93
+ export const ATTACH_SMOOTHING_MAX = 50;
94
+ // Warudo Attachable defaults and slider ceilings, verbatim.
95
+ export const DEFAULT_ELASTICITY = { stiffness: 2, damping: 3, maxSpeed: 2 };
96
+ export const ELASTICITY_STIFFNESS_MAX = 100;
97
+ export const ELASTICITY_DAMPING_MAX = 10;
98
+ export const ELASTICITY_MAX_SPEED_MAX = 100;
@@ -0,0 +1,9 @@
1
+ /** Locales shipped in the compiled catalogs (`pnpm i18n`). */
2
+ export declare const LOCALES: readonly ["en", "ja", "zh-CN", "zh-TW"];
3
+ export type Locale = (typeof LOCALES)[number];
4
+ /** `ui.language` setting: an explicit locale, or follow the OS language. */
5
+ export type LanguageSetting = 'system' | Locale;
6
+ /** Native-language display names for the language picker (deliberately untranslated). */
7
+ export declare const LOCALE_LABELS: Record<Locale, string>;
8
+ /** Best supported locale for a BCP 47-ish tag: exact match first, then fuzzy per language. */
9
+ export declare function matchLocaleTag(tag: string): Locale;
@@ -0,0 +1,21 @@
1
+ /** Locales shipped in the compiled catalogs (`pnpm i18n`). */
2
+ export const LOCALES = ['en', 'ja', 'zh-CN', 'zh-TW'];
3
+ /** Native-language display names for the language picker (deliberately untranslated). */
4
+ export const LOCALE_LABELS = {
5
+ en: 'English',
6
+ ja: '日本語',
7
+ 'zh-CN': '简体中文',
8
+ 'zh-TW': '繁體中文',
9
+ };
10
+ /** Best supported locale for a BCP 47-ish tag: exact match first, then fuzzy per language. */
11
+ export function matchLocaleTag(tag) {
12
+ if (LOCALES.includes(tag))
13
+ return tag;
14
+ const t = tag.toLowerCase();
15
+ // Most systems/browsers report `zh-TW`/`zh-HK` with no `hant` subtag, so match the regions too.
16
+ if (t.startsWith('zh'))
17
+ return /hant|tw|hk|mo/.test(t) ? 'zh-TW' : 'zh-CN';
18
+ if (t.startsWith('ja'))
19
+ return 'ja';
20
+ return 'en';
21
+ }
@@ -12,11 +12,7 @@ export interface RequestMessage<M extends MethodName = MethodName> {
12
12
  export interface HelloMessage {
13
13
  kind: 'hello';
14
14
  protocol: number;
15
- app: {
16
- name: string;
17
- version: string;
18
- platform: string;
19
- };
15
+ app: AppInfo;
20
16
  }
21
17
  export interface ResponseMessage {
22
18
  kind: 'response';
@@ -37,6 +33,12 @@ export interface EventMessage<E extends EventName = EventName> {
37
33
  }
38
34
  export type ServerMessage = HelloMessage | ResponseMessage | ErrorMessage | EventMessage;
39
35
  export type ClientMessage = RequestMessage;
36
+ /** The desktop app's identity, as `hello` reports it. */
37
+ export interface AppInfo {
38
+ name: string;
39
+ version: string;
40
+ platform: string;
41
+ }
40
42
  /**
41
43
  * Parse one inbound client frame. Returns the request, or an error code telling
42
44
  * the server what to answer: `parse-error` for junk bytes, `invalid-request`
@@ -1,8 +1,6 @@
1
+ import { isRecord } from "../values/guards.js";
1
2
  import { isApiErrorCode } from "./errors.js";
2
3
  import { isEventName } from "./events.js";
3
- function isRecord(v) {
4
- return typeof v === 'object' && v !== null && !Array.isArray(v);
5
- }
6
4
  /**
7
5
  * Parse one inbound client frame. Returns the request, or an error code telling
8
6
  * the server what to answer: `parse-error` for junk bytes, `invalid-request`
@@ -3,10 +3,14 @@ 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
+ /** Default host clients dial: the server binds loopback unless LAN is opted in. */
7
+ export declare const DEFAULT_API_HOST = "127.0.0.1";
6
8
  /** Server close code: the session's API key was revoked. Terminal — the client must not redial. */
7
9
  export declare const CLOSE_KEY_REVOKED = 4001;
8
10
  /** Server close code: the user disconnected this session from Persona's settings. Terminal — the client must not redial. */
9
11
  export declare const CLOSE_FORCE_DISCONNECTED = 4002;
12
+ /** Standard going-away code the server sends when it stops; reconnecting is expected once it returns. */
13
+ export declare const CLOSE_SERVER_STOPPING = 1001;
10
14
  /** An injected parameter reverts this long after its last write — the lease, not a setting. */
11
15
  export declare const INJECT_LEASE_TTL_MS = 1000;
12
16
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
@@ -2,10 +2,14 @@
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
+ /** Default host clients dial: the server binds loopback unless LAN is opted in. */
6
+ export const DEFAULT_API_HOST = '127.0.0.1';
5
7
  /** Server close code: the session's API key was revoked. Terminal — the client must not redial. */
6
8
  export const CLOSE_KEY_REVOKED = 4001;
7
9
  /** Server close code: the user disconnected this session from Persona's settings. Terminal — the client must not redial. */
8
10
  export const CLOSE_FORCE_DISCONNECTED = 4002;
11
+ /** Standard going-away code the server sends when it stops; reconnecting is expected once it returns. */
12
+ export const CLOSE_SERVER_STOPPING = 1001;
9
13
  /** An injected parameter reverts this long after its last write — the lease, not a setting. */
10
14
  export const INJECT_LEASE_TTL_MS = 1000;
11
15
  /** How often {@link PersonaClient.driveParameter} re-sends held leases to keep them alive. */
@@ -1,12 +1,10 @@
1
1
  import * as z from 'zod';
2
+ import { isRecord } from "../values/guards.js";
2
3
  // Runtime validation for the request side of the wire. Schemas exist for the
3
4
  // methods whose params the app's main process consumes directly; methods without
4
5
  // one are stage-owned — the renderer validates and heals them (same rules as the
5
6
  // panel). Fields marked z.custom are deliberately gated shallowly here because
6
7
  // the app heals them into shape server-side.
7
- function isRecord(v) {
8
- return typeof v === 'object' && v !== null && !Array.isArray(v);
9
- }
10
8
  const nonEmpty = z.string().min(1);
11
9
  export const InjectTargetSchema = z.object({
12
10
  type: z.enum(['input', 'live2d-param', 'vrm-expression']),
@@ -345,6 +345,32 @@ export interface ScenePixelate {
345
345
  enabled: boolean;
346
346
  granularity: number;
347
347
  }
348
+ /** Rain droplets running down a glass pane in front of the frame, refracting it. */
349
+ export interface SceneDroplets {
350
+ enabled: boolean;
351
+ /** How much rain falls, from a light drizzle to a downpour (0 to 1.25). */
352
+ intensity: number;
353
+ /** Animation speed multiplier. */
354
+ speed: number;
355
+ /** Size of the droplet pattern. Higher means smaller drops. */
356
+ scale: number;
357
+ /** Width of the droplets and their trails. */
358
+ dropWidth: number;
359
+ /** How elongated the falling droplets are. */
360
+ dropLength: number;
361
+ /** How strongly droplets refract the frame behind them. */
362
+ refraction: number;
363
+ /** How fast the running drops slide down. */
364
+ fallSpeed: number;
365
+ /** Fall direction in degrees: 0 falls down, ±90 sideways, ±180 up. */
366
+ direction: number;
367
+ /** Horizontal wiggle of the running drops. */
368
+ wiggle: number;
369
+ /** Multiplier for the small static droplets. */
370
+ staticDrops: number;
371
+ /** Specular glint on drops where the frame is transparent, so rain reads over the desktop (0 to 1). */
372
+ glints: number;
373
+ }
348
374
  /**
349
375
  * Post-processing over the rendered 3D frame. Everything off skips the effect
350
376
  * chain entirely. Deliberately flat: every toggle-plus-numbers effect sits at
@@ -364,6 +390,7 @@ export interface SceneEffects {
364
390
  dof: SceneDepthOfField;
365
391
  pixelate: ScenePixelate;
366
392
  glitch: SceneGlitch;
393
+ droplets: SceneDroplets;
367
394
  }
368
395
  /**
369
396
  * Image-based lighting for the 3D stage: an environment map, whether to show it
@@ -476,8 +503,11 @@ export interface ExpressionPersistence {
476
503
  enabled: boolean;
477
504
  saved: string[];
478
505
  }
479
- export type TrackingSourceId = 'vts-ios' | 'vts-ios-native' | 'ifacialmocap';
480
- export type PoseSourceId = 'vmc';
506
+ export declare const TRACKING_SOURCE_IDS: readonly ["vts-ios", "vts-ios-native", "ifacialmocap"];
507
+ export type TrackingSourceId = (typeof TRACKING_SOURCE_IDS)[number];
508
+ /** Body-pose protocols. Every one so far is UDP with a configurable port. */
509
+ export declare const POSE_SOURCE_IDS: readonly ["vmc"];
510
+ export type PoseSourceId = (typeof POSE_SOURCE_IDS)[number];
481
511
  export type TrackingStatus = 'off' | 'waiting' | 'tracking' | 'no-face';
482
512
  export type PoseStatus = 'off' | 'waiting' | 'tracking';
483
513
  export declare const EFFECTS_QUALITY_LEVELS: readonly ["low", "medium", "high"];
@@ -7,6 +7,10 @@
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
+ // ---- Settings ------------------------------------------------------------------
11
+ export const TRACKING_SOURCE_IDS = ['vts-ios', 'vts-ios-native', 'ifacialmocap'];
12
+ /** Body-pose protocols. Every one so far is UDP with a configurable port. */
13
+ export const POSE_SOURCE_IDS = ['vmc'];
10
14
  export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
11
15
  /**
12
16
  * 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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes