@grundyjs/canvas-take 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-10-28
4
+
5
+ - Correct package homepage to the CanvasTake page; refresh package description, keywords and English/Russian README navigation and test instructions.
6
+
7
+ - Browser CI tests in Chromium and Firefox exercise the real GIF module Worker, cancellation and a fresh export on the same page. Downloaded GIFs are independently decoded to verify pixels, dimensions, frame timings and loop count.
8
+
9
+ ## 0.4.0 — 2026-10-26
10
+
11
+ - Ready-made `gif/worker` adapter with a dedicated module Worker, transferable frames, request timeouts and cleanup on completion/cancellation/errors.
12
+ - Encoder-independent `onProgress` notifications with completed frame count, encoded duration, optional total frame hint and finalization/terminal phases.
13
+ - Worker/progress example, integration tests and package asset checks.
14
+ - Packed Next.js 16.3 / Turbopack consumer build in CI; browser-verified Worker export, progress and cancellation under same-origin Worker CSP.
15
+
3
16
  ## 0.3.0 — 2026-10-15
4
17
 
5
18
  - Offline canvas GIF export via `@grundyjs/canvas-take/gif` with an injected encoder factory.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # CanvasTake
2
2
 
3
+ [Русский](ru/README.md)
4
+
5
+ [Website](https://grundyjs.ru/en/canvas-take/) · [npm](https://www.npmjs.com/package/@grundyjs/canvas-take) · [Issues](https://github.com/urffin/canvas-take/issues)
6
+
3
7
  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
8
 
5
9
  ## Install
@@ -25,7 +29,7 @@ recorder.dispose();
25
29
 
26
30
  Auto format order: WebM VP9, VP8, generic WebM, MP4. With audio, the VP9 and VP8 candidates include Opus. An explicit unsupported MIME fails without silently changing the format. The result reports the actual MIME and matching extension. FPS and bitrate are targets, not guarantees. durationMs measures recording wall time, not parsed file duration.
27
31
 
28
- Keep canvas bitmap dimensions fixed from start until stop. CSS scaling is allowed. Size changes fail the recording. Canvas must remain origin-clean. No screen capture, pause, frame-by-frame encoding or server upload. Chunks are retained in memory: the caller should limit duration. If background-tab throttling is unacceptable, the caller should dispose when the page becomes hidden. Codec support depends on the browser and available resources.
32
+ Keep canvas bitmap dimensions fixed from start until stop. CSS scaling is allowed. Size changes fail the recording. Canvas must remain origin-clean. The video recorder does not support screen capture, pause, frame-by-frame video encoding or server upload. Chunks are retained in memory: the caller should limit duration. If background-tab throttling is unacceptable, the caller should dispose when the page becomes hidden. Codec support depends on the browser and available resources.
29
33
 
30
34
  Development: Node.js 22+, `npm install`, `npm test`, `npm pack`. ESM and declarations are emitted to dist. `npm pack` rebuilds automatically; `npm publish` runs tests before packing. The integration example expects AlgiViz in a sibling directory and is served by the included local server.
31
35
 
@@ -130,7 +134,33 @@ try {
130
134
 
131
135
  `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
136
 
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.
137
+ 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.
138
+
139
+ ### Worker adapter and progress (0.4.0)
140
+
141
+ ```js
142
+ import { createGifExporter } from '@grundyjs/canvas-take/gif';
143
+ import { createGifWorkerEncoder } from '@grundyjs/canvas-take/gif/worker';
144
+
145
+ const gif = createGifExporter({
146
+ encoder: createGifWorkerEncoder({ maxColors: 128, quality: 'high' }),
147
+ signal: controller.signal,
148
+ totalFrames: 121,
149
+ onProgress({ phase, framesEncoded, totalFrames, durationMs }) {
150
+ status.textContent = `${phase}: ${framesEncoded}/${totalFrames}, ${durationMs} ms`;
151
+ },
152
+ });
153
+ ```
154
+
155
+ 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.
156
+
157
+ 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.
158
+
159
+ `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.
160
+
161
+ 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.
162
+
163
+ The offline `examples/gif.html` now demonstrates this adapter, progress and cancellation.
134
164
 
135
165
  ### Supplying another encoder
136
166
 
@@ -165,6 +195,10 @@ Recording requires a browser with `canvas.captureStream`, `MediaRecorder` and `M
165
195
 
166
196
  The integration example was verified with an actual VP9 recording at 1920×1080. This is not a promise that every browser can produce WebM or MP4. A canvas containing cross-origin content without CORS may not be captured.
167
197
 
198
+ ## Development checks
199
+
200
+ Run `npm test` for unit and Node Worker tests, and `npm run test:package` for an isolated packed consumer. For real browser tests, first run `npx playwright install chromium firefox`, then `npm run test:browser`. These check GIF export, cancellation, a fresh export on the same page, Worker cleanup and independently decoded output. `npm run test:next` checks the packed library with Next.js/Turbopack. See [GIF verification details](docs/gif-encoder.md#verification).
201
+
168
202
  ## Release
169
203
 
170
204
  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.
@@ -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 CHANGED
@@ -26,6 +26,16 @@ export interface GifExportOptions {
26
26
  readonly signal?: AbortSignal;
27
27
  /** Transparent pixels are composited onto this RGB color. Default: white. */
28
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;
29
39
  }
30
40
  export type GifExportState = 'ready' | 'encoding' | 'finished' | 'failed' | 'aborted' | 'disposed';
31
41
  export interface GifExporter {
package/dist/gif.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Offline, sequential GIF export. No timers are used to determine frame durations. */
2
2
  export function createGifExporter(options) {
3
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');
4
7
  const repeat = options.repeat ?? 0;
5
8
  const background = [...(options.background ?? [255, 255, 255])];
6
9
  if (typeof factory !== 'function')
@@ -16,6 +19,14 @@ export function createGifExporter(options) {
16
19
  let encoder = factory(Object.freeze({ repeat }));
17
20
  let state = 'ready';
18
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
+ }
19
30
  let rejectCancelled;
20
31
  const cancelled = new Promise((_, reject) => { rejectCancelled = reject; });
21
32
  void cancelled.catch(() => { });
@@ -35,6 +46,7 @@ export function createGifExporter(options) {
35
46
  state = 'aborted';
36
47
  rejectCancelled(abortError());
37
48
  cleanup();
49
+ report('aborted');
38
50
  }
39
51
  signal?.addEventListener('abort', onAbort, { once: true });
40
52
  if (signal?.aborted)
@@ -57,6 +69,7 @@ export function createGifExporter(options) {
57
69
  if (state === 'encoding') {
58
70
  state = 'failed';
59
71
  cleanup();
72
+ report('failed');
60
73
  }
61
74
  throw error;
62
75
  }
@@ -94,6 +107,9 @@ export function createGifExporter(options) {
94
107
  width = w;
95
108
  height = h;
96
109
  frames++;
110
+ duration += Math.round(durationMs / 10) * 10;
111
+ report('encoding');
112
+ active();
97
113
  state = 'ready';
98
114
  });
99
115
  },
@@ -101,6 +117,8 @@ export function createGifExporter(options) {
101
117
  return operation(async () => {
102
118
  if (!frames)
103
119
  throw new Error('GIF export requires at least one frame');
120
+ report('finalizing');
121
+ active();
104
122
  const bytes = await encoder.finish();
105
123
  active();
106
124
  if (!(bytes instanceof Uint8Array) || bytes.length < 6 ||
@@ -110,15 +128,20 @@ export function createGifExporter(options) {
110
128
  const blob = new Blob([new Uint8Array(bytes)], { type: 'image/gif' });
111
129
  state = 'finished';
112
130
  cleanup();
131
+ report('finished');
113
132
  return blob;
114
133
  });
115
134
  },
116
135
  dispose() {
117
136
  if (state === 'disposed')
118
137
  return;
138
+ const unfinished = state === 'ready' || state === 'encoding';
119
139
  state = 'disposed';
120
- rejectCancelled(new DOMException('GIF exporter disposed', 'AbortError'));
140
+ if (unfinished)
141
+ rejectCancelled(new DOMException('GIF exporter disposed', 'AbortError'));
121
142
  cleanup();
143
+ if (unfinished)
144
+ report('disposed');
122
145
  }
123
146
  });
124
147
  }
@@ -15,7 +15,7 @@ gifenc was selected for the supplied adapter, not embedded into the controller.
15
15
 
16
16
  ## Loading and execution
17
17
 
18
- Measured emitted files (uncompressed / gzip):
18
+ Measured emitted files in release 0.3.0 (uncompressed / gzip, before Worker/progress additions):
19
19
 
20
20
  - `dist/index.js`: 8,120 / 2,128 bytes. The video entry remains unchanged.
21
21
  - `dist/gif.js`: 5,207 / 1,688 bytes. Controller only, no runtime imports.
@@ -25,8 +25,18 @@ The adapter is bundled at build time; gifenc, esbuild and the test decoder are d
25
25
 
26
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
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
+
28
34
  ## Verification
29
35
 
36
+ Run `npx playwright install chromium firefox` once, then `npm run test:browser`. The browser suite serves built modules with `worker-src 'self'`, observes actual Worker creation/closure, exports through a real canvas and downloads the GIF for independent decoding with gifuct-js. It checks every pixel, dimensions, frame count/durations, repeat count and progress events. An open-ended frame producer keeps the cancellation case active until the test clicks Cancel after observing a completed frame; no machine-speed-dependent delay is used. A second export on the same page must create and close a fresh Worker and produce the expected GIF. CI installs both browsers and retains the HTML report, GIF outputs, and failure screenshots/traces for seven days. These tests exercise direct module loading; the separate Next.js job covers bundler compilation.
37
+
38
+ `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.
39
+
30
40
  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
41
 
32
42
  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.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@grundyjs/canvas-take",
3
- "version": "0.3.0",
4
- "description": "Record HTML canvas to video with explicit resource lifecycle",
3
+ "version": "0.4.1",
4
+ "description": "Record HTML canvas video with audio and export GIFs with pluggable encoders, Worker support and explicit resource cleanup",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
8
8
  "README.md",
9
+ "ru/README.md",
9
10
  "LICENSE",
10
11
  "CHANGELOG.md",
11
12
  "docs/gif-encoder.md"
@@ -25,16 +26,25 @@
25
26
  "types": "./dist/gifenc.d.ts",
26
27
  "import": "./dist/gifenc.js",
27
28
  "default": "./dist/gifenc.js"
28
- }
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"
29
36
  },
