← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.2.5"+version = "0.2.6" 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.5"+version = "0.2.6" 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.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.+0.2.6. 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+14
@@ -134,3 +134,17 @@ kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-r .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; } }+.piph { position: absolute; width: 22px; height: 22px; z-index: 2; opacity: 0; transition: opacity .15s; }+.pipbox:hover .piph, .pipbox.drag .piph { opacity: 1; }+.piph::after { content: ""; position: absolute; inset: 4px; border: 3px solid var(--accent); border-radius: 2px; }+.piph.tl { left: 0; top: 0; cursor: nwse-resize; } .piph.tl::after { border-right: none; border-bottom: none; }+.piph.tr { right: 0; top: 0; cursor: nesw-resize; } .piph.tr::after { border-left: none; border-bottom: none; }+.piph.bl { left: 0; bottom: 0; cursor: nesw-resize; } .piph.bl::after { border-right: none; border-top: none; }+.piph.br { right: 0; bottom: 0; cursor: nwse-resize; } .piph.br::after { border-left: none; border-top: none; }+.pipbox.circle { overflow: visible; } .pipbox.circle .campip { border-radius: 50%; }+.pipbar { position: absolute; left: 50%; top: 6px; transform: translateX(-50%); display: flex; gap: 4px; z-index: 3; opacity: 0; transition: opacity .15s; white-space: nowrap; }+.pipbox:hover .pipbar { opacity: 1; } .pipbox.drag .pipbar { opacity: 0; }+.pipbar button { background: rgba(13,17,23,.85); color: var(--text); border: 1px solid var(--accent); border-radius: 999px; padding: 3px 10px; font: 500 12px var(--font-body); cursor: pointer; }+.pipbar button:hover { background: var(--accent); color: #04211f; }+.piptoggle { width: 100%; margin: 0 0 6px; text-align: left; } .piptoggle b { float: right; color: var(--muted); }+.piptoggle.is-on { border-color: var(--accent); background: rgba(0,184,177,.12); } .piptoggle.is-on b { color: var(--accent); }
assets/narrate.html+3−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>-    <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="pipbox" id="pipbox" data-tooltip="You, as you will appear in the film. Drag to move it, drag a corner to resize it, or scroll over it."><video class="campip" id="campip" muted playsinline></video><div class="pipbar" id="pipbar"><button id="pipb-shape" data-tooltip="Change the shape: landscape, portrait, square, circle">Shape</button><button id="pipb-off" data-tooltip="Turn your picture off. Your voice is still recorded. Turn it back on under Camera.">Hide me</button></div><i class="piph tl" data-c="tl"></i><i class="piph tr" data-c="tr"></i><i class="piph bl" data-c="bl"></i><i class="piph br" data-c="br"></i></div>     <div class="recbadge" id="recbadge"><i></i><span id="recbadge-text">Recording</span></div>   </section> @@ -67,6 +67,7 @@     </div>      <h2>Camera</h2>+    <button class="btn ghost piptoggle" id="pip-toggle" aria-pressed="false" data-tooltip="Show your face in a corner of the film, or record your voice only. Your choice; nothing is recorded from the camera when this is off.">Show my face in the film: <b>Off</b></button>     <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">@@ -75,7 +76,7 @@         <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="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.1" max="0.6" 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>
assets/narrate.js+24−7
@@ -267,10 +267,27 @@   $('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); };+  const SHAPES = ['landscape', 'portrait', 'square', 'circle'];+  $('pipb-shape').onclick = (e) => { e.stopPropagation(); PIP.shape = SHAPES[(SHAPES.indexOf(PIP.shape) + 1) % SHAPES.length]; layout(true); toast('Shape: ' + PIP.shape, 'info'); };+  $('pipb-off').onclick = (e) => { e.stopPropagation(); $('pip-toggle').click(); };+  for (const b of ['pipb-shape', 'pipb-off']) $(b).addEventListener('pointerdown', (e) => e.stopPropagation());+  function toggleUi() { const on = !!camStream; const t = $('pip-toggle'); t.classList.toggle('is-on', on); t.setAttribute('aria-pressed', on); t.querySelector('b').textContent = on ? 'On' : 'Off'; }+  $('pip-toggle').onclick = () => { if (mode === 'record') return toast('Finish this take first.', 'info');+    if (camStream) { $('cam').value = ''; try { localStorage.removeItem('aiflow-video-cam'); } catch (_) {} closeCam(); $('camstat').textContent = 'Off: voice only. Your face is not recorded.'; toast('Your picture is off. Voice only.', 'info'); report(); }+    else openCam(true).then(() => camStream && toast('Your picture is on. Drag it, resize it from a corner, or change its shape.', 'success')); };   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); };+  const SMIN = 0.1, SMAX = 0.6; let sizeAt = null;                       // resizing: the opposite corner stays where it is+  box.addEventListener('pointerdown', (e) => { if (livePip) return; box.setPointerCapture(e.pointerId); box.classList.add('drag'); e.preventDefault();+    const c = e.target.dataset && e.target.dataset.c;+    if (c) { const { w, h } = pipSize(); sizeAt = { ax: PIP.x + (c[1] === 'l' ? w / 2 : -w / 2), ay: PIP.y + (c[0] === 't' ? h / 2 : -h / 2), sx: c[1] === 'l' ? -1 : 1, sy: c[0] === 't' ? -1 : 1 }; }+    else dragAt = [e.clientX, e.clientY, PIP.x, PIP.y]; });+  box.addEventListener('pointermove', (e) => { const r = p.getBoundingClientRect();+    if (sizeAt) { const a = (ASPECT[PIP.shape] || 16 / 9) * 9 / 16, py = (e.clientY - r.top) / r.height, px = (e.clientX - r.left) / r.width;+      PIP.size = Math.max(SMIN, Math.min(SMAX, Math.max(Math.abs(py - sizeAt.ay), Math.abs(px - sizeAt.ax) / a)));+      PIP.x = sizeAt.ax + sizeAt.sx * PIP.size * a / 2; PIP.y = sizeAt.ay + sizeAt.sy * PIP.size / 2; return layout(); }+    if (!dragAt) return; PIP.x = dragAt[2] + (e.clientX - dragAt[0]) / r.width; PIP.y = dragAt[3] + (e.clientY - dragAt[1]) / r.height; layout(); });+  box.addEventListener('wheel', (e) => { if (livePip) return; e.preventDefault(); PIP.size = Math.max(SMIN, Math.min(SMAX, PIP.size * (1 - e.deltaY * 0.0012))); layout(true); }, { passive: false });+  const dragEnd = () => { if (!dragAt && !sizeAt) return; dragAt = null; sizeAt = 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');@@ -278,7 +295,7 @@     $('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; box.classList.remove('on'); $('pipctl').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'); toggleUi(); }   async function openCam(first) {     closeCam();     const id = $('cam').value;@@ -291,9 +308,9 @@     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; 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.`;+    $('camstat').textContent = `On, ${st.width} x ${st.height}. Drag your picture to move it, drag a corner to resize it; shape and crop are below.`;     try { localStorage.setItem('aiflow-video-cam', $('cam').selectedOptions[0]?.textContent || ''); } catch (_) {}-    report();+    report(); toggleUi();   }   $('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() {@@ -473,7 +490,7 @@     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); },+      PIP.size = Math.max(SMIN, Math.min(SMAX, 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.5",+  "version": "0.2.6",   "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.5",+  "version": "0.2.6",   "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",