@laplace.live/persona-sdk 1.20.0 → 1.22.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.
@@ -2,6 +2,7 @@ import { parseServerMessage } from "../wire/envelope.js";
2
2
  import { PersonaApiError } from "../wire/errors.js";
3
3
  import { CLOSE_FORCE_DISCONNECTED, CLOSE_KEY_REVOKED, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION, SCENE_ACTIVATION_TIMEOUT_MS, } from "../wire/protocol.js";
4
4
  import { personaWsUrl } from "./address.js";
5
+ import { renameLegacyKinds } from "./legacy-kinds.js";
5
6
  const DEFAULTS = {
6
7
  url: personaWsUrl(),
7
8
  reconnectDelayMs: 500,
@@ -109,7 +110,8 @@ export class PersonaClient {
109
110
  reject(new Error(`request timed out: ${method}`));
110
111
  }, this.opts.requestTimeoutMs ??
111
112
  (method === 'scene.activate' ? SCENE_ACTIVATION_TIMEOUT_MS : DEFAULTS.requestTimeoutMs));
112
- this.pending.set(id, { resolve: resolve, reject, timer });
113
+ // Only the runtime id ties a response back to `M`, and its result is unvalidated wire JSON.
114
+ this.pending.set(id, { method, resolve: resolve, reject, timer });
113
115
  ws.send(JSON.stringify({ kind: 'request', id, method, ...(params === undefined ? {} : { params }) }));
114
116
  });
115
117
  }
@@ -355,8 +357,10 @@ export class PersonaClient {
355
357
  const set = this.listeners.get(msg.event);
356
358
  if (!set)
357
359
  return;
360
+ const data = renameLegacyKinds(msg.event, msg.data);
361
+ // Only `msg.event` ties these callbacks to their payload type, and the payload is unvalidated wire JSON.
358
362
  for (const cb of set)
359
- cb(msg.data);
363
+ cb(data);
360
364
  return;
361
365
  }
362
366
  const { id } = msg;
@@ -368,7 +372,7 @@ export class PersonaClient {
368
372
  this.pending.delete(id);
369
373
  clearTimeout(p.timer);
370
374
  if (msg.kind === 'response')
371
- p.resolve(msg.result);
375
+ p.resolve(renameLegacyKinds(p.method, msg.result));
372
376
  else
373
377
  p.reject(new PersonaApiError(msg.code, msg.message));
374
378
  }
@@ -0,0 +1,2 @@
1
+ /** A method result or event payload with the action kinds an older host still sends renamed to current ones. */
2
+ export declare function renameLegacyKinds(name: string, data: unknown): unknown;
@@ -0,0 +1,40 @@
1
+ import { isRecord } from "../values/guards.js";
2
+ import { canonicalActionKind } from "../wire/types.js";
3
+ function renameHotkeyActions(state) {
4
+ if (!isRecord(state) || !Array.isArray(state.hotkeys))
5
+ return state;
6
+ return {
7
+ ...state,
8
+ hotkeys: state.hotkeys.map((hotkey) => isRecord(hotkey) && typeof hotkey.action === 'string'
9
+ ? { ...hotkey, action: canonicalActionKind(hotkey.action) }
10
+ : hotkey),
11
+ };
12
+ }
13
+ function renameActionKinds(list) {
14
+ if (!isRecord(list) || !Array.isArray(list.automations))
15
+ return list;
16
+ return {
17
+ ...list,
18
+ automations: list.automations.map((automation) => isRecord(automation) && Array.isArray(automation.actionKinds)
19
+ ? {
20
+ ...automation,
21
+ actionKinds: automation.actionKinds.map((kind) => typeof kind === 'string' ? canonicalActionKind(kind) : kind),
22
+ }
23
+ : automation),
24
+ };
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) {
28
+ switch (name) {
29
+ case 'hotkey.list':
30
+ case 'hotkey.set':
31
+ return renameHotkeyActions(data);
32
+ case 'hotkey.state':
33
+ return isRecord(data) ? { ...data, config: renameHotkeyActions(data.config) } : data;
34
+ case 'automation.list':
35
+ case 'automation.state':
36
+ return renameActionKinds(data);
37
+ default:
38
+ return data;
39
+ }
40
+ }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from './values/effect-schema.ts';
10
10
  export * from './values/gltf-extensions.ts';
11
11
  export * from './values/guards.ts';
12
12
  export * from './values/hands.ts';
