app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
← Commit history
Publish 0.2.4
12 files changed
+99−11
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.2.3"+version = "0.2.4" dependencies = [ "clap", "serde",
Cargo.toml+1−1@@ -2,7 +2,7 @@ # Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow. [package] name = "adom-aiflow-video"-version = "0.2.3"+version = "0.2.4" edition = "2021" license = "MIT"
README.md+1−1@@ -58,4 +58,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off ## Status -0.2.3. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, and the narration page for recording your own voice. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.+0.2.4. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, and the narration page for recording your own voice. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.
assets/narrate.css+5@@ -120,3 +120,8 @@ kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-r .ainote.pulse { position: relative; z-index: 9001; border-style: solid; border-color: var(--accent-bright); box-shadow: 0 0 0 3px rgba(0,230,220,.25); animation: notepulse 1.2s ease-in-out infinite; background: var(--surface); } @keyframes notepulse { 50% { box-shadow: 0 0 0 7px rgba(0,230,220,.10); } }++/* the narrator's camera, shown where it will sit in the film */+.campip { position: absolute; right: 26px; bottom: 23%; width: 19%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; border: 2px solid var(--accent); box-shadow: 0 8px 28px rgba(0,0,0,.5); z-index: 4; display: none; background: #000; transform: scaleX(-1); }+.campip.on { display: block; }+.campip.playback { transform: none; }
assets/narrate.html+6−1@@ -25,7 +25,7 @@ <div class="popover help-pop" id="help-pop"> <div class="pop-title">How narrating works</div> <ol>- <li><b>Check your microphone.</b> Calibrate sets the level for your voice in about 15 seconds.</li>+ <li><b>Check your microphone.</b> Calibrate sets the level for your voice in about 15 seconds. Pick a camera too if you want your face in the corner of the film.</li> <li><b>Rehearse</b> if you like: the film plays with its captions and nothing is recorded.</li> <li><b>Record.</b> The film plays, muted, with a line to read under it. Say it your own way. The bar shows how much is left and warns you before the end.</li> <li><b>Review.</b> Play the film with your voice on it. Record again if you want.</li>@@ -38,6 +38,7 @@ <adom-video id="player" video="video" chapters="chapters.json" captions="captions.vtt" muted></adom-video> <div class="overlay" id="preroll"><div class="pre-num" id="pre-num">5</div><div class="pre-text" id="pre-text">Get ready</div></div> <div class="wrapup" id="wrapup"><b id="wrap-num">10</b><span id="wrap-text">seconds left: wrap up</span></div>+ <video class="campip" id="campip" muted playsinline data-tooltip="You, as you will appear in the corner of the film"></video> <div class="recbadge" id="recbadge"><i></i><span id="recbadge-text">Recording</span></div> </section> @@ -61,6 +62,10 @@ <button class="btn ghost" id="test" data-tooltip="Open the microphone and watch the level. Nothing is recorded.">Test mic</button> </div> + <h2>Camera</h2>+ <select class="select wide" id="cam" aria-label="Camera" data-tooltip="Record your face while you narrate. It becomes a small picture in the corner of the film. Pick No camera for voice only."><option value="">No camera (voice only)</option></select>+ <div class="heard" id="camstat">Off. Pick a camera to appear in the corner of the film.</div>+ <h2>Takes</h2> <ul class="takes" id="takes"><li class="empty">Your recordings will be listed here.</li></ul> <button class="btn ghost startover" id="startover" data-tooltip="Go back to step 1 with a clean page. Nothing is deleted: your takes are moved into a dated folder in your project, and you can undo this.">Start over</button>
assets/narrate.js+58−4@@ -62,7 +62,7 @@ } function report() { const d = v() ? v().duration || 0 : 0, t = v() ? v().currentTime : 0, c = v() ? p._chapterAt?.(t) : null;- const body = { mode, t: +t.toFixed(2), duration: +d.toFixed(2), left: +(d - t).toFixed(1), chapter: c ? c.title : '', mics: [...$('mic').options].map((o) => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess,+ const body = { mode, t: +t.toFixed(2), duration: +d.toFixed(2), left: +(d - t).toFixed(1), chapter: c ? c.title : '', mics: [...$('mic').options].map((o) => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, cams: [...$('cam').options].slice(1).map((o) => o.textContent), camera: $('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : '', camAccess, recording: mode === 'record', level: +level.toFixed(3), rawLevel: +rawLevel.toFixed(3), peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, calibrating: calPhase, takes: takes.length, chosen: chosen ? chosen.file : null, strokes: strokes.length, status: lastStatus }; fetch('state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {}); }@@ -240,6 +240,41 @@ } // ---------------- rehearse, record, stop ----------------+ // ---------------- camera: the narrator's face, a picture in the corner of the film ----------------+ let camStream = null, camRec = null, camChunks = [], camAccess = 'not asked';+ const pip = $('campip');+ async function cams() {+ const devs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput');+ const keep = $('cam').value;+ $('cam').innerHTML = '<option value="">No camera (voice only)</option>' + devs.map((d, i) => `<option value="${d.deviceId}">${esc(d.label || 'Camera ' + (i + 1))}</option>`).join('');+ if (keep && [...$('cam').options].some((o) => o.value === keep)) $('cam').value = keep;+ }+ function closeCam() { if (camStream) camStream.getTracks().forEach((x) => x.stop()); camStream = null; pip.srcObject = null; pip.classList.remove('on'); }+ async function openCam(first) {+ closeCam();+ const id = $('cam').value;+ if (!id && !first) { $('camstat').textContent = 'Off. Pick a camera to appear in the corner of the film.'; report(); return; }+ try {+ camStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: id ? { exact: id } : undefined, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } } });+ camAccess = 'allowed';+ } catch (e) { camAccess = 'blocked: ' + e.message; $('cam').value = ''; $('camstat').textContent = 'The browser did not give access to a camera. Allow it in the address bar, then pick it again.'; toast('The browser did not give access to a camera.', 'error'); report(); return; }+ await cams();+ const st = camStream.getVideoTracks()[0].getSettings();+ if (!$('cam').value) { const o = [...$('cam').options].find((o) => o.value === st.deviceId) || $('cam').options[1]; if (o) $('cam').value = o.value; }+ pip.classList.remove('playback'); pip.srcObject = camStream; pip.classList.add('on'); pip.play().catch(() => {});+ $('camstat').textContent = `On, ${st.width} x ${st.height}. This corner picture is recorded with every take.`;+ try { localStorage.setItem('aiflow-video-cam', $('cam').selectedOptions[0]?.textContent || ''); } catch (_) {}+ report();+ }+ $('cam').onchange = () => { if (mode === 'record') return toast('Finish this take first.', 'info'); if (!$('cam').value) { try { localStorage.removeItem('aiflow-video-cam'); } catch (_) {} } openCam(); };+ function stopCamRec() {+ return new Promise((res) => {+ if (!camRec || camRec.state === 'inactive') return res(null);+ camRec.onstop = () => res(new Blob(camChunks, { type: camRec.mimeType || 'video/webm' }));+ camRec.stop();+ });+ }+ async function preroll(text) { const n = Math.max(0, Math.round(S.preRoll)); if (!n) return true; $('preroll').classList.add('on'); $('pre-text').textContent = text;@@ -264,11 +299,17 @@ const type = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || ''; rec = new MediaRecorder(recStream, type ? { mimeType: type, audioBitsPerSecond: 192000 } : undefined); rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);+ camRec = null; camChunks = [];+ if (camStream) {+ const vt = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || '';+ try { camRec = new MediaRecorder(camStream, vt ? { mimeType: vt, videoBitsPerSecond: 3000000 } : undefined); camRec.ondataavailable = (e) => e.data.size && camChunks.push(e.data); camRec.onstart = () => { tRec.cam = performance.now(); }; camRec.start(1000); }+ catch (e) { camRec = null; toast('The camera could not be recorded (' + e.message + '). Your voice is still being recorded.', 'error'); }+ } rec.onstart = () => { tRec.rec = performance.now(); v().play(); }; rec.onstop = save; v().addEventListener('playing', () => { if (tRec.rec && tRec.play === undefined) tRec.play = performance.now(); }, { once: true }); rec.start(1000);- setMode('record'); $('recbadge-text').textContent = 'Recording your voice'; say('Recording');+ setMode('record'); $('recbadge-text').textContent = camRec ? 'Recording your voice and camera' : 'Recording your voice'; say('Recording'); } function stop() { stopFlag = true;@@ -280,14 +321,17 @@ async function save() { v().pause(); const whole = v().duration - v().currentTime < 1.5;+ const camBlob = await stopCamRec();+ const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' }); const drawn = strokes.map((s) => ({ t0: s.t0, end: s.end ?? +v().currentTime.toFixed(3), wiped: s.wiped, pts: s.pts }));- const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), videoStoppedAtS: +v().currentTime.toFixed(2), videoDurationS: +v().duration.toFixed(2), whole, peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, strokes: drawn.length, drawFade: S.drawFade };+ const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), camStartsAtMs: camBlob && tRec.cam !== undefined ? Math.round(tRec.cam - tRec.rec) : null, camera: camBlob ? ($('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : 'camera') : '', videoStoppedAtS: +v().currentTime.toFixed(2), videoDurationS: +v().duration.toFixed(2), whole, peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, strokes: drawn.length, drawFade: S.drawFade }; setMode('idle', 'Saving'); say('Saving the take'); modal(`<h2>Saving your recording...</h2><p>Keep this tab open.</p>`); try { const r = await (await fetch('take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json(); if (drawn.length) await fetch('draw?file=' + encodeURIComponent(r.file), { method: 'POST', body: JSON.stringify(drawn) });+ if (camBlob && camBlob.size) { modal(`<h2>Saving your camera recording...</h2><p>Keep this tab open.</p>`); const c = await (await fetch('cam?file=' + encodeURIComponent(r.file), { method: 'POST', headers: { 'X-Cam-Mime': camBlob.type }, body: camBlob })).json(); if (c.cam) r.cam = c.cam; else toast('The camera recording could not be saved; your voice was.', 'error'); } const take = { ...meta, ...r, strokes: drawn, n: takes.length + 1 }; takes.push(take); renderTakes(); header(); finishCard(take); setMode('idle'); say(`Take ${take.n} saved (${fmt(take.seconds)})`); } catch (e) { modal(`<h2>The take could not be saved</h2><p>${esc(e.message)}. It is still in this page: do not close the tab, and tell your AI.</p>`); setMode('idle'); say('Save failed: ' + e.message); }@@ -328,7 +372,10 @@ const off = (tk.videoStartsAtMs || 0) / 1000; const sync = () => { if (!reviewAudio) return; const want = v().currentTime + off; if (Math.abs(reviewAudio.currentTime - want) > 0.25) reviewAudio.currentTime = want; }; v().muted = true; p.seekTo(0); capsOn();- v().onplay = () => { sync(); reviewAudio && reviewAudio.play().catch(() => {}); }; v().onpause = () => reviewAudio && reviewAudio.pause(); v().onseeked = sync; v().ontimeupdate = sync;+ const camOff = off - (tk.camStartsAtMs || 0) / 1000;+ if (tk.cam) { pip.srcObject = null; pip.src = 'takes/' + tk.cam; pip.classList.add('on', 'playback'); }+ const csync = () => { if (!tk.cam) return; const want = Math.max(0, v().currentTime + camOff); if (Math.abs(pip.currentTime - want) > 0.3) pip.currentTime = want; };+ v().onplay = () => { sync(); csync(); reviewAudio && reviewAudio.play().catch(() => {}); tk.cam && pip.play().catch(() => {}); }; v().onpause = () => { reviewAudio && reviewAudio.pause(); tk.cam && pip.pause(); }; v().onseeked = () => { sync(); csync(); }; v().ontimeupdate = () => { sync(); csync(); }; v().onended = () => mode === 'review' && endReview(true); setMode('review', `Reviewing take ${tk.n}`); $('recbadge-text').textContent = `Take ${tk.n}: your voice with the film`; say(`Reviewing take ${tk.n} with the film`); v().play().catch(() => {});@@ -336,6 +383,7 @@ } function endReview(done) { v().pause(); if (reviewAudio) { reviewAudio.pause(); reviewAudio = null; }+ if (pip.classList.contains('playback')) { pip.pause(); pip.removeAttribute('src'); pip.load(); pip.classList.remove('on', 'playback'); if (camStream) { pip.srcObject = camStream; pip.classList.add('on'); pip.play().catch(() => {}); } } v().onplay = v().onpause = v().onseeked = v().ontimeupdate = v().onended = null; const tk = reviewTake; reviewTake = null; setMode('idle'); say('Review ended'); if (done && tk && !chosen) finishCard(tk);@@ -387,6 +435,10 @@ test: (d) => act('test', (d.by || 'AI') + ': test the microphone'), calibrate: (d) => act('cal', (d.by || 'AI') + ': calibrate'), gain: async (d) => { await glide('#boost', (d.by || 'AI') + ': boost ' + d.value + 'x'); setBoost(d.value); toast(`Your AI set the microphone boost to ${boost.toFixed(1)}x`, 'info'); }, mic: async (d) => { const o = [...$('mic').options].find((o) => o.textContent.toLowerCase().includes(String(d.name || '').toLowerCase())); if (!o) return toast('No microphone matching "' + d.name + '"', 'error'); await glide('#mic', (d.by || 'AI') + ': pick ' + o.textContent); $('mic').value = o.value; $('mic').onchange(); },+ cam: async (d) => { const n = String(d.name || '').toLowerCase(); await glide('#cam', (d.by || 'AI') + (n === 'off' ? ': camera off' : ': camera on'));+ if (n === 'off') { $('cam').value = ''; return $('cam').onchange(); }+ if (n === 'on' || !n) { $('cam').value = ''; return openCam(true); }+ const o = [...$('cam').options].find((o) => o.value && o.textContent.toLowerCase().includes(n)); if (!o) return openCam(true); $('cam').value = o.value; $('cam').onchange(); }, seek: (d) => p.seekTo(Math.max(0, +d.seconds || 0)), play: () => v().play(), pause: () => v().pause(), review: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? review(tk) : toast('No such take', 'error'); }, use: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? choose(tk) : toast('No such take', 'error'); },@@ -404,6 +456,8 @@ if (st.chosen) chosen = takes.find((t) => t.file === st.chosen) || null; segs(); header(); renderTakes(); setBoost(boost, true); setMode('idle'); mics(); tick(); navigator.mediaDevices.addEventListener('devicechange', mics);+ await cams(); navigator.mediaDevices.addEventListener('devicechange', cams);+ try { const want = localStorage.getItem('aiflow-video-cam'); if (want) { const o = [...$('cam').options].find((o) => o.textContent === want); if (o) { $('cam').value = o.value; openCam(); } else openCam(true); } } catch (_) {} v().addEventListener('ended', () => { if (mode === 'record' && rec && rec.state !== 'inactive') rec.stop(); else if (mode === 'rehearse') { setMode('idle'); say('Rehearsal finished'); toast('That was the whole film. Press Record when you are ready.', 'success'); } }); v().addEventListener('durationchange', () => { segs(); header(); }); say('Ready');
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.2.3",+ "version": "0.2.4", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.2.3",+ "version": "0.2.4", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
skills/adom-aiflow-video/SKILL.md+4@@ -95,3 +95,7 @@ Show the human the draft in the Adom video player (`adom-video play <file> --cha ## Clean up Raw footage is large. `track` every file you create, on every machine. When the human has approved the final film, `clean` lists what is left and `clean --yes` removes it; remove the files on other machines there, and look for the desktop recorder's temp folder and any full-length step recordings in the AI Flow run folder. Keep the film, the captions, the chapters and the edit plan, and tell the human how much space came back.++## The narrator on camera++The narration page has a Camera picker under the microphone. When the human picks a camera (or you run `narrate cam on`, `narrate cam "<name>"`, `narrate cam off`), a small live picture of them sits in the corner of the film while they record, and every take also saves `take-N.cam.webm` beside the voice. The browser asks them once to allow the camera. Offer it: a face in the corner makes a narrated film more personal. When you finish the film, put that recording in the bottom-right corner as a small rounded picture (about a fifth of the frame wide), in step with the voice: `camStartsAtMs` in `take-N.json` is how far the camera recording starts after the voice recording, and `videoStartsAtMs` is where the film starts in the voice recording. Keep it clear of anything the viewer needs to read, and leave it out of the logo sting. Look at their framing in a test take first (lighting, eyes near the top third) and tell them kindly if something is off.
src/main.rs+1@@ -262,6 +262,7 @@ fn main() { let body = match (act, arg) { ("seek", Some(a)) => json!({"seconds": a.parse::<f64>().unwrap_or(0.0)}), ("mic", Some(a)) => json!({"name": a}),+ ("cam", a) => json!({"name": a.clone().unwrap_or_else(|| "on".to_string())}), ("gain", Some(a)) => json!({"value": a.parse::<f64>().unwrap_or(1.0)}), ("toast", Some(a)) => json!({"text": a, "kind": "info"}), ("note", a) => json!({"text": a.clone().unwrap_or_default()}),
src/narrate.rs+20−1@@ -232,6 +232,25 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let _ = std::fs::write(out.join(stem).with_extension("draw.json"), &body); respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}") }+ // the narrator's camera for a take: saved beside it as take-N.cam.webm and named in take-N.json+ ("POST", p) if p == "/cam" => {+ let mut body = vec![0u8; len];+ if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); }+ let file = target.split_once("file=").map(|x| pct_decode(x.1)).unwrap_or_default();+ if file.is_empty() || file.contains('/') { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"which take\"}"); }+ let stem = Path::new(&file).with_extension("").to_string_lossy().to_string();+ let cam = format!("{stem}.cam.{}", if body.len() > 8 && &body[4..8] == b"ftyp" { "mp4" } else { "webm" });+ if std::fs::write(out.join(&cam), &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write the camera take\"}"); }+ let mj = out.join(format!("{stem}.json"));+ if let Some(mut m) = std::fs::read_to_string(&mj).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ m["cam"] = cam.clone().into(); m["camBytes"] = body.len().into();+ let _ = std::fs::write(&mj, serde_json::to_string_pretty(&m).unwrap_or_default() + "\n");+ }+ println!("OK: camera for {stem} saved: {} ({:.0} MB)", out.join(&cam).display(), body.len() as f64 / 1e6);+ println!("Hint: put it in a corner of the film as a small picture, in step with the voice (camStartsAtMs in the take's json is its offset from the voice recording).");+ broadcast(&sh, "state", &json!({"takes": takes_in(&out)}));+ respond(&mut s, "200 OK", "application/json", "", json!({"cam": cam}).to_string().as_bytes())+ } ("GET", "/video") => serve_file(&mut s, &video, range.as_deref()), // the spoken lines of this video, so the calibration reads a real one ("GET", "/captions") => {@@ -270,7 +289,7 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let mut data: Value = serde_json::from_slice(&body).unwrap_or(json!({})); if who.is_empty() { who = data["aiThread"].as_str().unwrap_or("").to_string(); } if who.is_empty() { return respond(&mut s, "400 Bad Request", "application/json", "", json!({"error": "caller_identity_required", "hint": "say who is driving the page: pass --ai-thread \"<your thread name>\" (header X-AI-Thread)"}).to_string().as_bytes()); }- if !["record", "rehearse", "stop", "test", "calibrate", "gain", "mic", "seek", "review", "use", "wipe", "reset", "note", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {+ if !["record", "rehearse", "stop", "test", "calibrate", "gain", "mic", "cam", "seek", "review", "use", "wipe", "reset", "note", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) { return respond(&mut s, "404 Not Found", "application/json", "", json!({"error": "unknown directive", "hint": "record, rehearse, stop, test, calibrate, gain, mic, seek, play, pause, review, use, wipe, note, toast, click, reveal, cursor, reload"}).to_string().as_bytes()); } data["by"] = who.clone().into();