@effing/annie 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.
- package/LICENSE +11 -0
- package/README.md +182 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
O'Saasy License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026, Trackuity BV.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# @effing/annie
|
|
2
|
+
|
|
3
|
+
**Generate TAR archives of PNG or JPEG frames for animated layers.**
|
|
4
|
+
|
|
5
|
+
> Part of the [**Effing**](../../README.md) family — programmatic video creation with TypeScript.
|
|
6
|
+
|
|
7
|
+
Annie is a simple animation format: a TAR archive containing sequentially-named PNG or JPEG frames. Generate frames server-side, stream them to the browser or FFmpeg.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @effing/annie
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Concepts
|
|
16
|
+
|
|
17
|
+
### Annie Format
|
|
18
|
+
|
|
19
|
+
An Annie is a TAR archive where each entry is a PNG or JPEG frame:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
frame_00000
|
|
23
|
+
frame_00001
|
|
24
|
+
frame_00002
|
|
25
|
+
...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This format is:
|
|
29
|
+
|
|
30
|
+
- **Streamable** — frames can be written as they're generated
|
|
31
|
+
- **Universal** — TAR is supported everywhere
|
|
32
|
+
- **FFmpeg-compatible** — can be piped directly to FFmpeg as image input
|
|
33
|
+
|
|
34
|
+
### Generation Patterns
|
|
35
|
+
|
|
36
|
+
Annie supports two generation patterns:
|
|
37
|
+
|
|
38
|
+
**Buffer** — Collect all frames, return complete archive:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
const archive = await annieBuffer(frames);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Stream** — Yield chunks as frames are generated:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
const stream = annieStream(frames);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { annieStream, annieBuffer } from "@effing/annie";
|
|
54
|
+
import { pngFromSatori } from "@effing/satori";
|
|
55
|
+
import { tween, easeOutQuad } from "@effing/tween";
|
|
56
|
+
|
|
57
|
+
// Define a frame generator
|
|
58
|
+
async function* generateFrames() {
|
|
59
|
+
yield* tween(90, async ({ lower: progress }) => {
|
|
60
|
+
const scale = 1 + 0.3 * easeOutQuad(progress);
|
|
61
|
+
return pngFromSatori(
|
|
62
|
+
<div style={{
|
|
63
|
+
width: 1080,
|
|
64
|
+
height: 1920,
|
|
65
|
+
display: "flex",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
justifyContent: "center",
|
|
68
|
+
fontSize: 72,
|
|
69
|
+
transform: `scale(${scale})`
|
|
70
|
+
}}>
|
|
71
|
+
Hello World!
|
|
72
|
+
</div>,
|
|
73
|
+
{ width: 1080, height: 1920, fonts: myFonts }
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stream response
|
|
79
|
+
const stream = annieStream(generateFrames(), { signal: request.signal });
|
|
80
|
+
return new Response(stream, {
|
|
81
|
+
headers: { "Content-Type": "application/x-tar" }
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API Overview
|
|
86
|
+
|
|
87
|
+
### Functions
|
|
88
|
+
|
|
89
|
+
#### `annieStream(frames, options?)`
|
|
90
|
+
|
|
91
|
+
Create a `ReadableStream` that produces TAR chunks as frames are generated.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
function annieStream(
|
|
95
|
+
frames: AsyncIterable<Buffer>,
|
|
96
|
+
options?: AnnieStreamOptions,
|
|
97
|
+
): ReadableStream<Buffer>;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Options:**
|
|
101
|
+
|
|
102
|
+
- `signal` — AbortSignal for cancellation
|
|
103
|
+
|
|
104
|
+
#### `annieBuffer(frames)`
|
|
105
|
+
|
|
106
|
+
Collect all frames and return a complete TAR buffer.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
function annieBuffer(frames: AsyncIterable<Buffer>): Promise<Buffer>;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Response Helper
|
|
113
|
+
|
|
114
|
+
#### `annieResponse(frames, options?)`
|
|
115
|
+
|
|
116
|
+
Create a complete `Response` object with proper headers.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { annieResponse } from "@effing/annie";
|
|
120
|
+
|
|
121
|
+
return annieResponse(generateFrames(), {
|
|
122
|
+
signal: request.signal,
|
|
123
|
+
headers: { "Cache-Control": "public, max-age=3600" },
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Examples
|
|
128
|
+
|
|
129
|
+
### With Express/Node.js
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { Readable } from "stream";
|
|
133
|
+
import { annieStream } from "@effing/annie";
|
|
134
|
+
|
|
135
|
+
app.get("/animation.tar", async (req, res) => {
|
|
136
|
+
const stream = annieStream(generateFrames());
|
|
137
|
+
res.setHeader("Content-Type", "application/x-tar");
|
|
138
|
+
Readable.fromWeb(stream).pipe(res);
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### With React Router / Remix
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { annieResponse } from "@effing/annie";
|
|
146
|
+
|
|
147
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
148
|
+
return annieResponse(generateFrames(), { signal: request.signal });
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Saving to File
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { writeFile } from "fs/promises";
|
|
156
|
+
import { annieBuffer } from "@effing/annie";
|
|
157
|
+
|
|
158
|
+
const buffer = await annieBuffer(generateFrames());
|
|
159
|
+
await writeFile("animation.tar", buffer);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### FFmpeg Integration
|
|
163
|
+
|
|
164
|
+
Note that Annie TAR archives can be piped directly to FFmpeg. Extract the frames with `tar -xO` and pipe to FFmpeg's image2pipe input:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Create animated PNG (loops forever)
|
|
168
|
+
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - -plays 0 -c:v apng -f apng output.png
|
|
169
|
+
|
|
170
|
+
# Create MP4 video
|
|
171
|
+
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -pix_fmt yuv420p output.mp4
|
|
172
|
+
|
|
173
|
+
# Create GIF
|
|
174
|
+
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - output.gif
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Related Packages
|
|
178
|
+
|
|
179
|
+
- [`@effing/tween`](../tween) — Step iteration and easing functions for frame generation
|
|
180
|
+
- [`@effing/satori`](../satori) — Render JSX to PNG for each frame
|
|
181
|
+
- [`@effing/annie-player`](../annie-player) — Play Annies in the browser
|
|
182
|
+
- [`@effing/effie`](../effie) — Use Annies as layers in video compositions
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annie generation utilities (TAR archives of PNG frames)
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Options for annie stream generation
|
|
6
|
+
*/
|
|
7
|
+
type AnnieStreamOptions = {
|
|
8
|
+
/** Abort signal for cancellation */
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Collect all frames into a single annie Buffer (TAR archive)
|
|
13
|
+
*
|
|
14
|
+
* @param frames Async iterator yielding PNG or JPEG frame buffers
|
|
15
|
+
* @returns Complete annie as a Buffer
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const frames = renderAnnieFrames(annieId, props, { width, height });
|
|
20
|
+
* const annie = await annieBuffer(frames);
|
|
21
|
+
* await fs.writeFile("animation.tar", annie);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
declare function annieBuffer(frames: AsyncIterable<Buffer>): Promise<Buffer>;
|
|
25
|
+
/**
|
|
26
|
+
* Create a ReadableStream that produces an annie (TAR archive of frames)
|
|
27
|
+
*
|
|
28
|
+
* Use this when you need the stream but want to customize the Response yourself.
|
|
29
|
+
*
|
|
30
|
+
* @param frames Async iterator yielding PNG or JPEG frame buffers
|
|
31
|
+
* @param options Configuration options
|
|
32
|
+
* @returns ReadableStream of annie data
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* const frames = renderAnnieFrames(annieId, props, { width, height });
|
|
37
|
+
* const stream = annieStream(frames, { signal: request.signal });
|
|
38
|
+
* return new Response(stream, {
|
|
39
|
+
* headers: { "Content-Type": "application/x-tar" }
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function annieStream(frames: AsyncIterable<Buffer>, options?: AnnieStreamOptions): ReadableStream<Buffer>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Options for annie Response generation
|
|
47
|
+
*/
|
|
48
|
+
type AnnieResponseOptions = AnnieStreamOptions & {
|
|
49
|
+
/** Additional headers to include in the response */
|
|
50
|
+
headers?: HeadersInit;
|
|
51
|
+
/** Cache-Control header value (default: "public, max-age=3600") */
|
|
52
|
+
cacheControl?: string;
|
|
53
|
+
/** Filename for Content-Disposition header (without .tar extension) */
|
|
54
|
+
filename?: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Create an HTTP Response that streams an annie
|
|
58
|
+
*
|
|
59
|
+
* This is the most convenient way to serve an annie animation from a web server.
|
|
60
|
+
* It handles all the streaming, headers, and cleanup automatically.
|
|
61
|
+
*
|
|
62
|
+
* @param frames Async iterator yielding PNG or JPEG frame buffers
|
|
63
|
+
* @param options Configuration options
|
|
64
|
+
* @returns Response streaming the annie
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* // In a route handler:
|
|
69
|
+
* export async function loader({ request, params }: LoaderFunctionArgs) {
|
|
70
|
+
* const frames = renderAnnieFrames(annieId, props, { width, height });
|
|
71
|
+
* return annieResponse(frames, {
|
|
72
|
+
* signal: request.signal,
|
|
73
|
+
* filename: "my-animation",
|
|
74
|
+
* });
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare function annieResponse(frames: AsyncIterable<Buffer>, options?: AnnieResponseOptions): Response;
|
|
79
|
+
|
|
80
|
+
export { type AnnieResponseOptions, type AnnieStreamOptions, annieBuffer, annieResponse, annieStream };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/generate.ts
|
|
2
|
+
var TAR_BLOCK_SIZE = 512;
|
|
3
|
+
function createTarHeader(name, size) {
|
|
4
|
+
const header = Buffer.alloc(TAR_BLOCK_SIZE);
|
|
5
|
+
const nameBytes = Buffer.from(name.slice(0, 100), "utf8");
|
|
6
|
+
nameBytes.copy(header, 0);
|
|
7
|
+
header.write("0000664 ", 100, "utf8");
|
|
8
|
+
header.write("0001750 ", 108, "utf8");
|
|
9
|
+
header.write("0001750 ", 116, "utf8");
|
|
10
|
+
const sizeOctal = size.toString(8).padStart(11, "0") + " ";
|
|
11
|
+
header.write(sizeOctal, 124, "utf8");
|
|
12
|
+
const mtime = Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0") + " ";
|
|
13
|
+
header.write(mtime, 136, "utf8");
|
|
14
|
+
header.write(" ", 148, "utf8");
|
|
15
|
+
header.write("0", 156, "utf8");
|
|
16
|
+
header.write("ustar ", 257, "utf8");
|
|
17
|
+
header.write(" \0", 263, "utf8");
|
|
18
|
+
let checksum = 0;
|
|
19
|
+
for (let i = 0; i < TAR_BLOCK_SIZE; i++) {
|
|
20
|
+
checksum += header[i];
|
|
21
|
+
}
|
|
22
|
+
const checksumOctal = checksum.toString(8).padStart(6, "0") + "\0 ";
|
|
23
|
+
header.write(checksumOctal, 148, "utf8");
|
|
24
|
+
return header;
|
|
25
|
+
}
|
|
26
|
+
function padToBlockSize(data) {
|
|
27
|
+
const remainder = data.length % TAR_BLOCK_SIZE;
|
|
28
|
+
if (remainder === 0) return data;
|
|
29
|
+
const padding = Buffer.alloc(TAR_BLOCK_SIZE - remainder);
|
|
30
|
+
return Buffer.concat([data, padding]);
|
|
31
|
+
}
|
|
32
|
+
async function* tarChunks(frames, options = {}) {
|
|
33
|
+
const { framePrefix = "frame_", frameDigits = 5, signal } = options;
|
|
34
|
+
let i = 0;
|
|
35
|
+
for await (const frame of frames) {
|
|
36
|
+
if (signal?.aborted) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const name = `${framePrefix}${i.toString().padStart(frameDigits, "0")}`;
|
|
40
|
+
yield createTarHeader(name, frame.length);
|
|
41
|
+
yield padToBlockSize(frame);
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
if (!signal?.aborted) {
|
|
45
|
+
yield Buffer.alloc(TAR_BLOCK_SIZE * 2);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function annieBuffer(frames) {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
for await (const chunk of tarChunks(frames)) {
|
|
51
|
+
chunks.push(chunk);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks);
|
|
54
|
+
}
|
|
55
|
+
function annieStream(frames, options = {}) {
|
|
56
|
+
const abortController = new AbortController();
|
|
57
|
+
const combinedSignal = options.signal ? AbortSignal.any([options.signal, abortController.signal]) : abortController.signal;
|
|
58
|
+
let iterator = null;
|
|
59
|
+
return new ReadableStream({
|
|
60
|
+
async start() {
|
|
61
|
+
iterator = tarChunks(frames, { signal: combinedSignal });
|
|
62
|
+
},
|
|
63
|
+
async pull(controller) {
|
|
64
|
+
if (!iterator) return;
|
|
65
|
+
try {
|
|
66
|
+
const { value, done } = await iterator.next();
|
|
67
|
+
if (done) {
|
|
68
|
+
controller.close();
|
|
69
|
+
} else {
|
|
70
|
+
controller.enqueue(value);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (!combinedSignal.aborted) {
|
|
74
|
+
controller.error(err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
cancel() {
|
|
79
|
+
abortController.abort();
|
|
80
|
+
iterator?.return(void 0).catch(() => {
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/response.ts
|
|
87
|
+
function annieResponse(frames, options = {}) {
|
|
88
|
+
const {
|
|
89
|
+
headers: extraHeaders,
|
|
90
|
+
cacheControl = "public, max-age=3600",
|
|
91
|
+
filename,
|
|
92
|
+
...streamOptions
|
|
93
|
+
} = options;
|
|
94
|
+
const stream = annieStream(frames, streamOptions);
|
|
95
|
+
const headers = new Headers(extraHeaders);
|
|
96
|
+
headers.set("Content-Type", "application/x-tar");
|
|
97
|
+
if (cacheControl) {
|
|
98
|
+
headers.set("Cache-Control", cacheControl);
|
|
99
|
+
}
|
|
100
|
+
if (filename) {
|
|
101
|
+
headers.set("Content-Disposition", `inline; filename="${filename}.tar"`);
|
|
102
|
+
}
|
|
103
|
+
return new Response(stream, { status: 200, headers });
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
annieBuffer,
|
|
107
|
+
annieResponse,
|
|
108
|
+
annieStream
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/generate.ts","../src/response.ts"],"sourcesContent":["/**\n * Annie generation utilities (TAR archives of PNG frames)\n */\n\nconst TAR_BLOCK_SIZE = 512;\n\n/**\n * Create a TAR header for a file entry\n */\nfunction createTarHeader(name: string, size: number): Buffer {\n const header = Buffer.alloc(TAR_BLOCK_SIZE);\n\n // Filename (100 bytes)\n const nameBytes = Buffer.from(name.slice(0, 100), \"utf8\");\n nameBytes.copy(header, 0);\n\n // Mode (8 bytes octal) - 0000664 = regular file, rw-rw-r--\n header.write(\"0000664 \", 100, \"utf8\");\n\n // UID (8 bytes octal)\n header.write(\"0001750 \", 108, \"utf8\");\n\n // GID (8 bytes octal)\n header.write(\"0001750 \", 116, \"utf8\");\n\n // Size (12 bytes octal)\n const sizeOctal = size.toString(8).padStart(11, \"0\") + \" \";\n header.write(sizeOctal, 124, \"utf8\");\n\n // Modification time (12 bytes octal)\n const mtime =\n Math.floor(Date.now() / 1000)\n .toString(8)\n .padStart(11, \"0\") + \" \";\n header.write(mtime, 136, \"utf8\");\n\n // Checksum placeholder (8 bytes) - fill with spaces initially\n header.write(\" \", 148, \"utf8\");\n\n // Type flag (1 byte) - '0' for regular file\n header.write(\"0\", 156, \"utf8\");\n\n // Magic (6 bytes)\n header.write(\"ustar \", 257, \"utf8\");\n\n // Version (2 bytes)\n header.write(\" \\0\", 263, \"utf8\");\n\n // Calculate checksum (sum of all bytes, treating checksum field as spaces)\n let checksum = 0;\n for (let i = 0; i < TAR_BLOCK_SIZE; i++) {\n checksum += header[i];\n }\n\n // Write checksum (8 bytes octal)\n const checksumOctal = checksum.toString(8).padStart(6, \"0\") + \"\\0 \";\n header.write(checksumOctal, 148, \"utf8\");\n\n return header;\n}\n\n/**\n * Pad data to TAR block size (512 bytes)\n */\nfunction padToBlockSize(data: Buffer): Buffer {\n const remainder = data.length % TAR_BLOCK_SIZE;\n if (remainder === 0) return data;\n const padding = Buffer.alloc(TAR_BLOCK_SIZE - remainder);\n return Buffer.concat([data, padding]);\n}\n\n/**\n * Options for annie stream generation\n */\nexport type AnnieStreamOptions = {\n /** Abort signal for cancellation */\n signal?: AbortSignal;\n};\n\ntype ChunkOptions = {\n framePrefix?: string;\n frameDigits?: number;\n signal?: AbortSignal;\n};\n\n/**\n * Generate TAR archive chunks from an async iterator of frame buffers\n */\nasync function* tarChunks(\n frames: AsyncIterable<Buffer>,\n options: ChunkOptions = {},\n): AsyncGenerator<Buffer> {\n const { framePrefix = \"frame_\", frameDigits = 5, signal } = options;\n let i = 0;\n for await (const frame of frames) {\n if (signal?.aborted) {\n return;\n }\n\n const name = `${framePrefix}${i.toString().padStart(frameDigits, \"0\")}`;\n\n // Yield TAR header\n yield createTarHeader(name, frame.length);\n\n // Yield padded frame data\n yield padToBlockSize(frame);\n\n i++;\n }\n\n // Write two empty blocks (1024 bytes) to indicate end of TAR archive\n if (!signal?.aborted) {\n yield Buffer.alloc(TAR_BLOCK_SIZE * 2);\n }\n}\n\n/**\n * Collect all frames into a single annie Buffer (TAR archive)\n *\n * @param frames Async iterator yielding PNG or JPEG frame buffers\n * @returns Complete annie as a Buffer\n *\n * @example\n * ```ts\n * const frames = renderAnnieFrames(annieId, props, { width, height });\n * const annie = await annieBuffer(frames);\n * await fs.writeFile(\"animation.tar\", annie);\n * ```\n */\nexport async function annieBuffer(\n frames: AsyncIterable<Buffer>,\n): Promise<Buffer> {\n const chunks: Buffer[] = [];\n for await (const chunk of tarChunks(frames)) {\n chunks.push(chunk);\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * Create a ReadableStream that produces an annie (TAR archive of frames)\n *\n * Use this when you need the stream but want to customize the Response yourself.\n *\n * @param frames Async iterator yielding PNG or JPEG frame buffers\n * @param options Configuration options\n * @returns ReadableStream of annie data\n *\n * @example\n * ```ts\n * const frames = renderAnnieFrames(annieId, props, { width, height });\n * const stream = annieStream(frames, { signal: request.signal });\n * return new Response(stream, {\n * headers: { \"Content-Type\": \"application/x-tar\" }\n * });\n * ```\n */\nexport function annieStream(\n frames: AsyncIterable<Buffer>,\n options: AnnieStreamOptions = {},\n): ReadableStream<Buffer> {\n const abortController = new AbortController();\n const combinedSignal = options.signal\n ? AbortSignal.any([options.signal, abortController.signal])\n : abortController.signal;\n\n let iterator: AsyncGenerator<Buffer> | null = null;\n\n return new ReadableStream({\n async start() {\n iterator = tarChunks(frames, { signal: combinedSignal });\n },\n async pull(controller) {\n if (!iterator) return;\n\n try {\n const { value, done } = await iterator.next();\n if (done) {\n controller.close();\n } else {\n controller.enqueue(value);\n }\n } catch (err) {\n if (!combinedSignal.aborted) {\n controller.error(err);\n }\n }\n },\n cancel() {\n abortController.abort();\n iterator?.return(undefined).catch(() => {});\n },\n });\n}\n","import { annieStream } from \"./generate\";\nimport type { AnnieStreamOptions } from \"./generate\";\n\n/**\n * Options for annie Response generation\n */\nexport type AnnieResponseOptions = AnnieStreamOptions & {\n /** Additional headers to include in the response */\n headers?: HeadersInit;\n /** Cache-Control header value (default: \"public, max-age=3600\") */\n cacheControl?: string;\n /** Filename for Content-Disposition header (without .tar extension) */\n filename?: string;\n};\n\n/**\n * Create an HTTP Response that streams an annie\n *\n * This is the most convenient way to serve an annie animation from a web server.\n * It handles all the streaming, headers, and cleanup automatically.\n *\n * @param frames Async iterator yielding PNG or JPEG frame buffers\n * @param options Configuration options\n * @returns Response streaming the annie\n *\n * @example\n * ```ts\n * // In a route handler:\n * export async function loader({ request, params }: LoaderFunctionArgs) {\n * const frames = renderAnnieFrames(annieId, props, { width, height });\n * return annieResponse(frames, {\n * signal: request.signal,\n * filename: \"my-animation\",\n * });\n * }\n * ```\n */\nexport function annieResponse(\n frames: AsyncIterable<Buffer>,\n options: AnnieResponseOptions = {},\n): Response {\n const {\n headers: extraHeaders,\n cacheControl = \"public, max-age=3600\",\n filename,\n ...streamOptions\n } = options;\n\n const stream = annieStream(frames, streamOptions);\n\n const headers = new Headers(extraHeaders);\n headers.set(\"Content-Type\", \"application/x-tar\");\n if (cacheControl) {\n headers.set(\"Cache-Control\", cacheControl);\n }\n if (filename) {\n headers.set(\"Content-Disposition\", `inline; filename=\"${filename}.tar\"`);\n }\n\n return new Response(stream, { status: 200, headers });\n}\n"],"mappings":";AAIA,IAAM,iBAAiB;AAKvB,SAAS,gBAAgB,MAAc,MAAsB;AAC3D,QAAM,SAAS,OAAO,MAAM,cAAc;AAG1C,QAAM,YAAY,OAAO,KAAK,KAAK,MAAM,GAAG,GAAG,GAAG,MAAM;AACxD,YAAU,KAAK,QAAQ,CAAC;AAGxB,SAAO,MAAM,YAAY,KAAK,MAAM;AAGpC,SAAO,MAAM,YAAY,KAAK,MAAM;AAGpC,SAAO,MAAM,YAAY,KAAK,MAAM;AAGpC,QAAM,YAAY,KAAK,SAAS,CAAC,EAAE,SAAS,IAAI,GAAG,IAAI;AACvD,SAAO,MAAM,WAAW,KAAK,MAAM;AAGnC,QAAM,QACJ,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,EACzB,SAAS,CAAC,EACV,SAAS,IAAI,GAAG,IAAI;AACzB,SAAO,MAAM,OAAO,KAAK,MAAM;AAG/B,SAAO,MAAM,YAAY,KAAK,MAAM;AAGpC,SAAO,MAAM,KAAK,KAAK,MAAM;AAG7B,SAAO,MAAM,UAAU,KAAK,MAAM;AAGlC,SAAO,MAAM,OAAO,KAAK,MAAM;AAG/B,MAAI,WAAW;AACf,WAAS,IAAI,GAAG,IAAI,gBAAgB,KAAK;AACvC,gBAAY,OAAO,CAAC;AAAA,EACtB;AAGA,QAAM,gBAAgB,SAAS,SAAS,CAAC,EAAE,SAAS,GAAG,GAAG,IAAI;AAC9D,SAAO,MAAM,eAAe,KAAK,MAAM;AAEvC,SAAO;AACT;AAKA,SAAS,eAAe,MAAsB;AAC5C,QAAM,YAAY,KAAK,SAAS;AAChC,MAAI,cAAc,EAAG,QAAO;AAC5B,QAAM,UAAU,OAAO,MAAM,iBAAiB,SAAS;AACvD,SAAO,OAAO,OAAO,CAAC,MAAM,OAAO,CAAC;AACtC;AAmBA,gBAAgB,UACd,QACA,UAAwB,CAAC,GACD;AACxB,QAAM,EAAE,cAAc,UAAU,cAAc,GAAG,OAAO,IAAI;AAC5D,MAAI,IAAI;AACR,mBAAiB,SAAS,QAAQ;AAChC,QAAI,QAAQ,SAAS;AACnB;AAAA,IACF;AAEA,UAAM,OAAO,GAAG,WAAW,GAAG,EAAE,SAAS,EAAE,SAAS,aAAa,GAAG,CAAC;AAGrE,UAAM,gBAAgB,MAAM,MAAM,MAAM;AAGxC,UAAM,eAAe,KAAK;AAE1B;AAAA,EACF;AAGA,MAAI,CAAC,QAAQ,SAAS;AACpB,UAAM,OAAO,MAAM,iBAAiB,CAAC;AAAA,EACvC;AACF;AAeA,eAAsB,YACpB,QACiB;AACjB,QAAM,SAAmB,CAAC;AAC1B,mBAAiB,SAAS,UAAU,MAAM,GAAG;AAC3C,WAAO,KAAK,KAAK;AAAA,EACnB;AACA,SAAO,OAAO,OAAO,MAAM;AAC7B;AAoBO,SAAS,YACd,QACA,UAA8B,CAAC,GACP;AACxB,QAAM,kBAAkB,IAAI,gBAAgB;AAC5C,QAAM,iBAAiB,QAAQ,SAC3B,YAAY,IAAI,CAAC,QAAQ,QAAQ,gBAAgB,MAAM,CAAC,IACxD,gBAAgB;AAEpB,MAAI,WAA0C;AAE9C,SAAO,IAAI,eAAe;AAAA,IACxB,MAAM,QAAQ;AACZ,iBAAW,UAAU,QAAQ,EAAE,QAAQ,eAAe,CAAC;AAAA,IACzD;AAAA,IACA,MAAM,KAAK,YAAY;AACrB,UAAI,CAAC,SAAU;AAEf,UAAI;AACF,cAAM,EAAE,OAAO,KAAK,IAAI,MAAM,SAAS,KAAK;AAC5C,YAAI,MAAM;AACR,qBAAW,MAAM;AAAA,QACnB,OAAO;AACL,qBAAW,QAAQ,KAAK;AAAA,QAC1B;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,CAAC,eAAe,SAAS;AAC3B,qBAAW,MAAM,GAAG;AAAA,QACtB;AAAA,MACF;AAAA,IACF;AAAA,IACA,SAAS;AACP,sBAAgB,MAAM;AACtB,gBAAU,OAAO,MAAS,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IAC5C;AAAA,EACF,CAAC;AACH;;;AC5JO,SAAS,cACd,QACA,UAAgC,CAAC,GACvB;AACV,QAAM;AAAA,IACJ,SAAS;AAAA,IACT,eAAe;AAAA,IACf;AAAA,IACA,GAAG;AAAA,EACL,IAAI;AAEJ,QAAM,SAAS,YAAY,QAAQ,aAAa;AAEhD,QAAM,UAAU,IAAI,QAAQ,YAAY;AACxC,UAAQ,IAAI,gBAAgB,mBAAmB;AAC/C,MAAI,cAAc;AAChB,YAAQ,IAAI,iBAAiB,YAAY;AAAA,EAC3C;AACA,MAAI,UAAU;AACZ,YAAQ,IAAI,uBAAuB,qBAAqB,QAAQ,OAAO;AAAA,EACzE;AAEA,SAAO,IAAI,SAAS,QAAQ,EAAE,QAAQ,KAAK,QAAQ,CAAC;AACtD;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effing/annie",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Annie animation frame generator utilities",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@effing/serde": "0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"tsup": "^8.0.0",
|
|
20
|
+
"typescript": "^5.9.3",
|
|
21
|
+
"vitest": "^3.2.4"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"zod": "^3.0.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"zod": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"animation",
|
|
33
|
+
"frames",
|
|
34
|
+
"video",
|
|
35
|
+
"satori"
|
|
36
|
+
],
|
|
37
|
+
"license": "O'Saasy",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "vitest run"
|
|
45
|
+
}
|
|
46
|
+
}
|