@laplace.live/persona-sdk 1.23.1 → 1.25.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.
Files changed (42) hide show
  1. package/README.md +10 -10
  2. package/dist/client/client.js +5 -6
  3. package/dist/client/legacy-names.d.ts +2 -0
  4. package/dist/client/{legacy-kinds.js → legacy-names.js} +12 -3
  5. package/dist/client/node.d.ts +13 -0
  6. package/dist/client/node.js +29 -0
  7. package/dist/client/scene-hotkeys.d.ts +16 -0
  8. package/dist/client/scene-hotkeys.js +17 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +3 -0
  11. package/dist/values/bindings.d.ts +40 -15
  12. package/dist/values/controller.d.ts +9 -6
  13. package/dist/values/effect-schema.d.ts +26 -0
  14. package/dist/values/effect-schema.js +77 -91
  15. package/dist/values/hotkey-targets.d.ts +10 -3
  16. package/dist/values/hotkey-targets.js +20 -0
  17. package/dist/values/hotkeys.d.ts +9 -1
  18. package/dist/values/hotkeys.js +11 -0
  19. package/dist/values/labels.js +1 -0
  20. package/dist/values/limits.d.ts +96 -8
  21. package/dist/values/limits.js +109 -1
  22. package/dist/values/lipsync.d.ts +7 -8
  23. package/dist/values/locale.d.ts +4 -1
  24. package/dist/values/locale.js +4 -0
  25. package/dist/values/model-movement.d.ts +19 -4
  26. package/dist/values/model-movement.js +17 -4
  27. package/dist/values/volumetric-lighting.d.ts +1 -1
  28. package/dist/values/volumetric-lighting.js +1 -1
  29. package/dist/wire/envelope.js +6 -3
  30. package/dist/wire/methods.d.ts +16 -17
  31. package/dist/wire/protocol.d.ts +2 -0
  32. package/dist/wire/protocol.js +4 -0
  33. package/dist/wire/schemas/fields.d.ts +8 -0
  34. package/dist/wire/schemas/fields.js +1 -0
  35. package/dist/wire/schemas/requests.d.ts +2 -2
  36. package/dist/wire/schemas/requests.js +70 -20
  37. package/dist/wire/schemas/settings.d.ts +3 -3
  38. package/dist/wire/schemas/settings.js +3 -3
  39. package/dist/wire/types.d.ts +71 -116
  40. package/dist/wire/types.js +68 -9
  41. package/package.json +2 -2
  42. package/dist/client/legacy-kinds.d.ts +0 -2
package/README.md CHANGED
@@ -34,21 +34,21 @@ mouth.set(0.3);
34
34
  mouth.release();
35
35
  ```
36
36
 
37
- By default the token travels as `?token=` (works in browsers). From Node you can use an
38
- `Authorization: Bearer` header instead by supplying a socket that can set headers, e.g. the
39
- [`ws`](https://www.npmjs.com/package/ws) package (install it separately — it is not a
40
- dependency of this SDK):
37
+ By default the token travels as `?token=` (works in browsers). From Node, `createNodeClient`
38
+ sends it as an `Authorization: Bearer` header instead, over Node's global `WebSocket`, and
39
+ logs the close that ends a session for good (the key revoked, or the session disconnected in
40
+ Persona):
41
41
 
42
42
  ```ts
43
- import WebSocket from "ws";
43
+ import { createNodeClient } from "@laplace.live/persona-sdk";
44
44
 
