@doki-land/live2d 0.0.14 → 0.0.16

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
+ }
@@ -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);
@@ -274,6 +281,7 @@ export class Live2dStageImpl implements Live2dStage {
274
281
  actor.destroy();
275
282
  }
276
283
  this.#actors.clear();
284
+ this.#assets.destroy();
277
285
  this.#renderer.destroy();
278
286
  this.#canvas = null;
279
287
  this.#initPromise = null;