13
+ export * from './values/hotkey-targets.ts';
13
14
  export * from './values/hotkeys.ts';
14
15
  export * from './values/item-transition.ts';
15
16
  export * from './values/labels.ts';
@@ -22,6 +23,7 @@ export * from './values/mouse.ts';
22
23
  export * from './values/scene-transition.ts';
23
24
  export * from './values/stage-info.ts';
24
25
  export * from './values/terms.ts';
26
+ export * from './values/time.ts';
25
27
  export * from './values/volumetric-lighting.ts';
26
28
  export * from './values/vrm-bindings.ts';
27
29
  export * from './wire/envelope.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./values/effect-schema.js";
12
12
  export * from "./values/gltf-extensions.js";
13
13
  export * from "./values/guards.js";
14
14
  export * from "./values/hands.js";
15
+ export * from "./values/hotkey-targets.js";
15
16
  export * from "./values/hotkeys.js";
16
17
  export * from "./values/item-transition.js";
17
18
  export * from "./values/labels.js";
@@ -24,6 +25,7 @@ export * from "./values/mouse.js";
24
25
  export * from "./values/scene-transition.js";
25
26
  export * from "./values/stage-info.js";
26
27
  export * from "./values/terms.js";
28
+ export * from "./values/time.js";
27
29
  export * from "./values/volumetric-lighting.js";
28
30
  export * from "./values/vrm-bindings.js";
29
31
  export * from "./wire/envelope.js";
