@doki-land/live2d-element 0.0.0 → 0.0.23

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,37 @@
1
- # @doki-land/live2d-element
1
+ # `@doki-land/live2d-element`
2
2
 
3
- Placeholder package (0.0.0). Reserved for doki-land/live2d.ts.
3
+ Official `<live-2d>` Custom Element thin host over `@doki-land/live2d` with Stage-owned RAF.
4
+
5
+ ## Status (Developer Preview)
6
+
7
+ v0.0.22 thin gate: pure TypeScript `customElements.define('live-2d', …)`. Zero VMZ runtime dependency on this package or on `@doki-land/live2d*`.
8
+
9
+ HTML Custom Elements require a hyphen; the registered tag is **`live-2d`** (not unhyphenated `live2d`). Design `01` still targets eventual `vmz build --target custom-element` when that CLI target ships.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm add @doki-land/live2d-element
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```html
20
+ <script type="module">
21
+ import "@doki-land/live2d-element";
22
+ </script>
23
+ <live-2d
24
+ model="/models/Wanko/Wanko.model3.json"
25
+ renderer="auto"
26
+ width="320"
27
+ height="320"
28
+ autoplay
29
+ ></live-2d>
30
+ <script type="module">
31
+ const el = document.querySelector("live-2d");
32
+ el.addEventListener("live2d-ready", () => console.log("ready"));
33
+ el.addEventListener("live2d-error", (e) => console.error(e.detail));
34
+ </script>
35
+ ```
36
+
37
+ See `fixtures/index.html` for a native HTML dogfood page.
@@ -0,0 +1,32 @@
1
+ import { Live2DRuntime } from '@doki-land/live2d';
2
+
3
+ /** Idempotent `customElements.define('live-2d', …)`. */
4
+ declare function defineLive2dElement(): void;
5
+
6
+ declare const LIVE2D_ELEMENT_TAG: "live-2d";
7
+ type Live2dElementRenderer = "auto" | "webgpu" | "webgl2" | "canvas2d";
8
+ /**
9
+ * Thin `<live-2d>` host: attribute → createLive2D → Stage RAF → live2d-* events.
10
+ */
11
+ declare class Live2dElement extends HTMLElement {
12
+ #private;
13
+ static get observedAttributes(): string[];
14
+ get model(): string;
15
+ set model(value: string);
16
+ get renderer(): Live2dElementRenderer;
17
+ set renderer(value: Live2dElementRenderer);
18
+ get width(): number;
19
+ set width(value: number);
20
+ get height(): number;
21
+ set height(value: number);
22
+ get autoplay(): boolean;
23
+ set autoplay(value: boolean);
24
+ get runtime(): Live2DRuntime | null;
25
+ /** Imperative reload (also used when `model` attribute changes). */
26
+ loadModel(source?: string): Promise<void>;
27
+ connectedCallback(): void;
28
+ disconnectedCallback(): void;
29
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
30
+ }
31
+
32
+ export { LIVE2D_ELEMENT_TAG, Live2dElement, type Live2dElementRenderer, defineLive2dElement };
package/dist/index.js ADDED
@@ -0,0 +1,253 @@
1
+ // src/live2d-element.ts
2
+ import { createLive2D } from "@doki-land/live2d";
3
+ var LIVE2D_ELEMENT_TAG = "live-2d";
4
+ var OBSERVED = ["model", "renderer", "width", "height", "autoplay"];
5
+ function preferFromRenderer(renderer) {
6
+ if (renderer === "webgpu") return ["webgpu", "webgl2", "canvas2d"];
7
+ if (renderer === "webgl2") return ["webgl2", "canvas2d", "webgpu"];
8
+ if (renderer === "canvas2d") return ["canvas2d", "webgl2", "webgpu"];
9
+ return ["webgpu", "webgl2", "canvas2d"];
10
+ }
11
+ function parseBoolAttr(el, name, fallback) {
12
+ if (!el.hasAttribute(name)) return fallback;
13
+ const v = el.getAttribute(name);
14
+ if (v === null || v === "" || v === name) return true;
15
+ if (v === "false" || v === "0") return false;
16
+ return true;
17
+ }
18
+ var Live2dElement = class extends HTMLElement {
19
+ static get observedAttributes() {
20
+ return [...OBSERVED];
21
+ }
22
+ #runtime = null;
23
+ #canvas = null;
24
+ #mountGen = 0;
25
+ #model = "";
26
+ #renderer = "auto";
27
+ #width = 320;
28
+ #height = 320;
29
+ #autoplay = true;
30
+ #connected = false;
31
+ get model() {
32
+ return this.#model;
33
+ }
34
+ set model(value) {
35
+ const next = String(value ?? "");
36
+ if (next) this.setAttribute("model", next);
37
+ else this.removeAttribute("model");
38
+ }
39
+ get renderer() {
40
+ return this.#renderer;
41
+ }
42
+ set renderer(value) {
43
+ this.setAttribute("renderer", value || "auto");
44
+ }
45
+ get width() {
46
+ return this.#width;
47
+ }
48
+ set width(value) {
49
+ this.setAttribute("width", String(Math.max(1, Number(value) || 320)));
50
+ }
51
+ get height() {
52
+ return this.#height;
53
+ }
54
+ set height(value) {
55
+ this.setAttribute("height", String(Math.max(1, Number(value) || 320)));
56
+ }
57
+ get autoplay() {
58
+ return this.#autoplay;
59
+ }
60
+ set autoplay(value) {
61
+ if (value) this.setAttribute("autoplay", "");
62
+ else this.removeAttribute("autoplay");
63
+ }
64
+ get runtime() {
65
+ return this.#runtime;
66
+ }
67
+ /** Imperative reload (also used when `model` attribute changes). */
68
+ async loadModel(source) {
69
+ if (source !== void 0) {
70
+ this.model = String(source);
71
+ }
72
+ await this.#boot();
73
+ }
74
+ connectedCallback() {
75
+ this.#connected = true;
76
+ this.#ensureCanvas();
77
+ this.#readAttributes();
78
+ void this.#boot();
79
+ }
80
+ disconnectedCallback() {
81
+ this.#connected = false;
82
+ this.#mountGen += 1;
83
+ this.#destroyRuntime();
84
+ }
85
+ attributeChangedCallback(name, _old, value) {
86
+ if (name === "model") {
87
+ this.#model = value ?? "";
88
+ if (this.#connected) void this.#boot();
89
+ return;
90
+ }
91
+ if (name === "renderer") {
92
+ this.#renderer = value || "auto";
93
+ if (this.#connected) void this.#boot();
94
+ return;
95
+ }
96
+ if (name === "width") {
97
+ this.#width = Math.max(1, Number(value) || 320);
98
+ this.#syncCanvasSize();
99
+ return;
100
+ }
101
+ if (name === "height") {
102
+ this.#height = Math.max(1, Number(value) || 320);
103
+ this.#syncCanvasSize();
104
+ return;
105
+ }
106
+ if (name === "autoplay") {
107
+ this.#autoplay = parseBoolAttr(this, "autoplay", true);
108
+ if (this.#runtime) {
109
+ if (this.#autoplay) this.#runtime.stage.start();
110
+ else this.#runtime.stage.pause();
111
+ }
112
+ }
113
+ }
114
+ #readAttributes() {
115
+ if (this.hasAttribute("model")) {
116
+ this.#model = this.getAttribute("model") ?? "";
117
+ }
118
+ if (this.hasAttribute("renderer")) {
119
+ this.#renderer = this.getAttribute("renderer") || "auto";
120
+ }
121
+ if (this.hasAttribute("width")) {
122
+ this.#width = Math.max(
123
+ 1,
124
+ Number(this.getAttribute("width")) || 320
125
+ );
126
+ }
127
+ if (this.hasAttribute("height")) {
128
+ this.#height = Math.max(
129
+ 1,
130
+ Number(this.getAttribute("height")) || 320
131
+ );
132
+ }
133
+ this.#autoplay = parseBoolAttr(this, "autoplay", true);
134
+ }
135
+ #ensureCanvas() {
136
+ if (this.#canvas?.isConnected) return this.#canvas;
137
+ this.replaceChildren();
138
+ const canvas = document.createElement("canvas");
139
+ canvas.setAttribute("part", "canvas");
140
+ this.appendChild(canvas);
141
+ this.#canvas = canvas;
142
+ this.#syncCanvasSize();
143
+ return canvas;
144
+ }
145
+ #syncCanvasSize() {
146
+ const canvas = this.#canvas;
147
+ if (!canvas) return;
148
+ const dpr = typeof globalThis.devicePixelRatio === "number" ? Math.min(globalThis.devicePixelRatio, 2) : 1;
149
+ canvas.width = Math.max(1, Math.round(this.#width * dpr));
150
+ canvas.height = Math.max(1, Math.round(this.#height * dpr));
151
+ canvas.style.width = `${this.#width}px`;
152
+ canvas.style.height = `${this.#height}px`;
153
+ this.style.display = this.style.display || "inline-block";
154
+ this.#runtime?.stage.resize?.(this.#width, this.#height);
155
+ }
156
+ #destroyRuntime() {
157
+ try {
158
+ this.#runtime?.stage.stop?.();
159
+ } catch {
160
+ }
161
+ this.#runtime?.destroy?.();
162
+ this.#runtime = null;
163
+ this.removeAttribute("data-phase");
164
+ this.removeAttribute("aria-busy");
165
+ }
166
+ async #boot() {
167
+ if (!this.#connected) return;
168
+ const model = this.#model.trim();
169
+ if (!model) return;
170
+ const gen = ++this.#mountGen;
171
+ this.#destroyRuntime();
172
+ const canvas = this.#ensureCanvas();
173
+ this.setAttribute("data-phase", "loading");
174
+ this.setAttribute("aria-busy", "true");
175
+ try {
176
+ const runtime = createLive2D({
177
+ prefer: preferFromRenderer(this.#renderer),
178
+ updateMode: "auto"
179
+ });
180
+ if (gen !== this.#mountGen) {
181
+ runtime.destroy();
182
+ return;
183
+ }
184
+ runtime.events.on("ready", () => {
185
+ if (gen !== this.#mountGen) return;
186
+ this.setAttribute("data-phase", "live");
187
+ this.setAttribute("aria-busy", "false");
188
+ if (this.#autoplay) {
189
+ try {
190
+ runtime.stage.start();
191
+ } catch {
192
+ }
193
+ }
194
+ this.dispatchEvent(
195
+ new CustomEvent("live2d-ready", {
196
+ bubbles: true,
197
+ composed: true,
198
+ detail: { model }
199
+ })
200
+ );
201
+ });
202
+ runtime.events.on("error", (payload) => {
203
+ if (gen !== this.#mountGen) return;
204
+ this.#emitError(payload?.error ?? "load error");
205
+ });
206
+ runtime.mount(canvas);
207
+ await runtime.loadModel(model);
208
+ if (gen !== this.#mountGen) {
209
+ runtime.destroy();
210
+ return;
211
+ }
212
+ this.#runtime = runtime;
213
+ } catch (err) {
214
+ if (gen !== this.#mountGen) return;
215
+ this.#emitError(err);
216
+ }
217
+ }
218
+ #emitError(error) {
219
+ this.setAttribute("data-phase", "error");
220
+ this.setAttribute("aria-busy", "false");
221
+ this.dispatchEvent(
222
+ new CustomEvent("live2d-error", {
223
+ bubbles: true,
224
+ composed: true,
225
+ detail: {
226
+ error: error instanceof Error ? error.message : String(error),
227
+ cause: error
228
+ }
229
+ })
230
+ );
231
+ }
232
+ };
233
+
234
+ // src/define.ts
235
+ var defined = false;
236
+ function defineLive2dElement() {
237
+ if (defined) return;
238
+ if (typeof customElements === "undefined") return;
239
+ if (customElements.get(LIVE2D_ELEMENT_TAG)) {
240
+ defined = true;
241
+ return;
242
+ }
243
+ customElements.define(LIVE2D_ELEMENT_TAG, Live2dElement);
244
+ defined = true;
245
+ }
246
+
247
+ // src/index.ts
248
+ defineLive2dElement();
249
+ export {
250
+ LIVE2D_ELEMENT_TAG,
251
+ Live2dElement,
252
+ defineLive2dElement
253
+ };
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>&lt;live-2d&gt; element fixture</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ font-family: "Segoe UI", system-ui, sans-serif;
11
+ }
12
+ body {
13
+ margin: 0;
14
+ min-height: 100vh;
15
+ display: grid;
16
+ place-content: center;
17
+ gap: 1rem;
18
+ background: linear-gradient(160deg, #f7f3ea, #dce9f4);
19
+ }
20
+ h1 {
21
+ margin: 0;
22
+ font-size: 1.25rem;
23
+ font-weight: 650;
24
+ }
25
+ #log {
26
+ max-width: 40rem;
27
+ font: 0.85rem/1.4 ui-monospace, monospace;
28
+ white-space: pre-wrap;
29
+ }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>live2d.ts &lt;live-2d&gt; fixture</h1>
34
+ <!-- Point model at a served model3.json (homepage public models or CDN). -->
35
+ <live-2d
36
+ id="actor"
37
+ model="/models/samples/moc3-wanko/Wanko.model3.json"
38
+ renderer="auto"
39
+ width="320"
40
+ height="320"
41
+ autoplay
42
+ ></live-2d>
43
+ <pre id="log">waiting…</pre>
44
+ <script type="module">
45
+ // Dev: resolve via workspace package entry after `pnpm install`.
46
+ import "@doki-land/live2d-element";
47
+
48
+ const log = document.getElementById("log");
49
+ const el = document.getElementById("actor");
50
+ el.addEventListener("live2d-ready", (e) => {
51
+ log.textContent = `live2d-ready ${JSON.stringify(e.detail)}`;
52
+ });
53
+ el.addEventListener("live2d-error", (e) => {
54
+ log.textContent = `live2d-error ${e.detail?.error ?? e.detail}`;
55
+ });
56
+ </script>
57
+ </body>
58
+ </html>
package/package.json CHANGED
@@ -1,10 +1,54 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-element",
3
- "version": "0.0.0",
4
- "description": "live2d.ts placeholdernot for production use.",
3
+ "version": "0.0.23",
4
+ "description": "Official <live-2d> Custom Element thin host over @doki-land/live2d (Stage-owned RAF).",
5
+ "type": "module",
5
6
  "license": "MIT",
6
- "private": false,
7
+ "author": "Doki Land",
8
+ "homepage": "https://github.com/doki-land/live2d.ts",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/doki-land/live2d.ts.git",
12
+ "directory": "projects/live2d-element"
13
+ },
14
+ "keywords": [
15
+ "live2d",
16
+ "custom-element",
17
+ "web-components",
18
+ "doki-land"
19
+ ],
20
+ "main": "./src/index.ts",
21
+ "types": "./src/index.ts",
22
+ "exports": {
23
+ ".": "./src/index.ts"
24
+ },
7
25
  "files": [
26
+ "dist",
27
+ "src",
28
+ "fixtures",
8
29
  "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "import": "./dist/index.js"
39
+ }
40
+ }
41
+ },
42
+ "scripts": {
43
+ "build": "tsup src/index.ts --format esm --dts",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run --passWithNoTests"
46
+ },
47
+ "dependencies": {
48
+ "@doki-land/live2d": "0.0.23"
49
+ },
50
+ "sideEffects": [
51
+ "./src/index.ts",
52
+ "./dist/index.js"
9
53
  ]
10
54
  }
