@laplace.live/persona-sdk 0.6.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.
- package/dist/client/address.d.ts +18 -0
- package/dist/client/address.js +56 -0
- package/dist/{client.d.ts → client/client.d.ts} +37 -15
- package/dist/{client.js → client/client.js} +67 -20
- package/dist/index.d.ts +14 -8
- package/dist/index.js +16 -8
- package/dist/values/effect-schema.d.ts +30 -0
- package/dist/values/effect-schema.js +111 -0
- package/dist/values/guards.d.ts +8 -0
- package/dist/values/guards.js +17 -0
- package/dist/values/labels.d.ts +6 -0
- package/dist/values/labels.js +9 -0
- package/dist/values/limits.d.ts +73 -0
- package/dist/values/limits.js +112 -0
- package/dist/values/locale.d.ts +9 -0
- package/dist/values/locale.js +21 -0
- package/dist/{envelope.d.ts → wire/envelope.d.ts} +10 -5
- package/dist/{envelope.js → wire/envelope.js} +5 -4
- package/dist/{events.d.ts → wire/events.d.ts} +10 -0
- package/dist/{events.js → wire/events.js} +2 -0
- package/dist/{methods.d.ts → wire/methods.d.ts} +126 -1
- package/dist/{protocol.d.ts → wire/protocol.d.ts} +4 -0
- package/dist/{protocol.js → wire/protocol.js} +4 -0
- package/dist/{schemas.d.ts → wire/schemas.d.ts} +39 -0
- package/dist/{schemas.js → wire/schemas.js} +46 -4
- package/dist/{types.d.ts → wire/types.d.ts} +71 -3
- package/dist/{types.js → wire/types.js} +19 -0
- package/package.json +2 -2
- /package/dist/{errors.d.ts → wire/errors.d.ts} +0 -0
- /package/dist/{errors.js → wire/errors.js} +0 -0
- /package/dist/{methods.js → wire/methods.js} +0 -0
|
@@ -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 {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
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
|
|
19
|
-
reason
|
|
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
|
-
/**
|
|
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 "
|
|
2
|
-
import { PersonaApiError } from "
|
|
3
|
-
import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED,
|
|
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:
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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 './
|
|
3
|
-
export * from './
|
|
4
|
-
export * from './
|
|
5
|
-
export * from './
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
8
|
-
export * from './
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
export * from "./
|
|
4
|
-
export * from "./
|
|
5
|
-
export * from "./
|
|
6
|
-
export * from "./
|
|
7
|
-
export * from "./
|
|
8
|
-
export * from "./
|
|
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,30 @@
|
|
|
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
|
+
/**
|
|
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[];
|
|
29
|
+
/** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
|
|
30
|
+
export declare function defaultToggleEffects(): Pick<SceneEffects, ToggleEffectKey>;
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
// Scene-space effects (geometry, not a post stage — e.g. snow) also join
|
|
8
|
+
// SCENE_SPACE_EFFECT_KEYS below.
|
|
9
|
+
// 4. Drop one `<EffectSection>` line into a panel section.
|
|
10
|
+
// Effects that don't fit the pattern (tone mapping, exposure, LUT, shockwave)
|
|
11
|
+
// stay bespoke — extend the pattern before special-casing a third shape.
|
|
12
|
+
//
|
|
13
|
+
// Plain data only — no React, no lingui (labels live in the panel's
|
|
14
|
+
// effect-labels.ts) — so main-process healing and API plugins import it freely.
|
|
15
|
+
// Declared string-indexed so generic call sites (slider loop, healing walk) iterate
|
|
16
|
+
// without erasure casts; `satisfies` still checks exact per-effect param parity here.
|
|
17
|
+
export const EFFECT_SPECS = {
|
|
18
|
+
color: {
|
|
19
|
+
hue: { default: 0, min: -180, max: 180, step: 1 },
|
|
20
|
+
saturation: { default: 0, min: -1, max: 1, step: 0.01 },
|
|
21
|
+
brightness: { default: 0, min: -1, max: 1, step: 0.01 },
|
|
22
|
+
contrast: { default: 0, min: -1, max: 1, step: 0.01 },
|
|
23
|
+
},
|
|
24
|
+
bloom: {
|
|
25
|
+
// Past ×3 the halo overwhelms the source pixels and everything reads as haze.
|
|
26
|
+
intensity: { default: 1, min: 0, max: 3, step: 0.01 },
|
|
27
|
+
// Threshold 0.8 catches highlights without hazing the whole avatar; radius is the lib default.
|
|
28
|
+
threshold: { default: 0.8, min: 0, max: 1, step: 0.01 },
|
|
29
|
+
radius: { default: 0.85, min: 0, max: 1, step: 0.01 },
|
|
30
|
+
},
|
|
31
|
+
dof: {
|
|
32
|
+
bokehScale: { default: 2, min: 0, max: 8, step: 0.01 },
|
|
33
|
+
/** World metres of acceptably-sharp depth around the focus plane. */
|
|
34
|
+
focusRange: { default: 2, min: 0.1, max: 10, step: 0.01 },
|
|
35
|
+
},
|
|
36
|
+
chromaticAberration: {
|
|
37
|
+
strength: { default: 0.2, min: 0, max: 1, step: 0.01 },
|
|
38
|
+
},
|
|
39
|
+
grain: {
|
|
40
|
+
strength: { default: 0.3, min: 0, max: 1, step: 0.01 },
|
|
41
|
+
},
|
|
42
|
+
vignette: {
|
|
43
|
+
darkness: { default: 0.5, min: 0, max: 1, step: 0.01 },
|
|
44
|
+
offset: { default: 0.5, min: 0, max: 1, step: 0.01 },
|
|
45
|
+
},
|
|
46
|
+
pixelate: {
|
|
47
|
+
// Pixels per block; past ~64 the frame is a handful of tiles.
|
|
48
|
+
granularity: { default: 8, min: 2, max: 64, step: 1 },
|
|
49
|
+
},
|
|
50
|
+
// Canvas UI Glitch ranges, verbatim — the effect is a port and should read the same.
|
|
51
|
+
glitch: {
|
|
52
|
+
intensity: { default: 1, min: 0, max: 2, step: 0.05 },
|
|
53
|
+
speed: { default: 1, min: 0.1, max: 4, step: 0.1, digits: 1, unit: '×' },
|
|
54
|
+
interval: { default: 3, min: 0, max: 8, step: 0.25, digits: 2, unit: 's' },
|
|
55
|
+
duration: { default: 0.4, min: 0.1, max: 2, step: 0.05, digits: 2, unit: 's' },
|
|
56
|
+
slices: { default: 24, min: 4, max: 80, step: 1 },
|
|
57
|
+
shift: { default: 30, min: 0, max: 120, step: 2, unit: 'px' },
|
|
58
|
+
rgbShift: { default: 4, min: 0, max: 20, step: 0.5, digits: 1, unit: 'px' },
|
|
59
|
+
blocks: { default: 0.5, min: 0, max: 1, step: 0.02 },
|
|
60
|
+
noise: { default: 0.35, min: 0, max: 1, step: 0.02 },
|
|
61
|
+
},
|
|
62
|
+
// Canvas UI Droplets ranges, verbatim where the option carried over; direction
|
|
63
|
+
// and glints are ours (rotatable field, transparent-window visibility).
|
|
64
|
+
droplets: {
|
|
65
|
+
intensity: { default: 0.5, min: 0, max: 1.25, step: 0.05 },
|
|
66
|
+
speed: { default: 1, min: 0, max: 3, step: 0.1, digits: 1, unit: '×' },
|
|
67
|
+
scale: { default: 0.4, min: 0.4, max: 2.5, step: 0.05 },
|
|
68
|
+
dropWidth: { default: 1, min: 0.4, max: 1.5, step: 0.05 },
|
|
69
|
+
dropLength: { default: 1, min: 0.4, max: 2.5, step: 0.05 },
|
|
70
|
+
refraction: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
|
|
71
|
+
fallSpeed: { default: 1, min: 0, max: 3, step: 0.1, digits: 1, unit: '×' },
|
|
72
|
+
direction: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
|
|
73
|
+
wiggle: { default: 1, min: 0, max: 2, step: 0.1, digits: 1 },
|
|
74
|
+
staticDrops: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
|
|
75
|
+
glints: { default: 0.6, min: 0, max: 1, step: 0.05 },
|
|
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
|
+
},
|
|
88
|
+
};
|
|
89
|
+
// Object.keys widens to string[]; the annotation above pins the keys to exactly ToggleEffectKey.
|
|
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));
|
|
99
|
+
/** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
|
|
100
|
+
export function defaultToggleEffects() {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const key of TOGGLE_EFFECT_KEYS) {
|
|
103
|
+
const fx = { enabled: false };
|
|
104
|
+
for (const [param, spec] of Object.entries(EFFECT_SPECS[key]))
|
|
105
|
+
fx[param] = spec.default;
|
|
106
|
+
out[key] = fx;
|
|
107
|
+
}
|
|
108
|
+
// Runtime-built, invisible to TS; EFFECT_SPECS's satisfies check is what pins
|
|
109
|
+
// each entry's params to exactly SceneEffects[key].
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -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,73 @@
|
|
|
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
|
+
/** 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;
|
|
31
|
+
/**
|
|
32
|
+
* Exp2 falloff is squared, so a stage measured in metres is fully socked in well
|
|
33
|
+
* before Unity's nominal 1 — this keeps the slider's useful band across its width.
|
|
34
|
+
*/
|
|
35
|
+
export declare const SCENE_FOG_DENSITY_MAX = 0.5;
|
|
36
|
+
/** ×4 is +2 stops — enough to rescue an AgX-dimmed avatar without turning the slider to mush. */
|
|
37
|
+
export declare const SCENE_EXPOSURE_MAX = 4;
|
|
38
|
+
export declare const MTOON_RIM_MAX = 2;
|
|
39
|
+
export declare const MTOON_OUTLINE_WIDTH_MAX = 2;
|
|
40
|
+
/** HDR headroom: past ×1 the point is pushing emissive parts over the bloom threshold. */
|
|
41
|
+
export declare const MTOON_EMISSIVE_MAX = 4;
|
|
42
|
+
export declare function defaultMToonTuning(): MToonTuning;
|
|
43
|
+
export declare const DEFAULT_LIGHT_AZIMUTH_DEG = 45;
|
|
44
|
+
export declare const DEFAULT_LIGHT_ELEVATION_DEG: number;
|
|
45
|
+
/** A fresh light of `type`, at the defaults that read sensibly for that type. */
|
|
46
|
+
export declare function defaultSceneLightOf(type: SceneLightType): SceneLight;
|
|
47
|
+
export declare const PLACE_2D_SCALE_MIN = 0.01;
|
|
48
|
+
export declare const PLACE_2D_SCALE_MAX = 50;
|
|
49
|
+
export declare const PLACE_3D_SCALE_MIN = 0.01;
|
|
50
|
+
export declare const PLACE_3D_SCALE_MAX = 100;
|
|
51
|
+
/** A prop is a mesh, so it only exists in the three.js scene; every other kind renders in both. */
|
|
52
|
+
export declare function objectSupportsSpace(kind: ObjectContent['kind'], space: ObjectSpace): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The one model format a space's objects can ride: the renderers never mix, and
|
|
55
|
+
* the three canvas always composites over the Pixi one, so a cross-space pin
|
|
56
|
+
* would z-fight by construction.
|
|
57
|
+
*/
|
|
58
|
+
export declare function attachableParentFormat(space: ObjectSpace): ModelFormat;
|
|
59
|
+
export declare const DEFAULT_HEAD_ANGLE: NonNullable<Attach['headAngle']>;
|
|
60
|
+
export declare const ATTACH_MULTIPLIER_MIN = -2;
|
|
61
|
+
export declare const ATTACH_MULTIPLIER_MAX = 2;
|
|
62
|
+
export declare const ATTACH_SMOOTHING_MAX = 50;
|
|
63
|
+
export declare const DEFAULT_ELASTICITY: NonNullable<Attach['elasticity']>;
|
|
64
|
+
export declare const ELASTICITY_STIFFNESS_MAX = 100;
|
|
65
|
+
export declare const ELASTICITY_DAMPING_MAX = 10;
|
|
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;
|