@grundyjs/canvas-take 0.2.0 → 0.3.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-15
4
+
5
+ - Offline canvas GIF export via `@grundyjs/canvas-take/gif` with an injected encoder factory.
6
+ - Optional isolated `gif/gifenc` adapter, configurable palette quality/color count, frame durations and repeat count.
7
+ - AbortSignal cancellation, owned-resource cleanup and asynchronous/Worker adapter support.
8
+ - Independent GIF decoding tests, package isolation checks and an offline canvas example.
9
+
3
10
  ## 0.2.0 — 2026-10-15
4
11
 
5
12
  - Optional caller-provided `audioTracks`, cloned at start to preserve caller ownership.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CanvasTake
2
2
 
3
- Independent TypeScript library for HTML canvas video recording with optional audio tracks. No runtime dependencies or dependency on AlgiViz. ESM only, with TypeScript declarations. MIT licensed. The package does not start or draw your animation.
3
+ 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
4
 
5
5
  ## Install
6
6
 
@@ -88,6 +88,77 @@ For a browser smoke test, run `node examples/server.cjs` after building and open
88
88
 
89
89
  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
90
 
91
+ ## Offline GIF export
92
+
93
+ Available since version 0.3.0.
94
+
95
+ ```js
96
+ import { createGifExporter } from '@grundyjs/canvas-take/gif';
97
+ import { createGifencEncoder } from '@grundyjs/canvas-take/gif/gifenc';
98
+
99
+ const controller = new AbortController();
100
+ const gif = createGifExporter({
101
+ encoder: createGifencEncoder({ maxColors: 128, quality: 'high' }),
102
+ repeat: 0,
103
+ signal: controller.signal,
104
+ });
105
+ try {
106
+ for (let time = 0; time < 6000; time += 50) {
107
+ drawTimeline(time); // Your deterministic renderer draws into canvas.
108
+ await gif.addFrame(canvas, 50);
109
+ }
110
+ drawTimeline(6000);
111
+ await gif.addFrame(canvas, 2000); // Hold the final frame for two seconds.
112
+ const blob = await gif.finish(); // image/gif; 8 seconds, 121 frames.
113
+ // Download/preview blob; revoke any object URL when no longer needed.
114
+ } finally {
115
+ gif.dispose();
116
+ }
117
+ ```
118
+
119
+ `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.
120
+
121
+ | Setting | Meaning |
122
+ | --- | --- |
123
+ | `repeat` | Default `0`: forever. `-1`: play once. `1..65535`: extra repetitions after the first play |
124
+ | `signal` | Optional AbortSignal; abort rejects pending work and disposes the encoder |
125
+ | `background` | RGB byte tuple, default `[255, 255, 255]`; composites transparency onto an opaque background |
126
+ | Adapter `maxColors` | Maximum palette colors per frame, integer `2..256`, default `256` |
127
+ | Adapter `quality` | `high` (default, RGB565) or `fast` (RGB444); no dithering |
128
+
129
+ `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.
130
+
131
+ `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`.
132
+
133
+ 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, 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.
134
+
135
+ ### Supplying another encoder
136
+
137
+ Implement the exported `GifEncoderFactory` contract:
138
+
139
+ ```ts
140
+ import type { GifEncoderFactory } from '@grundyjs/canvas-take/gif';
141
+
142
+ const encoder: GifEncoderFactory = ({ repeat }) => ({
143
+ addFrame({ data, width, height, durationMs }) {
144
+ // Pass the opaque RGBA snapshot to your codec; may return Promise<void>.
145
+ },
146
+ async finish() {
147
+ // Return complete GIF bytes as Uint8Array.
148
+ return encodedBytes;
149
+ },
150
+ dispose() {
151
+ // Drop buffers, terminate owned Workers, settle pending operations.
152
+ },
153
+ });
154
+ ```
155
+
156
+ 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.
157
+
158
+ 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).
159
+
160
+ 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.
161
+
91
162
  ## Browser and server use
