p5.record.js 0.1.2 → 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/README.md CHANGED
@@ -1,23 +1,24 @@
1
1
  # p5.record.js
2
2
 
3
- Record your p5.js sketch and export it as a video.
3
+ Record your p5.js sketch and export it as a video or image sequence.
4
4
 
5
- This p5.js addon library provides a simple interface to record your p5.js sketch into a video by using the [`captureStream()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream) API of `HTMLCanvasElement`. No external dependencies is included with this library.
5
+ This p5.js addon library provides a simple interface to record your p5.js sketch into a video or image sequence by using the [`captureStream()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream) or [`toBlob`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) APIs of `HTMLCanvasElement`. No external dependencies is included or required with this library.
6
6
 
7
7
  p5.record.js starts with a set of default configuration that covers the simplest and likely most common use, while also providing additional customization options to achieve additional functionalities:
8
8
 
9
9
  * Recording at specific defined frame rate, and in supported browsers, full control over [when individual frame capture should happen](https://developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStreamTrack/requestFrame).
10
10
  * Record canvas backed objects, such as `p5.Graphics`, and HTML canvas directly.
11
11
  * Create multiple recordings at the same time.
12
+ * Record image sequence.
12
13
 
13
14
  ## Usage
14
15
  To add p5.record.js to your sketch, include the following script tag in your HTML file:
15
16
 
16
17
  ```html
17
- <script src="https://cdn.jsdelivr.net/npm/p5.record.js@0.1.2/dist/p5.record.min.js"></script>
18
+ <script src="https://cdn.jsdelivr.net/npm/p5.record.js@0.2.0/dist/p5.record.min.js"></script>
18
19
  ```
19
20
 
20
- ESM builds are also available either via CDN at https://cdn.jsdelivr.net/npm/p5.record.js@0.1.2-beta.0/dist/p5.record.esm.js or via NPM:
21
+ ESM builds are also available either via CDN at https://cdn.jsdelivr.net/npm/p5.record.js@0.2.0/dist/p5.record.esm.js or via NPM:
21
22
 
22
23
  ```sh
23
24
  npm install p5.record.js
@@ -35,7 +36,7 @@ For different use cases, please check out the `examples/` folder. The available
35
36
  ```
36
37
  * `frameRate` - (`Number|"manual"`) If `options.frameRate` is set to a string with value `"manual"`, sketch recording will automatically be tied to each call of `draw()`. This is useful when you have set the sketch to `noLoop()` and is manually calling `redraw()`, only available in supported browsers.
37
38
  * `source` - An object containing a property of `canvas` of type `HTMLCanvasElement` (eg. `p5.Graphics` instance) or an `HTMLCanvasElement`.
38
- * `mimeType` - The container and codec to be used for recording. Currently only a very small set of containers and codecs are supported natively, it is recommended to leave the default and reencode the video after downloading the result.
39
+ * `mimeType` - The container and codec to be used for recording. Currently only a very small set of containers and codecs are supported natively, it is recommended to leave the default and reencode the video after downloading the result. Set this to `image/png`, `image/jpeg`, or `image/webp` to capture image sequence instead of video, will download a zip file instead.
39
40
  * `startRecording()` - Start recording at specified frame rate.
40
41
  * `stopRecording()` - Stop recording and download the recording file.
41
42
  * `pauseRecording()` - Pause recording to be resumed later.
@@ -56,8 +57,10 @@ The library also provides a `p5.Recorder` class for multiple simultaneous record
56
57
  ## Limitations
57
58
  This library is only designed for canvas backed sketches and objects, recording of other inputs (eg. SVG) is not supported.
58
59
 
59
- Currently the recording output format is limited to VP8 encoded WebM video file, depending on use case you may need to reencode the video to a different format.
60
+ Currently the video recording output format is limited to VP8 encoded WebM video file, depending on use case you may need to reencode the video to a different format.
61
+
62
+ For image sequence recording, PNG, JPEG, and WebP formats are supported (except for Safari which does not support WebP). The image sequence will be downloaded as a Zip file with no compression applied. When capturing image sequence, you are more likely to encounter physical limits such as RAM limits or Zip file limits (archive size is currently limited to 4GB and 65,535 entries, I may implement ZIP64 later but it is a little bit less commonly supported, you may run out of RAM first anyway).
60
63
 
61
64
  ## Future features
62
65
  * Custom output video formats
63
- * Record as gif and image sequence
66
+ * Record as gif
@@ -1,137 +1 @@
1
- class Recorder {
2
- #stream;
3
- #recorder;
4
-
5
- constructor(options) {
6
- const frameRate = options.frameRate === "manual" ? 0 : options.frameRate;
7
- const chunks = [];
8
- const stream = (
9
- options.source instanceof HTMLCanvasElement ?
10
- options.source :
11
- options.source.canvas
12
- ).captureStream(frameRate);
13
- const recorder = new MediaRecorder(stream, {
14
- mimeType: options.mimeType ?? "video/webm;codecs=vp8"
15
- });
16
-
17
- recorder.addEventListener("start", (e) => {
18
- console.log("recording started");
19
- });
20
-
21
- recorder.addEventListener("stop", (e) => {
22
- const blob = new Blob(chunks);
23
-
24
- const executeDefault = typeof options?.stopCallback === "function" ?
25
- options?.stopCallback(blob) :
26
- true;
27
-
28
- if(executeDefault){
29
- const blobUrl = URL.createObjectURL(blob);
30
- const link = document.createElement("a");
31
- link.href = blobUrl;
32
- link.download = "recording.webm";
33
- link.click();
34
- }
35
- });
36
-
37
- recorder.addEventListener("dataavailable", (e) => {
38
- chunks.push(e.data);
39
- });
40
-
41
- recorder.addEventListener("pause", (e) => {
42
- console.log("recording paused");
43
- });
44
-
45
- recorder.addEventListener("resume", (e) => {
46
- console.log("recording resumed");
47
- });
48
-
49
- this.#recorder = recorder;
50
- this.#stream = stream;
51
- }
52
-
53
- get state() {
54
- return this.#recorder.state;
55
- }
56
-
57
- start() {
58
- this.#recorder.start();
59
- }
60
-
61
- stop() {
62
- this.#recorder.stop();
63
- }
64
-
65
- pause() {
66
- this.#recorder.pause();
67
- }
68
-
69
- resume() {
70
- this.#recorder.resume();
71
- }
72
-
73
- frame() {
74
- this.#stream.getVideoTracks()[0].requestFrame();
75
- }
76
- }
77
-
78
- function p5Record(p5, fn, lifecycles){
79
- let recorder, options;
80
-
81
- const p5VersionSemver = p5.VERSION.split(".")
82
- .map((n) => parseInt(n));
83
- if(!(
84
- p5VersionSemver[0] > 2 ||
85
- (p5VersionSemver[0] > 2 && p5VersionSemver[1] > 0) ||
86
- (p5VersionSemver[0] === 2 && p5VersionSemver[1] === 0 && p5VersionSemver[2] >= 3)
87
- )){
88
- console.error(`p5.record.js requires p5.js >= 2.0.3`);
89
- return;
90
- }
91
-
92
- p5.Recorder = Recorder;
93
-
94
- lifecycles.postdraw = function() {
95
- if(recorder && recorder.state === "recording" && options?.frameRate === "manual"){
96
- recorder.frame();
97
- }
98
- };
99
-
100
- fn.setRecording = function(opt) {
101
- if(opt.frameRate === "manual" && !("CanvasCaptureMediaStreamTrack" in window)){
102
- console.error("Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.");
103
- return;
104
- }
105
- options = opt;
106
- };
107
-
108
- fn.startRecording = function() {
109
- options = Object.assign({
110
- source: this.canvas,
111
- frameRate: this.getTargetFrameRate()
112
- }, options);
113
- recorder = new Recorder(options);
114
- recorder.start();
115
- };
116
-
117
- fn.stopRecording = function() {
118
- recorder.stop();
119
- };
120
-
121
- fn.pauseRecording = function() {
122
- recorder.pause();
123
- };
124
-
125
- fn.resumeRecording = function() {
126
- recorder.resume();
127
- };
128
-
129
- fn.createRecording = function(options) {
130
- return new Recorder(options);
131
- };
132
- }
133
- if(typeof p5 !== "undefined"){
134
- p5.registerAddon(p5Record);
135
- }
136
-
137
- export { Recorder, p5Record };
1
+ function e(){let e,t=[];for(let n=0;n<256;n++){e=n;for(let t=0;t<8;t++)e=e&1?3988292384^e>>>1:e>>>1;t[n]=e}return t}const t=e();function n(e,n,r,i){let a=t,o=i+r;e^=-1;for(let t=i;t<o;t++)e=e>>>8^a[(e^n[t])&255];return e^-1}const r=new TextEncoder;var i=class e{#e=[];#t=[];#n=0;constructor(){}static generateCentralDirectoryHeader(e,t){let n=new ArrayBuffer(46+e.filename.length),i=new Uint8Array(n),a=new DataView(n);a.setUint32(0,1347092738),a.setUint16(4,20,!0),a.setUint16(6,20,!0),a.setUint16(12,e.modifyTime,!0),a.setUint16(14,e.modifyDate,!0),a.setUint32(16,e.checksum,!0),a.setUint32(20,e.compressedSize,!0),a.setUint32(24,e.uncompressedSize,!0),a.setUint16(28,e.filename.length,!0),a.setUint32(42,t,!0);let o=r.encode(e.filename);return i.set(o,46),a.buffer}addFile(t){let n=e.generateCentralDirectoryHeader(t,this.#n);this.#e.push(t),this.#t.push(n),this.#n+=t.localFileHeader.byteLength,this.#n+=t.content.length}pack(){let e=this.#t.reduce((e,t)=>(e+=t.byteLength,e),0);this.#n+=e;let t=new ArrayBuffer(22),n=new DataView(t);n.setUint32(0,1347093766),n.setUint16(8,this.#e.length,!0),n.setUint16(10,this.#e.length,!0),n.setUint32(12,e,!0),n.setUint32(16,this.#n-e,!0);let r=[];for(let e of this.#e)r.push(e.localFileHeader),r.push(e.content);return r.push(...this.#t),r.push(n.buffer),new Blob(r)}},a=class{modifyTime;modifyDate;checksum;compressedSize;uncompressedSize;filename;content;constructor(e,t){this.filename=e,this.content=t;let{date:r,time:i}=o();this.modifyTime=i,this.modifyDate=r,this.checksum=n(0,t,t.length,0),this.compressedSize=t.length,this.uncompressedSize=t.length}get localFileHeader(){let e=new ArrayBuffer(30+this.filename.length),t=new Uint8Array(e),n=new DataView(e);n.setUint32(0,1347093252),n.setUint16(4,20,!0),n.setUint16(10,this.modifyTime,!0),n.setUint16(12,this.modifyDate,!0),n.setUint32(14,this.checksum,!0),n.setUint32(18,this.compressedSize,!0),n.setUint32(22,this.uncompressedSize,!0),n.setUint16(26,this.filename.length,!0);let i=r.encode(this.filename);return t.set(i,30),n.buffer}};function o(){let e=new Date,t=e.getUTCHours();t<<=6,t|=e.getUTCMinutes(),t<<=5,t|=e.getUTCSeconds()/2;let n=e.getUTCFullYear()-1980;return n<<=4,n|=e.getUTCMonth()+1,n<<=5,n|=e.getUTCDate(),{date:n,time:t}}const s={"image/png":`png`,"image/jpeg":`jpg`,"image/webp":`webp`};var c=class extends EventTarget{#e=0;#t;#n=[];#r;#i;#a;#o;#s=0;#c;state=`inactive`;constructor(e,t,n=60){super(),this.#t=e,this.#i=1e3/n,this.#c=t}start(){this.state=`recording`,this.dispatchEvent(new CustomEvent(`start`)),this.frame()}stop(){this.state=`inactive`,this.#r&&cancelAnimationFrame(this.#r);let e=Promise.all(this.#n.map(e=>e.arrayBuffer())),t=new i;e.then(e=>{for(let n=0;n<e.length;n++){let r=new a(`capture-${String(n+1).padStart(5,`0`)}.${s[this.#c]}`,new Uint8Array(e[n]));t.addFile(r)}this.dispatchEvent(new CustomEvent(`stop`,{detail:{blob:t.pack()}}))})}pause(){this.state=`paused`,this.#r&&cancelAnimationFrame(this.#r),this.dispatchEvent(new CustomEvent(`pause`))}resume(){this.state=`recording`,this.dispatchEvent(new CustomEvent(`resume`)),this.frame()}frame(e){if(this.state===`recording`){if(this.#a=e-this.#o,this.#o=e,this.#s+=this.#a||0,this.#s>=this.#i||this.#i===1/0){this.#s=0;let e=this.#e;this.#t.toBlob(t=>{this.#n[e]=t},this.#c),this.#e++}this.#i<1/0&&(this.#r=requestAnimationFrame(this.frame.bind(this)))}}},l=class{#e;#t;constructor(e){let t=e.frameRate===`manual`?0:e.frameRate,n=[],r=e.source instanceof HTMLCanvasElement?e.source:e.source.canvas,i=r.captureStream(t),a;a=Object.keys(s).includes(e.mimeType)?new c(r,e.mimeType,t):new MediaRecorder(i,{mimeType:e.mimeType??`video/webm;codecs=vp8`}),a.addEventListener(`start`,()=>{console.log(`recording started`)}),a.addEventListener(`stop`,t=>{let r,i;if(this.#t instanceof c?(r=t.detail.blob,i=`recording.zip`):(r=new Blob(n),i=`recording.webm`),typeof e?.stopCallback!=`function`||e?.stopCallback(r)){let e=URL.createObjectURL(r),t=document.createElement(`a`);t.href=e,t.download=i,t.click()}}),a.addEventListener(`dataavailable`,e=>{n.push(e.data)}),a.addEventListener(`pause`,()=>{console.log(`recording paused`)}),a.addEventListener(`resume`,()=>{console.log(`recording resumed`)}),this.#t=a,this.#e=i}get state(){return this.#t.state}start(){this.#t.start()}stop(){this.#t.stop()}pause(){this.#t.pause()}resume(){this.#t.resume()}frame(){this.#t instanceof c?this.#t.frame():this.#e.getVideoTracks()[0].requestFrame()}};function u(e,t,n){let r,i,a=e.VERSION.split(`.`).map(e=>parseInt(e));if(!(a[0]>2||a[0]===2&&a[1]>0||a[0]===2&&a[1]===0&&a[2]>=3)){console.error(`p5.record.js requires p5.js >= 2.0.3`);return}e.Recorder=l,n.postdraw=function(){r&&r.state===`recording`&&i?.frameRate===`manual`&&r.frame()},t.setRecording=function(e){if(e.frameRate===`manual`&&!(`CanvasCaptureMediaStreamTrack`in window)&&!Object.keys(s).includes(e.mimeType)){console.error(`Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.`);return}i=e},t.startRecording=function(){i=Object.assign({source:this.canvas,frameRate:this.getTargetFrameRate(),stopCallback:this._customActions?.recordingStopped},i),r=new l(i),r.start()},t.stopRecording=function(){r.stop()},t.pauseRecording=function(){r.pause()},t.resumeRecording=function(){r.resume()},t.createRecording=function(e){return new l(e)}}typeof p5<`u`&&p5.registerAddon(u);export{l as Recorder,u as p5Record};
@@ -1 +1 @@
1
- !function(e){"use strict";class r{#e;#r;constructor(e){const r="manual"===e.frameRate?0:e.frameRate,t=[],a=(e.source instanceof HTMLCanvasElement?e.source:e.source.canvas).captureStream(r),s=new MediaRecorder(a,{mimeType:e.mimeType??"video/webm;codecs=vp8"});s.addEventListener("start",e=>{console.log("recording started")}),s.addEventListener("stop",r=>{const a=new Blob(t);if("function"!=typeof e?.stopCallback||e?.stopCallback(a)){const e=URL.createObjectURL(a),r=document.createElement("a");r.href=e,r.download="recording.webm",r.click()}}),s.addEventListener("dataavailable",e=>{t.push(e.data)}),s.addEventListener("pause",e=>{console.log("recording paused")}),s.addEventListener("resume",e=>{console.log("recording resumed")}),this.#r=s,this.#e=a}get state(){return this.#r.state}start(){this.#r.start()}stop(){this.#r.stop()}pause(){this.#r.pause()}resume(){this.#r.resume()}frame(){this.#e.getVideoTracks()[0].requestFrame()}}function t(e,t,a){let s,o;const n=e.VERSION.split(".").map(e=>parseInt(e));n[0]>2||n[0]>2&&n[1]>0||2===n[0]&&0===n[1]&&n[2]>=3?(e.Recorder=r,a.postdraw=function(){s&&"recording"===s.state&&"manual"===o?.frameRate&&s.frame()},t.setRecording=function(e){"manual"!==e.frameRate||"CanvasCaptureMediaStreamTrack"in window?o=e:console.error("Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.")},t.startRecording=function(){o=Object.assign({source:this.canvas,frameRate:this.getTargetFrameRate()},o),s=new r(o),s.start()},t.stopRecording=function(){s.stop()},t.pauseRecording=function(){s.pause()},t.resumeRecording=function(){s.resume()},t.createRecording=function(e){return new r(e)}):console.error("p5.record.js requires p5.js >= 2.0.3")}"undefined"!=typeof p5&&p5.registerAddon(t),e.Recorder=r,e.p5Record=t}({});
1
+ (function(e){function t(){let e,t=[];for(let n=0;n<256;n++){e=n;for(let t=0;t<8;t++)e=e&1?3988292384^e>>>1:e>>>1;t[n]=e}return t}let n=t();function r(e,t,r,i){let a=n,o=i+r;e^=-1;for(let n=i;n<o;n++)e=e>>>8^a[(e^t[n])&255];return e^-1}let i=new TextEncoder;var a=class e{#e=[];#t=[];#n=0;constructor(){}static generateCentralDirectoryHeader(e,t){let n=new ArrayBuffer(46+e.filename.length),r=new Uint8Array(n),a=new DataView(n);a.setUint32(0,1347092738),a.setUint16(4,20,!0),a.setUint16(6,20,!0),a.setUint16(12,e.modifyTime,!0),a.setUint16(14,e.modifyDate,!0),a.setUint32(16,e.checksum,!0),a.setUint32(20,e.compressedSize,!0),a.setUint32(24,e.uncompressedSize,!0),a.setUint16(28,e.filename.length,!0),a.setUint32(42,t,!0);let o=i.encode(e.filename);return r.set(o,46),a.buffer}addFile(t){let n=e.generateCentralDirectoryHeader(t,this.#n);this.#e.push(t),this.#t.push(n),this.#n+=t.localFileHeader.byteLength,this.#n+=t.content.length}pack(){let e=this.#t.reduce((e,t)=>(e+=t.byteLength,e),0);this.#n+=e;let t=new ArrayBuffer(22),n=new DataView(t);n.setUint32(0,1347093766),n.setUint16(8,this.#e.length,!0),n.setUint16(10,this.#e.length,!0),n.setUint32(12,e,!0),n.setUint32(16,this.#n-e,!0);let r=[];for(let e of this.#e)r.push(e.localFileHeader),r.push(e.content);return r.push(...this.#t),r.push(n.buffer),new Blob(r)}},o=class{modifyTime;modifyDate;checksum;compressedSize;uncompressedSize;filename;content;constructor(e,t){this.filename=e,this.content=t;let{date:n,time:i}=s();this.modifyTime=i,this.modifyDate=n,this.checksum=r(0,t,t.length,0),this.compressedSize=t.length,this.uncompressedSize=t.length}get localFileHeader(){let e=new ArrayBuffer(30+this.filename.length),t=new Uint8Array(e),n=new DataView(e);n.setUint32(0,1347093252),n.setUint16(4,20,!0),n.setUint16(10,this.modifyTime,!0),n.setUint16(12,this.modifyDate,!0),n.setUint32(14,this.checksum,!0),n.setUint32(18,this.compressedSize,!0),n.setUint32(22,this.uncompressedSize,!0),n.setUint16(26,this.filename.length,!0);let r=i.encode(this.filename);return t.set(r,30),n.buffer}};function s(){let e=new Date,t=e.getUTCHours();t<<=6,t|=e.getUTCMinutes(),t<<=5,t|=e.getUTCSeconds()/2;let n=e.getUTCFullYear()-1980;return n<<=4,n|=e.getUTCMonth()+1,n<<=5,n|=e.getUTCDate(),{date:n,time:t}}let c={"image/png":`png`,"image/jpeg":`jpg`,"image/webp":`webp`};var l=class extends EventTarget{#e=0;#t;#n=[];#r;#i;#a;#o;#s=0;#c;state=`inactive`;constructor(e,t,n=60){super(),this.#t=e,this.#i=1e3/n,this.#c=t}start(){this.state=`recording`,this.dispatchEvent(new CustomEvent(`start`)),this.frame()}stop(){this.state=`inactive`,this.#r&&cancelAnimationFrame(this.#r);let e=Promise.all(this.#n.map(e=>e.arrayBuffer())),t=new a;e.then(e=>{for(let n=0;n<e.length;n++){let r=new o(`capture-${String(n+1).padStart(5,`0`)}.${c[this.#c]}`,new Uint8Array(e[n]));t.addFile(r)}this.dispatchEvent(new CustomEvent(`stop`,{detail:{blob:t.pack()}}))})}pause(){this.state=`paused`,this.#r&&cancelAnimationFrame(this.#r),this.dispatchEvent(new CustomEvent(`pause`))}resume(){this.state=`recording`,this.dispatchEvent(new CustomEvent(`resume`)),this.frame()}frame(e){if(this.state===`recording`){if(this.#a=e-this.#o,this.#o=e,this.#s+=this.#a||0,this.#s>=this.#i||this.#i===1/0){this.#s=0;let e=this.#e;this.#t.toBlob(t=>{this.#n[e]=t},this.#c),this.#e++}this.#i<1/0&&(this.#r=requestAnimationFrame(this.frame.bind(this)))}}},u=class{#e;#t;constructor(e){let t=e.frameRate===`manual`?0:e.frameRate,n=[],r=e.source instanceof HTMLCanvasElement?e.source:e.source.canvas,i=r.captureStream(t),a;a=Object.keys(c).includes(e.mimeType)?new l(r,e.mimeType,t):new MediaRecorder(i,{mimeType:e.mimeType??`video/webm;codecs=vp8`}),a.addEventListener(`start`,()=>{console.log(`recording started`)}),a.addEventListener(`stop`,t=>{let r,i;if(this.#t instanceof l?(r=t.detail.blob,i=`recording.zip`):(r=new Blob(n),i=`recording.webm`),typeof e?.stopCallback!=`function`||e?.stopCallback(r)){let e=URL.createObjectURL(r),t=document.createElement(`a`);t.href=e,t.download=i,t.click()}}),a.addEventListener(`dataavailable`,e=>{n.push(e.data)}),a.addEventListener(`pause`,()=>{console.log(`recording paused`)}),a.addEventListener(`resume`,()=>{console.log(`recording resumed`)}),this.#t=a,this.#e=i}get state(){return this.#t.state}start(){this.#t.start()}stop(){this.#t.stop()}pause(){this.#t.pause()}resume(){this.#t.resume()}frame(){this.#t instanceof l?this.#t.frame():this.#e.getVideoTracks()[0].requestFrame()}};function d(e,t,n){let r,i,a=e.VERSION.split(`.`).map(e=>parseInt(e));if(!(a[0]>2||a[0]===2&&a[1]>0||a[0]===2&&a[1]===0&&a[2]>=3)){console.error(`p5.record.js requires p5.js >= 2.0.3`);return}e.Recorder=u,n.postdraw=function(){r&&r.state===`recording`&&i?.frameRate===`manual`&&r.frame()},t.setRecording=function(e){if(e.frameRate===`manual`&&!(`CanvasCaptureMediaStreamTrack`in window)&&!Object.keys(c).includes(e.mimeType)){console.error(`Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.`);return}i=e},t.startRecording=function(){i=Object.assign({source:this.canvas,frameRate:this.getTargetFrameRate(),stopCallback:this._customActions?.recordingStopped},i),r=new u(i),r.start()},t.stopRecording=function(){r.stop()},t.pauseRecording=function(){r.pause()},t.resumeRecording=function(){r.resume()},t.createRecording=function(e){return new u(e)}}return typeof p5<`u`&&p5.registerAddon(d),e.Recorder=u,e.p5Record=d,e})({});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "p5.record.js",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "p5.js addon providing functions to record HTML canvas based sketches",
5
5
  "homepage": "https://github.com/limzykenneth/p5.record.js",
6
6
  "bugs": "https://github.com/limzykenneth/p5.record.js/issues",
@@ -26,12 +26,11 @@
26
26
  },
27
27
  "browser": "./dist/p5.record.min.js",
28
28
  "scripts": {
29
- "build": "rollup -c rollup.config.js",
30
- "dev": "rollup -c rollup.config.js --watch",
29
+ "build": "rolldown -c rolldown.config.ts",
30
+ "dev": "rolldown -c rolldown.config.ts --watch",
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "devDependencies": {
34
- "@rollup/plugin-terser": "^0.4.4",
35
- "rollup": "^4.41.1"
34
+ "rolldown": "^1.0.0-beta.55"
36
35
  }
37
36
  }
@@ -0,0 +1,105 @@
1
+ import { SequenceRecorder, imageTypesMap } from "./SequenceRecorder";
2
+
3
+ interface RecorderOptions {
4
+ frameRate: "manual" | number,
5
+ source: HTMLCanvasElement | {
6
+ canvas: HTMLCanvasElement
7
+ },
8
+ mimeType?: string,
9
+ stopCallback?: (blob: Blob) => void
10
+ }
11
+
12
+ export class Recorder {
13
+ #stream: MediaStream;
14
+ #recorder: MediaRecorder | SequenceRecorder;
15
+
16
+ constructor(options: RecorderOptions) {
17
+ const frameRate = options.frameRate === "manual" ? 0 : options.frameRate;
18
+ const chunks = [];
19
+ const canvas = (
20
+ options.source instanceof HTMLCanvasElement ?
21
+ options.source :
22
+ options.source.canvas
23
+ );
24
+ const stream = canvas.captureStream(frameRate);
25
+ let recorder: MediaRecorder | SequenceRecorder;
26
+ if(Object.keys(imageTypesMap).includes(options.mimeType)){
27
+ recorder = new SequenceRecorder(canvas, options.mimeType, frameRate);
28
+ }else{
29
+ recorder = new MediaRecorder(stream, {
30
+ mimeType: options.mimeType ?? "video/webm;codecs=vp8"
31
+ });
32
+ }
33
+
34
+ recorder.addEventListener("start", () => {
35
+ console.log("recording started");
36
+ });
37
+
38
+ recorder.addEventListener("stop", (e: Event) => {
39
+ let blob: Blob, filename: string;
40
+
41
+ if(this.#recorder instanceof SequenceRecorder){
42
+ blob = (e as CustomEvent).detail.blob;
43
+ filename = "recording.zip";
44
+ }else{
45
+ blob = new Blob(chunks);
46
+ filename = "recording.webm";
47
+ }
48
+
49
+ const executeDefault = typeof options?.stopCallback === "function" ?
50
+ options?.stopCallback(blob) :
51
+ true;
52
+
53
+ if(executeDefault){
54
+ const blobUrl = URL.createObjectURL(blob);
55
+ const link = document.createElement("a");
56
+ link.href = blobUrl;
57
+ link.download = filename;
58
+ link.click();
59
+ }
60
+ });
61
+
62
+ (recorder as MediaRecorder).addEventListener("dataavailable", (e: BlobEvent) => {
63
+ chunks.push(e.data);
64
+ });
65
+
66
+ recorder.addEventListener("pause", () => {
67
+ console.log("recording paused");
68
+ });
69
+
70
+ recorder.addEventListener("resume", () => {
71
+ console.log("recording resumed");
72
+ });
73
+
74
+ this.#recorder = recorder;
75
+ this.#stream = stream;
76
+ }
77
+
78
+ get state() {
79
+ return this.#recorder.state;
80
+ }
81
+
82
+ start() {
83
+ this.#recorder.start();
84
+ }
85
+
86
+ stop() {
87
+ this.#recorder.stop();
88
+ }
89
+
90
+ pause() {
91
+ this.#recorder.pause();
92
+ }
93
+
94
+ resume() {
95
+ this.#recorder.resume();
96
+ }
97
+
98
+ frame() {
99
+ if(this.#recorder instanceof SequenceRecorder){
100
+ this.#recorder.frame();
101
+ }else{
102
+ (this.#stream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack).requestFrame();
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,100 @@
1
+ import { Zip, FileEntry } from "./Zip";
2
+
3
+ export const imageTypesMap = {
4
+ "image/png": "png",
5
+ "image/jpeg": "jpg",
6
+ "image/webp": "webp"
7
+ };
8
+
9
+ export class SequenceRecorder extends EventTarget {
10
+ #counter = 0;
11
+ #source: HTMLCanvasElement;
12
+ #images = [];
13
+ #animationFrame: number;
14
+ #timePerFrame: number;
15
+ #deltaTime: number;
16
+ #previousTimestamp: number;
17
+ #accumulator = 0;
18
+ #mimeType: keyof typeof imageTypesMap;
19
+ state = "inactive";
20
+
21
+ constructor(
22
+ source: HTMLCanvasElement,
23
+ mimeType: keyof typeof imageTypesMap,
24
+ frameRate = 60
25
+ ) {
26
+ super();
27
+ this.#source = source;
28
+ this.#timePerFrame = 1000 / frameRate;
29
+ this.#mimeType = mimeType;
30
+ }
31
+
32
+ start() {
33
+ this.state = "recording";
34
+ this.dispatchEvent(new CustomEvent("start"));
35
+ this.frame();
36
+ }
37
+
38
+ stop() {
39
+ this.state = "inactive";
40
+ if(this.#animationFrame){
41
+ cancelAnimationFrame(this.#animationFrame);
42
+ }
43
+
44
+ const buffersPromise = Promise.all(this.#images.map((image) => {
45
+ return image.arrayBuffer();
46
+ }));
47
+
48
+ const zip = new Zip();
49
+ buffersPromise.then((buffers) => {
50
+ for(let i=0; i<buffers.length; i++){
51
+ const file = new FileEntry(
52
+ `capture-${String(i+1).padStart(5, "0")}.${imageTypesMap[this.#mimeType]}`,
53
+ new Uint8Array(buffers[i])
54
+ );
55
+ zip.addFile(file);
56
+ }
57
+
58
+ this.dispatchEvent(new CustomEvent("stop", {
59
+ detail: {
60
+ blob: zip.pack()
61
+ }
62
+ }));
63
+ });
64
+ }
65
+
66
+ pause() {
67
+ this.state = "paused";
68
+ if(this.#animationFrame){
69
+ cancelAnimationFrame(this.#animationFrame);
70
+ }
71
+ this.dispatchEvent(new CustomEvent("pause"));
72
+ }
73
+
74
+ resume() {
75
+ this.state = "recording";
76
+ this.dispatchEvent(new CustomEvent("resume"));
77
+ this.frame();
78
+ }
79
+
80
+ frame(timestamp?: DOMHighResTimeStamp) {
81
+ if(this.state === "recording"){
82
+ this.#deltaTime = timestamp - this.#previousTimestamp;
83
+ this.#previousTimestamp = timestamp;
84
+ this.#accumulator += this.#deltaTime || 0;
85
+
86
+ if(this.#accumulator >= this.#timePerFrame || this.#timePerFrame === Number.POSITIVE_INFINITY){
87
+ this.#accumulator = 0;
88
+ let c = this.#counter;
89
+ this.#source.toBlob((blob) => {
90
+ this.#images[c] = blob;
91
+ }, this.#mimeType);
92
+ this.#counter++;
93
+ }
94
+
95
+ if(this.#timePerFrame < Number.POSITIVE_INFINITY){
96
+ this.#animationFrame = requestAnimationFrame(this.frame.bind(this));
97
+ }
98
+ }
99
+ }
100
+ }
package/src/Zip.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { crc32 } from "./crc32";
2
+
3
+ const textEncoder = new TextEncoder();
4
+
5
+ export class Zip {
6
+ #files = [];
7
+ #centralDirectories = [];
8
+ #currentOffset = 0;
9
+
10
+ constructor(){}
11
+
12
+ static generateCentralDirectoryHeader(
13
+ file: FileEntry,
14
+ localFileHeaderOffset: number
15
+ ): ArrayBuffer {
16
+ const buf = new ArrayBuffer(46 + file.filename.length);
17
+ const arr = new Uint8Array(buf);
18
+ const view = new DataView(buf);
19
+
20
+ // Set Magic Number
21
+ view.setUint32(0, 1347092738);
22
+ // Set Version
23
+ view.setUint16(4, 20, true);
24
+ // Set Version extract
25
+ view.setUint16(6, 20, true);
26
+ // Set last modify time
27
+ view.setUint16(12, file.modifyTime, true);
28
+ // Set last modify date
29
+ view.setUint16(14, file.modifyDate, true);
30
+ // Set CRC-32
31
+ view.setUint32(16, file.checksum, true);
32
+ // Set Compressed size
33
+ view.setUint32(20, file.compressedSize, true);
34
+ // Set Uncompressed size
35
+ view.setUint32(24, file.uncompressedSize, true);
36
+ // Set file name length
37
+ view.setUint16(28, file.filename.length, true);
38
+ // Set relative offset
39
+ view.setUint32(42, localFileHeaderOffset, true);
40
+ // Set file name
41
+ const filenameBytes = textEncoder.encode(file.filename);
42
+ arr.set(filenameBytes, 46);
43
+
44
+ return view.buffer;
45
+ }
46
+
47
+ addFile(file: FileEntry){
48
+ const centralDirectory = Zip.generateCentralDirectoryHeader(file, this.#currentOffset);
49
+ this.#files.push(file);
50
+
51
+ this.#centralDirectories.push(centralDirectory);
52
+ this.#currentOffset += file.localFileHeader.byteLength;
53
+ this.#currentOffset += file.content.length;
54
+ }
55
+
56
+ pack(){
57
+ // Calcualte needed buffer size
58
+ const centralDirectoriesSize = this.#centralDirectories.reduce((acc, data) => {
59
+ acc += data.byteLength
60
+ return acc;
61
+ }, 0);
62
+ this.#currentOffset += centralDirectoriesSize;
63
+
64
+ // Calculate end of central directory record
65
+ const buf = new ArrayBuffer(22);
66
+ const view = new DataView(buf);
67
+ // Set Magic Number
68
+ view.setUint32(0, 1347093766);
69
+ // Set central directory number
70
+ view.setUint16(8, this.#files.length, true);
71
+ // Set total central directory number
72
+ view.setUint16(10, this.#files.length, true);
73
+ // Set size of central directory in byes
74
+ view.setUint32(12, centralDirectoriesSize, true); // VARIABLE
75
+ // Set offset of central directory
76
+ view.setUint32(16, this.#currentOffset-centralDirectoriesSize, true); // VARIABLE
77
+
78
+ // Put all components into blob and return
79
+ const items = [];
80
+ // Insert files
81
+ for(const file of this.#files){
82
+ items.push(file.localFileHeader);
83
+ items.push(file.content);
84
+ }
85
+
86
+ // Insert central directory
87
+ items.push(...this.#centralDirectories);
88
+
89
+ // Insert end of central directory
90
+ items.push(view.buffer);
91
+
92
+ return new Blob(items);
93
+ }
94
+ }
95
+
96
+ export class FileEntry {
97
+ modifyTime: number;
98
+ modifyDate: number;
99
+ checksum: number;
100
+ compressedSize: number;
101
+ uncompressedSize: number;
102
+ filename: string;
103
+ content: Uint8Array;
104
+
105
+ constructor(filename: string, content: Uint8Array){
106
+ this.filename = filename;
107
+ this.content = content;
108
+ const { date, time } = getDosDateTime();
109
+ this.modifyTime = time;
110
+ this.modifyDate = date;
111
+ this.checksum = crc32(0, content, content.length, 0);
112
+ this.compressedSize = content.length;
113
+ this.uncompressedSize = content.length;
114
+ }
115
+
116
+ get localFileHeader(){
117
+ const buf = new ArrayBuffer(30 + this.filename.length);
118
+ const arr = new Uint8Array(buf);
119
+ const view = new DataView(buf);
120
+
121
+ // Set Magic Number
122
+ view.setUint32(0, 1347093252);
123
+ // Set Version extract
124
+ view.setUint16(4, 20, true);
125
+ // Set last modify time
126
+ view.setUint16(10, this.modifyTime, true);
127
+ // Set last modify date
128
+ view.setUint16(12, this.modifyDate, true);
129
+ // Set CRC-32
130
+ view.setUint32(14, this.checksum, true);
131
+ // Set Compressed size
132
+ view.setUint32(18, this.compressedSize, true);
133
+ // Set Uncompressed size
134
+ view.setUint32(22, this.uncompressedSize, true);
135
+ // Set file name length
136
+ view.setUint16(26, this.filename.length, true);
137
+ // Set file name
138
+ const filenameBytes = textEncoder.encode(this.filename);
139
+ arr.set(filenameBytes, 30);
140
+
141
+ return view.buffer;
142
+ }
143
+ }
144
+
145
+ function getDosDateTime(): {date: number, time: number}{
146
+ const date = new Date();
147
+
148
+ let dosTime = date.getUTCHours();
149
+ dosTime = dosTime << 6;
150
+ dosTime = dosTime | date.getUTCMinutes();
151
+ dosTime = dosTime << 5;
152
+ dosTime = dosTime | date.getUTCSeconds() / 2;
153
+
154
+ let dosDate = date.getUTCFullYear() - 1980;
155
+ dosDate = dosDate << 4;
156
+ dosDate = dosDate | (date.getUTCMonth() + 1);
157
+ dosDate = dosDate << 5;
158
+ dosDate = dosDate | date.getUTCDate();
159
+
160
+ return {
161
+ date: dosDate,
162
+ time: dosTime
163
+ };
164
+ }
package/src/crc32.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Adapted from JSZip (https://github.com/Stuk/jszip/blob/643714aa770afd8fe1df6cfc7e2bde945bb0ef64/lib/crc32.js).
3
+ * While JSZip adapted from Pako (https://github.com/nodeca/pako/blob/master/lib/zlib/crc32.js).
4
+ * While at the same time StackOverflow user Alex had very similar implementation at an earlier
5
+ * date (https://stackoverflow.com/questions/18638900/javascript-crc32/18639999#18639999).
6
+ *
7
+ * Do I know who ultimately created this implementation?
8
+ * No, it may be one of the above, it may be none of the above. I have slightly
9
+ * different needs and priorities for this CRC-32 implementation than them so
10
+ * I may also adapt accordingly.
11
+ *
12
+ * You are free to draw your own conclusions.
13
+ */
14
+
15
+ function makeTable() {
16
+ let c: number, table = [];
17
+
18
+ for(let n =0; n < 256; n++){
19
+ c = n;
20
+ for(let k =0; k < 8; k++){
21
+ c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
22
+ }
23
+ table[n] = c;
24
+ }
25
+
26
+ return table;
27
+ }
28
+
29
+ // Create table on load. Just 255 signed longs. Not a problem.
30
+ const crcTable = makeTable();
31
+
32
+ export function crc32(crc: number, buf: number, len: number, pos: number): number {
33
+ const t = crcTable, end = pos + len;
34
+
35
+ crc = crc ^ (-1);
36
+
37
+ for (let i = pos; i < end; i++ ) {
38
+ crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF];
39
+ }
40
+
41
+ return (crc ^ (-1)); // >>> 0;
42
+ }
@@ -1,4 +1,5 @@
1
- import { Recorder } from "./Recorder.js";
1
+ import { Recorder } from "./Recorder";
2
+ import { imageTypesMap } from "./SequenceRecorder";
2
3
 
3
4
  export function p5Record(p5, fn, lifecycles){
4
5
  let recorder, options;
@@ -7,7 +8,7 @@ export function p5Record(p5, fn, lifecycles){
7
8
  .map((n) => parseInt(n));
8
9
  if(!(
9
10
  p5VersionSemver[0] > 2 ||
10
- (p5VersionSemver[0] > 2 && p5VersionSemver[1] > 0) ||
11
+ (p5VersionSemver[0] === 2 && p5VersionSemver[1] > 0) ||
11
12
  (p5VersionSemver[0] === 2 && p5VersionSemver[1] === 0 && p5VersionSemver[2] >= 3)
12
13
  )){
13
14
  console.error(`p5.record.js requires p5.js >= 2.0.3`);
@@ -23,7 +24,13 @@ export function p5Record(p5, fn, lifecycles){
23
24
  };
24
25
 
25
26
  fn.setRecording = function(opt) {
26
- if(opt.frameRate === "manual" && !("CanvasCaptureMediaStreamTrack" in window)){
27
+ if(
28
+ opt.frameRate === "manual" &&
29
+ (
30
+ !("CanvasCaptureMediaStreamTrack" in window) &&
31
+ !Object.keys(imageTypesMap).includes(opt.mimeType)
32
+ )
33
+ ){
27
34
  console.error("Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.");
28
35
  return;
29
36
  }
@@ -33,7 +40,8 @@ export function p5Record(p5, fn, lifecycles){
33
40
  fn.startRecording = function() {
34
41
  options = Object.assign({
35
42
  source: this.canvas,
36
- frameRate: this.getTargetFrameRate()
43
+ frameRate: this.getTargetFrameRate(),
44
+ stopCallback: this._customActions?.recordingStopped
37
45
  }, options);
38
46
  recorder = new Recorder(options);
39
47
  recorder.start();
package/src/utils.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function buf2hex(buffer: ArrayBuffer): string {
2
+ return [...new Uint8Array(buffer)]
3
+ .map(x => x.toString(16).padStart(2, '0'))
4
+ .join('');
5
+ }
package/src/Recorder.js DELETED
@@ -1,76 +0,0 @@
1
- export class Recorder {
2
- #stream;
3
- #recorder;
4
-
5
- constructor(options) {
6
- const frameRate = options.frameRate === "manual" ? 0 : options.frameRate;
7
- const chunks = [];
8
- const stream = (
9
- options.source instanceof HTMLCanvasElement ?
10
- options.source :
11
- options.source.canvas
12
- ).captureStream(frameRate);
13
- const recorder = new MediaRecorder(stream, {
14
- mimeType: options.mimeType ?? "video/webm;codecs=vp8"
15
- });
16
-
17
- recorder.addEventListener("start", (e) => {
18
- console.log("recording started");
19
- });
20
-
21
- recorder.addEventListener("stop", (e) => {
22
- const blob = new Blob(chunks);
23
-
24
- const executeDefault = typeof options?.stopCallback === "function" ?
25
- options?.stopCallback(blob) :
26
- true;
27
-
28
- if(executeDefault){
29
- const blobUrl = URL.createObjectURL(blob);
30
- const link = document.createElement("a");
31
- link.href = blobUrl;
32
- link.download = "recording.webm";
33
- link.click();
34
- }
35
- });
36
-
37
- recorder.addEventListener("dataavailable", (e) => {
38
- chunks.push(e.data);
39
- });
40
-
41
- recorder.addEventListener("pause", (e) => {
42
- console.log("recording paused");
43
- });
44
-
45
- recorder.addEventListener("resume", (e) => {
46
- console.log("recording resumed");
47
- });
48
-
49
- this.#recorder = recorder;
50
- this.#stream = stream;
51
- }
52
-
53
- get state() {
54
- return this.#recorder.state;
55
- }
56
-
57
- start() {
58
- this.#recorder.start();
59
- }
60
-
61
- stop() {
62
- this.#recorder.stop();
63
- }
64
-
65
- pause() {
66
- this.#recorder.pause();
67
- }
68
-
69
- resume() {
70
- this.#recorder.resume();
71
- }
72
-
73
- frame() {
74
- this.#stream.getVideoTracks()[0].requestFrame();
75
- }
76
- }
@@ -1,67 +0,0 @@
1
- class SequenceRecorder extends EventTarget {
2
- #counter = 0;
3
- #source;
4
- #images = [];
5
- #animationFrame;
6
- #timePerFrame;
7
- #accumulator;
8
- state = "inactive";
9
-
10
- constructor(source, frameRate = 60) {
11
- this.#source = source;
12
- this.#timePerFrame = 1000 / frameRate;
13
- }
14
-
15
- start() {
16
- state = "recording";
17
- this.dispatchEvent(new Event("start"));
18
- this.frame();
19
- }
20
-
21
- stop() {
22
- state = "inactive";
23
- if(this.#animationFrame){
24
- cancelAnimationFrame(this.#animationFrame);
25
- }
26
-
27
- // NOTE: Compression Streams API creates raw compressed streams.
28
- // Need some implementation to makie it zip file, probably headers
29
- // const compressor = new CompressionStream("deflate");
30
- // for(let image of this.#images){
31
- // image.stream().pipeThrough(compressor);
32
- // }
33
-
34
- this.dispatchEvent(new Event("stop"));
35
- }
36
-
37
- pause() {
38
- state = "paused";
39
- if(this.#animationFrame){
40
- cancelAnimationFrame(this.#animationFrame);
41
- }
42
- this.dispatchEvent(new Event("pause"));
43
- }
44
-
45
- resume() {
46
- state = "recording";
47
- this.dispatchEvent(new Event("resume"));
48
- this.frame();
49
- }
50
-
51
- frame(deltaTime) {
52
- let c = this.#counter;
53
- this.#source.toBlob((blob) => {
54
- this.#images[c] = blob;
55
- });
56
- this.#counter++;
57
-
58
- if(state === "recording"){
59
- this.#accumulator += deltaTime;
60
- if(this.accumulator >= this.#timePerFrame){
61
- this.#animationFrame = requestAnimationFrame(this.frame);
62
- }else{
63
- this.#animationFrame = requestAnimationFrame(() => {});
64
- }
65
- }
66
- }
67
- }