← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.2.4"+version = "0.2.5" 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.4"+version = "0.2.5" 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.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.+0.2.5. 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+13−4
@@ -121,7 +121,16 @@ 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; }+/* the narrator's camera, shown where and how it will sit in the film */+.pipbox { position: absolute; z-index: 8; display: none; overflow: hidden; border-radius: 12px; border: 2px solid var(--accent); box-shadow: 0 8px 28px rgba(0,0,0,.5); background: #000; cursor: grab; touch-action: none; }+.pipbox.on { display: block; } .pipbox.circle { border-radius: 50%; } .pipbox.drag { cursor: grabbing; }+.campip { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }+.pipctl { display: none; flex-direction: column; gap: 4px; margin: 0 0 8px; } .pipctl.on { display: flex; }+.pipctl .boost-row span { min-width: 74px; } .pipctl .select { align-self: center; flex: 1; }+.btn.small { padding: 4px 10px; font-size: 12px; }+.pipgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }+.pipgrid label { display: flex; flex-direction: column; font-size: 12px; color: var(--muted); min-width: 0; }+.pipgrid label span { display: flex; justify-content: space-between; } .pipgrid b { color: var(--text); font-weight: 500; }+.pipgrid input { width: 100%; accent-color: var(--accent); margin: 2px 0 4px; }+.steps-list small { display: block; }+@media (max-height: 900px) { aside h2 { margin: 4px 0 5px; } .steps-list li { padding-top: 6px; padding-bottom: 6px; } .heard { margin: 2px 0 5px; } }
assets/narrate.html+18−2
@@ -38,7 +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="pipbox" id="pipbox" data-tooltip="You, as you will appear in the film. Drag to move it anywhere."><video class="campip" id="campip" muted playsinline></video></div>     <div class="recbadge" id="recbadge"><i></i><span id="recbadge-text">Recording</span></div>   </section> @@ -52,6 +52,10 @@      <h2>Microphone</h2>     <select class="select wide" id="mic" aria-label="Microphone" data-tooltip="Which microphone to record. Any microphone your computer sees is listed here."></select>+    <select class="select wide" id="plan" aria-label="Where the final sound comes from" data-tooltip="Tell your AI how you are set up. With a separate recorder (a wireless mic that records on the device, a phone), this microphone is only a guide: afterwards plug the recorder in and ask your AI to fetch the clip, and it lines the better sound up with this one.">+      <option value="page">This microphone is my final sound</option>+      <option value="external">I am also recording on a separate recorder</option>+    </select>     <div class="meter" data-tooltip="Your level after the boost. Aim for the dashed line when you speak. Red means too loud."><i id="lvl"></i><em></em></div>     <div class="heard" id="heard">Microphone not opened yet</div>     <div class="boost-row" data-tooltip="Mic boost. Calibrate sets it for your voice; drag to set it yourself.">@@ -63,8 +67,20 @@     </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>+    <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, my face is not shown</option></select>     <div class="heard" id="camstat">Off. Pick a camera to appear in the corner of the film.</div>+    <div class="pipctl" id="pipctl">+      <div class="boost-row" data-tooltip="The shape of your picture. Portrait and Circle keep your face and drop the room to your left and right, so the picture covers less of the film.">+        <span>Shape</span><select class="select" id="pip-shape" aria-label="Picture shape"><option value="landscape">Landscape</option><option value="portrait">Portrait</option><option value="square">Square</option><option value="circle">Circle</option></select>+        <button class="btn ghost small" id="pip-corner" data-tooltip="Move the picture to the next corner. You can also drag it anywhere on the film.">Next corner</button>+      </div>+      <div class="pipgrid">+        <label data-tooltip="How big your picture is in the film."><span>Size <b class="mono" id="pip-size-v"></b></span><input id="pip-size" type="range" min="0.12" max="0.45" step="0.01" aria-label="Picture size"></label>+        <label data-tooltip="Zoom in on your face. Everything outside the picture is cropped away in the film."><span>Zoom <b class="mono" id="pip-zoom-v"></b></span><input id="pip-zoom" type="range" min="1" max="2.5" step="0.05" aria-label="Zoom"></label>+        <label data-tooltip="Slide the crop left or right to centre your face."><span>Left / right</span><input id="pip-ox" type="range" min="-1" max="1" step="0.02" aria-label="Crop left or right"></label>+        <label data-tooltip="Slide the crop up or down to centre your face."><span>Up / down</span><input id="pip-oy" type="range" min="-1" max="1" step="0.02" aria-label="Crop up or down"></label>+      </div>+    </div>      <h2>Takes</h2>     <ul class="takes" id="takes"><li class="empty">Your recordings will be listed here.</li></ul>
assets/narrate.js+46−11
@@ -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,+    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, 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(() => {});   }@@ -98,6 +98,9 @@     $('mic').innerHTML = devs.map((d, i) => `<option value="${d.deviceId}">${esc(d.label || 'Microphone ' + (i + 1))}</option>`).join('');     if (keep) $('mic').value = keep;   }+  try { $('plan').value = localStorage.getItem('aiflow-video-plan') || 'page'; } catch (_) {}+  $('plan').onchange = () => { try { localStorage.setItem('aiflow-video-plan', $('plan').value); } catch (_) {} report();+    toast($('plan').value === 'external' ? 'Got it: this microphone is the guide. Start your recorder before you press Record, and plug it in afterwards so your AI can fetch the clip.' : 'Got it: this microphone is the final sound.', 'info'); };   async function openMic() {     try {       if (stream) stream.getTracks().forEach((x) => x.stop());@@ -243,17 +246,43 @@   // ---------------- 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');+  // where and how the picture sits in the film; x, y are the centre and size is the height, all as fractions of the film+  const PIPDEF = { shape: 'landscape', size: 0.22, x: 0.87, y: 0.68, zoom: 1, ox: 0, oy: 0 }, ASPECT = { landscape: 16 / 9, portrait: 3 / 4, square: 1, circle: 1 };+  let PIP = { ...PIPDEF }, livePip = null; const box = $('pipbox');+  try { PIP = { ...PIPDEF, ...JSON.parse(localStorage.getItem('aiflow-video-pip') || '{}') }; } catch (_) {}+  function pipSize() { const a = ASPECT[PIP.shape] || 16 / 9; return { w: PIP.size * a * 9 / 16, h: PIP.size }; }   // fractions of the film's width and height+  function layout(save) {+    const r = p.getBoundingClientRect(), s = $('stage').getBoundingClientRect(), { w, h } = pipSize();+    PIP.x = Math.max(w / 2 + 0.01, Math.min(1 - w / 2 - 0.01, PIP.x)); PIP.y = Math.max(h / 2 + 0.015, Math.min(1 - h / 2 - 0.015, PIP.y));+    box.style.width = w * r.width + 'px'; box.style.height = h * r.height + 'px';+    box.style.left = (r.left - s.left + (PIP.x - w / 2) * r.width) + 'px'; box.style.top = (r.top - s.top + (PIP.y - h / 2) * r.height) + 'px';+    box.classList.toggle('circle', PIP.shape === 'circle');+    const live = !pip.classList.contains('playback');                     // the live preview is mirrored, like a mirror; the film is not+    pip.style.objectPosition = `${50 + PIP.ox * 50}% ${50 + PIP.oy * 50}%`; pip.style.transform = `${live ? 'scaleX(-1) ' : ''}scale(${PIP.zoom})`;+    $('pip-shape').value = PIP.shape; $('pip-size').value = PIP.size; $('pip-zoom').value = PIP.zoom; $('pip-ox').value = PIP.ox; $('pip-oy').value = PIP.oy;+    $('pip-size-v').textContent = Math.round(PIP.size * 100) + '%'; $('pip-zoom-v').textContent = (+PIP.zoom).toFixed(1) + 'x';+    if (save && !livePip) { try { localStorage.setItem('aiflow-video-pip', JSON.stringify(PIP)); } catch (_) {} report(); }+  }+  addEventListener('resize', () => layout());+  $('pip-shape').onchange = () => { PIP.shape = $('pip-shape').value; layout(true); };+  for (const [id, k] of [['pip-size', 'size'], ['pip-zoom', 'zoom'], ['pip-ox', 'ox'], ['pip-oy', 'oy']]) $(id).oninput = () => { PIP[k] = +$(id).value; layout(true); };+  $('pip-corner').onclick = () => { const right = PIP.x > 0.5, low = PIP.y > 0.5; const [nx, ny] = right && low ? [0, 1] : !right && low ? [0, 0] : !right && !low ? [1, 0] : [1, 1]; PIP.x = nx ? 0.99 : 0.01; PIP.y = ny ? 0.75 : 0.01; layout(true); };+  let dragAt = null;+  box.addEventListener('pointerdown', (e) => { if (livePip) return; box.setPointerCapture(e.pointerId); dragAt = [e.clientX, e.clientY, PIP.x, PIP.y]; box.classList.add('drag'); e.preventDefault(); });+  box.addEventListener('pointermove', (e) => { if (!dragAt) return; const r = p.getBoundingClientRect(); PIP.x = dragAt[2] + (e.clientX - dragAt[0]) / r.width; PIP.y = dragAt[3] + (e.clientY - dragAt[1]) / r.height; layout(); });+  const dragEnd = () => { if (!dragAt) return; dragAt = null; box.classList.remove('drag'); layout(true); };+  box.addEventListener('pointerup', dragEnd); box.addEventListener('pointercancel', dragEnd);   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('');+    $('cam').innerHTML = '<option value="">No camera: voice only, my face is not shown</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'); }+  function closeCam() { if (camStream) camStream.getTracks().forEach((x) => x.stop()); camStream = null; pip.srcObject = null; box.classList.remove('on'); $('pipctl').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; }+    if (!id && !first) { $('camstat').textContent = 'Off: voice only. Pick a camera if you want your face in 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';@@ -261,8 +290,8 @@     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.`;+    pip.classList.remove('playback'); pip.srcObject = camStream; box.classList.add('on'); $('pipctl').classList.add('on'); layout(); pip.play().catch(() => {});+    $('camstat').textContent = `On, ${st.width} x ${st.height}. Drag your picture on the film to place it; set its shape and crop below.`;     try { localStorage.setItem('aiflow-video-cam', $('cam').selectedOptions[0]?.textContent || ''); } catch (_) {}     report();   }@@ -294,7 +323,7 @@     if (!(cal && cal.result === 'ok')) toast('Tip: Calibrate first so your voice is recorded at a good level.', 'info');     stopFlag = false; strokes = []; chunks = []; peak = 0; tRec = {};     setMode('preroll'); say('Countdown before recording');-    v().pause(); p.seekTo(0); capsOn(); v().muted = true;+    v().pause(); p.seekTo(0); capsOn(); v().muted = !info.cueAudio;     if (!(await preroll('Recording starts when this reaches zero. Read each line in your own words.'))) { setMode('idle'); say('Recording cancelled'); return; }     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);@@ -325,7 +354,7 @@      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), 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 };+    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, audioPlan: $('plan').value, pip: camBlob ? { ...PIP } : 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 {@@ -347,6 +376,8 @@       <table><tr><td>Take ${tk.n}</td><td><b>${fmt(tk.seconds)}</b> of your voice${tk.whole ? ', the whole film' : `, stopped at ${fmt(tk.videoStoppedAtS)} of ${fmt(tk.videoDurationS)}`}</td></tr>       <tr><td>Level</td><td><span class="${cls}">${cls === 'good' ? 'Good' : cls === 'warn' ? 'Loud' : 'Quiet'}.</span> ${q}</td></tr>       <tr><td>Drawings</td><td>${tk.strokes.length ? tk.strokes.length + ' saved with the take; they will be drawn into the final film' : 'none'}</td></tr>+      ${tk.cam ? `<tr><td>Camera</td><td>recorded with the take; it goes into the film as your ${(tk.pip && tk.pip.shape) || 'landscape'} picture</td></tr>` : ''}+      ${tk.audioPlan === 'external' ? `<tr><td>Your recorder</td><td>Stop it now and plug it in to this computer, then tell your AI. It will fetch the clip and line it up with this take, which stays as the guide.</td></tr>` : ''}       <tr><td>Saved</td><td>safely, in your project. Your AI can already see it.</td></tr></table>       <div class="next"><b>What happens next.</b> Review it with the film if you like. When you press <b>Use this take</b>, your AI puts your voice on the film, removes the on-screen lines, writes new captions from what you said, and shows you the result. Nothing is published until you say so.</div>       <div class="row"><button class="btn lg" id="f-use">Use this take</button><button class="btn ghost lg" id="f-rev">Review with the film</button><button class="btn ghost lg" id="f-again">Record again</button><button class="btn ghost lg" id="f-close">Close</button></div>`);@@ -371,9 +402,9 @@     reviewTake = tk; reviewAudio = new Audio('takes/' + tk.file); reviewAudio.preload = 'auto';     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().muted = !info.cueAudio; p.seekTo(0); capsOn();     const camOff = off - (tk.camStartsAtMs || 0) / 1000;-    if (tk.cam) { pip.srcObject = null; pip.src = 'takes/' + tk.cam; pip.classList.add('on', 'playback'); }+    if (tk.cam) { pip.srcObject = null; pip.src = 'takes/' + tk.cam; pip.classList.add('playback'); box.classList.add('on'); if (tk.pip) { livePip = { ...PIP }; Object.assign(PIP, tk.pip); } layout(); }     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);@@ -383,7 +414,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(() => {}); } }+    if (pip.classList.contains('playback')) { pip.pause(); pip.removeAttribute('src'); pip.load(); pip.classList.remove('playback'); box.classList.remove('on'); if (livePip) { Object.assign(PIP, livePip); livePip = null; } if (camStream) { pip.srcObject = camStream; box.classList.add('on'); pip.play().catch(() => {}); } layout(); }     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);@@ -439,6 +470,10 @@       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(); },+    pip: async (d) => { await glide('#pipctl', (d.by || 'AI') + ': place your picture');+      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(0.12, Math.min(0.45, PIP.size)); PIP.zoom = Math.max(1, Math.min(2.5, PIP.zoom)); layout(true); },     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'); },
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.2.4",+  "version": "0.2.5",   "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.4",+  "version": "0.2.5",   "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+8
@@ -99,3 +99,11 @@ Raw footage is large. `track` every file you create, on every machine. When the ## 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.++Placement and crop are the human's choice: on the page they pick the shape (landscape, portrait, square, circle), size, zoom and crop, and drag the picture anywhere on the film; `narrate pip "shape=portrait size=0.3 corner=br zoom=1.4"` does the same for them. The whole camera frame is always recorded; the take's json carries `pip` (shape, size as a fraction of the film's height, x and y of its centre as fractions of the film, zoom, ox, oy), so apply that crop and place when you composite, and you can still change it afterwards. "No camera" means no face in the film; never push someone to show theirs.++## Two ways to get the sound, and cues the narrator must hear++Under the microphone the human says how they are set up. **This microphone is my final sound**: use the take as it is. **I am also recording on a separate recorder** (a wireless mic that records on the device, a phone): the page's take is the guide track. Afterwards they plug the recorder in and ask you to fetch the clip; take the newest recording from it, line it up with the guide by cross-correlation, check they stay in step to the end, and use the recorder's sound. `take-N.json` has `audioPlan` so you know which to expect, and the finish card tells them what to do.++The film plays silent while they record, so they never hear the AI voiceover. If the film has a sound they must not talk over (an opening or closing sting), serve a copy whose sound track holds only those cues and start the page with `--cue-audio`: they hear the cue, and you mute those stretches of their recording afterwards because the microphone will have picked it up.
src/main.rs+6−3
@@ -63,7 +63,9 @@ enum Cmd {     Guide,     /// Let the human narrate with their computer's microphone: `narrate --video teleprompter.mp4` serves a page that plays it and records them in step     /// With an action it drives the open page: `narrate status|wait|console|test|calibrate|rehearse|record|stop|play|pause|wipe|reload`, `narrate review|use <take number>`, `narrate note "<text>"`, `narrate gain <x>`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>`-    Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] captions: Option<String>, #[arg(long)] chapters: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16, #[arg(long, default_value_t = 3600)] timeout: u64 },+    Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] captions: Option<String>, #[arg(long)] chapters: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16, #[arg(long, default_value_t = 3600)] timeout: u64,+        /// Play the film's own sound while the human records (use a film whose sound holds only cues they must hear, such as an opening sting, never a voiceover)+        #[arg(long)] cue_audio: bool }, }  fn thread(cli: &Cli) -> String {@@ -209,13 +211,13 @@ fn main() {             ok(&format!("walkthrough director written to {out}"), &["Inject it into the project's Adom 3D viewer page with pup_evaluate, then GHW.setupCase / GHW.setupBoard / GHW.mergeBoard / GHW.run. `guide` explains the steps file and the take.".into()]);         }         Cmd::Guide => print!("{}", include_str!("../docs/walkthrough.md")),-        Cmd::Narrate { action, arg, video, captions, chapters, title, out, port, timeout } => match action.as_deref() {+        Cmd::Narrate { action, arg, video, captions, chapters, title, out, port, timeout, cue_audio } => match action.as_deref() {             None | Some("serve") => {                 let Some(video) = video else { err("narrate needs the teleprompter copy: --video <teleprompter.mp4>", &["Make a working copy of the draft with the captions showing and a short countdown at the start.".into(), "To drive a page that is already open: narrate status | test | record | stop | mic <name> | seek <seconds> | toast \"...\"".into()]) };                 if !Path::new(video).is_file() { err(&format!("could not find the teleprompter video: {video}"), &["Make a working copy of the draft with the captions showing and a short countdown at the start, then pass it with --video.".into()]); }                 let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());                 let dir = out.as_ref().map(PathBuf::from).or_else(|| ledger::Project::find(&start, FLOW_DIR).map(|p| p.path("voice"))).unwrap_or_else(|| start.join("voice"));-                if let Err(e) = narrate::serve(Path::new(video), &dir, *port, captions.as_ref().map(Path::new), chapters.as_ref().map(Path::new), title.as_deref()) { err(&e, &["Another narration page may still be running on that port; leave --port off to take a free one.".into()]); }+                if let Err(e) = narrate::serve(Path::new(video), &dir, *port, captions.as_ref().map(Path::new), chapters.as_ref().map(Path::new), title.as_deref(), *cue_audio) { err(&e, &["Another narration page may still be running on that port; leave --port off to take a free one.".into()]); }             }             Some(act) => {                 let p = if *port != 0 { *port } else { narrate::find_port().unwrap_or_else(|| err("no narration page is running", &["Start one: adom-aiflow-video narrate --video <teleprompter.mp4>, then open its address in the human's browser.".into()])) };@@ -262,6 +264,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}),+                        ("pip", Some(a)) => json!({"spec": 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"}),
src/narrate.rs+4−3
@@ -289,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", "cam", "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", "pip", "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();@@ -322,6 +322,7 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>             m["file"] = file.clone().into(); m["bytes"] = body.len().into(); m["seconds"] = secs.into(); m["video"] = video.display().to_string().into();             let _ = std::fs::write(out.join(format!("take-{n}.json")), serde_json::to_string_pretty(&m).unwrap_or_default() + "\n");             println!("OK: take {n} saved: {} ({:.0} s, peak {})", out.join(&file).display(), secs, m["peak"]);+            if m["audioPlan"] == "external" { println!("Hint: the human is also recording on a separate recorder, so this take is the guide track. When they plug it in, fetch the newest clip from it, line it up with this take (cross-correlate the two; trim the offset), check the two stay in step to the end, and use the recorder's sound in the film."); }             println!("Hint: listen to it before you cut (clipping, room noise, missed scenes); the video starts {} ms into the recording.", m["videoStartsAtMs"]);             broadcast(&sh, "state", &json!({"takes": takes_in(&out)}));             respond(&mut s, "200 OK", "application/json", "", serde_json::json!({"file": file, "seconds": secs}).to_string().as_bytes())@@ -354,14 +355,14 @@ pub fn call(port: u16, method: &str, path: &str, ai_thread: Option<&str>, body: }  /// Serve until stopped. Returns only when the port cannot be bound.-pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapters: Option<&Path>, title: Option<&str>) -> Result<(), String> {+pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapters: Option<&Path>, title: Option<&str>, cue_audio: bool) -> Result<(), String> {     let l = TcpListener::bind(("0.0.0.0", port)).map_err(|e| format!("could not listen on port {port}: {e}"))?;     let port = l.local_addr().map(|a| a.port()).unwrap_or(port);     let caps = captions.map(Path::to_path_buf).or_else(|| sibling(video, &["vtt", "srt"]));     let chaps = chapters.map(Path::to_path_buf).or_else(|| sibling(video, &["chapters.json"])).or_else(|| video.parent().map(|d| d.join("chapters.json")).filter(|p| p.is_file()));     let name = title.map(str::to_string).unwrap_or_else(|| video.file_stem().map(|x| x.to_string_lossy().replace(['-', '_'], " ")).unwrap_or_default());     let sh = Arc::new(Mutex::new(Shared { clients: vec![], console: vec![], state: json!({"app": "adom-aiflow-video narrate", "version": env!("CARGO_PKG_VERSION"), "port": port, "title": name, "video": video.display().to_string(), "out": out.display().to_string(),-        "captionsFile": caps.as_ref().map(|p| p.display().to_string()), "chaptersFile": chaps.as_ref().map(|p| p.display().to_string()), "settings": load_settings(), "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) }));+        "captionsFile": caps.as_ref().map(|p| p.display().to_string()), "chaptersFile": chaps.as_ref().map(|p| p.display().to_string()), "settings": load_settings(), "cueAudio": cue_audio, "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) }));     if caps.is_none() { println!("Hint: no captions file beside the film (same name, .vtt or .srt), so the human has no lines to read: pass --captions <file>."); }     if chaps.is_none() { println!("Hint: no chapters file (same name, .chapters.json): pass --chapters <file> so the timeline shows where each part starts."); }     let _ = std::fs::create_dir_all(registry());