@awflow/avatar-export 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doan Labs
4
+ Copyright (c) 2026 AWFlow
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # @awflow/avatar-export
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ AWFlow avatars as PNG, animated PNG, GIF and sprite sheets, in Node and the
4
+ browser. Rasterised by resvg compiled to WebAssembly, so the same name and
5
+ options give the same bytes on every machine. Built on Peek by Doan Labs.
6
+
7
+ ```sh
8
+ npm install @awflow/avatar-export @awflow/avatar
9
+ ```
10
+
11
+ ## Use
12
+
13
+ Every function returns a `Promise<Uint8Array>`. In Node, Bun and Deno the
14
+ wasm loads on first use; nothing to set up.
15
+
16
+ ```ts
17
+ import { toApng, toGif, toPng, toSprite } from '@awflow/avatar-export'
18
+
19
+ // a still PNG, 256 px square by default
20
+ const png = await toPng('Linh', { expression: 'happy' }, { size: 512 })
21
+
22
+ // an animated PNG: lossless, full alpha, 24 fps for 2 s by default
23
+ const apng = await toApng('Linh', { reaction: 'hop' }, { fps: 30, seconds: 3 })
24
+
25
+ // a GIF with one 256-colour palette, transparent where nothing is drawn
26
+ const gif = await toGif('Linh', { frame: 'none', activity: 'done' })
27
+
28
+ // many avatars on one sheet: 3 columns of 64 px tiles, 4 px apart
29
+ const sheet = await toSprite(['Ada', 'Linh', 'Sam'], {}, {
30
+ size: 64,
31
+ columns: 3,
32
+ gap: 4,
33
+ })
34
+ ```
35
+
36
+ Write the bytes anywhere: `await Bun.write('linh.png', png)`,
37
+ `fs.writeFileSync('linh.gif', gif)`, or
38
+ `URL.createObjectURL(new Blob([png], { type: 'image/png' }))`.
39
+
40
+ ## Browsers
41
+
42
+ Load the wasm once with `init` before the first export. It takes a
43
+ `fetch()` response (or its promise), an `ArrayBuffer`, typed array or a `URL`.
44
+ The wasm file comes from `@resvg/resvg-wasm`, which this package depends on.
45
+ Importing it by path from your own code needs it as a direct dependency under
46
+ strict package managers (pnpm), at the exact version this package uses:
47
+
48
+ ```sh
49
+ npm install @resvg/resvg-wasm@2.6.2
50
+ ```
51
+
52
+ ```ts
53
+ import { init, toPng } from '@awflow/avatar-export'
54
+ import wasmUrl from '@resvg/resvg-wasm/index_bg.wasm?url' // Vite
55
+
56
+ await init(fetch(wasmUrl))
57
+ const png = await toPng('Linh')
58
+ ```
59
+
60
+ `init` is idempotent: concurrent calls share one load, and later calls
61
+ return at once.
62
+
63
+ ## Limits
64
+
65
+ `LIMITS` is
66
+ `{ maxFrames: 600, maxSize: 1024, maxSprite: 400, maxSheet: 16777216 }`, with
67
+ `maxFrames` taken from the core's `FRAME_LIMITS` (1 to 120 fps, up to 60
68
+ seconds). `maxSheet` caps a sprite sheet's area at 4096 by 4096 pixels. Frames are `round(fps * seconds)`, checked before anything is
69
+ drawn. A value out of range, or not a finite number, throws a `RangeError`
70
+ that names the limit.
71
+
72
+ GIF counts time in hundredths of a second and browsers slow down frames
73
+ shorter than 2/100 s, so keep GIFs at 50 fps or less. APNG timing is exact.
74
+
75
+ Rasterising is synchronous CPU work on the calling thread. On a recent laptop
76
+ a 1024 px, 60-frame export takes about 13 s as a GIF and 17 s as an APNG, and
77
+ a sheet of 256 tiles at 256 px about 11 s. The worst case the limits allow,
78
+ 600 frames at 1024 px, is minutes of CPU. On a server, cap `size`, `fps` and
79
+ `seconds` well below `LIMITS` for untrusted input, and run exports in a worker
80
+ or a queue rather than on the request path.
81
+
82
+ ## Docs
83
+
84
+ Full documentation: <https://github.com/awflow/agent-avatar#readme>
85
+
86
+ ## Credits
87
+
88
+ Built on Peek by Doan Labs (MIT). Rasterising by
89
+ [resvg](https://github.com/linebender/resvg) through
90
+ [`@resvg/resvg-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm)
91
+ (MPL-2.0), GIF encoding by
92
+ [gifenc](https://github.com/mattdesl/gifenc), compression by
93
+ [fflate](https://github.com/101arrowz/fflate).
94
+
95
+ [MIT](./LICENSE) © Doan Labs, © AWFlow
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /* `npx @awflow/avatar-export Linh --png 256`: the core CLI with this package
3
+ * guaranteed to be the export module. */
4
+ import { run } from '@awflow/avatar/cli'
5
+
6
+ await run(process.argv.slice(2), () => import('../dist/index.js'))
@@ -0,0 +1,37 @@
1
+ import { type AvatarOptions, type Reaction } from '@awflow/avatar';
2
+ export type AnimatedOptions = AvatarOptions & {
3
+ reaction?: Reaction;
4
+ };
5
+ export type AnimationSettings = {
6
+ /** Side in pixels, 1 to LIMITS.maxSize. Default 256. */
7
+ size?: number;
8
+ /** Frames per second, FRAME_LIMITS.minFps to maxFps. Default 24. */
9
+ fps?: number;
10
+ /** Length in seconds, above 0 up to FRAME_LIMITS.maxSeconds. Default 2. */
11
+ seconds?: number;
12
+ };
13
+ /**
14
+ * One palette for a whole animation, from an even sample of at most
15
+ * SAMPLE_PIXELS pixels across all frames. Transparency is looked for in every
16
+ * pixel, not only the sample, so a small clear area is never lost: when there
17
+ * is one, the palette always keeps a fully transparent entry (forced in if
18
+ * quantising dropped it). Exported for the tests, not from the package.
19
+ */
20
+ export declare function paletteFor(count: number, pixels: number, frame: (i: number) => Uint8Array): {
21
+ format: 'rgb565' | 'rgb444' | 'rgba4444';
22
+ palette: number[][];
23
+ transparentIndex: number;
24
+ };
25
+ /**
26
+ * An animated PNG of `seconds` of the live avatar at `fps`, looping forever.
27
+ * Lossless with full alpha. A `reaction` in the options starts on frame one.
28
+ */
29
+ export declare function toApng(name: string, opts?: AnimatedOptions, o?: AnimationSettings): Promise<Uint8Array>;
30
+ /**
31
+ * An animated GIF of `seconds` of the live avatar at `fps`, looping forever.
32
+ * One 256-colour palette is quantised for the whole animation (gifenc). The
33
+ * background is transparent wherever the avatar draws nothing, e.g. with
34
+ * `frame: 'none'`. GIF counts time in hundredths of a second: frame delays
35
+ * are rounded so the total stays exact.
36
+ */
37
+ export declare function toGif(name: string, opts?: AnimatedOptions, o?: AnimationSettings): Promise<Uint8Array>;
@@ -0,0 +1,12 @@
1
+ import type { AvatarOptions } from '@awflow/avatar';
2
+ export type { AnimatedOptions, AnimationSettings, } from './animated.js';
3
+ export { toApng, toGif } from './animated.js';
4
+ export { DEFAULTS, LIMITS } from './limits.js';
5
+ export type { WasmSource } from './raster.js';
6
+ export { init } from './raster.js';
7
+ export type { SpriteSettings } from './sprite.js';
8
+ export { toSprite } from './sprite.js';
9
+ /** The avatar as a still PNG, `size` pixels square (default 256). */
10
+ export declare function toPng(name: string, opts?: AvatarOptions, o?: {
11
+ size?: number;
12
+ }): Promise<Uint8Array>;
package/dist/index.js ADDED
@@ -0,0 +1,408 @@
1
+ // src/index.ts
2
+ import { toSvg as toSvg2 } from "@awflow/avatar";
3
+
4
+ // src/limits.ts
5
+ import { FRAME_LIMITS } from "@awflow/avatar";
6
+ var LIMITS = Object.freeze({
7
+ maxFrames: FRAME_LIMITS.maxFrames,
8
+ maxSize: 1024,
9
+ maxSprite: 400,
10
+ maxSheet: 16777216
11
+ });
12
+ var DEFAULTS = Object.freeze({ size: 256, fps: 24, seconds: 2 });
13
+ function whole(fn, what, value, fallback, min, limit) {
14
+ if (value === undefined)
15
+ return fallback;
16
+ const max = LIMITS[limit];
17
+ if (!Number.isInteger(value) || value < min || value > max)
18
+ throw new RangeError(`${fn}: ${what} ${value} must be a whole number from ${min} to ${limit} ${max}`);
19
+ return value;
20
+ }
21
+ var checkSize = (fn, size) => whole(fn, "size", size, DEFAULTS.size, 1, "maxSize");
22
+ var checkGap = (fn, gap) => whole(fn, "gap", gap, 0, 0, "maxSize");
23
+ function checkSprite(fn, names, columns) {
24
+ const n = names.length;
25
+ if (n < 1 || n > LIMITS.maxSprite)
26
+ throw new RangeError(`${fn}: ${n} names must be from 1 to maxSprite ${LIMITS.maxSprite}`);
27
+ return whole(fn, "columns", columns, Math.ceil(Math.sqrt(n)), 1, "maxSprite");
28
+ }
29
+ function checkTiming(fn, o) {
30
+ const L = FRAME_LIMITS;
31
+ const fps = o.fps ?? DEFAULTS.fps;
32
+ const seconds = o.seconds ?? DEFAULTS.seconds;
33
+ if (!Number.isFinite(fps))
34
+ throw new RangeError(`${fn}: fps ${fps} must be a finite number from minFps ${L.minFps} to maxFps ${L.maxFps}`);
35
+ if (fps < L.minFps)
36
+ throw new RangeError(`${fn}: fps ${fps} is below minFps ${L.minFps}`);
37
+ if (fps > L.maxFps)
38
+ throw new RangeError(`${fn}: fps ${fps} is above maxFps ${L.maxFps}`);
39
+ if (!Number.isFinite(seconds) || seconds <= 0)
40
+ throw new RangeError(`${fn}: seconds ${seconds} must be a finite number above 0, up to maxSeconds ${L.maxSeconds}`);
41
+ if (seconds > L.maxSeconds)
42
+ throw new RangeError(`${fn}: seconds ${seconds} is above maxSeconds ${L.maxSeconds}`);
43
+ const count = Math.round(fps * seconds);
44
+ if (count > LIMITS.maxFrames)
45
+ throw new RangeError(`${fn}: ${count} frames is above maxFrames ${LIMITS.maxFrames}`);
46
+ if (count < 1)
47
+ throw new RangeError(`${fn}: fps ${fps} for ${seconds} seconds rounds to 0 frames, at least 1 is needed`);
48
+ return { fps, seconds, count };
49
+ }
50
+ function checkSheet(fn, w, h) {
51
+ if (w * h > LIMITS.maxSheet)
52
+ throw new RangeError(`${fn}: a ${w}x${h} sheet is ${w * h} pixels, above maxSheet ${LIMITS.maxSheet}`);
53
+ }
54
+
55
+ // src/raster.ts
56
+ import { initWasm, Resvg } from "@resvg/resvg-wasm";
57
+ var ready;
58
+ function init(wasm) {
59
+ ready ??= load(wasm).catch((error) => {
60
+ ready = undefined;
61
+ throw error;
62
+ });
63
+ return ready;
64
+ }
65
+ var ensure = () => ready ?? init();
66
+ async function load(given) {
67
+ const bytes = await source(await given);
68
+ try {
69
+ await initWasm(bytes);
70
+ } catch (error) {
71
+ if (!/already initiali[sz]ed/i.test(String(error)))
72
+ throw error;
73
+ }
74
+ }
75
+ async function source(wasm) {
76
+ if (wasm === undefined)
77
+ return bundled();
78
+ if (wasm instanceof URL)
79
+ return source(await fetch(wasm));
80
+ if (wasm instanceof ArrayBuffer || ArrayBuffer.isView(wasm))
81
+ return wasm;
82
+ if (typeof wasm.arrayBuffer === "function") {
83
+ const res = wasm;
84
+ if (!res.ok)
85
+ throw new Error(`init(): fetching the wasm failed with ${res.status}`);
86
+ return res.arrayBuffer();
87
+ }
88
+ return wasm;
89
+ }
90
+ async function bundled() {
91
+ const proc = globalThis.process;
92
+ if (!proc?.versions?.node)
93
+ throw new TypeError("init(): in a browser pass the resvg wasm, e.g. init(fetch(wasmUrl)) with the URL of @resvg/resvg-wasm/index_bg.wasm");
94
+ const load2 = (id) => import(id);
95
+ const { createRequire } = await load2("node:module");
96
+ const { readFile } = await load2("node:fs/promises");
97
+ const path = createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm");
98
+ return new Uint8Array(await readFile(path));
99
+ }
100
+ var options = (size) => ({
101
+ fitTo: { mode: "width", value: size },
102
+ font: { loadSystemFonts: false }
103
+ });
104
+ function png(svg, size) {
105
+ const resvg = new Resvg(svg, options(size));
106
+ const image = resvg.render();
107
+ try {
108
+ return image.asPng();
109
+ } finally {
110
+ image.free();
111
+ resvg.free();
112
+ }
113
+ }
114
+ function rgba(svg, size) {
115
+ const resvg = new Resvg(svg, options(size));
116
+ const image = resvg.render();
117
+ try {
118
+ const px = image.pixels.slice();
119
+ if (image.width !== size || image.height !== size)
120
+ throw new Error(`rasterising gave ${image.width}x${image.height}, expected ${size}x${size}`);
121
+ for (let i = 0;i < px.length; i += 4) {
122
+ const a = px[i + 3];
123
+ if (a === 255 || a === 0)
124
+ continue;
125
+ px[i] = Math.round(px[i] * 255 / a);
126
+ px[i + 1] = Math.round(px[i + 1] * 255 / a);
127
+ px[i + 2] = Math.round(px[i + 2] * 255 / a);
128
+ }
129
+ return px;
130
+ } finally {
131
+ image.free();
132
+ resvg.free();
133
+ }
134
+ }
135
+
136
+ // src/animated.ts
137
+ import { frames } from "@awflow/avatar";
138
+ import * as gifenc from "gifenc";
139
+
140
+ // src/png.ts
141
+ import { zlibSync } from "fflate";
142
+ var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
143
+ var CRC = new Uint32Array(256);
144
+ for (let n = 0;n < 256; n++) {
145
+ let c = n;
146
+ for (let k = 0;k < 8; k++)
147
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
148
+ CRC[n] = c >>> 0;
149
+ }
150
+ function crc32(bytes, crc = 4294967295) {
151
+ for (let i = 0;i < bytes.length; i++)
152
+ crc = CRC[(crc ^ bytes[i]) & 255] ^ crc >>> 8;
153
+ return crc;
154
+ }
155
+
156
+ class Writer {
157
+ parts = [Uint8Array.from(SIGNATURE)];
158
+ chunk(type, data) {
159
+ const head = new Uint8Array(8);
160
+ const view = new DataView(head.buffer);
161
+ view.setUint32(0, data.length);
162
+ for (let i = 0;i < 4; i++)
163
+ head[4 + i] = type.charCodeAt(i);
164
+ const tail = new Uint8Array(4);
165
+ const crc = crc32(data, crc32(head.subarray(4)));
166
+ new DataView(tail.buffer).setUint32(0, (crc ^ 4294967295) >>> 0);
167
+ this.parts.push(head, data, tail);
168
+ }
169
+ bytes() {
170
+ const out = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0));
171
+ let at = 0;
172
+ for (const p of this.parts) {
173
+ out.set(p, at);
174
+ at += p.length;
175
+ }
176
+ return out;
177
+ }
178
+ }
179
+ function fields(...values) {
180
+ const out = new Uint8Array(values.reduce((n, [, b]) => n + b, 0));
181
+ const view = new DataView(out.buffer);
182
+ let at = 0;
183
+ for (const [v, b] of values) {
184
+ if (b === 4)
185
+ view.setUint32(at, v);
186
+ else if (b === 2)
187
+ view.setUint16(at, v);
188
+ else
189
+ view.setUint8(at, v);
190
+ at += b;
191
+ }
192
+ return out;
193
+ }
194
+ var ihdr = (w, h) => fields([w, 4], [h, 4], [8, 1], [6, 1], [0, 1], [0, 1], [0, 1]);
195
+ var paeth = (a, b, c) => {
196
+ const p = a + b - c;
197
+ const pa = Math.abs(p - a);
198
+ const pb = Math.abs(p - b);
199
+ const pc = Math.abs(p - c);
200
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
201
+ };
202
+ function compress(px, stride, x, y, w, h) {
203
+ const row = w * 4;
204
+ const raw = new Uint8Array(h * (row + 1));
205
+ const tries = Array.from({ length: 5 }, () => new Uint8Array(row));
206
+ for (let r = 0;r < h; r++) {
207
+ const at = ((y + r) * stride + x) * 4;
208
+ const cur = px.subarray(at, at + row);
209
+ const prev = r ? px.subarray(at - stride * 4, at - stride * 4 + row) : null;
210
+ let best = 0;
211
+ let bestSum = Number.POSITIVE_INFINITY;
212
+ for (let f = 0;f < 5; f++) {
213
+ const out = tries[f];
214
+ let sum = 0;
215
+ for (let i = 0;i < row; i++) {
216
+ const a = i >= 4 ? cur[i - 4] : 0;
217
+ const b = prev ? prev[i] : 0;
218
+ const c = prev && i >= 4 ? prev[i - 4] : 0;
219
+ const pred = f === 0 ? 0 : f === 1 ? a : f === 2 ? b : f === 3 ? a + b >> 1 : paeth(a, b, c);
220
+ const v = cur[i] - pred & 255;
221
+ out[i] = v;
222
+ sum += v < 128 ? v : 256 - v;
223
+ }
224
+ if (sum < bestSum) {
225
+ bestSum = sum;
226
+ best = f;
227
+ }
228
+ }
229
+ const o = r * (row + 1);
230
+ raw[o] = best;
231
+ raw.set(tries[best], o + 1);
232
+ }
233
+ return zlibSync(raw, { level: 9 });
234
+ }
235
+ function encodePng(px, w, h) {
236
+ const png2 = new Writer;
237
+ png2.chunk("IHDR", ihdr(w, h));
238
+ png2.chunk("IDAT", compress(px, w, 0, 0, w, h));
239
+ png2.chunk("IEND", new Uint8Array(0));
240
+ return png2.bytes();
241
+ }
242
+ function changed(a, b, w, h) {
243
+ const A = new Uint32Array(a.buffer, a.byteOffset, w * h);
244
+ const B = new Uint32Array(b.buffer, b.byteOffset, w * h);
245
+ let x0 = w;
246
+ let y0 = h;
247
+ let x1 = -1;
248
+ let y1 = -1;
249
+ for (let y = 0;y < h; y++) {
250
+ const o = y * w;
251
+ for (let x = 0;x < w; x++) {
252
+ if (A[o + x] === B[o + x])
253
+ continue;
254
+ if (x < x0)
255
+ x0 = x;
256
+ if (x > x1)
257
+ x1 = x;
258
+ if (y < y0)
259
+ y0 = y;
260
+ y1 = y;
261
+ }
262
+ }
263
+ return x1 < 0 ? { x: 0, y: 0, w: 1, h: 1 } : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
264
+ }
265
+ function encodeApng(count, frame, w, h, delay) {
266
+ const png2 = new Writer;
267
+ png2.chunk("IHDR", ihdr(w, h));
268
+ png2.chunk("acTL", fields([count, 4], [0, 4]));
269
+ let seq = 0;
270
+ let prev;
271
+ for (let i = 0;i < count; i++) {
272
+ const px = frame(i);
273
+ const box = prev ? changed(prev, px, w, h) : { x: 0, y: 0, w, h };
274
+ png2.chunk("fcTL", fields([seq++, 4], [box.w, 4], [box.h, 4], [box.x, 4], [box.y, 4], [delay[0], 2], [delay[1], 2], [0, 1], [0, 1]));
275
+ const data = compress(px, w, box.x, box.y, box.w, box.h);
276
+ if (i === 0)
277
+ png2.chunk("IDAT", data);
278
+ else {
279
+ const fd = new Uint8Array(4 + data.length);
280
+ new DataView(fd.buffer).setUint32(0, seq++);
281
+ fd.set(data, 4);
282
+ png2.chunk("fdAT", fd);
283
+ }
284
+ prev = px;
285
+ }
286
+ png2.chunk("IEND", new Uint8Array(0));
287
+ return png2.bytes();
288
+ }
289
+
290
+ // src/animated.ts
291
+ var gif = "GIFEncoder" in gifenc && typeof gifenc.GIFEncoder === "function" ? gifenc : gifenc.default;
292
+ var KEEP_BYTES = 64 * 1024 * 1024;
293
+ var SAMPLE_PIXELS = 1 << 21;
294
+ function paletteFor(count, pixels, frame) {
295
+ const stride = Math.max(1, Math.ceil(count * pixels / SAMPLE_PIXELS));
296
+ const sample = new Uint8Array(Math.ceil(count * pixels / stride) * 4);
297
+ let taken = 0;
298
+ let next = 0;
299
+ let clear = false;
300
+ for (let i = 0;i < count; i++) {
301
+ const px = frame(i);
302
+ if (!clear) {
303
+ for (let a = 3;a < px.length; a += 4)
304
+ if (px[a] < 128) {
305
+ clear = true;
306
+ break;
307
+ }
308
+ }
309
+ const base = i * pixels;
310
+ for (;next < base + pixels; next += stride) {
311
+ const at = (next - base) * 4;
312
+ sample.set(px.subarray(at, at + 4), taken * 4);
313
+ taken++;
314
+ }
315
+ }
316
+ const format = clear ? "rgba4444" : "rgb565";
317
+ const palette = gif.quantize(sample.subarray(0, taken * 4), 256, {
318
+ format,
319
+ oneBitAlpha: clear
320
+ });
321
+ if (!clear)
322
+ return { format, palette, transparentIndex: -1 };
323
+ let transparentIndex = palette.findIndex((c) => c[3] === 0);
324
+ if (transparentIndex < 0) {
325
+ transparentIndex = Math.min(palette.length, 255);
326
+ palette[transparentIndex] = [0, 0, 0, 0];
327
+ }
328
+ return { format, palette, transparentIndex };
329
+ }
330
+ async function prepare(fn, name, opts, o) {
331
+ const size = checkSize(fn, o.size);
332
+ const { fps, seconds, count } = checkTiming(fn, o);
333
+ await ensure();
334
+ const svgs = frames(name, opts, { fps, seconds });
335
+ return { size, fps, svgs, count };
336
+ }
337
+ async function toApng(name, opts = {}, o = {}) {
338
+ const { size, fps, svgs, count } = await prepare("toApng()", name, opts, o);
339
+ const delay = [100, Math.round(fps * 100)];
340
+ return encodeApng(count, (i) => rgba(svgs[i], size), size, size, delay);
341
+ }
342
+ async function toGif(name, opts = {}, o = {}) {
343
+ const { size, fps, svgs, count } = await prepare("toGif()", name, opts, o);
344
+ const pixels = size * size;
345
+ const keep = count * pixels * 4 <= KEEP_BYTES;
346
+ const held = [];
347
+ const frame = (i) => held[i] ?? rgba(svgs[i], size);
348
+ const { format, palette, transparentIndex } = paletteFor(count, pixels, (i) => {
349
+ const px = frame(i);
350
+ if (keep)
351
+ held[i] = px;
352
+ return px;
353
+ });
354
+ const transparent = transparentIndex >= 0;
355
+ const out = gif.GIFEncoder();
356
+ for (let i = 0;i < count; i++) {
357
+ const index = gif.applyPalette(frame(i), palette, format);
358
+ const cs = Math.round((i + 1) * 100 / fps) - Math.round(i * 100 / fps);
359
+ out.writeFrame(index, size, size, {
360
+ palette: i === 0 ? palette : undefined,
361
+ delay: cs * 10,
362
+ repeat: 0,
363
+ transparent,
364
+ transparentIndex: transparent ? transparentIndex : 0,
365
+ dispose: transparent ? 2 : 1
366
+ });
367
+ }
368
+ out.finish();
369
+ return out.bytes();
370
+ }
371
+ // src/sprite.ts
372
+ import { toSvg } from "@awflow/avatar";
373
+ async function toSprite(names, opts = {}, o = {}) {
374
+ const fn = "toSprite()";
375
+ const columns = checkSprite(fn, names, o.columns);
376
+ const size = checkSize(fn, o.size);
377
+ const gap = checkGap(fn, o.gap);
378
+ const rows = Math.ceil(names.length / columns);
379
+ const w = columns * size + (columns - 1) * gap;
380
+ const h = rows * size + (rows - 1) * gap;
381
+ checkSheet(fn, w, h);
382
+ await ensure();
383
+ const sheet = new Uint8Array(w * h * 4);
384
+ names.forEach((name, i) => {
385
+ const tile = rgba(toSvg(name, opts), size);
386
+ const x = i % columns * (size + gap);
387
+ const y = Math.floor(i / columns) * (size + gap);
388
+ for (let r = 0;r < size; r++)
389
+ sheet.set(tile.subarray(r * size * 4, (r + 1) * size * 4), ((y + r) * w + x) * 4);
390
+ });
391
+ return encodePng(sheet, w, h);
392
+ }
393
+
394
+ // src/index.ts
395
+ async function toPng(name, opts = {}, o = {}) {
396
+ const size = checkSize("toPng()", o.size);
397
+ await ensure();
398
+ return png(toSvg2(name, opts), size);
399
+ }
400
+ export {
401
+ toSprite,
402
+ toPng,
403
+ toGif,
404
+ toApng,
405
+ init,
406
+ LIMITS,
407
+ DEFAULTS
408
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The export caps: `maxFrames` is the core's own (FRAME_LIMITS, one source of
3
+ * truth), `maxSize` bounds the side of an image in pixels, `maxSprite` the
4
+ * number of names on one sheet and `maxSheet` a sheet's area in pixels
5
+ * (4096 squared).
6
+ */
7
+ export declare const LIMITS: Readonly<{
8
+ maxFrames: 600;
9
+ maxSize: 1024;
10
+ maxSprite: 400;
11
+ maxSheet: 16777216;
12
+ }>;
13
+ export declare const DEFAULTS: Readonly<{
14
+ size: 256;
15
+ fps: 24;
16
+ seconds: 2;
17
+ }>;
18
+ export declare const checkSize: (fn: string, size: number | undefined) => number;
19
+ /** Tile gap in pixels: 0 up to maxSize. */
20
+ export declare const checkGap: (fn: string, gap: number | undefined) => number;
21
+ /** A sprite sheet's names and columns. */
22
+ export declare function checkSprite(fn: string, names: readonly string[], columns: number | undefined): number;
23
+ /**
24
+ * fps and seconds against FRAME_LIMITS, then the frame count round(fps *
25
+ * seconds) against maxFrames, all before rendering.
26
+ */
27
+ export declare function checkTiming(fn: string, o: {
28
+ fps?: number;
29
+ seconds?: number;
30
+ }): {
31
+ fps: number;
32
+ seconds: number;
33
+ count: number;
34
+ };
35
+ /** A sprite sheet's area, w * h, against maxSheet, before any allocation. */
36
+ export declare function checkSheet(fn: string, w: number, h: number): void;
package/dist/png.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** A still PNG of an RGBA buffer. */
2
+ export declare function encodePng(px: Uint8Array, w: number, h: number): Uint8Array;
3
+ /**
4
+ * An animated PNG that loops forever. `frame(i)` gives frame i as RGBA;
5
+ * frames are pulled one at a time so only two are held at once. Each frame
6
+ * after the first stores only the box that changed, replacing those pixels
7
+ * (blend source) over the previous frame left in place (dispose none).
8
+ * `delay` is each frame's length as a [numerator, denominator] of seconds.
9
+ */
10
+ export declare function encodeApng(count: number, frame: (i: number) => Uint8Array, w: number, h: number, delay: readonly [number, number]): Uint8Array;
@@ -0,0 +1,19 @@
1
+ /** What init() accepts: wasm bytes, a fetch() Response, or a URL to fetch. */
2
+ export type WasmSource = ArrayBuffer | ArrayBufferView | Response | URL | WebAssembly.Module;
3
+ /**
4
+ * Loads the resvg wasm once. Idempotent and safe to call concurrently: every
5
+ * caller shares one in-flight promise, and a failed load can be retried.
6
+ * In Node (and Bun or Deno) a call without an argument loads the wasm bundled
7
+ * in `@resvg/resvg-wasm`; in a browser pass it, e.g. `init(fetch(wasmUrl))`.
8
+ */
9
+ export declare function init(wasm?: WasmSource | PromiseLike<WasmSource>): Promise<void>;
10
+ /** The export functions wait on this: init() with no argument when needed. */
11
+ export declare const ensure: () => Promise<void>;
12
+ /** One SVG as a PNG, size pixels wide, encoded by resvg. */
13
+ export declare function png(svg: string, size: number): Uint8Array;
14
+ /**
15
+ * One SVG as straight (not premultiplied) RGBA pixels, size by size. resvg
16
+ * hands premultiplied pixels back; they are demultiplied the way its own PNG
17
+ * encoder does it, so a tile reads as the PNG of the same avatar would.
18
+ */
19
+ export declare function rgba(svg: string, size: number): Uint8Array;
@@ -0,0 +1,15 @@
1
+ import { type AvatarOptions } from '@awflow/avatar';
2
+ export type SpriteSettings = {
3
+ /** Each tile's side in pixels, 1 to LIMITS.maxSize. Default 256. */
4
+ size?: number;
5
+ /** Tiles per row, 1 to LIMITS.maxSprite. Default ceil(sqrt(names)). */
6
+ columns?: number;
7
+ /** Transparent pixels between tiles, 0 to LIMITS.maxSize. Default 0. */
8
+ gap?: number;
9
+ };
10
+ /**
11
+ * The names as one PNG grid, `columns` tiles wide: width is columns * size +
12
+ * (columns - 1) * gap, height likewise for the rows used. The same options
13
+ * apply to every tile. The sheet's area is at most LIMITS.maxSheet pixels. Name i sits at column i % columns, row i / columns.
14
+ */
15
+ export declare function toSprite(names: readonly string[], opts?: AvatarOptions, o?: SpriteSettings): Promise<Uint8Array>;
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@awflow/avatar-export",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "bin": {
7
+ "awflow-avatar-export": "./bin/awflow-avatar-export.mjs"
8
+ },
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "bin",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "dependencies": {
24
+ "@awflow/avatar": "^1.0.1",
25
+ "@resvg/resvg-wasm": "2.6.2",
26
+ "fflate": "0.8.3",
27
+ "gifenc": "1.0.3"
28
+ },
29
+ "description": "Rasterise AWFlow avatars to PNG, APNG, GIF and sprite sheets, in Node and the browser.",
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "author": "AWFlow",
35
+ "homepage": "https://github.com/awflow/agent-avatar",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/awflow/agent-avatar.git",
39
+ "directory": "packages/avatar-export"
40
+ },
41
+ "keywords": [
42
+ "avatar",
43
+ "png",
44
+ "apng",
45
+ "gif",
46
+ "sprite",
47
+ "deterministic"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }