@laplace.live/persona-sdk 0.18.1 → 1.1.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 (48) hide show
  1. package/README.md +4 -2
  2. package/dist/client/client.d.ts +1 -1
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.js +9 -1
  5. package/dist/values/bindings.d.ts +43 -0
  6. package/dist/values/bindings.js +1 -0
  7. package/dist/values/controller.d.ts +109 -0
  8. package/dist/values/controller.js +167 -0
  9. package/dist/values/curve.d.ts +5 -8
  10. package/dist/values/curve.js +16 -7
  11. package/dist/values/edit-history.d.ts +21 -0
  12. package/dist/values/edit-history.js +62 -0
  13. package/dist/values/effect-schema.d.ts +72 -12
  14. package/dist/values/effect-schema.js +279 -67
  15. package/dist/values/gltf-extensions.d.ts +20 -0
  16. package/dist/values/gltf-extensions.js +68 -0
  17. package/dist/values/guards.d.ts +2 -0
  18. package/dist/values/guards.js +5 -0
  19. package/dist/values/hands.d.ts +53 -0
  20. package/dist/values/hands.js +89 -0
  21. package/dist/values/hotkeys.d.ts +6 -0
  22. package/dist/values/hotkeys.js +11 -0
  23. package/dist/values/labels.d.ts +2 -0
  24. package/dist/values/labels.js +5 -1
  25. package/dist/values/limits.d.ts +19 -0
  26. package/dist/values/limits.js +27 -0
  27. package/dist/values/lipsync.d.ts +106 -0
  28. package/dist/values/lipsync.js +120 -0
  29. package/dist/values/locale.d.ts +2 -0
  30. package/dist/values/model-info.d.ts +66 -0
  31. package/dist/values/model-info.js +1 -0
  32. package/dist/values/stage-info.d.ts +27 -0
  33. package/dist/values/stage-info.js +1 -0
  34. package/dist/values/vrm-bindings.d.ts +14 -0
  35. package/dist/values/vrm-bindings.js +20 -0
  36. package/dist/wire/events.d.ts +1 -1
  37. package/dist/wire/methods.d.ts +210 -5
  38. package/dist/wire/protocol.d.ts +1 -1
  39. package/dist/wire/protocol.js +1 -1
  40. package/dist/wire/schemas.d.ts +116 -2
  41. package/dist/wire/schemas.js +40 -0
  42. package/dist/wire/types.d.ts +294 -131
  43. package/dist/wire/types.js +90 -12
  44. package/package.json +3 -17
  45. package/dist/effects.d.ts +0 -79
  46. package/dist/effects.js +0 -6
  47. package/dist/values/custom-effect.d.ts +0 -44
  48. package/dist/values/custom-effect.js +0 -136
@@ -2,6 +2,9 @@
2
2
  // carry. Structural mirrors of the app's scene/settings models, minus anything
3
3
  // filesystem-shaped — model refs are sanitized to ids, never directories.
4
4
  import { ARKIT_INPUT_NAMES } from "../values/arkit.js";
5
+ import { BASE_CONTROLLER_INPUT_NAMES, BASE_CONTROLLER_INPUT_RANGES, controllerInputRange, isControllerInputName, } from "../values/controller.js";
6
+ import { HAND_INPUT_NAMES, HAND_INPUT_RANGES } from "../values/hands.js";
7
+ import { VOICE_INPUT_NAMES, VOICE_INPUT_RANGES, } from "../values/lipsync.js";
5
8
  /**
6
9
  * How a registered file is labelled. Wider than an object's content kinds: `.hdr` is only
7
10
  * ever an environment map, and a `.vmd` splits by content — `cameraMotion` for one that
@@ -23,7 +26,15 @@ export const SHADOW_QUALITY_LEVELS = ['off', 'low', 'medium', 'high', 'ultra'];
23
26
  * App-level features a client gates on (never version-sniff): `hello` and
24
27
  * `app.info` report them — the per-app mirror of {@link InstanceRuntime.capabilities}.
25
28
  */