30
37
  "scripts": {
31
38
  "build": "tsc -p tsconfig.json && node scripts/build-gif.mjs",
32
39
  "test": "npm run build && node --test test/*.test.mjs",
33
40
  "prepack": "npm run build",
34
41
  "prepublishOnly": "npm test",
35
- "test:package": "node scripts/test-package.mjs"
42
+ "test:package": "node scripts/test-package.mjs",
43
+ "test:next": "node scripts/test-next.mjs",
44
+ "test:browser": "npm run build && playwright test"
36
45
  },
37
46
  "devDependencies": {
47
+ "@playwright/test": "1.64.1",
38
48
  "esbuild": "0.28.2",
39
49
  "gifenc": "1.0.3",
40
50
  "gifuct-js": "2.1.2",
@@ -45,12 +55,15 @@
45
55
  "name": "Grundy",
46
56
  "url": "https://grundyjs.ru/"
47
57
  },
48
- "homepage": "https://grundyjs.ru/algorithms/insertion-sort/",
58
+ "homepage": "https://grundyjs.ru/canvas-take/",
49
59
  "keywords": [
50
60
  "canvas",
51
61
  "video",
52
62
  "recording",
53
63
  "mediarecorder",
64
+ "gif",
65
+ "audio",
66
+ "web-worker",
54
67
  "typescript"
55
68
  ],
56
69
  "sideEffects": false,
package/ru/README.md ADDED
@@ -0,0 +1,330 @@
1
+ # CanvasTake
2
+
3
+ [English](../README.md)
4
+
5
+ [Сайт](https://grundyjs.ru/canvas-take/) · [npm](https://www.npmjs.com/package/@grundyjs/canvas-take) · [Ошибки и предложения](https://github.com/urffin/canvas-take/issues)
6
+
7
+ Независимая TypeScript-библиотека для записи видео с HTML canvas с необязательными
8
+ аудиодорожками и покадрового экспорта GIF через отдельную точку входа. Без внешних
9
+ зависимостей во время выполнения и без зависимости от AlgiViz. Только ESM,
10
+ с декларациями TypeScript. Лицензия MIT. Пакет не запускает и не рисует вашу анимацию.
11
+
12
+ ## Установка
13
+
14
+ ```sh
15
+ npm install @grundyjs/canvas-take
16
+ ```
17
+
18
+ ## Запись canvas
19
+
20
+ ```js
21
+ import { createCanvasRecorder } from "@grundyjs/canvas-take";
22
+ const recorder = createCanvasRecorder(canvas, { fps: 30 });
23
+ await recorder.start();
24
+ // Run your animation, then:
25
+ const { blob, extension, mimeType, durationMs } = await recorder.stop();
26
+ const url = URL.createObjectURL(blob);
27
+ // Use url in a video player or download link. Revoke it when no longer needed.
28
+ recorder.dispose();
29
+ ```
30
+
31
+ `start` завершается после события start от MediaRecorder. `stop` ждёт последних
32
+ событий dataavailable и stop; повторные вызовы возвращают тот же promise.
33
+ При ошибках ожидающие операции отклоняются, принадлежащие рекордеру дорожки
34
+ останавливаются, а накопленные фрагменты освобождаются. `dispose` отменяет незавершённую
35
+ запись и допускает повторные вызовы. Каждый экземпляр записывает один раз.
36
+ Пока start ожидает завершения, состояние остаётся idle; повторный start отклоняется.
37
+
38
+ Порядок автоматического выбора формата: WebM VP9, VP8, обычный WebM, MP4.
39
+ При записи со звуком варианты VP9 и VP8 включают Opus. Явно заданный неподдерживаемый
40
+ MIME вызывает ошибку без скрытой смены формата. Результат содержит фактический MIME
41
+ и соответствующее расширение. FPS и битрейт — целевые значения, а не гарантии.
42
+ durationMs измеряет реальное время записи, а не длительность, прочитанную из файла.
43
+
44
+ Не меняйте размеры битовой карты canvas от start до stop. Масштабирование через CSS
45
+ допускается. Изменение размеров завершает запись ошибкой. Canvas должен оставаться
46
+ origin-clean. Захват экрана, пауза записи, покадровое кодирование видео и отправка
47
+ на сервер не поддерживаются. Фрагменты хранятся в памяти: ограничивайте длительность.
48
+ Если замедление фоновой вкладки недопустимо, вызывайте dispose при скрытии страницы.
49
+ Поддержка кодеков зависит от браузера и доступных ресурсов.
50
+
51
+ Разработка: Node.js 22+, `npm install`, `npm test`, `npm pack`. ESM и декларации
52
+ создаются в dist. `npm pack` автоматически пересобирает пакет; `npm publish`
53
+ запускает тесты до упаковки. Интеграционный пример ожидает AlgiViz в соседнем
54
+ каталоге и запускается через включённый в репозиторий локальный сервер.
55
+
56
+ См. [остановку MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop)
57
+ и [проверку формата](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported_static).
58
+ WebM от MediaRecorder может не содержать метаданные длительности: воспроизведение
59
+ работает, но плеер показывает неопределённую длительность. Для надёжной перемотки
60
+ или публикации отдельно перепакуйте контейнер. CanvasTake не переписывает его метаданные.
61
+
62
+ ## Настройки и результат
63
+
64
+ | Настройка | Значение |
65
+ | --- | --- |
66
+ | `fps` | Обязательная положительная конечная целевая частота захвата |
67
+ | `videoBitsPerSecond` | Необязательный положительный конечный целевой битрейт кодировщика |
68
+ | `mimeType` | Необязательный явно заданный поддерживаемый MIME `video/webm` или `video/mp4`, включая кодеки |
69
+ | `audioTracks` | Необязательный список только для чтения из активных звуковых `MediaStreamTrack`; без него или с пустым списком запись идёт без звука |
70
+
71
+ `stop()` возвращает `{ blob, mimeType, extension, durationMs }`. Используйте рекордер
72
+ в try/finally и вызывайте `dispose()`, даже если анимация завершилась ошибкой.
73
+ Освобождайте object URL, когда они больше не нужны.
74
+
75
+ ## Запись с озвучкой
76
+
77
+ Сведите звук в одну выходную дорожку Web Audio: несколько звуковых дорожек
78
+ поддерживает не каждый кодировщик. Декодируйте озвучку до начала записи.
79
+ Вызывайте код из обработчика действия пользователя, чтобы браузер мог возобновить
80
+ аудиоконтекст:
81
+
82
+ ```js
83
+ async function recordNarration(canvas, narrationBytes) {
84
+ const audio = new AudioContext();
85
+ let recorder;
86
+ let destination;
87
+ try {
88
+ await audio.resume();
89
+ const buffer = await audio.decodeAudioData(narrationBytes);
90
+ destination = audio.createMediaStreamDestination();
91
+ const source = audio.createBufferSource();
92
+ source.buffer = buffer;
93
+ source.connect(destination);
94
+ recorder = createCanvasRecorder(canvas, {
95
+ fps: 30,
96
+ mimeType: "video/mp4",
97
+ audioTracks: destination.stream.getAudioTracks(),
98
+ });
99
+ const finished = new Promise(resolve => {
100
+ source.addEventListener("ended", resolve, { once: true });
101
+ });
102
+ await recorder.start();
103
+ source.start();
104
+ // Run your canvas animation while the narration plays.
105
+ await finished;
106
+ return await recorder.stop();
107
+ } finally {
108
+ recorder?.dispose();
109
+ destination?.stream.getTracks().forEach(track => track.stop());
110
+ await audio.close();
111
+ }
112
+ }
113
+ ```
114
+
115
+ Для MP4 со звуком нужна поддержка кодировщика в браузере; явный запрос MP4
116
+ никогда не переключается на WebM. CanvasTake использует MediaRecorder напрямую
117
+ и не перекодирует результат. Воспроизведением звука и анимацией canvas управляет
118
+ приложение; они работают в реальном времени.
119
+
120
+ `video/mp4` выбирает контейнер, а не конкретные кодеки: браузер может записать
121
+ VP9/Opus в MP4. Если площадке нужны H.264/AAC, укажите MIME с кодеками, например
122
+ `video/mp4;codecs=avc1,mp4a.40.2`, и обработайте ошибку неподдерживаемого формата.
123
+
124
+ Для быстрой проверки в браузере после сборки запустите `node examples/server.cjs`
125
+ и откройте `http://127.0.0.1:3012/canvas-take/examples/audio.html`. Пример записывает
126
+ сгенерированный тон и движущийся canvas 1080×1920 с явно заданными H.264/AAC,
127
+ запрашивает остановку через две секунды, показывает результат и сохраняет его
128
+ как `canvastake-test.mp4` во временном каталоге сервера. Ранее проверенный результат
129
+ в локальном браузере: H.264 Constrained Baseline, прогрессивная развёртка 4:2:0,
130
+ стерео AAC-LC 48 кГц, 2,476 секунды и примерно 29,1 fps. Перекодирование и перепаковка
131
+ не использовались. Фактические длительность, частота кадров и поддержка кодеков
132
+ зависят от браузера и нагрузки во время выполнения.
133
+
134
+ Список аудиодорожек копируется при создании и повторно проверяется при start.
135
+ CanvasTake клонирует дорожки при start и при завершении, dispose или ошибке
136
+ останавливает только свои копии и дорожки захвата canvas. Исходными дорожками
137
+ и AudioContext владеет вызывающий код. Неожиданное завершение записываемой копии
138
+ вызывает ошибку записи. `stop()` исходной дорожки не останавливает её копию;
139
+ для завершения записи вызовите `recorder.stop()`. Окончание AudioBufferSource
140
+ также не завершает выходную дорожку.
141
+
142
+ ## Покадровый экспорт GIF
143
+
144
+ Доступен с версии 0.3.0.
145
+
146
+ ```js
147
+ import { createGifExporter } from '@grundyjs/canvas-take/gif';
148
+ import { createGifencEncoder } from '@grundyjs/canvas-take/gif/gifenc';
149
+
150
+ const controller = new AbortController();
151
+ const gif = createGifExporter({
152
+ encoder: createGifencEncoder({ maxColors: 128, quality: 'high' }),
153
+ repeat: 0,
154
+ signal: controller.signal,
155
+ });
156
+ try {
157
+ for (let time = 0; time < 6000; time += 50) {
158
+ drawTimeline(time); // Your deterministic renderer draws into canvas.
159
+ await gif.addFrame(canvas, 50);
160
+ }
161
+ drawTimeline(6000);
162
+ await gif.addFrame(canvas, 2000); // Hold the final frame for two seconds.
163
+ const blob = await gif.finish(); // image/gif; 8 seconds, 121 frames.
164
+ // Download/preview blob; revoke any object URL when no longer needed.
165
+ } finally {
166
+ gif.dispose();
167
+ }
168
+ ```
169
+
170
+ `encoder` обязателен и передаётся приложением. Точка входа `/gif` не зависит от
171
+ кодека; `/gif/gifenc` — необязательный самостоятельный адаптер со встроенным
172
+ кодеком gifenc под MIT. Корневой импорт видео не загружает ни один GIF-модуль.
173
+ Адаптер можно заменить без изменения цикла экспорта.
174
+
175
+ | Настройка | Значение |
176
+ | --- | --- |
177
+ | `repeat` | По умолчанию `0`: бесконечно. `-1`: один просмотр. `1..65535`: дополнительные повторы после первого просмотра |
178
+ | `signal` | Необязательный AbortSignal; отмена отклоняет ожидающие операции и освобождает кодировщик |
179
+ | `background` | Тройка байтов RGB, по умолчанию `[255, 255, 255]`; прозрачность накладывается на непрозрачный фон |
180
+ | `maxColors` адаптера | Максимум цветов палитры на кадр: целое `2..256`, по умолчанию `256` |
181
+ | `quality` адаптера | `high` (по умолчанию, RGB565) или `fast` (RGB444); без дизеринга |
182
+
183
+ `addFrame(canvas, durationMs)` немедленно снимает копию 2D HTMLCanvasElement или
184
+ OffscreenCanvas, затем отдаёт управление перед кодированием. Дожидайтесь каждого
185
+ вызова перед следующим кадром или `finish()`. Все кадры должны иметь одинаковые
186
+ положительные размеры, не более 65535 по каждой оси — предел GIF. Длительности
187
+ должны быть конечными и находиться в диапазоне от 10 до 655350 мс; они округляются
188
+ до ближайших 10 мс. GIF-плееры могут увеличивать короткие задержки; 50 мс —
189
+ практичный шаг временной шкалы. Время рисования и реальное время кодирования
190
+ не влияют на длительность GIF. Canvas остаётся у приложения и не изменяется.
191
+ Для canvas с ограниченным доступом к пикселям операция завершается ошибкой чтения.
192
+
193
+ `finish()` требует хотя бы один кадр, возвращает Blob и освобождает кодировщик.
194
+ Каждый экземпляр создаёт один GIF. `dispose()` допускает повторные вызовы,
195
+ отменяет ожидающие операции и освобождает ресурсы кодировщика. Неверные данные
196
+ кадра, изменение размеров и ошибки кодировщика делают дальнейший экспорт невозможным.
197
+ Одновременные вызовы отклоняются, не отменяя текущую операцию. Возможные состояния:
198
+ `ready`, `encoding`, `finished`, `failed`, `aborted` и `disposed`.
199
+
200
+ Встроенный адаптер постепенно кодирует полные кадры с отдельными палитрами,
201
+ сохраняя сжатый результат вместо всех исходных кадров. Обработка каждого кадра
202
+ синхронна и выполняется в вызывающем потоке; отмена обрабатывается между кадрами.
203
+ Для тяжёлых кадров используйте готовый адаптер `/gif/worker`, OffscreenCanvas в Worker или передайте асинхронный
204
+ адаптер с Worker; автоматического пула Worker нет. Байты GIF и итоговый Blob
205
+ занимают память, поэтому ограничивайте размеры и число кадров. Размер файла
206
+ зависит от визуальной сложности; анимация шириной 800 px и длиной 6–10 секунд —
207
+ целевой сценарий, а не гарантия файла размером 2–3 МБ.
208
+
209
+ ### Worker-адаптер и прогресс (0.4.0)
210
+
211
+ ```ts
212
+ import { createGifExporter } from '@grundyjs/canvas-take/gif';
213
+ import { createGifWorkerEncoder } from '@grundyjs/canvas-take/gif/worker';
214
+
215
+ const gif = createGifExporter({
216
+ encoder: createGifWorkerEncoder({ maxColors: 128, quality: 'high' }),
217
+ totalFrames: 121,
218
+ onProgress({ phase, framesEncoded, totalFrames, durationMs }) {
219
+ console.log(phase, framesEncoded, totalFrames, durationMs);
220
+ },
221
+ });
222
+ // Последовательно: await gif.addFrame(canvas, durationMs), затем await gif.finish().
223
+ ```
224
+
225
+ Квантование и кодирование выполняются в отдельном module Worker. Чтение canvas
226
+ и наложение прозрачности на фон остаются в вызывающем потоке. Каждый экспортёр
227
+ владеет одним Worker и завершает его при успехе, ошибке, отмене или `dispose()`.
228
+ Буферы пикселей передаются без копирования; исходный canvas не меняется.
229
+ Требуется поддержка Worker; автоматического перехода к синхронному кодированию нет.
230
+
231
+ При прямом импорте в браузере размещайте `gif-worker.js`, `gif-worker-runtime.js`
232
+ и `gifenc.js` рядом, с MIME-типом JavaScript. Для сборщика проверьте, что Worker
233
+ и его зависимости попали в результат сборки. При необходимости скопируйте runtime
234
+ и `gifenc.js` в один публичный каталог и передайте `workerUrl: '/assets/gif-worker-runtime.js'`.
235
+ Путь пакета `/gif/worker-runtime` предназначен для разрешения адреса ресурса,
236
+ а не для импорта в основном потоке. Учитывайте same-origin и CSP `worker-src`;
237
+ Blob URL и встроенный код Worker не требуются.
238
+
239
+ `timeoutMs` по умолчанию равен 30000 мс **на каждый запрос**: запуск, отдельный кадр
240
+ и финализацию. При тайм-ауте, ошибке загрузки или передачи сообщения ожидающие
241
+ операции отклоняются, а Worker завершается.
242
+
243
+ `onProgress` работает с любым кодировщиком. Неизменяемый снимок приходит после
244
+ успешного кадра (`encoding`), перед финализацией (`finalizing`) и при результате
245
+ `finished`, `failed`, `aborted` или преждевременном `disposed`. Начального события нет.
246
+ `framesEncoded` и `durationMs` учитывают только завершённые кадры с округлёнными
247
+ длительностями GIF. Необязательный `totalFrames` — положительное безопасное целое
248
+ для отображения, а не ограничение числа кадров; без него поле отсутствует.
249
+ Полное число кадров ещё не означает завершение финализации. Процент и ETA
250
+ автоматически не вычисляются. Исключения наблюдателя игнорируются; из него можно
251
+ отменить или освободить экспортёр. `dispose()` после конечного результата
252
+ не создаёт дополнительного события.
253
+
254
+ ### Подключение другого кодировщика
255
+
256
+ Реализуйте экспортируемый контракт `GifEncoderFactory`:
257
+
258
+ ```ts
259
+ import type { GifEncoderFactory } from '@grundyjs/canvas-take/gif';
260
+
261
+ const encoder: GifEncoderFactory = ({ repeat }) => ({
262
+ addFrame({ data, width, height, durationMs }) {
263
+ // Pass the opaque RGBA snapshot to your codec; may return Promise<void>.
264
+ },
265
+ async finish() {
266
+ // Return complete GIF bytes as Uint8Array.
267
+ return encodedBytes;
268
+ },
269
+ dispose() {
270
+ // Drop buffers, terminate owned Workers, settle pending operations.
271
+ },
272
+ });
273
+ ```
274
+
275
+ Каждый вызов фабрики должен создавать новый кодировщик и учитывать переданное число
276
+ повторов. Экспортёр вызывает методы последовательно и вызывает `dispose()` один
277
+ раз при успехе, ошибке или отмене. Адаптер может передать принадлежащий ему буфер
278
+ кадра в Worker; он должен освобождать ссылки на кадр после каждой операции
279
+ и поддерживать dispose во время ожидающей операции. Отмена немедленно отклоняет
280
+ операции экспортёра, но внешнюю работу очищает адаптер. Синхронную работу кодека
281
+ нельзя прервать в том же потоке. Исключения очистки подавляются, чтобы сохранить
282
+ основной результат операции.
283
+
284
+ После сборки запустите `node examples/server.cjs` и откройте
285
+ `http://127.0.0.1:3012/canvas-take/examples/gif.html` — отдельный пример покадрового
286
+ экспорта с отменой, предпросмотром и скачиванием. Обоснование выбора кодека
287
+ и измеренные размеры модулей приведены в [docs/gif-encoder.md](../docs/gif-encoder.md).
288
+
289
+ Если собранный AlgiViz находится в соседнем каталоге `algiviz`, откройте
290
+ `http://127.0.0.1:3012/canvas-take/examples/gif-bfs.html` для интеграции с настоящим
291
+ BFS. Ранее проверенный результат: 800×560, 141 кадр, 9 секунд с удержанием последнего
292
+ кадра 2 секунды, около 1,60 МБ. Пример использует существующие алгоритм и рендерер
293
+ AlgiViz и не изменяет тот проект.
294
+
295
+ ## Использование в браузере и на сервере
296
+
297
+ Для записи нужен браузер с `canvas.captureStream`, `MediaRecorder` и `MutationObserver`.
298
+ Импорт модуля не обращается к этим API; создание рекордера проверяет поддержку
299
+ захвата и кодировщика. Реализации записи для Node.js нет. Поддержка кодеков
300
+ определяется во время выполнения, а не по названию браузера.
301
+
302
+ Интеграционный пример ранее проверен настоящей записью VP9 в 1920×1080.
303
+ Это не обещание, что любой браузер создаст WebM или MP4. Canvas с содержимым
304
+ из другого источника без CORS может оказаться недоступен для захвата.
305
+
306
+ ## Проверки при разработке
307
+
308
+ `npm test` запускает модульные тесты и проверки Node Worker, а `npm run test:package`
309
+ проверяет установку упакованной библиотеки в отдельный проект. Для настоящих браузеров
310
+ сначала выполните `npx playwright install chromium firefox`, затем `npm run test:browser`.
311
+ Проверяются GIF-экспорт, отмена, повторный экспорт на той же странице, завершение Worker
312
+ и независимое декодирование результата. `npm run test:next` проверяет сборку
313
+ упакованной библиотеки с Next.js/Turbopack. Подробности — в
314
+ [описании проверок GIF](../docs/gif-encoder.md#verification).
315
+
316
+ ## Выпуск версии
317
+
318
+ Запустите `npm test` и `npm run test:package`: последняя команда упаковывает архив,
319
+ устанавливает его в изолированное приложение и проверяет декларации TypeScript
320
+ и импорты во время выполнения. Архив содержит dist, README, LICENSE, CHANGELOG
321
+ и метаданные пакета. Исходный код и задачи:
322
+ [urffin/canvas-take](https://github.com/urffin/canvas-take). API версии 0.x может
323
+ меняться в следующих минорных релизах.
324
+
325
+ После успешного CI для коммита релиза опубликуйте стабильный GitHub Release
326
+ с тегом `v<package version>`. Workflow `npm-publish.yml` проверяет согласованность
327
+ версий, тестирует приложение с упакованным пакетом и публикует с OIDC/provenance.
328
+ Перед первым автоматическим релизом настройте npm trusted publisher для владельца
329
+ `urffin`, репозитория `canvas-take` и workflow `npm-publish.yml`. Токен npm
330
+ не используется. Публикация — отдельное действие сопровождающего проекта.