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.5.0
15 files changed
+150−17
Cargo.lock+1−1Cargo.toml+1−1README.md+7−4assets/narrate.css+15assets/narrate.html+3assets/narrate.js+32−1bin/adom-aiflow-videodocs/screenshots/title-thumbnail-picker.jpgflows/flow.json+8−1package.json+1−1page.json+1−1skills/adom-aiflow-video/SKILL.md+8src/main.rs+18−5src/narrate.rs+29−1src/youtube.rs+26−1Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.4.0"+version = "0.5.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.4.0"+version = "0.5.0" edition = "2021" license = "MIT"
README.md+7−4@@ -41,9 +41,11 @@ The AI voiceover is fine for a draft. A film people want to watch has a person i ## YouTube -- **Title and thumbnail, together with you.** The AI offers a handful of titles and `youtube title` gives feedback on each; it composes two or three thumbnails from a frame of the film, a frame of you and a few large words, and `youtube thumbnail` checks the size YouTube wants. You pick.+- **Title and thumbnail first, together with you.** Before you record, the AI brings a handful of titles and two or three thumbnails, and the narration page shows each pair the way YouTube will. You pick, or ask for other ideas. What the thumbnail promises is what your opening lines should deliver, so this comes before the voice. - **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` sends the film as private or unlisted first, so you can watch it on YouTube before the world does. When adom-google cannot reach your channel, the AI uploads through YouTube Studio in a browser it drives.+- **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.++  @@ -88,7 +90,8 @@ disk --clips N --seconds S clip start|stop mark <step> --note "..." script <script.json> scan <files...> track <path> clean [--yes] director --out <file.js> guide narrate --video <draft.mp4> brand [--video <final.mp4> --out <branded.mp4>]-youtube check | title "..." | thumbnail <file> | upload --file f.mp4 --title T --description-file d.txt --thumbnail t.jpg --privacy unlisted+youtube check | title "..." | thumbnail <file> | upload --file f.mp4 --title T --description-file d.txt --thumbnail t.jpg | open <link> | captions <link> --file f.srt+narrate pitch --titles "A" "B" --thumbs a.jpg b.jpg ``` Every command answers `OK:` with `Hint:` lines. The skill is in [skills/adom-aiflow-video/SKILL.md](skills/adom-aiflow-video/SKILL.md); the walkthrough guide is in [docs/walkthrough.md](docs/walkthrough.md).@@ -99,4 +102,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off ## Status -0.4.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.+0.5.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+15@@ -154,3 +154,18 @@ kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-r .pipbar { top: auto; bottom: 100%; padding-bottom: 8px; } .pipbox.barlow .pipbar { bottom: auto; top: 100%; padding: 8px 0 0; } .piph { margin: -3px; }++/* title and thumbnail: shown the way YouTube shows them */+.pitchbox { display: none; gap: 10px; align-items: center; padding: 8px; margin: 0 0 10px; border: 1px solid var(--accent); border-radius: 10px; cursor: pointer; background: rgba(0,184,177,.08); }+.pitchbox.on { display: flex; } .pitchbox img { width: 96px; aspect-ratio: 16/9; object-fit: cover; border-radius: 6px; background: #000; }+.pitchbox b { display: block; font: 600 13px var(--font-body); color: var(--text); } .pitchbox small { color: var(--muted); font-size: 12px; }+.pitch-thumbs { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 10px 0 14px; }+.pitch-thumbs button, .pitch-titles button { all: unset; cursor: pointer; border: 2px solid var(--border); border-radius: 10px; box-sizing: border-box; }+.pitch-thumbs button { position: relative; overflow: hidden; } .pitch-thumbs img { display: block; width: 100%; aspect-ratio: 16/9; object-fit: cover; }+.pitch-thumbs button.is-on, .pitch-titles button.is-on { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,177,.25); }+.pitch-titles { display: flex; flex-direction: column; gap: 6px; margin: 0 0 14px; } .pitch-titles button { padding: 8px 12px; font: 500 15px var(--font-body); color: var(--text); width: 100%; }+.pitch-titles button.is-on { background: rgba(0,184,177,.1); }+.pitch-preview { display: flex; gap: 14px; align-items: flex-start; padding: 12px; border-radius: 12px; background: #0f0f0f; margin: 0 0 14px; }+.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; }
assets/narrate.html+3@@ -43,6 +43,9 @@ </section> <aside>+ <div class="pitchbox" id="pitchbox" role="button" tabindex="0" data-tooltip="The title and thumbnail for YouTube. Pick them before you record: what they promise is what your narration should deliver.">+ <img id="pitch-img" alt=""><div><b id="pitch-title">Title and thumbnail</b><small id="pitch-sub">Your AI has ideas for you to pick from</small></div>+ </div> <h2>Steps</h2> <ol class="steps-list" id="steps"> <li id="st-mic" role="button" tabindex="0" data-tooltip="Calibrate your microphone: about 15 seconds. You can do this again at any time."><span class="n">1</span><div><b>Check your microphone</b><small id="st-mic-s">Not checked yet</small></div></li>
assets/narrate.js+32−1@@ -525,6 +525,7 @@ async function refreshTakes() { const st = await api('state').catch(() => ({})); takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));+ if (st.pitch && st.pitch.thumbs) { pitch = st.pitch; pitchChoice = st.pitchChoice || null; pitchBox(); } chosen = st.chosen ? takes.find((t) => t.file === st.chosen) || null : null; setNote(st.note || ''); renderTakes(); header(); }@@ -543,11 +544,40 @@ clearTimeout(armT); armed = false; b.classList.remove('armed'); b.textContent = 'Start over'; startOver(); }; + // ---------------- title and thumbnail: picked before the narration, shown the way YouTube shows them ----------------+ let pitch = null, pitchChoice = null;+ function pitchBox() {+ const on = !!(pitch && pitch.thumbs && pitch.thumbs.length); $('pitchbox').classList.toggle('on', on); if (!on) return;+ const i = pitchChoice ? Math.max(0, pitch.thumbs.indexOf(pitchChoice.thumbnail)) : 0;+ $('pitch-img').src = 'pitch-thumb/' + i + '?v=' + pitch.thumbs.length;+ $('pitch-title').textContent = pitchChoice ? pitchChoice.title : 'Title and thumbnail';+ $('pitch-sub').textContent = pitchChoice ? 'Your pick for YouTube. Click to change it.' : `Your AI has ${pitch.titles.length} titles and ${pitch.thumbs.length} thumbnails for you`;+ }+ function showPitch() {+ if (!pitch) return; let ti = pitchChoice ? Math.max(0, pitch.titles.indexOf(pitchChoice.title)) : 0, hi = pitchChoice ? Math.max(0, pitch.thumbs.indexOf(pitchChoice.thumbnail)) : 0;+ const d = v().duration || 0;+ modal(`<h2>How it will look on YouTube</h2>+ <p>Pick the thumbnail and the title before you record. What they promise is what the first lines of your narration should deliver.</p>+ <div class="pitch-preview"><div class="th"><img id="pp-img" alt=""><span class="dur">${fmt(d)}</span></div><div><div class="tt" id="pp-title"></div><div class="ch">Your channel</div></div></div>+ <div class="pitch-thumbs">${pitch.thumbs.map((_, i) => `<button data-h="${i}" aria-label="Thumbnail ${i + 1}"><img src="pitch-thumb/${i}?v=${pitch.thumbs.length}" alt="Thumbnail ${i + 1}"></button>`).join('')}</div>+ <div class="pitch-titles">${pitch.titles.map((t, i) => `<button data-t="${i}">${esc(t)}</button>`).join('')}</div>+ <div class="row"><button class="btn lg" id="pp-use">Use this pair</button><button class="btn ghost lg" id="pp-more">Ask my AI for other ideas</button><button class="btn ghost lg" id="pp-close">Close</button></div>`);+ const paint = () => { $('pp-img').src = 'pitch-thumb/' + hi + '?v=' + pitch.thumbs.length; $('pp-title').textContent = pitch.titles[ti];+ document.querySelectorAll('.pitch-thumbs button').forEach((b) => b.classList.toggle('is-on', +b.dataset.h === hi)); document.querySelectorAll('.pitch-titles button').forEach((b) => b.classList.toggle('is-on', +b.dataset.t === ti)); };+ document.querySelectorAll('.pitch-thumbs button').forEach((b) => b.onclick = () => { hi = +b.dataset.h; paint(); });+ document.querySelectorAll('.pitch-titles button').forEach((b) => b.onclick = () => { ti = +b.dataset.t; paint(); });+ paint();+ $('pp-use').onclick = async () => { const r = await api('pitch/choose', { title: pitch.titles[ti], thumb: hi }).catch((e) => ({ error: e.message })); if (r.error) return toast('Could not save your pick: ' + r.error, 'error'); pitchChoice = { title: pitch.titles[ti], thumbnail: pitch.thumbs[hi] }; pitchBox(); closeModal(); toast('Saved. Your AI will use this title and thumbnail, and shape the opening to match.', 'success'); };+ $('pp-more').onclick = async () => { await api('pitch/choose', { more: true }).catch(() => {}); closeModal(); toast('Asked your AI for other ideas.', 'info'); };+ $('pp-close').onclick = closeModal;+ }+ $('pitchbox').onclick = showPitch; $('pitchbox').onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') showPitch(); };+ // ---------------- 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.note !== undefined) setNote(d.note); if (d.settings) { S = { ...S, ...d.settings }; $('fade-s').textContent = S.drawFade; } },+ 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; } }, 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'),@@ -561,6 +591,7 @@ 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); },+ pitch: (d) => { pitch = { titles: d.titles || [], thumbs: d.thumbs || [] }; 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'); },
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
docs/screenshots/title-thumbnail-picker.jpgadded⋯ 1 unchanged line ⋯
flows/flow.json+8−1@@ -44,6 +44,13 @@ ], "do": "Narrate with adom-tts, fit each clip to its line (1x to 2x, never dead air, never a flashed shot), add teal circles where the eye should go, and write captions (.vtt, .srt) and chapters as sidecar files, never burned in. Render 1080p one section at a time. Make a contact sheet and look at every scene before calling it a draft." },+ {+ "name": "pitch",+ "done": [+ "pitch.json"+ ],+ "do": "Before anyone narrates, settle the title and the thumbnail, because the narration should deliver what they promise. Brainstorm about five titles (`youtube title ...` gives feedback) and compose two or three thumbnails at 1280 x 720 from a clean frame of the film and 3 to 5 large words (`youtube thumbnail <file>` checks each). Show them on the narration page: `narrate pitch --titles \"A\" \"B\" ... --thumbs a.jpg b.jpg ...` opens a picker that shows each pair the way YouTube will. The human picks a pair, or asks for other ideas; their pick lands in pitch.json in the takes folder. Then shape the first lines of the script to pay off the thumbnail's words. `mark pitch --note \"<title>\"`."+ }, { "name": "voice", "done": [@@ -70,7 +77,7 @@ "done": [ "publish.json" ],- "do": "With the human's OK: push the film, poster, captions and a video.json manifest (captions and chapters inlined) to the project's wiki page and put `::adom-video{src=...}` under the title; check the live player. For YouTube: brainstorm about five titles with them (`youtube title \"...\" \"...\"` gives feedback) and show two or three thumbnails composed from a frame of the film, a frame of them and 3 to 5 large words (`youtube thumbnail <file>` checks it); write a description with what the film shows, chapters (`m:ss Title` from 0:00, each at least 10 s) and a link back to the wiki page. Run `youtube check` to see which channel each adom-google account reaches, say the channel's name back to them, and if none reaches it suggest setting adom-google up (the hint says how) or upload through YouTube Studio in a browser you drive. `youtube upload ... --privacy private` (or unlisted) first, so they can watch it on YouTube itself; make it public only when they say so. `mark publish --note \"<links>\"`."+ "do": "With the human's OK: push the film, poster, captions and a video.json manifest (captions and chapters inlined) to the project's wiki page and put `::adom-video{src=...}` under the title; check the live player. For YouTube: brainstorm about five titles with them (`youtube title \"...\" \"...\"` gives feedback) and show two or three thumbnails composed from a frame of the film, a frame of them and 3 to 5 large words (`youtube thumbnail <file>` checks it); write a description with what the film shows, chapters (`m:ss Title` from 0:00, each at least 10 s) and a link back to the wiki page. Run `youtube check` to see which channel each adom-google account reaches, say the channel's name back to them, and if none reaches it suggest setting adom-google up (the hint says how) or upload through YouTube Studio in a browser you drive. Use the title and thumbnail they picked (pitch.json). `youtube upload ...` uploads as unlisted, always, on their behalf: only people with the link see it, and public is their own decision afterwards. It then opens the video's YouTube Studio page in a browser window for them; bring it to the front. Upload the film's own captions next (`youtube captions <link> --file <film.srt>` opens the Subtitles page and says how), because YouTube's automatic captions misspell names. `mark publish --note \"<links>\"`." }, { "name": "cleanup",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.4.0",+ "version": "0.5.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.4.0",+ "version": "0.5.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+8@@ -123,3 +123,11 @@ The title and the thumbnail decide whether anyone presses play, so do them with Write the description in their voice: the hook in the first two lines, the link to the project's wiki page, what the film shows, chapters as `m:ss Title` lines starting at 0:00 with every chapter at least 10 seconds long, and what it was built with. `youtube check` lists each adom-google account and the channel it uploads to. A person often has several channels (personal, family, company): ask which one, and say its name back before uploading. YouTube access cannot be requested together with other Google access, so it needs its own adom-google account, authorised with only the two YouTube scopes and with the right channel picked on Google's "Choose your account or a brand account" screen; the hint spells out the steps. If no account reaches their channel (no adom-google, or the Google app their account signs in with has the YouTube API switched off), tell them plainly, offer to set adom-google up, and otherwise upload through YouTube Studio in a browser you drive: Create, Upload videos, the file, title, description, thumbnail, Not made for kids, Visibility. Upload private or unlisted first with `youtube upload`; public is their call after they have watched it on YouTube.++## Title and thumbnail come before the voice++The thumbnail's few words are a promise, and the narration has to keep it, so settle them before the human records. `narrate pitch --titles "A" "B" "C" --thumbs one.jpg two.jpg` puts a picker on the narration page that shows each thumbnail and title the way YouTube will (the picture with the duration badge, the title beside it). They pick a pair or press "Ask my AI for other ideas"; the pick is saved as `pitch.json` in the takes folder and stays on the page as a small card. Then adjust the opening lines they will read so the film starts by paying off those words.++## 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.
src/main.rs+18−5@@ -57,7 +57,7 @@ enum Cmd { /// What this run left behind, and with --yes remove the local files not marked keep Clean { #[arg(long)] yes: bool }, /// YouTube for any user: `youtube check` (which channel each adom-google account reaches, and how to set one up), `youtube title "A" "B"` (feedback on titles), `youtube thumbnail <file>`, `youtube upload --file f.mp4 --title T --description-file d.txt [--thumbnail t.jpg] [--privacy private|unlisted] [--account name]`- Youtube { action: String, items: Vec<String>, #[arg(long)] file: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] description_file: Option<String>, #[arg(long)] thumbnail: Option<String>, #[arg(long, default_value = "private")] privacy: String, #[arg(long)] account: Option<String>, #[arg(long)] tags: Option<String> },+ Youtube { action: String, items: Vec<String>, #[arg(long)] file: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] description_file: Option<String>, #[arg(long)] thumbnail: Option<String>, #[arg(long, default_value = "unlisted")] privacy: String, #[arg(long)] account: Option<String>, #[arg(long)] tags: Option<String> }, /// Adom's logo sting, for official Adom videos by Adom employees only: `brand` says whether it applies; `brand --video final.mp4 --out branded.mp4` adds it Brand { #[arg(long)] video: Option<String>, #[arg(long)] out: Option<String> }, /// Write the ready-made 3D walkthrough director (JavaScript for the Adom 3D viewer): `director --out walkthrough-director.js`@@ -68,7 +68,11 @@ enum Cmd { /// 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, /// 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 },+ #[arg(long)] cue_audio: bool,+ /// With `narrate pitch`: the title ideas to show the human (one or more, quoted)+ #[arg(long, num_args = 1..)] titles: Vec<String>,+ /// With `narrate pitch`: the thumbnail pictures to show beside them+ #[arg(long, num_args = 1..)] thumbs: Vec<String> }, } fn thread(cli: &Cli) -> String {@@ -183,12 +187,16 @@ fn main() { "check" => { let (m, h) = youtube::check(); ok(&m, &h); } "title" | "titles" => { if items.is_empty() { err("give one or more titles in quotes", &["youtube title \"AI Designed a PCB for My Dorm Faucet\" \"...\"".into()]); } let (m, h) = youtube::title(items); ok(&m, &h); } "thumbnail" => { let f = items.first().cloned().or(thumbnail.clone()).unwrap_or_else(|| err("which picture?", &["youtube thumbnail thumb.jpg".into()])); let (m, h) = youtube::thumbnail(&f); ok(&m, &h); }+ "open" | "captions" => {+ let v = items.first().cloned().unwrap_or_else(|| err("which video? give its link or id", &["youtube open https://youtu.be/<id>".into()]));+ let (m, h) = youtube::open(&v, action == "captions", file.as_deref(), cli.ai_thread.as_deref().unwrap_or("adom-aiflow-video")); ok(&m, &h);+ } "upload" => { let (Some(f), Some(t)) = (file, title) else { err("upload needs --file and --title", &["youtube upload --file film.mp4 --title \"...\" --description-file description.txt --thumbnail thumb.jpg --privacy unlisted".into()]) }; if !["private", "unlisted", "public"].contains(&privacy.as_str()) { err("--privacy is private, unlisted or public", &[]); }- match youtube::upload(&youtube::Upload { file: f, title: t, description_file: description_file.as_deref(), thumbnail: thumbnail.as_deref(), privacy, account: account.as_deref(), tags: tags.as_deref() }) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ match youtube::upload(&youtube::Upload { file: f, title: t, description_file: description_file.as_deref(), thumbnail: thumbnail.as_deref(), privacy, account: account.as_deref(), tags: tags.as_deref() }) { Ok((m, mut h)) => { if let Some(u) = m.split_whitespace().find(|w| w.contains("youtu")) { let (m2, h2) = youtube::open(u, false, None, cli.ai_thread.as_deref().unwrap_or("adom-aiflow-video")); h.insert(0, m2); h.extend(h2.into_iter().take(1)); } ok(&m, &h) }, Err((m, h)) => err(&m, &h) } }- _ => err("youtube takes: check, title, thumbnail, upload", &[]),+ _ => err("youtube takes: check, title, thumbnail, upload, open, captions", &[]), }, Cmd::Brand { video, out } => { match (video, out) {@@ -225,7 +233,7 @@ 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, cue_audio } => match action.as_deref() {+ Cmd::Narrate { action, arg, video, captions, chapters, title, out, port, timeout, cue_audio, titles, thumbs } => 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()]); }@@ -280,6 +288,11 @@ 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}),+ ("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();+ json!({"titles": titles, "thumbs": abs})+ } ("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+29−1@@ -257,6 +257,33 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> broadcast(&sh, "state", &json!({"takes": takes_in(&out)})); respond(&mut s, "200 OK", "application/json", "", json!({"cam": cam}).to_string().as_bytes()) }+ // the title and thumbnail ideas the AI brought: pictures by index, and the human's pick+ ("GET", p) if p.starts_with("/pitch-thumb/") => {+ let i: usize = p.trim_start_matches("/pitch-thumb/").parse().unwrap_or(usize::MAX);+ let f = sh.lock().unwrap().state["pitch"]["thumbs"][i].as_str().map(PathBuf::from);+ match f { Some(f) if f.is_file() => serve_file(&mut s, &f, None), _ => respond(&mut s, "404 Not Found", "text/plain", "", b"") }+ }+ ("POST", "/pitch/choose") => {+ let mut body = vec![0u8; len];+ let _ = rd.read_exact(&mut body);+ let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));+ let thumb = sh.lock().unwrap().state["pitch"]["thumbs"][req["thumb"].as_u64().unwrap_or(0) as usize].clone();+ if req["more"] == true {+ let why = req["why"].as_str().unwrap_or("");+ println!("OK: the human wants other title and thumbnail ideas{}", if why.is_empty() { String::new() } else { format!(": {why}") });+ println!("Hint: bring a new set with `narrate pitch --titles ... --thumbs ...` and tell them on the page (`narrate note`) what you changed.");+ let n = "Your AI is making new title and thumbnail ideas."; sh.lock().unwrap().state["note"] = n.into(); broadcast(&sh, "note", &json!({"text": n}));+ return respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}");+ }+ let pick = json!({"title": req["title"], "thumbnail": thumb, "t": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)});+ let _ = std::fs::create_dir_all(&out);+ let _ = std::fs::write(out.join("pitch.json"), serde_json::to_string_pretty(&pick).unwrap_or_default() + "\n");+ sh.lock().unwrap().state["pitchChoice"] = pick.clone();+ println!("OK: the human picked the title \"{}\" and the thumbnail {} ({})", pick["title"].as_str().unwrap_or(""), pick["thumbnail"].as_str().unwrap_or(""), out.join("pitch.json").display());+ println!("Hint: the thumbnail's words are a promise. Make the first lines of the film pay them off: adjust the opening of the script (and the line they read first) before they record, and use exactly this title and thumbnail at upload.");+ broadcast(&sh, "state", &json!({"pitchChoice": pick}));+ 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,12 +324,13 @@ 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", "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", "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(); 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(); g.state["pitch"] = json!({"titles": data["titles"], "thumbs": data["thumbs"]}); g.state["pitchChoice"] = Value::Null; } if ["toast", "click", "reveal", "cursor", "reload"].contains(&verb.as_str()) { data["type"] = verb.clone().into(); if data["label"].is_null() { data["label"] = who.clone().into(); }
src/youtube.rs+26−1@@ -83,7 +83,7 @@ pub fn upload(u: &Upload) -> Result<(String, Vec<String>), (String, Vec<String>) Some((false, out)) => Err(("the upload did not go through".into(), vec![out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(300).collect(), "Run `youtube check` to see which channel each account reaches.".into(), STUDIO.into()])), Some((true, out)) => { let url = out.split('"').find(|s| s.contains("youtube.com/watch") || s.contains("youtu.be/")).unwrap_or("").to_string();- let mut hints = vec![format!("It is {}: only people with the link can find it. Give the human the link and the channel name, and have them watch it on YouTube itself (picture quality, thumbnail, captions, chapter markers) before anything is made public.", u.privacy)];+ let mut hints = vec![format!("It is {}: only people with the link can find it (unlisted is the default, so the human always sees it on YouTube before the world does). Give the human the link and the channel name, and have them watch it on YouTube itself (picture quality, thumbnail, captions, chapter markers) before anything is made public.", u.privacy)]; if u.privacy == "public" { hints[0] = "It is public. Next time upload as private or unlisted first so the human can check it on YouTube before the world sees it.".into(); } hints.push("Chapters come from the description: a list of `m:ss Title` lines that starts at 0:00, with every chapter at least 10 seconds long. Captions: upload the film's .srt in YouTube Studio (Subtitles), or leave YouTube's automatic ones.".into()); hints.push("Then `mark publish --note \"<link>\"`.".into());@@ -91,3 +91,28 @@ pub fn upload(u: &Upload) -> Result<(String, Vec<String>), (String, Vec<String>) } } }++/// The video's id from a link (youtu.be/<id>, watch?v=<id>, studio .../video/<id>/...) or the id itself.+pub fn video_id(v: &str) -> String {+ for k in ["youtu.be/", "watch?v=", "/video/", "/shorts/"] { if let Some(i) = v.find(k) { return v[i + k.len()..].chars().take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect(); } }+ v.trim().to_string()+}++/// Open the video's YouTube Studio page in a browser window on the human's desktop, so they land on it+/// without hunting: Details by default, Subtitles for captions.+pub fn open(video: &str, captions: bool, srt: Option<&str>, thread: &str) -> (String, Vec<String>) {+ let id = video_id(video);+ let url = format!("https://studio.youtube.com/video/{id}/{}", if captions { "translations" } else { "edit" });+ let args = serde_json::json!({"sessionId": "aiflow-video-youtube", "url": url, "purpose": "the human's YouTube Studio page for the film just uploaded"}).to_string();+ let opened = Command::new("adom-bridge").args(["--ai-thread", thread, "pup_open_window", &args]).output().map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("\"ok\":true") || String::from_utf8_lossy(&o.stdout).contains("\"ok\": true")).unwrap_or(false);+ let mut hints = vec![];+ if !opened { hints.push(format!("Could not open a browser window through Adom Bridge. Give the human this link instead: {url}")); }+ hints.push("If Studio shows the wrong channel, the browser is signed in to another Google account: add `?authuser=1` (or 2) to the link, or use the account switcher, until the channel's name matches the one the human named.".into());+ if captions {+ hints.push(format!("Captions: YouTube's automatic ones misspell names, so upload the film's own .srt{}. On this Subtitles page press `Upload manual`, keep `With timing`, press `Continue`, and give the file to the page's file input (`input#captions-file-loader`; with pup: `pup_upload_file`). Studio then holds them as a draft: open `Edit subtitles`, read the first lines back to check they are yours, and press `Publish`.", srt.map(|f| format!(" ({f})")).unwrap_or_default()));+ hints.push("Check afterwards that the video lists an `English` track beside `English (auto-generated)`.".into());+ } else {+ hints.push("Bring that window to the front and tell the human it is their video's page: title, description, thumbnail and visibility are all there to change. Then do the captions: `youtube captions <link> --file <film.srt>`.".into());+ }+ (if opened { format!("YouTube Studio is open for the human: {url}") } else { format!("YouTube Studio page: {url}") }, hints)+}