@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,591 @@
1
+ import type {
2
+ AssetResolver,
3
+ FrameSnapshot,
4
+ InternalModel,
5
+ Live2DSession,
6
+ LoadProgress,
7
+ ModelSource,
8
+ MotionDefinition,
9
+ SessionPhase,
10
+ SessionState,
11
+ } from "@doki-land/live2d-core";
12
+ import { EventEmitter, modelSourceUrl } from "@doki-land/live2d-core";
13
+ import {
14
+ createUrlAssetResolver,
15
+ fetchModelJson,
16
+ normalizeModelSettings,
17
+ resolveModelSourceUrl,
18
+ } from "@doki-land/live2d-loader";
19
+ import type {
20
+ ModelBackend,
21
+ ParameterBinding,
22
+ Renderer,
23
+ RendererKind,
24
+ TextureData,
25
+ } from "@doki-land/live2d-renderer";
26
+ import {
27
+ createMoc2Backend,
28
+ createMoc3Backend,
29
+ createRenderer,
30
+ selectModelBackend,
31
+ } from "@doki-land/live2d-renderer";
32
+ import { loadTextureData, releaseTextureData } from "./load-textures.js";
33
+ import {
34
+ type Motion3Clip,
35
+ MotionPlayer,
36
+ MotionPriority,
37
+ type PlayMotionOptions,
38
+ parseMotion3,
39
+ } from "./motion/index.js";
40
+
41
+ export type { PlayMotionOptions } from "./motion/index.js";
42
+ export { MotionPriority } from "./motion/index.js";
43
+
44
+ export interface CreateLive2DOptions {
45
+ backends?: ModelBackend[];
46
+ /** Defaults to `createRenderer({ prefer })` (async fallback on initialize). */
47
+ renderer?: Renderer;
48
+ /**
49
+ * Renderer try order when `renderer` is omitted.
50
+ * Default: `["webgpu", "webgl2", "canvas2d"]`.
51
+ */
52
+ prefer?: RendererKind[];
53
+ }
54
+
55
+ export interface Live2DRuntime extends Live2DSession {
56
+ readonly renderer: Renderer;
57
+ readonly backends: readonly ModelBackend[];
58
+
59
+ setParameter(id: string, value: number): void;
60
+
61
+ listParameters(): readonly ParameterBinding[];
62
+
63
+ /** Hit-test the current model in normalized canvas coordinates (-1..1). */
64
+ hitTest(x: number, y: number): string | null;
65
+
66
+ /** Motion groups from the loaded model settings. */
67
+ listMotionGroups(): Record<string, readonly MotionDefinition[]>;
68
+
69
+ /**
70
+ * Load and play a motion from `settings.motionGroups[group][index]`.
71
+ * Returns false if priority rejects or the entry is missing.
72
+ */
73
+ playMotion(
74
+ group: string,
75
+ index?: number,
76
+ options?: PlayMotionOptions,
77
+ ): Promise<boolean>;
78
+
79
+ /** Fade out (default) or hard-stop. Optional slot; omit = all slots. */
80
+ stopMotion(opts?: { fade?: boolean; slot?: string }): void;
81
+
82
+ /** Active motion slots (idle + tap can both appear). */
83
+ listPlayingMotions(): ReadonlyArray<{
84
+ slot: string;
85
+ group: string;
86
+ index: number;
87
+ time: number;
88
+ priority: number;
89
+ }>;
90
+
91
+ /**
92
+ * Draw one frame and encode the canvas as PNG.
93
+ * Works for Canvas2D and WebGL2 (`preserveDrawingBuffer`).
94
+ */
95
+ capturePng(opts?: {
96
+ mimeType?: "image/png";
97
+ quality?: number;
98
+ }): Promise<Blob>;
99
+ }
100
+
101
+ function lerp(a: number, b: number, t: number): number {
102
+ return a + (b - a) * Math.min(1, Math.max(0, t));
103
+ }
104
+
105
+ function nowMs(): number {
106
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
107
+ }
108
+
109
+ /** Wire moc backends and a renderer into one session. */
110
+ export function createLive2D(options: CreateLive2DOptions = {}): Live2DRuntime {
111
+ const backends = options.backends ?? [
112
+ createMoc2Backend(),
113
+ createMoc3Backend(),
114
+ ];
115
+ const renderer =
116
+ options.renderer ?? createRenderer({ prefer: options.prefer });
117
+ const events = new EventEmitter();
118
+
119
+ let canvas: HTMLCanvasElement | null = null;
120
+ let model: InternalModel | null = null;
121
+ let activeBackend: ModelBackend | null = null;
122
+ let drawPass: ReturnType<Renderer["createModelDrawPass"]> | null = null;
123
+ let loadedTextures: TextureData[] = [];
124
+ let initPromise: Promise<void> | null = null;
125
+ let phase: SessionPhase = "idle";
126
+ let lastError: unknown | null = null;
127
+ let generation = 0;
128
+ let loadGeneration = 0;
129
+ let fpsSmooth = 0;
130
+ let activeResolver: AssetResolver | null = null;
131
+ const motionCache = new Map<string, Motion3Clip>();
132
+ const motionPlayer = new MotionPlayer({
133
+ onStart: ({ group, index, slot }) =>
134
+ events.emit("motion:start", { group, index, slot }),
135
+ onFinish: ({ group, index, slot }) =>
136
+ events.emit("motion:finish", { group, index, slot }),
137
+ });
138
+
139
+ const applyMotionSamples = (
140
+ samples: ReturnType<MotionPlayer["update"]>,
141
+ ): void => {
142
+ if (!model || !activeBackend) return;
143
+ for (const s of samples) {
144
+ if (s.weight <= 0) continue;
145
+ if (s.target === "PartOpacity") {
146
+ if (!activeBackend.setPartOpacity) continue;
147
+ if (s.weight >= 1) {
148
+ activeBackend.setPartOpacity(model, s.id, s.value);
149
+ } else {
150
+ // Soft blend toward motion opacity from full visibility.
151
+ const cur = 1;
152
+ activeBackend.setPartOpacity(
153
+ model,
154
+ s.id,
155
+ cur + (s.value - cur) * s.weight,
156
+ );
157
+ }
158
+ continue;
159
+ }
160
+ if (s.target !== "Parameter" || !activeBackend.setParameter)
161
+ continue;
162
+ if (s.weight >= 1) {
163
+ activeBackend.setParameter(model, s.id, s.value);
164
+ continue;
165
+ }
166
+ const cur =
167
+ activeBackend.listParameters?.(model).find((p) => p.id === s.id)
168
+ ?.value ?? s.value;
169
+ activeBackend.setParameter(
170
+ model,
171
+ s.id,
172
+ cur + (s.value - cur) * s.weight,
173
+ );
174
+ }
175
+ };
176
+
177
+ const clearTextures = () => {
178
+ if (loadedTextures.length > 0) {
179
+ releaseTextureData(loadedTextures);
180
+ loadedTextures = [];
181
+ }
182
+ drawPass?.setTextures([]);
183
+ };
184
+
185
+ const setPhase = (next: SessionPhase) => {
186
+ phase = next;
187
+ events.emit("phase", { phase, generation });
188
+ };
189
+
190
+ const report = (payload: LoadProgress) => {
191
+ events.emit("progress", payload);
192
+ };
193
+
194
+ const state = (): SessionState => ({
195
+ phase,
196
+ lastError,
197
+ generation,
198
+ });
199
+
200
+ const ensureInitialized = async (): Promise<void> => {
201
+ if (!canvas) {
202
+ throw new Error(
203
+ "@doki-land/live2d: call mount(canvas) before loadModel",
204
+ );
205
+ }
206
+ if (!initPromise) {
207
+ setPhase("mounting");
208
+ const gen = generation;
209
+ initPromise = renderer
210
+ .initialize(canvas)
211
+ .then(() => {
212
+ if (gen !== generation) return;
213
+ drawPass = renderer.createModelDrawPass();
214
+ setPhase("ready");
215
+ })
216
+ .catch((err) => {
217
+ lastError = err;
218
+ setPhase("error");
219
+ events.emit("error", { error: err });
220
+ throw err;
221
+ });
222
+ }
223
+ await initPromise;
224
+ };
225
+
226
+ const runtime: Live2DRuntime = {
227
+ events,
228
+ backends,
229
+ renderer,
230
+ get model() {
231
+ return model;
232
+ },
233
+ get state() {
234
+ return state();
235
+ },
236
+ mount(target) {
237
+ generation += 1;
238
+ canvas = target;
239
+ initPromise = null;
240
+ clearTextures();
241
+ drawPass?.destroy();
242
+ drawPass = null;
243
+ setPhase("idle");
244
+ void ensureInitialized();
245
+ },
246
+ async loadModel(source: ModelSource, resolver?: AssetResolver) {
247
+ const gen = ++loadGeneration;
248
+ setPhase("loading");
249
+ report({
250
+ stage: "mounting",
251
+ progress: 0.01,
252
+ detail: "initialize renderer",
253
+ });
254
+ await ensureInitialized();
255
+ if (gen !== loadGeneration) {
256
+ throw new Error("@doki-land/live2d: load cancelled");
257
+ }
258
+ report({
259
+ stage: "resolve",
260
+ progress: 0.02,
261
+ detail: "resolve source",
262
+ });
263
+ try {
264
+ let json: unknown;
265
+ let baseUrl: string;
266
+ let settingsUrl: string;
267
+
268
+ if (typeof source === "object" && source.kind === "json") {
269
+ json = source.json;
270
+ baseUrl = source.baseUrl;
271
+ settingsUrl = source.baseUrl;
272
+ report({
273
+ stage: "settings",
274
+ progress: 0.2,
275
+ detail: "inline settings",
276
+ });
277
+ } else {
278
+ const raw =
279
+ typeof source === "string"
280
+ ? source
281
+ : source.kind === "npm"
282
+ ? modelSourceUrl(source)
283
+ : source.url;
284
+ const cdnBase =
285
+ typeof source === "object" && source.kind === "npm"
286
+ ? source.cdnBase
287
+ : undefined;
288
+ const fetchUrl = resolveModelSourceUrl(raw, {
289
+ npmCdnBase: cdnBase,
290
+ });
291
+ report({
292
+ stage: "settings",
293
+ progress: 0.05,
294
+ detail: fetchUrl,
295
+ });
296
+ json = await fetchModelJson(fetchUrl, (u) => {
297
+ const ratio =
298
+ u.bytesTotal && u.bytesTotal > 0
299
+ ? u.bytesLoaded / u.bytesTotal
300
+ : 0;
301
+ report({
302
+ stage: "settings",
303
+ progress: lerp(0.05, 0.22, ratio),
304
+ detail: fetchUrl,
305
+ bytesLoaded: u.bytesLoaded,
306
+ bytesTotal: u.bytesTotal,
307
+ });
308
+ });
309
+ baseUrl = fetchUrl;
310
+ settingsUrl = fetchUrl;
311
+ }
312
+ if (gen !== loadGeneration) {
313
+ throw new Error("@doki-land/live2d: load cancelled");
314
+ }
315
+
316
+ const settings = normalizeModelSettings(json, settingsUrl);
317
+ report({
318
+ stage: "moc",
319
+ progress: 0.25,
320
+ detail: settings.moc,
321
+ });
322
+
323
+ const assetResolver =
324
+ resolver ??
325
+ createUrlAssetResolver(baseUrl, {
326
+ onBytesProgress: (key, u) => {
327
+ const isMoc = key === settings.moc;
328
+ const ratio =
329
+ u.bytesTotal && u.bytesTotal > 0
330
+ ? u.bytesLoaded / u.bytesTotal
331
+ : 0;
332
+ if (isMoc) {
333
+ report({
334
+ stage: "moc",
335
+ progress: lerp(0.25, 0.8, ratio),
336
+ detail: key,
337
+ bytesLoaded: u.bytesLoaded,
338
+ bytesTotal: u.bytesTotal,
339
+ });
340
+ } else {
341
+ report({
342
+ stage: "textures",
343
+ progress: lerp(0.8, 0.9, ratio),
344
+ detail: key,
345
+ bytesLoaded: u.bytesLoaded,
346
+ bytesTotal: u.bytesTotal,
347
+ });
348
+ }
349
+ },
350
+ });
351
+ activeResolver = assetResolver;
352
+ motionPlayer.clear();
353
+ motionCache.clear();
354
+
355
+ const backend = selectModelBackend(backends, json);
356
+ report({
357
+ stage: "decode",
358
+ progress: 0.85,
359
+ detail: `decode ${settings.format}`,
360
+ });
361
+ const next = await backend.createModel(settings, {
362
+ renderer,
363
+ resolver: assetResolver,
364
+ });
365
+ if (gen !== loadGeneration) {
366
+ backend.destroyModel(next);
367
+ throw new Error("@doki-land/live2d: load cancelled");
368
+ }
369
+
370
+ clearTextures();
371
+ if (settings.textures.length > 0 && drawPass) {
372
+ report({
373
+ stage: "textures",
374
+ progress: 0.88,
375
+ detail: `${settings.textures.length} textures`,
376
+ });
377
+ const textures = await loadTextureData(
378
+ assetResolver,
379
+ settings.textures,
380
+ {
381
+ onProgress: (u) => {
382
+ const ratio =
383
+ u.total > 0 ? (u.index + 1) / u.total : 1;
384
+ report({
385
+ stage: "textures",
386
+ progress: lerp(0.88, 0.96, ratio),
387
+ detail: u.key,
388
+ bytesLoaded: u.bytesLoaded,
389
+ bytesTotal: u.bytesTotal,
390
+ });
391
+ },
392
+ },
393
+ );
394
+ if (gen !== loadGeneration) {
395
+ releaseTextureData(textures);
396
+ backend.destroyModel(next);
397
+ throw new Error("@doki-land/live2d: load cancelled");
398
+ }
399
+ loadedTextures = textures;
400
+ drawPass.setTextures(textures);
401
+ }
402
+
403
+ if (model && activeBackend) {
404
+ activeBackend.destroyModel(model);
405
+ }
406
+ model = next;
407
+ activeBackend = backend;
408
+ lastError = null;
409
+ setPhase("live");
410
+ report({
411
+ stage: "ready",
412
+ progress: 1,
413
+ detail: model.id,
414
+ });
415
+ events.emit("ready", { modelId: model.id });
416
+ return model;
417
+ } catch (err) {
418
+ lastError = err;
419
+ setPhase("error");
420
+ events.emit("error", { error: err });
421
+ throw err;
422
+ }
423
+ },
424
+ captureFrame(): FrameSnapshot | null {
425
+ if (!model || !activeBackend?.captureFrame) return null;
426
+ return activeBackend.captureFrame(model);
427
+ },
428
+ setParameter(id, value) {
429
+ if (!model || !activeBackend?.setParameter) return;
430
+ activeBackend.setParameter(model, id, value);
431
+ },
432
+ hitTest(x, y) {
433
+ if (!model || !activeBackend) return null;
434
+ const drawables = activeBackend.getDrawables(model);
435
+ for (let n = drawables.length - 1; n >= 0; n -= 1) {
436
+ const d = drawables[n]!;
437
+ if (!d.visible || d.opacity <= 0) continue;
438
+ const p = d.vertexPositions;
439
+ const idx = d.indices;
440
+ for (let i = 0; i + 2 < idx.length; i += 3) {
441
+ const a = idx[i]! * 2,
442
+ b = idx[i + 1]! * 2,
443
+ c = idx[i + 2]! * 2;
444
+ const ax = p[a]!,
445
+ ay = p[a + 1]!;
446
+ const bx = p[b]!,
447
+ by = p[b + 1]!;
448
+ const cx = p[c]!,
449
+ cy = p[c + 1]!;
450
+ const s = (ax - cx) * (y - cy) - (ay - cy) * (x - cx);
451
+ const s1 = (bx - ax) * (y - ay) - (by - ay) * (x - ax);
452
+ const s2 = (cx - bx) * (y - by) - (cy - by) * (x - bx);
453
+ if (
454
+ (s >= 0 && s1 >= 0 && s2 >= 0) ||
455
+ (s <= 0 && s1 <= 0 && s2 <= 0)
456
+ ) {
457
+ return `drawable:${d.index}`;
458
+ }
459
+ }
460
+ }
461
+ return null;
462
+ },
463
+ listParameters() {
464
+ if (!model || !activeBackend?.listParameters) return [];
465
+ return activeBackend.listParameters(model);
466
+ },
467
+ listMotionGroups() {
468
+ return model?.settings.motionGroups ?? {};
469
+ },
470
+ async playMotion(group, index = 0, options = {}) {
471
+ if (!model || !activeResolver) return false;
472
+ const list = model.settings.motionGroups[group];
473
+ const def = list?.[index];
474
+ if (!def) return false;
475
+
476
+ let clip = motionCache.get(def.file);
477
+ if (!clip) {
478
+ const json = await activeResolver.fetchJson(def.file);
479
+ clip = parseMotion3(json);
480
+ motionCache.set(def.file, clip);
481
+ }
482
+
483
+ const fadeInTime =
484
+ options.fadeInTime ?? def.fadeInTime ?? clip.fadeInTime;
485
+ const fadeOutTime =
486
+ options.fadeOutTime ?? def.fadeOutTime ?? clip.fadeOutTime;
487
+
488
+ return motionPlayer.start(group, index, clip, {
489
+ priority: options.priority ?? MotionPriority.normal,
490
+ slot: options.slot,
491
+ queue: options.queue,
492
+ loop: options.loop,
493
+ fadeInTime,
494
+ fadeOutTime,
495
+ });
496
+ },
497
+ stopMotion(opts) {
498
+ motionPlayer.stop(opts?.fade !== false, opts?.slot);
499
+ },
500
+ listPlayingMotions() {
501
+ return motionPlayer.listPlaying();
502
+ },
503
+ async capturePng(opts = {}) {
504
+ if (!canvas) {
505
+ throw new Error(
506
+ "@doki-land/live2d: mount(canvas) before capturePng",
507
+ );
508
+ }
509
+ if (phase === "live" && model && activeBackend && drawPass) {
510
+ runtime.update(0);
511
+ }
512
+ const mime = opts.mimeType ?? "image/png";
513
+ return await new Promise<Blob>((resolve, reject) => {
514
+ canvas!.toBlob(
515
+ (blob) => {
516
+ if (blob) resolve(blob);
517
+ else
518
+ reject(
519
+ new Error(
520
+ "@doki-land/live2d: canvas.toBlob returned null",
521
+ ),
522
+ );
523
+ },
524
+ mime,
525
+ opts.quality,
526
+ );
527
+ });
528
+ },
529
+ update(deltaTimeSeconds) {
530
+ if (!model || !activeBackend || !drawPass) return;
531
+ if (phase !== "live") return;
532
+
533
+ const t0 = nowMs();
534
+ applyMotionSamples(motionPlayer.update(deltaTimeSeconds));
535
+ activeBackend.updateModel(model, deltaTimeSeconds);
536
+ const drawables = activeBackend.getDrawables(model);
537
+ const t1 = nowMs();
538
+
539
+ renderer.beginFrame();
540
+ drawPass.draw(drawables, new Float32Array(16));
541
+ renderer.endFrame();
542
+ const t2 = nowMs();
543
+
544
+ let vertexCount = 0;
545
+ let indexCount = 0;
546
+ for (const d of drawables) {
547
+ vertexCount += d.vertexPositions.length / 2;
548
+ indexCount += d.indices.length;
549
+ }
550
+
551
+ const frameMs = t2 - t0;
552
+ const evaluateMs = t1 - t0;
553
+ const drawMs = t2 - t1;
554
+ const fps = deltaTimeSeconds > 0 ? 1 / deltaTimeSeconds : 0;
555
+ fpsSmooth = fpsSmooth <= 0 ? fps : fpsSmooth * 0.85 + fps * 0.15;
556
+
557
+ events.emit("profile", {
558
+ fps,
559
+ fpsSmooth,
560
+ frameMs,
561
+ evaluateMs,
562
+ drawMs,
563
+ drawableCount: drawables.length,
564
+ vertexCount,
565
+ indexCount,
566
+ });
567
+ },
568
+ destroy() {
569
+ loadGeneration += 1;
570
+ generation += 1;
571
+ motionPlayer.clear();
572
+ motionCache.clear();
573
+ activeResolver = null;
574
+ if (model && activeBackend) {
575
+ activeBackend.destroyModel(model);
576
+ }
577
+ model = null;
578
+ activeBackend = null;
579
+ clearTextures();
580
+ drawPass?.destroy();
581
+ drawPass = null;
582
+ initPromise = null;
583
+ renderer.destroy();
584
+ canvas = null;
585
+ setPhase("destroyed");
586
+ events.clear();
587
+ },
588
+ };
589
+
590
+ return runtime;
591
+ }
package/src/focus.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { ParameterBinding } from "@doki-land/live2d-renderer";
2
+
3
+ /**
4
+ * Map canvas focus (-1..1, Y-up) onto standard Cubism drag parameters.
5
+ *
6
+ * Matches the Cubism sample / community widget weights:
7
+ * ANGLE_X/Y full range, ANGLE_Z = -dragX*dragY, BODY_ANGLE_X, EYE_BALL_*.
8
+ * Missing ids on a given model are skipped.
9
+ */
10
+ export function focusParameterUpdates(
11
+ parameters: readonly ParameterBinding[],
12
+ dragX: number,
13
+ dragY: number,
14
+ ): Array<{ id: string; value: number }> {
15
+ const byId = new Map(parameters.map((p) => [p.id, p]));
16
+ const x = clampUnit(dragX);
17
+ const y = clampUnit(dragY);
18
+ const out: Array<{ id: string; value: number }> = [];
19
+
20
+ const set = (id: string, normalized: number) => {
21
+ const binding = byId.get(id);
22
+ if (!binding) return;
23
+ out.push({ id, value: valueFromNormalized(binding, normalized) });
24
+ };
25
+
26
+ set("PARAM_ANGLE_X", x);
27
+ set("PARAM_ANGLE_Y", y);
28
+ set("PARAM_ANGLE_Z", clampUnit(x * y * -1));
29
+ // Official sample uses dragX*10 vs ANGLE's *30; BODY range is typically ±10,
30
+ // so full-range normalized drag still matches that relative weight.
31
+ set("PARAM_BODY_ANGLE_X", x);
32
+ set("PARAM_BODY_ANGLE_Y", y);
33
+ set("PARAM_EYE_BALL_X", x);
34
+ set("PARAM_EYE_BALL_Y", y);
35
+
36
+ return out;
37
+ }
38
+
39
+ function clampUnit(n: number): number {
40
+ if (n > 1) return 1;
41
+ if (n < -1) return -1;
42
+ return n;
43
+ }
44
+
45
+ function valueFromNormalized(
46
+ binding: ParameterBinding,
47
+ normalized: number,
48
+ ): number {
49
+ const n = clampUnit(normalized);
50
+ return n >= 0
51
+ ? binding.defaultValue + (binding.max - binding.defaultValue) * n
52
+ : binding.defaultValue + (binding.defaultValue - binding.min) * n;
53
+ }