26
- export const APP_CAPABILITIES = ['storage', 'speech', 'shortcuts'];
29
+ export const APP_CAPABILITIES = [
30
+ 'storage',
31
+ 'speech',
32
+ 'shortcuts',
33
+ 'controllers',
34
+ 'model-editing',
35
+ 'asset-inspection',
36
+ 'layer-effects',
37
+ ];
27
38
  export function isAppCapability(v) {
28
39
  return typeof v === 'string' && APP_CAPABILITIES.includes(v);
29
40
  }
@@ -38,15 +49,69 @@ export const SHORTCUT_ACTION_KINDS = [
38
49
  'stream-mode',
39
50
  ];
40
51
  // ---- Settings ------------------------------------------------------------------
41
- export const TRACKING_SOURCE_IDS = ['vts-ios', 'ifacialmocap'];
52
+ export const TRACKING_SOURCE_IDS = ['persona-ios', 'ifacialmocap', 'vts-ios'];
42
53
  /** Body-pose protocols. Every one so far is UDP with a configurable port. */
43
- export const POSE_SOURCE_IDS = ['vmc'];
44
- /** Every protocol a tracking source instance can speak; face kinds plus the body (vmc) kind. */
45
- export const TRACKING_SOURCE_KINDS = [...TRACKING_SOURCE_IDS, ...POSE_SOURCE_IDS];
46
- /** Whether a source kind feeds the face channel (vmc is the body channel). */
54
+ export const POSE_SOURCE_IDS = ['vmc', 'mocopi'];
55
+ /** Every protocol a tracking source instance can speak; face and body kinds. */
56
+ export const TRACKING_SOURCE_KINDS = [...TRACKING_SOURCE_IDS, ...POSE_SOURCE_IDS, 'mediapipe'];
57
+ export const TRACKING_CHANNELS = ['face', 'pose', 'hands'];
58
+ /** The scene-item binding each channel reads and writes. */
59
+ export const TRACKING_CHANNEL_FIELDS = {
60
+ face: 'faceSourceId',
61
+ pose: 'poseSourceId',
62
+ hands: 'handSourceId',
63
+ };
64
+ export const HAND_TRACKING_MODES = ['arms', 'fingers'];
65
+ export function isHandTrackingMode(v) {
66
+ return HAND_TRACKING_MODES.includes(v);
67
+ }
68
+ export const DEFAULT_MEDIAPIPE_CONFIG = {
69
+ deviceId: '',
70
+ mirror: true,
71
+ face: true,
72
+ hands: true,
73
+ body: false,
74
+ delegate: 'CPU',
75
+ };
76
+ /** Whether a source kind uses a network face receiver. */
47
77
  export function isFaceSourceKind(kind) {
48
78
  return TRACKING_SOURCE_IDS.includes(kind);
49
79
  }
