@grundyjs/canvas-take 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ - Silent canvas recording with automatic WebM/MP4 format selection.
6
+ - Promise-based start and stop, final-chunk handling and idempotent disposal.
7
+ - Resource cleanup on errors, resize and unexpected track termination.
8
+ - ESM and TypeScript declarations; no runtime dependencies.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grundy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,54 @@
1
- # Temporary Holding Version
1
+ # CanvasTake
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
- If no other versions are published within 30 days, this package and version will be deleted.
3
+ Independent TypeScript library for silent HTML canvas video recording. No runtime dependencies or dependency on AlgiViz. ESM only, with TypeScript declarations. MIT licensed. The package does not start or draw your animation.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @grundyjs/canvas-take
9
+ ```
10
+
11
+ ## Record a canvas
12
+
13
+ ```js
14
+ import { createCanvasRecorder } from "@grundyjs/canvas-take";
15
+ const recorder = createCanvasRecorder(canvas, { fps: 30 });
16
+ await recorder.start();
17
+ // Run your animation, then:
18
+ const { blob, extension, mimeType, durationMs } = await recorder.stop();
19
+ const url = URL.createObjectURL(blob);
20
+ // Use url in a video player or download link. Revoke it when no longer needed.
21
+ recorder.dispose();
22
+ ```
23
+
24
+ `start` resolves after the MediaRecorder start event. `stop` waits for the final dataavailable and stop events; repeated calls return the same promise. Errors reject pending operations, stop owned tracks and release chunks. `dispose` cancels an unfinished recording; it is idempotent. Each instance records once. State stays idle while start is pending; a second start is rejected.
25
+
26
+ Auto format order: WebM VP9, VP8, generic WebM, MP4. 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
+
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 audio, 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.
29
+
30
+ 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
+
32
+ See [MediaRecorder stop](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop) and [format support](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported_static).
33
+ MediaRecorder WebM output may omit duration metadata: playback can work while the player reports an indefinite duration. For reliable seeking or publication, remux the output separately. CanvasTake does not rewrite container metadata.
34
+
35
+ ## Options and result
36
+
37
+ | Option | Meaning |
38
+ | --- | --- |
39
+ | `fps` | Required positive finite capture rate target |
40
+ | `videoBitsPerSecond` | Optional positive finite encoder bitrate target |
41
+ | `mimeType` | Optional explicit supported `video/webm` or `video/mp4` MIME, including codecs |
42
+
43
+ `stop()` returns `{ blob, mimeType, extension, durationMs }`. Keep the recorder in a try/finally and call `dispose()` even if your animation fails. Revoke object URLs when no longer needed.
44
+
45
+ ## Browser and server use
46
+
47
+ 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.
48
+
49
+ 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.
50
+
51
+ ## Release
52
+
53
+ Run `npm test`, `npm pack --dry-run` and install the tarball in a separate consumer project. The archive contains dist, README, LICENSE, CHANGELOG and package metadata. Source and issues: [urffin/canvas-take](https://github.com/urffin/canvas-take). Version 0.1 is an initial API and may change in later minor releases. Publishing is a separate maintainer action.
54
+
@@ -0,0 +1,20 @@
1
+ export interface RecordingResult {
2
+ readonly blob: Blob;
3
+ readonly mimeType: string;
4
+ readonly extension: "webm" | "mp4";
5
+ readonly durationMs: number;
6
+ }
7
+ export interface RecorderOptions {
8
+ readonly fps: number;
9
+ readonly videoBitsPerSecond?: number;
10
+ readonly mimeType?: string;
11
+ }
12
+ export type RecorderState = "idle" | "recording" | "stopping" | "stopped" | "disposed";
13
+ export interface CanvasRecorder {
14
+ readonly state: RecorderState;
15
+ start(): Promise<void>;
16
+ stop(): Promise<RecordingResult>;
17
+ dispose(): void;
18
+ }
19
+ /** One recorder instance produces one silent video; drawing is managed by the caller. */
20
+ export declare function createCanvasRecorder(canvas: HTMLCanvasElement, options: RecorderOptions): CanvasRecorder;
package/dist/index.js ADDED
@@ -0,0 +1,182 @@
1
+ function deferred() {
2
+ let resolve;
3
+ let reject;
4
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
5
+ // Errors may occur before the caller asks for the final result.
6
+ void promise.catch(() => { });
7
+ return { promise, resolve, reject };
8
+ }
9
+ function extension(mime) {
10
+ const base = mime.split(";", 1)[0].trim().toLowerCase();
11
+ if (base === "video/webm")
12
+ return "webm";
13
+ if (base === "video/mp4")
14
+ return "mp4";
15
+ throw new TypeError(`Unsupported video container: ${mime}`);
16
+ }
17
+ /** One recorder instance produces one silent video; drawing is managed by the caller. */
18
+ export function createCanvasRecorder(canvas, options) {
19
+ if (!Number.isFinite(options.fps) || options.fps <= 0)
20
+ throw new RangeError("fps must be positive and finite");
21
+ if (options.videoBitsPerSecond !== undefined &&
22
+ (!Number.isFinite(options.videoBitsPerSecond) || options.videoBitsPerSecond <= 0)) {
23
+ throw new RangeError("videoBitsPerSecond must be positive and finite");
24
+ }
25
+ if (typeof MediaRecorder === "undefined" || typeof canvas.captureStream !== "function") {
26
+ throw new Error("Canvas video recording is not supported in this environment");
27
+ }
28
+ const requested = options.mimeType;
29
+ if (requested !== undefined)
30
+ extension(requested);
31
+ const mime = requested ?? ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"]
32
+ .find(candidate => MediaRecorder.isTypeSupported(candidate));
33
+ if (!mime || !MediaRecorder.isTypeSupported(mime))
34
+ throw new Error(`Unsupported recording format: ${mime ?? "none"}`);
35
+ let state = "idle";
36
+ let recorder;
37
+ let stream;
38
+ let observer;
39
+ let width = 0, height = 0, startedAt = 0, stoppedAt;
40
+ let chunks = [];
41
+ let startResult;
42
+ const result = deferred();
43
+ let stopPromise;
44
+ let failure;
45
+ function cleanup() {
46
+ observer?.disconnect();
47
+ recorder?.removeEventListener("start", onStart);
48
+ recorder?.removeEventListener("dataavailable", onData);
49
+ recorder?.removeEventListener("stop", onStop);
50
+ recorder?.removeEventListener("error", onError);
51
+ for (const track of stream?.getTracks() ?? []) {
52
+ track.removeEventListener("ended", onEnded);
53
+ }
54
+ try {
55
+ if (recorder && recorder.state !== "inactive")
56
+ recorder.stop();
57
+ }
58
+ catch { }
59
+ for (const track of stream?.getTracks() ?? [])
60
+ track.stop();
61
+ chunks = [];
62
+ }
63
+ function fail(error) {
64
+ if (state === "disposed" || state === "stopped")
65
+ return;
66
+ failure = error;
67
+ state = "stopped";
68
+ startResult?.reject(error);
69
+ result.reject(error);
70
+ cleanup();
71
+ }
72
+ function unchanged() { return canvas.width === width && canvas.height === height; }
73
+ function onStart() {
74
+ startedAt = performance.now();
75
+ state = "recording";
76
+ startResult.resolve();
77
+ }
78
+ function onData(event) { if (event.data.size)
79
+ chunks.push(event.data); }
80
+ function onError(event) {
81
+ fail(event.error ?? new Error("Video recording failed"));
82
+ }
83
+ function onEnded() { fail(new Error("Canvas capture track ended unexpectedly")); }
84
+ function onStop() {
85
+ if (!unchanged()) {
86
+ fail(new Error("Canvas bitmap size changed during recording"));
87
+ return;
88
+ }
89
+ if (state !== "stopping") {
90
+ fail(new Error("Recording stopped unexpectedly"));
91
+ return;
92
+ }
93
+ try {
94
+ const actualMime = recorder.mimeType || chunks.find(chunk => chunk.type)?.type || mime;
95
+ const ext = extension(actualMime);
96
+ const blob = new Blob(chunks, { type: actualMime });
97
+ if (!blob.size)
98
+ throw new Error("Recording produced an empty video");
99
+ state = "stopped";
100
+ result.resolve(Object.freeze({ blob, mimeType: actualMime, extension: ext,
101
+ durationMs: Math.max(0, (stoppedAt ?? performance.now()) - startedAt) }));
102
+ cleanup();
103
+ }
104
+ catch (error) {
105
+ fail(error);
106
+ }
107
+ }
108
+ function beginStop() {
109
+ if (state === "disposed")
110
+ return Promise.reject(new Error("Recorder is disposed"));
111
+ if (failure)
112
+ return result.promise;
113
+ if (state === "stopped" || state === "stopping")
114
+ return result.promise;
115
+ if (!unchanged()) {
116
+ fail(new Error("Canvas bitmap size changed during recording"));
117
+ return result.promise;
118
+ }
119
+ state = "stopping";
120
+ stoppedAt = performance.now();
121
+ try {
122
+ recorder.stop();
123
+ }
124
+ catch (error) {
125
+ fail(error);
126
+ }
127
+ return result.promise;
128
+ }
129
+ return Object.freeze({
130
+ get state() { return state; },
131
+ start() {
132
+ if (state !== "idle" || startResult)
133
+ return Promise.reject(new Error("Recorder can only start once"));
134
+ startResult = deferred();
135
+ try {
136
+ width = canvas.width;
137
+ height = canvas.height;
138
+ if (!width || !height)
139
+ throw new RangeError("Canvas must have a positive bitmap size");
140
+ stream = canvas.captureStream(options.fps);
141
+ if (!stream.getVideoTracks().length)
142
+ throw new Error("Canvas stream has no video track");
143
+ recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: options.videoBitsPerSecond });
144
+ recorder.addEventListener("start", onStart);
145
+ recorder.addEventListener("dataavailable", onData);
146
+ recorder.addEventListener("stop", onStop);
147
+ recorder.addEventListener("error", onError);
148
+ for (const track of stream.getTracks())
149
+ track.addEventListener("ended", onEnded);
150
+ if (typeof MutationObserver !== "undefined") {
151
+ observer = new MutationObserver(() => {
152
+ if (!unchanged())
153
+ fail(new Error("Canvas bitmap size changed during recording"));
154
+ });
155
+ observer.observe(canvas, { attributes: true, attributeFilter: ["width", "height"] });
156
+ }
157
+ recorder.start(1000);
158
+ }
159
+ catch (error) {
160
+ fail(error);
161
+ }
162
+ return startResult.promise;
163
+ },
164
+ stop() {
165
+ if (state === "disposed")
166
+ return Promise.reject(new Error("Recorder is disposed"));
167
+ if (!startResult)
168
+ return Promise.reject(new Error("Recorder has not started"));
169
+ stopPromise ??= startResult.promise.then(beginStop);
170
+ return stopPromise;
171
+ },
172
+ dispose() {
173
+ if (state === "disposed")
174
+ return;
175
+ const error = new Error("Recording was disposed");
176
+ state = "disposed";
177
+ startResult?.reject(error);
178
+ result.reject(error);
179
+ cleanup();
180
+ }
181
+ });
182
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@grundyjs/canvas-take",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Record HTML canvas to video with explicit resource lifecycle",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md",
9
+ "LICENSE",
10
+ "CHANGELOG.md"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "test": "npm run build && node --test test/*.test.mjs",
22
+ "prepack": "npm run build",
23
+ "prepublishOnly": "npm test"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "6.0.3"
27
+ },
28
+ "license": "MIT",
29
+ "author": {
30
+ "name": "Grundy",
31
+ "url": "https://grundyjs.ru/"
32
+ },
33
+ "homepage": "https://grundyjs.ru/algorithms/insertion-sort/",
34
+ "keywords": [
35
+ "canvas",
36
+ "video",
37
+ "recording",
38
+ "mediarecorder",
39
+ "typescript"
40
+ ],
41
+ "sideEffects": false,
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "main": "./dist/index.js",
47
+ "types": "./dist/index.d.ts",
48
+ "repository": {
49
+ "url": "git+https://github.com/urffin/canvas-take.git",
50
+ "type": "git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/urffin/canvas-take/issues"
54
+ }
55
+ }