package/src/define.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { LIVE2D_ELEMENT_TAG, Live2dElement } from "./live2d-element.js";
2
+
3
+ let defined = false;
4
+
5
+ /** Idempotent `customElements.define('live-2d', …)`. */
6
+ export function defineLive2dElement(): void {
7
+ if (defined) return;
8
+ if (typeof customElements === "undefined") return;
9
+ if (customElements.get(LIVE2D_ELEMENT_TAG)) {
10
+ defined = true;
11
+ return;
12
+ }
13
+ customElements.define(LIVE2D_ELEMENT_TAG, Live2dElement);
14
+ defined = true;
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@doki-land/live2d-element` — official `<live-2d>` Custom Element.
3
+ *
4
+ * v0.0.22 thin gate: pure TypeScript CE wrapping `@doki-land/live2d`
5
+ * (`createLive2D` + Stage-owned RAF). Zero VMZ runtime dependency.
6
+ * Design `01` still targets eventual `vmz build --target custom-element`
7
+ * when that CLI target ships in published `@vmz/vmz`; until then this
8
+ * package is the cross-ecosystem ABI skeleton.
9
+ */
10
+
11
+ export { defineLive2dElement } from "./define.js";
12
+ export {
13
+ LIVE2D_ELEMENT_TAG,
14
+ Live2dElement,
15
+ type Live2dElementRenderer,
16
+ } from "./live2d-element.js";
17
+
18
+ import { defineLive2dElement } from "./define.js";
19
+
20
+ /** Side-effect registration for `<script type="module">` consumers. */
21
+ defineLive2dElement();
@@ -0,0 +1,281 @@
1
+ import { createLive2D, type Live2DRuntime } from "@doki-land/live2d";
2
+
3
+ export const LIVE2D_ELEMENT_TAG = "live-2d" as const;
4
+
5
+ export type Live2dElementRenderer = "auto" | "webgpu" | "webgl2" | "canvas2d";
6
+
7
+ const OBSERVED = ["model", "renderer", "width", "height", "autoplay"] as const;
8
+
9
+ function preferFromRenderer(
10
+ renderer: Live2dElementRenderer,
11
+ ): Array<"webgpu" | "webgl2" | "canvas2d"> {
12
+ if (renderer === "webgpu") return ["webgpu", "webgl2", "canvas2d"];
13
+ if (renderer === "webgl2") return ["webgl2", "canvas2d", "webgpu"];
14
+ if (renderer === "canvas2d") return ["canvas2d", "webgl2", "webgpu"];
15
+ return ["webgpu", "webgl2", "canvas2d"];
16
+ }
17
+
18
+ function parseBoolAttr(
19
+ el: HTMLElement,
20
+ name: string,
21
+ fallback: boolean,
22
+ ): boolean {
23
+ if (!el.hasAttribute(name)) return fallback;
24
+ const v = el.getAttribute(name);
25
+ if (v === null || v === "" || v === name) return true;
26
+ if (v === "false" || v === "0") return false;
27
+ return true;
28
+ }
29
+
30
+ /**
31
+ * Thin `<live-2d>` host: attribute → createLive2D → Stage RAF → live2d-* events.
32
+ */
33
+ export class Live2dElement extends HTMLElement {
34
+ static get observedAttributes(): string[] {
35
+ return [...OBSERVED];
36
+ }
37
+
38
+ #runtime: Live2DRuntime | null = null;
39
+ #canvas: HTMLCanvasElement | null = null;
40
+ #mountGen = 0;
41
+ #model = "";
42
+ #renderer: Live2dElementRenderer = "auto";
43
+ #width = 320;
44
+ #height = 320;
45
+ #autoplay = true;
46
+ #connected = false;
47
+
48
+ get model(): string {
49
+ return this.#model;
50
+ }
51
+ set model(value: string) {
52
+ const next = String(value ?? "");
53
+ if (next) this.setAttribute("model", next);
54
+ else this.removeAttribute("model");
55
+ }
56
+
57
+ get renderer(): Live2dElementRenderer {
58
+ return this.#renderer;
59
+ }
60
+ set renderer(value: Live2dElementRenderer) {
61
+ this.setAttribute("renderer", (value || "auto") as string);
62
+ }
63
+
64
+ get width(): number {
65
+ return this.#width;
66
+ }
67
+ set width(value: number) {
68
+ this.setAttribute("width", String(Math.max(1, Number(value) || 320)));
69
+ }
70
+
71
+ get height(): number {
72
+ return this.#height;
73
+ }
74
+ set height(value: number) {
75
+ this.setAttribute("height", String(Math.max(1, Number(value) || 320)));
76
+ }
77
+
78
+ get autoplay(): boolean {
79
+ return this.#autoplay;
80
+ }
81
+ set autoplay(value: boolean) {
82
+ if (value) this.setAttribute("autoplay", "");
83
+ else this.removeAttribute("autoplay");
84
+ }
85
+
86
+ get runtime(): Live2DRuntime | null {
87
+ return this.#runtime;
88
+ }
89
+
90
+ /** Imperative reload (also used when `model` attribute changes). */
91
+ async loadModel(source?: string): Promise<void> {
92
+ if (source !== undefined) {
93
+ this.model = String(source);
94
+ }
95
+ await this.#boot();
96
+ }
97
+
98
+ connectedCallback(): void {
99
+ this.#connected = true;
100
+ this.#ensureCanvas();
101
+ this.#readAttributes();
102
+ void this.#boot();
103
+ }
104
+
105
+ disconnectedCallback(): void {
106
+ this.#connected = false;
107
+ this.#mountGen += 1;
108
+ this.#destroyRuntime();
109
+ }
110
+
111
+ attributeChangedCallback(
112
+ name: string,
113
+ _old: string | null,
114
+ value: string | null,
115
+ ): void {
116
+ if (name === "model") {
117
+ this.#model = value ?? "";
118
+ if (this.#connected) void this.#boot();
119
+ return;
120
+ }
121
+ if (name === "renderer") {
122
+ this.#renderer = (value || "auto") as Live2dElementRenderer;
123
+ if (this.#connected) void this.#boot();
124
+ return;
125
+ }
126
+ if (name === "width") {
127
+ this.#width = Math.max(1, Number(value) || 320);
128
+ this.#syncCanvasSize();
129
+ return;
130
+ }
131
+ if (name === "height") {
132
+ this.#height = Math.max(1, Number(value) || 320);
133
+ this.#syncCanvasSize();
134
+ return;
135
+ }
136
+ if (name === "autoplay") {
137
+ this.#autoplay = parseBoolAttr(this, "autoplay", true);
138
+ if (this.#runtime) {
139
+ if (this.#autoplay) this.#runtime.stage.start();
140
+ else this.#runtime.stage.pause();
141
+ }
142
+ }
143
+ }
144
+
145
+ #readAttributes(): void {
146
+ if (this.hasAttribute("model")) {
147
+ this.#model = this.getAttribute("model") ?? "";
148
+ }
149
+ if (this.hasAttribute("renderer")) {
150
+ this.#renderer = (this.getAttribute("renderer") ||
151
+ "auto") as Live2dElementRenderer;
152
+ }
153
+ if (this.hasAttribute("width")) {
154
+ this.#width = Math.max(
155
+ 1,
156
+ Number(this.getAttribute("width")) || 320,
157
+ );
158
+ }
159
+ if (this.hasAttribute("height")) {
160
+ this.#height = Math.max(
161
+ 1,
162
+ Number(this.getAttribute("height")) || 320,
163
+ );
164
+ }
165
+ this.#autoplay = parseBoolAttr(this, "autoplay", true);
166
+ }
167
+
168
+ #ensureCanvas(): HTMLCanvasElement {
169
+ if (this.#canvas?.isConnected) return this.#canvas;
170
+ this.replaceChildren();
171
+ const canvas = document.createElement("canvas");
172
+ canvas.setAttribute("part", "canvas");
173
+ this.appendChild(canvas);
174
+ this.#canvas = canvas;
175
+ this.#syncCanvasSize();
176
+ return canvas;
177
+ }
178
+
179
+ #syncCanvasSize(): void {
180
+ const canvas = this.#canvas;
181
+ if (!canvas) return;
182
+ const dpr =
183
+ typeof globalThis.devicePixelRatio === "number"
184
+ ? Math.min(globalThis.devicePixelRatio, 2)
185
+ : 1;
186
+ canvas.width = Math.max(1, Math.round(this.#width * dpr));
187
+ canvas.height = Math.max(1, Math.round(this.#height * dpr));
188
+ canvas.style.width = `${this.#width}px`;
189
+ canvas.style.height = `${this.#height}px`;
190
+ this.style.display = this.style.display || "inline-block";
191
+ this.#runtime?.stage.resize?.(this.#width, this.#height);
192
+ }
193
+
194
+ #destroyRuntime(): void {
195
+ try {
196
+ this.#runtime?.stage.stop?.();
197
+ } catch {
198
+ /* manual / already stopped */
199
+ }
200
+ this.#runtime?.destroy?.();
201
+ this.#runtime = null;
202
+ this.removeAttribute("data-phase");
203
+ this.removeAttribute("aria-busy");
204
+ }
205
+
206
+ async #boot(): Promise<void> {
207
+ if (!this.#connected) return;
208
+ const model = this.#model.trim();
209
+ if (!model) return;
210
+
211
+ const gen = ++this.#mountGen;
212
+ this.#destroyRuntime();
213
+
214
+ const canvas = this.#ensureCanvas();
215
+ this.setAttribute("data-phase", "loading");
216
+ this.setAttribute("aria-busy", "true");
217
+
218
+ try {
219
+ const runtime = createLive2D({
220
+ prefer: preferFromRenderer(this.#renderer),
221
+ updateMode: "auto",
222
+ });
223
+ if (gen !== this.#mountGen) {
224
+ runtime.destroy();
225
+ return;
226
+ }
227
+
228
+ runtime.events.on("ready", () => {
229
+ if (gen !== this.#mountGen) return;
230
+ this.setAttribute("data-phase", "live");
231
+ this.setAttribute("aria-busy", "false");
232
+ if (this.#autoplay) {
233
+ try {
234
+ runtime.stage.start();
235
+ } catch {
236
+ /* already running */
237
+ }
238
+ }
239
+ this.dispatchEvent(
240
+ new CustomEvent("live2d-ready", {
241
+ bubbles: true,
242
+ composed: true,
243
+ detail: { model },
244
+ }),
245
+ );
246
+ });
247
+
248
+ runtime.events.on("error", (payload) => {
249
+ if (gen !== this.#mountGen) return;
250
+ this.#emitError(payload?.error ?? "load error");
251
+ });
252
+
253
+ runtime.mount(canvas);
254
+ await runtime.loadModel(model);
255
+ if (gen !== this.#mountGen) {
256
+ runtime.destroy();
257
+ return;
258
+ }
259
+ this.#runtime = runtime;
260
+ } catch (err) {
261
+ if (gen !== this.#mountGen) return;
262
+ this.#emitError(err);
263
+ }
264
+ }
265
+
266
+ #emitError(error: unknown): void {
267
+ this.setAttribute("data-phase", "error");
268
+ this.setAttribute("aria-busy", "false");
269
+ this.dispatchEvent(
270
+ new CustomEvent("live2d-error", {
271
+ bubbles: true,
272
+ composed: true,
273
+ detail: {
274
+ error:
275
+ error instanceof Error ? error.message : String(error),
276
+ cause: error,
277
+ },
278
+ }),
279
+ );
280
+ }
281
+ }