45
- const persona = new PersonaClient({
46
- token,
47
- auth: "header",
48
- createWebSocket: (url, headers) => new WebSocket(url, { headers }),
49
- });
45
+ const persona = createNodeClient({ token, log: console.warn });
46
+ await persona.connect();
50
47
  ```
51
48
 
49
+ Any other socket that can set headers, such as the [`ws`](https://www.npmjs.com/package/ws)
50
+ package, plugs into `new PersonaClient({ token, auth: "header", createWebSocket })`.
51
+
52
52
  ## Identifying your app
53
53
 
54
54
  Pass `clientInfo` so your app shows up by name under **Connected Clients** in Persona's
@@ -1,8 +1,8 @@
1
1
  import { parseServerMessage } from "../wire/envelope.js";
2
2
  import { PersonaApiError } from "../wire/errors.js";
3
- import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, SCENE_ACTIVATION_TIMEOUT_MS, } from "../wire/protocol.js";
3
+ import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, requestTimeoutMs, } from "../wire/protocol.js";
4
4
  import { personaWsUrl } from "./address.js";
5
- import { renameLegacyKinds } from "./legacy-kinds.js";
5
+ import { renameLegacyNames } from "./legacy-names.js";
6
6
  const DEFAULTS = {
7
7
  url: personaWsUrl(),
8
8
  reconnectDelayMs: 500,
@@ -108,8 +108,7 @@ export class PersonaClient {
108
108
  const timer = setTimeout(() => {
109
109
  this.pending.delete(id);
110
110
  reject(new Error(`request timed out: ${method}`));
111
- }, this.opts.requestTimeoutMs ??
112
- (method === 'scene.activate' ? SCENE_ACTIVATION_TIMEOUT_MS : DEFAULTS.requestTimeoutMs));
111
+ }, this.opts.requestTimeoutMs ?? requestTimeoutMs(method, DEFAULTS.requestTimeoutMs));
113
112
  // Only the runtime id ties a response back to `M`, and its result is unvalidated wire JSON.
114
113
  this.pending.set(id, { method, resolve: resolve, reject, timer });
115
114
  ws.send(JSON.stringify({ kind: 'request', id, method, ...(params === undefined ? {} : { params }) }));
@@ -357,7 +356,7 @@ export class PersonaClient {
357
356
  const set = this.listeners.get(msg.event);
358
357
  if (!set)
359
358
  return;
360
- const data = renameLegacyKinds(msg.event, msg.data);
359
+ const data = renameLegacyNames(msg.event, msg.data);
361
360
  // Only `msg.event` ties these callbacks to their payload type, and the payload is unvalidated wire JSON.
362
361
  for (const cb of set)
363
362
  cb(data);
@@ -372,7 +371,7 @@ export class PersonaClient {
372
371
  this.pending.delete(id);
373
372
  clearTimeout(p.timer);
374
373
  if (msg.kind === 'response')
375
- p.resolve(renameLegacyKinds(p.method, msg.result));
374
+ p.resolve(renameLegacyNames(p.method, msg.result));
376
375
  else
377
376
  p.reject(new PersonaApiError(msg.code, msg.message));
378
377
  }
@@ -0,0 +1,2 @@
1
+ /** A method result or event payload with the old names an older host still sends renamed to current ones. */
2
+ export declare function renameLegacyNames(name: string, data: unknown): unknown;
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from "../values/guards.js";
2
- import { canonicalActionKind } from "../wire/types.js";
2
+ import { canonicalActionKind, canonicalAppCapability } from "../wire/types.js";
3
3
  function renameHotkeyActions(state) {
4
4
  if (!isRecord(state) || !Array.isArray(state.hotkeys))
5
5
  return state;
@@ -23,8 +23,15 @@ function renameActionKinds(list) {
23
23
  : automation),
24
24
  };
25
25
  }
26
- /** A method result or event payload with the action kinds an older host still sends renamed to current ones. */
27
- export function renameLegacyKinds(name, data) {
26
+ /** Old capability names folded into current ones, once each; unknown names stay, as `app.info` reports them. */
27
+ function renameCapabilities(info) {
28
+ if (!isRecord(info) || !Array.isArray(info.capabilities))
29
+ return info;
30
+ const names = info.capabilities.map((name) => typeof name === 'string' ? canonicalAppCapability(name) : name);
31
+ return { ...info, capabilities: [...new Set(names)] };
32
+ }
33
+ /** A method result or event payload with the old names an older host still sends renamed to current ones. */
34
+ export function renameLegacyNames(name, data) {
28
35
  switch (name) {
29
36
  case 'hotkey.list':
30
37
  case 'hotkey.set':
@@ -34,6 +41,8 @@ export function renameLegacyKinds(name, data) {
34
41
  case 'automation.list':
35
42
  case 'automation.state':
36
43
  return renameActionKinds(data);
44
+ case 'app.info':
45
+ return renameCapabilities(data);
37
46
  default:
38
47
  return data;
39
48
  }
@@ -0,0 +1,13 @@
1
+ import type { PersonaClientOptions } from './client.ts';
2
+ import { PersonaClient } from './client.ts';
3
+ /** A Node app's own identity and log sink; {@link createNodeClient} sets the transport and close handling. */
4
+ export type NodeClientOptions = Omit<PersonaClientOptions, 'auth' | 'connectTimeoutMs' | 'createWebSocket' | 'onWarning' | 'onClose'> & {
5
+ /** Receives SDK warnings and the notice of a terminal close. */
6
+ log: (message: string) => void;
7
+ };
8
+ /**
9
+ * A client for a Node process — the Stream Deck plugin, the MCP server. The API key rides an
10
+ * `Authorization: Bearer` header, never the URL, and a terminal close (key revoked, session
11
+ * disconnected in Persona) is logged, since the client stops redialing after one.
12
+ */
13
+ export declare function createNodeClient({ log, ...options }: NodeClientOptions): PersonaClient;
@@ -0,0 +1,29 @@
1
+ import { PersonaClient } from "./client.js";
2
+ /** A key press or tool call must answer promptly; the attempt keeps going in the background. */
3
+ const CONNECT_TIMEOUT_MS = 5000;
4
+ /** Trusts a global WebSocket to be Node's: the DOM typings the SDK compiles under only know the browser one. */
5
+ function takesHeaders(ctor) {
6
+ return typeof ctor === 'function';
7
+ }
8
+ /**
9
+ * A client for a Node process — the Stream Deck plugin, the MCP server. The API key rides an
10
+ * `Authorization: Bearer` header, never the URL, and a terminal close (key revoked, session
11
+ * disconnected in Persona) is logged, since the client stops redialing after one.
12
+ */
13
+ export function createNodeClient({ log, ...options }) {
14
+ const Socket = globalThis.WebSocket;
15
+ if (!takesHeaders(Socket))
16
+ throw new Error('createNodeClient needs a global WebSocket (Node 22 or later)');
17
+ return new PersonaClient({
18
+ ...options,
19
+ connectTimeoutMs: CONNECT_TIMEOUT_MS,
20
+ auth: 'header',
21
+ createWebSocket: (url, headers) => new Socket(url, { headers }),
22
+ onWarning: log,
23
+ onClose: ({ code, reason, terminal }) => {
24
+ if (!terminal)
25
+ return;
26
+ log(`Persona closed the session (${String(code)}${reason === '' ? '' : `: ${reason}`}) — check the API key in Persona's settings`);
27
+ },
28
+ });
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { HotkeyState, Scene, SceneModelItem } from '../wire/types.ts';
2
+ import type { PersonaClient } from './client.ts';
3
+ /** One on-stage model's `hotkey.list` answer. */
4
+ export interface ModelHotkeys {
5
+ model: SceneModelItem;
6
+ /** First in {@link hotkeyModels} order: the scene's primary model. */
7
+ primary: boolean;
8
+ state: HotkeyState;
9
+ }
10
+ /**
11
+ * `hotkey.list` for each model whose hotkeys are live ({@link hotkeyModels}: every model on stage, the
12
+ * primary first) — one promise per model, so a caller can settle each alone.
13
+ */
14
+ export declare function listModelHotkeys(client: Pick<PersonaClient, 'call'>, scene: Pick<Scene, 'items' | 'primaryInstanceId'>): Promise<ModelHotkeys>[];
15
+ /** The active scene's live hotkeys, per model and the primary first; a scene without a model lists none. */
16
+ export declare function listSceneHotkeys(client: Pick<PersonaClient, 'call'>): Promise<ModelHotkeys[]>;
@@ -0,0 +1,17 @@
1
+ import { hotkeyModels } from "../values/hotkeys.js";
2
+ /**
3
+ * `hotkey.list` for each model whose hotkeys are live ({@link hotkeyModels}: every model on stage, the
4
+ * primary first) — one promise per model, so a caller can settle each alone.
5
+ */
6
+ export function listModelHotkeys(client, scene) {
7
+ return hotkeyModels(scene).map(async (model, index) => ({
8
+ model,
9
+ primary: index === 0,
10
+ state: await client.call('hotkey.list', { modelId: model.ref.id }),
11
+ }));
12
+ }
13
+ /** The active scene's live hotkeys, per model and the primary first; a scene without a model lists none. */
14
+ export async function listSceneHotkeys(client) {
15
+ const { scene } = await client.call('scene.get', {});
16
+ return Promise.all(listModelHotkeys(client, scene));
17
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from './client/address.ts';
2
2
  export * from './client/client.ts';
3
+ export * from './client/node.ts';
4
+ export * from './client/scene-hotkeys.ts';
3
5
  export * from './values/arkit.ts';
4
6
  export * from './values/bindings.ts';
5
7
  export * from './values/camera.ts';
@@ -31,6 +33,7 @@ export * from './wire/errors.ts';
31
33
  export * from './wire/events.ts';
32
34
  export * from './wire/methods.ts';
33
35
  export * from './wire/protocol.ts';
36
+ export * from './wire/schemas/fields.ts';
34
37
  export * from './wire/schemas/requests.ts';
35
38
  export * from './wire/schemas/settings.ts';
36
39
  export * from './wire/types.ts';
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // and the values/registries every Persona app must agree on.
3
3
  export * from "./client/address.js";
4
4
  export * from "./client/client.js";
5
+ export * from "./client/node.js";
6
+ export * from "./client/scene-hotkeys.js";
5
7
  export * from "./values/arkit.js";
6
8
  export * from "./values/bindings.js";
7
9
  export * from "./values/camera.js";
@@ -33,6 +35,7 @@ export * from "./wire/errors.js";
33
35
  export * from "./wire/events.js";
34
36
  export * from "./wire/methods.js";
35
37
  export * from "./wire/protocol.js";
38
+ export * from "./wire/schemas/fields.js";
36
39
  export * from "./wire/schemas/requests.js";
37
40
  export * from "./wire/schemas/settings.js";
38
41
  export * from "./wire/types.js";
@@ -6,13 +6,25 @@ export interface Binding {
6
6
  /** Summed at their weights before the range map; never empty. The first is the input the list files it under. */
7
7
  terms: BindingTerm[];
8
8
  output: string;
9
- /** The rig author's own name for this binding, when their `.vtube.json` carried one. */
9
+ /**
10
+ * The rig author's own name for this binding, when their `.vtube.json` carried one
11
+ * (`ParameterSettings[].Name`). It outranks `.cdi3.json`, which names only the output parameter.
12
+ */
10
13
  name?: string;
11
14
  inRange: [number, number];
12
15
  outRange: [number, number];
13
16
  clampOutput: boolean;
17
+ /** VTS's smoothing, per parameter as its manual prescribes, on its own 0-and-up scale; 0 is none. */
14
18
  smoothing: number;
19
+ /**
20
+ * VTS's auto-blink: the engine's blink closes the output alongside any input. Only on the
21
+ * model's `EyeBlink` parameters, the ones that blink writes; inert elsewhere.
22
+ */
15
23
  autoBlink: boolean;
24
+ /**
25
+ * Response shape over the normalized input, replacing the straight line between the ranges.
26
+ * Absent until something authors one: `.vtube.json` has no curve.
27
+ */
16
28
  curve?: Curve;
17
29
  }
18
30
  /** A parameter the rig declares, as the picker lists it. */
@@ -29,23 +41,36 @@ export interface BindingLiveValues {
29
41
  /** What each bound parameter reads on the model right now, by output id; empty for a host that cannot read it back. */
30
42
  outputs: Record<string, number>;
31
43
  }
44
+ /** A `.cdi3.json` parameter group. */
45
+ export interface CubismParameterGroup {
46
+ id: string;
47
+ name: string;
48
+ }
49
+ /** The rig author's own naming, from `.cdi3.json`; every map omits ids the file leaves unnamed. */
50
+ export interface CubismDisplayInfo {
51
+ parameterNames: Record<string, string>;
52
+ /** Parameter id → group id. Absent for a parameter the rig left ungrouped. */
53
+ parameterGroups: Record<string, string>;
54
+ /** Groups in file order — the order the rig author arranged them in. */
55
+ groups: CubismParameterGroup[];
56
+ }
57
+ /** What `.physics3.json` says about parameters. */
58
+ export interface CubismPhysicsInfo {
59
+ /** Parameters that drive physics — normal to track. */
60
+ inputs: string[];
61
+ /** Parameters physics writes; tracking one of these fights the simulation. */
62
+ outputs: string[];
63
+ }
64
+ /** What a rig's Cubism sidecars say about its parameters; each side null when absent or unusable. */
65
+ export interface CubismMetadata {
66
+ display: CubismDisplayInfo | null;
67
+ physics: CubismPhysicsInfo | null;
68
+ }
32
69
  /** Metadata read from a rig's display and physics sidecars. */
33
70
  export interface BindingEditorData {
34
71
  connections: Binding[];
35
72
  parameters: BindableParameter[];
36
- metadata: {
37
- display: {
38
- parameterNames: Record<string, string>;
39
- parameterGroups: Record<string, string>;
40
- groups: {
41
- id: string;
42
- name: string;
43
- }[];
44
- } | null;
45
- physics: {
46
- inputs: string[];
47
- outputs: string[];
48
- } | null;
49
- };
73
+ metadata: CubismMetadata;
74
+ /** The connections came from Persona's sidecar, not the rig's `.vtube.json`. */
50
75
  edited: boolean;
51
76
  }
@@ -92,6 +92,7 @@ export declare const CONTROLLER_DEAD_ZONE_MAX = 0.5;
92
92
  export interface ControllerProfileInfo {
93
93
  slot: ControllerSlot;
94
94
  deviceId: string;
95
+ /** User label; absent keeps the numbered controller name. */
95
96
  name?: string;
96
97
  /** Radial stick dead zone, 0..0.5; absent until tuned, meaning `DEFAULT_CONTROLLER_DEAD_ZONE`. */
97
98
  deadZone?: number;
@@ -100,15 +101,17 @@ export interface ControllerConfig {
100
101
  enabled: boolean;
101
102
  profiles: ControllerProfileInfo[];
102
103
  }
104
+ /** A live connection and the saved profile it is identified as, if any. */
105
+ export interface ControllerDeviceInfo {
106
+ id: string;
107
+ index: number;
108
+ mapping: string;
109
+ slot: ControllerSlot | null;
110
+ }
103
111
  /** Stage-owned input sample; hardware is never polled by the remote console. */
104
112
  export interface ControllerState {
105
113
  profiles: ControllerProfileInfo[];
106
- devices: {
107
- id: string;
108
- index: number;
109
- mapping: string;
110
- slot: ControllerSlot | null;
111
- }[];
114
+ devices: ControllerDeviceInfo[];
112
115
  inputs: Partial<Record<ControllerInputName, number>>;
113
116
  pressed: ControllerTriggerName[];
114
117
  assigningSlot: ControllerSlot | null;
@@ -60,6 +60,23 @@ export interface EffectEnumSpec<T extends string = string> {
60
60
  default: T;
61
61
  values: readonly T[];
62
62
  }
63
+ /** Levels' color channels, run before the master levels; each suffixes its own copy of the params (`inputBlackR`). */
64
+ export declare const LEVEL_CHANNELS: readonly ["R", "G", "B"];
65
+ /** Color Shift's hue bands, around the wheel from red; each suffixes its own copy of the params (`hueRed`). */
66
+ export declare const COLOR_SHIFT_BANDS: readonly ["Red", "Yellow", "Green", "Cyan", "Blue", "Magenta"];
67
+ /** Select Colors' color slots, `color1` up. */
68
+ export declare const SELECT_COLORS_SLOTS: readonly [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
69
+ /** A Select Colors slot, one of {@link SELECT_COLORS_SLOTS}. */
70
+ export type SelectColorsSlot = (typeof SELECT_COLORS_SLOTS)[number];
71
+ /** The color slots a layer's bloom can limit its glow to, `color1` up. */
72
+ export declare const BLOOM_COLOR_SLOTS: readonly [1, 2, 3, 4, 5];
73
+ /** A layer bloom color slot, one of {@link BLOOM_COLOR_SLOTS}. */
74
+ export type BloomColorSlot = (typeof BLOOM_COLOR_SLOTS)[number];
75
+ /**
76
+ * `table` once per suffix, suffix by suffix, each key suffixed (`{ hue }` over `['Red', 'Blue']` is
77
+ * `{ hueRed, hueBlue }`): how a per-channel parameter set spells its specs, labels and descriptions.
78
+ */
79
+ export declare function suffixKeys<K extends string, V, S extends string>(table: Readonly<Record<K, V>>, suffixes: readonly S[]): Record<`${K}${S}`, V>;
63
80
  export declare const EFFECT_SPECS: Record<EffectKey, Readonly<Record<string, EffectParamSpec>>>;
64
81
  /**
65
82
  * Hex-color params, keyed like {@link EFFECT_SPECS} — generic walkers (healing,
@@ -71,6 +88,15 @@ export declare const EFFECT_COLOR_SPECS: Partial<Record<EffectKey, Readonly<Reco
71
88
  export declare const EFFECT_BOOLEAN_SPECS: Partial<Record<EffectKey, Readonly<Record<string, EffectBooleanSpec>>>>;
72
89
  /** Blend-mode order shared by effect editors and shader uniforms. */
73
90
  export declare const EFFECT_BLEND_MODES: readonly ["normal", "darken", "multiply", "colorBurn", "linearBurn", "add", "lighten", "screen", "colorDodge", "overlay", "softLight", "hardLight", "vividLight", "linearLight", "pinLight", "hardMix", "difference", "exclusion", "subtract", "divide", "hue", "saturation", "color", "luminosity"];
91
+ export declare const FLARE_MODES: readonly ["add", "screen", "colorDodge"];
92
+ export declare const FLARE_RAYS_MODES: readonly ["sun", "shine", "god"];
93
+ export declare const GRADIENT_TYPES: readonly ["solid", "linear", "circle", "ellipse"];
94
+ export declare const BLUR_MODES: readonly ["gaussian", "bokeh"];
95
+ export declare const BLOOM_MODES: readonly ["normal", "streak", "star"];
96
+ export declare const RIM_MODES: readonly ["single", "double", "sharpenSingle", "sharpenDouble"];
97
+ export declare const COLOR_WHEELS_MODES: readonly ["liftGammaGain", "shadowsMidtonesHighlights"];
98
+ /** Effects quality tiers, lowest first. */
99
+ export declare const EFFECTS_QUALITY_LEVELS: readonly ["low", "medium", "high"];
74
100
  /** Enum defaults and allowed values. */
75
101
  export declare const EFFECT_ENUM_SPECS: Partial<Record<EffectKey, Readonly<Record<string, EffectEnumSpec>>>>;
76
102
  /** Select the layer effects from any value set containing them. */
@@ -1,5 +1,5 @@
1
1
  // Scope and parameter metadata are plain data; labels and rendering stay with their consumers.
2
- import { finiteOr, isOneOf, isRecord } from "./guards.js";
2
+ import { finiteOr, isOneOf, isRecord, keysOf } from "./guards.js";
3
3
  import { clamp, hexColorOr } from "./limits.js";
4
4
  /** Supported owners of each toggle effect; scene and layer membership are independent. */
5
5
  export const EFFECT_SCOPES = {
@@ -48,6 +48,57 @@ export const SCENE_EFFECT_KEYS = keysForScope('scene');
48
48
  /** Scene toggle keys; retained for existing SDK consumers. */
49
49
  export const TOGGLE_EFFECT_KEYS = SCENE_EFFECT_KEYS;
50
50
  export const LAYER_EFFECT_KEYS = keysForScope('layer');
51
+ /** Levels' color channels, run before the master levels; each suffixes its own copy of the params (`inputBlackR`). */
52
+ export const LEVEL_CHANNELS = ['R', 'G', 'B'];
53
+ /** Color Shift's hue bands, around the wheel from red; each suffixes its own copy of the params (`hueRed`). */
54
+ export const COLOR_SHIFT_BANDS = ['Red', 'Yellow', 'Green', 'Cyan', 'Blue', 'Magenta'];
55
+ /** Select Colors' color slots, `color1` up. */
56
+ export const SELECT_COLORS_SLOTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
57
+ /** The color slots a layer's bloom can limit its glow to, `color1` up. */
58
+ export const BLOOM_COLOR_SLOTS = [1, 2, 3, 4, 5];
59
+ /** One channel's levels, shared by the master set and each of {@link LEVEL_CHANNELS}. */
60
+ const LEVEL_SPECS = {
61
+ inputBlack: { default: 0, min: 0, max: 1, step: 0.01 },
62
+ inputWhite: { default: 1, min: 0, max: 1, step: 0.01 },
63
+ inputGamma: { default: 1, min: 0.1, max: 4, step: 0.01 },
64
+ outputBlack: { default: 0, min: 0, max: 1, step: 0.01 },
65
+ outputWhite: { default: 1, min: 0, max: 1, step: 0.01 },
66
+ };
67
+ /** One hue band's offsets, shared by each of {@link COLOR_SHIFT_BANDS}. */
68
+ const COLOR_SHIFT_SPECS = {
69
+ hue: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
70
+ saturation: { default: 0, min: -1, max: 1, step: 0.01 },
71
+ luminance: { default: 0, min: -1, max: 1, step: 0.01 },
72
+ luminanceSaturation: { default: 0, min: -1, max: 1, step: 0.01 },
73
+ };
74
+ const SELECT_COLORS_DEFAULTS = {
75
+ 1: '#ff0000',
76
+ 2: '#ffff00',
77
+ 3: '#00ff00',
78
+ 4: '#00ffff',
79
+ 5: '#0000ff',
80
+ 6: '#ff00ff',
81
+ 7: '#ffffff',
82
+ 8: '#808080',
83
+ 9: '#000000',
84
+ 10: '#ff8800',
85
+ };
86
+ /**
87
+ * `table` once per suffix, suffix by suffix, each key suffixed (`{ hue }` over `['Red', 'Blue']` is
88
+ * `{ hueRed, hueBlue }`): how a per-channel parameter set spells its specs, labels and descriptions.
89
+ */
90
+ export function suffixKeys(table, suffixes) {
91
+ // Object.fromEntries widens its keys to string; the entries are exactly the keys named.
92
+ return Object.fromEntries(suffixes.flatMap(suffix => keysOf(table).map(key => [`${key}${suffix}`, table[key]])));
93
+ }
94
+ /** `spec(slot)` for each color slot, keyed `color${slot}${suffix}` in slot order. */
95
+ function colorSlotSpecs(slots, suffix, spec) {
96
+ // Object.fromEntries widens its keys to string; the entries are exactly the keys named.
97
+ return Object.fromEntries(slots.map(slot => [`color${slot}${suffix}`, spec(slot)]));
98
+ }
99
+ // The first slot starts on: with every pick off the mask is zero, so switching selection on
100
+ // would select nothing rather than narrow.
101
+ const slotEnabled = (slot) => ({ default: slot === 1 });
51
102
  // Declared string-indexed so generic call sites (slider loop, healing walk) iterate
52
103
  // without erasure casts; `satisfies` still checks exact per-effect param parity here.
53
104
  export const EFFECT_SPECS = {
@@ -60,28 +111,7 @@ export const EFFECT_SPECS = {
60
111
  temperature: { default: 0, min: -100, max: 100, step: 1 },
61
112
  tint: { default: 0, min: -100, max: 100, step: 1 },
62
113
  },
63
- levels: {
64
- inputBlack: { default: 0, min: 0, max: 1, step: 0.01 },
65
- inputWhite: { default: 1, min: 0, max: 1, step: 0.01 },
66
- inputGamma: { default: 1, min: 0.1, max: 4, step: 0.01 },
67
- outputBlack: { default: 0, min: 0, max: 1, step: 0.01 },
68
- outputWhite: { default: 1, min: 0, max: 1, step: 0.01 },
69
- inputBlackR: { default: 0, min: 0, max: 1, step: 0.01 },
70
- inputWhiteR: { default: 1, min: 0, max: 1, step: 0.01 },
71
- inputGammaR: { default: 1, min: 0.1, max: 4, step: 0.01 },
72
- outputBlackR: { default: 0, min: 0, max: 1, step: 0.01 },
73
- outputWhiteR: { default: 1, min: 0, max: 1, step: 0.01 },
74
- inputBlackG: { default: 0, min: 0, max: 1, step: 0.01 },
75
- inputWhiteG: { default: 1, min: 0, max: 1, step: 0.01 },
76
- inputGammaG: { default: 1, min: 0.1, max: 4, step: 0.01 },
77
- outputBlackG: { default: 0, min: 0, max: 1, step: 0.01 },
78
- outputWhiteG: { default: 1, min: 0, max: 1, step: 0.01 },
79
- inputBlackB: { default: 0, min: 0, max: 1, step: 0.01 },
80
- inputWhiteB: { default: 1, min: 0, max: 1, step: 0.01 },
81
- inputGammaB: { default: 1, min: 0.1, max: 4, step: 0.01 },
82
- outputBlackB: { default: 0, min: 0, max: 1, step: 0.01 },
83
- outputWhiteB: { default: 1, min: 0, max: 1, step: 0.01 },
84
- },
114
+ levels: suffixKeys(LEVEL_SPECS, ['', ...LEVEL_CHANNELS]),
85
115
  colorWheels: {
86
116
  lift: { default: 0, min: -1, max: 1, step: 0.01 },
87
117
  gamma: { default: 0, min: -1, max: 1, step: 0.01 },
@@ -92,32 +122,7 @@ export const EFFECT_SPECS = {
92
122
  shadowLimit: { default: 0.3, min: 0, max: 1, step: 0.01 },
93
123
  highlightLimit: { default: 0.7, min: 0, max: 1, step: 0.01 },
94
124
  },
95
- colorShift: {
96
- hueRed: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
97
- saturationRed: { default: 0, min: -1, max: 1, step: 0.01 },
98
- luminanceRed: { default: 0, min: -1, max: 1, step: 0.01 },
99
- luminanceSaturationRed: { default: 0, min: -1, max: 1, step: 0.01 },
100
- hueYellow: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
101
- saturationYellow: { default: 0, min: -1, max: 1, step: 0.01 },
102
- luminanceYellow: { default: 0, min: -1, max: 1, step: 0.01 },
103
- luminanceSaturationYellow: { default: 0, min: -1, max: 1, step: 0.01 },
104
- hueGreen: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
105
- saturationGreen: { default: 0, min: -1, max: 1, step: 0.01 },
106
- luminanceGreen: { default: 0, min: -1, max: 1, step: 0.01 },
107
- luminanceSaturationGreen: { default: 0, min: -1, max: 1, step: 0.01 },
108
- hueCyan: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
109
- saturationCyan: { default: 0, min: -1, max: 1, step: 0.01 },
110
- luminanceCyan: { default: 0, min: -1, max: 1, step: 0.01 },
111
- luminanceSaturationCyan: { default: 0, min: -1, max: 1, step: 0.01 },
112
- hueBlue: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
113
- saturationBlue: { default: 0, min: -1, max: 1, step: 0.01 },
114
- luminanceBlue: { default: 0, min: -1, max: 1, step: 0.01 },
115
- luminanceSaturationBlue: { default: 0, min: -1, max: 1, step: 0.01 },
116
- hueMagenta: { default: 0, min: -180, max: 180, step: 1, unit: '°' },
117
- saturationMagenta: { default: 0, min: -1, max: 1, step: 0.01 },
118
- luminanceMagenta: { default: 0, min: -1, max: 1, step: 0.01 },
119
- luminanceSaturationMagenta: { default: 0, min: -1, max: 1, step: 0.01 },
120
- },
125
+ colorShift: suffixKeys(COLOR_SHIFT_SPECS, COLOR_SHIFT_BANDS),
121
126
  selectColors: {
122
127
  hueRange: { default: 0.08, min: 0, max: 0.5, step: 0.01 },
123
128
  saturationRange: { default: 1, min: 0, max: 1, step: 0.01 },
@@ -202,6 +207,7 @@ export const EFFECT_SPECS = {
202
207
  },
203
208
  outline: {
204
209
  size: { default: 0, min: 0, max: 1, step: 0.01 },
210
+ roundness: { default: 0, min: 0, max: 1, step: 0.01 },
205
211
  opacity: { default: 1, min: 0, max: 1, step: 0.01 },
206
212
  },
207
213
  // VTube Studio's backlight shadow: an offset silhouette copy. VTS hides it at
@@ -288,25 +294,10 @@ export const EFFECT_COLOR_SPECS = {
288
294
  midtonesColor: { default: '#ffffff' },
289
295
  highlightsColor: { default: '#ffffff' },
290
296
  },
291
- selectColors: {
292
- color1: { default: '#ff0000' },
293
- color2: { default: '#ffff00' },
294
- color3: { default: '#00ff00' },
295
- color4: { default: '#00ffff' },
296
- color5: { default: '#0000ff' },
297
- color6: { default: '#ff00ff' },
298
- color7: { default: '#ffffff' },
299
- color8: { default: '#808080' },
300
- color9: { default: '#000000' },
301
- color10: { default: '#ff8800' },
302
- },
297
+ selectColors: colorSlotSpecs(SELECT_COLORS_SLOTS, '', slot => ({ default: SELECT_COLORS_DEFAULTS[slot] })),
303
298
  bloom: {
304
299
  tint: { default: '#ffffff' },
305
- color1: { default: '#ffffff' },
306
- color2: { default: '#ffffff' },
307
- color3: { default: '#ffffff' },
308
- color4: { default: '#ffffff' },
309
- color5: { default: '#ffffff' },
300
+ ...colorSlotSpecs(BLOOM_COLOR_SLOTS, '', () => ({ default: '#ffffff' })),
310
301
  },
311
302
  rim: {
312
303
  color: { default: '#ffeb74' },
@@ -334,26 +325,11 @@ export const EFFECT_BOOLEAN_SPECS = {
334
325
  bloom: {
335
326
  selectColors: { default: false },
336
327
  invertColors: { default: false },
337
- // Like Select Colors' first slot: with every pick off the mask is zero, so switching
338
- // selection on would make the glow vanish rather than narrow.
339
- color1Enabled: { default: true },
340
- color2Enabled: { default: false },
341
- color3Enabled: { default: false },
342
- color4Enabled: { default: false },
343
- color5Enabled: { default: false },
328
+ ...colorSlotSpecs(BLOOM_COLOR_SLOTS, 'Enabled', slotEnabled),
344
329
  },
345
330
  selectColors: {
346
331
  invert: { default: false },
347
- color1Enabled: { default: true },
348
- color2Enabled: { default: false },
349
- color3Enabled: { default: false },
350
- color4Enabled: { default: false },
351
- color5Enabled: { default: false },
352
- color6Enabled: { default: false },
353
- color7Enabled: { default: false },
354
- color8Enabled: { default: false },
355
- color9Enabled: { default: false },
356
- color10Enabled: { default: false },
332
+ ...colorSlotSpecs(SELECT_COLORS_SLOTS, 'Enabled', slotEnabled),
357
333
  },
358
334
  };
359
335
  /** Blend-mode order shared by effect editors and shader uniforms. */
@@ -383,21 +359,31 @@ export const EFFECT_BLEND_MODES = [
383
359
  'color',
384
360
  'luminosity',
385
361
  ];
362
+ // The wire types derive from these, so every member a type admits also heals and publishes.
363
+ export const FLARE_MODES = ['add', 'screen', 'colorDodge'];
364
+ export const FLARE_RAYS_MODES = ['sun', 'shine', 'god'];
365
+ export const GRADIENT_TYPES = ['solid', 'linear', 'circle', 'ellipse'];
366
+ export const BLUR_MODES = ['gaussian', 'bokeh'];
367
+ export const BLOOM_MODES = ['normal', 'streak', 'star'];
368
+ export const RIM_MODES = ['single', 'double', 'sharpenSingle', 'sharpenDouble'];
369
+ export const COLOR_WHEELS_MODES = ['liftGammaGain', 'shadowsMidtonesHighlights'];
370
+ /** Effects quality tiers, lowest first. */
371
+ export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
386
372
  /** Enum defaults and allowed values. */
387
373
  export const EFFECT_ENUM_SPECS = {
388
374
  flare: {
389
- mode: { default: 'add', values: ['add', 'screen', 'colorDodge'] },
390
- raysMode: { default: 'sun', values: ['sun', 'shine', 'god'] },
375
+ mode: { default: 'add', values: FLARE_MODES },
376
+ raysMode: { default: 'sun', values: FLARE_RAYS_MODES },
391
377
  },
392
378
  gradient: {
393
- type: { default: 'circle', values: ['solid', 'linear', 'circle', 'ellipse'] },
379
+ type: { default: 'circle', values: GRADIENT_TYPES },
394
380
  blendMode: { default: 'normal', values: EFFECT_BLEND_MODES },
395
381
  },
396
- blur: { mode: { default: 'gaussian', values: ['gaussian', 'bokeh'] } },
397
- bloom: { mode: { default: 'normal', values: ['normal', 'streak', 'star'] } },
398
- rim: { mode: { default: 'single', values: ['single', 'double', 'sharpenSingle', 'sharpenDouble'] } },
399
- outline: { quality: { default: 'medium', values: ['low', 'medium', 'high'] } },
400
- colorWheels: { mode: { default: 'liftGammaGain', values: ['liftGammaGain', 'shadowsMidtonesHighlights'] } },
382
+ blur: { mode: { default: 'gaussian', values: BLUR_MODES } },
383
+ bloom: { mode: { default: 'normal', values: BLOOM_MODES } },
384
+ rim: { mode: { default: 'single', values: RIM_MODES } },
385
+ outline: { quality: { default: 'medium', values: EFFECTS_QUALITY_LEVELS } },
386
+ colorWheels: { mode: { default: 'liftGammaGain', values: COLOR_WHEELS_MODES } },
401
387
  };
402
388
  /** Select the layer effects from any value set containing them. */
403
389
  export function pickLayerEffects(effects) {
@@ -1,6 +1,6 @@
1
- import type { Hotkey, MotionGroup } from '../wire/types.ts';
1
+ import type { Hotkey, ModelRef, MotionGroup } from '../wire/types.ts';
2
2
  /** A hotkey action Persona can run. */
3
- type HotkeyAction = NonNullable<Hotkey['action']>;
3
+ export type HotkeyAction = NonNullable<Hotkey['action']>;
4
4
  /** What a model offers hotkeys: its expressions (a null `file` on formats with none) and its motion groups. */
5
5
  export interface HotkeyListing {
6
6
  expressions: readonly {
@@ -53,4 +53,11 @@ export declare function modelMatchesFile(file: string, model: {
53
53
  vtubeFile?: string | null;
54
54
  entryFile?: string;
55
55
  }): boolean;
56
- export {};
56
+ /**
57
+ * Labels a model's hotkeys by what each points at — an expression's Name, a motion's label, a
58
+ * load-model's model name — or undefined for an action with no file, or once the target is gone.
59
+ * Walks the listing once, so a shortcut list builds one per model and asks it per row.
60
+ * @param models `model.list`'s refs; scene item refs carry no `vtubeFile` (see {@link modelMatchesFile}).
61
+ * @param groupLabel Display name for a motion group; VRM groups are clip refs, not names.
62
+ */
63
+ export declare function hotkeyTargetLabels(listing: HotkeyListing, models: readonly Pick<ModelRef, 'name' | 'vtubeFile' | 'entryFile'>[], groupLabel?: (group: string) => string): (h: Pick<Hotkey, 'action' | 'file'>) => string | undefined;