@doki-land/live2d-loader 0.0.0 → 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.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @doki-land/live2d-loader
2
2
 
3
- Placeholder package (0.0.0). Reserved for doki-land/live2d.ts.
3
+ live2d.ts package 0.0.11.
@@ -0,0 +1,71 @@
1
+ import { ModelFormat, ModelSettings, AssetKey, AssetResolver } from '@doki-land/live2d-core';
2
+
3
+ /**
4
+ * Fetch helpers with optional byte-level progress.
5
+ */
6
+ type FetchProgressHandler = (update: {
7
+ bytesLoaded: number;
8
+ bytesTotal: number | null;
9
+ }) => void;
10
+ /**
11
+ * Fetch bytes with progress. Uses `arrayBuffer()` when streaming is unavailable
12
+ * or when Content-Length is known (avoids stuck ReadableStream readers in some
13
+ * embedded Chromium hosts during rapid remounts).
14
+ */
15
+ declare function fetchArrayBufferWithProgress(url: string, onProgress?: FetchProgressHandler): Promise<ArrayBuffer>;
16
+ declare function fetchJsonWithProgress(url: string, onProgress?: FetchProgressHandler): Promise<unknown>;
17
+
18
+ /** Resolve a relative asset URL against the model settings URL. */
19
+ declare function resolveAssetUrl(baseUrl: string, relative: string): string;
20
+ interface UrlAssetResolverOptions {
21
+ onBytesProgress?: (key: AssetKey, update: {
22
+ bytesLoaded: number;
23
+ bytesTotal: number | null;
24
+ }) => void;
25
+ }
26
+ declare function createUrlAssetResolver(baseUrl: string, options?: UrlAssetResolverOptions): AssetResolver;
27
+ interface LoadPipelineContext {
28
+ source: string;
29
+ json?: unknown;
30
+ format?: ModelFormat;
31
+ settings?: ModelSettings;
32
+ }
33
+ type LoadMiddleware = (ctx: LoadPipelineContext, next: () => Promise<void>) => Promise<void>;
34
+ declare function runLoadPipeline(ctx: LoadPipelineContext, middlewares: LoadMiddleware[]): Promise<LoadPipelineContext>;
35
+ /** Fetch model settings JSON from a URL. */
36
+ declare function fetchModelJson(url: string, onProgress?: (update: {
37
+ bytesLoaded: number;
38
+ bytesTotal: number | null;
39
+ }) => void): Promise<unknown>;
40
+ /** Detect moc2 vs moc3 from settings JSON shape (core helper; throws if unknown). */
41
+ declare function detectModelFormat(json: unknown): ModelFormat;
42
+ /** Normalize raw model3.json / model.json into ModelSettings. */
43
+ declare function normalizeModelSettings(json: unknown, url: string): ModelSettings;
44
+
45
+ /**
46
+ * Resolve model settings URLs, including `npm:` specifiers via a CDN.
47
+ */
48
+ declare const DEFAULT_NPM_CDN = "https://cdn.jsdelivr.net/npm";
49
+ interface ResolveModelSourceOptions {
50
+ /** Default: `https://cdn.jsdelivr.net/npm` */
51
+ npmCdnBase?: string;
52
+ }
53
+ /**
54
+ * Parse `name@version/path`, `@scope/name@version/path`, or `name/path`.
55
+ * When version is omitted, jsDelivr resolves the latest published version.
56
+ */
57
+ declare function resolveNpmSpecifier(spec: string, cdnBase?: string): string;
58
+ /**
59
+ * Turn a model source string into a fetchable URL.
60
+ * - `npm:pkg[@ver]/path` → CDN URL (jsDelivr by default)
61
+ * - `http(s)://...` / absolute / relative paths pass through
62
+ */
63
+ declare function resolveModelSourceUrl(source: string, options?: ResolveModelSourceOptions): string;
64
+
65
+ /**
66
+ * `@doki-land/live2d-loader` — model JSON fetch and load pipeline.
67
+ */
68
+
69
+ declare const LIVE2D_LOADER_VERSION: "0.0.0";
70
+
71
+ export { DEFAULT_NPM_CDN, type FetchProgressHandler, LIVE2D_LOADER_VERSION, type LoadMiddleware, type LoadPipelineContext, type ResolveModelSourceOptions, type UrlAssetResolverOptions, createUrlAssetResolver, detectModelFormat, fetchArrayBufferWithProgress, fetchJsonWithProgress, fetchModelJson, normalizeModelSettings, resolveAssetUrl, resolveModelSourceUrl, resolveNpmSpecifier, runLoadPipeline };
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ // src/fetch-progress.ts
2
+ function safeProgress(onProgress, update) {
3
+ if (!onProgress) return;
4
+ try {
5
+ onProgress(update);
6
+ } catch {
7
+ }
8
+ }
9
+ async function fetchArrayBufferWithProgress(url, onProgress) {
10
+ const res = await fetch(url);
11
+ if (!res.ok) {
12
+ throw new Error(
13
+ `@doki-land/live2d-loader: failed to fetch bytes (${res.status}): ${url}`
14
+ );
15
+ }
16
+ const totalHeader = res.headers.get("content-length");
17
+ const bytesTotal = totalHeader ? Number(totalHeader) : null;
18
+ const preferBuffer = !res.body || !onProgress || bytesTotal !== null && Number.isFinite(bytesTotal) && bytesTotal >= 0;
19
+ if (preferBuffer) {
20
+ safeProgress(onProgress, { bytesLoaded: 0, bytesTotal });
21
+ const buf = await res.arrayBuffer();
22
+ safeProgress(onProgress, {
23
+ bytesLoaded: buf.byteLength,
24
+ bytesTotal: bytesTotal ?? buf.byteLength
25
+ });
26
+ return buf;
27
+ }
28
+ const reader = res.body.getReader();
29
+ const chunks = [];
30
+ let bytesLoaded = 0;
31
+ for (; ; ) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ if (value) {
35
+ chunks.push(value);
36
+ bytesLoaded += value.byteLength;
37
+ safeProgress(onProgress, { bytesLoaded, bytesTotal });
38
+ }
39
+ }
40
+ const out = new Uint8Array(bytesLoaded);
41
+ let offset = 0;
42
+ for (const chunk of chunks) {
43
+ out.set(chunk, offset);
44
+ offset += chunk.byteLength;
45
+ }
46
+ safeProgress(onProgress, {
47
+ bytesLoaded,
48
+ bytesTotal: bytesTotal ?? bytesLoaded
49
+ });
50
+ return out.buffer;
51
+ }
52
+ async function fetchJsonWithProgress(url, onProgress) {
53
+ const bytes = await fetchArrayBufferWithProgress(url, onProgress);
54
+ const text = new TextDecoder().decode(bytes);
55
+ return JSON.parse(text);
56
+ }
57
+
58
+ // src/pipeline.ts
59
+ import { detectModelSettingsFormat } from "@doki-land/live2d-core";
60
+ function resolveAssetUrl(baseUrl, relative) {
61
+ if (/^(?:[a-z]+:)?\/\//i.test(relative) || relative.startsWith("data:")) {
62
+ return relative;
63
+ }
64
+ try {
65
+ return new URL(relative, baseUrl).href;
66
+ } catch {
67
+ const slash = baseUrl.lastIndexOf("/");
68
+ const dir = slash >= 0 ? baseUrl.slice(0, slash + 1) : "";
69
+ return `${dir}${relative}`;
70
+ }
71
+ }
72
+ function createUrlAssetResolver(baseUrl, options = {}) {
73
+ return {
74
+ baseUrl,
75
+ resolve(key) {
76
+ return resolveAssetUrl(baseUrl, key);
77
+ },
78
+ async fetchJson(key) {
79
+ const url = resolveAssetUrl(baseUrl, key);
80
+ return fetchJsonWithProgress(url, (update) => {
81
+ options.onBytesProgress?.(key, update);
82
+ });
83
+ },
84
+ async fetchBytes(key) {
85
+ const url = resolveAssetUrl(baseUrl, key);
86
+ return fetchArrayBufferWithProgress(url, (update) => {
87
+ options.onBytesProgress?.(key, update);
88
+ });
89
+ }
90
+ };
91
+ }
92
+ async function runLoadPipeline(ctx, middlewares) {
93
+ let index = 0;
94
+ const dispatch = async () => {
95
+ const mw = middlewares[index++];
96
+ if (!mw) return;
97
+ await mw(ctx, dispatch);
98
+ };
99
+ await dispatch();
100
+ return ctx;
101
+ }
102
+ async function fetchModelJson(url, onProgress) {
103
+ return fetchJsonWithProgress(url, onProgress);
104
+ }
105
+ function detectModelFormat(json) {
106
+ if (!json || typeof json !== "object") {
107
+ throw new Error(
108
+ "@doki-land/live2d-loader: model json must be an object"
109
+ );
110
+ }
111
+ const format = detectModelSettingsFormat(json);
112
+ if (!format) {
113
+ throw new Error(
114
+ "@doki-land/live2d-loader: unrecognized Live2D model json"
115
+ );
116
+ }
117
+ return format;
118
+ }
119
+ function asStringArray(value) {
120
+ if (!Array.isArray(value)) return [];
121
+ return value.filter((v) => typeof v === "string");
122
+ }
123
+ function normalizeModelSettings(json, url) {
124
+ const format = detectModelFormat(json);
125
+ const o = json;
126
+ if (format === "moc3") {
127
+ const fileRefs = o.FileReferences;
128
+ const moc = fileRefs.Moc;
129
+ const textures = asStringArray(fileRefs.Textures);
130
+ const motionGroups = {};
131
+ const motions = fileRefs.Motions;
132
+ if (motions && typeof motions === "object") {
133
+ for (const [group, list] of Object.entries(
134
+ motions
135
+ )) {
136
+ if (!Array.isArray(list)) continue;
137
+ motionGroups[group] = list.map((item) => {
138
+ if (!item || typeof item !== "object") return null;
139
+ const file = item.File;
140
+ if (typeof file !== "string") return null;
141
+ const def = { file };
142
+ const sound = item.Sound;
143
+ if (typeof sound === "string") def.sound = sound;
144
+ const fadeIn = item.FadeInTime;
145
+ if (typeof fadeIn === "number") def.fadeInTime = fadeIn;
146
+ const fadeOut = item.FadeOutTime;
147
+ if (typeof fadeOut === "number")
148
+ def.fadeOutTime = fadeOut;
149
+ return def;
150
+ }).filter((x) => x !== null);
151
+ }
152
+ }
153
+ const expressions = [];
154
+ const expr = fileRefs.Expressions;
155
+ if (Array.isArray(expr)) {
156
+ for (const item of expr) {
157
+ if (!item || typeof item !== "object") continue;
158
+ const name = item.Name;
159
+ const file = item.File;
160
+ if (typeof name === "string" && typeof file === "string") {
161
+ expressions.push({ name, file });
162
+ }
163
+ }
164
+ }
165
+ const hitAreas = [];
166
+ if (Array.isArray(o.HitAreas)) {
167
+ for (const item of o.HitAreas) {
168
+ if (!item || typeof item !== "object") continue;
169
+ const name = item.Name;
170
+ const id = item.Id;
171
+ if (typeof name === "string" && typeof id === "string") {
172
+ hitAreas.push({ name, id });
173
+ }
174
+ }
175
+ }
176
+ return {
177
+ format: "moc3",
178
+ url,
179
+ name: typeof o.Name === "string" ? o.Name : void 0,
180
+ moc,
181
+ textures,
182
+ motionGroups,
183
+ expressions,
184
+ physics: typeof fileRefs.Physics === "string" ? fileRefs.Physics : void 0,
185
+ pose: typeof fileRefs.Pose === "string" ? fileRefs.Pose : void 0,
186
+ hitAreas
187
+ };
188
+ }
189
+ return {
190
+ format: "moc2",
191
+ url,
192
+ moc: o.model,
193
+ textures: asStringArray(o.textures),
194
+ motionGroups: {},
195
+ expressions: [],
196
+ hitAreas: []
197
+ };
198
+ }
199
+
200
+ // src/resolve-source.ts
201
+ var DEFAULT_NPM_CDN = "https://cdn.jsdelivr.net/npm";
202
+ function resolveNpmSpecifier(spec, cdnBase = DEFAULT_NPM_CDN) {
203
+ const trimmed = spec.trim().replace(/^\/+/, "");
204
+ if (!trimmed) {
205
+ throw new Error("@doki-land/live2d-loader: empty npm model specifier");
206
+ }
207
+ let pkg = "";
208
+ let version = null;
209
+ let assetPath = "";
210
+ if (trimmed.startsWith("@")) {
211
+ const scopeSlash = trimmed.indexOf("/");
212
+ if (scopeSlash < 0) {
213
+ throw new Error(
214
+ `@doki-land/live2d-loader: scoped npm specifier needs /name: ${spec}`
215
+ );
216
+ }
217
+ const afterScope = trimmed.slice(scopeSlash + 1);
218
+ const at = afterScope.indexOf("@");
219
+ const slash = afterScope.indexOf("/");
220
+ if (at >= 0 && (slash < 0 || at < slash)) {
221
+ pkg = `${trimmed.slice(0, scopeSlash + 1)}${afterScope.slice(0, at)}`;
222
+ const rest = afterScope.slice(at + 1);
223
+ const pathSlash = rest.indexOf("/");
224
+ if (pathSlash < 0) {
225
+ version = rest;
226
+ } else {
227
+ version = rest.slice(0, pathSlash);
228
+ assetPath = rest.slice(pathSlash + 1);
229
+ }
230
+ } else if (slash >= 0) {
231
+ pkg = `${trimmed.slice(0, scopeSlash + 1)}${afterScope.slice(0, slash)}`;
232
+ assetPath = afterScope.slice(slash + 1);
233
+ } else {
234
+ pkg = trimmed;
235
+ }
236
+ } else {
237
+ const at = trimmed.indexOf("@");
238
+ const slash = trimmed.indexOf("/");
239
+ if (at >= 0 && (slash < 0 || at < slash)) {
240
+ pkg = trimmed.slice(0, at);
241
+ const rest = trimmed.slice(at + 1);
242
+ const pathSlash = rest.indexOf("/");
243
+ if (pathSlash < 0) {
244
+ version = rest;
245
+ } else {
246
+ version = rest.slice(0, pathSlash);
247
+ assetPath = rest.slice(pathSlash + 1);
248
+ }
249
+ } else if (slash >= 0) {
250
+ pkg = trimmed.slice(0, slash);
251
+ assetPath = trimmed.slice(slash + 1);
252
+ } else {
253
+ pkg = trimmed;
254
+ }
255
+ }
256
+ if (!pkg) {
257
+ throw new Error(
258
+ `@doki-land/live2d-loader: invalid npm model specifier: ${spec}`
259
+ );
260
+ }
261
+ if (!assetPath) {
262
+ throw new Error(
263
+ `@doki-land/live2d-loader: npm model specifier needs an asset path (got "${spec}")`
264
+ );
265
+ }
266
+ const base = cdnBase.replace(/\/+$/, "");
267
+ const nameWithVersion = version ? `${pkg}@${version}` : pkg;
268
+ return `${base}/${nameWithVersion}/${assetPath.replace(/^\/+/, "")}`;
269
+ }
270
+ function resolveModelSourceUrl(source, options = {}) {
271
+ if (source.startsWith("npm:")) {
272
+ return resolveNpmSpecifier(
273
+ source.slice("npm:".length),
274
+ options.npmCdnBase ?? DEFAULT_NPM_CDN
275
+ );
276
+ }
277
+ return source;
278
+ }
279
+
280
+ // src/index.ts
281
+ var LIVE2D_LOADER_VERSION = "0.0.0";
282
+ export {
283
+ DEFAULT_NPM_CDN,
284
+ LIVE2D_LOADER_VERSION,
285
+ createUrlAssetResolver,
286
+ detectModelFormat,
287
+ fetchArrayBufferWithProgress,
288
+ fetchJsonWithProgress,
289
+ fetchModelJson,
290
+ normalizeModelSettings,
291
+ resolveAssetUrl,
292
+ resolveModelSourceUrl,
293
+ resolveNpmSpecifier,
294
+ runLoadPipeline
295
+ };
package/package.json CHANGED
@@ -1,7 +1,46 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-loader",
3
- "version": "0.0.0",
4
- "description": "live2d.ts placeholder not for production use.",
3
+ "version": "0.0.11",
4
+ "description": "Live2D asset resolve and model load pipeline",
5
+ "type": "module",
5
6
  "license": "MIT",
