p5.record.js 0.1.2 → 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/README.md +8 -5
- package/dist/p5.record.esm.js +1 -137
- package/dist/p5.record.min.js +1 -1
- package/package.json +1 -1
- package/src/Recorder.js +28 -8
- package/src/SequenceRecorder.js +58 -29
- package/src/Zip.js +161 -0
- package/src/crc32.js +42 -0
- package/src/main.js +8 -1
- package/src/utils.js +5 -0
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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:
|
|
@@ -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
|
|
66
|
+
* Record as gif
|
package/dist/p5.record.esm.js
CHANGED
|
@@ -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
|
+
const e=function(){let e,t=[];for(let s=0;s<256;s++){e=s;for(let t=0;t<8;t++)e=1&e?3988292384^e>>>1:e>>>1;t[s]=e}return t}();const t=new TextEncoder;class s{#e=[];#t=[];#s=0;constructor(){}static generateCentralDirectoryHeader(e,s){const i=new ArrayBuffer(46+e.filename.length),r=new Uint8Array(i),n=new DataView(i);n.setUint32(0,1347092738),n.setUint16(4,20,!0),n.setUint16(6,20,!0),n.setUint16(12,e.modifyTime,!0),n.setUint16(14,e.modifyDate,!0),n.setUint32(16,e.checksum,!0),n.setUint32(20,e.compressedSize,!0),n.setUint32(24,e.uncompressedSize,!0),n.setUint16(28,e.filename.length,!0),n.setUint32(42,s,!0);const a=t.encode(e.filename);return r.set(a,46),n.buffer}addFile(e){const t=s.generateCentralDirectoryHeader(e,this.#s);this.#e.push(e),this.#t.push(t),this.#s+=e.localFileHeader.byteLength,this.#s+=e.content.length}pack(){const e=this.#t.reduce((e,t)=>e+=t.byteLength,0);this.#s+=e;const t=new ArrayBuffer(22),s=new DataView(t);s.setUint32(0,1347093766),s.setUint16(8,this.#e.length,!0),s.setUint16(10,this.#e.length,!0),s.setUint32(12,e,!0),s.setUint32(16,this.#s-e,!0);const i=[];for(const e of this.#e)i.push(e.localFileHeader),i.push(e.content);return i.push(...this.#t),i.push(s.buffer),new Blob(i)}}class i{modifyTime;modifyDate;checksum;compressedSize;uncompressedSize;filename;content;constructor(t,s){this.filename=t,this.content=s;const{date:i,time:r}=function(){const e=new Date;let t=e.getUTCHours();t<<=6,t|=e.getUTCMinutes(),t<<=5,t|=e.getUTCSeconds()/2;let s=e.getUTCFullYear()-1980;return s<<=4,s|=e.getUTCMonth()+1,s<<=5,s|=e.getUTCDate(),{date:s,time:t}}();this.modifyTime=r,this.modifyDate=i,this.checksum=function(t,s,i,r){const n=e,a=r+i;t^=-1;for(let e=r;e<a;e++)t=t>>>8^n[255&(t^s[e])];return-1^t}(0,s,s.length,0),this.compressedSize=s.length,this.uncompressedSize=s.length}get localFileHeader(){const e=new ArrayBuffer(30+this.filename.length),s=new Uint8Array(e),i=new DataView(e);i.setUint32(0,1347093252),i.setUint16(4,20,!0),i.setUint16(10,this.modifyTime,!0),i.setUint16(12,this.modifyDate,!0),i.setUint32(14,this.checksum,!0),i.setUint32(18,this.compressedSize,!0),i.setUint32(22,this.uncompressedSize,!0),i.setUint16(26,this.filename.length,!0);const r=t.encode(this.filename);return s.set(r,30),i.buffer}}const r={"image/png":"png","image/jpeg":"jpg","image/webp":"webp"};class n extends EventTarget{#i=0;#r;#n=[];#a;#o;#c;#m;#u=0;#h;state="inactive";constructor(e,t,s=60){super(),this.#r=e,this.#o=1e3/s,this.#h=t}start(){this.state="recording",this.dispatchEvent(new CustomEvent("start")),this.frame()}stop(){this.state="inactive",this.#a&&cancelAnimationFrame(this.#a);const e=Promise.all(this.#n.map(e=>e.arrayBuffer())),t=new s;e.then(e=>{for(let s=0;s<e.length;s++){const n=new i(`capture-${String(s+1).padStart(5,"0")}.${r[this.#h]}`,new Uint8Array(e[s]));t.addFile(n)}this.dispatchEvent(new CustomEvent("stop",{detail:{blob:t.pack()}}))})}pause(){this.state="paused",this.#a&&cancelAnimationFrame(this.#a),this.dispatchEvent(new CustomEvent("pause"))}resume(){this.state="recording",this.dispatchEvent(new CustomEvent("resume")),this.frame()}frame(e){if("recording"===this.state){if(this.#c=e-this.#m,this.#m=e,this.#u+=this.#c||0,this.#u>=this.#o||this.#o===Number.POSITIVE_INFINITY){this.#u=0;let e=this.#i;this.#r.toBlob(t=>{this.#n[e]=t},this.#h),this.#i++}this.#o<Number.POSITIVE_INFINITY&&(this.#a=requestAnimationFrame(this.frame.bind(this)))}}}class a{#d;#l;constructor(e){const t="manual"===e.frameRate?0:e.frameRate,s=[],i=e.source instanceof HTMLCanvasElement?e.source:e.source.canvas,a=i.captureStream(t);let o;o=Object.keys(r).includes(e.mimeType)?new n(i,e.mimeType,t):new MediaRecorder(a,{mimeType:e.mimeType??"video/webm;codecs=vp8"}),o.addEventListener("start",e=>{console.log("recording started")}),o.addEventListener("stop",t=>{let i,r;this.#l instanceof n?(i=t.detail.blob,r="recording.zip"):(i=new Blob(s),r="recording.webm");if("function"!=typeof e?.stopCallback||e?.stopCallback(i)){const e=URL.createObjectURL(i),t=document.createElement("a");t.href=e,t.download=r,t.click()}}),o.addEventListener("dataavailable",e=>{s.push(e.data)}),o.addEventListener("pause",e=>{console.log("recording paused")}),o.addEventListener("resume",e=>{console.log("recording resumed")}),this.#l=o,this.#d=a}get state(){return this.#l.state}start(){this.#l.start()}stop(){this.#l.stop()}pause(){this.#l.pause()}resume(){this.#l.resume()}frame(){this.#l instanceof n?this.#l.frame():this.#d.getVideoTracks()[0].requestFrame()}}function o(e,t,s){let i,n;const o=e.VERSION.split(".").map(e=>parseInt(e));o[0]>2||o[0]>2&&o[1]>0||2===o[0]&&0===o[1]&&o[2]>=3?(e.Recorder=a,s.postdraw=function(){i&&"recording"===i.state&&"manual"===n?.frameRate&&i.frame()},t.setRecording=function(e){"manual"!==e.frameRate||"CanvasCaptureMediaStreamTrack"in window||Object.keys(r).includes(e.mimeType)?n=e:console.error("Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.")},t.startRecording=function(){n=Object.assign({source:this.canvas,frameRate:this.getTargetFrameRate()},n),i=new a(n),i.start()},t.stopRecording=function(){i.stop()},t.pauseRecording=function(){i.pause()},t.resumeRecording=function(){i.resume()},t.createRecording=function(e){return new a(e)}):console.error("p5.record.js requires p5.js >= 2.0.3")}"undefined"!=typeof p5&&p5.registerAddon(o);export{a as Recorder,o as p5Record};
|
package/dist/p5.record.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(e){"use strict";class
|
|
1
|
+
!function(e){"use strict";const t=function(){let e,t=[];for(let s=0;s<256;s++){e=s;for(let t=0;t<8;t++)e=1&e?3988292384^e>>>1:e>>>1;t[s]=e}return t}();const s=new TextEncoder;class i{#e=[];#t=[];#s=0;constructor(){}static generateCentralDirectoryHeader(e,t){const i=new ArrayBuffer(46+e.filename.length),r=new Uint8Array(i),n=new DataView(i);n.setUint32(0,1347092738),n.setUint16(4,20,!0),n.setUint16(6,20,!0),n.setUint16(12,e.modifyTime,!0),n.setUint16(14,e.modifyDate,!0),n.setUint32(16,e.checksum,!0),n.setUint32(20,e.compressedSize,!0),n.setUint32(24,e.uncompressedSize,!0),n.setUint16(28,e.filename.length,!0),n.setUint32(42,t,!0);const a=s.encode(e.filename);return r.set(a,46),n.buffer}addFile(e){const t=i.generateCentralDirectoryHeader(e,this.#s);this.#e.push(e),this.#t.push(t),this.#s+=e.localFileHeader.byteLength,this.#s+=e.content.length}pack(){const e=this.#t.reduce((e,t)=>e+=t.byteLength,0);this.#s+=e;const t=new ArrayBuffer(22),s=new DataView(t);s.setUint32(0,1347093766),s.setUint16(8,this.#e.length,!0),s.setUint16(10,this.#e.length,!0),s.setUint32(12,e,!0),s.setUint32(16,this.#s-e,!0);const i=[];for(const e of this.#e)i.push(e.localFileHeader),i.push(e.content);return i.push(...this.#t),i.push(s.buffer),new Blob(i)}}class r{modifyTime;modifyDate;checksum;compressedSize;uncompressedSize;filename;content;constructor(e,s){this.filename=e,this.content=s;const{date:i,time:r}=function(){const e=new Date;let t=e.getUTCHours();t<<=6,t|=e.getUTCMinutes(),t<<=5,t|=e.getUTCSeconds()/2;let s=e.getUTCFullYear()-1980;return s<<=4,s|=e.getUTCMonth()+1,s<<=5,s|=e.getUTCDate(),{date:s,time:t}}();this.modifyTime=r,this.modifyDate=i,this.checksum=function(e,s,i,r){const n=t,a=r+i;e^=-1;for(let t=r;t<a;t++)e=e>>>8^n[255&(e^s[t])];return-1^e}(0,s,s.length,0),this.compressedSize=s.length,this.uncompressedSize=s.length}get localFileHeader(){const e=new ArrayBuffer(30+this.filename.length),t=new Uint8Array(e),i=new DataView(e);i.setUint32(0,1347093252),i.setUint16(4,20,!0),i.setUint16(10,this.modifyTime,!0),i.setUint16(12,this.modifyDate,!0),i.setUint32(14,this.checksum,!0),i.setUint32(18,this.compressedSize,!0),i.setUint32(22,this.uncompressedSize,!0),i.setUint16(26,this.filename.length,!0);const r=s.encode(this.filename);return t.set(r,30),i.buffer}}const n={"image/png":"png","image/jpeg":"jpg","image/webp":"webp"};class a extends EventTarget{#i=0;#r;#n=[];#a;#o;#c;#m;#u=0;#h;state="inactive";constructor(e,t,s=60){super(),this.#r=e,this.#o=1e3/s,this.#h=t}start(){this.state="recording",this.dispatchEvent(new CustomEvent("start")),this.frame()}stop(){this.state="inactive",this.#a&&cancelAnimationFrame(this.#a);const e=Promise.all(this.#n.map(e=>e.arrayBuffer())),t=new i;e.then(e=>{for(let s=0;s<e.length;s++){const i=new r(`capture-${String(s+1).padStart(5,"0")}.${n[this.#h]}`,new Uint8Array(e[s]));t.addFile(i)}this.dispatchEvent(new CustomEvent("stop",{detail:{blob:t.pack()}}))})}pause(){this.state="paused",this.#a&&cancelAnimationFrame(this.#a),this.dispatchEvent(new CustomEvent("pause"))}resume(){this.state="recording",this.dispatchEvent(new CustomEvent("resume")),this.frame()}frame(e){if("recording"===this.state){if(this.#c=e-this.#m,this.#m=e,this.#u+=this.#c||0,this.#u>=this.#o||this.#o===Number.POSITIVE_INFINITY){this.#u=0;let e=this.#i;this.#r.toBlob(t=>{this.#n[e]=t},this.#h),this.#i++}this.#o<Number.POSITIVE_INFINITY&&(this.#a=requestAnimationFrame(this.frame.bind(this)))}}}class o{#d;#l;constructor(e){const t="manual"===e.frameRate?0:e.frameRate,s=[],i=e.source instanceof HTMLCanvasElement?e.source:e.source.canvas,r=i.captureStream(t);let o;o=Object.keys(n).includes(e.mimeType)?new a(i,e.mimeType,t):new MediaRecorder(r,{mimeType:e.mimeType??"video/webm;codecs=vp8"}),o.addEventListener("start",e=>{console.log("recording started")}),o.addEventListener("stop",t=>{let i,r;this.#l instanceof a?(i=t.detail.blob,r="recording.zip"):(i=new Blob(s),r="recording.webm");if("function"!=typeof e?.stopCallback||e?.stopCallback(i)){const e=URL.createObjectURL(i),t=document.createElement("a");t.href=e,t.download=r,t.click()}}),o.addEventListener("dataavailable",e=>{s.push(e.data)}),o.addEventListener("pause",e=>{console.log("recording paused")}),o.addEventListener("resume",e=>{console.log("recording resumed")}),this.#l=o,this.#d=r}get state(){return this.#l.state}start(){this.#l.start()}stop(){this.#l.stop()}pause(){this.#l.pause()}resume(){this.#l.resume()}frame(){this.#l instanceof a?this.#l.frame():this.#d.getVideoTracks()[0].requestFrame()}}function c(e,t,s){let i,r;const a=e.VERSION.split(".").map(e=>parseInt(e));a[0]>2||a[0]>2&&a[1]>0||2===a[0]&&0===a[1]&&a[2]>=3?(e.Recorder=o,s.postdraw=function(){i&&"recording"===i.state&&"manual"===r?.frameRate&&i.frame()},t.setRecording=function(e){"manual"!==e.frameRate||"CanvasCaptureMediaStreamTrack"in window||Object.keys(n).includes(e.mimeType)?r=e:console.error("Your browser does not support directly specifying frame capture timing with { frameRate: 'manual' }.")},t.startRecording=function(){r=Object.assign({source:this.canvas,frameRate:this.getTargetFrameRate()},r),i=new o(r),i.start()},t.stopRecording=function(){i.stop()},t.pauseRecording=function(){i.pause()},t.resumeRecording=function(){i.resume()},t.createRecording=function(e){return new o(e)}):console.error("p5.record.js requires p5.js >= 2.0.3")}"undefined"!=typeof p5&&p5.registerAddon(c),e.Recorder=o,e.p5Record=c}({});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "p5.record.js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
package/src/Recorder.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SequenceRecorder, imageTypesMap } from "./SequenceRecorder";
|
|
2
|
+
|
|
1
3
|
export class Recorder {
|
|
2
4
|
#stream;
|
|
3
5
|
#recorder;
|
|
@@ -5,21 +7,35 @@ export class Recorder {
|
|
|
5
7
|
constructor(options) {
|
|
6
8
|
const frameRate = options.frameRate === "manual" ? 0 : options.frameRate;
|
|
7
9
|
const chunks = [];
|
|
8
|
-
const
|
|
10
|
+
const canvas = (
|
|
9
11
|
options.source instanceof HTMLCanvasElement ?
|
|
10
12
|
options.source :
|
|
11
13
|
options.source.canvas
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
);
|
|
15
|
+
const stream = canvas.captureStream(frameRate);
|
|
16
|
+
let recorder;
|
|
17
|
+
if(Object.keys(imageTypesMap).includes(options.mimeType)){
|
|
18
|
+
recorder = new SequenceRecorder(canvas, options.mimeType, frameRate);
|
|
19
|
+
}else{
|
|
20
|
+
recorder = new MediaRecorder(stream, {
|
|
21
|
+
mimeType: options.mimeType ?? "video/webm;codecs=vp8"
|
|
22
|
+
});
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
recorder.addEventListener("start", (e) => {
|
|
18
26
|
console.log("recording started");
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
recorder.addEventListener("stop", (e) => {
|
|
22
|
-
|
|
30
|
+
let blob, filename;
|
|
31
|
+
|
|
32
|
+
if(this.#recorder instanceof SequenceRecorder){
|
|
33
|
+
blob = e.detail.blob;
|
|
34
|
+
filename = "recording.zip";
|
|
35
|
+
}else{
|
|
36
|
+
blob = new Blob(chunks);
|
|
37
|
+
filename = "recording.webm";
|
|
38
|
+
}
|
|
23
39
|
|
|
24
40
|
const executeDefault = typeof options?.stopCallback === "function" ?
|
|
25
41
|
options?.stopCallback(blob) :
|
|
@@ -29,7 +45,7 @@ export class Recorder {
|
|
|
29
45
|
const blobUrl = URL.createObjectURL(blob);
|
|
30
46
|
const link = document.createElement("a");
|
|
31
47
|
link.href = blobUrl;
|
|
32
|
-
link.download =
|
|
48
|
+
link.download = filename;
|
|
33
49
|
link.click();
|
|
34
50
|
}
|
|
35
51
|
});
|
|
@@ -71,6 +87,10 @@ export class Recorder {
|
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
frame() {
|
|
74
|
-
this.#
|
|
90
|
+
if(this.#recorder instanceof SequenceRecorder){
|
|
91
|
+
this.#recorder.frame();
|
|
92
|
+
}else{
|
|
93
|
+
this.#stream.getVideoTracks()[0].requestFrame();
|
|
94
|
+
}
|
|
75
95
|
}
|
|
76
96
|
}
|
package/src/SequenceRecorder.js
CHANGED
|
@@ -1,66 +1,95 @@
|
|
|
1
|
-
|
|
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 {
|
|
2
10
|
#counter = 0;
|
|
3
11
|
#source;
|
|
4
12
|
#images = [];
|
|
5
13
|
#animationFrame;
|
|
6
14
|
#timePerFrame;
|
|
7
|
-
#
|
|
15
|
+
#deltaTime;
|
|
16
|
+
#previousTimestamp;
|
|
17
|
+
#accumulator = 0;
|
|
18
|
+
#mimeType;
|
|
8
19
|
state = "inactive";
|
|
9
20
|
|
|
10
|
-
constructor(source, frameRate = 60) {
|
|
21
|
+
constructor(source, mimeType, frameRate = 60) {
|
|
22
|
+
super();
|
|
11
23
|
this.#source = source;
|
|
12
24
|
this.#timePerFrame = 1000 / frameRate;
|
|
25
|
+
this.#mimeType = mimeType;
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
start() {
|
|
16
|
-
state = "recording";
|
|
17
|
-
this.dispatchEvent(new
|
|
29
|
+
this.state = "recording";
|
|
30
|
+
this.dispatchEvent(new CustomEvent("start"));
|
|
18
31
|
this.frame();
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
stop() {
|
|
22
|
-
state = "inactive";
|
|
35
|
+
this.state = "inactive";
|
|
23
36
|
if(this.#animationFrame){
|
|
24
37
|
cancelAnimationFrame(this.#animationFrame);
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
const buffersPromise = Promise.all(this.#images.map((image) => {
|
|
41
|
+
return image.arrayBuffer();
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const zip = new Zip();
|
|
45
|
+
buffersPromise.then((buffers) => {
|
|
46
|
+
for(let i=0; i<buffers.length; i++){
|
|
47
|
+
const file = new FileEntry(
|
|
48
|
+
`capture-${String(i+1).padStart(5, "0")}.${imageTypesMap[this.#mimeType]}`,
|
|
49
|
+
new Uint8Array(buffers[i])
|
|
50
|
+
);
|
|
51
|
+
zip.addFile(file);
|
|
52
|
+
}
|
|
33
53
|
|
|
34
|
-
|
|
54
|
+
this.dispatchEvent(new CustomEvent("stop", {
|
|
55
|
+
detail: {
|
|
56
|
+
blob: zip.pack()
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
pause() {
|
|
38
|
-
state = "paused";
|
|
63
|
+
this.state = "paused";
|
|
39
64
|
if(this.#animationFrame){
|
|
40
65
|
cancelAnimationFrame(this.#animationFrame);
|
|
41
66
|
}
|
|
42
|
-
this.dispatchEvent(new
|
|
67
|
+
this.dispatchEvent(new CustomEvent("pause"));
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
resume() {
|
|
46
|
-
state = "recording";
|
|
47
|
-
this.dispatchEvent(new
|
|
71
|
+
this.state = "recording";
|
|
72
|
+
this.dispatchEvent(new CustomEvent("resume"));
|
|
48
73
|
this.frame();
|
|
49
74
|
}
|
|
50
75
|
|
|
51
|
-
frame(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.#
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
frame(timestamp) {
|
|
77
|
+
if(this.state === "recording"){
|
|
78
|
+
this.#deltaTime = timestamp - this.#previousTimestamp;
|
|
79
|
+
this.#previousTimestamp = timestamp;
|
|
80
|
+
this.#accumulator += this.#deltaTime || 0;
|
|
81
|
+
|
|
82
|
+
if(this.#accumulator >= this.#timePerFrame || this.#timePerFrame === Number.POSITIVE_INFINITY){
|
|
83
|
+
this.#accumulator = 0;
|
|
84
|
+
let c = this.#counter;
|
|
85
|
+
this.#source.toBlob((blob) => {
|
|
86
|
+
this.#images[c] = blob;
|
|
87
|
+
}, this.#mimeType);
|
|
88
|
+
this.#counter++;
|
|
89
|
+
}
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if(this.accumulator >= this.#timePerFrame){
|
|
61
|
-
this.#animationFrame = requestAnimationFrame(this.frame);
|
|
62
|
-
}else{
|
|
63
|
-
this.#animationFrame = requestAnimationFrame(() => {});
|
|
91
|
+
if(this.#timePerFrame < Number.POSITIVE_INFINITY){
|
|
92
|
+
this.#animationFrame = requestAnimationFrame(this.frame.bind(this));
|
|
64
93
|
}
|
|
65
94
|
}
|
|
66
95
|
}
|
package/src/Zip.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { crc32 } from "./crc32.js";
|
|
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(file, localFileHeaderOffset){
|
|
13
|
+
const buf = new ArrayBuffer(46 + file.filename.length);
|
|
14
|
+
const arr = new Uint8Array(buf);
|
|
15
|
+
const view = new DataView(buf);
|
|
16
|
+
|
|
17
|
+
// Set Magic Number
|
|
18
|
+
view.setUint32(0, 1347092738);
|
|
19
|
+
// Set Version
|
|
20
|
+
view.setUint16(4, 20, true);
|
|
21
|
+
// Set Version extract
|
|
22
|
+
view.setUint16(6, 20, true);
|
|
23
|
+
// Set last modify time
|
|
24
|
+
view.setUint16(12, file.modifyTime, true);
|
|
25
|
+
// Set last modify date
|
|
26
|
+
view.setUint16(14, file.modifyDate, true);
|
|
27
|
+
// Set CRC-32
|
|
28
|
+
view.setUint32(16, file.checksum, true);
|
|
29
|
+
// Set Compressed size
|
|
30
|
+
view.setUint32(20, file.compressedSize, true);
|
|
31
|
+
// Set Uncompressed size
|
|
32
|
+
view.setUint32(24, file.uncompressedSize, true);
|
|
33
|
+
// Set file name length
|
|
34
|
+
view.setUint16(28, file.filename.length, true);
|
|
35
|
+
// Set relative offset
|
|
36
|
+
view.setUint32(42, localFileHeaderOffset, true);
|
|
37
|
+
// Set file name
|
|
38
|
+
const filenameBytes = textEncoder.encode(file.filename);
|
|
39
|
+
arr.set(filenameBytes, 46);
|
|
40
|
+
|
|
41
|
+
return view.buffer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
addFile(file){
|
|
45
|
+
const centralDirectory = Zip.generateCentralDirectoryHeader(file, this.#currentOffset);
|
|
46
|
+
this.#files.push(file);
|
|
47
|
+
|
|
48
|
+
this.#centralDirectories.push(centralDirectory);
|
|
49
|
+
this.#currentOffset += file.localFileHeader.byteLength;
|
|
50
|
+
this.#currentOffset += file.content.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pack(){
|
|
54
|
+
// Calcualte needed buffer size
|
|
55
|
+
const centralDirectoriesSize = this.#centralDirectories.reduce((acc, data) => {
|
|
56
|
+
acc += data.byteLength
|
|
57
|
+
return acc;
|
|
58
|
+
}, 0);
|
|
59
|
+
this.#currentOffset += centralDirectoriesSize;
|
|
60
|
+
|
|
61
|
+
// Calculate end of central directory record
|
|
62
|
+
const buf = new ArrayBuffer(22);
|
|
63
|
+
const view = new DataView(buf);
|
|
64
|
+
// Set Magic Number
|
|
65
|
+
view.setUint32(0, 1347093766);
|
|
66
|
+
// Set central directory number
|
|
67
|
+
view.setUint16(8, this.#files.length, true);
|
|
68
|
+
// Set total central directory number
|
|
69
|
+
view.setUint16(10, this.#files.length, true);
|
|
70
|
+
// Set size of central directory in byes
|
|
71
|
+
view.setUint32(12, centralDirectoriesSize, true); // VARIABLE
|
|
72
|
+
// Set offset of central directory
|
|
73
|
+
view.setUint32(16, this.#currentOffset-centralDirectoriesSize, true); // VARIABLE
|
|
74
|
+
|
|
75
|
+
// Put all components into blob and return
|
|
76
|
+
const items = [];
|
|
77
|
+
// Insert files
|
|
78
|
+
for(const file of this.#files){
|
|
79
|
+
items.push(file.localFileHeader);
|
|
80
|
+
items.push(file.content);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Insert central directory
|
|
84
|
+
items.push(...this.#centralDirectories);
|
|
85
|
+
|
|
86
|
+
// Insert end of central directory
|
|
87
|
+
items.push(view.buffer);
|
|
88
|
+
|
|
89
|
+
return new Blob(items);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class FileEntry {
|
|
94
|
+
modifyTime;
|
|
95
|
+
modifyDate;
|
|
96
|
+
checksum;
|
|
97
|
+
compressedSize;
|
|
98
|
+
uncompressedSize;
|
|
99
|
+
filename;
|
|
100
|
+
content;
|
|
101
|
+
|
|
102
|
+
constructor(filename, content){
|
|
103
|
+
this.filename = filename;
|
|
104
|
+
this.content = content;
|
|
105
|
+
const { date, time } = getDosDateTime();
|
|
106
|
+
this.modifyTime = time;
|
|
107
|
+
this.modifyDate = date;
|
|
108
|
+
this.checksum = crc32(0, content, content.length, 0);
|
|
109
|
+
this.compressedSize = content.length;
|
|
110
|
+
this.uncompressedSize = content.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get localFileHeader(){
|
|
114
|
+
const buf = new ArrayBuffer(30 + this.filename.length);
|
|
115
|
+
const arr = new Uint8Array(buf);
|
|
116
|
+
const view = new DataView(buf);
|
|
117
|
+
|
|
118
|
+
// Set Magic Number
|
|
119
|
+
view.setUint32(0, 1347093252);
|
|
120
|
+
// Set Version extract
|
|
121
|
+
view.setUint16(4, 20, true);
|
|
122
|
+
// Set last modify time
|
|
123
|
+
view.setUint16(10, this.modifyTime, true);
|
|
124
|
+
// Set last modify date
|
|
125
|
+
view.setUint16(12, this.modifyDate, true);
|
|
126
|
+
// Set CRC-32
|
|
127
|
+
view.setUint32(14, this.checksum, true);
|
|
128
|
+
// Set Compressed size
|
|
129
|
+
view.setUint32(18, this.compressedSize, true);
|
|
130
|
+
// Set Uncompressed size
|
|
131
|
+
view.setUint32(22, this.uncompressedSize, true);
|
|
132
|
+
// Set file name length
|
|
133
|
+
view.setUint16(26, this.filename.length, true);
|
|
134
|
+
// Set file name
|
|
135
|
+
const filenameBytes = textEncoder.encode(this.filename);
|
|
136
|
+
arr.set(filenameBytes, 30);
|
|
137
|
+
|
|
138
|
+
return view.buffer;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getDosDateTime(){
|
|
143
|
+
const date = new Date();
|
|
144
|
+
|
|
145
|
+
let dosTime = date.getUTCHours();
|
|
146
|
+
dosTime = dosTime << 6;
|
|
147
|
+
dosTime = dosTime | date.getUTCMinutes();
|
|
148
|
+
dosTime = dosTime << 5;
|
|
149
|
+
dosTime = dosTime | date.getUTCSeconds() / 2;
|
|
150
|
+
|
|
151
|
+
let dosDate = date.getUTCFullYear() - 1980;
|
|
152
|
+
dosDate = dosDate << 4;
|
|
153
|
+
dosDate = dosDate | (date.getUTCMonth() + 1);
|
|
154
|
+
dosDate = dosDate << 5;
|
|
155
|
+
dosDate = dosDate | date.getUTCDate();
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
date: dosDate,
|
|
159
|
+
time: dosTime
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/crc32.js
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, 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, buf, len, pos) {
|
|
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
|
+
}
|
package/src/main.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Recorder } from "./Recorder.js";
|
|
2
|
+
import { imageTypesMap } from "./SequenceRecorder.js";
|
|
2
3
|
|
|
3
4
|
export function p5Record(p5, fn, lifecycles){
|
|
4
5
|
let recorder, options;
|
|
@@ -23,7 +24,13 @@ export function p5Record(p5, fn, lifecycles){
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
fn.setRecording = function(opt) {
|
|
26
|
-
if(
|
|
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
|
}
|