← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.5.2"+version = "0.6.0" 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.5.2"+version = "0.6.0" edition = "2021" license = "MIT" 
README.md+3−1
@@ -45,6 +45,8 @@ The AI voiceover is fine for a draft. A film people want to watch has a person i - **The description.** What the film shows, chapters, and a link back to the project's wiki page. - **The upload.** `youtube check` says which channel each of your adom-google accounts uploads to, and how to set one up if you have none. `youtube upload` always sends the film as unlisted, so only people with the link see it and going public is your call. It then opens the video's YouTube Studio page for you and uploads the film's own captions, because YouTube's automatic ones misspell names. When adom-google cannot reach your channel, the AI uploads through YouTube Studio in a browser it drives. +- **You in the thumbnail.** A pose shot on the same page: your camera with a "point here" dot where the product will sit, a countdown and three stills. The AI cuts you out and puts you in the thumbnail pointing at what you built.+ ![The picker: three thumbnails and four titles, previewed the way YouTube shows them](docs/screenshots/title-thumbnail-picker.jpg)  ![A thumbnail composed for the example film](docs/screenshots/example-thumbnail.jpg)@@ -102,4 +104,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off  ## Status -0.5.2. It grew out of the 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, the narration page (voice, camera picture, drawing, playback check, AI notes), and the YouTube channel check, title and thumbnail feedback and upload. Still done by the AI by hand, with the skill as the guide: the cut itself, putting the voice, picture and drawings on the film, lining up a second recorder, composing thumbnails, and the frame-by-frame privacy check.+0.6.0. It grew out of the 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, the narration page (voice, camera picture, drawing, playback check, AI notes), and the YouTube channel check, title and thumbnail feedback and upload. Still done by the AI by hand, with the skill as the guide: the cut itself, putting the voice, picture and drawings on the film, lining up a second recorder, composing thumbnails, and the frame-by-frame privacy check.
assets/narrate.css+9
@@ -169,3 +169,12 @@ kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-r .pitch-preview .th { position: relative; width: 300px; flex: none; } .pitch-preview img { width: 100%; aspect-ratio: 16/9; object-fit: cover; border-radius: 10px; display: block; } .pitch-preview .dur { position: absolute; right: 6px; bottom: 6px; background: rgba(0,0,0,.8); color: #fff; font: 500 12px/1 Roboto, Arial, sans-serif; padding: 3px 5px; border-radius: 4px; } .pitch-preview .tt { font: 500 17px/1.3 Roboto, Arial, sans-serif; color: #f1f1f1; } .pitch-preview .ch { font: 400 13px Roboto, Arial, sans-serif; color: #aaa; margin-top: 6px; }++/* pose shot: a still of you for the thumbnail */+.pose { position: relative; width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 12px; overflow: hidden; margin: 10px 0 14px; }+.pose video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); display: block; }+.pose .dot { position: absolute; width: 46px; height: 46px; margin: -23px; border-radius: 50%; border: 4px solid var(--accent); background: rgba(0,184,177,.25); box-shadow: 0 0 0 9999px rgba(0,0,0,0); animation: pulse 1s infinite; cursor: grab; }+.pose .dot::after { content: "point here"; position: absolute; left: 50%; top: 110%; transform: translateX(-50%); white-space: nowrap; font: 600 14px var(--font-body); color: #fff; text-shadow: 0 1px 4px #000; }+.pose .count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font: 700 clamp(80px, 14vw, 220px) var(--font-head); color: #fff; text-shadow: 0 4px 24px rgba(0,0,0,.6); pointer-events: none; }+.pose .flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; transition: opacity .25s; }+.pose-shots { display: flex; gap: 8px; margin: 0 0 12px; } .pose-shots img { width: 32%; border-radius: 8px; border: 1px solid var(--border); }
assets/narrate.js+51−5
@@ -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, cams: [...$('cam').options].slice(1).map((o) => o.textContent), camera: $('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : '', camAccess, filmInPage: filmLocal, playback, audioPlan: $('plan').value, pip: $('cam').value || camStream ? PIP : 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, cams: [...$('cam').options].slice(1).map((o) => o.textContent), camera: $('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : '', pageId: myId, inControl: inControl(), hidden: document.hidden, camAccess, filmInPage: filmLocal, playback, audioPlan: $('plan').value, pip: $('cam').value || camStream ? PIP : null,       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(() => {});   }@@ -305,7 +305,7 @@     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; }+    } catch (e) { camAccess = 'blocked: ' + e.name + ' ' + e.message; const busy = e.name === 'NotReadableError'; $('cam').value = ''; $('camstat').textContent = busy ? 'Another app is using this camera. Close it there or pick another camera.' : 'The browser did not give access to a camera. Allow it in the address bar, then pick it again.'; toast(busy ? 'Another app is using that camera.' : '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; }@@ -573,11 +573,56 @@   }   $('pitchbox').onclick = showPitch; $('pitchbox').onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') showPitch(); }; +  // ---------------- pose shot: a still of you from the camera, for the thumbnail ----------------+  async function poseShot(say) {+    if (mode !== 'idle') return toast('Finish what is running first.', 'info');+    let st = camStream, why = '';+    if (!st) {                                                   // try every camera: one may be held by another app+      const want = { width: { ideal: 1920 }, height: { ideal: 1080 } };+      const ids = [undefined, ...(await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput').map((d) => d.deviceId)];+      for (const id of ids) { try { st = await navigator.mediaDevices.getUserMedia({ video: id ? { ...want, deviceId: { exact: id } } : want }); break; } catch (e) { why = e.name === 'NotReadableError' ? 'another app is using the camera (close it there, or pick another camera)' : e.name === 'NotAllowedError' ? 'the browser was not allowed to use a camera' : e.message; } }+    }+    if (!st) { say('Pose shot: no camera: ' + why); report(); return toast('No camera for the photo: ' + why + '.', 'error'); }+    const own = st !== camStream;+    modal(`<h2>A photo of you for the thumbnail</h2><p>${esc(say || 'Point at the dot: that is where your product will be in the thumbnail. Drag the dot if you want it somewhere else. Big expression, big gesture.')}</p>+      <div class="pose" id="pose"><video id="pose-v" muted playsinline></video><i class="dot" id="pose-dot" style="left:18%;top:62%"></i><div class="count" id="pose-n"></div><div class="flash" id="pose-f"></div></div>+      <div class="pose-shots" id="pose-shots"></div>+      <div class="row"><button class="btn lg" id="pose-go">Take 3 photos</button><button class="btn ghost lg" id="pose-done">Done</button></div>`);+    const pv = $('pose-v'); pv.srcObject = st; pv.play().catch(() => {});+    const dot = $('pose-dot'); let dd = false;+    dot.onpointerdown = (e) => { dd = true; dot.setPointerCapture(e.pointerId); }; dot.onpointerup = () => dd = false;+    dot.onpointermove = (e) => { if (!dd) return; const r = $('pose').getBoundingClientRect(); dot.style.left = Math.max(3, Math.min(97, (e.clientX - r.left) / r.width * 100)) + '%'; dot.style.top = Math.max(5, Math.min(92, (e.clientY - r.top) / r.height * 100)) + '%'; };+    const snap = async () => {+      const c = document.createElement('canvas'); c.width = pv.videoWidth; c.height = pv.videoHeight; const g = c.getContext('2d'); g.translate(c.width, 0); g.scale(-1, 1); g.drawImage(pv, 0, 0);   // saved as seen, like a mirror+      $('pose-f').style.opacity = .9; setTimeout(() => $('pose-f').style.opacity = 0, 120);+      const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.95));+      const r = await (await fetch('pose', { method: 'POST', body: blob })).json().catch(() => ({}));+      if (r.file) { const im = new Image(); im.src = URL.createObjectURL(blob); $('pose-shots').appendChild(im); }+      return r.file;+    };+    $('pose-go').onclick = async () => {+      $('pose-go').disabled = true; const got = [];+      for (let k = 0; k < 3; k++) { for (let n = k ? 2 : 5; n > 0; n--) { $('pose-n').textContent = n; await wait(1000); } $('pose-n').textContent = ''; got.push(await snap()); }+      $('pose-go').disabled = false; $('pose-go').textContent = 'Take 3 more';+      say(`Pose shots saved: ${got.filter(Boolean).join(', ')}`); toast('Got them. Your AI will cut you out and put you in the thumbnail.', 'success'); report();+    };+    $('pose-done').onclick = () => { if (own) st.getTracks().forEach((x) => x.stop()); closeModal(); };+  }+   // ---------------- the live channel: the AI drives this page ----------------   const act = async (id, label) => { const el = await glide('#' + id, label); if (el && !el.disabled) el.click(); setTimeout(() => document.querySelector('.ai-cursor')?.classList.remove('on'), 900); };   function setNote(text) { note = text || ''; $('ainote').textContent = note || 'Nothing yet. Your AI can see this page and will tell you here what it is doing with your recording.'; $('ainote').classList.toggle('live', !!note); }-  const es = connect({-    state: (d) => { if (d.pitchChoice !== undefined) { pitchChoice = d.pitchChoice; pitchBox(); } if (d.note !== undefined) setNote(d.note); if (d.settings) { S = { ...S, ...d.settings }; $('fade-s').textContent = S.drawFade; } },+  // Only one open copy of this page acts on microphone, camera and recording directives: a hidden tab never does,+  // and among visible ones it is the newest, or the one you last touched. Otherwise a forgotten second tab would+  // take the camera or start a second recording.+  let myId = null, leader = null;+  const inControl = (d) => !document.hidden && (myId === null || (d && d.leader !== undefined ? d.leader : leader) === myId || leader === null);+  const lead = () => { if (myId !== null && leader !== myId) { leader = myId; fetch('lead', { method: 'POST', body: JSON.stringify({ id: myId }) }).catch(() => {}); } };+  addEventListener('pointerdown', lead, true); addEventListener('keydown', lead, true); addEventListener('focus', lead);+  const only = (h) => Object.fromEntries(Object.entries(h).map(([k, fn]) => [k, ['state', 'ui', 'note', 'hello'].includes(k) ? fn : (d) => { if (inControl(d)) return fn(d); console.log('another copy of this page is in control; ignoring', k); }]));+  const es = connect(only({+    hello: (d) => { myId = d.id; leader = d.id; },+    state: (d) => { if (d.leader !== undefined) leader = d.leader; if (d.pitchChoice !== undefined) { pitchChoice = d.pitchChoice; pitchBox(); } if (d.note !== undefined) setNote(d.note); if (d.settings) { S = { ...S, ...d.settings }; $('fade-s').textContent = S.drawFade; } },     ui: (d) => uiCommon(d),     record: (d) => act('rec', (d.by || 'AI') + ': start recording'), rehearse: (d) => act('rehearse', (d.by || 'AI') + ': rehearse'), stop: (d) => act('stop', (d.by || 'AI') + ': stop'),     test: (d) => act('test', (d.by || 'AI') + ': test the microphone'), calibrate: (d) => act('cal', (d.by || 'AI') + ': calibrate'),@@ -591,12 +636,13 @@       for (const kv of String(d.spec || '').split(/\s+/)) { const [k, val] = kv.split('='); if (!val) continue;         if (k === 'shape' && ASPECT[val]) PIP.shape = val; else if (k === 'corner') { PIP.x = /r/.test(val) ? 0.99 : 0.01; PIP.y = /b/.test(val) ? 0.75 : 0.01; } else if (['size', 'x', 'y', 'zoom', 'ox', 'oy'].includes(k) && isFinite(+val)) PIP[k] = +val; }       PIP.size = Math.max(SMIN, Math.min(SMAX, PIP.size)); PIP.zoom = Math.max(1, Math.min(2.5, PIP.zoom)); layout(true); },+    pose: (d) => poseShot(d.say),     pitch: (d) => { pitch = { titles: d.titles || [], thumbs: d.thumbs || [], stamp: d.stamp || Date.now() }; pitchChoice = null; pitchBox(); if (mode === 'idle') showPitch(); else toast('Your AI has title and thumbnail ideas for you: see the box at the top right.', 'info'); },     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'); },     reset: () => startOver(), wipe: () => wipe(), note: (d) => { setNote(d.text); if (d.text) toast('Your AI: ' + d.text, 'info'); },-  });+  }));   es.onopen = () => $('led').className = 'led ok'; es.addEventListener('error', () => $('led').className = 'led bad');    // ---------------- start ----------------
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.5.2",+  "version": "0.6.0",   "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.5.2",+  "version": "0.6.0",   "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+6
@@ -131,3 +131,9 @@ The thumbnail's few words are a promise, and the narration has to keep it, so se ## After the upload: always unlisted, always open their Studio page, always their own captions  `youtube upload` is unlisted unless told otherwise: you are acting for the human, and making a film public is theirs to do. It opens the video's YouTube Studio page in a browser window on their desktop (`youtube open <link>` does it again later); bring that window to the front and tell them it is there. Then upload the film's captions with `youtube captions <link> --file <film.srt>`: it opens the Subtitles page and gives the steps (Upload manual, With timing, Continue, the file, then Edit subtitles to check and Publish). YouTube's automatic captions get product and part names wrong; yours are from the script or from what they actually said.++## A photo of the narrator for the thumbnail++A person pointing at the product beats a face in a circle. `narrate pose "<what to do>"` opens the camera on the narration page with a "point here" dot (they can drag it), counts down and takes three stills, saved as `pose-N.jpg` in the takes folder, mirrored the way they saw themselves so what they pointed at is where they point. Cut them out of the background (a person-segmentation model such as u2net_human_seg run with onnxruntime works offline), place them in the thumbnail pointing at the product, and show the result in the picker. If the page says another app is using the camera, tell them: the browser cannot take it from another program.++Only one open copy of the narration page acts on camera, microphone and recording commands: a hidden tab never does, and among visible ones it is the newest or the one the human last touched. `narrate status` shows `pageId`, `inControl` and `hidden` for the page that last reported.
src/main.rs+1
@@ -288,6 +288,7 @@ fn main() {                         ("seek", Some(a)) => json!({"seconds": a.parse::<f64>().unwrap_or(0.0)}),                         ("mic", Some(a)) => json!({"name": a}),                         ("pip", Some(a)) => json!({"spec": a}),+                        ("pose", a) => json!({"say": a.clone().unwrap_or_default()}),                         ("pitch", _) => {                             if titles.is_empty() || thumbs.is_empty() { err("pitch needs --titles \"A\" \"B\" ... and --thumbs a.jpg b.jpg ...", &["Bring about five titles and two or three thumbnails (1280 x 720). `youtube title` and `youtube thumbnail` give feedback on them first.".into()]); }                             let abs: Vec<String> = thumbs.iter().map(|t| std::fs::canonicalize(t).map(|p| p.display().to_string()).unwrap_or_else(|_| err(&format!("no picture at {t}"), &[]))).collect();
src/narrate.rs+26−2
@@ -284,6 +284,26 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>             broadcast(&sh, "state", &json!({"pitchChoice": pick}));             respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")         }+        // a still of the narrator from their camera, posed for the thumbnail: saved as pose-N.jpg in the takes folder+        ("POST", "/pose") => {+            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 _ = std::fs::create_dir_all(&out);+            let n = (1..).find(|i| !out.join(format!("pose-{i}.jpg")).exists()).unwrap_or(1);+            let f = out.join(format!("pose-{n}.jpg"));+            if std::fs::write(&f, &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write\"}"); }+            println!("OK: pose shot saved: {} ({:.1} MB)", f.display(), body.len() as f64 / 1e6);+            println!("Hint: it is saved the way they saw it (mirrored), so what they pointed at is where they point. Cut them out of the background, put them in the thumbnail pointing at the product, and show the result in the picker (`narrate pitch ...`).");+            respond(&mut s, "200 OK", "application/json", "", json!({"file": format!("pose-{n}.jpg")}).to_string().as_bytes())+        }+        // a page says the human is using it: it takes control of the microphone, camera and recording directives+        ("POST", "/lead") => {+            let mut body = vec![0u8; len];+            let _ = rd.read_exact(&mut body);+            let id = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v["id"].as_u64()).unwrap_or(0);+            if id > 0 { sh.lock().unwrap().state["leader"] = id.into(); broadcast(&sh, "state", &json!({"leader": id})); }+            respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")+        }         ("GET", "/video") => serve_file(&mut s, &video, range.as_deref()),         // the same film in another codec, for a computer that stalls on the original         ("GET", "/video-alt") => serve_file(&mut s, &smooth_copy(&video), range.as_deref()),@@ -297,7 +317,10 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>         ("GET", "/events") => {             let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n:{}\n\n", " ".repeat(2048));             let mut g = sh.lock().unwrap();-            let _ = write!(s, "event: state\ndata: {}\n\n", g.state);+            // every open copy of the page gets a number; the newest one, or the one the human last touched, is in control+            let id = g.state["pageSeq"].as_u64().unwrap_or(0) + 1;+            g.state["pageSeq"] = id.into(); g.state["leader"] = id.into();+            let _ = write!(s, "event: hello\ndata: {{\"id\":{id}}}\n\nevent: state\ndata: {}\n\n", g.state);             let _ = s.flush();             g.clients.push(s);         }@@ -324,10 +347,11 @@ 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", "cam", "pip", "seek", "review", "use", "wipe", "reset", "note", "pitch", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {+            if !["record", "rehearse", "stop", "test", "calibrate", "gain", "mic", "cam", "pip", "seek", "review", "use", "wipe", "reset", "note", "pitch", "pose", "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();+            data["leader"] = sh.lock().unwrap().state["leader"].clone();             let pages = sh.lock().unwrap().clients.len();             if verb == "note" { sh.lock().unwrap().state["note"] = data["text"].clone(); }             if verb == "pitch" { let mut g = sh.lock().unwrap(); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); data["stamp"] = stamp.into(); g.state["pitch"] = json!({"titles": data["titles"], "thumbs": data["thumbs"], "stamp": stamp}); g.state["pitchChoice"] = Value::Null; }