@laplace.live/persona-sdk 0.9.0 → 0.10.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.
@@ -89,6 +89,9 @@ export class PersonaClient {
89
89
  this.settleConnect?.(new Error('client closed'));
90
90
  const ws = this.ws;
91
91
  this.ws = null;
92
+ // Synchronously, not in the attempt's `.finally` a microtask later: a connect()
93
+ // in this same tick must dial instead of joining the attempt just settled.
94
+ this.connectPromise = null;
92
95
  ws?.close();
93
96
  this.setState('closed');
94
97
  }
@@ -0,0 +1,79 @@
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
+ }
@@ -0,0 +1,6 @@
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";
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './client/address.ts';
2
2
  export * from './client/client.ts';
3
+ export * from './values/custom-effect.ts';
3
4
  export * from './values/effect-schema.ts';
4
5
  export * from './values/guards.ts';
5
6
  export * from './values/labels.ts';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
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 "./values/custom-effect.js";
5
6
  export * from "./values/effect-schema.js";
6
7
  export * from "./values/guards.js";
7
8
  export * from "./values/labels.js";
@@ -0,0 +1,44 @@
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>;
@@ -0,0 +1,136 @@
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
+ }
@@ -10,13 +10,23 @@ export interface EffectParamSpec {
10
10
  /** Appended to the readout (`s`, `px`, …). */
11
11
  unit?: string;
12
12
  }
13
- /** Keys of {@link SceneEffects} that follow the `{ enabled } + numeric params` pattern. */
13
+ /** Healing metadata for one hex-color effect parameter (validated, not clamped). */
14
+ export interface EffectColorSpec {
15
+ default: string;
16
+ }
17
+ /** Keys of {@link SceneEffects} that follow the `{ enabled } + params` pattern. */
14
18
  export type ToggleEffectKey = {
15
19
  [K in keyof SceneEffects]: SceneEffects[K] extends {
16
20
  enabled: boolean;
17
21
  } ? K : never;
18
22
  }[keyof SceneEffects];
19
23
  export declare const EFFECT_SPECS: Record<ToggleEffectKey, Readonly<Record<string, EffectParamSpec>>>;
24
+ /**
25
+ * Hex-color params, keyed like {@link EFFECT_SPECS} — generic walkers (healing,
26
+ * defaults, hotkey snapshots, the panel) visit both tables. Only effects with a
27
+ * string param appear.
28
+ */
29
+ export declare const EFFECT_COLOR_SPECS: Partial<Record<ToggleEffectKey, Readonly<Record<string, EffectColorSpec>>>>;
20
30
  export declare const TOGGLE_EFFECT_KEYS: readonly ToggleEffectKey[];
21
31
  /**
22
32
  * Registry effects that render as scene geometry inside the scene pass rather
@@ -2,7 +2,8 @@
2
2
  // Defaults, healing clamps, active checks, structural keys, and the panel's
3
3
  // sliders are all derived from this table, so adding an effect is:
4
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).
5
+ // 2. Spec it here (hex-color params go in EFFECT_COLOR_SPECS); label it in
6
+ // the panel's effect-labels.ts (parity-checked).
6
7
  // 3. Implement its stage under apps/desktop/src/renderer/vrm/webgpu/effects/.
7
8
  // Scene-space effects (geometry, not a post stage — e.g. snow) also join
8
9
  // SCENE_SPACE_EFFECT_KEYS below.
@@ -27,14 +28,31 @@ export const EFFECT_SPECS = {
27
28
  // Threshold 0.8 catches highlights without hazing the whole avatar; radius is the lib default.
28
29
  threshold: { default: 0.8, min: 0, max: 1, step: 0.01 },
29
30
  radius: { default: 0.85, min: 0, max: 1, step: 0.01 },
31
+ // VTS bloom's anamorphic-flare half. Streak 0 keeps the passes out of the
32
+ // graph; the angle is VTS's horizontal/vertical toggle made continuous.
33
+ streak: { default: 0, min: 0, max: 3, step: 0.01 },
34
+ streakThreshold: { default: 0.8, min: 0, max: 1, step: 0.01 },
35
+ streakAngle: { default: 0, min: -90, max: 90, step: 1, unit: '°' },
36
+ darken: { default: 0, min: 0, max: 1, step: 0.01 },
37
+ },
38
+ // Shoost's diffusion (its Kino bloom variant, sliders Radius/Intensity/
39
+ // Contrast→threshold): screen-composited, so its Opacity slider folds into
40
+ // intensity. Threshold 0.35 mists the midtones up — the stock Pro-Mist read.
41
+ diffusion: {
42
+ intensity: { default: 1, min: 0, max: 2, step: 0.01 },
43
+ radius: { default: 0.85, min: 0, max: 1, step: 0.01 },
44
+ threshold: { default: 0.35, min: 0, max: 1, step: 0.01 },
30
45
  },
31
46
  dof: {
32
47
  bokehScale: { default: 2, min: 0, max: 8, step: 0.01 },
33
48
  /** World metres of acceptably-sharp depth around the focus plane. */
