@doki-land/live2d 0.0.11 → 0.0.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doki-land/live2d",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Live2D TypeScript library — public facade for Doki Land",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,9 +57,9 @@
57
57
  "test": "vitest run --passWithNoTests"
58
58
  },
59
59
  "dependencies": {
60
- "@doki-land/live2d-core": "0.0.11",
61
- "@doki-land/live2d-loader": "0.0.11",
62
- "@doki-land/live2d-renderer": "0.0.11"
60
+ "@doki-land/live2d-core": "0.0.13",
61
+ "@doki-land/live2d-loader": "0.0.13",
62
+ "@doki-land/live2d-renderer": "0.0.13"
63
63
  },
64
64
  "sideEffects": false
65
65
  }
@@ -1,591 +1,39 @@
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
1
  import {
27
2
  createMoc2Backend,
28
3
  createMoc3Backend,
29
4
  createRenderer,
30
- selectModelBackend,
31
5
  } from "@doki-land/live2d-renderer";
32
- import { loadTextureData, releaseTextureData } from "./load-textures.js";
6
+ import { allocateActorId } from "./stage/actor.js";
33
7
  import {
34
- type Motion3Clip,
35
- MotionPlayer,
8
+ type CreateLive2DOptions,
9
+ createSingleActorFacade,
10
+ type Live2DRuntime,
11
+ } from "./stage/single-facade.js";
12
+ import { createLive2dStage } from "./stage/stage.js";
13
+
14
+ export type {
15
+ CreateLive2DOptions,
16
+ Live2DRuntime,
17
+ } from "./stage/single-facade.js";
18
+ export {
36
19
  MotionPriority,
37
20
  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>;
21
+ } from "./stage/single-facade.js";
78
22
 
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. */
23
+ /** Wire moc backends and a renderer into one single-actor session. */
110
24
  export function createLive2D(options: CreateLive2DOptions = {}): Live2DRuntime {
111
25
  const backends = options.backends ?? [
112
26
  createMoc2Backend(),
113
27
  createMoc3Backend(),
114
28
  ];
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,
29
+ const stage = createLive2dStage({
228
30
  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;
31
+ renderer:
32
+ options.renderer ?? createRenderer({ prefer: options.prefer }),
33
+ updateMode: options.updateMode ?? "manual",
34
+ }) as import("./stage/stage.js").Live2dStageImpl;
35
+ const actor = stage.createActor({
36
+ id: allocateActorId("default"),
37
+ }) as import("./stage/actor.js").Live2dActorImpl;
38
+ return createSingleActorFacade(stage, actor, backends);
591
39
  }
package/src/index.ts CHANGED
@@ -7,11 +7,17 @@
7
7
  */
8
8
 
9
9
  export type {
10
+ ActorHit,
11
+ ActorTransform,
10
12
  AssetResolver,
13
+ CreateActorOptions,
14
+ CreateLive2dStageOptions,
11
15
  FrameProfile,
12
16
  FrameSnapshot,
13
17
  InternalModel,
14
18
  Live2DSession,
19
+ Live2dActor,
20
+ Live2dStage,
15
21
  LoadProgress,
16
22
  LoadProgressStage,
17
23
  ModelFormat,
@@ -19,16 +25,19 @@ export type {
19
25
  ModelProgram,
20
26
  ModelSettings,
21
27
  ModelSource,
28
+ PointerTrackingMode,
29
+ PointerTrackingPolicy,
22
30
  SessionPhase,
23
31
  SessionState,
32
+ StagePointerEvent,
33
+ StageUpdateMode,
24
34
  } from "@doki-land/live2d-core";
25
- export { EventEmitter } from "@doki-land/live2d-core";
35
+ export { DEFAULT_ACTOR_TRANSFORM, EventEmitter } from "@doki-land/live2d-core";
26
36
  export {
27
37
  DEFAULT_NPM_CDN,
28
38
  resolveModelSourceUrl,
29
39
  resolveNpmSpecifier,
30
40
  } from "@doki-land/live2d-loader";
31
-
32
41
  export {
33
42
  createCanvas2DRenderer,
34
43
  createMoc2Backend,
@@ -64,5 +73,9 @@ export {
64
73
  MotionPlayer,
65
74
  parseMotion3,
66
75
  } from "./motion/index.js";
76
+ export {
77
+ type CreateLive2dStageFullOptions,
78
+ createLive2dStage,
79
+ } from "./stage/stage.js";
67
80
 
68
81
  export const LIVE2D_VERSION = "0.0.0" as const;