deck-recorder 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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # deck-recorder
2
+
3
+ Records a talk given with a [`<deck-shift>`](../deck-shift) slide deck, and
4
+ turns the recording into a video.
5
+
6
+ ```html
7
+ <script type="module" src="https://unpkg.com/deck-recorder"></script>
8
+ <deck-controls></deck-controls>
9
+ ```
10
+
11
+ Or:
12
+
13
+ ```sh
14
+ npm install deck-recorder
15
+ ```
16
+
17
+ ```js
18
+ import "deck-recorder";
19
+ ```
20
+
21
+ Add `<deck-recorder>` to the page and press <kbd>R</kbd> to start recoding a
22
+ run-through of the deck. The microphone will begin recording (and, if
23
+ [`webcam-view`] is present, the camera stream too), as well as every slide and
24
+ step change, and any clicks and key presses. <kbd>.</kbd> will pause and resume,
25
+ and the recording marks the cut. Press <kbd>R</kbd> again to download the video
26
+ (`recording-<time>.webm`) and its flow (`recording-<time>.json`).
27
+
28
+ The flow is a [Puppeteer Replay] user flow which means a tool like
29
+ [Chrome DevTools Recorder] can replay it. `setViewport` and a `navigate` to
30
+ the deck, a `navigate` to `#<slide>.<step>` for each change, `click` (selectors
31
+ pierce shadow roots) and `keyDown`/`keyUp` steps, and a `customStep` named
32
+ `pause` at each cut. Every step also has `t`, the ms into the video it happened;
33
+ the flow also has `video` (its file name), `started`, `duration` and `slides`
34
+ (the deck's `script()`). Why is this useful? Combine it with `deck-replay` to
35
+ get a video!
36
+
37
+ ```sh
38
+ npm install puppeteer @puppeteer/replay
39
+ npx deck-replay recording-<time>.json [--out video.mp4] [--headed]
40
+ ```
41
+
42
+ That replays the recording in a browser, while also screen-recording the page in
43
+ Chrome as the steps play, laying the recording's sound under it.
44
+
45
+ ## Styling & API
46
+
47
+ A REC pill shows while recording (`::part(indicator)`). `start()`, `pause()`,
48
+ `resume()`, `stop()` (resolves with `{ video, flow }`), `toggle()`, `recording`
49
+ and `paused` drive it from script, and it fires a bubbling
50
+ `deck-recorder-change` event (`DeckRecorderChangeEvent`, with `recording` and
51
+ `paused`) as they change.
52
+
53
+ It adds the custom `--record` and `--record-pause` commands, meaning it can be
54
+ scripted also.
55
+
56
+ [`webcam-view`]: ../webcam-view
57
+ [Puppeteer Replay]: https://github.com/puppeteer/replay
@@ -0,0 +1,2 @@
1
+ var E=new CSSStyleSheet;E.replaceSync(`:host{position:fixed;top:16px;right:16px;z-index:2147483647;pointer-events:none}.rec{display:none;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--deck-ui-bg, rgba(22, 22, 24, .88));color:var(--deck-ui-fg, #f4f4f4);font:600 14px/1 ui-monospace,monospace}:host(:state(recording)) .rec{display:flex}.dot{box-sizing:border-box;width:10px;height:10px;border-radius:50%;background:#e5484d}:host(:state(paused)) .dot{border:2px solid #e5484d;background:none}
2
+ `);var L=E;var $=a=>String(a).padStart(2,"0"),M=(a,e)=>{let t=Object.assign(document.createElement("a"),{href:URL.createObjectURL(a),download:e});t.click(),setTimeout(()=>URL.revokeObjectURL(t.href),6e4)},m={record:"r",pause:"."},N=a=>{let e=a.getRootNode(),t=[];for(let n=a;n;n=n.parentElement){let o=n.id&&`#${globalThis.CSS.escape(n.id)}`;if(o&&e.querySelectorAll(o).length===1){t.unshift(o);break}let i=[...n.parentElement?.children??[n]].filter(c=>c.localName===n.localName);t.unshift(i.length>1?`${n.localName}:nth-of-type(${i.indexOf(n)+1})`:n.localName)}return t.join(" > ")},O=a=>{let e=[];for(let t=a;t;t=t.getRootNode().host)e.unshift(N(t));return e},g=class extends Event{constructor(e,t){super("deck-recorder-change",{bubbles:!0,composed:!0}),this.recording=e,this.paused=t}},w=class extends HTMLElement{static define(e="deck-recorder",t=customElements){t.define(e,this)}#t;#e=null;#o=this.attachInternals();shadowRoot=Object.assign(this.attachShadow({mode:"open"}),{adoptedStyleSheets:[L],innerHTML:'<div class="rec" part="indicator" role="status"><i class="dot"></i><span class="time">00:00</span></div>'});get recording(){return this.#e!==null}get paused(){return this.#o.states.has("paused")}async connectedCallback(){if(this.#t)return;let e=this.closest("deck-shift")??this.ownerDocument.querySelector("deck-shift");await customElements.whenDefined(e.localName),this.#t=e,e.addShortcut("--record",m.record,()=>this.toggle(),"Start / stop recording"),e.addShortcut("--record-pause",m.pause,()=>this.paused?this.resume():this.pause(),"Pause / resume recording")}async start(){this.#e??=this.#r().catch(e=>{throw this.#e=null,e}),await this.#e}async pause(){(await this.#e)?.pause()}async resume(){(await this.#e)?.resume()}async stop(){let e=this.#e;if(!e)return null;this.#e=null;let{recorder:t,mic:n,done:o,controller:i,started:c,steps:h}=await e;t.state!=="inactive"&&t.stop();try{let{video:r,duration:u}=await o,d=`recording-${c.replace(/[:.]/g,"-")}`;return{video:r,flow:{title:d,video:`${d}.${r.type.includes("mp4")?"mp4":"webm"}`,started:c,duration:u,slides:this.#t.script(),steps:h}}}finally{i.abort(),n.getTracks().forEach(r=>r.stop()),this.#s(!1,!1)}}async toggle(){if(!this.#e)return this.start();let{video:e,flow:t}=await this.stop();M(e,t.video),M(new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),`${t.title}.json`)}#n(e){let t=Math.floor(e/1e3);this.shadowRoot.querySelector(".time").textContent=`${$(Math.floor(t/60))}:${$(t%60)}`}#s(e,t){let{states:n}=this.#o;for(let[o,i]of[["recording",e],["paused",t]])i?n.add(o):n.delete(o);this.dispatchEvent(new g(e,t))}async#r(){let e=this.#t,t=await navigator.mediaDevices.getUserMedia({audio:!0}),n=await this.ownerDocument.querySelector("webcam-view")?.stream?.catch(s=>(console.warn("deck-recorder: recording without the webcam",s),null)),o=new MediaRecorder(new MediaStream([...n?.getVideoTracks()??[],...t.getAudioTracks()])),i=[],c=[],h=new AbortController,{signal:r}=h,u=0,d=null,f=()=>Math.round((d??performance.now())-u),l=s=>c.push({t:f(),...s}),P=new Promise(s=>{o.ondataavailable=p=>i.push(p.data),o.onstop=()=>s({video:new Blob(i,{type:o.mimeType}),duration:f()})});o.onerror=s=>console.error("deck-recorder:",s.error),await new Promise(s=>{o.onstart=()=>{u=performance.now(),s()},o.start(1e3)});let R=new Date().toISOString();l({type:"setViewport",width:innerWidth,height:innerHeight,deviceScaleFactor:devicePixelRatio,isMobile:!1,hasTouch:!1,isLandscape:innerWidth>=innerHeight}),l({type:"navigate",url:location.href}),e.addEventListener("deckchange",()=>l({type:"navigate",url:location.href}),{signal:r});let y="",k=()=>`${e.slide}.${e.step}`,T=()=>y=k(),b=()=>y!==k(),v=new Set;for(let s of["keydown","click"])addEventListener(s,T,{capture:!0,signal:r});addEventListener("keydown",s=>{b()||s.isComposing||/^(Unidentified|Dead|Process)$/.test(s.key)||s.defaultPrevented&&Object.values(m).includes(s.key)||(v.add(s.code),l({type:"keyDown",key:s.key}))},{signal:r}),addEventListener("keyup",s=>v.delete(s.code)&&l({type:"keyUp",key:s.key}),{signal:r}),addEventListener("click",s=>{let[p]=s.composedPath();if(b()||!s.detail||!s.composedPath().includes(e))return;let x=p.getBoundingClientRect();l({type:"click",selectors:[O(p)],offsetX:Math.round(s.clientX-x.left),offsetY:Math.round(s.clientY-x.top)})},{signal:r});let S=()=>this.#n(f());S();let j=setInterval(S,250);return r.addEventListener("abort",()=>clearInterval(j)),this.#s(!0,!1),{recorder:o,mic:t,done:P,controller:h,started:R,steps:c,pause:()=>{o.state==="recording"&&(o.pause(),d=performance.now(),l({type:"customStep",name:"pause"}),this.#s(!0,!0))},resume:()=>{o.state==="paused"&&(u+=performance.now()-d,d=null,o.resume(),this.#s(!0,!1))}}}};new URL(import.meta.url).searchParams.has("nodefine")||w.define();export{w as DeckRecorder,g as DeckRecorderChangeEvent};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "deck-recorder",
3
+ "version": "0.1.0",
4
+ "description": "Records a <deck-shift> talk as a webcam video plus a timed Puppeteer Replay flow, and replays them into one video.",
5
+ "keywords": [
6
+ "web-components",
7
+ "custom-elements",
8
+ "slides",
9
+ "presentation",
10
+ "recording",
11
+ "puppeteer",
12
+ "replay"
13
+ ],
14
+ "author": "Keith Cirkel",
15
+ "type": "module",
16
+ "exports": "./deck-recorder.min.js",
17
+ "bin": {
18
+ "deck-replay": "./replay.js"
19
+ },
20
+ "files": [
21
+ "deck-recorder.min.js",
22
+ "replay.js"
23
+ ],
24
+ "scripts": {
25
+ "build": "node esbuild.js"
26
+ },
27
+ "peerDependencies": {
28
+ "@puppeteer/replay": "^4.0.2",
29
+ "deck-shift": "^0.1.0",
30
+ "puppeteer": "^25.12.0",
31
+ "webcam-view": "^0.1.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@puppeteer/replay": {
35
+ "optional": true
36
+ },
37
+ "puppeteer": {
38
+ "optional": true
39
+ },
40
+ "webcam-view": {
41
+ "optional": true
42
+ }
43
+ }
44
+ }
package/replay.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // Turns a <deck-recorder> recording into a video. The recording's flow
3
+ // (recording-<time>.json, a Puppeteer Replay user flow with `t` on each step)
4
+ // is replayed in time in Chrome, with its video playing in the page's
5
+ // <webcam-view>; the page is screen-recorded and the recording's sound laid
6
+ // under it. Writes <title>.mp4 next to the flow.
7
+ //
8
+ // node replay.js recording-<time>.json [--out video.mp4] [--headed]
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { readFile, rm } from "node:fs/promises";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { setTimeout as sleep } from "node:timers/promises";
14
+ import { parseArgs } from "node:util";
15
+ import { createRunner, PuppeteerRunnerExtension } from "@puppeteer/replay";
16
+ import puppeteer from "puppeteer";
17
+
18
+ const CHUNK = 4 << 20;
19
+ const wait = (until) => sleep(Math.max(0, until - performance.now()));
20
+
21
+ const {
22
+ values: opt,
23
+ positionals: [file],
24
+ } = parseArgs({
25
+ allowPositionals: true,
26
+ options: { out: { type: "string" }, headed: { type: "boolean" } },
27
+ });
28
+ if (!file) {
29
+ console.error(
30
+ "usage: node replay.js recording-<time>.json [--out video.mp4] [--headed]",
31
+ );
32
+ process.exit(1);
33
+ }
34
+
35
+ const dir = dirname(resolve(file));
36
+ const flow = JSON.parse(await readFile(file, "utf8"));
37
+ const video = await readFile(join(dir, flow.video));
38
+ const screen = join(dir, `${flow.title}.screen.webm`);
39
+ const out = resolve(opt.out ?? join(dir, `${flow.title}.mp4`));
40
+
41
+ // Plays the webcam video in the page's <webcam-view>, paused at the start.
42
+ async function load(page) {
43
+ await page.exposeFunction("__recordingChunk", (i) =>
44
+ video.subarray(i * CHUNK, (i + 1) * CHUNK).toString("base64"),
45
+ );
46
+ await page.evaluate(
47
+ async (size, chunk, type) => {
48
+ const cam = document.querySelector("webcam-view");
49
+ if (!cam) return;
50
+ const parts = [];
51
+ for (let i = 0; i * chunk < size; i++) {
52
+ parts.push(Uint8Array.fromBase64(await window.__recordingChunk(i)));
53
+ }
54
+ cam.src = URL.createObjectURL(new Blob(parts, { type }));
55
+ const v = cam.video;
56
+ if (v.readyState < 3) {
57
+ await new Promise((ok, fail) => {
58
+ v.addEventListener("canplay", ok, { once: true });
59
+ v.addEventListener("error", () => fail(v.error), { once: true });
60
+ });
61
+ }
62
+ v.pause();
63
+ v.currentTime = 0;
64
+ },
65
+ video.length,
66
+ CHUNK,
67
+ flow.video.endsWith(".mp4") ? "video/mp4" : "video/webm",
68
+ );
69
+ }
70
+
71
+ // Runs each step at its `t`, counted from when the video starts: just after
72
+ // the first navigation, once the deck is ready.
73
+ class Timed extends PuppeteerRunnerExtension {
74
+ t0 = null;
75
+ recorder = null;
76
+ captured = 0;
77
+
78
+ async beforeEachStep(step, flow) {
79
+ if (this.t0 !== null) await wait(this.t0 + step.t);
80
+ await super.beforeEachStep?.(step, flow);
81
+ }
82
+
83
+ async afterEachStep(step, flow) {
84
+ await super.afterEachStep?.(step, flow);
85
+ if (this.t0 !== null || step.type !== "navigate") return;
86
+ const { page } = this;
87
+ await page.evaluate(() => document.querySelector("deck-shift")?.ready);
88
+ await load(page);
89
+ this.recorder = await page.screencast({ path: screen });
90
+ this.captured = performance.now();
91
+ await page.evaluate(() =>
92
+ document.querySelector("webcam-view")?.video.play(),
93
+ );
94
+ this.t0 = performance.now();
95
+ }
96
+ }
97
+
98
+ const browser = await puppeteer.launch({
99
+ headless: !opt.headed,
100
+ acceptInsecureCerts: true,
101
+ args: ["--autoplay-policy=no-user-gesture-required"],
102
+ });
103
+ try {
104
+ const page = await browser.newPage();
105
+ page.on("pageerror", (e) => console.error(`page: ${e.message}`));
106
+ const timed = new Timed(browser, page);
107
+ await (await createRunner(flow, timed)).run();
108
+ // minimal: real-time capture, so frames can lag a slow machine; seek every
109
+ // animation per frame (as ../demo's renderer does) if that shows.
110
+ await wait(timed.t0 + flow.duration);
111
+ await timed.recorder.stop();
112
+ const offset = (timed.t0 - timed.captured) / 1000;
113
+ const ffmpeg = spawn(
114
+ "ffmpeg",
115
+ [
116
+ "-y",
117
+ "-loglevel",
118
+ "error",
119
+ "-ss",
120
+ `${offset}`,
121
+ "-i",
122
+ screen,
123
+ "-i",
124
+ join(dir, flow.video),
125
+ "-map",
126
+ "0:v",
127
+ "-map",
128
+ "1:a?",
129
+ "-t",
130
+ `${flow.duration / 1000}`,
131
+ "-vf",
132
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2",
133
+ "-c:v",
134
+ "libx264",
135
+ "-pix_fmt",
136
+ "yuv420p",
137
+ "-c:a",
138
+ "aac",
139
+ "-movflags",
140
+ "+faststart",
141
+ out,
142
+ ],
143
+ { stdio: "inherit" },
144
+ );
145
+ const code = await new Promise((ok) => ffmpeg.on("exit", ok));
146
+ if (code) throw new Error(`ffmpeg exited with ${code}`);
147
+ await rm(screen);
148
+ console.log(out);
149
+ } finally {
150
+ await browser.close();
151
+ }