@grundyjs/canvas-take 0.1.0 → 0.2.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,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 — unreleased
3
+ ## 0.2.0 — 2026-10-15
4
+
5
+ - Optional caller-provided `audioTracks`, cloned at start to preserve caller ownership.
6
+ - Audio-aware automatic WebM codec selection (Opus), with explicit MP4 support where available.
7
+ - Audio validation and cleanup on termination, cancellation and partial setup failure.
8
+ - Web Audio narration example and audio lifecycle tests.
9
+ - Verified vertical MP4 recording with H.264/AAC; GitHub Releases publish through npm trusted publishing with provenance.
10
+
11
+ ## 0.1.0
4
12
 
5
13
  - Silent canvas recording with automatic WebM/MP4 format selection.
6
14
  - Promise-based start and stop, final-chunk handling and idempotent disposal.
package/LICENSE CHANGED
@@ -1,21 +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.
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,6 +1,6 @@
1
1
  # CanvasTake
2
2
 
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.
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.
4
4
 
5
5
  ## Install
6
6
 
@@ -23,14 +23,14 @@ recorder.dispose();
23
23
 
24
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
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.
26
+ 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
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.
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.
29
29
 
30
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
31
 
32
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.
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
34
 
35
35
  ## Options and result
36
36
 
@@ -39,9 +39,55 @@ MediaRecorder WebM output may omit duration metadata: playback can work while th
39
39
  | `fps` | Required positive finite capture rate target |
40
40
  | `videoBitsPerSecond` | Optional positive finite encoder bitrate target |
41
41
  | `mimeType` | Optional explicit supported `video/webm` or `video/mp4` MIME, including codecs |
42
+ | `audioTracks` | Optional readonly list of live audio `MediaStreamTrack`s; omitted or empty records silently |
42
43
 
43
44
  `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
 
46
+ ## Record with narration
47
+
48
+ Mix audio into one Web Audio destination track (multiple audio tracks are not supported by every encoder). Decode your narration before starting. Call this from a user gesture so the browser can resume the audio context:
49
+
50
+ ```js
51
+ async function recordNarration(canvas, narrationBytes) {
52
+ const audio = new AudioContext();
53
+ let recorder;
54
+ let destination;
55
+ try {
56
+ await audio.resume();
57
+ const buffer = await audio.decodeAudioData(narrationBytes);
58
+ destination = audio.createMediaStreamDestination();
59
+ const source = audio.createBufferSource();
60
+ source.buffer = buffer;
61
+ source.connect(destination);
62
+ recorder = createCanvasRecorder(canvas, {
63
+ fps: 30,
64
+ mimeType: "video/mp4",
65
+ audioTracks: destination.stream.getAudioTracks(),
66
+ });
67
+ const finished = new Promise(resolve => {
68
+ source.addEventListener("ended", resolve, { once: true });
69
+ });
70
+ await recorder.start();
71
+ source.start();
72
+ // Run your canvas animation while the narration plays.
73
+ await finished;
74
+ return await recorder.stop();
75
+ } finally {
76
+ recorder?.dispose();
77
+ destination?.stream.getTracks().forEach(track => track.stop());
78
+ await audio.close();
79
+ }
80
+ }
81
+ ```
82
+
83
+ MP4 with audio requires browser encoder support; an explicit MP4 request never falls back to WebM. CanvasTake uses MediaRecorder directly and does not transcode. Audio playback and canvas animation remain caller-controlled and use real time.
84
+
85
+ `video/mp4` selects a container, not specific codecs: a browser may produce VP9/Opus in MP4. If your destination requires H.264/AAC, request an explicit codec MIME such as `video/mp4;codecs=avc1,mp4a.40.2` and handle unsupported-format errors.
86
+
87
+ For a browser smoke test, run `node examples/server.cjs` after building and open `http://127.0.0.1:3012/canvas-take/examples/audio.html`. It records a generated tone and moving 1080×1920 canvas with explicit H.264/AAC, requests stop after two seconds, previews the result and saves it to `canvastake-test.mp4` in the server's temporary directory. Verified in the local browser: H.264 Constrained Baseline, progressive 4:2:0, stereo AAC-LC at 48 kHz, 2.476 seconds and approximately 29.1 fps. No transcoding or remuxing was used. Actual duration/frame rate and codec support depend on the browser and runtime load.
88
+
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
+
45
91
  ## Browser and server use
46
92
 
47
93
  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.
@@ -50,5 +96,7 @@ The integration example was verified with an actual VP9 recording at 1920×1080.
50
96
 
51
97
  ## Release