92
163
 
93
164
  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 +170,3 @@ The integration example was verified with an actual VP9 recording at 1920×1080.
99
170
  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
171
 
101
172
  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
-
package/dist/gif.d.ts ADDED
@@ -0,0 +1,39 @@
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
+ }
30
+ export type GifExportState = 'ready' | 'encoding' | 'finished' | 'failed' | 'aborted' | 'disposed';
31
+ export interface GifExporter {
32
+ readonly state: GifExportState;
33
+ /** Snapshot a 2D canvas immediately. Await completion before adding another frame or finishing. */
34
+ addFrame(canvas: HTMLCanvasElement | OffscreenCanvas, durationMs: number): Promise<void>;
35
+ finish(): Promise<Blob>;
36
+ dispose(): void;
37
+ }
38
+ /** Offline, sequential GIF export. No timers are used to determine frame durations. */
39
+ export declare function createGifExporter(options: GifExportOptions): GifExporter;
package/dist/gif.js ADDED
@@ -0,0 +1,124 @@
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 repeat = options.repeat ?? 0;
5
+ const background = [...(options.background ?? [255, 255, 255])];
6
+ if (typeof factory !== 'function')
7
+ throw new TypeError('An encoder factory is required');
8
+ if (!Number.isInteger(repeat) || repeat < -1 || repeat > 65535)
9
+ throw new RangeError('repeat must be -1..65535');
10
+ if (background.length !== 3 || background.some(v => !Number.isInteger(v) || v < 0 || v > 255)) {
11
+ throw new RangeError('background must contain three RGB bytes');
12
+ }
13
+ const abortError = () => signal?.reason ?? new DOMException('GIF export aborted', 'AbortError');
14
+ if (signal?.aborted)
15
+ throw abortError();
16
+ let encoder = factory(Object.freeze({ repeat }));
17
+ let state = 'ready';
18
+ let width = 0, height = 0, frames = 0;
19
+ let rejectCancelled;
20
+ const cancelled = new Promise((_, reject) => { rejectCancelled = reject; });
21
+ void cancelled.catch(() => { });
22
+ function cleanup() {
23
+ signal?.removeEventListener('abort', onAbort);
24
+ const owned = encoder;
25
+ encoder = undefined;
26
+ // Preserve the original failure/cancellation even if an adapter's cleanup throws.
27
+ try {
28
+ owned?.dispose();
29
+ }
30
+ catch { }
31
+ }
32
+ function onAbort() {
33
+ if (state !== 'ready' && state !== 'encoding')
34
+ return;
35
+ state = 'aborted';
36
+ rejectCancelled(abortError());
37
+ cleanup();
38
+ }
39
+ signal?.addEventListener('abort', onAbort, { once: true });
40
+ if (signal?.aborted)
41
+ onAbort();
42
+ function ready() {
43
+ if (state !== 'ready')
44
+ throw new Error(`GIF exporter is ${state}`);
45
+ }
46
+ function active() {
47
+ if (state !== 'encoding')
48
+ throw new Error(`GIF exporter is ${state}`);
49
+ }
50
+ async function operation(work) {
51
+ ready();
52
+ state = 'encoding';
53
+ try {
54
+ return await Promise.race([work(), cancelled]);
55
+ }
56
+ catch (error) {
57
+ if (state === 'encoding') {
58
+ state = 'failed';
59
+ cleanup();
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+ return Object.freeze({
65
+ get state() { return state; },
66
+ addFrame(canvas, durationMs) {
67
+ return operation(async () => {
68
+ if (!Number.isFinite(durationMs) || durationMs < 10 || durationMs > 655350) {
69
+ throw new RangeError('durationMs must be between 10 and 655350');
70
+ }
71
+ const w = canvas.width, h = canvas.height;
72
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1 || w > 65535 || h > 65535) {
73
+ throw new RangeError('Canvas dimensions must be integers in 1..65535');
74
+ }
75
+ if (frames && (w !== width || h !== height))
76
+ throw new Error('GIF frame dimensions must remain fixed');
77
+ const context = canvas.getContext('2d');
78
+ if (!context)
79
+ throw new Error('GIF export requires a 2D canvas');
80
+ const data = new Uint8ClampedArray(context.getImageData(0, 0, w, h).data);
81
+ if (data.length !== w * h * 4)
82
+ throw new Error('Invalid canvas pixel data');
83
+ // Yield between frames so UI cancellation can be delivered; snapshot is already owned.
84
+ await new Promise(resolve => setTimeout(resolve, 0));
85
+ active();
86
+ for (let i = 0; i < data.length; i += 4) {
87
+ const alpha = data[i + 3] / 255;
88
+ for (let c = 0; c < 3; c++)
89
+ data[i + c] = Math.round(data[i + c] * alpha + background[c] * (1 - alpha));
90
+ data[i + 3] = 255;
91
+ }
92
+ await encoder.addFrame({ width: w, height: h, data, durationMs: Math.round(durationMs / 10) * 10 });
93
+ active();
94
+ width = w;
95
+ height = h;
96
+ frames++;
97
+ state = 'ready';
98
+ });
99
+ },
100
+ finish() {
101
+ return operation(async () => {
102
+ if (!frames)
103
+ throw new Error('GIF export requires at least one frame');
104
+ const bytes = await encoder.finish();
105
+ active();
106
+ if (!(bytes instanceof Uint8Array) || bytes.length < 6 ||
107
+ !['GIF87a', 'GIF89a'].includes(String.fromCharCode(...bytes.subarray(0, 6)))) {
108
+ throw new Error('Encoder did not return GIF bytes');
109
+ }
110
+ const blob = new Blob([new Uint8Array(bytes)], { type: 'image/gif' });
111
+ state = 'finished';
112
+ cleanup();
113
+ return blob;
114
+ });
115
+ },
116
+ dispose() {
117
+ if (state === 'disposed')
118
+ return;
119
+ state = 'disposed';
120
+ rejectCancelled(new DOMException('GIF exporter disposed', 'AbortError'));
121
+ cleanup();
122
+ }
123
+ });
124
+ }
@@ -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
+
@@ -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,34 @@
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 (uncompressed / gzip):
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
+ ## Verification
29
+
30
+ 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.
31
+
32
+ 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.
33
+
34
+ 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,43 @@
1
1
  {
2
2
  "name": "@grundyjs/canvas-take",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
9
  "LICENSE",
10
- "CHANGELOG.md"
10
+ "CHANGELOG.md",
11
+ "docs/gif-encoder.md"
11
12
  ],
12
13
  "exports": {
13
14
  ".": {
14
15
  "types": "./dist/index.d.ts",
15
16
  "import": "./dist/index.js",
16
17
  "default": "./dist/index.js"
18
+ },
19
+ "./gif": {
20
+ "types": "./dist/gif.d.ts",
21
+ "import": "./dist/gif.js",
22
+ "default": "./dist/gif.js"
23
+ },
24
+ "./gif/gifenc": {
25
+ "types": "./dist/gifenc.d.ts",
26
+ "import": "./dist/gifenc.js",
27
+ "default": "./dist/gifenc.js"
17
28
  }
18
29
  },
19
30
  "scripts": {
20
- "build": "tsc -p tsconfig.json",
31
+ "build": "tsc -p tsconfig.json && node scripts/build-gif.mjs",
21
32
  "test": "npm run build && node --test test/*.test.mjs",
22
33
  "prepack": "npm run build",
23
34
  "prepublishOnly": "npm test",
24
35
  "test:package": "node scripts/test-package.mjs"
25
36
  },
26
37
  "devDependencies": {
38
+ "esbuild": "0.28.2",
39
+ "gifenc": "1.0.3",
40
+ "gifuct-js": "2.1.2",
27
41
  "typescript": "6.0.3"
28
42
  },
29
43
  "license": "MIT",