80
+ /** Whether a source uses a network body receiver. */
81
+ export function isPoseSource(source) {
82
+ return POSE_SOURCE_IDS.includes(source.kind);
83
+ }
84
+ const FACE_ONLY = ['face'];
85
+ const POSE_ONLY = ['pose'];
86
+ /** The channels a kind can supply; the webcam's are further gated by its task switches. */
87
+ export function sourceKindChannels(kind) {
88
+ return kind === 'mediapipe' ? TRACKING_CHANNELS : isFaceSourceKind(kind) ? FACE_ONLY : POSE_ONLY;
89
+ }
90
+ /** Whether this source can supply a channel; {@link sourceChannelEnabled} folds in the switches. */
91
+ export function sourceSupportsChannel(source, channel) {
92
+ if (!sourceKindChannels(source.kind).includes(channel))
93
+ return false;
94
+ if (source.kind !== 'mediapipe')
95
+ return true;
96
+ const config = source.mediapipe ?? DEFAULT_MEDIAPIPE_CONFIG;
97
+ return channel === 'pose' ? config.body : config[channel];
98
+ }
99
+ /**
100
+ * Whether a source feeds a channel right now: its own switch, its task, and — for a network
101
+ * face or body source — the channel master. Webcam tasks and hands answer to the source switch alone.
102
+ */
103
+ export function sourceChannelEnabled(source, channel, masters) {
104
+ if (!source.enabled || !sourceSupportsChannel(source, channel))
105
+ return false;
106
+ return channel === 'hands' || source.kind === 'mediapipe' || masters[channel];
107
+ }
108
+ /**
109
+ * Whether another source already holds `port` and would contend for it. iFacialMocap instances
110
+ * share one socket per port (frames demux by phone); every other pairing EADDRINUSEs a listener.
111
+ */
112
+ export function trackingPortTaken(sources, kind, port, excludeId) {
113
+ return sources.some(s => s.id !== excludeId && s.port === port && !(s.kind === 'ifacialmocap' && kind === 'ifacialmocap'));
114
+ }
50
115
  export const EFFECTS_QUALITY_LEVELS = ['low', 'medium', 'high'];
51
116
  /**
52
117
  * Accepted values for `performance.fpsLimit`; 0 = unlimited. Anything else is snapped
@@ -79,14 +144,20 @@ const VTS_INPUT_NAMES = [
79
144
  'TongueOut',
80
145
  ];
81
146
  /**
82
- * The valid `id`s for `input` inject targets, and the names a model's `.vtube.json`
83
- * references: VTS's derived vocabulary plus every raw ARKit channel.
147
+ * Default input vocabulary: face and hand inputs, raw ARKit channels and controller profile 1.
148
+ * Additional controller profile ids are accepted by isInputName without appearing in this list.
84
149
  */
85
- export const INPUT_NAMES = [...VTS_INPUT_NAMES, ...ARKIT_INPUT_NAMES];
150
+ export const INPUT_NAMES = [
151
+ ...VTS_INPUT_NAMES,
152
+ ...VOICE_INPUT_NAMES,
153
+ ...HAND_INPUT_NAMES,
154
+ ...ARKIT_INPUT_NAMES,
155
+ ...BASE_CONTROLLER_INPUT_NAMES,
156
+ ];
86
157
  const INPUT_NAME_SET = new Set(INPUT_NAMES);
87
158
  /** Whether an untrusted string names a tracking input — the guard every wire boundary needs. */
88
159
  export function isInputName(v) {
89
- return INPUT_NAME_SET.has(v);
160
+ return INPUT_NAME_SET.has(v) || isControllerInputName(v);
90
161
  }
91
162
  /**
92
163
  * VTS-vocabulary inputs that are the same float as one raw ARKit channel (the desktop derives
@@ -145,12 +216,19 @@ const VTS_INPUT_RANGES = {
145
216
  /** ARKit blendshapes are unit-scale by definition, so every raw channel shares one span. */
146
217
  const ARKIT_RANGE = [0, 1];
147
218
  /**
148
- * Each input's natural span — the units a sender should write, and the input range a new
149
- * binding starts from. Head angles are **degrees**; the rest are unitless.
219
+ * Default inputs' natural spans. Use getInputRange for a dynamically numbered controller input.
220
+ * Head angles are degrees; the rest are unitless.
150
221
  */
151
222
  export const INPUT_RANGES = {
152
223
  ...VTS_INPUT_RANGES,
224
+ ...VOICE_INPUT_RANGES,
225
+ ...HAND_INPUT_RANGES,
226
+ ...BASE_CONTROLLER_INPUT_RANGES,
153
227
  // `fromEntries` widens the key type back to `string`; the annotation above is what keeps
154
228
  // this exhaustive, and it fails to typecheck if a name ever lacks a range.
155
229
  ...Object.fromEntries(ARKIT_INPUT_NAMES.map(n => [n, ARKIT_RANGE])),
156
230
  };
