main
John Lauer Publish 0.7.0 8ff960b 8h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
// AI Flow Video, the narration page. The film plays in the real <adom-video> player; this script adds
// the microphone, calibration, recording with a visible clock, drawing on the film, review and the hand-off
// to the AI. State lives on the server (static/ui.js gives tooltips, toasts, settings, the AI cursor).
(() => {
  const $ = (id) => document.getElementById(id);
  const { api, toast, hoverPopover, initSettings, connect, uiCommon, glide } = UI;
  // this app's mark: a microphone, the same line drawing as the tab icon
  window.ICONS.mark = '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5V21"/><path d="M8.5 21h7"/>';
  const p = $('player');
  const fmt = (s) => { s = Math.max(0, Math.round(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  const dB = (x) => (x > 0 ? 20 * Math.log10(x) : -100);
  const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

  let S = { preRoll: 5, wrapUp: 10, drawFade: 2 };            // settings, from the server
  let info = {}, mode = 'idle', takes = [], chosen = null, lines = [], note = '';
  let stream = null, recStream = null, rec = null, chunks = [], ctx = null, raf = 0, gainNode = null, rawAn = null, sampler = null;
  let boost = 1, level = 0, rawLevel = 0, peak = 0, heardAt = -1e9, micAccess = 'not asked yet', cal = null, calPhase = null, tRec = {}, lastStatus = '';
  let strokes = [], liveStroke = null, reviewAudio = null, reviewTake = null, stopFlag = false;
  const v = () => p.v;

  // ---------------- header ----------------
  $('brand-icon').innerHTML = icon('mark', 18);
  document.querySelectorAll('[data-icon]').forEach((b) => b.insertAdjacentHTML('afterbegin', icon(b.dataset.icon, Number(b.dataset.iconSize || 18))));
  hoverPopover($('help-btn'), $('help-pop'));
  initSettings();
  function header() {
    $('title').textContent = info.title || 'Your film';
    $('meta').textContent = [fmt(v().duration), (p.chapters.length > 1 ? p.chapters.length + ' chapters' : ''), takes.length ? takes.length + (takes.length === 1 ? ' take' : ' takes') : ''].filter(Boolean).join('  ยท  ');
    $('brand-pop').innerHTML = `<div class="pop-title">AI Flow Video</div>
      <div class="pop-row"><span>Version</span><span class="mono">${esc(info.version || '')}</span></div>
      <div class="pop-row"><span>Server</span><span class="mono">port ${esc(info.port || '')}</span></div>
      <div class="pop-row"><span>Player</span><span>the Adom video player (::adom-video)</span></div>
      <div class="pop-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-aiflow-video" target="_blank" rel="noopener">adom/adom-aiflow-video</a></div>`;
  }
  hoverPopover($('brand'), $('brand-pop'));

  // ---------------- status + reporting to the server ----------------
  function setMode(m, label) {
    mode = m;
    const el = $('mode'); el.textContent = label || { idle: 'Ready', preroll: 'Get ready', rehearse: 'Rehearsing', record: 'Recording', review: 'Reviewing' }[m];
    el.className = 'mode' + (m === 'record' ? ' rec' : m === 'idle' ? '' : ' play');
    $('progress').classList.toggle('rec', m === 'record');
    $('rec').disabled = $('rehearse').disabled = m !== 'idle';
    $('stop').disabled = m === 'idle';
    $('mic').disabled = m === 'record' || m === 'preroll';
    canvas.style.pointerEvents = m === 'record' || m === 'rehearse' ? 'auto' : 'none';
    $('drawbar').classList.toggle('on', m === 'record' || m === 'rehearse');
    $('recbadge').classList.toggle('on', m === 'record' || m === 'review');
    $('recbadge').classList.toggle('review', m === 'review');
    steps(); report();
  }
  function say(text) { lastStatus = text; report(); }
  function steps() {
    const micOk = cal && cal.result === 'ok';
    $('st-mic').className = micOk ? 'done' : (takes.length ? '' : 'now');
    $('st-mic-s').textContent = micOk ? `Calibrated: boost ${boost.toFixed(1)}x, voice ${cal.gapDb} dB above the room` : micAccess === 'allowed' ? 'Microphone on. Calibrate to set the level for your voice.' : 'Not checked yet';
    $('st-rec').className = takes.length ? 'done' : (micOk ? 'now' : '');
    $('st-rec-s').textContent = mode === 'record' ? 'Recording now' : takes.length ? takes.length + (takes.length === 1 ? ' take recorded' : ' takes recorded') : 'No takes yet';
    $('st-rev').className = chosen ? 'done' : (takes.length ? 'now' : '');
    $('st-rev-s').textContent = chosen ? `Take ${chosen.n} sent to your AI` : takes.length ? 'Review a take, then send the one you like' : 'Nothing to review yet';
  }
  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 || '' : '', 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(() => {});
  }
  setInterval(report, 1000);

  // ---------------- the clock, the bar, chapters, the wrap-up warning ----------------
  function segs() {
    const d = v().duration || 0; if (!d) return;
    $('segs').innerHTML = p.chapters.slice(1).map((c) => `<span style="left:${(c.t / d * 100).toFixed(3)}%"></span>`).join('');
    $('t-dur').textContent = fmt(d);
  }
  function tick() {
    requestAnimationFrame(tick);
    if (!v() || !v().duration) return;
    const d = v().duration, t = v().currentTime, left = d - t;
    $('t-now').textContent = fmt(t); $('t-left').textContent = fmt(left);
    $('prog-fill').style.width = (t / d * 100) + '%';
    const live = mode === 'record' || mode === 'rehearse' || mode === 'review';
    $('t-left').parentElement.className = 'left mono' + (live && left <= 3 ? ' now' : live && left <= S.wrapUp ? ' soon' : '');
    const ch = p.chapters, i = ch.findLastIndex ? ch.findLastIndex((c) => t + 0.05 >= c.t) : -1, cur = ch[i], nxt = ch[i + 1];
    $('chap-now').textContent = live && cur && cur.title ? cur.title : mode === 'idle' ? 'Ready when you are' : '';
    $('chap-next').textContent = live && nxt ? `Next: ${nxt.title} in ${fmt(nxt.t - t)}` : live ? 'Last chapter' : (ch.length > 1 ? ch.length + ' chapters, ' + fmt(d) : fmt(d));
    const w = $('wrapup'), warn = (mode === 'record' || mode === 'rehearse') && S.wrapUp > 0 && left <= S.wrapUp && left > 0.05;
    w.classList.toggle('on', warn);
    if (warn) { $('wrap-num').textContent = Math.ceil(left); w.classList.toggle('final', left <= 3); $('wrap-text').textContent = left <= 3 ? 'finish your sentence' : 'seconds left: wrap up'; }
    drawFrame();
  }

  // ---------------- microphone: boost, limiter, meter ----------------
  async function mics() {
    const devs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audioinput');
    const keep = $('mic').value;
    $('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());
      const id = $('mic').value;
      stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: id ? { exact: id } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } });
      micAccess = 'allowed';
    } catch (e) { micAccess = 'blocked: ' + e.message; toast('The browser did not give access to a microphone. Allow it in the address bar, then try again.', 'error'); steps(); report(); throw e; }
    await mics();
    ctx = ctx || new AudioContext(); if (ctx.state !== 'running') await ctx.resume();
    const src = ctx.createMediaStreamSource(stream);
    rawAn = ctx.createAnalyser(); rawAn.fftSize = 1024; src.connect(rawAn);
    gainNode = ctx.createGain(); gainNode.gain.value = boost;
    const lim = ctx.createDynamicsCompressor(); lim.threshold.value = -3; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.002; lim.release.value = 0.1;
    const an = ctx.createAnalyser(); an.fftSize = 1024; const dest = ctx.createMediaStreamDestination();
    src.connect(gainNode); gainNode.connect(lim); lim.connect(an); lim.connect(dest); recStream = dest.stream;
    const buf = new Float32Array(an.fftSize), raw = new Float32Array(rawAn.fftSize);
    cancelAnimationFrame(raf);
    (function loop() {
      an.getFloatTimeDomainData(buf); rawAn.getFloatTimeDomainData(raw);
      let m = 0; for (const x of buf) m = Math.max(m, Math.abs(x));
      let r = 0; for (const x of raw) r = Math.max(r, Math.abs(x));
      rawLevel = r; if (sampler) sampler(r); peak = Math.max(peak, m); level = m;
      $('lvl').style.width = Math.max(0, Math.min(100, (dB(m) + 54) / 54 * 100)) + '%'; $('lvl').classList.toggle('hot', m > 0.95);
      if (m > 0.03) heardAt = performance.now();
      const live = performance.now() - heardAt < 700;
      $('heard').textContent = m > 0.95 ? 'Too loud: back off a little' : live ? (m < 0.12 ? 'I hear you (a bit quiet)' : 'I hear you: good level') : 'Listening... say something';
      $('heard').style.color = m > 0.95 ? 'var(--red)' : live ? 'var(--accent-bright)' : 'var(--muted)';
      raf = requestAnimationFrame(loop);
    })();
    steps(); report();
  }
  function setBoost(x, quiet) {
    boost = Math.max(1, Math.min(16, +x || 1)); if (gainNode) gainNode.gain.value = boost;
    $('boost').value = boost; $('boostv').textContent = boost.toFixed(1) + 'x';
    try { localStorage.setItem('narrate.boost', String(boost)); } catch (_) {}
    if (!quiet) report();
  }
  try { boost = Math.max(1, Math.min(16, +localStorage.getItem('narrate.boost') || 1)); } catch (_) {}
  $('boost').oninput = (e) => setBoost(e.target.value);
  $('boost').onchange = () => toast(`Microphone boost: ${boost.toFixed(1)}x`, 'success');
  $('mic').onchange = () => openMic().then(() => toast('Microphone: ' + $('mic').selectedOptions[0].textContent, 'success')).catch(() => {});
  $('test').onclick = () => openMic().then(() => { toast('Microphone on: ' + ($('mic').selectedOptions[0]?.textContent || '') + '. Say something and watch the level.', 'success'); say('Microphone on, testing the level'); }).catch(() => {});

  // ---------------- the card (calibration, finish, sent) ----------------
  const modal = (html) => { $('card').innerHTML = html; $('modal').classList.add('on'); };
  const closeModal = () => { $('modal').classList.remove('on'); calPhase = null; report(); };
  const pills = (names, k) => `<div class="pills">${names.map((s, i) => `<span class="${i < k ? 'done' : i === k ? 'now' : ''}">${s}</span>`).join('')}</div>`;
  const CAL = ['1 Quiet', '2 Read', '3 Result'];
  function sample() { let out = [], n = 0; for (const l of lines) { const w = l.split(/\s+/).length; if (out.length && n + w > 22) break; out.push(l); n += w; if (n >= 12) break; } return out.join(' ') || 'This is how I will sound when I narrate my film.'; }
  let calCancel = false, calRunning = false;
  async function phase(k, title, sub, big, secs) {
    modal(pills(CAL, k) + `<h2>${title}</h2><p>${sub}</p>` + (big ? `<div class="read">${big}</div>` : '') + `<div class="count"><b id="cnum">${secs}</b><div class="prog"><i id="cprog"></i></div></div><div class="bigmeter"><i id="cmeter"></i><em></em></div><div class="row"><button class="btn ghost lg" id="c-cancel">Cancel</button><span class="esc-hint">or press Esc</span></div>`);
    $('c-cancel').onclick = () => { calCancel = true; };
    calPhase = { step: k + 1, title, secondsLeft: secs }; report();
    const xs = [], t0 = performance.now(), ms = secs * 1000;
    sampler = (x) => { if (performance.now() - t0 > 350) xs.push(x); const m = $('cmeter'); if (m) m.style.width = Math.max(0, Math.min(100, (dB(x * boost) + 54) / 54 * 100)) + '%'; };
    while (performance.now() - t0 < ms) {
      if (calCancel) { sampler = null; return null; }
      const el = performance.now() - t0, left = Math.ceil((ms - el) / 1000);
      if ($('cnum')) { $('cnum').textContent = left; $('cprog').style.width = (el / ms * 100) + '%'; }
      if (calPhase.secondsLeft !== left) { calPhase.secondsLeft = left; report(); }
      await wait(60);
    }
    sampler = null; return xs.sort((a, b) => a - b);
  }
  const pct = (xs, q) => xs[Math.min(xs.length - 1, Math.floor(xs.length * q))] || 0;
  async function calibrate() {
    if (mode !== 'idle') return toast('Stop the film first, then calibrate.', 'info');
    try { await openMic(); } catch (_) { return; }
    $('cal').disabled = true; calCancel = false; calRunning = true;
    const stopCal = () => { calRunning = false; $('cal').disabled = false; closeModal(); say('Calibration cancelled'); toast('Calibration cancelled. Nothing was changed.', 'info'); };
    const before = boost, text = sample(), readSecs = Math.max(6, Math.ceil(text.split(/\s+/).length / 2.4) + 2);
    if (await phase(0, 'Get ready', `Calibration takes about ${3 + 3 + readSecs} seconds: a moment of quiet, then one line to read out loud.`, '', 3) === null) return stopCal();
    const room = await phase(0, 'Stay quiet', 'I am listening to the room. Do not talk or type.', '', 3);
    if (room === null) return stopCal();
    const talk = await phase(1, 'Read this out loud', 'Use the voice you will narrate with. Start now.', '&ldquo;' + esc(text) + '&rdquo;', readSecs);
    if (talk === null) return stopCal();
    calRunning = false;
    const noise = pct(room, 0.6), voice = pct(talk, 0.95), heard = voice >= Math.max(noise * 2.5, 0.004);
    cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1), boostBefore: +before.toFixed(2) };
    let rows, verdict;
    if (heard) {
      setBoost(0.5 / voice, true); cal.boost = +boost.toFixed(2); cal.result = 'ok';
      rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x &rarr; <b class="to">${boost.toFixed(1)}x</b></td></tr>
        <tr><td>Your voice, loudest parts</td><td>${cal.voiceDb} dB &rarr; <b class="to">${dB(voice * boost).toFixed(1)} dB</b> (the target is about -6 dB)</td></tr>
        <tr><td>The room when you were quiet</td><td>${cal.noiseDb} dB &rarr; ${dB(noise * boost).toFixed(1)} dB after the boost</td></tr>
        <tr><td>Your voice above the room</td><td><b>${cal.gapDb} dB</b></td></tr>`;
      verdict = (0.5 / voice > 16 ? 'That is the most boost I will add. For a stronger signal, move closer or raise the microphone level in your sound settings. ' : '') +
        (cal.gapDb >= 30 ? '<span class="good">Good.</span> Your voice is well clear of the room.' : cal.gapDb >= 20 ? '<span class="warn">Fine.</span> A little room noise will be audible in the pauses.' : '<span class="bad">Noisy.</span> The room is nearly as loud as your voice. A closer microphone or a quieter room will sound much better.');
    } else {
      cal.result = 'not heard';
      rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x, <b>not changed</b></td></tr><tr><td>While you read</td><td>${cal.voiceDb} dB</td></tr><tr><td>While you were quiet</td><td>${cal.noiseDb} dB</td></tr>
        <tr><td>Difference</td><td><b>${cal.gapDb} dB</b> (I need at least 8 dB to tell your voice from the room)</td></tr>`;
      verdict = '<span class="bad">I could not tell your voice from the room.</span> Either something made noise during the quiet part, or this microphone is not picking you up. Check the microphone (' + esc($('mic').selectedOptions[0]?.textContent || '') + '), move closer, and try again.';
    }
    modal(pills(CAL, 2) + `<h2>${heard ? 'Calibrated' : 'Calibration did not work'}</h2><table>${rows}</table><div class="verdict">${verdict}</div>
      <div class="row">${heard ? '<button class="btn rec lg" id="c-go">Record now</button><button class="btn ghost lg" id="c-reh">Rehearse first</button>' : ''}<button class="btn ${heard ? 'ghost' : ''} lg" id="c-again">Calibrate again</button><button class="btn ghost lg" id="c-close">Close</button></div>`);
    $('c-close').onclick = closeModal; $('c-again').onclick = () => { closeModal(); calibrate(); };
    if (heard) { $('c-go').onclick = () => { closeModal(); record(); }; $('c-reh').onclick = () => { closeModal(); rehearse(); }; }
    $('cal').disabled = false; calPhase = { step: 3, title: 'result' }; steps();
    say(heard ? `Calibrated: boost ${before.toFixed(1)}x to ${boost.toFixed(1)}x, voice ${cal.gapDb} dB above the room` : 'Calibration did not work: boost not changed');
    toast(heard ? `Calibrated: boost ${boost.toFixed(1)}x` : 'Calibration did not work', heard ? 'success' : 'error');
  }
  $('cal').onclick = calibrate;

  // ---------------- drawing on the film ----------------
  const canvas = document.createElement('canvas'); canvas.id = 'draw'; canvas.style.cssText = 'position:absolute;z-index:4;pointer-events:none;cursor:crosshair;touch-action:none';
  $('stage').appendChild(canvas);
  const bar = document.createElement('div'); bar.id = 'drawbar'; bar.className = 'drawbar';
  bar.innerHTML = `<span class="pen"></span><b>Draw on the film</b><span>Drag to circle or point at something. It fades after <i id="fade-s">2</i> s.</span><button class="btn ghost" id="wipe" data-tooltip="Wipe your drawing now. Useful when the film moves to another shot.">Wipe now <kbd>Space</kbd></button>`;
  $('stage').appendChild(bar);
  const g = canvas.getContext('2d');
  function fit() { const r = p.getBoundingClientRect(), s = $('stage').getBoundingClientRect(); canvas.style.left = (r.left - s.left) + 'px'; canvas.style.top = (r.top - s.top) + 'px'; canvas.style.width = r.width + 'px'; canvas.style.height = r.height + 'px'; const k = devicePixelRatio || 1; if (canvas.width !== Math.round(r.width * k)) { canvas.width = Math.round(r.width * k); canvas.height = Math.round(r.height * k); } }
  addEventListener('resize', fit);
  const pos = (e) => { const r = canvas.getBoundingClientRect(); return [Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), Math.max(0, Math.min(1, (e.clientY - r.top) / r.height))]; };
  canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); const [x, y] = pos(e); liveStroke = { t0: +v().currentTime.toFixed(3), pts: [[x, y, 0]], end: null, wiped: null, wall: performance.now() }; strokes.push(liveStroke); });
  canvas.addEventListener('pointermove', (e) => { if (!liveStroke) return; const [x, y] = pos(e); liveStroke.pts.push([+x.toFixed(4), +y.toFixed(4), +((performance.now() - liveStroke.wall) / 1000).toFixed(3)]); });
  const penUp = () => { if (liveStroke) { liveStroke.end = +v().currentTime.toFixed(3); liveStroke.endWall = performance.now(); liveStroke = null; } };
  canvas.addEventListener('pointerup', penUp); canvas.addEventListener('pointercancel', penUp);
  function wipe() { const t = +v().currentTime.toFixed(3), now = performance.now(); let n = 0; for (const s of strokes) if (s.wiped === null && (s.end === null || now - s.endWall < S.drawFade * 1000)) { if (s.end === null) { s.end = t; s.endWall = now; } s.wiped = t; s.wipedWall = now; n++; } liveStroke = null; if (n) toast('Drawing wiped', 'info'); }
  $('wipe').onclick = wipe;
  addEventListener('keydown', (e) => { if ((mode === 'record' || mode === 'rehearse') && (e.code === 'Space' || e.key === ' ')) { e.preventDefault(); e.stopImmediatePropagation(); wipe(); } }, true);
  // what is visible at film time t: live strokes use wall time; a review replays by film time
  function drawFrame() {
    fit(); const W = canvas.width, H = canvas.height; g.clearRect(0, 0, W, H);
    const now = performance.now(), t = v() ? v().currentTime : 0, replay = mode === 'review';
    const list = replay && reviewTake ? (reviewTake.strokes || []) : strokes;
    for (const s of list) {
      let alpha = 1, upto = Infinity;
      if (replay) {
        if (t < s.t0) continue; upto = t - s.t0;
        const end = s.end ?? s.t0, gone = s.wiped ?? end + S.drawFade; if (t > gone) continue;
        if (t > end) alpha = s.wiped !== null && s.wiped !== undefined ? 1 : Math.max(0, 1 - (t - end) / S.drawFade);
      } else {
        if (s.wiped !== null) continue;
        if (s.end !== null) { const a = (now - s.endWall) / (S.drawFade * 1000); if (a >= 1) continue; alpha = 1 - Math.max(0, a - 0.6) / 0.4; }
      }
      g.lineJoin = g.lineCap = 'round';
      for (const [col, wdt] of [['rgba(5,34,31,' + (0.55 * alpha) + ')', 0.0105], ['rgba(0,230,220,' + alpha + ')', 0.0062]]) {
        g.strokeStyle = col; g.lineWidth = Math.max(3, wdt * W); g.beginPath();
        let started = false;
        for (const [x, y, dt] of s.pts) { if (dt > upto) break; if (!started) { g.moveTo(x * W, y * H); started = true; } else g.lineTo(x * W, y * H); }
        if (s.pts.length === 1 || (replay && upto < 0.03)) g.lineTo(s.pts[0][0] * W + 0.01, s.pts[0][1] * H);
        g.stroke();
      }
    }
  }

  // ---------------- rehearse, record, stop ----------------
  // ---------------- camera: the narrator's face, a picture in the corner of the film ----------------
  let camStream = null, camRec = null, camChunks = [], camAccess = 'not asked';
  const pip = $('campip');
  // 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');
    box.classList.toggle('barlow', (PIP.y - h / 2) * r.height < 44);          // no room above: put its buttons underneath
    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); };
  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;
  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); e.preventDefault();
    const c = e.target.dataset && e.target.dataset.c;
    box.classList.add(c ? 'sizing' : 'drag'); if (c) box.style.cursor = (c === 'tl' || c === 'br') ? 'nwse-resize' : 'nesw-resize';
    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', 'sizing'); box.style.cursor = ''; 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, 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'); toggleUi(); }
  async function openCam(first) {
    closeCam();
    const id = $('cam').value;
    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';
    } 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; }
    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 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(); 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() {
    return new Promise((res) => {
      if (!camRec || camRec.state === 'inactive') return res(null);
      camRec.onstop = () => res(new Blob(camChunks, { type: camRec.mimeType || 'video/webm' }));
      camRec.stop();
    });
  }

  // ---------------- the film: held whole in this page, and proven to play smoothly on THIS computer ----------------
  // Some computers stall on one kind of video and play another perfectly, so before a take the page plays a few
  // seconds out of sight and measures it. If the film does not keep time, it switches to the smooth copy the
  // server makes (a different codec) and measures again.
  let filmLocal = 'no', filmPct = 0, filmTry = null, filmMsg = 'Loading the film', playback = { source: 'not tested' };
  async function pull(url, label) {
    const r = await fetch(url); if (!r.ok) throw new Error(label + ' is not available');
    const total = +r.headers.get('Content-Length') || 0, rd = r.body.getReader(), parts = []; let got = 0;
    for (;;) { const { done, value } = await rd.read(); if (done) break; parts.push(value); got += value.length; filmPct = total ? Math.round(got / total * 100) : 0; filmMsg = `${label}: ${filmPct}%`; if (mode === 'idle') $('mode').textContent = filmMsg; }
    return URL.createObjectURL(new Blob(parts, { type: r.headers.get('Content-Type') || 'video/mp4' }));
  }
  async function smooth(url) {
    const x = document.createElement('video'); x.muted = true; x.playsInline = true; x.style.cssText = 'position:fixed;left:0;bottom:0;width:240px;opacity:.01;pointer-events:none;z-index:1';
    document.body.appendChild(x);
    try {
      await new Promise((res, rej) => { x.onloadeddata = res; x.onerror = () => rej(new Error('this browser cannot play it')); x.src = url; });
      const at = Math.min((x.duration || 30) * 0.3, 90); if (at > 1) { await new Promise((res) => { x.onseeked = res; x.currentTime = at; }); }
      let w = 0; x.addEventListener('waiting', () => w++); await x.play();
      const t0 = performance.now(), c0 = x.currentTime; await wait(4000);
      const wall = (performance.now() - t0) / 1000, film = x.currentTime - c0, q = x.getVideoPlaybackQuality ? x.getVideoPlaybackQuality() : {};
      return { ok: film > wall * 0.94 && w === 0 && (q.droppedVideoFrames || 0) < 12, film: +film.toFixed(2), wall: +wall.toFixed(2), waiting: w, dropped: q.droppedVideoFrames || 0 };
    } catch (e) { return { ok: false, error: e.message }; } finally { x.pause(); x.removeAttribute('src'); x.load(); x.remove(); }
  }
  async function useFilm(url) {
    const el = v(), t = el.currentTime, muted = el.muted; el.pause(); el.preload = 'auto';
    await new Promise((res, rej) => { el.addEventListener('loadeddata', res, { once: true }); el.addEventListener('error', rej, { once: true }); el.src = url; el.load(); });
    if (t > 0.2) el.currentTime = t; el.muted = muted;
  }
  function loadFilm() {
    if (filmTry) return filmTry;
    filmTry = (async () => {
      try {
        filmLocal = 'loading'; const url = await pull('video', 'Loading the film');
        filmMsg = 'Checking that the film plays smoothly on this computer'; if (mode === 'idle') $('mode').textContent = 'Checking playback';
        const first = await smooth(url);
        const altNow = (await api('state').catch(() => ({}))).alt;
        if (first.ok && altNow !== 'ready') { await useFilm(url); playback = { source: 'film', test: first }; }
        else if (first.ok) {                                           // both exist: the smooth copy is the easier one to play, so use it when it measures well
          const url2 = await pull('video-alt', 'Loading the smooth copy'); filmMsg = 'Checking the smooth copy'; const second = await smooth(url2);
          await useFilm(second.ok ? url2 : url); playback = second.ok ? { source: 'smooth copy', test: second, filmTest: first } : { source: 'film', test: first, smoothCopyTest: second };
        }
        else {
          playback = { source: 'film', test: first, note: 'the film does not keep time in this browser' }; report();
          filmMsg = 'This computer stalls on the film as it is. Making a copy that plays smoothly (about a minute)';
          let alt = 'building'; for (let i = 0; i < 200 && alt === 'building'; i++) { alt = (await api('state').catch(() => ({}))).alt || 'none'; if (alt === 'building') await wait(2000); }
          if (alt !== 'ready') throw new Error('the film stalls here and no smooth copy could be made (' + alt + ')');
          const url2 = await pull('video-alt', 'Loading the smooth copy');
          filmMsg = 'Checking the smooth copy'; const second = await smooth(url2);
          await useFilm(second.ok ? url2 : url);
          playback = second.ok ? { source: 'smooth copy', test: second, filmTest: first } : { source: 'film', test: first, smoothCopyTest: second, note: 'neither the film nor the smooth copy keeps time here' };
          if (!second.ok) toast('This computer is not playing video smoothly right now. Close other heavy apps or try another browser; tell your AI.', 'error');
        }
        filmLocal = 'yes'; if (mode === 'idle') setMode('idle'); report();
      } catch (e) { filmLocal = 'failed: ' + (e && e.message || 'could not load'); playback = { ...playback, error: filmLocal }; filmTry = null; if (mode === 'idle') setMode('idle'); report(); }
    })();
    return filmTry;
  }

  async function preroll(text) {
    const n = Math.max(0, Math.round(S.preRoll)); if (!n) return true;
    $('preroll').classList.add('on'); $('pre-text').textContent = text;
    for (let i = n; i > 0 && !stopFlag; i--) { $('pre-num').textContent = i; await wait(1000); }
    $('preroll').classList.remove('on'); return !stopFlag;
  }
  function capsOn() { if (p.cues && p.cues.length && !p.capOn) p.captions(); }
  async function rehearse() {
    if (mode !== 'idle') return; stopFlag = false; strokes = [];
    setMode('rehearse'); say('Rehearsing: the film plays with its captions, nothing is recorded');
    capsOn(); v().muted = false; p.seekTo(0); await v().play().catch(() => {});
    toast('Rehearsing. Nothing is being recorded. Try drawing on the film.', 'info');
  }
  let stalls = [], syncLog = [];
  setInterval(() => { if (mode === 'record' && tRec.rec) syncLog.push([Math.round(performance.now() - tRec.rec), +v().currentTime.toFixed(3)]); }, 500);
  let lastT = -1, lastMove = 0;
  setInterval(() => { if (mode !== 'record' || !tRec.rec || v().paused) { lastT = -1; return; } const t = v().currentTime, now = performance.now();
    if (t !== lastT) { lastT = t; lastMove = now; return; }
    if (now - lastMove > 1200) { stalls.push([Math.round(now - tRec.rec), +t.toFixed(2), 'nudged']); lastMove = now; try { v().currentTime = t + 0.04; v().play().catch(() => {}); } catch (_) {} }
  }, 300);
  const onStall = () => { if (mode === 'record' && tRec.rec) { stalls.push([Math.round(performance.now() - tRec.rec), +v().currentTime.toFixed(2)]); if (stalls.length === 1) toast('The film paused for a moment. Keep going: your AI can still line your voice up.', 'info'); } };
  async function record() {
    if (mode !== 'idle') return;
    try { await openMic(); } catch (_) { return; }
    if (!(cal && cal.result === 'ok')) toast('Tip: Calibrate first so your voice is recorded at a good level.', 'info');
    if (filmLocal !== 'yes') { modal(`<h2>Getting the film ready...</h2><p>The whole film is loaded into this page and test-played, so it cannot pause while you talk.</p><p><b id="film-pct"></b></p>`); const iv = setInterval(() => { const e = $('film-pct'); if (e) e.textContent = filmMsg; }, 200); await loadFilm(); clearInterval(iv); closeModal();
      if (filmLocal !== 'yes') toast('The film could not be prepared (' + filmLocal + '). It may pause while you record; tell your AI.', 'error'); }
    stopFlag = false; strokes = []; chunks = []; peak = 0; tRec = {}; stalls = []; syncLog = [];
    setMode('preroll'); say('Countdown before recording');
    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);
    rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
    camRec = null; camChunks = [];
    if (camStream) {
      const vt = ['video/webm;codecs=h264', 'video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm', 'video/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || '';
      try { camRec = new MediaRecorder(camStream, vt ? { mimeType: vt, videoBitsPerSecond: 2500000 } : undefined); camRec.ondataavailable = (e) => e.data.size && camChunks.push(e.data); camRec.onstart = () => { tRec.cam = performance.now(); }; camRec.start(1000); }
      catch (e) { camRec = null; toast('The camera could not be recorded (' + e.message + '). Your voice is still being recorded.', 'error'); }
    }
    rec.onstart = () => { tRec.rec = performance.now(); v().play(); };
    rec.onstop = save;
    v().addEventListener('playing', () => { if (tRec.rec && tRec.play === undefined) tRec.play = performance.now(); }, { once: true });
    rec.start(1000);
    setMode('record'); $('recbadge-text').textContent = camRec ? 'Recording your voice and camera' : 'Recording your voice'; say('Recording');
  }
  function stop() {
    stopFlag = true;
    if (mode === 'record' && rec && rec.state !== 'inactive') return rec.stop();
    if (mode === 'review') return endReview();
    if (mode === 'rehearse') { v().pause(); setMode('idle'); say('Rehearsal stopped'); toast('Rehearsal stopped', 'info'); }
  }
  $('rec').onclick = record; $('rehearse').onclick = rehearse; $('stop').onclick = stop;

  async function save() {
    v().pause(); const whole = v().duration - v().currentTime < 1.5;
    const camBlob = await stopCamRec();

    const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
    const drawn = strokes.map((s) => ({ t0: s.t0, end: s.end ?? +v().currentTime.toFixed(3), wiped: s.wiped, pts: s.pts }));
    const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), camStartsAtMs: camBlob && tRec.cam !== undefined ? Math.round(tRec.cam - tRec.rec) : null, filmInPage: filmLocal, playback, stalls, sync: syncLog.filter((_, i) => i % 4 === 0), 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 {
      const r = await (await fetch('take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json();
      if (drawn.length) await fetch('draw?file=' + encodeURIComponent(r.file), { method: 'POST', body: JSON.stringify(drawn) });
      if (camBlob && camBlob.size) { modal(`<h2>Saving your camera recording...</h2><p>Keep this tab open.</p>`); const c = await (await fetch('cam?file=' + encodeURIComponent(r.file), { method: 'POST', headers: { 'X-Cam-Mime': camBlob.type }, body: camBlob })).json(); if (c.cam) r.cam = c.cam; else toast('The camera recording could not be saved; your voice was.', 'error'); }
      const take = { ...meta, ...r, strokes: drawn, n: takes.length + 1 }; takes.push(take); renderTakes(); header();
      finishCard(take); setMode('idle'); say(`Take ${take.n} saved (${fmt(take.seconds)})`);
    } catch (e) { modal(`<h2>The take could not be saved</h2><p>${esc(e.message)}. It is still in this page: do not close the tab, and tell your AI.</p>`); setMode('idle'); say('Save failed: ' + e.message); }
  }
  function quality(tk) {
    if (tk.peak > 0.985) return ['warn', 'It got very loud in places. The limiter kept it from distorting, but another take a little further from the microphone may sound better.'];
    if (tk.peak < 0.1) return ['bad', 'It is very quiet. Check the right microphone is picked and calibrate before the next take.'];
    return ['good', 'The level is good.'];
  }
  function finishCard(tk) {
    const [cls, q] = quality(tk);
    modal(`<h2>Recording complete</h2>
      <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>`);
    $('f-use').onclick = () => choose(tk); $('f-rev').onclick = () => { closeModal(); review(tk); };
    $('f-again').onclick = () => { closeModal(); record(); }; $('f-close').onclick = closeModal;
    toast(`Take ${tk.n} saved`, 'success');
  }
  async function choose(tk) {
    const r = await api('choose', { file: tk.file }).catch((e) => ({ error: e.message }));
    if (r.error) return toast('Could not tell your AI: ' + r.error, 'error');
    chosen = tk; renderTakes(); steps(); say(`Take ${tk.n} sent to the AI`);
    $('ainote').classList.add('pulse'); $('ainote').scrollIntoView({ block: 'nearest' }); setTimeout(() => $('ainote').classList.remove('pulse'), 12000);
    modal(`<h2>Sent to your AI</h2><p>Take ${tk.n} is with your AI now. It will put your voice on the film, write new captions and show you the result. Stay on this page: the <b>From your AI</b> box in its right-hand column (highlighted now) will tell you what it is doing. Your AI's chat is in Hydrogen, a separate window.</p>
      <div class="row"><button class="btn lg" id="s-ok">OK</button><button class="btn ghost lg" id="s-rev">Review it meanwhile</button></div>`);
    $('s-ok').onclick = closeModal; $('s-rev').onclick = () => { closeModal(); review(tk); };
    toast(`Take ${tk.n} sent to your AI`, 'success');
  }

  // ---------------- review: the film with your voice on it ----------------
  function review(tk) {
    if (mode !== 'idle') stop();
    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 = !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('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);
    setMode('review', `Reviewing take ${tk.n}`); $('recbadge-text').textContent = `Take ${tk.n}: your voice with the film`;
    say(`Reviewing take ${tk.n} with the film`); v().play().catch(() => {});
    toast(`Reviewing take ${tk.n}. Use the player to pause or jump around; Stop ends the review.`, 'info');
  }
  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('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);
  }
  function renderTakes() {
    $('takes').innerHTML = takes.length ? '' : '<li class="empty">Your recordings will be listed here.</li>';
    for (const tk of takes) {
      const li = document.createElement('li'); if (chosen && chosen.file === tk.file) li.className = 'chosen';
      li.innerHTML = `<div class="head"><span>Take ${tk.n}</span><span class="mono">${fmt(tk.seconds)}</span></div><div class="sub">${chosen && chosen.file === tk.file ? 'Sent to your AI. ' : ''}${tk.whole === false ? 'Stopped early. ' : ''}${(tk.strokes || []).length ? (tk.strokes.length + ' drawings. ') : ''}Peak ${(dB(tk.peak || 0)).toFixed(0)} dB</div>
        <div class="acts"><button class="btn ghost" data-a="rev" data-tooltip="Play the film with this take's voice on it">Review</button><button class="btn" data-a="use" data-tooltip="Send this take to your AI. It puts your voice on the film and writes new captions.">Use this take</button></div>`;
      li.querySelector('[data-a=rev]').onclick = () => review(tk); li.querySelector('[data-a=use]').onclick = () => choose(tk);
      $('takes').appendChild(li);
    }
    steps();
  }

  // ---------------- the steps are buttons, and Start over ----------------
  const stepDo = { 'st-mic': () => calibrate(), 'st-rec': () => (mode === 'idle' ? record() : toast('Stop first, then start a new take.', 'info')),
    'st-rev': () => (takes.length ? review(chosen || takes[takes.length - 1]) : toast('Nothing to review yet: record a take first.', 'info')) };
  for (const [id, fn] of Object.entries(stepDo)) { $(id).onclick = fn; $(id).onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); } }; }
  async function refreshTakes() {
    const st = await api('state').catch(() => ({}));
    takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));
    chosen = st.chosen ? takes.find((t) => t.file === st.chosen) || null : null; setNote(st.note || '');
    renderTakes(); header();
  }
  let armed = false, armT;
  async function startOver() {
    if (mode !== 'idle') stop();
    const r = await api('reset', {}).catch((e) => ({ error: e.message }));
    if (r.error) return toast('Could not start over: ' + r.error, 'error');
    cal = null; strokes = []; peak = 0; closeModal(); await refreshTakes(); p.seekTo(0); steps(); say('Started over');
    toast(r.moved ? `Started over. Your ${r.moved} earlier ${r.moved === 1 ? 'take was' : 'takes were'} kept in ${r.folder}.` : 'Started over.', 'success',
      r.moved ? { label: 'Undo', run: async () => { const u = await api('reset/undo', {}).catch((e) => ({ error: e.message })); if (u.error) return toast('Could not undo: ' + u.error, 'error'); await refreshTakes(); toast('Your takes are back.', 'success'); } } : undefined);
  }
  $('startover').onclick = () => {
    const b = $('startover');
    if (!armed) { armed = true; b.classList.add('armed'); b.textContent = 'Click again to start over'; armT = setTimeout(() => { armed = false; b.classList.remove('armed'); b.textContent = 'Start over'; }, 4000); return; }
    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.stamp || 0);
    $('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.stamp || 0}" 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.stamp || 0); $('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(); };

  // ---------------- pose shot: a still of you from the camera, for the thumbnail ----------------
  async function poseShot(say, product) {
    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 || (product ? 'Point at the picture on the right: that is your product, where it will sit in the thumbnail. Reach toward it and look at it. Drag the picture if you want it somewhere else. Big expression, big gesture.' : '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>${product ? `<div class="target" id="pose-dot" style="left:76%;top:62%"><img src="pose-product?v=${product}" alt=""><b>POINT AT THIS</b><span class="arrow"></span></div>` : `<i class="dot" id="pose-dot" style="left:76%;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(); };
  }

  addEventListener('keydown', (e) => {
    if (e.key !== 'Escape' || !$('modal').classList.contains('on')) return;
    if (calRunning) { calCancel = true; return; }
    if (/Saving your/.test($('card').textContent)) return;
    const c = $('pp-close') || $('pose-done') || $('c-close') || $('f-close') || $('s-ok'); c ? c.click() : 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); }
  // 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'),
    gain: async (d) => { await glide('#boost', (d.by || 'AI') + ': boost ' + d.value + 'x'); setBoost(d.value); toast(`Your AI set the microphone boost to ${boost.toFixed(1)}x`, 'info'); },
    mic: async (d) => { const o = [...$('mic').options].find((o) => o.textContent.toLowerCase().includes(String(d.name || '').toLowerCase())); if (!o) return toast('No microphone matching "' + d.name + '"', 'error'); await glide('#mic', (d.by || 'AI') + ': pick ' + o.textContent); $('mic').value = o.value; $('mic').onchange(); },
    cam: async (d) => { const n = String(d.name || '').toLowerCase(); await glide('#cam', (d.by || 'AI') + (n === 'off' ? ': camera off' : ': camera on'));
      if (n === 'off') { $('cam').value = ''; return $('cam').onchange(); }
      if (n === 'on' || !n) { $('cam').value = ''; return openCam(true); }
      const o = [...$('cam').options].find((o) => o.value && o.textContent.toLowerCase().includes(n)); if (!o) return openCam(true); $('cam').value = o.value; $('cam').onchange(); },
    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(SMIN, Math.min(SMAX, PIP.size)); PIP.zoom = Math.max(1, Math.min(2.5, PIP.zoom)); layout(true); },
    pose: (d) => poseShot(d.say, d.hasProduct ? (d.stamp || 1) : 0),
    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 ----------------
  (async () => {
    const st = await api('state').catch(() => ({}));
    info = st; S = { ...S, ...(st.settings || {}) };
    if (st.pitch && st.pitch.thumbs) { pitch = st.pitch; pitchChoice = st.pitchChoice || null; pitchBox(); } $('fade-s').textContent = S.drawFade; setNote(st.note || '');
    lines = (await fetch('captions').then((r) => r.json()).catch(() => ({ lines: [] }))).lines || [];
    await p.adomDrive.ready(); await wait(300);
    takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));
    if (st.chosen) chosen = takes.find((t) => t.file === st.chosen) || null;
    segs(); header(); renderTakes(); setBoost(boost, true); setMode('idle'); mics(); tick();
    navigator.mediaDevices.addEventListener('devicechange', mics);
    await cams(); navigator.mediaDevices.addEventListener('devicechange', cams);
    try { const want = localStorage.getItem('aiflow-video-cam'); if (want) { const o = [...$('cam').options].find((o) => o.textContent === want); if (o) { $('cam').value = o.value; openCam(); } else openCam(true); } } catch (_) {}
    v().addEventListener('ended', () => { if (mode === 'record' && rec && rec.state !== 'inactive') rec.stop(); else if (mode === 'rehearse') { setMode('idle'); say('Rehearsal finished'); toast('That was the whole film. Press Record when you are ready.', 'success'); } });
    v().addEventListener('durationchange', () => { segs(); header(); });
    v().addEventListener('waiting', onStall); v().addEventListener('stalled', onStall);
    setTimeout(loadFilm, 1500);
    say('Ready');
  })();
})();