@@ -18,8 +18,8 @@ export interface Keyframe {
18
18
  anchor: Point;
19
19
  /**
20
20
  * Bezier handles. `x` is a **fraction of the segment's x-span** (0 = this segment's left
21
- * anchor, 1 = its right), `y` is absolute — nizima's asymmetry, and worth keeping: an
22
- * anchor dragged sideways carries its handles, while the shape stays pinned to real output.
21
+ * anchor, 1 = its right), `y` is absolute — nizima's format. Because `y` stays put when its
22
+ * anchor moves, an editor drag has to carry the handles itself (`dragAnchor`), or the point kinks.
23
23
  */
24
24
  next?: Point;
25
25
  previous?: Point;
@@ -61,8 +61,9 @@ export interface CurveHandle extends Point {
61
61
  span: readonly [number, number];
62
62
  }
63
63
  /**
64
- * The keyframe's draggable handles, in curve coordinates — its outgoing one when it governs a
65
- * Bezier segment, its incoming one when the keyframe before it does.
64
+ * The keyframe's handles, in curve coordinates — its outgoing one when it governs a Bezier
65
+ * segment, its incoming one when the keyframe before it does. A sharp point's are retracted onto
66
+ * it and still reported; `extendedHandles` is what an editor draws.
66
67
  *
67
68
  * `handleX` rather than a second clamp-and-lerp: a handle has to read back where the evaluator
68
69
  * looks for it. `y` is absolute and deliberately unclamped — `sanitizeCurve` keeps a control point
@@ -70,6 +71,8 @@ export interface CurveHandle extends Point {
70
71
  * legitimate overshoot at the wrong height.
71
72
  */
72
73
  export declare function handlesFor(keyframes: Keyframe[], index: number): CurveHandle[];
74
+ /** The keyframe's handles an editor draws and lets you grab: a retracted one has nothing to grab. */
75
+ export declare function extendedHandles(keyframes: Keyframe[], index: number): CurveHandle[];
73
76
  /**
74
77
  * How far an anchor may travel in x, as `[lo, hi]`.
75
78
  *
@@ -81,6 +84,31 @@ export declare function handlesFor(keyframes: Keyframe[], index: number): CurveH
81
84
  * One statement of the rule, so an editor's drag and its typed field cannot drift apart.
82
85
  */
83
86
  export declare function anchorBounds(keyframes: Keyframe[], index: number): [number, number];
87
+ /**
88
+ * The anchor at `index` moved to `to`, held within `anchorBounds` and the plot, carrying its own
89
+ * handles as a pen tool does, so the curve leaves and arrives at it in the same directions: a
90
+ * smooth point stays smooth, a corner keeps its angle. The neighbours' handles keep their
91
+ * fractions, so the segments either side stretch rather than re-aim.
92
+ */
93
+ export declare function dragAnchor(keyframes: Keyframe[], index: number, to: Point): Keyframe[];
94
+ /**
95
+ * The `side` handle of the anchor at `index` moved to `to`. On a smooth point the other handle
96
+ * turns to stay in line, keeping its length — a pen tool's default — unless `independent`, which
97
+ * moves this one alone and leaves a corner. `to` is held within the segment the handle shapes,
98
+ * where `handleX` reads it, and on the plot, where it can be found again; a stored overshoot still
99
+ * reads back. The turned handle is shortened to fit its own.
100
+ */
101
+ export declare function dragHandle(keyframes: Keyframe[], index: number, side: HandleSide, to: Point, independent: boolean): Keyframe[];
102
+ /**
103
+ * A pen tool's convert-point click on the interior anchor at `index`: a smooth point turns sharp,
104
+ * anything else turns smooth. Beside a step there is nothing to be in line with, so a point there is
105
+ * smooth while its one handle is out. Sharp retracts the handles onto the anchor, so no curve bends
106
+ * at it. Smooth aims them through the neighbours — flat at a peak or valley, so the curve cannot
107
+ * overshoot the neighbour it turns at — a third of the way along each segment, and a linear side
108
+ * becomes Bezier with its far handle on the old line, so the neighbour's end keeps its direction.
109
+ * Step sides are left as they are. Returns **the input array itself** when it declines.
110
+ */
111
+ export declare function toggleSmooth(keyframes: Keyframe[], index: number): Keyframe[];
84
112
  /**
85
113
  * Every Bezier segment carries both handles, seeded collinear unless it already has both.
86
114
  *
@@ -146,13 +174,18 @@ export declare const INTERPOLATIONS: readonly ["linear", "step", "invertStep", "
146
174
  */
147
175
  export declare function sanitizeCurve(v: unknown): Curve | null;
148
176
  /**
149
- * The straight 0..1 line — what a binding without a curve already does, as an editable start.
177
+ * The straight 0..1 line — what a binding without a curve already does — as the Linear preset.
150
178
  *
151
179
  * No handles: a `linear` keyframe's are never read, and one that outlives the segment it was cut
152
180
  * for is what bends a curve on a mode switch. `withBezierHandles` cuts fresh ones against whatever
153
181
  * the anchors are by then, so Bezier leaves the line where it is and only a drag shapes it.
154
182
  */
155
183
  export declare function identityCurve(): Curve;
184
+ /**
185
+ * The same straight line as one Bezier segment, handles on the line at the thirds — what a new
186
+ * curve starts as, so a point clicked into it splits smooth and a drag bends it rather than kinking.
187
+ */
188
+ export declare function bezierIdentityCurve(): Curve;
156
189
  /** A ready-made response shape that replaces the whole curve; `linear` is the identity. */
157
190
  export type CurvePresetId = (typeof CURVE_PRESET_IDS)[number];
158
191
  export declare const CURVE_PRESET_IDS: readonly ["linear", "step", "invertStep", "bezier", "easeIn", "easeOut", "sCurve", "threshold", "steps"];
@@ -92,8 +92,9 @@ export function evaluateCurve(curve, x) {
92
92
  return left === undefined || right === undefined ? first.anchor.y : segmentAt(left, right, x);
93
93
  }
94
94
  /**
95
- * The keyframe's draggable handles, in curve coordinates — its outgoing one when it governs a
96
- * Bezier segment, its incoming one when the keyframe before it does.
95
+ * The keyframe's handles, in curve coordinates — its outgoing one when it governs a Bezier
96
+ * segment, its incoming one when the keyframe before it does. A sharp point's are retracted onto
97
+ * it and still reported; `extendedHandles` is what an editor draws.
97
98
  *
98
99
  * `handleX` rather than a second clamp-and-lerp: a handle has to read back where the evaluator
99
100
  * looks for it. `y` is absolute and deliberately unclamped — `sanitizeCurve` keeps a control point
@@ -117,6 +118,15 @@ export function handlesFor(keyframes, index) {
117
118
  }
118
119
  return out;
119
120
  }
121
+ /** Below this length a handle is retracted onto its anchor, as a sharp point's are. */
122
+ const RETRACTED = 1e-9;
123
+ /** The keyframe's handles an editor draws and lets you grab: a retracted one has nothing to grab. */
124
+ export function extendedHandles(keyframes, index) {
125
+ const anchor = keyframes[index]?.anchor;
126
+ if (anchor === undefined)
127
+ return [];
128
+ return handlesFor(keyframes, index).filter(h => Math.hypot(h.x - anchor.x, h.y - anchor.y) > RETRACTED);
129
+ }
120
130
  /**
121
131
  * How far an anchor may travel in x, as `[lo, hi]`.
122
132
  *
@@ -133,7 +143,143 @@ export function anchorBounds(keyframes, index) {
133
143
  return [0, 1];
134
144
  if (index === 0 || index === keyframes.length - 1)
135
145
  return [held.anchor.x, held.anchor.x];
136
- return [(keyframes[index - 1]?.anchor.x ?? 0) + EDGE_EPSILON, (keyframes[index + 1]?.anchor.x ?? 1) - EDGE_EPSILON];
146
+ const lo = keyframes[index - 1]?.anchor.x ?? 0;
147
+ const hi = keyframes[index + 1]?.anchor.x ?? 1;
148
+ // Clicks in one column stack anchors on the curve; neighbours too close for the margin meet halfway.
149
+ const edge = Math.min(EDGE_EPSILON, (hi - lo) / 2);
150
+ return [lo + edge, hi - edge];
151
+ }
152
+ /**
153
+ * The handle at `anchor + s·offset` for the largest `s` in 0..1 inside the box, stored against
154
+ * `xs`, its segment's span: a handle out of room is shortened along its own direction, so a
155
+ * smooth point stays smooth.
156
+ */
157
+ function fitHandle(anchor, offset, xs, ys) {
158
+ let s = 1;
159
+ if (offset.x > 0)
160
+ s = Math.min(s, (xs[1] - anchor.x) / offset.x);
161
+ if (offset.x < 0)
162
+ s = Math.min(s, (xs[0] - anchor.x) / offset.x);
163
+ if (offset.y > 0)
164
+ s = Math.min(s, (ys[1] - anchor.y) / offset.y);
165
+ if (offset.y < 0)
166
+ s = Math.min(s, (ys[0] - anchor.y) / offset.y);
167
+ s = Math.max(0, s);
168
+ return { x: handleFraction(xs[0], xs[1], anchor.x + offset.x * s), y: anchor.y + offset.y * s };
169
+ }
170
+ /** Where an authored handle may sit in y: on the plot, or no further out than a stored overshoot. */
171
+ function heightsFor(y) {
172
+ return [Math.min(0, y), Math.max(1, y)];
173
+ }
174
+ /** Two handles in line through their anchor, on opposite sides of it: a smooth point. */
175
+ function inLine(anchor, a, b) {
176
+ const u = { x: a.x - anchor.x, y: a.y - anchor.y };
177
+ const v = { x: b.x - anchor.x, y: b.y - anchor.y };
178
+ const lu = Math.hypot(u.x, u.y);
179
+ const lv = Math.hypot(v.x, v.y);
180
+ if (lu <= RETRACTED || lv <= RETRACTED)
181
+ return false;
182
+ return u.x * v.x + u.y * v.y < 0 && Math.abs(u.x * v.y - u.y * v.x) <= 1e-3 * lu * lv;
183
+ }
184
+ /**
185
+ * The anchor at `index` moved to `to`, held within `anchorBounds` and the plot, carrying its own
186
+ * handles as a pen tool does, so the curve leaves and arrives at it in the same directions: a
187
+ * smooth point stays smooth, a corner keeps its angle. The neighbours' handles keep their
188
+ * fractions, so the segments either side stretch rather than re-aim.
189
+ */
190
+ export function dragAnchor(keyframes, index, to) {
191
+ const held = keyframes[index];
192
+ if (held === undefined)
193
+ return keyframes;
194
+ const [lo, hi] = anchorBounds(keyframes, index);
195
+ const at = { x: clamp(to.x, lo, hi), y: clamp(to.y, 0, 1) };
196
+ const moved = { ...held, anchor: at };
197
+ for (const h of handlesFor(keyframes, index)) {
198
+ const span = h.side === 'next' ? [at.x, h.span[1]] : [h.span[0], at.x];
199
+ moved[h.side] = fitHandle(at, { x: h.x - held.anchor.x, y: h.y - held.anchor.y }, span, heightsFor(h.y));
200
+ }
201
+ return keyframes.map((k, i) => (i === index ? moved : k));
202
+ }
203
+ /**
204
+ * The `side` handle of the anchor at `index` moved to `to`. On a smooth point the other handle
205
+ * turns to stay in line, keeping its length — a pen tool's default — unless `independent`, which
206
+ * moves this one alone and leaves a corner. `to` is held within the segment the handle shapes,
207
+ * where `handleX` reads it, and on the plot, where it can be found again; a stored overshoot still
208
+ * reads back. The turned handle is shortened to fit its own.
209
+ */
210
+ export function dragHandle(keyframes, index, side, to, independent) {
211
+ const held = keyframes[index];
212
+ const handles = handlesFor(keyframes, index);
213
+ const dragged = handles.find(h => h.side === side);
214
+ if (held === undefined || dragged === undefined)
215
+ return keyframes;
216
+ const at = { x: clamp(to.x, dragged.span[0], dragged.span[1]), y: clamp(to.y, 0, 1) };
217
+ const moved = { ...held };
218
+ moved[side] = { x: handleFraction(dragged.span[0], dragged.span[1], at.x), y: at.y };
219
+ const other = handles.find(h => h.side !== side);
220
+ const a = held.anchor;
221
+ const reach = Math.hypot(at.x - a.x, at.y - a.y);
222
+ if (!independent && other !== undefined && reach > 0 && inLine(a, dragged, other)) {
223
+ const keep = Math.hypot(other.x - a.x, other.y - a.y) / reach;
224
+ const away = { x: (a.x - at.x) * keep, y: (a.y - at.y) * keep };
225
+ moved[other.side] = fitHandle(a, away, other.span, heightsFor(other.y));
226
+ }
227
+ return keyframes.map((k, i) => (i === index ? moved : k));
228
+ }
229
+ /**
230
+ * A pen tool's convert-point click on the interior anchor at `index`: a smooth point turns sharp,
231
+ * anything else turns smooth. Beside a step there is nothing to be in line with, so a point there is
232
+ * smooth while its one handle is out. Sharp retracts the handles onto the anchor, so no curve bends
233
+ * at it. Smooth aims them through the neighbours — flat at a peak or valley, so the curve cannot
234
+ * overshoot the neighbour it turns at — a third of the way along each segment, and a linear side
235
+ * becomes Bezier with its far handle on the old line, so the neighbour's end keeps its direction.
236
+ * Step sides are left as they are. Returns **the input array itself** when it declines.
237
+ */
238
+ export function toggleSmooth(keyframes, index) {
239
+ const before = keyframes[index - 1];
240
+ const held = keyframes[index];
241
+ const after = keyframes[index + 1];
242
+ if (before === undefined || held === undefined || after === undefined)
243
+ return keyframes;
244
+ // A step side has no curve to aim; a linear one turns Bezier below.
245
+ const smoothIn = before.interpolation === 'linear' || before.interpolation === 'bezier';
246
+ const smoothOut = held.interpolation === 'linear' || held.interpolation === 'bezier';
247
+ if (!smoothIn && !smoothOut)
248
+ return keyframes;
249
+ const a = held.anchor;
250
+ const handles = handlesFor(keyframes, index);
251
+ const incoming = handles.find(h => h.side === 'previous');
252
+ const outgoing = handles.find(h => h.side === 'next');
253
+ const out = [...keyframes];
254
+ const isSmooth = smoothIn && smoothOut
255
+ ? incoming !== undefined && outgoing !== undefined && inLine(a, incoming, outgoing)
256
+ : extendedHandles(keyframes, index).length > 0;
257
+ if (isSmooth) {
258
+ const sharp = { ...held };
259
+ if (incoming !== undefined)
260
+ sharp.previous = { x: 1, y: a.y };
261
+ if (outgoing !== undefined)
262
+ sharp.next = { x: 0, y: a.y };
263
+ out[index] = sharp;
264
+ return out;
265
+ }
266
+ const l = before.anchor;
267
+ const r = after.anchor;
268
+ const slope = (a.y - l.y) * (r.y - a.y) > 0 ? (r.y - l.y) / (r.x - l.x) : 0;
269
+ const smooth = { ...held };
270
+ if (before.interpolation === 'linear') {
271
+ out[index - 1] = { ...before, interpolation: 'bezier', next: { x: 1 / 3, y: lerp(l.y, a.y, 1 / 3) } };
272
+ }
273
+ if (smoothIn)
274
+ smooth.previous = fitHandle(a, { x: (l.x - a.x) / 3, y: (slope * (l.x - a.x)) / 3 }, [l.x, a.x], [0, 1]);
275
+ if (held.interpolation === 'linear') {
276
+ smooth.interpolation = 'bezier';
277
+ out[index + 1] = { ...after, previous: { x: 2 / 3, y: lerp(a.y, r.y, 2 / 3) } };
278
+ }
279
+ if (smoothOut)
280
+ smooth.next = fitHandle(a, { x: (r.x - a.x) / 3, y: (slope * (r.x - a.x)) / 3 }, [a.x, r.x], [0, 1]);
281
+ out[index] = smooth;
282
+ return out;
137
283
  }
138
284
  /**
139
285
  * Every Bezier segment carries both handles, seeded collinear unless it already has both.
@@ -308,7 +454,7 @@ export function sanitizeCurve(v) {
308
454
  return { keyframes: withBezierHandles(keyframes) };
309
455
  }
310
456
  /**
311
- * The straight 0..1 line — what a binding without a curve already does, as an editable start.
457
+ * The straight 0..1 line — what a binding without a curve already does — as the Linear preset.
312
458
  *
313
459
  * No handles: a `linear` keyframe's are never read, and one that outlives the segment it was cut
314
460
  * for is what bends a curve on a mode switch. `withBezierHandles` cuts fresh ones against whatever
@@ -322,6 +468,18 @@ export function identityCurve() {
322
468
  ],
323
469
  };
324
470
  }
471
+ /**
472
+ * The same straight line as one Bezier segment, handles on the line at the thirds — what a new
473
+ * curve starts as, so a point clicked into it splits smooth and a drag bends it rather than kinking.
474
+ */
475
+ export function bezierIdentityCurve() {
476
+ return {
477
+ keyframes: withBezierHandles([
478
+ { anchor: { x: 0, y: 0 }, interpolation: 'bezier' },
479
+ { anchor: { x: 1, y: 1 }, interpolation: 'linear' },
480
+ ]),
481
+ };
482
+ }
325
483
  export const CURVE_PRESET_IDS = [
326
484
  'linear',
327
485
  'step',
@@ -11,6 +11,7 @@ export declare const EFFECT_SCOPES: {
11
11
  readonly blur: readonly ["scene", "layer"];
12
12
  readonly bloom: readonly ["scene", "layer"];
13
13
  readonly diffusion: readonly ["scene", "layer"];
14
+ readonly sketch: readonly ["scene", "layer"];
14
15
  readonly dof: readonly ["scene"];
15
16
  readonly chromaticAberration: readonly ["scene"];
16
17
  readonly grain: readonly ["scene"];
@@ -13,6 +13,7 @@ export const EFFECT_SCOPES = {
13
13
  blur: ['scene', 'layer'],
14
14
  bloom: ['scene', 'layer'],
15
15
  diffusion: ['scene', 'layer'],
16
+ sketch: ['scene', 'layer'],
16
17
  dof: ['scene'],
17
18
  chromaticAberration: ['scene'],
18
19
  grain: ['scene'],
@@ -165,6 +166,15 @@ export const EFFECT_SPECS = {
165
166
  radius: { default: 0.85, min: 0, max: 1, step: 0.01 },
166
167
  threshold: { default: 0.35, min: 0, max: 1, step: 0.01 },
167
168
  },
169
+ sketch: {
170
+ lines: { default: 1, min: 0, max: 2, step: 0.01 },
171
+ lineWidth: { default: 1, min: 0.5, max: 4, step: 0.1, digits: 1, unit: 'px' },
172
+ // 1 is the source's density; above it the grain packs toward solid graphite.
173
+ shading: { default: 1, min: 0, max: 2, step: 0.01 },
174
+ softness: { default: 1, min: 0, max: 1, step: 0.01 },
175
+ saturation: { default: 0, min: 0, max: 1, step: 0.01 },
176
+ opacity: { default: 1, min: 0, max: 1, step: 0.01 },
177
+ },
168
178
  dof: {
169
179
  bokehScale: { default: 2, min: 0, max: 8, step: 0.01 },
170
180
  /** World metres of acceptably-sharp depth around the focus plane. */
@@ -303,6 +313,10 @@ export const EFFECT_COLOR_SPECS = {
303
313
  outline: {
304
314
  color: { default: '#000000' },
305
315
  },
316
+ // The mean of the source demo's kraft paper photo.
317
+ sketch: {
318
+ paperColor: { default: '#b69b83' },
319
+ },
306
320
  dropShadow: {
307
321
  color: { default: '#000000' },
308
322
  },
@@ -315,6 +329,7 @@ export const EFFECT_BOOLEAN_SPECS = {
315
329
  transparentBackground: { default: false },
316
330
  },
317
331
  blur: { highQuality: { default: false } },
332
+ sketch: { paperBackground: { default: false } },
318
333
  bloom: {
319
334
  selectColors: { default: false },
320
335
  invertColors: { default: false },
@@ -435,6 +450,7 @@ export const ALL_EFFECT_STACK_ORDER = [
435
450
  'chromaticAberration',
436
451
  'bloom',
437
452
  'diffusion',
453
+ 'sketch',
438
454
  'color',
439
455
  'levels',
440
456
  'colorWheels',
@@ -0,0 +1,56 @@
1
+ import type { Hotkey, MotionGroup } from '../wire/types.ts';
2
+ /** A hotkey action Persona can run. */
3
+ type HotkeyAction = NonNullable<Hotkey['action']>;
4
+ /** What a model offers hotkeys: its expressions (a null `file` on formats with none) and its motion groups. */
5
+ export interface HotkeyListing {
6
+ expressions: readonly {
7
+ name: string;
8
+ file: string | null;
9
+ }[];
10
+ motions: readonly MotionGroup[];
11
+ }
12
+ /**
13
+ * The id an expression is stored and bound by: its exp3 basename on Live2D (VTS parity),
14
+ * its name on VRM, which declares no per-expression file. Every persisted or hotkey-bound
15
+ * reference to an expression goes through this, so the two can never disagree.
16
+ */
17
+ export declare function expressionKey(def: {
18
+ name: string;
19
+ file: string | null;
20
+ }): string;
21
+ /** Saved basenames → definition Names, in saved order; unmatched entries dropped. */
22
+ export declare function matchSavedToDefinitions(saved: readonly string[], defs: readonly {
23
+ Name: string;
24
+ File: string;
25
+ }[]): string[];
26
+ /** Expression Name for a stored expression key, or null when the model no longer declares it. */
27
+ export declare function resolveExpressionName(listing: HotkeyListing, file: string): string | null;
28
+ /** Motion group and index for a stored motion3 basename, or null when it's gone. */
29
+ export declare function resolveMotion(listing: {
30
+ motions: readonly MotionGroup[];
31
+ }, file: string): {
32
+ group: string;
33
+ index: number;
34
+ } | null;
35
+ /** `file` is a basename throughout — the form hotkeys are stored in. */
36
+ export interface HotkeyTarget {
37
+ file: string;
38
+ label: string;
39
+ }
40
+ /**
41
+ * The files an action's hotkey can point at, with display labels. Deduped by basename,
42
+ * first occurrence wins — the same rule the resolvers above apply, so the picker always
43
+ * lists exactly what a hotkey would run.
44
+ * @param groupLabel Display name for a motion group; VRM groups are clip refs, not names.
45
+ */
46
+ export declare function hotkeyTargets(listing: HotkeyListing, action: HotkeyAction, groupLabel?: (group: string) => string): HotkeyTarget[];
47
+ /**
48
+ * Whether a model-load hotkey's stored `File` names this model: its `.vtube.json` name (what VTS
49
+ * stores) or its entry file, case-insensitively — VTS's own file-name resolution. A `ModelRef`
50
+ * from an older host lists neither, and matches nothing.
51
+ */
52
+ export declare function modelMatchesFile(file: string, model: {
53
+ vtubeFile?: string | null;
54
+ entryFile?: string;
55
+ }): boolean;
56
+ export {};
@@ -0,0 +1,88 @@
1
+ import { fileBasename, motionLabel, motionSlots } from "./labels.js";
2
+ /**
3
+ * The id an expression is stored and bound by: its exp3 basename on Live2D (VTS parity),
4
+ * its name on VRM, which declares no per-expression file. Every persisted or hotkey-bound
5
+ * reference to an expression goes through this, so the two can never disagree.
6
+ */
7
+ export function expressionKey(def) {
8
+ return def.file === null ? def.name : fileBasename(def.file);
9
+ }
10
+ /** Saved basenames → definition Names, in saved order; unmatched entries dropped. */
11
+ export function matchSavedToDefinitions(saved, defs) {
12
+ const byBase = new Map();
13
+ for (const d of defs) {
14
+ const base = fileBasename(d.File);
15
+ if (!byBase.has(base))
16
+ byBase.set(base, d.Name);
17
+ }
18
+ return saved.flatMap(s => {
19
+ const name = byBase.get(fileBasename(s));
20
+ return name === undefined ? [] : [name];
21
+ });
22
+ }
23
+ // A hotkey stores the same id expression persistence does, so a VRM entry binds by name.
24
+ // A keyless entry (a model3 declaring an empty File) is dropped — nothing could bind it.
25
+ function expressionDefs(listing) {
26
+ return listing.expressions.flatMap(d => {
27
+ const key = expressionKey(d);
28
+ return key === '' ? [] : [{ Name: d.name, File: key }];
29
+ });
30
+ }
31
+ /** Expression Name for a stored expression key, or null when the model no longer declares it. */
32
+ export function resolveExpressionName(listing, file) {
33
+ return matchSavedToDefinitions([file], expressionDefs(listing))[0] ?? null;
34
+ }
35
+ /** Motion group and index for a stored motion3 basename, or null when it's gone. */
36
+ export function resolveMotion(listing, file) {
37
+ const base = fileBasename(file);
38
+ for (const { group, files } of listing.motions) {
39
+ const slot = motionSlots(files).find(s => fileBasename(s.file) === base);
40
+ if (slot)
41
+ return { group, index: slot.index };
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * The files an action's hotkey can point at, with display labels. Deduped by basename,
47
+ * first occurrence wins — the same rule the resolvers above apply, so the picker always
48
+ * lists exactly what a hotkey would run.
49
+ * @param groupLabel Display name for a motion group; VRM groups are clip refs, not names.
50
+ */
51
+ export function hotkeyTargets(listing, action, groupLabel = g => g) {
52
+ const seen = new Set();
53
+ if (action === 'expression-toggle') {
54
+ return expressionDefs(listing).flatMap(d => {
55
+ const file = fileBasename(d.File);
56
+ if (seen.has(file))
57
+ return [];
58
+ seen.add(file);
59
+ return [{ file, label: d.Name }];
60
+ });
61
+ }
62
+ if (action === 'motion-play') {
63
+ return listing.motions.flatMap(g => motionSlots(g.files).flatMap(({ file: f }) => {
64
+ const file = fileBasename(f);
65
+ if (seen.has(file))
66
+ return [];
67
+ seen.add(file);
68
+ // A VRM group is a single clip named for its own file, so the ` · file` half
69
+ // would just repeat the group.
70
+ const label = g.group === f ? groupLabel(g.group) : `${groupLabel(g.group)} · ${motionLabel(f)}`;
71
+ return [{ file, label }];
72
+ }));
73
+ }
74
+ return [];
75
+ }
76
+ /**
77
+ * Whether a model-load hotkey's stored `File` names this model: its `.vtube.json` name (what VTS
78
+ * stores) or its entry file, case-insensitively — VTS's own file-name resolution. A `ModelRef`
79
+ * from an older host lists neither, and matches nothing.
80
+ */
81
+ export function modelMatchesFile(file, model) {
82
+ const base = fileBasename(file).toLowerCase();
83
+ if (base === '')
84
+ return false;
85
+ if (model.vtubeFile?.toLowerCase() === base)
86
+ return true;
87
+ return model.entryFile !== undefined && fileBasename(model.entryFile).toLowerCase() === base;
88
+ }
@@ -1,4 +1,4 @@
1
- import type { AutomationBoundaryKind, AutomationInfo } from '../wire/types.ts';
1
+ import type { AutomationInfo, AutomationSetupKind } from '../wire/types.ts';
2
2
  /** Last path segment, tolerating both `/` and `\` separators. */
3
3
  export declare function fileBasename(path: string): string;
4
4
  /**
@@ -20,7 +20,9 @@ export declare function motionSlots(files: readonly string[]): {
20
20
  /** English label for one automation action kind; kinds newer than this SDK read `Action`. */
21
21
  export declare function automationActionLabel(kind: string): string;
22
22
  /** True for an action that prepares the scene before playback. */
23
- export declare function isAutomationBoundary(kind: string | undefined): kind is AutomationBoundaryKind;
23
+ export declare function isAutomationSetup(kind: string | undefined): kind is AutomationSetupKind;
24
+ /** @deprecated Use `isAutomationSetup`. */
25
+ export declare const isAutomationBoundary: typeof isAutomationSetup;
24
26
  /** Join action labels without implying timing that the client summary does not carry. */
25
27
  export declare function joinAutomationActionLabels(labels: readonly string[], kinds: readonly string[]): string;
26
28
  /** Display label for an app automation: its title, else its joined action-kind labels. */