@grundyjs/canvas-take 0.2.0 → 0.4.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/CHANGELOG.md +14 -0
- package/README.md +100 -2
- package/dist/gif-worker-runtime.d.ts +1 -0
- package/dist/gif-worker-runtime.js +33 -0
- package/dist/gif-worker.d.ts +10 -0
- package/dist/gif-worker.js +91 -0
- package/dist/gif.d.ts +49 -0
- package/dist/gif.js +147 -0
- package/dist/gifenc-LICENSE.txt +21 -0
- package/dist/gifenc.d.ts +9 -0
- package/dist/gifenc.js +2 -0
- package/docs/gif-encoder.md +42 -0
- package/package.json +27 -5
- package/ru/README.md +318 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Ready-made `gif/worker` adapter with a dedicated module Worker, transferable frames, request timeouts and cleanup on completion/cancellation/errors.
|
|
6
|
+
- Encoder-independent `onProgress` notifications with completed frame count, encoded duration, optional total frame hint and finalization/terminal phases.
|
|
7
|
+
- Worker/progress example, integration tests and package asset checks.
|
|
8
|
+
- Packed Next.js 16.3 / Turbopack consumer build in CI; browser-verified Worker export, progress and cancellation under same-origin Worker CSP.
|
|
9
|
+
|
|
10
|
+
## 0.3.0 — 2026-10-15
|
|
11
|
+
|
|
12
|
+
- Offline canvas GIF export via `@grundyjs/canvas-take/gif` with an injected encoder factory.
|
|
13
|
+
- Optional isolated `gif/gifenc` adapter, configurable palette quality/color count, frame durations and repeat count.
|
|
14
|
+
- AbortSignal cancellation, owned-resource cleanup and asynchronous/Worker adapter support.
|
|
15
|
+
- Independent GIF decoding tests, package isolation checks and an offline canvas example.
|
|
16
|
+
|
|
3
17
|
## 0.2.0 — 2026-10-15
|
|
4
18
|
|
|
5
19
|
- Optional caller-provided `audioTracks`, cloned at start to preserve caller ownership.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# CanvasTake
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Русский](ru/README.md)
|
|
4
|
+
|
|
5
|
+
Independent TypeScript library for HTML canvas video recording with optional audio tracks and offline GIF export through a separate entry point. No external runtime dependencies or dependency on AlgiViz. ESM only, with TypeScript declarations. MIT licensed. The package does not start or draw your animation.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -88,6 +90,103 @@ For a browser smoke test, run `node examples/server.cjs` after building and open
|
|
|
88
90
|
|
|
89
91
|
The audio track list is copied at creation and validated again at start. CanvasTake clones the tracks at start and stops only its clones and canvas capture tracks on completion, disposal or failure. The caller owns the original tracks and AudioContext. Unexpected termination of a recorded clone fails recording. Calling `stop()` on an original track does not stop its clone; call `recorder.stop()` to finish the recording. Finishing an AudioBufferSource does not end the destination track either.
|
|
90
92
|
|
|
93
|
+
## Offline GIF export
|
|
94
|
+
|
|
95
|
+
Available since version 0.3.0.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { createGifExporter } from '@grundyjs/canvas-take/gif';
|
|
99
|
+
import { createGifencEncoder } from '@grundyjs/canvas-take/gif/gifenc';
|
|
100
|
+
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const gif = createGifExporter({
|
|
103
|
+
encoder: createGifencEncoder({ maxColors: 128, quality: 'high' }),
|
|
104
|
+
repeat: 0,
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
for (let time = 0; time < 6000; time += 50) {
|
|
109
|
+
drawTimeline(time); // Your deterministic renderer draws into canvas.
|
|
110
|
+
await gif.addFrame(canvas, 50);
|
|
111
|
+
}
|
|
112
|
+
drawTimeline(6000);
|
|
113
|
+
await gif.addFrame(canvas, 2000); // Hold the final frame for two seconds.
|
|
114
|
+
const blob = await gif.finish(); // image/gif; 8 seconds, 121 frames.
|
|
115
|
+
// Download/preview blob; revoke any object URL when no longer needed.
|
|
116
|
+
} finally {
|
|
117
|
+
gif.dispose();
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`encoder` is required and supplied by the caller. The `/gif` entry has no codec dependency; `/gif/gifenc` is an optional, self-contained adapter bundling the MIT-licensed gifenc codec. The root video import loads neither GIF module. You can replace the adapter without changing the export loop.
|
|
122
|
+
|
|
123
|
+
| Setting | Meaning |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `repeat` | Default `0`: forever. `-1`: play once. `1..65535`: extra repetitions after the first play |
|
|
126
|
+
| `signal` | Optional AbortSignal; abort rejects pending work and disposes the encoder |
|
|
127
|
+
| `background` | RGB byte tuple, default `[255, 255, 255]`; composites transparency onto an opaque background |
|
|
128
|
+
| Adapter `maxColors` | Maximum palette colors per frame, integer `2..256`, default `256` |
|
|
129
|
+
| Adapter `quality` | `high` (default, RGB565) or `fast` (RGB444); no dithering |
|
|
130
|
+
|
|
131
|
+
`addFrame(canvas, durationMs)` snapshots a 2D HTMLCanvasElement or OffscreenCanvas immediately, then yields before encoding. Await each call before another frame or `finish()`. All frames must have identical positive dimensions (GIF limit 65535 per axis). Durations must be finite and between 10 and 655350 ms; they are rounded to the nearest 10 ms. GIF players may clamp short delays; 50 ms is a practical timeline step. The drawing time and encoding wall time do not affect the GIF duration. The canvas remains caller-owned and unmodified. Tainted canvases fail with the readback error.
|
|
132
|
+
|
|
133
|
+
`finish()` requires at least one frame, returns a Blob and releases the encoder. Each exporter produces one GIF. `dispose()` is idempotent, cancels pending work and releases encoder resources. Invalid frame data, changed dimensions and encoder errors make the exporter terminal. Concurrent calls are rejected without cancelling the ongoing operation. State is `ready`, `encoding`, `finished`, `failed`, `aborted` or `disposed`.
|
|
134
|
+
|
|
135
|
+
The built-in adapter incrementally encodes full frames with individual palettes, retaining compressed output rather than all raw frames. It performs synchronous work per frame on the calling thread, with cancellation serviced between frames. For expensive frames, use the ready-made `/gif/worker` adapter, run with OffscreenCanvas in a Worker or supply an asynchronous Worker adapter; there is no automatic Worker pool. GIF bytes and the final Blob consume memory, so callers should bound dimensions and frame count. File size depends on visual complexity; an 800 px, 6–10 second animation is a target, not a guaranteed 2–3 MB output.
|
|
136
|
+
|
|
137
|
+
### Worker adapter and progress (0.4.0)
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
import { createGifExporter } from '@grundyjs/canvas-take/gif';
|
|
141
|
+
import { createGifWorkerEncoder } from '@grundyjs/canvas-take/gif/worker';
|
|
142
|
+
|
|
143
|
+
const gif = createGifExporter({
|
|
144
|
+
encoder: createGifWorkerEncoder({ maxColors: 128, quality: 'high' }),
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
totalFrames: 121,
|
|
147
|
+
onProgress({ phase, framesEncoded, totalFrames, durationMs }) {
|
|
148
|
+
status.textContent = `${phase}: ${framesEncoded}/${totalFrames}, ${durationMs} ms`;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The dedicated module Worker performs quantization and GIF encoding. Canvas readback and alpha compositing remain on the calling thread. Each exporter owns one Worker, terminated on finish, cancellation, failure or disposal. Pixel buffers are transferred; the source canvas is unchanged. This adapter requires browser Worker support and does not silently fall back to main-thread encoding.
|
|
154
|
+
|
|
155
|
+
The default Worker URL is resolved relative to the adapter module. For direct browser imports, serve `gif-worker.js`, `gif-worker-runtime.js` and `gifenc.js` together with JavaScript MIME types. With a bundler, ensure the Worker asset and its dependencies are emitted; if necessary, copy the runtime and `gifenc.js` to the same public directory and pass `workerUrl: '/assets/gif-worker-runtime.js'`. The runtime is also exported at `@grundyjs/canvas-take/gif/worker-runtime` for asset resolution (do not import it on the main thread). Follow your site's same-origin and CSP `worker-src` rules. No Blob URL or inline worker is required.
|
|
156
|
+
|
|
157
|
+
`timeoutMs` defaults to 30000 ms **per request**, including startup, each frame and finalization. A timeout, script-load error or message-transfer error rejects work and terminates the Worker; increase the timeout explicitly for unusually expensive frames.
|
|
158
|
+
|
|
159
|
+
Progress works with every encoder, not just the Worker adapter. Immutable snapshots arrive after each completed frame (`encoding`), before finishing (`finalizing`), and at `finished`, `failed`, `aborted` or unfinished `disposed`. `framesEncoded` and `durationMs` include only successfully encoded frames, using rounded GIF durations. `totalFrames` is an optional positive-integer display hint, not a limit; absent hints are omitted from events. No percentage or ETA is guessed; a full frame count does not mean finalization is complete. There is no initial event. Observer exceptions are ignored; aborting or disposing from an observer is supported. Disposing after a terminal outcome emits no additional event.
|
|
160
|
+
|
|
161
|
+
The offline `examples/gif.html` now demonstrates this adapter, progress and cancellation.
|
|
162
|
+
|
|
163
|
+
### Supplying another encoder
|
|
164
|
+
|
|
165
|
+
Implement the exported `GifEncoderFactory` contract:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import type { GifEncoderFactory } from '@grundyjs/canvas-take/gif';
|
|
169
|
+
|
|
170
|
+
const encoder: GifEncoderFactory = ({ repeat }) => ({
|
|
171
|
+
addFrame({ data, width, height, durationMs }) {
|
|
172
|
+
// Pass the opaque RGBA snapshot to your codec; may return Promise<void>.
|
|
173
|
+
},
|
|
174
|
+
async finish() {
|
|
175
|
+
// Return complete GIF bytes as Uint8Array.
|
|
176
|
+
return encodedBytes;
|
|
177
|
+
},
|
|
178
|
+
dispose() {
|
|
179
|
+
// Drop buffers, terminate owned Workers, settle pending operations.
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Each factory call must allocate a fresh encoder and honor the supplied repeat count. The exporter calls methods sequentially and invokes `dispose()` once on success, failure or cancellation. The adapter may transfer the owned frame buffer to a Worker; it must release frame references after each operation and support disposal during pending work. Cancellation rejects exporter operations immediately, but cleanup of external work belongs to the adapter. Synchronous codec work cannot be preempted on the same thread. Cleanup exceptions are suppressed to preserve the primary outcome.
|
|
185
|
+
|
|
186
|
+
Run `node examples/server.cjs` after building and open `http://127.0.0.1:3012/canvas-take/examples/gif.html` for a standalone offline example with cancel, preview and download. Codec rationale and measured module sizes are in [docs/gif-encoder.md](docs/gif-encoder.md).
|
|
187
|
+
|
|
188
|
+
With a built AlgiViz checkout in the sibling `algiviz` directory, open `http://127.0.0.1:3012/canvas-take/examples/gif-bfs.html` for the real BFS integration. Verified output: 800×560, 141 frames, 9 seconds including a 2-second final hold, about 1.60 MB. The example uses AlgiViz's existing algorithm and renderer and does not modify that project.
|
|
189
|
+
|
|
91
190
|
## Browser and server use
|
|
92
191
|
|
|
93
192
|
Recording requires a browser with `canvas.captureStream`, `MediaRecorder` and `MutationObserver`. Importing the module does not access these APIs; creating a recorder checks capture/encoder support. Node.js has no recording implementation. Codec support is detected at runtime, not inferred from the browser name.
|
|
@@ -99,4 +198,3 @@ The integration example was verified with an actual VP9 recording at 1920×1080.
|
|
|
99
198
|
Run `npm test` and `npm run test:package` (packs and installs the tarball in an isolated consumer, then checks TypeScript declarations and runtime imports). The archive contains dist, README, LICENSE, CHANGELOG and package metadata. Source and issues: [urffin/canvas-take](https://github.com/urffin/canvas-take). The 0.x API may change in later minor releases.
|
|
100
199
|
|
|
101
200
|
After CI passes on the release commit, publish a stable GitHub Release tagged `v<package version>`. The `npm-publish.yml` workflow checks version consistency, verifies the packed consumer and publishes with OIDC/provenance. Configure npm's trusted publisher for owner `urffin`, repository `canvas-take`, workflow `npm-publish.yml` before the first automated release. No npm token is used. Publishing is a separate maintainer action.
|
|
102
|
-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createGifencEncoder } from './gifenc.js';
|
|
2
|
+
const scope = globalThis;
|
|
3
|
+
let encoder;
|
|
4
|
+
scope.onmessage = event => {
|
|
5
|
+
const { id, action, value } = event.data;
|
|
6
|
+
try {
|
|
7
|
+
if (action === 'init') {
|
|
8
|
+
if (encoder)
|
|
9
|
+
throw new Error('GIF Worker already initialized');
|
|
10
|
+
const options = value;
|
|
11
|
+
encoder = createGifencEncoder(options)({ repeat: options.repeat });
|
|
12
|
+
}
|
|
13
|
+
else if (!encoder)
|
|
14
|
+
throw new Error('GIF Worker is not initialized');
|
|
15
|
+
else if (action === 'frame')
|
|
16
|
+
encoder.addFrame(value);
|
|
17
|
+
else if (action === 'finish') {
|
|
18
|
+
const bytes = encoder.finish();
|
|
19
|
+
encoder.dispose();
|
|
20
|
+
encoder = undefined;
|
|
21
|
+
scope.postMessage({ id, bytes }, [bytes.buffer]);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
throw new Error('Unknown GIF Worker request');
|
|
26
|
+
scope.postMessage({ id });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
encoder?.dispose();
|
|
30
|
+
encoder = undefined;
|
|
31
|
+
scope.postMessage({ id, error: error instanceof Error ? error.message : String(error) });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GifEncoderFactory } from './gif.js';
|
|
2
|
+
import type { GifencOptions } from './gifenc.js';
|
|
3
|
+
export interface GifWorkerOptions extends GifencOptions {
|
|
4
|
+
/** Override the module Worker asset URL when copying assets or using a bundler. */
|
|
5
|
+
readonly workerUrl?: string | URL;
|
|
6
|
+
/** Per-request timeout, including Worker startup. Default 30000 ms. */
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** One dedicated module Worker per exporter. No codec runs on the calling thread. */
|
|
10
|
+
export declare function createGifWorkerEncoder(options?: GifWorkerOptions): GifEncoderFactory;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** One dedicated module Worker per exporter. No codec runs on the calling thread. */
|
|
2
|
+
export function createGifWorkerEncoder(options = {}) {
|
|
3
|
+
const maxColors = options.maxColors ?? 256, quality = options.quality ?? 'high';
|
|
4
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
5
|
+
const workerUrl = options.workerUrl;
|
|
6
|
+
if (!Number.isInteger(maxColors) || maxColors < 2 || maxColors > 256)
|
|
7
|
+
throw new RangeError('maxColors must be 2..256');
|
|
8
|
+
if (quality !== 'high' && quality !== 'fast')
|
|
9
|
+
throw new TypeError('quality must be high or fast');
|
|
10
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647)
|
|
11
|
+
throw new RangeError('timeoutMs must be in (0, 2147483647]');
|
|
12
|
+
return ({ repeat }) => {
|
|
13
|
+
if (typeof Worker === 'undefined')
|
|
14
|
+
throw new Error('GIF Worker encoding is not supported in this environment');
|
|
15
|
+
let worker = workerUrl === undefined
|
|
16
|
+
? new Worker(new URL('./gif-worker-runtime.js', import.meta.url), { type: 'module' })
|
|
17
|
+
: new Worker(workerUrl, { type: 'module' });
|
|
18
|
+
let nextId = 0;
|
|
19
|
+
let failure;
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
function close(error) {
|
|
22
|
+
failure ??= error;
|
|
23
|
+
const owned = worker;
|
|
24
|
+
worker = undefined;
|
|
25
|
+
if (owned) {
|
|
26
|
+
owned.removeEventListener('message', onMessage);
|
|
27
|
+
owned.removeEventListener('error', onError);
|
|
28
|
+
owned.removeEventListener('messageerror', onMessageError);
|
|
29
|
+
owned.terminate();
|
|
30
|
+
}
|
|
31
|
+
for (const request of pending.values()) {
|
|
32
|
+
clearTimeout(request.timer);
|
|
33
|
+
request.reject(failure);
|
|
34
|
+
}
|
|
35
|
+
pending.clear();
|
|
36
|
+
}
|
|
37
|
+
function onMessage(event) {
|
|
38
|
+
const message = event.data;
|
|
39
|
+
const request = pending.get(message?.id);
|
|
40
|
+
if (!request)
|
|
41
|
+
return;
|
|
42
|
+
if (typeof message.error === 'string') {
|
|
43
|
+
close(new Error(message.error));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
pending.delete(message.id);
|
|
47
|
+
clearTimeout(request.timer);
|
|
48
|
+
request.resolve(message.bytes);
|
|
49
|
+
}
|
|
50
|
+
function onError(event) { close(new Error(event.message || 'GIF Worker failed to load or execute')); }
|
|
51
|
+
function onMessageError() { close(new Error('GIF Worker message could not be decoded')); }
|
|
52
|
+
worker.addEventListener('message', onMessage);
|
|
53
|
+
worker.addEventListener('error', onError);
|
|
54
|
+
worker.addEventListener('messageerror', onMessageError);
|
|
55
|
+
function send(action, value, transfer = []) {
|
|
56
|
+
if (!worker)
|
|
57
|
+
return Promise.reject(failure ?? new Error('GIF Worker is disposed'));
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const id = ++nextId;
|
|
60
|
+
const timer = setTimeout(() => close(new Error(`GIF Worker ${action} timed out`)), timeoutMs);
|
|
61
|
+
pending.set(id, { resolve, reject, timer });
|
|
62
|
+
try {
|
|
63
|
+
worker.postMessage({ id, action, value }, transfer);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
close(error instanceof Error ? error : new Error(String(error)));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const initialized = send('init', { repeat, maxColors, quality });
|
|
71
|
+
void initialized.catch(() => { });
|
|
72
|
+
return {
|
|
73
|
+
async addFrame(frame) {
|
|
74
|
+
await initialized;
|
|
75
|
+
await send('frame', frame, [frame.data.buffer]);
|
|
76
|
+
},
|
|
77
|
+
async finish() {
|
|
78
|
+
await initialized;
|
|
79
|
+
const bytes = await send('finish');
|
|
80
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
81
|
+
const error = new Error('GIF Worker returned invalid bytes');
|
|
82
|
+
close(error);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
close(new Error('GIF Worker finished'));
|
|
86
|
+
return bytes;
|
|
87
|
+
},
|
|
88
|
+
dispose() { close(new DOMException('GIF Worker disposed', 'AbortError')); }
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}
|
package/dist/gif.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** An immutable frame description; the encoder may use the owned pixel copy until addFrame settles. */
|
|
2
|
+
export interface GifFrame {
|
|
3
|
+
readonly width: number;
|
|
4
|
+
readonly height: number;
|
|
5
|
+
readonly data: Uint8ClampedArray;
|
|
6
|
+
/** Milliseconds, rounded to GIF's 10 ms units by the exporter. */
|
|
7
|
+
readonly durationMs: number;
|
|
8
|
+
}
|
|
9
|
+
export interface GifEncoderOptions {
|
|
10
|
+
/** -1: play once; 0: forever; 1..65535: extra repetitions. */
|
|
11
|
+
readonly repeat: number;
|
|
12
|
+
}
|
|
13
|
+
/** Implementations may encode synchronously or use a Worker. Never retain frames after completion. */
|
|
14
|
+
export interface GifFrameEncoder {
|
|
15
|
+
addFrame(frame: GifFrame): void | Promise<void>;
|
|
16
|
+
/** Return a complete GIF file. */
|
|
17
|
+
finish(): Uint8Array | Promise<Uint8Array>;
|
|
18
|
+
/** Must release buffers/terminate owned Workers, including during pending operations. */
|
|
19
|
+
dispose(): void;
|
|
20
|
+
}
|
|
21
|
+
/** Must return a fresh, exclusively owned encoder for each export. */
|
|
22
|
+
export type GifEncoderFactory = (options: GifEncoderOptions) => GifFrameEncoder;
|
|
23
|
+
export interface GifExportOptions {
|
|
24
|
+
readonly encoder: GifEncoderFactory;
|
|
25
|
+
readonly repeat?: number;
|
|
26
|
+
readonly signal?: AbortSignal;
|
|
27
|
+
/** Transparent pixels are composited onto this RGB color. Default: white. */
|
|
28
|
+
readonly background?: readonly [number, number, number];
|
|
29
|
+
/** Optional display hint; does not limit the number of frames. */
|
|
30
|
+
readonly totalFrames?: number;
|
|
31
|
+
/** Observer exceptions are ignored. Called after frames and on terminal/finalizing states. */
|
|
32
|
+
readonly onProgress?: (progress: GifExportProgress) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface GifExportProgress {
|
|
35
|
+
readonly phase: 'encoding' | 'finalizing' | 'finished' | 'failed' | 'aborted' | 'disposed';
|
|
36
|
+
readonly framesEncoded: number;
|
|
37
|
+
readonly durationMs: number;
|
|
38
|
+
readonly totalFrames?: number;
|
|
39
|
+
}
|
|
40
|
+
export type GifExportState = 'ready' | 'encoding' | 'finished' | 'failed' | 'aborted' | 'disposed';
|
|
41
|
+
export interface GifExporter {
|
|
42
|
+
readonly state: GifExportState;
|
|
43
|
+
/** Snapshot a 2D canvas immediately. Await completion before adding another frame or finishing. */
|
|
44
|
+
addFrame(canvas: HTMLCanvasElement | OffscreenCanvas, durationMs: number): Promise<void>;
|
|
45
|
+
finish(): Promise<Blob>;
|
|
46
|
+
dispose(): void;
|
|
47
|
+
}
|
|
48
|
+
/** Offline, sequential GIF export. No timers are used to determine frame durations. */
|
|
49
|
+
export declare function createGifExporter(options: GifExportOptions): GifExporter;
|
package/dist/gif.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Offline, sequential GIF export. No timers are used to determine frame durations. */
|
|
2
|
+
export function createGifExporter(options) {
|
|
3
|
+
const { signal, encoder: factory } = options;
|
|
4
|
+
const { totalFrames, onProgress } = options;
|
|
5
|
+
if (totalFrames !== undefined && (!Number.isSafeInteger(totalFrames) || totalFrames < 1))
|
|
6
|
+
throw new RangeError('totalFrames must be a positive safe integer');
|
|
7
|
+
const repeat = options.repeat ?? 0;
|
|
8
|
+
const background = [...(options.background ?? [255, 255, 255])];
|
|
9
|
+
if (typeof factory !== 'function')
|
|
10
|
+
throw new TypeError('An encoder factory is required');
|
|
11
|
+
if (!Number.isInteger(repeat) || repeat < -1 || repeat > 65535)
|
|
12
|
+
throw new RangeError('repeat must be -1..65535');
|
|
13
|
+
if (background.length !== 3 || background.some(v => !Number.isInteger(v) || v < 0 || v > 255)) {
|
|
14
|
+
throw new RangeError('background must contain three RGB bytes');
|
|
15
|
+
}
|
|
16
|
+
const abortError = () => signal?.reason ?? new DOMException('GIF export aborted', 'AbortError');
|
|
17
|
+
if (signal?.aborted)
|
|
18
|
+
throw abortError();
|
|
19
|
+
let encoder = factory(Object.freeze({ repeat }));
|
|
20
|
+
let state = 'ready';
|
|
21
|
+
let width = 0, height = 0, frames = 0;
|
|
22
|
+
let duration = 0;
|
|
23
|
+
function report(phase) {
|
|
24
|
+
try {
|
|
25
|
+
onProgress?.(Object.freeze({ phase, framesEncoded: frames, durationMs: duration,
|
|
26
|
+
...(totalFrames === undefined ? {} : { totalFrames }) }));
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
let rejectCancelled;
|
|
31
|
+
const cancelled = new Promise((_, reject) => { rejectCancelled = reject; });
|
|
32
|
+
void cancelled.catch(() => { });
|
|
33
|
+
function cleanup() {
|
|
34
|
+
signal?.removeEventListener('abort', onAbort);
|
|
35
|
+
const owned = encoder;
|
|
36
|
+
encoder = undefined;
|
|
37
|
+
// Preserve the original failure/cancellation even if an adapter's cleanup throws.
|
|
38
|
+
try {
|
|
39
|
+
owned?.dispose();
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
function onAbort() {
|
|
44
|
+
if (state !== 'ready' && state !== 'encoding')
|
|
45
|
+
return;
|
|
46
|
+
state = 'aborted';
|
|
47
|
+
rejectCancelled(abortError());
|
|
48
|
+
cleanup();
|
|
49
|
+
report('aborted');
|
|
50
|
+
}
|
|
51
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
52
|
+
if (signal?.aborted)
|
|
53
|
+
onAbort();
|
|
54
|
+
function ready() {
|
|
55
|
+
if (state !== 'ready')
|
|
56
|
+
throw new Error(`GIF exporter is ${state}`);
|
|
57
|
+
}
|
|
58
|
+
function active() {
|
|
59
|
+
if (state !== 'encoding')
|
|
60
|
+
throw new Error(`GIF exporter is ${state}`);
|
|
61
|
+
}
|
|
62
|
+
async function operation(work) {
|
|
63
|
+
ready();
|
|
64
|
+
state = 'encoding';
|
|
65
|
+
try {
|
|
66
|
+
return await Promise.race([work(), cancelled]);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (state === 'encoding') {
|
|
70
|
+
state = 'failed';
|
|
71
|
+
cleanup();
|
|
72
|
+
report('failed');
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
get state() { return state; },
|
|
79
|
+
addFrame(canvas, durationMs) {
|
|
80
|
+
return operation(async () => {
|
|
81
|
+
if (!Number.isFinite(durationMs) || durationMs < 10 || durationMs > 655350) {
|
|
82
|
+
throw new RangeError('durationMs must be between 10 and 655350');
|
|
83
|
+
}
|
|
84
|
+
const w = canvas.width, h = canvas.height;
|
|
85
|
+
if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1 || w > 65535 || h > 65535) {
|
|
86
|
+
throw new RangeError('Canvas dimensions must be integers in 1..65535');
|
|
87
|
+
}
|
|
88
|
+
if (frames && (w !== width || h !== height))
|
|
89
|
+
throw new Error('GIF frame dimensions must remain fixed');
|
|
90
|
+
const context = canvas.getContext('2d');
|
|
91
|
+
if (!context)
|
|
92
|
+
throw new Error('GIF export requires a 2D canvas');
|
|
93
|
+
const data = new Uint8ClampedArray(context.getImageData(0, 0, w, h).data);
|
|
94
|
+
if (data.length !== w * h * 4)
|
|
95
|
+
throw new Error('Invalid canvas pixel data');
|
|
96
|
+
// Yield between frames so UI cancellation can be delivered; snapshot is already owned.
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
98
|
+
active();
|
|
99
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
100
|
+
const alpha = data[i + 3] / 255;
|
|
101
|
+
for (let c = 0; c < 3; c++)
|
|
102
|
+
data[i + c] = Math.round(data[i + c] * alpha + background[c] * (1 - alpha));
|
|
103
|
+
data[i + 3] = 255;
|
|
104
|
+
}
|
|
105
|
+
await encoder.addFrame({ width: w, height: h, data, durationMs: Math.round(durationMs / 10) * 10 });
|
|
106
|
+
active();
|
|
107
|
+
width = w;
|
|
108
|
+
height = h;
|
|
109
|
+
frames++;
|
|
110
|
+
duration += Math.round(durationMs / 10) * 10;
|
|
111
|
+
report('encoding');
|
|
112
|
+
active();
|
|
113
|
+
state = 'ready';
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
finish() {
|
|
117
|
+
return operation(async () => {
|
|
118
|
+
if (!frames)
|
|
119
|
+
throw new Error('GIF export requires at least one frame');
|
|
120
|
+
report('finalizing');
|
|
121
|
+
active();
|
|
122
|
+
const bytes = await encoder.finish();
|
|
123
|
+
active();
|
|
124
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 6 ||
|
|
125
|
+
!['GIF87a', 'GIF89a'].includes(String.fromCharCode(...bytes.subarray(0, 6)))) {
|
|
126
|
+
throw new Error('Encoder did not return GIF bytes');
|
|
127
|
+
}
|
|
128
|
+
const blob = new Blob([new Uint8Array(bytes)], { type: 'image/gif' });
|
|
129
|
+
state = 'finished';
|
|
130
|
+
cleanup();
|
|
131
|
+
report('finished');
|
|
132
|
+
return blob;
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
dispose() {
|
|
136
|
+
if (state === 'disposed')
|
|
137
|
+
return;
|
|
138
|
+
const unfinished = state === 'ready' || state === 'encoding';
|
|
139
|
+
state = 'disposed';
|
|
140
|
+
if (unfinished)
|
|
141
|
+
rejectCancelled(new DOMException('GIF exporter disposed', 'AbortError'));
|
|
142
|
+
cleanup();
|
|
143
|
+
if (unfinished)
|
|
144
|
+
report('disposed');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2017 Matt DesLauriers
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
18
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
19
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
20
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
|
21
|
+
|
package/dist/gifenc.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GifEncoderFactory } from './gif.js';
|
|
2
|
+
export interface GifencOptions {
|
|
3
|
+
/** Maximum colors per frame: 2..256. Default 256. */
|
|
4
|
+
readonly maxColors?: number;
|
|
5
|
+
/** high: RGB565 quantization; fast: RGB444. Default high. No dithering. */
|
|
6
|
+
readonly quality?: 'high' | 'fast';
|
|
7
|
+
}
|
|
8
|
+
/** Optional gifenc adapter. Import only when using this implementation. */
|
|
9
|
+
export declare function createGifencEncoder(options?: GifencOptions): GifEncoderFactory;
|
package/dist/gifenc.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! Includes gifenc (MIT). See gifenc-LICENSE.txt. */
|
|
2
|
+
var ce=Object.create;var W=Object.defineProperty;var se=Object.getOwnPropertyDescriptor;var le=Object.getOwnPropertyNames;var ae=Object.getPrototypeOf,fe=Object.prototype.hasOwnProperty;var ue=(e,t)=>()=>{try{return t||e((t={exports:{}}).exports,t),t.exports}catch(o){throw t=0,o}};var de=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of le(t))!fe.call(e,r)&&r!==o&&W(e,r,{get:()=>t[r],enumerable:!(n=se(t,r))||n.enumerable});return e};var ge=(e,t,o)=>(o=e!=null?ce(ae(e)):{},de(t||!e||!e.__esModule?W(o,"default",{value:e,enumerable:!0}):o,e));var ie=ue(N=>{var Y=Object.defineProperty,he=e=>Y(e,"__esModule",{value:!0}),ye=(e,t)=>{for(var o in t)Y(e,o,{get:t[o],enumerable:!0})};he(N);ye(N,{GIFEncoder:()=>re,applyPalette:()=>Ce,default:()=>Ge,nearestColor:()=>Ee,nearestColorIndex:()=>te,nearestColorIndexWithDistance:()=>ne,prequantize:()=>ve,quantize:()=>Ae,snapColorsToPalette:()=>ke});var we={signature:"GIF",version:"89a",trailer:59,extensionIntroducer:33,applicationExtensionLabel:255,graphicControlExtensionLabel:249,imageSeparator:44,signatureSize:3,versionSize:3,globalColorTableFlagMask:128,colorResolutionMask:112,sortFlagMask:8,globalColorTableSizeMask:7,applicationIdentifierSize:8,applicationAuthCodeSize:3,disposalMethodMask:28,userInputFlagMask:2,transparentColorFlagMask:1,localColorTableFlagMask:128,interlaceFlagMask:64,idSortFlagMask:32,localColorTableSizeMask:7};function J(e=256){let t=0,o=new Uint8Array(e);return{get buffer(){return o.buffer},reset(){t=0},bytesView(){return o.subarray(0,t)},bytes(){return o.slice(0,t)},writeByte(r){n(t+1),o[t]=r,t++},writeBytes(r,i=0,c=r.length){n(t+c);for(let l=0;l<c;l++)o[t++]=r[l+i]},writeBytesView(r,i=0,c=r.byteLength){n(t+c),o.set(r.subarray(i,i+c),t),t+=c}};function n(r){var i=o.length;if(i>=r)return;var c=1024*1024;r=Math.max(r,i*(i<c?2:1.125)>>>0),i!=0&&(r=Math.max(r,256));let l=o;o=new Uint8Array(r),t>0&&o.set(l.subarray(0,t),0)}}var O=12,Z=5003,be=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function pe(e,t,o,n,r=J(512),i=new Uint8Array(256),c=new Int32Array(Z),l=new Int32Array(Z)){let d=c.length,f=Math.max(2,n);i.fill(0),l.fill(0),c.fill(-1);let a=0,s=0,g=f+1,p=g,h=!1,y=p,A=(1<<y)-1,u=1<<g-1,_=u+1,B=u+2,w=0,I=o[0],F=0;for(let x=d;x<65536;x*=2)++F;F=8-F,r.writeByte(f),C(u);let b=o.length;for(let x=1;x<b;x++)e:{let k=o[x],E=(k<<O)+I,v=k<<F^I;if(c[v]===E){I=l[v];break e}let P=v===0?1:d-v;for(;c[v]>=0;)if(v-=P,v<0&&(v+=d),c[v]===E){I=l[v];break e}C(I),I=k,B<1<<O?(l[v]=B++,c[v]=E):(c.fill(-1),B=u+2,h=!0,C(u))}return C(I),C(_),r.writeByte(0),r.bytesView();function C(x){for(a&=be[s],s>0?a|=x<<s:a=x,s+=y;s>=8;)i[w++]=a&255,w>=254&&(r.writeByte(w),r.writeBytesView(i,0,w),w=0),a>>=8,s-=8;if((B>A||h)&&(h?(y=p,A=(1<<y)-1,h=!1):(++y,A=y===O?1<<y:(1<<y)-1)),x==_){for(;s>0;)i[w++]=a&255,w>=254&&(r.writeByte(w),r.writeBytesView(i,0,w),w=0),a>>=8,s-=8;w>0&&(r.writeByte(w),r.writeBytesView(i,0,w),w=0)}}}var xe=pe;function Q(e,t,o){return e<<8&63488|t<<2&992|o>>3}function $(e,t,o,n){return e>>4|t&240|(o&240)<<4|(n&240)<<8}function ee(e,t,o){return e>>4<<8|t&240|o>>4}function U(e,t,o){return e<t?t:e>o?o:e}function G(e){return e*e}function K(e,t,o){var n=0,r=1e100;let i=e[t],c=i.cnt,l=i.ac,d=i.rc,f=i.gc,a=i.bc;for(var s=i.fw;s!=0;s=e[s].fw){let p=e[s],h=p.cnt,y=c*h/(c+h);if(!(y>=r)){var g=0;o&&(g+=y*G(p.ac-l),g>=r)||(g+=y*G(p.rc-d),!(g>=r)&&(g+=y*G(p.gc-f),!(g>=r)&&(g+=y*G(p.bc-a),!(g>=r)&&(r=g,n=s))))}}i.err=r,i.nn=n}function L(){return{ac:0,rc:0,gc:0,bc:0,cnt:0,nn:0,fw:0,bk:0,tm:0,mtm:0,err:0}}function Be(e,t){let o=t==="rgb444"?4096:65536,n=new Array(o),r=e.length;if(t==="rgba4444")for(let i=0;i<r;++i){let c=e[i],l=c>>24&255,d=c>>16&255,f=c>>8&255,a=c&255,s=$(a,f,d,l),g=s in n?n[s]:n[s]=L();g.rc+=a,g.gc+=f,g.bc+=d,g.ac+=l,g.cnt++}else if(t==="rgb444")for(let i=0;i<r;++i){let c=e[i],l=c>>16&255,d=c>>8&255,f=c&255,a=ee(f,d,l),s=a in n?n[a]:n[a]=L();s.rc+=f,s.gc+=d,s.bc+=l,s.cnt++}else for(let i=0;i<r;++i){let c=e[i],l=c>>16&255,d=c>>8&255,f=c&255,a=Q(f,d,l),s=a in n?n[a]:n[a]=L();s.rc+=f,s.gc+=d,s.bc+=l,s.cnt++}return n}function Ae(e,t,o={}){let{format:n="rgb565",clearAlpha:r=!0,clearAlphaColor:i=0,clearAlphaThreshold:c=0,oneBitAlpha:l=!1}=o;if(!e||!e.buffer)throw new Error("quantize() expected RGBA Uint8Array data");if(!(e instanceof Uint8Array)&&!(e instanceof Uint8ClampedArray))throw new Error("quantize() expected RGBA Uint8Array data");let d=new Uint32Array(e.buffer),f=o.useSqrt!==!1,a=n==="rgba4444",s=Be(d,n),g=s.length,p=g-1,h=new Uint32Array(g+1);for(var y=0,u=0;u<g;++u){let M=s[u];if(M!=null){var A=1/M.cnt;a&&(M.ac*=A),M.rc*=A,M.gc*=A,M.bc*=A,s[y++]=M}}G(t)/y<.022&&(f=!1);for(var u=0;u<y-1;++u)s[u].fw=u+1,s[u+1].bk=u,f&&(s[u].cnt=Math.sqrt(s[u].cnt));f&&(s[u].cnt=Math.sqrt(s[u].cnt));var _,B,w;for(u=0;u<y;++u){K(s,u,!1);var I=s[u].err;for(B=++h[0];B>1&&(w=B>>1,!(s[_=h[w]].err<=I));B=w)h[B]=_;h[B]=u}var F=y-t;for(u=0;u<F;){for(var b;;){var C=h[1];if(b=s[C],b.tm>=b.mtm&&s[b.nn].mtm<=b.tm)break;b.mtm==p?C=h[1]=h[h[0]--]:(K(s,C,!1),b.tm=u);var I=s[C].err;for(B=1;(w=B+B)<=h[0]&&(w<h[0]&&s[h[w]].err>s[h[w+1]].err&&w++,!(I<=s[_=h[w]].err));B=w)h[B]=_;h[B]=C}var x=s[b.nn],k=b.cnt,E=x.cnt,A=1/(k+E);a&&(b.ac=A*(k*b.ac+E*x.ac)),b.rc=A*(k*b.rc+E*x.rc),b.gc=A*(k*b.gc+E*x.gc),b.bc=A*(k*b.bc+E*x.bc),b.cnt+=x.cnt,b.mtm=++u,s[x.bk].fw=x.fw,s[x.fw].bk=x.bk,x.mtm=p}let v=[];var P=0;for(u=0;;++P){let R=U(Math.round(s[u].rc),0,255),M=U(Math.round(s[u].gc),0,255),V=U(Math.round(s[u].bc),0,255),q=255;a&&(q=U(Math.round(s[u].ac),0,255),l&&(q=q<=(typeof l=="number"?l:127)?0:255),r&&q<=c&&(R=M=V=i,q=0));let H=a?[R,M,V,q]:[R,M,V];if(Ie(v,H)||v.push(H),(u=s[u].fw)==0)break}return v}function Ie(e,t){for(let o=0;o<e.length;o++){let n=e[o],r=n[0]===t[0]&&n[1]===t[1]&&n[2]===t[2],i=n.length>=4&&t.length>=4?n[3]===t[3]:!0;if(r&&i)return!0}return!1}function D(e,t){var o=0,n;for(n=0;n<e.length;n++){let r=e[n]-t[n];o+=r*r}return o}function S(e,t){return t>1?Math.round(e/t)*t:e}function ve(e,{roundRGB:t=5,roundAlpha:o=10,oneBitAlpha:n=null}={}){let r=new Uint32Array(e.buffer);for(let i=0;i<r.length;i++){let c=r[i],l=c>>24&255,d=c>>16&255,f=c>>8&255,a=c&255;l=S(l,o),n&&(l=l<=(typeof n=="number"?n:127)?0:255),a=S(a,t),f=S(f,t),d=S(d,t),r[i]=l<<24|d<<16|f<<8|a<<0}}function Ce(e,t,o="rgb565"){if(!e||!e.buffer)throw new Error("quantize() expected RGBA Uint8Array data");if(!(e instanceof Uint8Array)&&!(e instanceof Uint8ClampedArray))throw new Error("quantize() expected RGBA Uint8Array data");if(t.length>256)throw new Error("applyPalette() only works with 256 colors or less");let n=new Uint32Array(e.buffer),r=n.length,i=o==="rgb444"?4096:65536,c=new Uint8Array(r),l=new Array(i),d=o==="rgba4444";if(o==="rgba4444")for(let f=0;f<r;f++){let a=n[f],s=a>>24&255,g=a>>16&255,p=a>>8&255,h=a&255,y=$(h,p,g,s),A=y in l?l[y]:l[y]=Me(h,p,g,s,t);c[f]=A}else{let f=o==="rgb444"?ee:Q;for(let a=0;a<r;a++){let s=n[a],g=s>>16&255,p=s>>8&255,h=s&255,y=f(h,p,g),A=y in l?l[y]:l[y]=_e(h,p,g,t);c[a]=A}}return c}function Me(e,t,o,n,r){let i=0,c=1e100;for(let l=0;l<r.length;l++){let d=r[l],f=d[3],a=z(f-n);if(a>c)continue;let s=d[0];if(a+=z(s-e),a>c)continue;let g=d[1];if(a+=z(g-t),a>c)continue;let p=d[2];a+=z(p-o),!(a>c)&&(c=a,i=l)}return i}function _e(e,t,o,n){let r=0,i=1e100;for(let c=0;c<n.length;c++){let l=n[c],d=l[0],f=z(d-e);if(f>i)continue;let a=l[1];if(f+=z(a-t),f>i)continue;let s=l[2];f+=z(s-o),!(f>i)&&(i=f,r=c)}return r}function ke(e,t,o=5){if(!e.length||!t.length)return;let n=e.map(c=>c.slice(0,3)),r=o*o,i=e[0].length;for(let c=0;c<t.length;c++){let l=t[c];l.length<i?l=[l[0],l[1],l[2],255]:l.length>i?l=l.slice(0,3):l=l.slice();let d=ne(n,l.slice(0,3),D),f=d[0],a=d[1];a>0&&a<=r&&(e[f]=l)}}function z(e){return e*e}function te(e,t,o=D){let n=1/0,r=-1;for(let i=0;i<e.length;i++){let c=e[i],l=o(t,c);l<n&&(n=l,r=i)}return r}function ne(e,t,o=D){let n=1/0,r=-1;for(let i=0;i<e.length;i++){let c=e[i],l=o(t,c);l<n&&(n=l,r=i)}return[r,n]}function Ee(e,t,o=D){return e[te(e,t,o)]}function re(e={}){let{initialCapacity:t=4096,auto:o=!0}=e,n=J(t),r=5003,i=new Uint8Array(256),c=new Int32Array(r),l=new Int32Array(r),d=!1;return{reset(){n.reset(),d=!1},finish(){n.writeByte(we.trailer)},bytes(){return n.bytes()},bytesView(){return n.bytesView()},get buffer(){return n.buffer},get stream(){return n},writeHeader:f,writeFrame(a,s,g,p={}){let{transparent:h=!1,transparentIndex:y=0,delay:A=0,palette:u=null,repeat:_=0,colorDepth:B=8,dispose:w=-1}=p,I=!1;if(o?d||(I=!0,f(),d=!0):I=!!p.first,s=Math.max(0,Math.floor(s)),g=Math.max(0,Math.floor(g)),I){if(!u)throw new Error("First frame must include a { palette } option");me(n,s,g,u,B),X(n,u),_>=0&&ze(n,_)}let F=Math.round(A/10);Fe(n,w,F,h,y);let b=!!u&&!I;qe(n,s,g,b?u:null),b&&X(n,u),Te(n,a,s,g,B,i,c,l)}};function f(){oe(n,"GIF89a")}}function Fe(e,t,o,n,r){e.writeByte(33),e.writeByte(249),e.writeByte(4),r<0&&(r=0,n=!1);var i,c;n?(i=1,c=2):(i=0,c=0),t>=0&&(c=t&7),c<<=2,e.writeByte(0|c|0|i),m(e,o),e.writeByte(r||0),e.writeByte(0)}function me(e,t,o,n,r=8){let l=j(n.length)-1,d=128|r-1<<4|0|l,f=0,a=0;m(e,t),m(e,o),e.writeBytes([d,f,a])}function ze(e,t){e.writeByte(33),e.writeByte(255),e.writeByte(11),oe(e,"NETSCAPE2.0"),e.writeByte(3),e.writeByte(1),m(e,t),e.writeByte(0)}function X(e,t){let o=1<<j(t.length);for(let n=0;n<o;n++){let r=[0,0,0];n<t.length&&(r=t[n]),e.writeByte(r[0]),e.writeByte(r[1]),e.writeByte(r[2])}}function qe(e,t,o,n){if(e.writeByte(44),m(e,0),m(e,0),m(e,t),m(e,o),n){let c=j(n.length)-1;e.writeByte(128|c)}else e.writeByte(0)}function Te(e,t,o,n,r=8,i,c,l){xe(o,n,t,r,e,i,c,l)}function m(e,t){e.writeByte(t&255),e.writeByte(t>>8&255)}function oe(e,t){for(var o=0;o<t.length;o++)e.writeByte(t.charCodeAt(o))}function j(e){return Math.max(Math.ceil(Math.log2(e)),1)}var Ge=re});var T=ge(ie(),1);function Re(e={}){let t=e.maxColors??256,o=e.quality??"high";if(!Number.isInteger(t)||t<2||t>256)throw new RangeError("maxColors must be 2..256");if(o!=="high"&&o!=="fast")throw new TypeError("quality must be high or fast");let n=o==="high"?"rgb565":"rgb444";return({repeat:r})=>{let i=(0,T.GIFEncoder)();return{addFrame({data:c,width:l,height:d,durationMs:f}){if(!i)throw new Error("GIF encoder is disposed");let a=(0,T.quantize)(c,t,{format:n}),s=(0,T.applyPalette)(c,a,n);i.writeFrame(s,l,d,{palette:a,delay:f,repeat:r,dispose:1})},finish(){if(!i)throw new Error("GIF encoder is disposed");i.finish();let c=i.bytes();return i=void 0,c},dispose(){i=void 0}}}}export{Re as createGifencEncoder};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# GIF encoder selection and integration
|
|
2
|
+
|
|
3
|
+
The export controller accepts a `GifEncoderFactory`. Codec-specific tuning belongs to the adapter factory, not the controller. This keeps frame scheduling, cancellation and resource ownership independent of palette algorithms or Worker transport.
|
|
4
|
+
|
|
5
|
+
## Candidates inspected
|
|
6
|
+
|
|
7
|
+
| Encoder | License | Shipped JavaScript | Worker model | Fit |
|
|
8
|
+
| --- | --- | --- | --- | --- |
|
|
9
|
+
| [gifenc 1.0.3](https://github.com/mattdesl/gifenc) | MIT | CanvasTake's bundled adapter is 10,478 bytes / 4,539 gzip | No automatic pool; can encode in Workers | Incremental frames, palette controls, no dependencies, fits flat algorithm graphics |
|
|
10
|
+
| [gif.js 0.2.0](https://github.com/jnordberg/gif.js) | MIT | 13,451-byte main script + 16,636-byte Worker script, excluding maps | Built-in Worker orchestration with a separately hosted workerScript | Useful full exporter, but overlaps our controller and needs another deployment asset |
|
|
11
|
+
|
|
12
|
+
Sizes were measured from the installed/published versions on 2026-10-15; they are not comparative speed benchmarks. gifenc's npm unpacked size is 172,516 bytes including sources/maps. gif.js's npm unpacked size is 3,096,699 bytes including its demo media; this is not its browser download cost. The chosen adapter copies gifenc's complete MIT license into `dist/gifenc-LICENSE.txt`.
|
|
13
|
+
|
|
14
|
+
gifenc was selected for the supplied adapter, not embedded into the controller. It has no dithering and is less suited to photographs. Per-frame palettes may change quantized colors between frames. The current adapter encodes full opaque frames, not changed rectangles; size savings depend on the source. Alpha is composited onto the controller's configurable background before reaching any adapter.
|
|
15
|
+
|
|
16
|
+
## Loading and execution
|
|
17
|
+
|
|
18
|
+
Measured emitted files in release 0.3.0 (uncompressed / gzip, before Worker/progress additions):
|
|
19
|
+
|
|
20
|
+
- `dist/index.js`: 8,120 / 2,128 bytes. The video entry remains unchanged.
|
|
21
|
+
- `dist/gif.js`: 5,207 / 1,688 bytes. Controller only, no runtime imports.
|
|
22
|
+
- `dist/gifenc.js`: 10,478 / 4,539 bytes. Self-contained optional codec adapter.
|
|
23
|
+
|
|
24
|
+
The adapter is bundled at build time; gifenc, esbuild and the test decoder are development dependencies. Consumers install no additional runtime packages. Bundle-graph tests verify that each entry is standalone. Packed-consumer tests import all three public entries, compile their types and produce GIF bytes after installing the tarball in an isolated project.
|
|
25
|
+
|
|
26
|
+
The supplied adapter runs on the calling thread. The controller snapshots pixels before its first yield and yields before each frame, allowing UI cancellation between frames. It cannot interrupt synchronous quantization halfway through a frame. For large workloads, the caller can run the exporter with OffscreenCanvas in a Worker or supply an async Worker adapter. The adapter owns its Workers; `dispose()` must terminate them, reject pending calls and release buffers. A real Node Worker integration test transfers the owned pixel copies and independently decodes the returned result, then verifies Worker termination.
|
|
27
|
+
|
|
28
|
+
### Ready-made Worker adapter (0.4.0)
|
|
29
|
+
|
|
30
|
+
The `/gif/worker` entry provides `createGifWorkerEncoder`, with one dedicated module Worker per export. It transfers owned pixel buffers and runs quantization and encoding in that Worker; canvas readback and alpha compositing stay on the calling thread. The default runtime URL is relative to the adapter. Serve the runtime and `gifenc.js` together, or configure `workerUrl` when deploying copied/bundled assets. The runtime is exported at `/gif/worker-runtime` for asset resolution only. Startup, frame and finish requests each have a configurable 30-second default timeout. Success, failure, cancellation and disposal terminate the Worker and settle pending calls. Real Worker tests independently decode its result and verify termination; fault tests cover startup cancellation, transport errors and timeouts.
|
|
31
|
+
|
|
32
|
+
The controller's optional `onProgress` observer works with every encoder. It reports completed frames and their accumulated rounded duration, finalization, and terminal outcomes. `totalFrames` is an optional display hint, not a frame limit. See the README for event semantics and deployment details. The browser example now uses this adapter; the measurements below describe the synchronous adapter in 0.3.0.
|
|
33
|
+
|
|
34
|
+
## Verification
|
|
35
|
+
|
|
36
|
+
`npm run test:next` packs the local library and builds an isolated Next.js 16.3.0 / React 19.2.8 consumer with Turbopack. These are the site's installed versions at the time this fixture was added. Its client component uses the default Worker URL, export progress and cancellation under `worker-src 'self'`. The temporary directory is printed and retained for browser checks with the printed `npm start` command. A build pass alone does not establish browser execution or cancellation; verify those separately. This check does not modify the website checkout or publish a package. CI runs the build as a separate job.
|
|
37
|
+
|
|
38
|
+
Tests use gifuct-js as an independent parser/LZW decoder, checking full pixel patches, dimensions, frame delays, long final holds, loop extension values, alpha composition and palette limits. Lifecycle tests cover pre-abort, abort/dispose during pending frame/finalization calls, late rejection, codec exceptions, tainted canvas, resize, empty exports, duplicate operations and exactly-once adapter disposal. Releasing references makes buffers eligible for garbage collection; tests do not claim a particular GC schedule.
|
|
39
|
+
|
|
40
|
+
A real browser export from `examples/gif.html` produced 121 frames at 800×450: 120 × 50 ms plus a 2000 ms final hold, repeat forever. The downloaded GIF was independently decoded again: 8000 ms total, distinct first/final pixels, 931,669 bytes. Encoding took approximately 4.2 seconds on the test machine. This is a synthetic flat-graphics sample, not the future AlgiViz BFS animation; its size does not guarantee the size or rendering time of other scenes.
|
|
41
|
+
|
|
42
|
+
The integration example `examples/gif-bfs.html` additionally imports the actual BFS generator, graph renderer and timeline from a sibling AlgiViz checkout without modifying it. Its 36 algorithm steps are sampled every 50 ms across 7000 ms, followed by one 2000 ms final frame. Browser output independently decoded to 141 frames, 800×560, 9000 ms and an infinite loop, at 1,597,694 bytes (about 1.60 MB). Encoding took about 5.7 seconds. Visual inspection of decoded initial/middle/final frames confirmed readable labels and distinct state colors. Final distances from A were A=0, B=C=1, D=2, E=3; F and G remained unreachable. This meets the intended 6–10 second, roughly 800 px and under 2–3 MB README target for this graph. The example saves a test copy to `canvastake-test.gif` in the local server's temporary directory.
|
package/package.json
CHANGED
|
@@ -1,29 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grundyjs/canvas-take",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Record HTML canvas to video with explicit resource lifecycle",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
8
|
"README.md",
|
|
9
|
+
"ru/README.md",
|
|
9
10
|
"LICENSE",
|
|
10
|
-
"CHANGELOG.md"
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"docs/gif-encoder.md"
|
|
11
13
|
],
|
|
12
14
|
"exports": {
|
|
13
15
|
".": {
|
|
14
16
|
"types": "./dist/index.d.ts",
|
|
15
17
|
"import": "./dist/index.js",
|
|
16
18
|
"default": "./dist/index.js"
|
|
17
|
-
}
|
|
19
|
+
},
|
|
20
|
+
"./gif": {
|
|
21
|
+
"types": "./dist/gif.d.ts",
|
|
22
|
+
"import": "./dist/gif.js",
|
|
23
|
+
"default": "./dist/gif.js"
|
|
24
|
+
},
|
|
25
|
+
"./gif/gifenc": {
|
|
26
|
+
"types": "./dist/gifenc.d.ts",
|
|
27
|
+
"import": "./dist/gifenc.js",
|
|
28
|
+
"default": "./dist/gifenc.js"
|
|
29
|
+
},
|
|
30
|
+
"./gif/worker": {
|
|
31
|
+
"types": "./dist/gif-worker.d.ts",
|
|
32
|
+
"import": "./dist/gif-worker.js",
|
|
33
|
+
"default": "./dist/gif-worker.js"
|
|
34
|
+
},
|
|
35
|
+
"./gif/worker-runtime": "./dist/gif-worker-runtime.js"
|
|
18
36
|
},
|
|
19
37
|
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"build": "tsc -p tsconfig.json && node scripts/build-gif.mjs",
|
|
21
39
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
22
40
|
"prepack": "npm run build",
|
|
23
41
|
"prepublishOnly": "npm test",
|
|
24
|
-
"test:package": "node scripts/test-package.mjs"
|
|
42
|
+
"test:package": "node scripts/test-package.mjs",
|
|
43
|
+
"test:next": "node scripts/test-next.mjs"
|
|
25
44
|
},
|
|
26
45
|
"devDependencies": {
|
|
46
|
+
"esbuild": "0.28.2",
|
|
47
|
+
"gifenc": "1.0.3",
|
|
48
|
+
"gifuct-js": "2.1.2",
|
|
27
49
|
"typescript": "6.0.3"
|
|
28
50
|
},
|
|
29
51
|
"license": "MIT",
|
package/ru/README.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# CanvasTake
|
|
2
|
+
|
|
3
|
+
[English](../README.md)
|
|
4
|
+
|
|
5
|
+
Независимая TypeScript-библиотека для записи видео с HTML canvas с необязательными
|
|
6
|
+
аудиодорожками и покадрового экспорта GIF через отдельную точку входа. Без внешних
|
|
7
|
+
зависимостей во время выполнения и без зависимости от AlgiViz. Только ESM,
|
|
8
|
+
с декларациями TypeScript. Лицензия MIT. Пакет не запускает и не рисует вашу анимацию.
|
|
9
|
+
|
|
10
|
+
## Установка
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @grundyjs/canvas-take
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Запись canvas
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { createCanvasRecorder } from "@grundyjs/canvas-take";
|
|
20
|
+
const recorder = createCanvasRecorder(canvas, { fps: 30 });
|
|
21
|
+
await recorder.start();
|
|
22
|
+
// Run your animation, then:
|
|
23
|
+
const { blob, extension, mimeType, durationMs } = await recorder.stop();
|
|
24
|
+
const url = URL.createObjectURL(blob);
|
|
25
|
+
// Use url in a video player or download link. Revoke it when no longer needed.
|
|
26
|
+
recorder.dispose();
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`start` завершается после события start от MediaRecorder. `stop` ждёт последних
|
|
30
|
+
событий dataavailable и stop; повторные вызовы возвращают тот же promise.
|
|
31
|
+
При ошибках ожидающие операции отклоняются, принадлежащие рекордеру дорожки
|
|
32
|
+
останавливаются, а накопленные фрагменты освобождаются. `dispose` отменяет незавершённую
|
|
33
|
+
запись и допускает повторные вызовы. Каждый экземпляр записывает один раз.
|
|
34
|
+
Пока start ожидает завершения, состояние остаётся idle; повторный start отклоняется.
|
|
35
|
+
|
|
36
|
+
Порядок автоматического выбора формата: WebM VP9, VP8, обычный WebM, MP4.
|
|
37
|
+
При записи со звуком варианты VP9 и VP8 включают Opus. Явно заданный неподдерживаемый
|
|
38
|
+
MIME вызывает ошибку без скрытой смены формата. Результат содержит фактический MIME
|
|
39
|
+
и соответствующее расширение. FPS и битрейт — целевые значения, а не гарантии.
|
|
40
|
+
durationMs измеряет реальное время записи, а не длительность, прочитанную из файла.
|
|
41
|
+
|
|
42
|
+
Не меняйте размеры битовой карты canvas от start до stop. Масштабирование через CSS
|
|
43
|
+
допускается. Изменение размеров завершает запись ошибкой. Canvas должен оставаться
|
|
44
|
+
origin-clean. Захват экрана, пауза записи, покадровое кодирование видео и отправка
|
|
45
|
+
на сервер не поддерживаются. Фрагменты хранятся в памяти: ограничивайте длительность.
|
|
46
|
+
Если замедление фоновой вкладки недопустимо, вызывайте dispose при скрытии страницы.
|
|
47
|
+
Поддержка кодеков зависит от браузера и доступных ресурсов.
|
|
48
|
+
|
|
49
|
+
Разработка: Node.js 22+, `npm install`, `npm test`, `npm pack`. ESM и декларации
|
|
50
|
+
создаются в dist. `npm pack` автоматически пересобирает пакет; `npm publish`
|
|
51
|
+
запускает тесты до упаковки. Интеграционный пример ожидает AlgiViz в соседнем
|
|
52
|
+
каталоге и запускается через включённый в репозиторий локальный сервер.
|
|
53
|
+
|
|
54
|
+
См. [остановку MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop)
|
|
55
|
+
и [проверку формата](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported_static).
|
|
56
|
+
WebM от MediaRecorder может не содержать метаданные длительности: воспроизведение
|
|
57
|
+
работает, но плеер показывает неопределённую длительность. Для надёжной перемотки
|
|
58
|
+
или публикации отдельно перепакуйте контейнер. CanvasTake не переписывает его метаданные.
|
|
59
|
+
|
|
60
|
+
## Настройки и результат
|
|
61
|
+
|
|
62
|
+
| Настройка | Значение |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `fps` | Обязательная положительная конечная целевая частота захвата |
|
|
65
|
+
| `videoBitsPerSecond` | Необязательный положительный конечный целевой битрейт кодировщика |
|
|
66
|
+
| `mimeType` | Необязательный явно заданный поддерживаемый MIME `video/webm` или `video/mp4`, включая кодеки |
|
|
67
|
+
| `audioTracks` | Необязательный список только для чтения из активных звуковых `MediaStreamTrack`; без него или с пустым списком запись идёт без звука |
|
|
68
|
+
|
|
69
|
+
`stop()` возвращает `{ blob, mimeType, extension, durationMs }`. Используйте рекордер
|
|
70
|
+
в try/finally и вызывайте `dispose()`, даже если анимация завершилась ошибкой.
|
|
71
|
+
Освобождайте object URL, когда они больше не нужны.
|
|
72
|
+
|
|
73
|
+
## Запись с озвучкой
|
|
74
|
+
|
|
75
|
+
Сведите звук в одну выходную дорожку Web Audio: несколько звуковых дорожек
|
|
76
|
+
поддерживает не каждый кодировщик. Декодируйте озвучку до начала записи.
|
|
77
|
+
Вызывайте код из обработчика действия пользователя, чтобы браузер мог возобновить
|
|
78
|
+
аудиоконтекст:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
async function recordNarration(canvas, narrationBytes) {
|
|
82
|
+
const audio = new AudioContext();
|
|
83
|
+
let recorder;
|
|
84
|
+
let destination;
|
|
85
|
+
try {
|
|
86
|
+
await audio.resume();
|
|
87
|
+
const buffer = await audio.decodeAudioData(narrationBytes);
|
|
88
|
+
destination = audio.createMediaStreamDestination();
|
|
89
|
+
const source = audio.createBufferSource();
|
|
90
|
+
source.buffer = buffer;
|
|
91
|
+
source.connect(destination);
|
|
92
|
+
recorder = createCanvasRecorder(canvas, {
|
|
93
|
+
fps: 30,
|
|
94
|
+
mimeType: "video/mp4",
|
|
95
|
+
audioTracks: destination.stream.getAudioTracks(),
|
|
96
|
+
});
|
|
97
|
+
const finished = new Promise(resolve => {
|
|
98
|
+
source.addEventListener("ended", resolve, { once: true });
|
|
99
|
+
});
|
|
100
|
+
await recorder.start();
|
|
101
|
+
source.start();
|
|
102
|
+
// Run your canvas animation while the narration plays.
|
|
103
|
+
await finished;
|
|
104
|
+
return await recorder.stop();
|
|
105
|
+
} finally {
|
|
106
|
+
recorder?.dispose();
|
|
107
|
+
destination?.stream.getTracks().forEach(track => track.stop());
|
|
108
|
+
await audio.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Для MP4 со звуком нужна поддержка кодировщика в браузере; явный запрос MP4
|
|
114
|
+
никогда не переключается на WebM. CanvasTake использует MediaRecorder напрямую
|
|
115
|
+
и не перекодирует результат. Воспроизведением звука и анимацией canvas управляет
|
|
116
|
+
приложение; они работают в реальном времени.
|
|
117
|
+
|
|
118
|
+
`video/mp4` выбирает контейнер, а не конкретные кодеки: браузер может записать
|
|
119
|
+
VP9/Opus в MP4. Если площадке нужны H.264/AAC, укажите MIME с кодеками, например
|
|
120
|
+
`video/mp4;codecs=avc1,mp4a.40.2`, и обработайте ошибку неподдерживаемого формата.
|
|
121
|
+
|
|
122
|
+
Для быстрой проверки в браузере после сборки запустите `node examples/server.cjs`
|
|
123
|
+
и откройте `http://127.0.0.1:3012/canvas-take/examples/audio.html`. Пример записывает
|
|
124
|
+
сгенерированный тон и движущийся canvas 1080×1920 с явно заданными H.264/AAC,
|
|
125
|
+
запрашивает остановку через две секунды, показывает результат и сохраняет его
|
|
126
|
+
как `canvastake-test.mp4` во временном каталоге сервера. Ранее проверенный результат
|
|
127
|
+
в локальном браузере: H.264 Constrained Baseline, прогрессивная развёртка 4:2:0,
|
|
128
|
+
стерео AAC-LC 48 кГц, 2,476 секунды и примерно 29,1 fps. Перекодирование и перепаковка
|
|
129
|
+
не использовались. Фактические длительность, частота кадров и поддержка кодеков
|
|
130
|
+
зависят от браузера и нагрузки во время выполнения.
|
|
131
|
+
|
|
132
|
+
Список аудиодорожек копируется при создании и повторно проверяется при start.
|
|
133
|
+
CanvasTake клонирует дорожки при start и при завершении, dispose или ошибке
|
|
134
|
+
останавливает только свои копии и дорожки захвата canvas. Исходными дорожками
|
|
135
|
+
и AudioContext владеет вызывающий код. Неожиданное завершение записываемой копии
|
|
136
|
+
вызывает ошибку записи. `stop()` исходной дорожки не останавливает её копию;
|
|
137
|
+
для завершения записи вызовите `recorder.stop()`. Окончание AudioBufferSource
|
|
138
|
+
также не завершает выходную дорожку.
|
|
139
|
+
|
|
140
|
+
## Покадровый экспорт GIF
|
|
141
|
+
|
|
142
|
+
Доступен с версии 0.3.0.
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { createGifExporter } from '@grundyjs/canvas-take/gif';
|
|
146
|
+
import { createGifencEncoder } from '@grundyjs/canvas-take/gif/gifenc';
|
|
147
|
+
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const gif = createGifExporter({
|
|
150
|
+
encoder: createGifencEncoder({ maxColors: 128, quality: 'high' }),
|
|
151
|
+
repeat: 0,
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
for (let time = 0; time < 6000; time += 50) {
|
|
156
|
+
drawTimeline(time); // Your deterministic renderer draws into canvas.
|
|
157
|
+
await gif.addFrame(canvas, 50);
|
|
158
|
+
}
|
|
159
|
+
drawTimeline(6000);
|
|
160
|
+
await gif.addFrame(canvas, 2000); // Hold the final frame for two seconds.
|
|
161
|
+
const blob = await gif.finish(); // image/gif; 8 seconds, 121 frames.
|
|
162
|
+
// Download/preview blob; revoke any object URL when no longer needed.
|
|
163
|
+
} finally {
|
|
164
|
+
gif.dispose();
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`encoder` обязателен и передаётся приложением. Точка входа `/gif` не зависит от
|
|
169
|
+
кодека; `/gif/gifenc` — необязательный самостоятельный адаптер со встроенным
|
|
170
|
+
кодеком gifenc под MIT. Корневой импорт видео не загружает ни один GIF-модуль.
|
|
171
|
+
Адаптер можно заменить без изменения цикла экспорта.
|
|
172
|
+
|
|
173
|
+
| Настройка | Значение |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `repeat` | По умолчанию `0`: бесконечно. `-1`: один просмотр. `1..65535`: дополнительные повторы после первого просмотра |
|
|
176
|
+
| `signal` | Необязательный AbortSignal; отмена отклоняет ожидающие операции и освобождает кодировщик |
|
|
177
|
+
| `background` | Тройка байтов RGB, по умолчанию `[255, 255, 255]`; прозрачность накладывается на непрозрачный фон |
|
|
178
|
+
| `maxColors` адаптера | Максимум цветов палитры на кадр: целое `2..256`, по умолчанию `256` |
|
|
179
|
+
| `quality` адаптера | `high` (по умолчанию, RGB565) или `fast` (RGB444); без дизеринга |
|
|
180
|
+
|
|
181
|
+
`addFrame(canvas, durationMs)` немедленно снимает копию 2D HTMLCanvasElement или
|
|
182
|
+
OffscreenCanvas, затем отдаёт управление перед кодированием. Дожидайтесь каждого
|
|
183
|
+
вызова перед следующим кадром или `finish()`. Все кадры должны иметь одинаковые
|
|
184
|
+
положительные размеры, не более 65535 по каждой оси — предел GIF. Длительности
|
|
185
|
+
должны быть конечными и находиться в диапазоне от 10 до 655350 мс; они округляются
|
|
186
|
+
до ближайших 10 мс. GIF-плееры могут увеличивать короткие задержки; 50 мс —
|
|
187
|
+
практичный шаг временной шкалы. Время рисования и реальное время кодирования
|
|
188
|
+
не влияют на длительность GIF. Canvas остаётся у приложения и не изменяется.
|
|
189
|
+
Для canvas с ограниченным доступом к пикселям операция завершается ошибкой чтения.
|
|
190
|
+
|
|
191
|
+
`finish()` требует хотя бы один кадр, возвращает Blob и освобождает кодировщик.
|
|
192
|
+
Каждый экземпляр создаёт один GIF. `dispose()` допускает повторные вызовы,
|
|
193
|
+
отменяет ожидающие операции и освобождает ресурсы кодировщика. Неверные данные
|
|
194
|
+
кадра, изменение размеров и ошибки кодировщика делают дальнейший экспорт невозможным.
|
|
195
|
+
Одновременные вызовы отклоняются, не отменяя текущую операцию. Возможные состояния:
|
|
196
|
+
`ready`, `encoding`, `finished`, `failed`, `aborted` и `disposed`.
|
|
197
|
+
|
|
198
|
+
Встроенный адаптер постепенно кодирует полные кадры с отдельными палитрами,
|
|
199
|
+
сохраняя сжатый результат вместо всех исходных кадров. Обработка каждого кадра
|
|
200
|
+
синхронна и выполняется в вызывающем потоке; отмена обрабатывается между кадрами.
|
|
201
|
+
Для тяжёлых кадров используйте готовый адаптер `/gif/worker`, OffscreenCanvas в Worker или передайте асинхронный
|
|
202
|
+
адаптер с Worker; автоматического пула Worker нет. Байты GIF и итоговый Blob
|
|
203
|
+
занимают память, поэтому ограничивайте размеры и число кадров. Размер файла
|
|
204
|
+
зависит от визуальной сложности; анимация шириной 800 px и длиной 6–10 секунд —
|
|
205
|
+
целевой сценарий, а не гарантия файла размером 2–3 МБ.
|
|
206
|
+
|
|
207
|
+
### Worker-адаптер и прогресс (0.4.0)
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { createGifExporter } from '@grundyjs/canvas-take/gif';
|
|
211
|
+
import { createGifWorkerEncoder } from '@grundyjs/canvas-take/gif/worker';
|
|
212
|
+
|
|
213
|
+
const gif = createGifExporter({
|
|
214
|
+
encoder: createGifWorkerEncoder({ maxColors: 128, quality: 'high' }),
|
|
215
|
+
totalFrames: 121,
|
|
216
|
+
onProgress({ phase, framesEncoded, totalFrames, durationMs }) {
|
|
217
|
+
console.log(phase, framesEncoded, totalFrames, durationMs);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
// Последовательно: await gif.addFrame(canvas, durationMs), затем await gif.finish().
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Квантование и кодирование выполняются в отдельном module Worker. Чтение canvas
|
|
224
|
+
и наложение прозрачности на фон остаются в вызывающем потоке. Каждый экспортёр
|
|
225
|
+
владеет одним Worker и завершает его при успехе, ошибке, отмене или `dispose()`.
|
|
226
|
+
Буферы пикселей передаются без копирования; исходный canvas не меняется.
|
|
227
|
+
Требуется поддержка Worker; автоматического перехода к синхронному кодированию нет.
|
|
228
|
+
|
|
229
|
+
При прямом импорте в браузере размещайте `gif-worker.js`, `gif-worker-runtime.js`
|
|
230
|
+
и `gifenc.js` рядом, с MIME-типом JavaScript. Для сборщика проверьте, что Worker
|
|
231
|
+
и его зависимости попали в результат сборки. При необходимости скопируйте runtime
|
|
232
|
+
и `gifenc.js` в один публичный каталог и передайте `workerUrl: '/assets/gif-worker-runtime.js'`.
|
|
233
|
+
Путь пакета `/gif/worker-runtime` предназначен для разрешения адреса ресурса,
|
|
234
|
+
а не для импорта в основном потоке. Учитывайте same-origin и CSP `worker-src`;
|
|
235
|
+
Blob URL и встроенный код Worker не требуются.
|
|
236
|
+
|
|
237
|
+
`timeoutMs` по умолчанию равен 30000 мс **на каждый запрос**: запуск, отдельный кадр
|
|
238
|
+
и финализацию. При тайм-ауте, ошибке загрузки или передачи сообщения ожидающие
|
|
239
|
+
операции отклоняются, а Worker завершается.
|
|
240
|
+
|
|
241
|
+
`onProgress` работает с любым кодировщиком. Неизменяемый снимок приходит после
|
|
242
|
+
успешного кадра (`encoding`), перед финализацией (`finalizing`) и при результате
|
|
243
|
+
`finished`, `failed`, `aborted` или преждевременном `disposed`. Начального события нет.
|
|
244
|
+
`framesEncoded` и `durationMs` учитывают только завершённые кадры с округлёнными
|
|
245
|
+
длительностями GIF. Необязательный `totalFrames` — положительное безопасное целое
|
|
246
|
+
для отображения, а не ограничение числа кадров; без него поле отсутствует.
|
|
247
|
+
Полное число кадров ещё не означает завершение финализации. Процент и ETA
|
|
248
|
+
автоматически не вычисляются. Исключения наблюдателя игнорируются; из него можно
|
|
249
|
+
отменить или освободить экспортёр. `dispose()` после конечного результата
|
|
250
|
+
не создаёт дополнительного события.
|
|
251
|
+
|
|
252
|
+
### Подключение другого кодировщика
|
|
253
|
+
|
|
254
|
+
Реализуйте экспортируемый контракт `GifEncoderFactory`:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import type { GifEncoderFactory } from '@grundyjs/canvas-take/gif';
|
|
258
|
+
|
|
259
|
+
const encoder: GifEncoderFactory = ({ repeat }) => ({
|
|
260
|
+
addFrame({ data, width, height, durationMs }) {
|
|
261
|
+
// Pass the opaque RGBA snapshot to your codec; may return Promise<void>.
|
|
262
|
+
},
|
|
263
|
+
async finish() {
|
|
264
|
+
// Return complete GIF bytes as Uint8Array.
|
|
265
|
+
return encodedBytes;
|
|
266
|
+
},
|
|
267
|
+
dispose() {
|
|
268
|
+
// Drop buffers, terminate owned Workers, settle pending operations.
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Каждый вызов фабрики должен создавать новый кодировщик и учитывать переданное число
|
|
274
|
+
повторов. Экспортёр вызывает методы последовательно и вызывает `dispose()` один
|
|
275
|
+
раз при успехе, ошибке или отмене. Адаптер может передать принадлежащий ему буфер
|
|
276
|
+
кадра в Worker; он должен освобождать ссылки на кадр после каждой операции
|
|
277
|
+
и поддерживать dispose во время ожидающей операции. Отмена немедленно отклоняет
|
|
278
|
+
операции экспортёра, но внешнюю работу очищает адаптер. Синхронную работу кодека
|
|
279
|
+
нельзя прервать в том же потоке. Исключения очистки подавляются, чтобы сохранить
|
|
280
|
+
основной результат операции.
|
|
281
|
+
|
|
282
|
+
После сборки запустите `node examples/server.cjs` и откройте
|
|
283
|
+
`http://127.0.0.1:3012/canvas-take/examples/gif.html` — отдельный пример покадрового
|
|
284
|
+
экспорта с отменой, предпросмотром и скачиванием. Обоснование выбора кодека
|
|
285
|
+
и измеренные размеры модулей приведены в [docs/gif-encoder.md](../docs/gif-encoder.md).
|
|
286
|
+
|
|
287
|
+
Если собранный AlgiViz находится в соседнем каталоге `algiviz`, откройте
|
|
288
|
+
`http://127.0.0.1:3012/canvas-take/examples/gif-bfs.html` для интеграции с настоящим
|
|
289
|
+
BFS. Ранее проверенный результат: 800×560, 141 кадр, 9 секунд с удержанием последнего
|
|
290
|
+
кадра 2 секунды, около 1,60 МБ. Пример использует существующие алгоритм и рендерер
|
|
291
|
+
AlgiViz и не изменяет тот проект.
|
|
292
|
+
|
|
293
|
+
## Использование в браузере и на сервере
|
|
294
|
+
|
|
295
|
+
Для записи нужен браузер с `canvas.captureStream`, `MediaRecorder` и `MutationObserver`.
|
|
296
|
+
Импорт модуля не обращается к этим API; создание рекордера проверяет поддержку
|
|
297
|
+
захвата и кодировщика. Реализации записи для Node.js нет. Поддержка кодеков
|
|
298
|
+
определяется во время выполнения, а не по названию браузера.
|
|
299
|
+
|
|
300
|
+
Интеграционный пример ранее проверен настоящей записью VP9 в 1920×1080.
|
|
301
|
+
Это не обещание, что любой браузер создаст WebM или MP4. Canvas с содержимым
|
|
302
|
+
из другого источника без CORS может оказаться недоступен для захвата.
|
|
303
|
+
|
|
304
|
+
## Выпуск версии
|
|
305
|
+
|
|
306
|
+
Запустите `npm test` и `npm run test:package`: последняя команда упаковывает архив,
|
|
307
|
+
устанавливает его в изолированное приложение и проверяет декларации TypeScript
|
|
308
|
+
и импорты во время выполнения. Архив содержит dist, README, LICENSE, CHANGELOG
|
|
309
|
+
и метаданные пакета. Исходный код и задачи:
|
|
310
|
+
[urffin/canvas-take](https://github.com/urffin/canvas-take). API версии 0.x может
|
|
311
|
+
меняться в следующих минорных релизах.
|
|
312
|
+
|
|
313
|
+
После успешного CI для коммита релиза опубликуйте стабильный GitHub Release
|
|
314
|
+
с тегом `v<package version>`. Workflow `npm-publish.yml` проверяет согласованность
|
|
315
|
+
версий, тестирует приложение с упакованным пакетом и публикует с OIDC/provenance.
|
|
316
|
+
Перед первым автоматическим релизом настройте npm trusted publisher для владельца
|
|
317
|
+
`urffin`, репозитория `canvas-take` и workflow `npm-publish.yml`. Токен npm
|
|
318
|
+
не используется. Публикация — отдельное действие сопровождающего проекта.
|