@empty-sekai/renderer-wasm 0.1.0

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,449 @@
1
+ /**
2
+ * allium-renderer-wasm 核心封装。
3
+ *
4
+ * 直接在当前线程持有 emscripten 模块并调用 C ABI。skia CPU 光栅化是
5
+ * 同步阻塞调用——主线程使用会卡 UI,故浏览器中建议用 `./worker` 的
6
+ * Worker 客户端(本类在 Worker 内运行)。两者共享下方调用约定。
7
+ *
8
+ * 资源注入责任在使用方:本包不内嵌任何字体 / masterdata / 素材。
9
+ * - 字体:`registerFont(family, bytes)`,缺字体的文本元素不渲染。
10
+ * - masterdata:`loadMasterData(name, json)` 逐表注入后 `init()`。
11
+ * - 素材:`putAsset(key, bytes)`(key 由 `collectAssetKeys` 给出)。
12
+ */
13
+
14
+ import type { EmscriptenModule, EmscriptenModuleFactory } from "./emscripten.js";
15
+
16
+ /** 输出图片格式。 */
17
+ export enum ImageFormat {
18
+ Jpeg = 0,
19
+ Png = 1,
20
+ PngTransparent = 2,
21
+ }
22
+
23
+ /** C ABI 错误(携带来自 `alr_last_error` 的引擎错误文本)。 */
24
+ export class AlliumRenderError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "AlliumRenderError";
28
+ }
29
+ }
30
+
31
+ /** {@link AlliumRenderer.renderLayerCropped} 的输出:WebP 字节 + 画布坐标系裁剪框。 */
32
+ export interface CroppedLayerOutput {
33
+ /** 裁剪后的 WebP 编码字节。 */
34
+ data: Uint8Array;
35
+ /** 裁剪框左上角 X(原画布坐标系)。 */
36
+ x: number;
37
+ /** 裁剪框左上角 Y(原画布坐标系)。 */
38
+ y: number;
39
+ /** 裁剪框宽度(像素)。完全透明时为 0。 */
40
+ width: number;
41
+ /** 裁剪框高度(像素)。完全透明时为 0。 */
42
+ height: number;
43
+ }
44
+
45
+ /** {@link AlliumRenderer.renderAllLayers} 单层输出(WebP 字节 + 元数据)。 */
46
+ export interface LayerCrop {
47
+ /** layer 升序的 0-based 序号。 */
48
+ z: number;
49
+ /** 元素类型 "text" / "card_member" / ...。 */
50
+ type: string;
51
+ /** 原始可见性(调用方可自行覆盖)。不可见层 `data` 为空。 */
52
+ original_visible: boolean;
53
+ /** 裁剪后的 WebP 字节;不可见层为空 Uint8Array。 */
54
+ data: Uint8Array;
55
+ /** 裁剪框(原画布坐标系);不可见层全 0。 */
56
+ x: number;
57
+ y: number;
58
+ width: number;
59
+ height: number;
60
+ /** 元素属性(字体名/颜色 hex/文本等),仅 `includeProperties=true` 时存在。 */
61
+ properties?: Record<string, unknown>;
62
+ }
63
+
64
+ /** cwrap 出来的 C 函数签名集合。 */
65
+ interface Exports {
66
+ alloc: (size: number) => number;
67
+ free: (ptr: number, size: number) => void;
68
+ lastError: (lenPtr: number) => number;
69
+ loadMasterdata: (n: number, nl: number, j: number, jl: number) => number;
70
+ registerFont: (f: number, fl: number, b: number, bl: number) => number;
71
+ init: () => number;
72
+ collectAssetKeys: (c: number, cl: number, outPtr: number, outLen: number) => number;
73
+ putAsset: (k: number, kl: number, b: number, bl: number) => number;
74
+ render: (
75
+ c: number,
76
+ cl: number,
77
+ p: number,
78
+ pl: number,
79
+ fmt: number,
80
+ outPtr: number,
81
+ outLen: number,
82
+ ) => number;
83
+ renderLayerCropped: (
84
+ c: number,
85
+ cl: number,
86
+ p: number,
87
+ pl: number,
88
+ quality: number,
89
+ outPtr: number,
90
+ outLen: number,
91
+ outRect: number,
92
+ ) => number;
93
+ renderAllLayers: (
94
+ c: number,
95
+ cl: number,
96
+ p: number,
97
+ pl: number,
98
+ quality: number,
99
+ includeProps: number,
100
+ outMetaPtr: number,
101
+ outMetaLen: number,
102
+ outBlobPtr: number,
103
+ outBlobLen: number,
104
+ ) => number;
105
+ }
106
+
107
+ const textEncoder = new TextEncoder();
108
+ const textDecoder = new TextDecoder("utf-8");
109
+
110
+ export class AlliumRenderer {
111
+ private constructor(
112
+ private readonly mod: EmscriptenModule,
113
+ private readonly ex: Exports,
114
+ ) {}
115
+
116
+ /**
117
+ * 加载 wasm 模块并构造封装。
118
+ *
119
+ * @param factory 构建产物 `allium_renderer_wasm.js` 的默认导出。
120
+ * @param wasmUrl `.wasm` 文件 URL(默认让 emscripten 在 .js 旁解析;
121
+ * 打包/CDN 场景显式传入更可靠)。
122
+ */
123
+ static async create(
124
+ factory: EmscriptenModuleFactory,
125
+ wasmUrl?: string | URL,
126
+ ): Promise<AlliumRenderer> {
127
+ const mod = await factory(
128
+ wasmUrl
129
+ ? { locateFile: (path) => (path.endsWith(".wasm") ? String(wasmUrl) : path) }
130
+ : undefined,
131
+ );
132
+ const ex: Exports = {
133
+ alloc: mod.cwrap("alr_alloc", "number", ["number"]),
134
+ free: mod.cwrap("alr_free", "void", ["number", "number"]),
135
+ lastError: mod.cwrap("alr_last_error", "number", ["number"]),
136
+ loadMasterdata: mod.cwrap("alr_load_masterdata", "number", [
137
+ "number",
138
+ "number",
139
+ "number",
140
+ "number",
141
+ ]),
142
+ registerFont: mod.cwrap("alr_register_font", "number", [
143
+ "number",
144
+ "number",
145
+ "number",
146
+ "number",
147
+ ]),
148
+ init: mod.cwrap("alr_init", "number", []),
149
+ collectAssetKeys: mod.cwrap("alr_collect_asset_keys", "number", [
150
+ "number",
151
+ "number",
152
+ "number",
153
+ "number",
154
+ ]),
155
+ putAsset: mod.cwrap("alr_put_asset", "number", [
156
+ "number",
157
+ "number",
158
+ "number",
159
+ "number",
160
+ ]),
161
+ render: mod.cwrap("alr_render", "number", [
162
+ "number",
163
+ "number",
164
+ "number",
165
+ "number",
166
+ "number",
167
+ "number",
168
+ "number",
169
+ ]),
170
+ renderLayerCropped: mod.cwrap("alr_render_layer_cropped", "number", [
171
+ "number",
172
+ "number",
173
+ "number",
174
+ "number",
175
+ "number",
176
+ "number",
177
+ "number",
178
+ "number",
179
+ ]),
180
+ renderAllLayers: mod.cwrap("alr_render_all_layers", "number", [
181
+ "number",
182
+ "number",
183
+ "number",
184
+ "number",
185
+ "number",
186
+ "number",
187
+ "number",
188
+ "number",
189
+ "number",
190
+ "number",
191
+ ]),
192
+ };
193
+ return new AlliumRenderer(mod, ex);
194
+ }
195
+
196
+ /** 注入一张 masterdata 表(JSON 文本)。须在 {@link init} 前调用。 */
197
+ loadMasterData(name: string, json: string): void {
198
+ const n = this.pushStr(name);
199
+ const j = this.pushStr(json);
200
+ try {
201
+ this.check(this.ex.loadMasterdata(n.ptr, n.len, j.ptr, j.len));
202
+ } finally {
203
+ this.popBuf(j);
204
+ this.popBuf(n);
205
+ }
206
+ }
207
+
208
+ /** 注册内存字体(family 名 + 字体文件字节)。本包不内嵌字体,必须显式注入。 */
209
+ registerFont(family: string, bytes: Uint8Array): void {
210
+ const f = this.pushStr(family);
211
+ const b = this.pushBytes(bytes);
212
+ try {
213
+ this.check(this.ex.registerFont(f.ptr, f.len, b.ptr, b.len));
214
+ } finally {
215
+ this.popBuf(b);
216
+ this.popBuf(f);
217
+ }
218
+ }
219
+
220
+ /** 用已注入的表构建渲染器。重复调用等效热替换 masterdata。 */
221
+ init(): void {
222
+ this.check(this.ex.init());
223
+ }
224
+
225
+ /** 收集名片所需素材 key({@link init} 之后调用)。 */
226
+ collectAssetKeys(cardJson: string): string[] {
227
+ const c = this.pushStr(cardJson);
228
+ const outPtr = this.mod._malloc(4);
229
+ const outLen = this.mod._malloc(4);
230
+ try {
231
+ this.check(this.ex.collectAssetKeys(c.ptr, c.len, outPtr, outLen));
232
+ const json = this.takeOutput(outPtr, outLen);
233
+ return JSON.parse(textDecoder.decode(json)) as string[];
234
+ } finally {
235
+ this.mod._free(outLen);
236
+ this.mod._free(outPtr);
237
+ this.popBuf(c);
238
+ }
239
+ }
240
+
241
+ /** 注入素材(key + 编码图片字节)。 */
242
+ putAsset(key: string, bytes: Uint8Array): void {
243
+ const k = this.pushStr(key);
244
+ const b = this.pushBytes(bytes);
245
+ try {
246
+ this.check(this.ex.putAsset(k.ptr, k.len, b.ptr, b.len));
247
+ } finally {
248
+ this.popBuf(b);
249
+ this.popBuf(k);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * 渲染名片,返回编码图片字节。
255
+ *
256
+ * @param cardJson `CustomProfileCard` 或 `UserCustomProfileCard[]`(取首张)JSON。
257
+ * @param format 输出格式(默认 JPEG)。
258
+ * @param profileJson 可选 profile API 响应 JSON(注入 generals / 称号等级)。
259
+ */
260
+ render(
261
+ cardJson: string,
262
+ format: ImageFormat = ImageFormat.Jpeg,
263
+ profileJson?: string,
264
+ ): Uint8Array {
265
+ const c = this.pushStr(cardJson);
266
+ const p = profileJson ? this.pushStr(profileJson) : { ptr: 0, len: 0 };
267
+ const outPtr = this.mod._malloc(4);
268
+ const outLen = this.mod._malloc(4);
269
+ try {
270
+ this.check(
271
+ this.ex.render(c.ptr, c.len, p.ptr, p.len, format, outPtr, outLen),
272
+ );
273
+ return this.takeOutput(outPtr, outLen);
274
+ } finally {
275
+ this.mod._free(outLen);
276
+ this.mod._free(outPtr);
277
+ if (p.ptr) this.popBuf(p as Buf);
278
+ this.popBuf(c);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 分层裁剪渲染:所有可见元素绘到透明画布,裁剪到不透明像素的紧凑包围盒,
284
+ * 编码为 WebP,并返回裁剪框在原画布坐标系的偏移。
285
+ *
286
+ * @param cardJson `CustomProfileCard` 或 `UserCustomProfileCard[]`(取首张)JSON。
287
+ * @param quality WebP 质量(0-100,默认 80)。
288
+ * @param profileJson 可选 profile API 响应 JSON(注入 generals / 称号等级)。
289
+ */
290
+ renderLayerCropped(
291
+ cardJson: string,
292
+ quality = 80,
293
+ profileJson?: string,
294
+ ): CroppedLayerOutput {
295
+ const c = this.pushStr(cardJson);
296
+ const p = profileJson ? this.pushStr(profileJson) : { ptr: 0, len: 0 };
297
+ const outPtr = this.mod._malloc(4);
298
+ const outLen = this.mod._malloc(4);
299
+ const outRect = this.mod._malloc(16); // 4 × u32: x, y, width, height
300
+ try {
301
+ this.check(
302
+ this.ex.renderLayerCropped(
303
+ c.ptr,
304
+ c.len,
305
+ p.ptr,
306
+ p.len,
307
+ quality,
308
+ outPtr,
309
+ outLen,
310
+ outRect,
311
+ ),
312
+ );
313
+ const data = this.takeOutput(outPtr, outLen);
314
+ return {
315
+ data,
316
+ x: this.mod.getValue(outRect, "i32") >>> 0,
317
+ y: this.mod.getValue(outRect + 4, "i32") >>> 0,
318
+ width: this.mod.getValue(outRect + 8, "i32") >>> 0,
319
+ height: this.mod.getValue(outRect + 12, "i32") >>> 0,
320
+ };
321
+ } finally {
322
+ this.mod._free(outRect);
323
+ this.mod._free(outLen);
324
+ this.mod._free(outPtr);
325
+ if (p.ptr) this.popBuf(p as Buf);
326
+ this.popBuf(c);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 批量分层裁剪渲染:把名片按 layer 升序逐元素渲成裁剪 WebP,一次 FFI
332
+ * 拿全部 N 层。
333
+ *
334
+ * @param cardJson `CustomProfileCard` 或 `UserCustomProfileCard[]`(取首张)JSON。
335
+ * @param quality WebP 质量 0-100(默认 80)。
336
+ * @param includeProperties 是否填充每层 `properties`(字体名/颜色 hex/文本等);
337
+ * 不需要时关掉省一遍 masterdata 查询。
338
+ * @param profileJson 可选 profile API 响应 JSON(注入 generals / 称号等级)。
339
+ *
340
+ * 返回数组顺序 = layer 升序 = z 序号;不可见元素也在结果中(`data` 为空、
341
+ * rect 全 0),便于完整重建图层列表。
342
+ */
343
+ renderAllLayers(
344
+ cardJson: string,
345
+ quality = 80,
346
+ includeProperties = true,
347
+ profileJson?: string,
348
+ ): LayerCrop[] {
349
+ const c = this.pushStr(cardJson);
350
+ const p = profileJson ? this.pushStr(profileJson) : { ptr: 0, len: 0 };
351
+ const outMetaPtr = this.mod._malloc(4);
352
+ const outMetaLen = this.mod._malloc(4);
353
+ const outBlobPtr = this.mod._malloc(4);
354
+ const outBlobLen = this.mod._malloc(4);
355
+ try {
356
+ this.check(
357
+ this.ex.renderAllLayers(
358
+ c.ptr, c.len, p.ptr, p.len,
359
+ quality,
360
+ includeProperties ? 1 : 0,
361
+ outMetaPtr, outMetaLen,
362
+ outBlobPtr, outBlobLen,
363
+ ),
364
+ );
365
+ const metaBytes = this.takeOutput(outMetaPtr, outMetaLen);
366
+ const blobBytes = this.takeOutput(outBlobPtr, outBlobLen);
367
+ const meta = JSON.parse(textDecoder.decode(metaBytes)) as Array<{
368
+ z: number;
369
+ type: string;
370
+ original_visible: boolean;
371
+ x: number; y: number; width: number; height: number;
372
+ byte_offset: number; byte_length: number;
373
+ properties?: Record<string, unknown>;
374
+ }>;
375
+ // 按 meta 切 blob。slice() 复制一份独立 Uint8Array(blobBytes 是 takeOutput
376
+ // 复制出来的,引用切片虽然便宜但生命周期不直观;这里就显式复制)。
377
+ return meta.map((m) => ({
378
+ z: m.z,
379
+ type: m.type,
380
+ original_visible: m.original_visible,
381
+ x: m.x, y: m.y, width: m.width, height: m.height,
382
+ data: m.byte_length > 0
383
+ ? blobBytes.slice(m.byte_offset, m.byte_offset + m.byte_length)
384
+ : new Uint8Array(0),
385
+ properties: m.properties,
386
+ }));
387
+ } finally {
388
+ this.mod._free(outBlobLen);
389
+ this.mod._free(outBlobPtr);
390
+ this.mod._free(outMetaLen);
391
+ this.mod._free(outMetaPtr);
392
+ if (p.ptr) this.popBuf(p as Buf);
393
+ this.popBuf(c);
394
+ }
395
+ }
396
+
397
+ // ---- 内部 marshalling ----
398
+
399
+ private pushStr(s: string): Buf {
400
+ const bytes = textEncoder.encode(s);
401
+ return this.pushBytes(bytes);
402
+ }
403
+
404
+ private pushBytes(bytes: Uint8Array): Buf {
405
+ const len = bytes.length;
406
+ // 长度 0 也分配 1 字节,避免 0 长度指针歧义。
407
+ const ptr = this.ex.alloc(Math.max(len, 1));
408
+ if (ptr === 0) throw new AlliumRenderError("alr_alloc 返回空指针(内存不足)");
409
+ this.mod.HEAPU8.set(bytes, ptr);
410
+ return { ptr, len, cap: Math.max(len, 1) };
411
+ }
412
+
413
+ private popBuf(buf: Buf): void {
414
+ this.ex.free(buf.ptr, buf.cap);
415
+ }
416
+
417
+ /** 读取 `*out_ptr`/`*out_len` 指向的引擎输出缓冲并复制出来,随后 alr_free 释放。 */
418
+ private takeOutput(outPtrPtr: number, outLenPtr: number): Uint8Array {
419
+ const dataPtr = this.mod.getValue(outPtrPtr, "*");
420
+ const dataLen = this.mod.getValue(outLenPtr, "*") >>> 0;
421
+ // 复制出线性内存(HEAPU8 可能在后续调用因内存增长失效)。
422
+ const copy = this.mod.HEAPU8.slice(dataPtr, dataPtr + dataLen);
423
+ this.ex.free(dataPtr, dataLen);
424
+ return copy;
425
+ }
426
+
427
+ private check(code: number): void {
428
+ if (code === 0) return;
429
+ throw new AlliumRenderError(this.readLastError());
430
+ }
431
+
432
+ private readLastError(): string {
433
+ const lenPtr = this.mod._malloc(4);
434
+ try {
435
+ const ptr = this.ex.lastError(lenPtr);
436
+ const len = this.mod.getValue(lenPtr, "*") >>> 0;
437
+ if (ptr === 0 || len === 0) return "未知错误";
438
+ return textDecoder.decode(this.mod.HEAPU8.slice(ptr, ptr + len));
439
+ } finally {
440
+ this.mod._free(lenPtr);
441
+ }
442
+ }
443
+ }
444
+
445
+ interface Buf {
446
+ ptr: number;
447
+ len: number;
448
+ cap: number;
449
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Worker 客户端:在主线程驱动渲染 Worker,返回 Promise。
3
+ *
4
+ * 这是浏览器中的推荐入口——skia 光栅化在 Worker 内同步执行,不阻塞 UI。
5
+ *
6
+ * ```ts
7
+ * import { AlliumWorkerClient } from "@empty-sekai/renderer-wasm/worker";
8
+ *
9
+ * const client = await AlliumWorkerClient.spawn({
10
+ * workerUrl: new URL("@empty-sekai/renderer-wasm/worker.js", import.meta.url),
11
+ * moduleUrl: new URL("@empty-sekai/renderer-wasm/allium_renderer_wasm.js", import.meta.url).href,
12
+ * });
13
+ * const jpeg = await client.render({ cardJson, masterData, fonts, assets });
14
+ * ```
15
+ */
16
+
17
+ import { ImageFormat } from "./renderer.js";
18
+ import type {
19
+ RequestMessage,
20
+ ResponseMessage,
21
+ RenderRequest,
22
+ CroppedLayerOutput,
23
+ LayerCrop,
24
+ } from "./protocol.js";
25
+
26
+ export { ImageFormat };
27
+ export type { RenderRequest, CroppedLayerOutput, LayerCrop } from "./protocol.js";
28
+
29
+ export interface SpawnOptions {
30
+ /** Worker 脚本 URL(指向打包后的 `worker.js`)。 */
31
+ workerUrl: string | URL;
32
+ /** wasm 工厂模块 URL(Worker 内 import)。 */
33
+ moduleUrl: string;
34
+ /** `.wasm` 文件 URL(可选)。 */
35
+ wasmUrl?: string;
36
+ }
37
+
38
+ export class AlliumWorkerClient {
39
+ private nextId = 1;
40
+ private readonly pending = new Map<
41
+ number,
42
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
43
+ >();
44
+
45
+ private constructor(private readonly worker: Worker) {
46
+ this.worker.onmessage = (ev: MessageEvent<ResponseMessage>) => {
47
+ const msg = ev.data;
48
+ const entry = this.pending.get(msg.id);
49
+ if (!entry) return;
50
+ this.pending.delete(msg.id);
51
+ if (msg.ok) entry.resolve(msg.result);
52
+ else entry.reject(new Error(msg.error));
53
+ };
54
+ this.worker.onerror = (ev) => {
55
+ const err = new Error(ev.message || "Worker 错误");
56
+ for (const { reject } of this.pending.values()) reject(err);
57
+ this.pending.clear();
58
+ };
59
+ }
60
+
61
+ /** 启动 Worker 并完成初始化握手。 */
62
+ static async spawn(opts: SpawnOptions): Promise<AlliumWorkerClient> {
63
+ const worker = new Worker(opts.workerUrl, { type: "module" });
64
+ const client = new AlliumWorkerClient(worker);
65
+ await client.post({
66
+ id: client.nextId++,
67
+ kind: "init",
68
+ payload: { moduleUrl: opts.moduleUrl, wasmUrl: opts.wasmUrl },
69
+ });
70
+ return client;
71
+ }
72
+
73
+ /** 渲染名片,返回编码图片字节。 */
74
+ async render(req: RenderRequest): Promise<Uint8Array> {
75
+ const result = await this.post(
76
+ { id: this.nextId++, kind: "render", payload: req },
77
+ this.collectTransfer(req),
78
+ );
79
+ return result as Uint8Array;
80
+ }
81
+
82
+ /**
83
+ * 分层裁剪渲染,返回裁剪后的 WebP 字节及其在原画布的偏移。
84
+ */
85
+ async renderLayerCropped(req: RenderRequest): Promise<CroppedLayerOutput> {
86
+ const result = await this.post(
87
+ { id: this.nextId++, kind: "renderLayerCropped", payload: req },
88
+ this.collectTransfer(req),
89
+ );
90
+ return result as CroppedLayerOutput;
91
+ }
92
+
93
+ /**
94
+ * 批量分层裁剪渲染:一次返回所有元素的 WebP 字节 + 元数据。
95
+ */
96
+ async renderAllLayers(req: RenderRequest): Promise<LayerCrop[]> {
97
+ const result = await this.post(
98
+ { id: this.nextId++, kind: "renderAllLayers", payload: req },
99
+ this.collectTransfer(req),
100
+ );
101
+ return result as LayerCrop[];
102
+ }
103
+
104
+ /** 收集名片所需素材 key。 */
105
+ async collectAssetKeys(
106
+ cardJson: string,
107
+ masterData: Record<string, string>,
108
+ ): Promise<string[]> {
109
+ const result = await this.post({
110
+ id: this.nextId++,
111
+ kind: "collectAssetKeys",
112
+ payload: { cardJson, masterData },
113
+ });
114
+ return result as string[];
115
+ }
116
+
117
+ /** 终止 Worker,拒绝所有在途请求。 */
118
+ terminate(): void {
119
+ this.worker.terminate();
120
+ for (const { reject } of this.pending.values()) {
121
+ reject(new Error("Worker 已终止"));
122
+ }
123
+ this.pending.clear();
124
+ }
125
+
126
+ private collectTransfer(req: RenderRequest): Transferable[] {
127
+ // 转移字体/素材的 ArrayBuffer,避免结构化克隆复制大缓冲。
128
+ // 注意:转移后调用方的 Uint8Array 会失效(detached);如需复用请先复制。
129
+ const transfer: Transferable[] = [];
130
+ for (const f of req.fonts) transfer.push(f.bytes.buffer);
131
+ for (const a of req.assets) transfer.push(a.bytes.buffer);
132
+ return transfer;
133
+ }
134
+
135
+ private post(msg: RequestMessage, transfer: Transferable[] = []): Promise<unknown> {
136
+ return new Promise((resolve, reject) => {
137
+ this.pending.set(msg.id, { resolve, reject });
138
+ this.worker.postMessage(msg, transfer);
139
+ });
140
+ }
141
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 渲染 Worker 入口。在 Worker 线程加载 wasm 并串行处理请求。
3
+ *
4
+ * 用法(主线程通过 `./worker-client` 间接使用,或手动):
5
+ * ```ts
6
+ * const worker = new Worker(
7
+ * new URL("@empty-sekai/renderer-wasm/worker.js", import.meta.url),
8
+ * { type: "module" },
9
+ * );
10
+ * ```
11
+ *
12
+ * 协议见 `./protocol`。请求严格串行(单 AlliumRenderer 实例)。
13
+ */
14
+
15
+ import { AlliumRenderer, ImageFormat } from "./renderer.js";
16
+ import type {
17
+ EmscriptenModuleFactory,
18
+ } from "./emscripten.js";
19
+ import type {
20
+ RequestMessage,
21
+ ResponseMessage,
22
+ RenderRequest,
23
+ InitPayload,
24
+ } from "./protocol.js";
25
+
26
+ let renderer: AlliumRenderer | null = null;
27
+ let moduleUrl: string | null = null;
28
+ let wasmUrl: string | undefined;
29
+
30
+ async function ensureRenderer(): Promise<AlliumRenderer> {
31
+ if (renderer) return renderer;
32
+ if (!moduleUrl) throw new Error("Worker 未初始化(先发送 init 消息)");
33
+ const factoryModule = (await import(/* @vite-ignore */ moduleUrl)) as {
34
+ default: EmscriptenModuleFactory;
35
+ };
36
+ renderer = await AlliumRenderer.create(factoryModule.default, wasmUrl);
37
+ return renderer;
38
+ }
39
+
40
+ function handleInit(payload: InitPayload): void {
41
+ moduleUrl = payload.moduleUrl;
42
+ wasmUrl = payload.wasmUrl;
43
+ renderer = null; // 下次请求时按新 URL 重建
44
+ }
45
+
46
+ function applyInputs(r: AlliumRenderer, req: RenderRequest): void {
47
+ for (const { family, bytes } of req.fonts) r.registerFont(family, bytes);
48
+ for (const [name, json] of Object.entries(req.masterData)) {
49
+ r.loadMasterData(name, json);
50
+ }
51
+ r.init();
52
+ for (const { key, bytes } of req.assets) r.putAsset(key, bytes);
53
+ }
54
+
55
+ async function handle(msg: RequestMessage): Promise<{ result?: unknown; transfer: Transferable[] }> {
56
+ switch (msg.kind) {
57
+ case "init": {
58
+ handleInit(msg.payload);
59
+ return { transfer: [] };
60
+ }
61
+ case "render": {
62
+ const r = await ensureRenderer();
63
+ applyInputs(r, msg.payload);
64
+ const out = r.render(
65
+ msg.payload.cardJson,
66
+ msg.payload.format ?? ImageFormat.Jpeg,
67
+ msg.payload.profileJson,
68
+ );
69
+ return { result: out, transfer: [out.buffer] };
70
+ }
71
+ case "renderLayerCropped": {
72
+ const r = await ensureRenderer();
73
+ applyInputs(r, msg.payload);
74
+ const out = r.renderLayerCropped(
75
+ msg.payload.cardJson,
76
+ msg.payload.quality ?? 80,
77
+ msg.payload.profileJson,
78
+ );
79
+ return { result: out, transfer: [out.data.buffer] };
80
+ }
81
+ case "renderAllLayers": {
82
+ const r = await ensureRenderer();
83
+ applyInputs(r, msg.payload);
84
+ const layers = r.renderAllLayers(
85
+ msg.payload.cardJson,
86
+ msg.payload.quality ?? 80,
87
+ msg.payload.includeProperties ?? true,
88
+ msg.payload.profileJson,
89
+ );
90
+ // 每层 data 是独立 Uint8Array,全部 transfer 回主线程,避免大缓冲克隆。
91
+ const transfer: Transferable[] = layers
92
+ .map((l) => l.data.buffer)
93
+ .filter((b) => b.byteLength > 0);
94
+ return { result: layers, transfer };
95
+ }
96
+ case "collectAssetKeys": {
97
+ const r = await ensureRenderer();
98
+ for (const [name, json] of Object.entries(msg.payload.masterData)) {
99
+ r.loadMasterData(name, json);
100
+ }
101
+ r.init();
102
+ const keys = r.collectAssetKeys(msg.payload.cardJson);
103
+ return { result: keys, transfer: [] };
104
+ }
105
+ }
106
+ }
107
+
108
+ self.onmessage = async (ev: MessageEvent<RequestMessage>) => {
109
+ const msg = ev.data;
110
+ try {
111
+ const { result, transfer } = await handle(msg);
112
+ const res: ResponseMessage = { id: msg.id, ok: true, result };
113
+ (self as unknown as Worker).postMessage(res, transfer);
114
+ } catch (err) {
115
+ const res: ResponseMessage = {
116
+ id: msg.id,
117
+ ok: false,
118
+ error: err instanceof Error ? err.message : String(err),
119
+ };
120
+ (self as unknown as Worker).postMessage(res);
121
+ }
122
+ };