231
+ /** Natural span of any valid input, including dynamically numbered controller profiles. */
232
+ export function getInputRange(name) {
233
+ return isControllerInputName(name) ? controllerInputRange(name) : INPUT_RANGES[name];
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/persona-sdk",
3
- "version": "0.18.1",
3
+ "version": "1.1.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,10 +16,6 @@
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
- },
20
- "./effects": {
21
- "types": "./dist/effects.d.ts",
22
- "import": "./dist/effects.js"
23
19
  }
24
20
  },
25
21
  "files": [
@@ -30,22 +26,12 @@
30
26
  "provenance": true
31
27
  },
32
28
  "devDependencies": {
33
- "@types/three": "^0.185.4",
34
29
  "rimraf": "^6.1.3",
35
- "three": "0.185.1",
36
30
  "typescript": "~6.0.3",
37
- "vitest": "^4.1.11"
38
- },
39
- "peerDependencies": {
40
- "@types/three": "^0.185.0"
41
- },
42
- "peerDependenciesMeta": {
43
- "@types/three": {
44
- "optional": true
45
- }
31
+ "vitest": "^5.0.0"
46
32
  },
47
33
  "engines": {
48
- "node": ">=22"
34
+ "node": ">=24"
49
35
  },
50
36
  "dependencies": {
51
37
  "zod": "^4.5.4"
package/dist/effects.d.ts DELETED
@@ -1,79 +0,0 @@
1
- import type * as TSL from 'three/tsl';
2
- import type { Color, Node, Vector2, Vector3, Vector4 } from 'three/webgpu';
3
- export * from './values/custom-effect.ts';
4
- /** A vec4-typed TSL node with its swizzles — what `sample` returns and `build` composes over. */
5
- export type CustomEffectVec4 = ReturnType<typeof TSL.nodeObject<Node<'vec4'>>>;
6
- /** A float-typed TSL node with its operators — what `viewZ` returns. */
7
- export type CustomEffectFloat = ReturnType<typeof TSL.nodeObject<Node<'float'>>>;
8
- /** A vec3-typed TSL node with its swizzles — what the camera probes return. */
9
- export type CustomEffectVec3 = ReturnType<typeof TSL.nodeObject<Node<'vec3'>>>;
10
- /** What an author's module receives. Not a security boundary — just the useful surface. */
11
- export interface CustomEffectApi {
12
- /** The whole `three/tsl` namespace: `vec4`, `uv`, `uniform`, `mix`, `Fn`, … */
13
- tsl: typeof TSL;
14
- three: {
15
- Color: typeof Color;
16
- Vector2: typeof Vector2;
17
- Vector3: typeof Vector3;
18
- Vector4: typeof Vector4;
19
- };
20
- /**
21
- * Stock three display nodes an effect cannot build for itself. `afterImage`
22
- * is the seam for anything temporal: correct frame feedback needs two render
23
- * targets ping-ponged plus a renderer reference, and `build()` hands authors
24
- * neither. Upstream types return the bare class, hiding the TSL swizzles the
25
- * runtime proxy carries; this signature types the echo as what authors use it
26
- * as — a sampleable vec4 image.
27
- */
28
- nodes: {
29
- afterImage: (node: Node, damp?: Node | number) => CustomEffectVec4;
30
- };
31
- }
32
- /** Build-time context for one custom effect. */
33
- export interface CustomEffectBuildContext {
34
- /** Sample the incoming frame at any UV — the seam that makes warps and blurs possible. */
35
- sample: (at: unknown) => CustomEffectVec4;
36
- /** Screen UV of the pixel being shaded. */
37
- uv: typeof TSL.uv;
38
- /**
39
- * View-space Z of the 3D scene at this pixel — three's convention: 0 at the
40
- * camera, more negative with distance, the far plane where nothing was drawn.
41
- * The Live2D layer and 2D objects contribute no depth. Costs one depth read,
42
- * and only if called.
43
- */
44
- viewZ: () => CustomEffectFloat;
45
- /**
46
- * Normalized world-space direction of the scene camera's view ray through
47
- * this pixel, fov and aspect folded in. Inside the post pass TSL's own
48
- * `cameraPosition`/`cameraWorldMatrix` describe the fullscreen quad's
49
- * camera — this is the real one. Anchor a field here instead of `uv()` and
50
- * it holds still in the world while the camera orbits.
51
- */
52
- worldRay: () => CustomEffectVec3;
53
- /** The scene camera's world position in metres, as a per-frame uniform. */
54
- cameraPosition: () => CustomEffectVec3;
55
- /** Hand a build-time disposable (a blur render target) to the chain's transient list. */
56
- track: (t: {
57
- dispose?: () => void;
58
- }) => void;
59
- }
60
- /** What an author's module returns. Everything but `build` is optional. */
61
- export interface CustomEffectModule {
62
- /** Uniform nodes keyed by manifest param name; param edits write straight into `.value`. */
63
- uniforms?: Record<string, {
64
- value: unknown;
65
- }>;
66
- /** Compose the effect over `input` — the frame so far, already sampleable — and return the result node. */
67
- build: (input: Node, ctx: CustomEffectBuildContext) => Node;
68
- /** Per-frame CPU state. `dt` and `elapsed` are seconds. */
69
- update?: (dt: number, elapsed: number) => void;
70
- dispose?: () => void;
71
- }
72
- /** The module's default export. Runs once per load — mint uniforms here so they survive rebuilds. */
73
- export type CustomEffectFactory = (api: CustomEffectApi) => CustomEffectModule;
74
- /** Why an effect is not rendering, for the panel's error row. */
75
- export interface CustomEffectFault {
76
- slug: string;
77
- phase: 'load' | 'build' | 'update';
78
- message: string;
79
- }
package/dist/effects.js DELETED
@@ -1,6 +0,0 @@
1
- // The custom-effect authoring contract: what an effect module receives, what it
2
- // returns, and (re-exported) the manifest shape naming its params. Everything
3
- // touching three is type-only — the desktop hands the runtime to the module's
4
- // factory, because a module loaded from `persona://` can import nothing.
5
- // Typechecking against this entry needs `@types/three` (optional peer).
6
- export * from "./values/custom-effect.js";
@@ -1,44 +0,0 @@
1
- /** How wide a manifest may open a slider; keeps a typo'd range from making one unusable. */
2
- export declare const CUSTOM_EFFECT_PARAM_LIMIT = 1000000;
3
- /** Enabled custom effects one scene may compose — each costs a full-frame RTT. Disabled entries are uncapped. */
4
- export declare const CUSTOM_EFFECTS_MAX = 8;
5
- /** Params per effect. Past this the panel section stops being navigable. */
6
- export declare const CUSTOM_EFFECT_PARAM_MAX = 32;
7
- export type CustomEffectParamKind = 'number' | 'boolean' | 'color';
8
- /** One author-declared control. `kind` picks the panel widget and the healing rule. */
9
- export interface CustomEffectParam {
10
- kind: CustomEffectParamKind;
11
- /** Slider/field label. Author-supplied, so never translated. */
12
- label: string;
13
- /** Numbers: the clamp range and readout. Booleans and colors ignore these. */
14
- default: number | boolean | string;
15
- min?: number;
16
- max?: number;
17
- step?: number;
18
- digits?: number;
19
- unit?: string;
20
- }
21
- /** A validated `manifest.json`. */
22
- export interface CustomEffectManifest {
23
- name: string;
24
- /** Author-declared, shown in the panel's detail row. */
25
- version?: string;
26
- author?: string;
27
- description?: string;
28
- params: Record<string, CustomEffectParam>;
29
- }
30
- export declare function isCustomEffectSlug(v: unknown): v is string;
31
- /**
32
- * Validate a parsed `manifest.json`. Null when it carries no usable name —
33
- * everything else degrades (a bad param is dropped, not fatal), because a
34
- * half-typed manifest should still show the author what already works.
35
- */
36
- export declare function parseCustomEffectManifest(raw: unknown): CustomEffectManifest | null;
37
- /** Every declared param at its default — the params half of a fresh scene entry. */
38
- export declare function defaultCustomEffectParams(manifest: CustomEffectManifest): Record<string, number | boolean | string>;
39
- /**
40
- * Clamp saved params to the manifest that is installed now. Unknown keys are
41
- * kept: an author mid-edit who renames a param back should not find the value
42
- * gone, and a param costs nothing until the module reads it.
43
- */
44
- export declare function healCustomEffectParams(raw: unknown, manifest: CustomEffectManifest | null): Record<string, number | boolean | string>;
@@ -1,136 +0,0 @@
1
- // User-authored effects: the manifest contract and its validator.
2
- //
3
- // An effect folder holds `manifest.json` (plain data — this file's shape) and
4
- // `effect.js` (a TSL builder the stage worker evaluates). The split is what lets
5
- // main heal a scene's saved params without ever running author code: only the
6
- // worker imports the module, and it is the sole place third-party JS runs.
7
- //
8
- // Params mirror EffectParamSpec so the panel renders both kinds of effect with
9
- // the same sliders — but these arrive at runtime from disk, so everything here
10
- // validates rather than trusting the type.
11
- import { finiteOr, isFiniteNumber, isRecord, nonEmptyString } from "./guards.js";
12
- import { hexColorOr } from "./limits.js";
13
- /** How wide a manifest may open a slider; keeps a typo'd range from making one unusable. */
14
- export const CUSTOM_EFFECT_PARAM_LIMIT = 1e6;
15
- /** Enabled custom effects one scene may compose — each costs a full-frame RTT. Disabled entries are uncapped. */
16
- export const CUSTOM_EFFECTS_MAX = 8;
17
- /** Params per effect. Past this the panel section stops being navigable. */
18
- export const CUSTOM_EFFECT_PARAM_MAX = 32;
19
- /** Folder-name rule: lowercase, dash-separated. Also the on-disk path segment, so no dots or slashes. */
20
- const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
21
- export function isCustomEffectSlug(v) {
22
- return typeof v === 'string' && SLUG_RE.test(v);
23
- }
24
- function bounded(v) {
25
- return Math.min(CUSTOM_EFFECT_PARAM_LIMIT, Math.max(-CUSTOM_EFFECT_PARAM_LIMIT, v));
26
- }
27
- function parseParam(raw) {
28
- if (!isRecord(raw))
29
- return null;
30
- const label = nonEmptyString(raw.label);
31
- if (label === null)
32
- return null;
33
- if (raw.kind === 'boolean')
34
- return { kind: 'boolean', label, default: raw.default === true };
35
- if (raw.kind === 'color')
36
- return { kind: 'color', label, default: hexColorOr(raw.default, '#ffffff') };
37
- // Numbers are the default kind: an omitted or unknown `kind` is far more often
38
- // a slider the author forgot to tag than a control they meant to drop.
39
- // Both ends clamp into ±LIMIT before ordering, or a range wholly past the
40
- // limit would keep one end beyond it.
41
- const lo = bounded(finiteOr(raw.min, 0));
42
- const hi = bounded(finiteOr(raw.max, 1));
43
- // An inverted or empty range would leave a slider that cannot move; widen to the default's own value.
44
- const min = Math.min(lo, hi);
45
- const max = Math.max(lo, hi);
46
- const def = Math.min(max, Math.max(min, finiteOr(raw.default, min)));
47
- const step = Math.min(max - min || 1, Math.abs(finiteOr(raw.step, 0.01)) || 0.01);
48
- return {
49
- kind: 'number',
50
- label,
51
- default: def,
52
- min,
53
- max,
54
- step,
55
- ...(isFiniteNumber(raw.digits) && { digits: Math.min(6, Math.max(0, Math.trunc(raw.digits))) }),
56
- ...(typeof raw.unit === 'string' && raw.unit !== '' && { unit: raw.unit.slice(0, 8) }),
57
- };
58
- }
59
- /**
60
- * Validate a parsed `manifest.json`. Null when it carries no usable name —
61
- * everything else degrades (a bad param is dropped, not fatal), because a
62
- * half-typed manifest should still show the author what already works.
63
- */
64
- export function parseCustomEffectManifest(raw) {
65
- if (!isRecord(raw))
66
- return null;
67
- const name = nonEmptyString(raw.name);
68
- if (name === null)
69
- return null;
70
- const params = {};
71
- if (isRecord(raw.params)) {
72
- for (const [key, value] of Object.entries(raw.params)) {
73
- if (Object.keys(params).length >= CUSTOM_EFFECT_PARAM_MAX)
74
- break;
75
- // The key is a JS identifier on the uniforms object the module builds; a
76
- // key it cannot name would silently never receive its value.
77
- if (!/^[A-Za-z_$][\w$]*$/.test(key))
78
- continue;
79
- const param = parseParam(value);
80
- if (param)
81
- params[key] = param;
82
- }
83
- }
84
- return {
85
- name: name.slice(0, 64),
86
- params,
87
- ...(typeof raw.version === 'string' && raw.version !== '' && { version: raw.version.slice(0, 32) }),
88
- ...(typeof raw.author === 'string' && raw.author !== '' && { author: raw.author.slice(0, 64) }),
89
- ...(typeof raw.description === 'string' &&
90
- raw.description !== '' && { description: raw.description.slice(0, 280) }),
91
- };
92
- }
93
- /** Every declared param at its default — the params half of a fresh scene entry. */
94
- export function defaultCustomEffectParams(manifest) {
95
- const out = {};
96
- for (const [key, param] of Object.entries(manifest.params))
97
- out[key] = param.default;
98
- return out;
99
- }
100
- /** A primitive a saved param may hold; non-finite numbers are junk, not tuning. */
101
- function isParamValue(v) {
102
- return isFiniteNumber(v) || typeof v === 'boolean' || typeof v === 'string';
103
- }
104
- /**
105
- * Clamp saved params to the manifest that is installed now. Unknown keys are
106
- * kept: an author mid-edit who renames a param back should not find the value
107
- * gone, and a param costs nothing until the module reads it.
108
- */
109
- export function healCustomEffectParams(raw, manifest) {
110
- const src = isRecord(raw) ? raw : {};
111
- const out = {};
112
- for (const [key, value] of Object.entries(src)) {
113
- const spec = manifest?.params[key];
114
- if (!spec) {
115
- // No spec to clamp against — an unknown key, or no manifest at all (effect
116
- // not installed on this machine); keeping primitives is what lets a
117
- // reinstall restore the user's tuning.
118
- if (isParamValue(value))
119
- out[key] = value;
120
- continue;
121
- }
122
- if (spec.kind === 'boolean')
123
- out[key] = typeof value === 'boolean' ? value : spec.default === true;
124
- else if (spec.kind === 'color')
125
- out[key] = hexColorOr(value, String(spec.default));
126
- else {
127
- const lo = spec.min ?? 0;
128
- const hi = spec.max ?? 1;
129
- out[key] = Math.min(hi, Math.max(lo, finiteOr(value, Number(spec.default))));
130
- }
131
- }
132
- if (manifest)
133
- for (const [key, spec] of Object.entries(manifest.params))
134
- out[key] ??= spec.default;
135
- return out;
136
- }