@doki-land/live2d 0.0.10 → 0.0.11

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.
@@ -0,0 +1,389 @@
1
+ import { evaluateMotion3 } from "./evaluate-curve.js";
2
+ import type {
3
+ Motion3Clip,
4
+ MotionApplySample,
5
+ MotionPriorityLevel,
6
+ PlayMotionOptions,
7
+ } from "./types.js";
8
+ import { MotionPriority } from "./types.js";
9
+
10
+ export interface MotionPlayerHandlers {
11
+ onStart?: (info: { group: string; index: number; slot: string }) => void;
12
+ onFinish?: (info: { group: string; index: number; slot: string }) => void;
13
+ onEvent?: (info: {
14
+ group: string;
15
+ index: number;
16
+ slot: string;
17
+ time: number;
18
+ value: string;
19
+ }) => void;
20
+ }
21
+
22
+ interface ActiveMotion {
23
+ slot: string;
24
+ group: string;
25
+ index: number;
26
+ clip: Motion3Clip;
27
+ priority: MotionPriorityLevel;
28
+ loop: boolean;
29
+ fadeInTime: number;
30
+ fadeOutTime: number;
31
+ time: number;
32
+ fadingOut: boolean;
33
+ fadeOutElapsed: number;
34
+ lastEventIndex: number;
35
+ started: boolean;
36
+ }
37
+
38
+ interface QueuedMotion {
39
+ group: string;
40
+ index: number;
41
+ clip: Motion3Clip;
42
+ options: PlayMotionOptions;
43
+ }
44
+
45
+ /**
46
+ * Multi-slot motion player with per-slot queue.
47
+ *
48
+ * Default slot is `priority:{n}` so idle (1) and normal (2) can run together.
49
+ * Same slot replaces (with fade-out) unless `queue: true`.
50
+ */
51
+ export class MotionPlayer {
52
+ #slots = new Map<string, ActiveMotion>();
53
+ #queues = new Map<string, QueuedMotion[]>();
54
+ #handlers: MotionPlayerHandlers;
55
+
56
+ constructor(handlers: MotionPlayerHandlers = {}) {
57
+ this.#handlers = handlers;
58
+ }
59
+
60
+ get isPlaying(): boolean {
61
+ return this.#slots.size > 0;
62
+ }
63
+
64
+ listPlaying(): ReadonlyArray<{
65
+ slot: string;
66
+ group: string;
67
+ index: number;
68
+ time: number;
69
+ priority: MotionPriorityLevel;
70
+ }> {
71
+ return [...this.#slots.values()].map((a) => ({
72
+ slot: a.slot,
73
+ group: a.group,
74
+ index: a.index,
75
+ time: a.time,
76
+ priority: a.priority,
77
+ }));
78
+ }
79
+
80
+ /** @deprecated Prefer {@link listPlaying}; returns highest-priority slot. */
81
+ get current(): {
82
+ group: string;
83
+ index: number;
84
+ time: number;
85
+ priority: MotionPriorityLevel;
86
+ } | null {
87
+ const list = [...this.listPlaying()];
88
+ if (!list.length) return null;
89
+ list.sort((a, b) => b.priority - a.priority);
90
+ const top = list[0]!;
91
+ return {
92
+ group: top.group,
93
+ index: top.index,
94
+ time: top.time,
95
+ priority: top.priority,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Start a clip on a slot. Returns false if rejected by priority
101
+ * (and not queued).
102
+ */
103
+ start(
104
+ group: string,
105
+ index: number,
106
+ clip: Motion3Clip,
107
+ options: PlayMotionOptions = {},
108
+ ): boolean {
109
+ const priority = options.priority ?? MotionPriority.normal;
110
+ const slot = options.slot ?? `priority:${priority}`;
111
+ const existing = this.#slots.get(slot);
112
+
113
+ if (existing && priority < existing.priority) {
114
+ if (options.queue) {
115
+ this.#enqueue(slot, { group, index, clip, options });
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ if (existing && options.queue && !existing.fadingOut) {
122
+ this.#enqueue(slot, { group, index, clip, options });
123
+ return true;
124
+ }
125
+
126
+ if (existing) {
127
+ // Replace: fade out current, then start — or hard-swap if no fade.
128
+ if (existing.fadeOutTime > 0 && !existing.fadingOut) {
129
+ existing.fadingOut = true;
130
+ existing.fadeOutElapsed = 0;
131
+ this.#enqueueFront(slot, { group, index, clip, options });
132
+ return true;
133
+ }
134
+ this.#finish(existing, false);
135
+ }
136
+
137
+ this.#slots.set(
138
+ slot,
139
+ this.#createActive(slot, group, index, clip, options),
140
+ );
141
+ return true;
142
+ }
143
+
144
+ /** Fade out (default) or hard-stop; `slot` omits → all slots. */
145
+ stop(fade = true, slot?: string): void {
146
+ if (slot !== undefined) {
147
+ const a = this.#slots.get(slot);
148
+ if (!a) return;
149
+ this.#stopOne(a, fade);
150
+ return;
151
+ }
152
+ for (const a of [...this.#slots.values()]) {
153
+ this.#stopOne(a, fade);
154
+ }
155
+ }
156
+
157
+ clear(): void {
158
+ this.#slots.clear();
159
+ this.#queues.clear();
160
+ }
161
+
162
+ /**
163
+ * Advance all slots and return blended samples (weight baked in; apply as absolute).
164
+ */
165
+ update(deltaTimeSeconds: number): MotionApplySample[] {
166
+ const dt = Math.max(0, deltaTimeSeconds);
167
+ const layerSamples: Array<{
168
+ priority: number;
169
+ samples: MotionApplySample[];
170
+ }> = [];
171
+
172
+ for (const a of [...this.#slots.values()]) {
173
+ const samples = this.#tick(a, dt);
174
+ if (samples) {
175
+ layerSamples.push({ priority: a.priority, samples });
176
+ }
177
+ }
178
+
179
+ layerSamples.sort((a, b) => a.priority - b.priority);
180
+ return blendMotionLayers(layerSamples);
181
+ }
182
+
183
+ #tick(a: ActiveMotion, dt: number): MotionApplySample[] | null {
184
+ if (!a.started) {
185
+ a.started = true;
186
+ this.#handlers.onStart?.({
187
+ group: a.group,
188
+ index: a.index,
189
+ slot: a.slot,
190
+ });
191
+ }
192
+
193
+ a.time += dt;
194
+
195
+ if (a.fadingOut) {
196
+ a.fadeOutElapsed += dt;
197
+ if (a.fadeOutElapsed >= a.fadeOutTime) {
198
+ this.#finish(a, true);
199
+ return null;
200
+ }
201
+ } else if (!a.loop && a.time >= a.clip.duration) {
202
+ if (a.fadeOutTime > 0) {
203
+ a.fadingOut = true;
204
+ a.fadeOutElapsed = 0;
205
+ } else {
206
+ const samples = this.#sample(a, a.clip.duration, 1);
207
+ this.#finish(a, true);
208
+ return samples;
209
+ }
210
+ }
211
+
212
+ let playTime = a.time;
213
+ if (a.loop && a.clip.duration > 0) {
214
+ playTime = a.time % a.clip.duration;
215
+ } else {
216
+ playTime = Math.min(playTime, a.clip.duration);
217
+ }
218
+
219
+ this.#emitEvents(a, playTime);
220
+ return this.#sample(a, playTime, this.#fadeWeight(a));
221
+ }
222
+
223
+ #createActive(
224
+ slot: string,
225
+ group: string,
226
+ index: number,
227
+ clip: Motion3Clip,
228
+ options: PlayMotionOptions,
229
+ ): ActiveMotion {
230
+ const fadeIn =
231
+ options.fadeInTime ?? (clip.fadeInTime > 0 ? clip.fadeInTime : 0);
232
+ const fadeOut =
233
+ options.fadeOutTime ??
234
+ (clip.fadeOutTime > 0 ? clip.fadeOutTime : 0);
235
+ return {
236
+ slot,
237
+ group,
238
+ index,
239
+ clip,
240
+ priority: options.priority ?? MotionPriority.normal,
241
+ loop: options.loop ?? clip.loop,
242
+ fadeInTime: Math.max(0, fadeIn),
243
+ fadeOutTime: Math.max(0, fadeOut),
244
+ time: 0,
245
+ fadingOut: false,
246
+ fadeOutElapsed: 0,
247
+ lastEventIndex: -1,
248
+ started: false,
249
+ };
250
+ }
251
+
252
+ #enqueue(slot: string, item: QueuedMotion): void {
253
+ const q = this.#queues.get(slot) ?? [];
254
+ q.push(item);
255
+ this.#queues.set(slot, q);
256
+ }
257
+
258
+ #enqueueFront(slot: string, item: QueuedMotion): void {
259
+ const q = this.#queues.get(slot) ?? [];
260
+ q.unshift(item);
261
+ this.#queues.set(slot, q);
262
+ }
263
+
264
+ #stopOne(a: ActiveMotion, fade: boolean): void {
265
+ if (!fade || a.fadeOutTime <= 0) {
266
+ this.#finish(a, true);
267
+ return;
268
+ }
269
+ a.fadingOut = true;
270
+ a.fadeOutElapsed = 0;
271
+ }
272
+
273
+ #finish(a: ActiveMotion, promoteQueue: boolean): void {
274
+ if (this.#slots.get(a.slot) !== a) return;
275
+ this.#slots.delete(a.slot);
276
+ this.#handlers.onFinish?.({
277
+ group: a.group,
278
+ index: a.index,
279
+ slot: a.slot,
280
+ });
281
+ if (!promoteQueue) return;
282
+ const q = this.#queues.get(a.slot);
283
+ const next = q?.shift();
284
+ if (next) {
285
+ this.#slots.set(
286
+ a.slot,
287
+ this.#createActive(
288
+ a.slot,
289
+ next.group,
290
+ next.index,
291
+ next.clip,
292
+ next.options,
293
+ ),
294
+ );
295
+ }
296
+ }
297
+
298
+ #sample(
299
+ a: ActiveMotion,
300
+ playTime: number,
301
+ weight: number,
302
+ ): MotionApplySample[] {
303
+ const values = evaluateMotion3(a.clip, playTime);
304
+ return values.map((v) => ({
305
+ target: v.target,
306
+ id: v.id,
307
+ value: v.value,
308
+ weight,
309
+ }));
310
+ }
311
+
312
+ #fadeWeight(a: ActiveMotion): number {
313
+ let w = 1;
314
+ if (a.fadeInTime > 0 && a.time < a.fadeInTime) {
315
+ w = sineEase(a.time / a.fadeInTime);
316
+ }
317
+ if (a.fadingOut && a.fadeOutTime > 0) {
318
+ const u = 1 - a.fadeOutElapsed / a.fadeOutTime;
319
+ w *= sineEase(Math.max(0, u));
320
+ }
321
+ return w;
322
+ }
323
+
324
+ #emitEvents(a: ActiveMotion, playTime: number): void {
325
+ const events = a.clip.userData;
326
+ for (let i = a.lastEventIndex + 1; i < events.length; i += 1) {
327
+ const e = events[i]!;
328
+ if (e.time > playTime) break;
329
+ a.lastEventIndex = i;
330
+ this.#handlers.onEvent?.({
331
+ group: a.group,
332
+ index: a.index,
333
+ slot: a.slot,
334
+ time: e.time,
335
+ value: e.value,
336
+ });
337
+ }
338
+ if (a.loop && a.clip.duration > 0) {
339
+ const prevMod =
340
+ ((a.time - 1e-6) % a.clip.duration) +
341
+ (a.time - 1e-6 < 0 ? a.clip.duration : 0);
342
+ if (playTime < prevMod - 1e-4) {
343
+ a.lastEventIndex = -1;
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ /** Blend layers low→high priority; later layers lerp over earlier by their weight. */
350
+ export function blendMotionLayers(
351
+ layers: ReadonlyArray<{
352
+ priority: number;
353
+ samples: readonly MotionApplySample[];
354
+ }>,
355
+ ): MotionApplySample[] {
356
+ const map = new Map<
357
+ string,
358
+ {
359
+ target: MotionApplySample["target"];
360
+ id: string;
361
+ value: number;
362
+ weight: number;
363
+ }
364
+ >();
365
+ for (const layer of layers) {
366
+ for (const s of layer.samples) {
367
+ const key = `${s.target}\0${s.id}`;
368
+ const w = Math.min(1, Math.max(0, s.weight));
369
+ const prev = map.get(key);
370
+ if (!prev) {
371
+ map.set(key, {
372
+ target: s.target,
373
+ id: s.id,
374
+ value: s.value,
375
+ weight: w,
376
+ });
377
+ } else {
378
+ prev.value = prev.value + (s.value - prev.value) * w;
379
+ prev.weight = Math.min(1, prev.weight + w * (1 - prev.weight));
380
+ }
381
+ }
382
+ }
383
+ return [...map.values()];
384
+ }
385
+
386
+ function sineEase(t: number): number {
387
+ const x = Math.min(1, Math.max(0, t));
388
+ return 0.5 - 0.5 * Math.cos(x * Math.PI);
389
+ }
@@ -0,0 +1,164 @@
1
+ import type {
2
+ Motion3Clip,
3
+ MotionCurve,
4
+ MotionCurveTarget,
5
+ MotionPoint,
6
+ MotionSegment,
7
+ MotionSegmentKind,
8
+ MotionUserData,
9
+ } from "./types.js";
10
+
11
+ const SEGMENT_KIND: Record<number, MotionSegmentKind> = {
12
+ 0: "linear",
13
+ 1: "bezier",
14
+ 2: "stepped",
15
+ 3: "inverseStepped",
16
+ };
17
+
18
+ /**
19
+ * Parse Cubism `motion3.json` (FileFormats/motion3.json.md).
20
+ */
21
+ export function parseMotion3(json: unknown): Motion3Clip {
22
+ if (!json || typeof json !== "object") {
23
+ throw new Error(
24
+ "@doki-land/live2d: motion3.json root must be an object",
25
+ );
26
+ }
27
+ const root = json as Record<string, unknown>;
28
+ const version = Number(root.Version ?? 3);
29
+ const meta = root.Meta;
30
+ if (!meta || typeof meta !== "object") {
31
+ throw new Error("@doki-land/live2d: motion3.json missing Meta");
32
+ }
33
+ const m = meta as Record<string, unknown>;
34
+ const duration = num(m.Duration, "Meta.Duration");
35
+ const fps = num(m.Fps, "Meta.Fps");
36
+ const loop = m.Loop === true;
37
+ const areBeziersRestricted = m.AreBeziersRestricted !== false;
38
+ const fadeInTime = optionalNum(m.FadeInTime) ?? 0;
39
+ const fadeOutTime = optionalNum(m.FadeOutTime) ?? 0;
40
+
41
+ const curvesRaw = root.Curves;
42
+ if (!Array.isArray(curvesRaw)) {
43
+ throw new Error("@doki-land/live2d: motion3.json missing Curves");
44
+ }
45
+ const curves = curvesRaw.map((c, i) => parseCurve(c, i));
46
+
47
+ const userData: MotionUserData[] = [];
48
+ if (Array.isArray(root.UserData)) {
49
+ for (const item of root.UserData) {
50
+ if (!item || typeof item !== "object") continue;
51
+ const u = item as Record<string, unknown>;
52
+ if (typeof u.Time === "number" && typeof u.Value === "string") {
53
+ userData.push({ time: u.Time, value: u.Value });
54
+ }
55
+ }
56
+ userData.sort((a, b) => a.time - b.time);
57
+ }
58
+
59
+ return {
60
+ version,
61
+ duration,
62
+ fps,
63
+ loop,
64
+ areBeziersRestricted,
65
+ fadeInTime,
66
+ fadeOutTime,
67
+ curves,
68
+ userData,
69
+ };
70
+ }
71
+
72
+ function parseCurve(raw: unknown, index: number): MotionCurve {
73
+ if (!raw || typeof raw !== "object") {
74
+ throw new Error(`@doki-land/live2d: Curves[${index}] invalid`);
75
+ }
76
+ const c = raw as Record<string, unknown>;
77
+ const target = c.Target;
78
+ const id = c.Id;
79
+ if (typeof target !== "string" || typeof id !== "string") {
80
+ throw new Error(`@doki-land/live2d: Curves[${index}] needs Target/Id`);
81
+ }
82
+ if (
83
+ target !== "Parameter" &&
84
+ target !== "PartOpacity" &&
85
+ target !== "Model"
86
+ ) {
87
+ throw new Error(
88
+ `@doki-land/live2d: Curves[${index}] unknown Target ${target}`,
89
+ );
90
+ }
91
+ const segmentsFlat = c.Segments;
92
+ if (!Array.isArray(segmentsFlat) || segmentsFlat.length < 2) {
93
+ throw new Error(`@doki-land/live2d: Curves[${index}] empty Segments`);
94
+ }
95
+ const numbers = segmentsFlat.map((n, j) => {
96
+ if (typeof n !== "number" || !Number.isFinite(n)) {
97
+ throw new Error(
98
+ `@doki-land/live2d: Curves[${index}].Segments[${j}] not a number`,
99
+ );
100
+ }
101
+ return n;
102
+ });
103
+
104
+ return {
105
+ target: target as MotionCurveTarget,
106
+ id,
107
+ fadeInTime: optionalNum(c.FadeInTime),
108
+ fadeOutTime: optionalNum(c.FadeOutTime),
109
+ segments: parseSegments(numbers, index),
110
+ };
111
+ }
112
+
113
+ function parseSegments(
114
+ flat: readonly number[],
115
+ curveIndex: number,
116
+ ): MotionSegment[] {
117
+ let i = 0;
118
+ const p0: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
119
+ const out: MotionSegment[] = [];
120
+ let prev = p0;
121
+
122
+ while (i < flat.length) {
123
+ const kindId = flat[i++]!;
124
+ const kind = SEGMENT_KIND[kindId];
125
+ if (!kind) {
126
+ throw new Error(
127
+ `@doki-land/live2d: Curves[${curveIndex}] unknown segment ${kindId}`,
128
+ );
129
+ }
130
+ if (kind === "bezier") {
131
+ if (i + 5 >= flat.length) {
132
+ throw new Error(
133
+ `@doki-land/live2d: Curves[${curveIndex}] truncated bezier`,
134
+ );
135
+ }
136
+ const p1: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
137
+ const p2: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
138
+ const p3: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
139
+ out.push({ kind, p0: prev, p1, p2, p3 });
140
+ prev = p3;
141
+ } else {
142
+ if (i + 1 >= flat.length) {
143
+ throw new Error(
144
+ `@doki-land/live2d: Curves[${curveIndex}] truncated ${kind}`,
145
+ );
146
+ }
147
+ const p3: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
148
+ out.push({ kind, p0: prev, p3 });
149
+ prev = p3;
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function num(v: unknown, label: string): number {
156
+ if (typeof v !== "number" || !Number.isFinite(v)) {
157
+ throw new Error(`@doki-land/live2d: motion3 ${label} must be a number`);
158
+ }
159
+ return v;
160
+ }
161
+
162
+ function optionalNum(v: unknown): number | undefined {
163
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
164
+ }
@@ -0,0 +1,89 @@
1
+ /** Cubism motion3 segment kinds (spec). */
2
+ export type MotionSegmentKind =
3
+ | "linear"
4
+ | "bezier"
5
+ | "stepped"
6
+ | "inverseStepped";
7
+
8
+ export type MotionCurveTarget = "Parameter" | "PartOpacity" | "Model";
9
+
10
+ export interface MotionPoint {
11
+ readonly time: number;
12
+ readonly value: number;
13
+ }
14
+
15
+ export interface MotionSegment {
16
+ readonly kind: MotionSegmentKind;
17
+ /** Segment start (inclusive). */
18
+ readonly p0: MotionPoint;
19
+ /** Bezier controls (bezier only). */
20
+ readonly p1?: MotionPoint;
21
+ readonly p2?: MotionPoint;
22
+ /** Segment end. */
23
+ readonly p3: MotionPoint;
24
+ }
25
+
26
+ export interface MotionCurve {
27
+ readonly target: MotionCurveTarget;
28
+ readonly id: string;
29
+ readonly fadeInTime?: number;
30
+ readonly fadeOutTime?: number;
31
+ readonly segments: readonly MotionSegment[];
32
+ }
33
+
34
+ export interface MotionUserData {
35
+ readonly time: number;
36
+ readonly value: string;
37
+ }
38
+
39
+ export interface Motion3Clip {
40
+ readonly version: number;
41
+ readonly duration: number;
42
+ readonly fps: number;
43
+ readonly loop: boolean;
44
+ readonly areBeziersRestricted: boolean;
45
+ readonly fadeInTime: number;
46
+ readonly fadeOutTime: number;
47
+ readonly curves: readonly MotionCurve[];
48
+ readonly userData: readonly MotionUserData[];
49
+ }
50
+
51
+ /** Cubism-style priority: higher wins; equal may replace. */
52
+ export const MotionPriority = {
53
+ none: 0,
54
+ idle: 1,
55
+ normal: 2,
56
+ force: 3,
57
+ } as const;
58
+
59
+ export type MotionPriorityLevel =
60
+ (typeof MotionPriority)[keyof typeof MotionPriority];
61
+
62
+ export interface PlayMotionOptions {
63
+ /** Default {@link MotionPriority.normal}. */
64
+ priority?: MotionPriorityLevel;
65
+ /**
66
+ * Parallel layer id. Default `priority:{n}` so different priorities
67
+ * can play together; same slot replaces or queues.
68
+ */
69
+ slot?: string;
70
+ /**
71
+ * When the slot is busy, enqueue instead of replacing / rejecting.
72
+ * Default false.
73
+ */
74
+ queue?: boolean;
75
+ /** Override clip Meta.Loop. */
76
+ loop?: boolean;
77
+ /** Override fade-in seconds (clip / definition). */
78
+ fadeInTime?: number;
79
+ /** Override fade-out seconds. */
80
+ fadeOutTime?: number;
81
+ }
82
+
83
+ export interface MotionApplySample {
84
+ readonly target: MotionCurveTarget;
85
+ readonly id: string;
86
+ readonly value: number;
87
+ /** 0..1 fade weight for this frame. */
88
+ readonly weight: number;
89
+ }
@@ -0,0 +1 @@
1
+ export * from "@doki-land/live2d-core";
@@ -0,0 +1 @@
1
+ export * from "@doki-land/live2d-loader";
@@ -0,0 +1 @@
1
+ export * from "@doki-land/live2d-renderer";