@awflow/avatar-export 0.0.0-stage → 1.0.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.
- package/LICENSE +22 -0
- package/README.md +78 -2
- package/bin/awflow-avatar-export.mjs +6 -0
- package/dist/animated.d.ts +37 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +408 -0
- package/dist/limits.d.ts +36 -0
- package/dist/png.d.ts +10 -0
- package/dist/raster.d.ts +19 -0
- package/dist/sprite.d.ts +15 -0
- package/package.json +47 -4
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,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @awflow/avatar-export
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { init, toPng } from '@awflow/avatar-export'
|
|
47
|
+
import wasmUrl from '@resvg/resvg-wasm/index_bg.wasm?url' // Vite
|
|
48
|
+
|
|
49
|
+
await init(fetch(wasmUrl))
|
|
50
|
+
const png = await toPng('Linh')
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`init` is idempotent: concurrent calls share one load, and later calls
|
|
54
|
+
return at once.
|
|
55
|
+
|
|
56
|
+
## Limits
|
|
57
|
+
|
|
58
|
+
`LIMITS` is
|
|
59
|
+
`{ maxFrames: 600, maxSize: 1024, maxSprite: 400, maxSheet: 16777216 }`, with
|
|
60
|
+
`maxFrames` taken from the core's `FRAME_LIMITS` (1 to 120 fps, up to 60
|
|
61
|
+
seconds). `maxSheet` caps a sprite sheet's area at 4096 by 4096 pixels. Frames are `round(fps * seconds)`, checked before anything is
|
|
62
|
+
drawn. A value out of range, or not a finite number, throws a `RangeError`
|
|
63
|
+
that names the limit.
|
|
64
|
+
|
|
65
|
+
GIF counts time in hundredths of a second and browsers slow down frames
|
|
66
|
+
shorter than 2/100 s, so keep GIFs at 50 fps or less. APNG timing is exact.
|
|
67
|
+
|
|
68
|
+
## Docs
|
|
69
|
+
|
|
70
|
+
Full documentation: <https://github.com/awflow/agent-avatar#readme>
|
|
71
|
+
|
|
72
|
+
## Credits
|
|
73
|
+
|
|
74
|
+
Built on Peek by Doan Labs (MIT). Rasterising by
|
|
75
|
+
[resvg](https://github.com/linebender/resvg), GIF encoding by
|
|
76
|
+
[gifenc](https://github.com/mattdesl/gifenc), compression by
|
|
77
|
+
[fflate](https://github.com/101arrowz/fflate).
|
|
78
|
+
|
|
79
|
+
[MIT](./LICENSE) © Doan Labs, © AWFlow
|
|
@@ -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>;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/limits.d.ts
ADDED
|
@@ -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;
|
package/dist/raster.d.ts
ADDED
|
@@ -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;
|
package/dist/sprite.d.ts
ADDED
|
@@ -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: string[], opts?: AvatarOptions, o?: SpriteSettings): Promise<Uint8Array>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awflow/avatar-export",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
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.0",
|
|
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
|
+
"author": "AWFlow",
|
|
32
|
+
"homepage": "https://github.com/awflow/agent-avatar",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/awflow/agent-avatar.git",
|
|
36
|
+
"directory": "packages/avatar-export"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"avatar",
|
|
40
|
+
"png",
|
|
41
|
+
"apng",
|
|
42
|
+
"gif",
|
|
43
|
+
"sprite",
|
|
44
|
+
"deterministic"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|