@doki-land/live2d 0.0.13 → 0.0.15

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,337 @@
1
+ import type {
2
+ AssetResolver,
3
+ InternalModel,
4
+ Live2dStageAssets,
5
+ LoadProgress,
6
+ ModelAsset,
7
+ ModelSettings,
8
+ ModelSource,
9
+ } from "@doki-land/live2d-core";
10
+ import { modelSourceUrl } from "@doki-land/live2d-core";
11
+ import {
12
+ createUrlAssetResolver,
13
+ fetchModelJson,
14
+ normalizeModelSettings,
15
+ resolveModelSourceUrl,
16
+ } from "@doki-land/live2d-loader";
17
+ import type {
18
+ ModelBackend,
19
+ Renderer,
20
+ TextureData,
21
+ } from "@doki-land/live2d-renderer";
22
+ import {
23
+ compileSharedModelCompile,
24
+ selectModelBackend,
25
+ } from "@doki-land/live2d-renderer";
26
+ import { loadTextureData, releaseTextureData } from "../load-textures.js";
27
+ import type { Motion3Clip } from "../motion/index.js";
28
+ import { resolveModelAssetKey } from "./model-asset-key.js";
29
+
30
+ function lerp(a: number, b: number, t: number): number {
31
+ return a + (b - a) * Math.min(1, Math.max(0, t));
32
+ }
33
+
34
+ /** @internal */
35
+ export class ModelAssetHandle implements ModelAsset {
36
+ readonly #entry: SharedModelAssetEntry;
37
+
38
+ constructor(entry: SharedModelAssetEntry) {
39
+ this.#entry = entry;
40
+ }
41
+
42
+ get key(): string {
43
+ return this.#entry.key;
44
+ }
45
+
46
+ get settings(): ModelSettings {
47
+ return this.#entry.settings;
48
+ }
49
+
50
+ /** @internal */
51
+ get entry(): SharedModelAssetEntry {
52
+ return this.#entry;
53
+ }
54
+ }
55
+
56
+ /** @internal */
57
+ export interface SharedModelAssetEntry {
58
+ readonly key: string;
59
+ readonly settings: ModelSettings;
60
+ readonly backend: ModelBackend;
61
+ readonly resolver: AssetResolver;
62
+ readonly sharedCompile: ReturnType<typeof compileSharedModelCompile>;
63
+ readonly textures: TextureData[];
64
+ readonly motionCache: Map<string, Motion3Clip>;
65
+ refCount: number;
66
+ }
67
+
68
+ /** Lease held by one actor slot while a model is attached. */
69
+ export interface ModelAssetLease {
70
+ readonly asset: ModelAssetHandle;
71
+ readonly motionCache: Map<string, Motion3Clip>;
72
+ readonly resolver: AssetResolver;
73
+ readonly textures: readonly TextureData[];
74
+ createInstance(renderer: Renderer): Promise<{
75
+ model: InternalModel;
76
+ backend: ModelBackend;
77
+ }>;
78
+ release(): void;
79
+ }
80
+
81
+ export interface ModelAssetRegistryOptions {
82
+ backends: readonly ModelBackend[];
83
+ }
84
+
85
+ export class ModelAssetRegistry implements Live2dStageAssets {
86
+ readonly #backends: readonly ModelBackend[];
87
+ readonly #entries = new Map<string, SharedModelAssetEntry>();
88
+ readonly #inFlight = new Map<string, Promise<SharedModelAssetEntry>>();
89
+
90
+ constructor(options: ModelAssetRegistryOptions) {
91
+ this.#backends = options.backends;
92
+ }
93
+
94
+ async load(
95
+ source: ModelSource,
96
+ resolver?: AssetResolver,
97
+ ): Promise<ModelAsset> {
98
+ const entry = await this.#ensureEntry(source, resolver);
99
+ return new ModelAssetHandle(entry);
100
+ }
101
+
102
+ async acquire(
103
+ source: ModelSource,
104
+ resolver: AssetResolver | undefined,
105
+ onProgress?: (payload: LoadProgress) => void,
106
+ ): Promise<ModelAssetLease> {
107
+ const entry = await this.#ensureEntry(source, resolver, onProgress);
108
+ entry.refCount += 1;
109
+ return this.#leaseFromEntry(entry);
110
+ }
111
+
112
+ acquireExisting(asset: ModelAsset): ModelAssetLease {
113
+ const handle = asset as ModelAssetHandle;
114
+ const entry = handle.entry;
115
+ if (
116
+ !this.#entries.has(entry.key) ||
117
+ this.#entries.get(entry.key) !== entry
118
+ ) {
119
+ throw new Error(
120
+ "@doki-land/live2d: ModelAsset does not belong to this stage",
121
+ );
122
+ }
123
+ entry.refCount += 1;
124
+ return this.#leaseFromEntry(entry);
125
+ }
126
+
127
+ destroy(): void {
128
+ this.#inFlight.clear();
129
+ for (const entry of this.#entries.values()) {
130
+ releaseTextureData(entry.textures);
131
+ entry.motionCache.clear();
132
+ }
133
+ this.#entries.clear();
134
+ }
135
+
136
+ #leaseFromEntry(entry: SharedModelAssetEntry): ModelAssetLease {
137
+ let released = false;
138
+ return {
139
+ asset: new ModelAssetHandle(entry),
140
+ motionCache: entry.motionCache,
141
+ resolver: entry.resolver,
142
+ textures: entry.textures,
143
+ createInstance: async (renderer) => {
144
+ const model = await entry.backend.createModel(entry.settings, {
145
+ renderer,
146
+ resolver: entry.resolver,
147
+ sharedCompile: entry.sharedCompile,
148
+ });
149
+ return { model, backend: entry.backend };
150
+ },
151
+ release: () => {
152
+ if (released) return;
153
+ released = true;
154
+ entry.refCount = Math.max(0, entry.refCount - 1);
155
+ if (entry.refCount === 0) {
156
+ releaseTextureData(entry.textures);
157
+ entry.textures.length = 0;
158
+ entry.motionCache.clear();
159
+ this.#entries.delete(entry.key);
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ async #ensureEntry(
166
+ source: ModelSource,
167
+ resolver?: AssetResolver,
168
+ onProgress?: (payload: LoadProgress) => void,
169
+ ): Promise<SharedModelAssetEntry> {
170
+ const key = resolveModelAssetKey(source);
171
+ const existing = this.#entries.get(key);
172
+ if (existing) return existing;
173
+
174
+ let pending = this.#inFlight.get(key);
175
+ if (!pending) {
176
+ pending = this.#compileEntry(key, source, resolver, onProgress);
177
+ this.#inFlight.set(key, pending);
178
+ }
179
+ try {
180
+ const entry = await pending;
181
+ this.#entries.set(key, entry);
182
+ return entry;
183
+ } finally {
184
+ this.#inFlight.delete(key);
185
+ }
186
+ }
187
+
188
+ async #compileEntry(
189
+ key: string,
190
+ source: ModelSource,
191
+ resolver: AssetResolver | undefined,
192
+ onProgress?: (payload: LoadProgress) => void,
193
+ ): Promise<SharedModelAssetEntry> {
194
+ const notify = (payload: LoadProgress) => onProgress?.(payload);
195
+
196
+ notify({
197
+ stage: "resolve",
198
+ progress: 0.02,
199
+ detail: "resolve source",
200
+ });
201
+
202
+ let json: unknown;
203
+ let baseUrl: string;
204
+ let settingsUrl: string;
205
+
206
+ if (typeof source === "object" && source.kind === "json") {
207
+ json = source.json;
208
+ baseUrl = source.baseUrl;
209
+ settingsUrl = source.baseUrl;
210
+ notify({
211
+ stage: "settings",
212
+ progress: 0.2,
213
+ detail: "inline settings",
214
+ });
215
+ } else {
216
+ const raw =
217
+ typeof source === "string"
218
+ ? source
219
+ : source.kind === "npm"
220
+ ? modelSourceUrl(source)
221
+ : source.url;
222
+ const cdnBase =
223
+ typeof source === "object" && source.kind === "npm"
224
+ ? source.cdnBase
225
+ : undefined;
226
+ const fetchUrl = resolveModelSourceUrl(raw, {
227
+ npmCdnBase: cdnBase,
228
+ });
229
+ notify({
230
+ stage: "settings",
231
+ progress: 0.05,
232
+ detail: fetchUrl,
233
+ });
234
+ json = await fetchModelJson(fetchUrl, (u) => {
235
+ const ratio =
236
+ u.bytesTotal && u.bytesTotal > 0
237
+ ? u.bytesLoaded / u.bytesTotal
238
+ : 0;
239
+ notify({
240
+ stage: "settings",
241
+ progress: lerp(0.05, 0.22, ratio),
242
+ detail: fetchUrl,
243
+ bytesLoaded: u.bytesLoaded,
244
+ bytesTotal: u.bytesTotal,
245
+ });
246
+ });
247
+ baseUrl = fetchUrl;
248
+ settingsUrl = fetchUrl;
249
+ }
250
+
251
+ const settings = normalizeModelSettings(json, settingsUrl);
252
+ notify({
253
+ stage: "moc",
254
+ progress: 0.25,
255
+ detail: settings.moc,
256
+ });
257
+
258
+ const assetResolver =
259
+ resolver ??
260
+ createUrlAssetResolver(baseUrl, {
261
+ onBytesProgress: (assetKey, u) => {
262
+ const isMoc = assetKey === settings.moc;
263
+ const ratio =
264
+ u.bytesTotal && u.bytesTotal > 0
265
+ ? u.bytesLoaded / u.bytesTotal
266
+ : 0;
267
+ if (isMoc) {
268
+ notify({
269
+ stage: "moc",
270
+ progress: lerp(0.25, 0.8, ratio),
271
+ detail: assetKey,
272
+ bytesLoaded: u.bytesLoaded,
273
+ bytesTotal: u.bytesTotal,
274
+ });
275
+ } else {
276
+ notify({
277
+ stage: "textures",
278
+ progress: lerp(0.8, 0.9, ratio),
279
+ detail: assetKey,
280
+ bytesLoaded: u.bytesLoaded,
281
+ bytesTotal: u.bytesTotal,
282
+ });
283
+ }
284
+ },
285
+ });
286
+
287
+ const backend = selectModelBackend([...this.#backends], json);
288
+ notify({
289
+ stage: "decode",
290
+ progress: 0.85,
291
+ detail: `decode ${settings.format}`,
292
+ });
293
+
294
+ const mocBytes = await assetResolver.fetchBytes(settings.moc);
295
+ const sharedCompile = compileSharedModelCompile(settings, mocBytes);
296
+
297
+ const textures: TextureData[] = [];
298
+ if (settings.textures.length > 0) {
299
+ notify({
300
+ stage: "textures",
301
+ progress: 0.88,
302
+ detail: `${settings.textures.length} textures`,
303
+ });
304
+ textures.push(
305
+ ...(await loadTextureData(assetResolver, settings.textures, {
306
+ onProgress: (u) => {
307
+ const ratio = u.total > 0 ? (u.index + 1) / u.total : 1;
308
+ notify({
309
+ stage: "textures",
310
+ progress: lerp(0.88, 0.96, ratio),
311
+ detail: u.key,
312
+ bytesLoaded: u.bytesLoaded,
313
+ bytesTotal: u.bytesTotal,
314
+ });
315
+ },
316
+ })),
317
+ );
318
+ }
319
+
320
+ notify({
321
+ stage: "ready",
322
+ progress: 1,
323
+ detail: settings.name ?? key,
324
+ });
325
+
326
+ return {
327
+ key,
328
+ settings,
329
+ backend,
330
+ resolver: assetResolver,
331
+ sharedCompile,
332
+ textures,
333
+ motionCache: new Map(),
334
+ refCount: 0,
335
+ };
336
+ }
337
+ }
@@ -139,19 +139,19 @@ export function createSingleActorFacade(
139
139
  return hit.area;
140
140
  },
141
141
  listParameters() {
142
- return actor.slot.listParameters();
142
+ return actor.listParameters();
143
143
  },
144
144
  listMotionGroups() {
145
- return actor.slot.listMotionGroups();
145
+ return actor.listMotionGroups();
146
146
  },
147
147
  playMotion(group, index, options) {
148
- return actor.slot.playMotion(group, index, options);
148
+ return actor.playMotion(group, index, options);
149
149
  },
150
150
  stopMotion(opts) {
151
- actor.slot.stopMotion(opts);
151
+ actor.stopMotion(opts);
152
152
  },
153
153
  listPlayingMotions() {
154
- return actor.slot.listPlayingMotions();
154
+ return actor.listPlayingMotions();
155
155
  },
156
156
  async capturePng(opts = {}) {
157
157
  if (!canvas) {
@@ -18,6 +18,7 @@ import {
18
18
  createRenderer,
19
19
  } from "@doki-land/live2d-renderer";
20
20
  import { allocateActorId, Live2dActorImpl } from "./actor.js";
21
+ import { ModelAssetRegistry } from "./model-asset-registry.js";
21
22
  import {
22
23
  clientToStage,
23
24
  compareActorsForDraw,
@@ -36,6 +37,7 @@ type PointerListener = (event: StagePointerEvent) => void;
36
37
  export class Live2dStageImpl implements Live2dStage {
37
38
  readonly #backends: readonly ModelBackend[];
38
39
  readonly #renderer: Renderer;
40
+ readonly #assets: ModelAssetRegistry;
39
41
  readonly #updateMode: "auto" | "manual";
40
42
  readonly #actors = new Map<string, Live2dActorImpl>();
41
43
  readonly #definedLayers: string[] = [
@@ -76,9 +78,14 @@ export class Live2dStageImpl implements Live2dStage {
76
78
  ];
77
79
  this.#renderer =
78
80
  options.renderer ?? createRenderer({ prefer: options.prefer });
81
+ this.#assets = new ModelAssetRegistry({ backends: this.#backends });
79
82
  this.#updateMode = options.updateMode ?? "auto";
80
83
  }
81
84
 
85
+ get assets(): ModelAssetRegistry {
86
+ return this.#assets;
87
+ }
88
+
82
89
  get actors(): readonly Live2dActor[] {
83
90
  return [...this.#actors.values()];
84
91
  }
@@ -117,7 +124,7 @@ export class Live2dStageImpl implements Live2dStage {
117
124
  const actor = new Live2dActorImpl(options, {
118
125
  id,
119
126
  creationIndex,
120
- backends: this.#backends,
127
+ assets: this.#assets,
121
128
  renderer: this.#renderer,
122
129
  });
123
130
  this.#actors.set(id, actor);
@@ -127,6 +134,24 @@ export class Live2dStageImpl implements Live2dStage {
127
134
  return actor;
128
135
  }
129
136
 
137
+ getActor(id: string): Live2dActor | null {
138
+ return this.#actors.get(id) ?? null;
139
+ }
140
+
141
+ resize(width?: number, height?: number): void {
142
+ if (!this.#canvas || this.#destroyed) return;
143
+ const cssW = width ?? this.#canvas.clientWidth;
144
+ const cssH = height ?? this.#canvas.clientHeight;
145
+ if (cssW <= 0 || cssH <= 0) return;
146
+ const dpr =
147
+ typeof globalThis.devicePixelRatio === "number"
148
+ ? globalThis.devicePixelRatio
149
+ : 1;
150
+ this.#canvas.width = Math.round(cssW * dpr);
151
+ this.#canvas.height = Math.round(cssH * dpr);
152
+ this.#renderer.resize(cssW, cssH);
153
+ }
154
+
130
155
  removeActor(actorOrId: Live2dActor | string): void {
131
156
  const id = typeof actorOrId === "string" ? actorOrId : actorOrId.id;
132
157
  const actor = this.#actors.get(id);
@@ -256,6 +281,7 @@ export class Live2dStageImpl implements Live2dStage {
256
281
  actor.destroy();
257
282
  }
258
283
  this.#actors.clear();
284
+ this.#assets.destroy();
259
285
  this.#renderer.destroy();
260
286
  this.#canvas = null;
261
287
  this.#initPromise = null;