52
98
 
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
-
99
+ 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
+
101
+ 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/index.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface RecorderOptions {
8
8
  readonly fps: number;
9
9
  readonly videoBitsPerSecond?: number;
10
10
  readonly mimeType?: string;
11
+ /** Live caller-owned audio tracks; recorder owns and stops only clones. */
12
+ readonly audioTracks?: readonly MediaStreamTrack[];
11
13
  }
12
14
  export type RecorderState = "idle" | "recording" | "stopping" | "stopped" | "disposed";
13
15
  export interface CanvasRecorder {
@@ -16,5 +18,5 @@ export interface CanvasRecorder {
16
18
  stop(): Promise<RecordingResult>;
17
19
  dispose(): void;
18
20
  }
19
- /** One recorder instance produces one silent video; drawing is managed by the caller. */
21
+ /** One recorder instance produces one video, optionally with supplied audio; drawing is managed by the caller. */
20
22
  export declare function createCanvasRecorder(canvas: HTMLCanvasElement, options: RecorderOptions): CanvasRecorder;
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ function extension(mime) {
14
14
  return "mp4";
15
15
  throw new TypeError(`Unsupported video container: ${mime}`);
16
16
  }
17
- /** One recorder instance produces one silent video; drawing is managed by the caller. */
17
+ /** One recorder instance produces one video, optionally with supplied audio; drawing is managed by the caller. */
18
18
  export function createCanvasRecorder(canvas, options) {
19
19
  if (!Number.isFinite(options.fps) || options.fps <= 0)
20
20
  throw new RangeError("fps must be positive and finite");
@@ -25,10 +25,18 @@ export function createCanvasRecorder(canvas, options) {
25
25
  if (typeof MediaRecorder === "undefined" || typeof canvas.captureStream !== "function") {
26
26
  throw new Error("Canvas video recording is not supported in this environment");
27
27
  }
28
+ const audioTracks = [...(options.audioTracks ?? [])];
29
+ for (const track of audioTracks) {
30
+ if (track.kind !== "audio" || track.readyState !== "live")
31
+ throw new TypeError("audioTracks must contain live audio tracks");
32
+ }
28
33
  const requested = options.mimeType;
29
34
  if (requested !== undefined)
30
35
  extension(requested);
31
- const mime = requested ?? ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"]
36
+ const candidates = audioTracks.length
37
+ ? ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm", "video/mp4"]
38
+ : ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"];
39
+ const mime = requested ?? candidates
32
40
  .find(candidate => MediaRecorder.isTypeSupported(candidate));
33
41
  if (!mime || !MediaRecorder.isTypeSupported(mime))
34
42
  throw new Error(`Unsupported recording format: ${mime ?? "none"}`);
@@ -80,7 +88,7 @@ export function createCanvasRecorder(canvas, options) {
80
88
  function onError(event) {
81
89
  fail(event.error ?? new Error("Video recording failed"));
82
90
  }
83
- function onEnded() { fail(new Error("Canvas capture track ended unexpectedly")); }
91
+ function onEnded() { fail(new Error("Recording track ended unexpectedly")); }
84
92
  function onStop() {
85
93
  if (!unchanged()) {
86
94
  fail(new Error("Canvas bitmap size changed during recording"));
@@ -140,6 +148,18 @@ export function createCanvasRecorder(canvas, options) {
140
148
  stream = canvas.captureStream(options.fps);
141
149
  if (!stream.getVideoTracks().length)
142
150
  throw new Error("Canvas stream has no video track");
151
+ for (const track of audioTracks) {
152
+ if (track.readyState !== "live")
153
+ throw new Error("Audio track ended before recording");
154
+ const clone = track.clone();
155
+ try {
156
+ stream.addTrack(clone);
157
+ }
158
+ catch (error) {
159
+ clone.stop();
160
+ throw error;
161
+ }
162
+ }
143
163
  recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: options.videoBitsPerSecond });
144
164
  recorder.addEventListener("start", onStart);
145
165
  recorder.addEventListener("dataavailable", onData);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grundyjs/canvas-take",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Record HTML canvas to video with explicit resource lifecycle",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,7 +20,8 @@
20
20
  "build": "tsc -p tsconfig.json",
21
21
  "test": "npm run build && node --test test/*.test.mjs",
22
22
  "prepack": "npm run build",
23
- "prepublishOnly": "npm test"
23
+ "prepublishOnly": "npm test",
24
+ "test:package": "node scripts/test-package.mjs"
24
25
  },
25
26
  "devDependencies": {
26
27
  "typescript": "6.0.3"