34
49
  focusRange: { default: 2, min: 0.1, max: 10, step: 0.01 },
35
50
  },
51
+ // VTube Studio's chromatic_aberration surface: strength + blur edges, the
52
+ // latter continuous like rim.bothSides (VTS fades the same shader uniform).
36
53
  chromaticAberration: {
37
54
  strength: { default: 0.2, min: 0, max: 1, step: 0.01 },
55
+ blurEdges: { default: 1, min: 0, max: 1, step: 0.01 },
38
56
  },
39
57
  grain: {
40
58
  strength: { default: 0.3, min: 0, max: 1, step: 0.01 },
@@ -43,6 +61,36 @@ export const EFFECT_SPECS = {
43
61
  darkness: { default: 0.5, min: 0, max: 1, step: 0.01 },
44
62
  offset: { default: 0.5, min: 0, max: 1, step: 0.01 },
45
63
  },
64
+ // Shoost's rim-light ranges where the control carried over (size, angle,
65
+ // opacity→intensity); the rest follows VTube Studio's backlight. New params
66
+ // default neutral (omni 0, limit 1, darken 0) so healed scenes keep their look.
67
+ rim: {
68
+ intensity: { default: 1, min: 0, max: 2, step: 0.01 },
69
+ omni: { default: 0, min: 0, max: 1, step: 0.01 },
70
+ size: { default: 16, min: 1, max: 64, step: 1, unit: 'px' },
71
+ softness: { default: 0.6, min: 0, max: 1, step: 0.01 },
72
+ angle: { default: 45, min: 0, max: 360, step: 1, unit: '°' },
73
+ bothSides: { default: 0.5, min: 0, max: 1, step: 0.01 },
74
+ brightnessLimit: { default: 1, min: 0, max: 1, step: 0.01 },
75
+ darken: { default: 0, min: 0, max: 1, step: 0.01 },
76
+ },
77
+ // VTube Studio's backlight outline block: stripe count/speed/curve ranges are
78
+ // VTS's; size is ours (px, like rim) instead of VTS's normalized 0..1.
79
+ outline: {
80
+ size: { default: 8, min: 1, max: 64, step: 1, unit: 'px' },
81
+ opacity: { default: 1, min: 0, max: 1, step: 0.01 },
82
+ stripes: { default: 0.5, min: 0, max: 1, step: 0.01 },
83
+ stripeMix: { default: 1, min: 0, max: 1, step: 0.01 },
84
+ stripeSpeed: { default: 0.2, min: -1, max: 1, step: 0.01 },
85
+ stripeCurve: { default: 0, min: 0, max: 1, step: 0.01 },
86
+ },
87
+ // VTube Studio's backlight shadow: an offset silhouette copy. VTS hides it at
88
+ // zero offset; ours defaults to a visible down-right cast so enabling shows it.
89
+ dropShadow: {
90
+ opacity: { default: 0.6, min: 0, max: 1, step: 0.01 },
91
+ offsetX: { default: 3, min: -20, max: 20, step: 0.5, digits: 1, unit: '%' },
92
+ offsetY: { default: 3, min: -20, max: 20, step: 0.5, digits: 1, unit: '%' },
93
+ },
46
94
  pixelate: {
47
95
  // Pixels per block; past ~64 the frame is a handful of tiles.
48
96
  granularity: { default: 8, min: 2, max: 64, step: 1 },
@@ -74,6 +122,17 @@ export const EFFECT_SPECS = {
74
122
  staticDrops: { default: 0.2, min: 0, max: 3, step: 0.1, digits: 1 },
75
123
  glints: { default: 0.6, min: 0, max: 1, step: 0.05 },
76
124
  },
125
+ // VTS's weather rain rebuilt in world space: velocity-aligned streaks that
126
+ // slant with wind and splash where they land. `amount` is the live instance count.
127
+ rain: {
128
+ amount: { default: 4000, min: 100, max: 10000, step: 100 },
129
+ speed: { default: 1, min: 0.2, max: 3, step: 0.1, digits: 1, unit: '×' },
130
+ wind: { default: 0, min: -6, max: 6, step: 0.1, digits: 1, unit: 'm/s' },
131
+ length: { default: 1, min: 0.2, max: 3, step: 0.05 },
132
+ size: { default: 1, min: 0.4, max: 3, step: 0.05 },
133
+ splash: { default: 0.7, min: 0, max: 1, step: 0.05 },
134
+ opacity: { default: 0.4, min: 0.05, max: 1, step: 0.05 },
135
+ },
77
136
  // three's webgpu_compute_particles_snow, rescaled to the stage's metre-scale
78
137
  // world. `amount` is the live instance count; buffers allocate the max once.
79
138
  snow: {
@@ -86,6 +145,28 @@ export const EFFECT_SPECS = {
86
145
  opacity: { default: 1, min: 0.1, max: 1, step: 0.05 },
87
146
  },
88
147
  };
148
+ /**
149
+ * Hex-color params, keyed like {@link EFFECT_SPECS} — generic walkers (healing,
150
+ * defaults, hotkey snapshots, the panel) visit both tables. Only effects with a
151
+ * string param appear.
152
+ */
153
+ export const EFFECT_COLOR_SPECS = {
154
+ bloom: {
155
+ tint: { default: '#ffffff' },
156
+ // Beautify's anamorphic-flare tint default — the classic lens-streak blue.
157
+ streakTint: { default: '#8080ff' },
158
+ },
159
+ rim: {
160
+ color: { default: '#ffffff' },
161
+ },
162
+ outline: {
163
+ color: { default: '#ffffff' },
164
+ stripeColor: { default: '#000000' },
165
+ },
166
+ dropShadow: {
167
+ color: { default: '#000000' },
168
+ },
169
+ };
89
170
  // Object.keys widens to string[]; the annotation above pins the keys to exactly ToggleEffectKey.
90
171
  export const TOGGLE_EFFECT_KEYS = Object.keys(EFFECT_SPECS);
91
172
  /**
@@ -93,7 +174,7 @@ export const TOGGLE_EFFECT_KEYS = Object.keys(EFFECT_SPECS);
93
174
  * than composing into the post chain. A new scene-space effect must join this
94
175
  * list, or its toggle needlessly forces the post path on and rebuilds the graph.
95
176
  */
96
- export const SCENE_SPACE_EFFECT_KEYS = ['snow'];
177
+ export const SCENE_SPACE_EFFECT_KEYS = ['rain', 'snow'];
97
178
  /** Registry effects the post chain composes — {@link TOGGLE_EFFECT_KEYS} minus the scene-space ones. */
98
179
  export const POST_EFFECT_KEYS = TOGGLE_EFFECT_KEYS.filter(key => !SCENE_SPACE_EFFECT_KEYS.includes(key));
99
180
  /** Every registry effect at its defaults — the derived half of a fresh {@link SceneEffects}. */
@@ -103,6 +184,8 @@ export function defaultToggleEffects() {
103
184
  const fx = { enabled: false };
104
185
  for (const [param, spec] of Object.entries(EFFECT_SPECS[key]))
105
186
  fx[param] = spec.default;
187
+ for (const [param, spec] of Object.entries(EFFECT_COLOR_SPECS[key] ?? {}))
188
+ fx[param] = spec.default;
106
189
  out[key] = fx;
107
190
  }
108
191
  // Runtime-built, invisible to TS; EFFECT_SPECS's satisfies check is what pins
@@ -6,6 +6,8 @@ export declare const DEFAULT_LIVE2D_PLACEMENT: ScreenPlacement;
6
6
  export declare const DEFAULT_VRM_PLACEMENT: VrmPlacement;
7
7
  /** Scene colors heal to 6-digit hex. */
8
8
  export declare const SCENE_COLOR_RE: RegExp;
9
+ /** The value when it is a 6-digit hex color, else `fallback` — validated, never clamped. */
10
+ export declare function hexColorOr(v: unknown, fallback: string): string;
9
11
  /** Drop the alpha byte a picker's hex input can produce (`#rrggbbaa` → `#rrggbb`). */
10
12
  export declare function opaqueHex(hex: string): string;
11
13
  export declare const SCENE_LIGHT_INTENSITY_MAX = 2;
@@ -9,6 +9,10 @@ export const DEFAULT_LIVE2D_PLACEMENT = { x: 0, y: 0, scale: 1, rotation: 0 };
9
9
  export const DEFAULT_VRM_PLACEMENT = { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
10
10
  /** Scene colors heal to 6-digit hex. */
11
11
  export const SCENE_COLOR_RE = /^#[0-9a-f]{6}$/i;
12
+ /** The value when it is a 6-digit hex color, else `fallback` — validated, never clamped. */
13
+ export function hexColorOr(v, fallback) {
14
+ return typeof v === 'string' && SCENE_COLOR_RE.test(v) ? v : fallback;
15
+ }
12
16
  /** Drop the alpha byte a picker's hex input can produce (`#rrggbbaa` → `#rrggbb`). */
13
17
  export function opaqueHex(hex) {
14
18
  return /^#[0-9a-f]{8}$/i.test(hex) ? hex.slice(0, 7) : hex;
@@ -64,7 +68,8 @@ export function defaultSceneLightOf(type) {
64
68
  id: crypto.randomUUID(),
65
69
  type,
66
70
  color: '#ffffff',
67
- intensity: 1,
71
+ // Ambient is a flat albedo floor with no shading — 1.0 alone washes the model out.
72
+ intensity: type === 'ambient' ? 0.4 : 1,
68
73
  azimuth: DEFAULT_LIGHT_AZIMUTH_DEG,
69
74
  elevation: DEFAULT_LIGHT_ELEVATION_DEG,
70
75
  x: 0,
@@ -29,7 +29,6 @@ export declare const SettingsPatchSchema: z.ZodObject<{
29
29
  trayVisible: z.ZodOptional<z.ZodBoolean>;
30
30
  }, z.core.$strip>>;
31
31
  performance: z.ZodOptional<z.ZodObject<{
32
- backgroundRendering: z.ZodOptional<z.ZodBoolean>;
33
32
  showFps: z.ZodOptional<z.ZodBoolean>;
34
33
  fpsLimit: z.ZodOptional<z.ZodNumber>;
35
34
  selectionOutline: z.ZodOptional<z.ZodBoolean>;
@@ -139,7 +138,6 @@ export declare const requestSchemas: {
139
138
  trayVisible: z.ZodOptional<z.ZodBoolean>;
140
139
  }, z.core.$strip>>;
141
140
  performance: z.ZodOptional<z.ZodObject<{
142
- backgroundRendering: z.ZodOptional<z.ZodBoolean>;
143
141
  showFps: z.ZodOptional<z.ZodBoolean>;
144
142
  fpsLimit: z.ZodOptional<z.ZodNumber>;
145
143
  selectionOutline: z.ZodOptional<z.ZodBoolean>;
@@ -22,7 +22,6 @@ export const SettingsPatchSchema = z.object({
22
22
  ui: z.object({ trayVisible: z.boolean().optional() }).optional(),
23
23
  performance: z
24
24
  .object({
25
- backgroundRendering: z.boolean().optional(),
26
25
  showFps: z.boolean().optional(),
27
26
  fpsLimit: z.number().optional(),
28
27
  selectionOutline: z.boolean().optional(),
@@ -280,12 +280,42 @@ export interface SceneFog {
280
280
  }
281
281
  /** Display transform applied after the scene renders. `none` keeps colors exactly as authored. */
282
282
  export type SceneToneMapping = 'none' | 'neutral' | 'aces' | 'agx';
283
- /** Glow around bright pixels. `threshold` is the luminance floor; `radius` widens the halo. */
283
+ /**
284
+ * Glow around bright pixels, with anamorphic streak flares on the side
285
+ * (VTube Studio's Beautify-based bloom). `threshold` is the luminance floor;
286
+ * `radius` widens the halo.
287
+ */
284
288
  export interface SceneBloom {
285
289
  enabled: boolean;
286
290
  intensity: number;
287
291
  threshold: number;
288
292
  radius: number;
293
+ /** Halo tint (hex); white is untinted. */
294
+ tint: string;
295
+ /** Anamorphic streak strength; 0 keeps the streak passes out of the graph. */
296
+ streak: number;
297
+ /** Luminance floor for the streak's own bright pass. */
298
+ streakThreshold: number;
299
+ /** Streak axis in degrees: 0 horizontal, ±90 vertical (VTS's vertical toggle, continuous). */
300
+ streakAngle: number;
301
+ /** Streak tint (hex); classic anamorphic flares are light blue. */
302
+ streakTint: string;
303
+ /** Darkens the avatar under the glow so highlights pop (VTS's model darken), 0..1. */
304
+ darken: number;
305
+ }
306
+ /**
307
+ * Cinematic soft-focus veil (Shoost's diffusion, a Kino bloom variant): the
308
+ * frame's own light blurred wide and screened back over itself, so highlights
309
+ * halo and nearby shadows lift without bloom's additive blowout.
310
+ */
311
+ export interface SceneDiffusion {
312
+ enabled: boolean;
313
+ /** Veil strength; screen-composited, so 1 stays short of clipping. */
314
+ intensity: number;
315
+ /** Veil spread: 0 tight halos, 1 the widest wash. */
316
+ radius: number;
317
+ /** Luminance floor that feeds the veil: 0 mists the whole frame, higher keeps it to highlights. */
318
+ threshold: number;
289
319
  }
290
320
  /** Darkened frame corners. `offset` pushes the falloff outward. */
291
321
  export interface SceneVignette {
@@ -301,10 +331,13 @@ export interface SceneColorGrade {
301
331
  brightness: number;
302
332
  contrast: number;
303
333
  }
304
- /** RGB fringing toward frame edges. `strength` 0..1. */
334
+ /** Lens fringing that grows toward frame edges (Unity PPv2's curve, the one VTube Studio wraps). */
305
335
  export interface SceneChromaticAberration {
306
336
  enabled: boolean;
337
+ /** Fringe displacement 0..1; 1 smears ~10% of the frame at the corners. */
307
338
  strength: number;
339
+ /** 0 = three crisp RGB ghosts, 1 = fully integrated spectral smear (VTS's "blur edges"). */
340
+ blurEdges: number;
308
341
  }
309
342
  /** Animated film grain. `strength` is blend opacity 0..1. */
310
343
  export interface SceneFilmGrain {
@@ -378,6 +411,89 @@ export interface SceneDroplets {
378
411
  /** Specular glint on drops where the frame is transparent, so rain reads over the desktop (0 to 1). */
379
412
  glints: number;
380
413
  }
414
+ /**
415
+ * Screen-space rim light along the avatar silhouette (Shoost's layer rim light,
416
+ * VTube Studio's backlight): coverage edges facing `angle` catch the glow.
417
+ * Silhouette-driven — an opaque skybox leaves no edges to light.
418
+ */
419
+ export interface SceneRim {
420
+ enabled: boolean;
421
+ /** Rim tint (hex). */
422
+ color: string;
423
+ /** Glow strength; past 1 overdrives into bloom territory. */
424
+ intensity: number;
425
+ /** Rim width in output pixels. */
426
+ size: number;
427
+ /** Edge falloff: 0 a crisp line, 1 a wide soft fade. */
428
+ softness: number;
429
+ /** Light direction in degrees, compass-style: 0 lights from above, 90 from the right. */
430
+ angle: number;
431
+ /** How much the edge opposite the light glows too (0 to 1). */
432
+ bothSides: number;
433
+ /** Uniform glow on every edge regardless of `angle` (VTS's main backlight strength), 0..1. */
434
+ omni: number;
435
+ /** Ceiling the glow lightens pixels toward: 1 screens to white, lower protects highlights. */
436
+ brightnessLimit: number;
437
+ /** Darkens the avatar under the glow for contrast (VTS's model darken), 0..1. */
438
+ darken: number;
439
+ }
440
+ /**
441
+ * Contour band hugging the avatar silhouette, with an optional animated stripe
442
+ * pattern scrolling through it (VTube Studio's backlight outline). Silhouette-
443
+ * driven like the rim light — it needs coverage edges to trace.
444
+ */
445
+ export interface SceneOutline {
446
+ enabled: boolean;
447
+ /** Outline color (hex). */
448
+ color: string;
449
+ /** Stripe color (hex), painted over `color` where the stripe pattern lands. */
450
+ stripeColor: string;
451
+ /** Band thickness in output pixels. */
452
+ size: number;
453
+ /** Outline opacity 0..1. */
454
+ opacity: number;
455
+ /** Stripe density: 0 broad bands, 1 fine candy stripes. */
456
+ stripes: number;
457
+ /** Stripe visibility: 0 a solid outline, 1 full-strength stripes. */
458
+ stripeMix: number;
459
+ /** Stripe scroll speed; negative reverses the direction. */
460
+ stripeSpeed: number;
461
+ /** Bends the stripes into waves: 0 straight, 1 strongly curled. */
462
+ stripeCurve: number;
463
+ }
464
+ /**
465
+ * Hard-edged copy of the avatar silhouette cast behind it (VTube Studio's
466
+ * backlight shadow). Offsets are percent of the frame height.
467
+ */
468
+ export interface SceneDropShadow {
469
+ enabled: boolean;
470
+ /** Shadow color (hex). */
471
+ color: string;
472
+ /** Shadow opacity 0..1. */
473
+ opacity: number;
474
+ /** Horizontal offset, percent of frame height; positive casts right. */
475
+ offsetX: number;
476
+ /** Vertical offset, percent of frame height; positive casts down. */
477
+ offsetY: number;
478
+ }
479
+ /** Compute-driven 3D rainfall: motion-blur streaks fall through the scene and splash on models, props, and the floor. */
480
+ export interface SceneRain {
481
+ enabled: boolean;
482
+ /** How many drops are alive at once. */
483
+ amount: number;
484
+ /** Fall clock multiplier. */
485
+ speed: number;
486
+ /** Sideways drift in metres per second; slants the streaks. */
487
+ wind: number;
488
+ /** Streak length multiplier over the motion-blur baseline. */
489
+ length: number;
490
+ /** Streak width multiplier. */
491
+ size: number;
492
+ /** Impact splash strength; 0 recycles drops with no splash. */
493
+ splash: number;
494
+ /** Streak opacity. */
495
+ opacity: number;
496
+ }
381
497
  /** Compute-driven snowfall in the 3D scene: flakes drift down, settle on models and props, then melt. */
382
498
  export interface SceneSnow {
383
499
  enabled: boolean;
@@ -407,17 +523,40 @@ export interface SceneEffects {
407
523
  /** Scene brightness multiplied in before the tone curve; 1 is neutral. Works in every mode, including `none`. */
408
524
  exposure: number;
409
525
  bloom: SceneBloom;
526
+ diffusion: SceneDiffusion;
410
527
  vignette: SceneVignette;
411
528
  color: SceneColorGrade;
412
529
  chromaticAberration: SceneChromaticAberration;
413
530
  grain: SceneFilmGrain;
414
531
  lut: SceneLut;
415
532
  dof: SceneDepthOfField;
533
+ rim: SceneRim;
534
+ outline: SceneOutline;
535
+ dropShadow: SceneDropShadow;
416
536
  pixelate: ScenePixelate;
417
537
  glitch: SceneGlitch;
418
538
  droplets: SceneDroplets;
539
+ rain: SceneRain;
419
540
  snow: SceneSnow;
420
541
  }
542
+ /**
543
+ * One user-authored effect's placement in a scene. Deliberately a sibling of
544
+ * {@link SceneEffects} rather than a key inside it: `ToggleEffectKey` is derived
545
+ * from that interface, and healing, defaults, hotkey snapshots and the panel all
546
+ * iterate it as a closed compile-time union. Runtime keys in there would erase
547
+ * that guarantee for the built-in effects too.
548
+ */
549
+ export interface SceneCustomEffect {
550
+ /** Folder name under the config home's `effects/` dir — stable id and module location. */
551
+ slug: string;
552
+ enabled: boolean;
553
+ /**
554
+ * Author-declared values, keyed by the manifest's param names. Untyped by
555
+ * construction: the specs arrive from disk at runtime, so these are validated
556
+ * against the installed manifest rather than checked at compile time.
557
+ */
558
+ params: Record<string, number | boolean | string>;
559
+ }
421
560
  /**
422
561
  * Image-based lighting for the 3D stage: an environment map, whether to show it
423
562
  * behind the scene, the scene-global fog, and post-processing.
@@ -429,6 +568,14 @@ export interface SceneEnvironment {
429
568
  showSkybox: boolean;
430
569
  fog: SceneFog;
431
570
  effects: SceneEffects;
571
+ /**
572
+ * User-authored effects, in the order they compose. They run as a group at one
573
+ * fixed point in the post chain — after grading and the screen-space warps,
574
+ * before grain and vignette — so the built-in composition order stays fixed.
575
+ * An entry whose effect is not installed here is kept, not dropped: it would
576
+ * cost the user their tuning on a machine that simply lacks the folder.
577
+ */
578
+ customEffects: SceneCustomEffect[];
432
579
  }
433
580
  export interface Scene {
434
581
  id: string;
@@ -606,7 +753,6 @@ export interface Settings {
606
753
  trayVisible: boolean;
607
754
  };
608
755
  performance: {
609
- backgroundRendering: boolean;
610
756
  showFps: boolean;
611
757
  fpsLimit: number;
612
758
  selectionOutline: boolean;
@@ -633,7 +779,6 @@ export interface SettingsPatch {
633
779
  trayVisible?: boolean;
634
780
  };
635
781
  performance?: {
636
- backgroundRendering?: boolean;
637
782
  showFps?: boolean;
638
783
  fpsLimit?: number;
639
784
  selectionOutline?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laplace.live/persona-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "TypeScript SDK and wire schema for the LAPLACE Persona plugin API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,6 +16,10 @@
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"
19
23
  }
20
24
  },
21
25
  "files": [
@@ -26,10 +30,20 @@
26
30
  "provenance": true
27
31
  },
28
32
  "devDependencies": {
33
+ "@types/three": "^0.185.4",
29
34
  "rimraf": "^6.1.3",
35
+ "three": "0.185.1",
30
36
  "typescript": "~6.0.3",
31
37
  "vitest": "^4.1.11"
32
38
  },
39
+ "peerDependencies": {
40
+ "@types/three": "^0.185.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@types/three": {
44
+ "optional": true
45
+ }
46
+ },
33
47
  "engines": {
34
48
  "node": ">=22"
35
49
  },