6
- "private": false
7
+ "author": "Doki Land",
8
+ "keywords": [
9
+ "live2d",
10
+ "loader",
11
+ "doki-land"
12
+ ],
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --format esm --dts",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run"
37
+ },
38
+ "dependencies": {
39
+ "@doki-land/live2d-core": "0.0.11"
40
+ },
41
+ "sideEffects": false,
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/doki-land/live2d.ts.git"
45
+ }
7
46
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Fetch helpers with optional byte-level progress.
3
+ */
4
+
5
+ export type FetchProgressHandler = (update: {
6
+ bytesLoaded: number;
7
+ bytesTotal: number | null;
8
+ }) => void;
9
+
10
+ function safeProgress(
11
+ onProgress: FetchProgressHandler | undefined,
12
+ update: { bytesLoaded: number; bytesTotal: number | null },
13
+ ): void {
14
+ if (!onProgress) return;
15
+ try {
16
+ onProgress(update);
17
+ } catch {
18
+ // Progress UI must never abort the fetch.
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Fetch bytes with progress. Uses `arrayBuffer()` when streaming is unavailable
24
+ * or when Content-Length is known (avoids stuck ReadableStream readers in some
25
+ * embedded Chromium hosts during rapid remounts).
26
+ */
27
+ export async function fetchArrayBufferWithProgress(
28
+ url: string,
29
+ onProgress?: FetchProgressHandler,
30
+ ): Promise<ArrayBuffer> {
31
+ const res = await fetch(url);
32
+ if (!res.ok) {
33
+ throw new Error(
34
+ `@doki-land/live2d-loader: failed to fetch bytes (${res.status}): ${url}`,
35
+ );
36
+ }
37
+
38
+ const totalHeader = res.headers.get("content-length");
39
+ const bytesTotal = totalHeader ? Number(totalHeader) : null;
40
+ const preferBuffer =
41
+ !res.body ||
42
+ !onProgress ||
43
+ (bytesTotal !== null && Number.isFinite(bytesTotal) && bytesTotal >= 0);
44
+
45
+ if (preferBuffer) {
46
+ safeProgress(onProgress, { bytesLoaded: 0, bytesTotal });
47
+ const buf = await res.arrayBuffer();
48
+ safeProgress(onProgress, {
49
+ bytesLoaded: buf.byteLength,
50
+ bytesTotal: bytesTotal ?? buf.byteLength,
51
+ });
52
+ return buf;
53
+ }
54
+
55
+ const reader = res.body.getReader();
56
+ const chunks: Uint8Array[] = [];
57
+ let bytesLoaded = 0;
58
+
59
+ for (;;) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+ if (value) {
63
+ chunks.push(value);
64
+ bytesLoaded += value.byteLength;
65
+ safeProgress(onProgress, { bytesLoaded, bytesTotal });
66
+ }
67
+ }
68
+
69
+ const out = new Uint8Array(bytesLoaded);
70
+ let offset = 0;
71
+ for (const chunk of chunks) {
72
+ out.set(chunk, offset);
73
+ offset += chunk.byteLength;
74
+ }
75
+ safeProgress(onProgress, {
76
+ bytesLoaded,
77
+ bytesTotal: bytesTotal ?? bytesLoaded,
78
+ });
79
+ return out.buffer;
80
+ }
81
+
82
+ export async function fetchJsonWithProgress(
83
+ url: string,
84
+ onProgress?: FetchProgressHandler,
85
+ ): Promise<unknown> {
86
+ const bytes = await fetchArrayBufferWithProgress(url, onProgress);
87
+ const text = new TextDecoder().decode(bytes);
88
+ return JSON.parse(text) as unknown;
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@doki-land/live2d-loader` — model JSON fetch and load pipeline.
3
+ */
4
+
5
+ export {
6
+ type FetchProgressHandler,
7
+ fetchArrayBufferWithProgress,
8
+ fetchJsonWithProgress,
9
+ } from "./fetch-progress.js";
10
+ export {
11
+ createUrlAssetResolver,
12
+ detectModelFormat,
13
+ fetchModelJson,
14
+ type LoadMiddleware,
15
+ type LoadPipelineContext,
16
+ normalizeModelSettings,
17
+ resolveAssetUrl,
18
+ runLoadPipeline,
19
+ type UrlAssetResolverOptions,
20
+ } from "./pipeline.js";
21
+ export {
22
+ DEFAULT_NPM_CDN,
23
+ type ResolveModelSourceOptions,
24
+ resolveModelSourceUrl,
25
+ resolveNpmSpecifier,
26
+ } from "./resolve-source.js";
27
+
28
+ export const LIVE2D_LOADER_VERSION = "0.0.0" as const;
@@ -0,0 +1,208 @@
1
+ import type {
2
+ AssetKey,
3
+ AssetResolver,
4
+ ExpressionDefinition,
5
+ HitAreaDefinition,
6
+ ModelFormat,
7
+ ModelSettings,
8
+ MotionDefinition,
9
+ } from "@doki-land/live2d-core";
10
+ import { detectModelSettingsFormat } from "@doki-land/live2d-core";
11
+ import {
12
+ fetchArrayBufferWithProgress,
13
+ fetchJsonWithProgress,
14
+ } from "./fetch-progress.js";
15
+
16
+ /** Resolve a relative asset URL against the model settings URL. */
17
+ export function resolveAssetUrl(baseUrl: string, relative: string): string {
18
+ if (/^(?:[a-z]+:)?\/\//i.test(relative) || relative.startsWith("data:")) {
19
+ return relative;
20
+ }
21
+ try {
22
+ return new URL(relative, baseUrl).href;
23
+ } catch {
24
+ const slash = baseUrl.lastIndexOf("/");
25
+ const dir = slash >= 0 ? baseUrl.slice(0, slash + 1) : "";
26
+ return `${dir}${relative}`;
27
+ }
28
+ }
29
+
30
+ export interface UrlAssetResolverOptions {
31
+ onBytesProgress?: (
32
+ key: AssetKey,
33
+ update: { bytesLoaded: number; bytesTotal: number | null },
34
+ ) => void;
35
+ }
36
+
37
+ export function createUrlAssetResolver(
38
+ baseUrl: string,
39
+ options: UrlAssetResolverOptions = {},
40
+ ): AssetResolver {
41
+ return {
42
+ baseUrl,
43
+ resolve(key: AssetKey) {
44
+ return resolveAssetUrl(baseUrl, key);
45
+ },
46
+ async fetchJson(key: AssetKey) {
47
+ const url = resolveAssetUrl(baseUrl, key);
48
+ return fetchJsonWithProgress(url, (update) => {
49
+ options.onBytesProgress?.(key, update);
50
+ });
51
+ },
52
+ async fetchBytes(key: AssetKey) {
53
+ const url = resolveAssetUrl(baseUrl, key);
54
+ return fetchArrayBufferWithProgress(url, (update) => {
55
+ options.onBytesProgress?.(key, update);
56
+ });
57
+ },
58
+ };
59
+ }
60
+
61
+ export interface LoadPipelineContext {
62
+ source: string;
63
+ json?: unknown;
64
+ format?: ModelFormat;
65
+ settings?: ModelSettings;
66
+ }
67
+
68
+ export type LoadMiddleware = (
69
+ ctx: LoadPipelineContext,
70
+ next: () => Promise<void>,
71
+ ) => Promise<void>;
72
+
73
+ export async function runLoadPipeline(
74
+ ctx: LoadPipelineContext,
75
+ middlewares: LoadMiddleware[],
76
+ ): Promise<LoadPipelineContext> {
77
+ let index = 0;
78
+ const dispatch = async (): Promise<void> => {
79
+ const mw = middlewares[index++];
80
+ if (!mw) return;
81
+ await mw(ctx, dispatch);
82
+ };
83
+ await dispatch();
84
+ return ctx;
85
+ }
86
+
87
+ /** Fetch model settings JSON from a URL. */
88
+ export async function fetchModelJson(
89
+ url: string,
90
+ onProgress?: (update: {
91
+ bytesLoaded: number;
92
+ bytesTotal: number | null;
93
+ }) => void,
94
+ ): Promise<unknown> {
95
+ return fetchJsonWithProgress(url, onProgress);
96
+ }
97
+
98
+ /** Detect moc2 vs moc3 from settings JSON shape (core helper; throws if unknown). */
99
+ export function detectModelFormat(json: unknown): ModelFormat {
100
+ if (!json || typeof json !== "object") {
101
+ throw new Error(
102
+ "@doki-land/live2d-loader: model json must be an object",
103
+ );
104
+ }
105
+ const format = detectModelSettingsFormat(json);
106
+ if (!format) {
107
+ throw new Error(
108
+ "@doki-land/live2d-loader: unrecognized Live2D model json",
109
+ );
110
+ }
111
+ return format;
112
+ }
113
+
114
+ function asStringArray(value: unknown): string[] {
115
+ if (!Array.isArray(value)) return [];
116
+ return value.filter((v): v is string => typeof v === "string");
117
+ }
118
+
119
+ /** Normalize raw model3.json / model.json into ModelSettings. */
120
+ export function normalizeModelSettings(
121
+ json: unknown,
122
+ url: string,
123
+ ): ModelSettings {
124
+ const format = detectModelFormat(json);
125
+ const o = json as Record<string, unknown>;
126
+
127
+ if (format === "moc3") {
128
+ const fileRefs = o.FileReferences as Record<string, unknown>;
129
+ const moc = fileRefs.Moc as string;
130
+ const textures = asStringArray(fileRefs.Textures);
131
+ const motionGroups: Record<string, MotionDefinition[]> = {};
132
+ const motions = fileRefs.Motions;
133
+ if (motions && typeof motions === "object") {
134
+ for (const [group, list] of Object.entries(
135
+ motions as Record<string, unknown>,
136
+ )) {
137
+ if (!Array.isArray(list)) continue;
138
+ motionGroups[group] = list
139
+ .map((item) => {
140
+ if (!item || typeof item !== "object") return null;
141
+ const file = (item as Record<string, unknown>).File;
142
+ if (typeof file !== "string") return null;
143
+ const def: MotionDefinition = { file };
144
+ const sound = (item as Record<string, unknown>).Sound;
145
+ if (typeof sound === "string") def.sound = sound;
146
+ const fadeIn = (item as Record<string, unknown>)
147
+ .FadeInTime;
148
+ if (typeof fadeIn === "number") def.fadeInTime = fadeIn;
149
+ const fadeOut = (item as Record<string, unknown>)
150
+ .FadeOutTime;
151
+ if (typeof fadeOut === "number")
152
+ def.fadeOutTime = fadeOut;
153
+ return def;
154
+ })
155
+ .filter((x): x is MotionDefinition => x !== null);
156
+ }
157
+ }
158
+ const expressions: ExpressionDefinition[] = [];
159
+ const expr = fileRefs.Expressions;
160
+ if (Array.isArray(expr)) {
161
+ for (const item of expr) {
162
+ if (!item || typeof item !== "object") continue;
163
+ const name = (item as Record<string, unknown>).Name;
164
+ const file = (item as Record<string, unknown>).File;
165
+ if (typeof name === "string" && typeof file === "string") {
166
+ expressions.push({ name, file });
167
+ }
168
+ }
169
+ }
170
+ const hitAreas: HitAreaDefinition[] = [];
171
+ if (Array.isArray(o.HitAreas)) {
172
+ for (const item of o.HitAreas) {
173
+ if (!item || typeof item !== "object") continue;
174
+ const name = (item as Record<string, unknown>).Name;
175
+ const id = (item as Record<string, unknown>).Id;
176
+ if (typeof name === "string" && typeof id === "string") {
177
+ hitAreas.push({ name, id });
178
+ }
179
+ }
180
+ }
181
+ return {
182
+ format: "moc3",
183
+ url,
184
+ name: typeof o.Name === "string" ? o.Name : undefined,
185
+ moc,
186
+ textures,
187
+ motionGroups,
188
+ expressions,
189
+ physics:
190
+ typeof fileRefs.Physics === "string"
191
+ ? fileRefs.Physics
192
+ : undefined,
193
+ pose: typeof fileRefs.Pose === "string" ? fileRefs.Pose : undefined,
194
+ hitAreas,
195
+ };
196
+ }
197
+
198
+ // moc2
199
+ return {
200
+ format: "moc2",
201
+ url,
202
+ moc: o.model as string,
203
+ textures: asStringArray(o.textures),
204
+ motionGroups: {},
205
+ expressions: [],
206
+ hitAreas: [],
207
+ };
208
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Resolve model settings URLs, including `npm:` specifiers via a CDN.
3
+ */
4
+
5
+ const DEFAULT_NPM_CDN = "https://cdn.jsdelivr.net/npm";
6
+
7
+ export interface ResolveModelSourceOptions {
8
+ /** Default: `https://cdn.jsdelivr.net/npm` */
9
+ npmCdnBase?: string;
10
+ }
11
+
12
+ /**
13
+ * Parse `name@version/path`, `@scope/name@version/path`, or `name/path`.
14
+ * When version is omitted, jsDelivr resolves the latest published version.
15
+ */
16
+ export function resolveNpmSpecifier(
17
+ spec: string,
18
+ cdnBase: string = DEFAULT_NPM_CDN,
19
+ ): string {
20
+ const trimmed = spec.trim().replace(/^\/+/, "");
21
+ if (!trimmed) {
22
+ throw new Error("@doki-land/live2d-loader: empty npm model specifier");
23
+ }
24
+
25
+ let pkg = "";
26
+ let version: string | null = null;
27
+ let assetPath = "";
28
+
29
+ if (trimmed.startsWith("@")) {
30
+ // @scope/name[@version][/path]
31
+ const scopeSlash = trimmed.indexOf("/");
32
+ if (scopeSlash < 0) {
33
+ throw new Error(
34
+ `@doki-land/live2d-loader: scoped npm specifier needs /name: ${spec}`,
35
+ );
36
+ }
37
+ const afterScope = trimmed.slice(scopeSlash + 1);
38
+ const at = afterScope.indexOf("@");
39
+ const slash = afterScope.indexOf("/");
40
+ if (at >= 0 && (slash < 0 || at < slash)) {
41
+ pkg = `${trimmed.slice(0, scopeSlash + 1)}${afterScope.slice(0, at)}`;
42
+ const rest = afterScope.slice(at + 1);
43
+ const pathSlash = rest.indexOf("/");
44
+ if (pathSlash < 0) {
45
+ version = rest;
46
+ } else {
47
+ version = rest.slice(0, pathSlash);
48
+ assetPath = rest.slice(pathSlash + 1);
49
+ }
50
+ } else if (slash >= 0) {
51
+ pkg = `${trimmed.slice(0, scopeSlash + 1)}${afterScope.slice(0, slash)}`;
52
+ assetPath = afterScope.slice(slash + 1);
53
+ } else {
54
+ pkg = trimmed;
55
+ }
56
+ } else {
57
+ const at = trimmed.indexOf("@");
58
+ const slash = trimmed.indexOf("/");
59
+ if (at >= 0 && (slash < 0 || at < slash)) {
60
+ pkg = trimmed.slice(0, at);
61
+ const rest = trimmed.slice(at + 1);
62
+ const pathSlash = rest.indexOf("/");
63
+ if (pathSlash < 0) {
64
+ version = rest;
65
+ } else {
66
+ version = rest.slice(0, pathSlash);
67
+ assetPath = rest.slice(pathSlash + 1);
68
+ }
69
+ } else if (slash >= 0) {
70
+ pkg = trimmed.slice(0, slash);
71
+ assetPath = trimmed.slice(slash + 1);
72
+ } else {
73
+ pkg = trimmed;
74
+ }
75
+ }
76
+
77
+ if (!pkg) {
78
+ throw new Error(
79
+ `@doki-land/live2d-loader: invalid npm model specifier: ${spec}`,
80
+ );
81
+ }
82
+ if (!assetPath) {
83
+ throw new Error(
84
+ `@doki-land/live2d-loader: npm model specifier needs an asset path (got "${spec}")`,
85
+ );
86
+ }
87
+
88
+ const base = cdnBase.replace(/\/+$/, "");
89
+ const nameWithVersion = version ? `${pkg}@${version}` : pkg;
90
+ return `${base}/${nameWithVersion}/${assetPath.replace(/^\/+/, "")}`;
91
+ }
92
+
93
+ /**
94
+ * Turn a model source string into a fetchable URL.
95
+ * - `npm:pkg[@ver]/path` → CDN URL (jsDelivr by default)
96
+ * - `http(s)://...` / absolute / relative paths pass through
97
+ */
98
+ export function resolveModelSourceUrl(
99
+ source: string,
100
+ options: ResolveModelSourceOptions = {},
101
+ ): string {
102
+ if (source.startsWith("npm:")) {
103
+ return resolveNpmSpecifier(
104
+ source.slice("npm:".length),
105
+ options.npmCdnBase ?? DEFAULT_NPM_CDN,
106
+ );
107
+ }
108
+ return source;
109
+ }
110
+
111
+ export { DEFAULT_NPM_CDN };