12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Adom Video Post-Processing</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face {
  font-family: 'Familjen Grotesk';
  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');
  font-weight: 700; font-display: swap;
}
@font-face {
  font-family: 'Familjen Grotesk';
  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');
  font-weight: 500; font-display: swap;
}
@font-face {
  font-family: 'Satoshi';
  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');
  font-weight: 400; font-display: swap;
}
@font-face {
  font-family: 'Satoshi';
  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');
  font-weight: 500; font-display: swap;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --bg: #0d1117;
  --surface: #161b22;
  --elevated: #1c2128;
  --overlay: #21262d;
  --border: #30363d;
  --border-muted: #21262d;
  --text: #e6edf3;
  --text2: #8b949e;
  --text3: #484f58;
  --accent: #00b8b0;
  --accent-bright: #00e6dc;
  --accent-glow: rgba(0,184,177,0.2);
  --accent-tint: rgba(0,184,177,0.08);
  --purple: #8C6BF7;
  --purple-light: #C5B3FF;
  --green: #3fb950;
  --red: #f85149;
}

html, body { min-height: 100vh; }
body {
  background:
    radial-gradient(1200px 800px at 30% -20%, rgba(0,184,177,0.08) 0%, transparent 60%),
    radial-gradient(1000px 700px at 90% 110%, rgba(140,107,247,0.06) 0%, transparent 60%),
    linear-gradient(180deg, #0d1117 0%, #0a0e13 100%);
  color: var(--text);
  font-family: 'Satoshi', sans-serif;
  font-weight: 400;
  padding: 24px;
  max-width: 1100px;
  margin: 0 auto;
}

* { transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; }

/* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.
   Single row, 44 px tall. */
header {
  height: 44px;
  margin: -16px -16px 18px;
  padding: 0 16px;
  display: flex;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid var(--border-muted);
  background: var(--surface, var(--bg));
  position: relative;
  font-family: 'Familjen Grotesk', sans-serif;
  flex-shrink: 0;
}
header .brand {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--accent);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: default;
  flex-shrink: 0;
  outline: none;
}
header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }
header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }
header .brand .app-name { color: var(--accent); }
header .brand .app-subtitle { color: var(--text2); font-weight: 500; }
header .brand .sep { color: var(--text3); font-weight: 400; }
header .vsep {
  width: 1px; height: 18px;
  background: var(--border);
  flex-shrink: 0;
}
header .subject {
  display: flex; align-items: center; gap: 8px;
  min-width: 0; flex: 1 1 auto;
}
header .subject-name {
  color: var(--text);
  font-family: 'Familjen Grotesk', sans-serif;
  font-size: 14px; font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  min-width: 0;
}
header .subject-meta {
  color: var(--text2);
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  white-space: nowrap;
  flex-shrink: 0;
}
@media (max-width: 720px) {
  header .subject-meta { display: none; }
}
header .hover-popover {
  position: absolute; top: calc(100% + 6px); left: 16px;
  background: var(--elevated); border: 1px solid var(--border);
  border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
  padding: 12px 14px; min-width: 300px; max-width: 420px;
  font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text);
  z-index: 99999; opacity: 0; transform: translateY(-2px);
  pointer-events: none;
  transition: opacity 120ms ease, transform 120ms ease;
}
header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }
header .hover-popover .popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 8px; }
header .hover-popover .popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }
header .hover-popover .popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; width: 64px; flex-shrink: 0; }
header .hover-popover .popover-row .v { color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; flex: 1; word-break: break-word; }
header .hover-popover .popover-actions { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); display: flex; gap: 12px; }
header .hover-popover .popover-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }
header .hover-popover .popover-link:hover { text-decoration: underline; }
.subtitle {
  color: var(--text2);
  font-size: 14px;
  font-weight: 400;
  margin-bottom: 24px;
  max-width: 720px;
  line-height: 1.5;
}

/* ---------- card (frosted glass) ---------- */
.card {
  background: rgba(22,27,34,0.92);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--accent-glow);
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.02);
  padding: 18px;
  margin-bottom: 18px;
  position: relative;
  z-index: 1;
}
/* Lift a card above its siblings whenever a tooltip inside it is
   being hovered. Without this, a tooltip anchored to a button near
   the bottom of one card gets painted underneath the next card
   (the tooltip is an ::after pseudo-element confined to the
   button's ancestor stacking context, so bumping its own z-index
   is not enough — the containing card must climb above the next
   one in document order). Relies on CSS `:has()`, supported in
   every modern browser we care about. */
.card:has([data-tooltip]:hover) {
  z-index: 1000;
}
.card .card-title {
  font-family: 'Familjen Grotesk', sans-serif;
  font-weight: 500;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--purple);
  margin-bottom: 12px;
}

/* ---------- video + tab strip ---------- */
.video-tabs {
  display: flex;
  gap: 4px;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 6px 6px 0 6px;
  margin-bottom: -1px;
  background: transparent;
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}
.video-tabs::-webkit-scrollbar { height: 6px; }
.video-tabs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }

.video-tab {
  flex: 0 0 auto;
  background: var(--elevated);
  border: 1px solid var(--border-muted);
  border-bottom: none;
  border-radius: 8px 8px 0 0;
  padding: 8px 14px 10px 14px;
  font-family: 'Satoshi', sans-serif;
  font-size: 12px;
  font-weight: 500;
  color: var(--text2);
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
  position: relative;
}
.video-tab:hover {
  background: var(--accent-tint);
  color: var(--text);
}
.video-tab.selected {
  background: #000;
  color: var(--text);
  border-color: var(--border);
  border-bottom: 1px solid #000;
}
.video-tab.selected::after {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 2px;
  background: var(--accent);
  border-radius: 2px 2px 0 0;
}
.video-tab.original .dot { background: var(--text2); }
.video-tab.raw .dot { background: var(--text); }
.video-tab.normalized .dot { background: var(--purple); }
.video-tab.committed .dot { background: var(--accent-bright); }
.video-tab.legacy .dot { background: var(--text3); }
.video-tab .dot {
  width: 7px; height: 7px; border-radius: 50%;
  flex: 0 0 7px;
}
.video-tab.selected.normalized::after { background: var(--purple); }
.video-tab.selected.committed::after { background: var(--accent-bright); }

.video-wrap {
  background: #000;
  border-radius: 0 10px 10px 10px;
  overflow: hidden;
  border: 1px solid var(--border);
  position: relative;
}
video { display: block; width: 100%; max-height: 480px; }
.now-playing {
  position: absolute;
  top: 10px; left: 10px;
  display: flex;
  align-items: center;
  gap: 6px;
  background: rgba(13,17,23,0.85);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 6px 12px 6px 10px;
  font-family: 'Satoshi', sans-serif;
  font-size: 11px;
  font-weight: 500;
  color: var(--text);
  pointer-events: none;
  z-index: 5;
}
.now-playing .dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--text3);
}
.now-playing.source .dot { background: var(--text3); }
.now-playing.raw .dot { background: var(--text); }
.now-playing.preview .dot { background: var(--purple); box-shadow: 0 0 8px rgba(140,107,247,0.6); }
.now-playing .kind { font-weight: 500; }
.now-playing.preview { border-color: rgba(140,107,247,0.3); }

/* ---------- countdown overlay ---------- */
.countdown {
  position: absolute; inset: 0;
  display: none;
  align-items: center; justify-content: center;
  background: rgba(0,0,0,0.85);
  color: var(--accent-bright);
  font-size: 220px;
  font-weight: 700;
  font-family: 'Familjen Grotesk', sans-serif;
  text-shadow: 0 0 40px rgba(0,184,177,0.6);
  pointer-events: none;
  z-index: 10;
}
.countdown.visible { display: flex; }

/* Wrap-up countdown: small pill in the top-right of the video during
   the last 5 seconds of playback while recording, so the narrator
   knows to say "thanks for watching" on beat. */
.wrap-countdown {
  position: absolute;
  top: 10px; right: 10px;
  display: none;
  align-items: center;
  gap: 8px;
  background: rgba(248,81,73,0.92);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 999px;
  padding: 8px 14px 8px 12px;
  font-family: 'Satoshi', sans-serif;
  font-size: 12px;
  font-weight: 500;
  color: #fff;
  pointer-events: none;
  z-index: 6;
  box-shadow: 0 6px 18px rgba(248,81,73,0.4);
}
.wrap-countdown.visible { display: flex; }
.wrap-countdown .big {
  font-family: 'Familjen Grotesk', sans-serif;
  font-weight: 700;
  font-size: 18px;
  min-width: 14px;
  text-align: center;
}

/* ---------- controls ---------- */
.controls {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
}
.controls + .controls { margin-top: 12px; }

button {
  background: var(--elevated);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 18px;
  font-family: 'Satoshi', sans-serif;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
}
button:hover:not(:disabled) {
  border-color: var(--accent);
  color: var(--accent-bright);
  background: var(--accent-tint);
}
button:disabled { opacity: 0.35; cursor: not-allowed; }

.btn-record {
  background: var(--red);
  color: #fff;
  border-color: var(--red);
  padding: 12px 22px;
  font-weight: 500;
  box-shadow: 0 4px 14px rgba(248,81,73,0.25);
}
.btn-record:hover:not(:disabled) {
  background: #ff5a52;
  border-color: #ff5a52;
  color: #fff;
  box-shadow: 0 6px 18px rgba(248,81,73,0.4);
}
.btn-record.recording {
  background: var(--elevated);
  color: var(--red);
  border-color: var(--red);
  box-shadow: 0 0 0 3px rgba(248,81,73,0.15);
  animation: pulse 1.6s ease-in-out infinite;
}
.btn-record.recording::before { content: '⏹ '; }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 3px rgba(248,81,73,0.15); } 50% { box-shadow: 0 0 0 6px rgba(248,81,73,0.35); } }

.btn-finalize {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
  font-weight: 500;
  box-shadow: 0 4px 14px rgba(0,184,177,0.3);
}
.btn-finalize:hover:not(:disabled) {
  background: var(--accent-bright);
  border-color: var(--accent-bright);
  /* Explicit color because the generic `button:hover` rule sets color
     to --accent-bright, which would match the hover background and
     make the label invisible. */
  color: var(--bg);
  box-shadow: 0 6px 18px rgba(0,184,177,0.45);
}

.btn-normalize {
  background: var(--purple);
  color: #fff;
  border-color: var(--purple);
  font-weight: 500;
  box-shadow: 0 4px 14px rgba(140,107,247,0.3);
}
.btn-normalize:hover:not(:disabled) {
  background: var(--purple-light);
  border-color: var(--purple-light);
  color: var(--bg);
  box-shadow: 0 6px 18px rgba(140,107,247,0.45);
}
.btn-normalize.working {
  background: var(--elevated);
  color: var(--purple);
  border-color: var(--purple);
}

.btn-save {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
  font-weight: 500;
  box-shadow: 0 4px 14px rgba(0,184,177,0.3);
}
.btn-save:hover:not(:disabled) {
  background: var(--accent-bright);
  border-color: var(--accent-bright);
  /* Same fix as .btn-finalize:hover — generic button:hover forces
     color to --accent-bright, which would make the label invisible
     against the matching hover background. */
  color: var(--bg);
  box-shadow: 0 6px 18px rgba(0,184,177,0.45);
}

.timer {
  font-family: 'JetBrains Mono', monospace;
  font-size: 14px;
  color: var(--text3);
  padding: 0 8px;
}
.timer.recording { color: var(--red); font-weight: 500; }

/* ---------- tooltips ----------
   Hover-delayed frosted-glass tooltips that show only after ~500ms,
   so they don't pop in every time the pointer moves across the UI.
   Per app-creator skill: every button (and any non-obvious label) needs
   a tooltip, and the tooltip should be gentle (delay, smooth fade).
   Works on any element with a data-tooltip attribute, not just buttons.

   Positioning rule: the tooltip anchors its LEFT edge to the trigger's
   LEFT edge and grows rightward. The max-width is clamped to the
   viewport so it can never clip on the right side. For triggers near
   the right edge of the viewport, add data-tooltip-align="right" so
   the tooltip anchors its RIGHT edge to the trigger's right edge
   instead and grows leftward. */
[data-tooltip] { position: relative; }
[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  right: auto;
  background: rgba(22,27,34,0.96);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--accent-glow);
  border-radius: 10px;
  padding: 12px 16px;
  font-family: 'Satoshi', sans-serif;
  font-size: 12px;
  font-weight: 400;
  line-height: 1.55;
  color: var(--text);
  text-align: left;
  width: max-content;
  max-width: min(360px, calc(100vw - 48px));
  white-space: pre-wrap;
  pointer-events: none;
  opacity: 0;
  transform: translateY(4px);
  box-shadow: 0 12px 40px rgba(0,0,0,0.55);
  transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;
  z-index: 100;
}
[data-tooltip][data-tooltip-align="right"]::after {
  left: auto;
  right: 0;
}
/* Flip tooltip to appear BELOW the trigger when there isn't enough
   space above (for buttons near the bottom of the card/viewport, or
   rows whose tooltips are naturally tall and would clip off the top). */
[data-tooltip][data-tooltip-placement="below"]::after {
  bottom: auto;
  top: calc(100% + 8px);
  transform: translateY(-4px);
}
[data-tooltip][data-tooltip-placement="below"]:hover::after {
  transform: translateY(0);
}
/* All buttons in the post-production controls row live at the bottom
   of the card and always get tall tooltips. Default them to below. */
#postControls [data-tooltip]::after {
  bottom: auto;
  top: calc(100% + 8px);
  transform: translateY(-4px);
}
#postControls [data-tooltip]:hover::after {
  transform: translateY(0);
}
[data-tooltip]:hover::after {
  opacity: 1;
  transform: translateY(0);
  /* 500ms delay before the tooltip fades in. A moved pointer that
     doesn't linger never triggers it, so it's not noisy. */
  transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;
}
/* Help cursor when a span has a tooltip, so users know to hover it. */
span[data-tooltip] { cursor: help; text-decoration: underline dotted var(--text3); text-underline-offset: 3px; }

/* ---------- status card ---------- */
.status {
  background: rgba(13,17,23,0.5);
  border: 1px solid var(--border-muted);
  border-radius: 8px;
  padding: 14px 18px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 12px;
  line-height: 1.6;
  color: var(--text2);
  min-height: 52px;
  white-space: pre-wrap;
}
.status.success { border-color: var(--accent-glow); color: var(--accent-bright); background: rgba(0,184,177,0.05); }
.status.error { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.05); }

/* ---------- waveform chart ---------- */
.waveform-card { padding: 14px 18px; }
.waveform-row {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.waveform-block { display: flex; flex-direction: column; gap: 6px; }
.waveform-label {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-family: 'Satoshi', sans-serif;
  font-weight: 500;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text2);
}
.waveform-label .meta {
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  color: var(--text3);
  text-transform: none;
  letter-spacing: 0;
}
.waveform-img-wrap {
  background: var(--bg);
  border: 1px solid var(--border-muted);
  border-radius: 6px;
  overflow: hidden;
  height: 72px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.waveform-img-wrap img {
  width: 100%;
  height: 100%;
  display: block;
}
.waveform-img-wrap.empty {
  color: var(--text3);
  font-size: 11px;
  font-family: 'JetBrains Mono', monospace;
}

/* ---------- speedup timeline ---------- */
.timeline-summary {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-family: 'Satoshi', sans-serif;
  font-size: 12px;
  color: var(--text2);
  margin-bottom: 18px;
  flex-wrap: wrap;
}
.timeline-summary .stat-label {
  color: var(--text3);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 10px;
  font-weight: 500;
}
.timeline-summary .stat-val {
  color: var(--text);
  font-family: 'JetBrains Mono', monospace;
  font-size: 13px;
  font-weight: 500;
}
.timeline-summary .stat-saved {
  color: var(--accent-bright);
  font-family: 'JetBrains Mono', monospace;
  font-size: 13px;
  font-weight: 500;
}
.timeline-summary .stat-sep { color: var(--text3); }
.timeline-track-wrap {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.timeline-track-label {
  font-family: 'Satoshi', sans-serif;
  font-size: 10px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text3);
  margin-top: 6px;
}
.timeline-track {
  display: flex;
  height: 40px;
  background: var(--bg);
  border: 1px solid var(--border-muted);
  border-radius: 6px;
  overflow: hidden;
}
.timeline-seg {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'Satoshi', sans-serif;
  font-size: 10px;
  font-weight: 500;
  color: var(--text);
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  padding: 0 6px;
  position: relative;
}
.timeline-seg.normal {
  background: var(--elevated);
  color: var(--text2);
  border-right: 1px solid var(--border-muted);
}
.timeline-seg.normal:last-child { border-right: none; }
.timeline-seg.speedup {
  background: linear-gradient(180deg, rgba(140,107,247,0.9) 0%, rgba(140,107,247,0.7) 100%);
  color: #fff;
  border-right: 1px solid rgba(0,0,0,0.2);
  text-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
.timeline-seg.speedup:last-child { border-right: none; }

/* ---------- takes history ---------- */
.takes-subtitle {
  color: var(--text2);
  font-size: 12px;
  line-height: 1.5;
  margin-bottom: 14px;
  max-width: 640px;
}
.takes-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.takes-row {
  display: grid;
  grid-template-columns: 100px 1fr 90px 130px 90px;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  background: var(--elevated);
  border: 1px solid var(--border-muted);
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.15s;
}
.takes-row:hover {
  border-color: var(--accent-glow);
  background: var(--accent-tint);
}
.takes-row.selected {
  border-color: var(--accent);
  background: rgba(0,184,177,0.08);
  box-shadow: 0 0 0 2px rgba(0,184,177,0.12);
}
.takes-row .kind-pill {
  font-family: 'Familjen Grotesk', sans-serif;
  font-weight: 500;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  text-align: center;
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg);
}
.takes-row .kind-pill.committed { border-color: var(--accent); color: var(--accent-bright); }
.takes-row .kind-pill.normalized { border-color: var(--purple); color: var(--purple-light); }
.takes-row .kind-pill.take { border-color: var(--border); color: var(--text); }
.takes-row .path {
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.takes-row .size, .takes-row .mtime {
  font-family: 'JetBrains Mono', monospace;
  font-size: 11px;
  color: var(--text2);
  text-align: right;
}
.takes-row .status-badge {
  font-family: 'Satoshi', sans-serif;
  font-size: 11px;
  font-weight: 500;
  color: var(--accent);
  text-align: right;
}
.takes-row:not(.selected) .status-badge { visibility: hidden; }

/* ---------- tip callout ---------- */
.tip {
  color: var(--text2);
  font-size: 12px;
  line-height: 1.6;
  padding: 12px 16px 12px 18px;
  border-left: 2px solid var(--accent);
  background: var(--accent-tint);
  border-radius: 0 8px 8px 0;
  margin-top: 18px;
}
.tip strong { color: var(--accent-bright); font-weight: 500; }
</style>
</head>
<body>
<!-- Canonical Adom app-header — see gallia/skills/adom-app-header. -->
<header>
  <div class="brand" data-tooltip-target="app-info" tabindex="0">
    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>
    </svg>
    <span class="app-name">adom-video-post</span>
    <span class="sep">·</span>
    <span class="app-subtitle">voiceover</span>
  </div>
  <div class="vsep" aria-hidden="true"></div>
  <div class="subject">
    <span class="subject-name">Finishing Studio</span>
    <span class="subject-meta">Review every clip · narrate · mux + auto-level · validate · save the final cut</span>
  </div>
  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
    <div class="popover-title">adom-video-post · voiceover</div>
    <div class="popover-row"><span class="k">about</span>
      <span class="v">The finishing pass: review every captured clip, narrate (3-2-1 countdown, then talk while the video plays), mux + auto-level the audio (EBU R128), validate the cut, and save the final.</span></div>
    <div class="popover-row"><span class="k">version</span><span class="v">v0.3.0</span></div>
    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
    <div class="popover-row"><span class="k">tts</span>
      <span class="v"><a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener">adom-tts ↗</a> — for AI narration instead of mic, run `adom-tts say "<text>"`. Pronunciation overrides for Adom terms (KiCad, adom-tsci, etc.) apply uniformly.</span></div>
    <div class="popover-actions">
      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-video-post" target="_blank" rel="noopener">Open wiki ↗</a>
      <a class="popover-link" href="https://github.com/adom-inc/adom-video-post" target="_blank" rel="noopener">GitHub ↗</a>
    </div>
  </div>
</header>
<script>
// SHOW_DELAY = 500 ms per human-ui-patterns §1b; HIDE_GRACE = 220 ms.
(function attachHeaderPopovers(){
  const SHOW_DELAY = 500;
  const HIDE_GRACE = 220;
  const wire = () => {
    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {
      if (trigger.__popoverWired) return;
      trigger.__popoverWired = true;
      const target = document.getElementById(trigger.dataset.tooltipTarget);
      if (!target) return;
      let showTimer = null, hideTimer = null;
      const show = () => {
        clearTimeout(hideTimer);
        if (target.classList.contains('show')) return;
        clearTimeout(showTimer);
        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);
      };
      const queueHide = () => {
        clearTimeout(showTimer);
        clearTimeout(hideTimer);
        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);
      };
      trigger.addEventListener('mouseenter', show);
      trigger.addEventListener('mouseleave', queueHide);
      target.addEventListener('mouseenter', show);
      target.addEventListener('mouseleave', queueHide);
      trigger.addEventListener('click', e => {
        clearTimeout(showTimer);
        if (target.classList.contains('show')) target.classList.remove('show');
        else target.classList.add('show');
        e.stopPropagation();
      });
    });
  };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
  else wire();
})();
</script>
<div class="subtitle">Hydrogen captures the mic directly, no iframe permission needed. Click Record voiceover to get a 3-2-1 countdown, then start speaking when the video plays.</div>

<div id="timelineCard" class="card" style="display:none;">
  <div class="card-title">Speedup Timeline</div>
  <div class="timeline-summary" id="timelineSummary">
    <span class="stat-label">Source</span> <span class="stat-val" id="tlInputDur">--</span>
    <span class="stat-sep">→</span>
    <span class="stat-label">After speedup</span> <span class="stat-val" id="tlOutputDur">--</span>
    <span class="stat-sep">·</span>
    <span class="stat-saved" id="tlSaved">--</span>
    <span class="stat-sep">·</span>
    <span class="stat-label">Speedup segments</span> <span class="stat-val" id="tlCount">--</span>
  </div>
  <div class="timeline-track-wrap">
    <div class="timeline-track-label">Source video</div>
    <div class="timeline-track" id="timelineSource"></div>
    <div class="timeline-track-label">After speedup</div>
    <div class="timeline-track" id="timelineOutput"></div>
  </div>
</div>

<div class="card">
  <div id="videoTabs" class="video-tabs"></div>
  <div class="video-wrap">
    <video id="vid" controls preload="auto" src="video"></video>
    <div id="countdown" class="countdown"></div>
    <div id="wrapCountdown" class="wrap-countdown" data-tooltip="Appears during the final five seconds of playback while recording, so you have time to wrap up your narration with something like 'thanks for watching' or 'that's all for today'. When the video ends, the recording automatically stops so you never get silence tacked on the end.">
      <span>Wrap up in</span>
      <span class="big" id="wrapCountdownNum">5</span>
    </div>
  </div>

  <div class="controls" style="margin-top:14px;">
    <button id="btnRecord" class="btn-record" data-tooltip="Start a fresh voiceover take. You will get a 3-2-1 caption countdown, then the source video plays from the top (silent) while Hydrogen captures your mic. The previous takes stay as tabs above the video so you can come back to them any time.">Record voiceover</button>
    <span id="timer" class="timer">00:00</span>
    <button id="btnDiscard" disabled data-tooltip="Throw away the current in-progress recording before it gets turned into a take. The audio file on disk is preserved; this only clears the app's in-memory state so the next record is a fresh start.">Discard &amp; redo</button>
    <button id="btnFinalize" class="btn-finalize" disabled data-tooltip="Mux the captured audio with the sped-up video into Take N, then automatically run loudness normalization (EBU R128) to produce Take N (auto-leveled) as well. Both appear as new tabs above the video and the auto-leveled one plays back first.">Finalize &amp; mux</button>
  </div>

  <div id="postControls" class="controls" style="display:none; margin-top:10px;">
    <button id="btnReveal" data-tooltip-align="right" data-tooltip="Open the currently playing file in VS Code. The file explorer sidebar will auto-scroll to reveal it so you can see exactly where on disk this version lives.">Reveal in VS Code</button>
    <button id="btnSaveFinal" class="btn-save" data-tooltip-align="right" data-tooltip="Copy the currently selected tab (whichever version is playing) over the canonical output path. The tab files themselves stay on disk untouched so nothing is destroyed; this just sets the 'canonical final' slot to whichever version you picked.">Save as final</button>
  </div>
</div>

<div id="wavCard" class="card waveform-card" style="display:none;">
  <div class="card-title">Audio waveform</div>
  <div class="waveform-row">
    <div class="waveform-block">
      <div class="waveform-label">
        <span>Original mux</span>
        <span class="meta" id="wavOrigMeta">--</span>
      </div>
      <div id="wavOrigWrap" class="waveform-img-wrap empty">generating...</div>
    </div>
    <div id="wavNormBlock" class="waveform-block" style="display:none;">
      <div class="waveform-label">
        <span>After auto-level</span>
        <span class="meta" id="wavNormMeta">--</span>
      </div>
      <div id="wavNormWrap" class="waveform-img-wrap empty">generating...</div>
    </div>
  </div>
</div>

<div id="takesCard" class="card" style="display:none;">
  <div class="card-title">Takes history</div>
  <div class="takes-subtitle">Every mux and every auto-level pass is preserved on disk. Click a row to load and play it; Save as final will then commit whichever one you picked.</div>
  <div id="takesList" class="takes-list"></div>
</div>

<div id="status" class="status">Click Record voiceover to start. You will get a 3-2-1 countdown, then the video plays from the top while Hydrogen captures your mic.</div>

<div class="tip"><strong>Tip:</strong> If you mess up, click Discard &amp; redo to start fresh. After finalize, the muxed video auto-plays so you can hear the result. Click Auto-level audio to normalize loudness to EBU R128 broadcast standard (-16 LUFS target, -1.5 dBTP peak ceiling), then Save as final to commit the normalized version.</div>

<!-- EVAL_CLIENT -->
<script>
// Expose voiceover state to the /eval hot-patch channel so snippets
// can inspect current phase, current take, etc. via `ctx.*`.
window.__appCtx = window.__appCtx || {};
window.__appCtx.subcommand = 'voiceover';
</script>
<script>
// Forward UI console output to the server so the AI can read it via GET /console.
// Per app-creator skill: webview apps must surface their JS logs to the server.
(function() {
  const orig = { log: console.log, warn: console.warn, error: console.error };
  function forward(level, args) {
    orig[level].apply(console, args);
    try {
      // Use a relative URL (no leading slash) so it resolves under the
      // Coder /proxy/<port>/ prefix when running inside the Hydrogen webview.
      // A leading slash would hit hydrogen.adom.inc/console instead.
      fetch('console', {
        method: 'POST',
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify({
          level,
          ts: Date.now(),
          message: Array.prototype.slice.call(args).map(a => {
            try { return typeof a === 'string' ? a : JSON.stringify(a); }
            catch { return String(a); }
          }).join(' ')
        })
      }).catch(() => {});
    } catch {}
  }
  console.log = function() { forward('log', arguments); };
  console.warn = function() { forward('warn', arguments); };
  console.error = function() { forward('error', arguments); };
  window.addEventListener('error', e => forward('error', [e.message + ' at ' + (e.filename||'?') + ':' + (e.lineno||'?')]));
  window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection: ' + (e.reason && e.reason.message || e.reason)]));
  console.log('voiceover UI loaded');

  // Count how many iframe layers we're nested inside. Cross-origin blocks
  // reading the parent's DOM, but walking the window proxy chain (==/!==
  // comparisons and .parent traversal) is allowed in every browser. This
  // tells us exactly how many postMessage hops a MediaStreamTrack transfer
  // would need to survive to reach us from Hydrogen's top window.
  try {
    let depth = 0;
    let w = window;
    while (w !== w.top) {
      depth++;
      w = w.parent;
      if (depth > 10) break; // safety
    }
    const atTop = (window === window.top);
    const parentIsTop = (window.parent === window.top);
    console.log('iframe depth:', depth, '| atTop:', atTop, '| parentIsTop:', parentIsTop);
  } catch (e) {
    console.warn('iframe depth probe failed:', e.message);
  }
})();

const vid = document.getElementById('vid');
const videoTabs = document.getElementById('videoTabs');
const wrapCountdownEl = document.getElementById('wrapCountdown');
const wrapCountdownNum = document.getElementById('wrapCountdownNum');

// Install video listeners once. They only DO anything when phase=recording,
// so it is safe to leave them attached across the full app lifecycle.
vid.addEventListener('timeupdate', () => {
  if (lastPhase !== 'recording') {
    wrapCountdownEl.classList.remove('visible');
    return;
  }
  const duration = isFinite(vid.duration) ? vid.duration : 0;
  if (!duration) return;
  const remaining = duration - vid.currentTime;
  if (remaining <= 5 && remaining > 0) {
    wrapCountdownEl.classList.add('visible');
    wrapCountdownNum.textContent = String(Math.max(1, Math.ceil(remaining)));
  } else {
    wrapCountdownEl.classList.remove('visible');
  }
});
vid.addEventListener('ended', () => {
  if (lastPhase === 'recording') {
    console.log('video ended while recording, auto-stopping');
    wrapCountdownEl.classList.remove('visible');
    stopRecording();
  }
});

// The active video tab IS the "currently playing" indicator now.
// This function stays as a no-op so older call sites keep working.
function setNowPlaying(_kind) {}
const btnRecord = document.getElementById('btnRecord');
const btnDiscard = document.getElementById('btnDiscard');
const btnFinalize = document.getElementById('btnFinalize');
const btnSaveFinal = document.getElementById('btnSaveFinal');
const btnReveal = document.getElementById('btnReveal');
const postControls = document.getElementById('postControls');
const takesCard = document.getElementById('takesCard');
const takesList = document.getElementById('takesList');
const timerEl = document.getElementById('timer');
const status = document.getElementById('status');
const countdownEl = document.getElementById('countdown');

let lastPhase = null;
let recordStartTime = 0;
let timerInterval = null;

function setStatus(msg, kind) {
  status.textContent = msg;
  status.className = 'status' + (kind ? ' ' + kind : '');
}

function fmtDuration(ms) {
  const s = Math.floor(ms / 1000);
  const m = Math.floor(s / 60);
  const sec = s % 60;
  return String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
}

// --- state-driven UI ----------------------------------------------------
// Server phase is the single source of truth. The UI polls /state every
// 400ms and reacts to transitions. Any AI with curl can trigger the same
// transitions by POSTing to the endpoints, and the UI will animate the
// video, buttons, and timer identically. This is the "2-way comms" part
// of the app-creator pattern: AI controls frontend via server state.
function applyPhase(phase) {
  if (phase === lastPhase) return;
  console.log('phase transition:', lastPhase, '->', phase);
  lastPhase = phase;
  if (phase === 'idle') {
    btnRecord.disabled = false;
    btnRecord.classList.remove('recording');
    btnRecord.textContent = 'Record voiceover';
    btnDiscard.disabled = true;
    btnFinalize.disabled = true;
    postControls.style.display = 'none';
    vid.pause();
    vid.currentTime = 0;
    if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
    timerEl.classList.remove('recording');
    timerEl.textContent = '00:00';
    setStatus('Click Record voiceover to start. Get a 3-2-1 countdown, then the video plays from the top while Hydrogen captures your mic.');
  } else if (phase === 'recording') {
    btnRecord.disabled = false;
    btnRecord.classList.add('recording');
    btnRecord.textContent = 'Stop';
    btnDiscard.disabled = true;
    btnFinalize.disabled = true;
    postControls.style.display = 'none';
    // Swap the player back to the source video (silent) so the user
    // narrates against the raw input, NOT against a previously-finalized
    // mux which would contain their earlier voice. This is the key
    // detail that makes re-recording safe: no double voices.
    if (vid.src.indexOf('final-video') !== -1 || !vid.src.endsWith('video')) {
      vid.src = 'video?t=' + Date.now();
      vid.load();
    }
    setNowPlaying('source');
    vid.currentTime = 0;
    vid.play().catch(e => console.warn('video play failed:', e.message));
    recordStartTime = Date.now();
    timerEl.classList.add('recording');
    if (timerInterval) clearInterval(timerInterval);
    timerInterval = setInterval(() => {
      timerEl.textContent = fmtDuration(Date.now() - recordStartTime);
    }, 200);
    setStatus('Recording... narrate over the video. Click Stop to finish.');
  } else if (phase === 'recorded') {
    btnRecord.disabled = false;
    btnRecord.classList.remove('recording');
    btnRecord.textContent = 'Record voiceover';
    btnDiscard.disabled = false;
    btnFinalize.disabled = false;
    vid.pause();
    if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
    timerEl.classList.remove('recording');
    setStatus('Recorded. Click Finalize to mux with the video, or Discard to redo.');
  } else if (phase === 'finalizing') {
    btnRecord.disabled = true;
    btnDiscard.disabled = true;
    btnFinalize.disabled = true;
    setStatus('Running ffmpeg mux...');
  } else if (phase === 'done') {
    // Keep Record and Discard enabled so the user can start a new take
    // at any time if they don't like the current result. Previous takes
    // and normalize sidecars stay on disk untouched.
    btnRecord.disabled = false;
    btnRecord.classList.remove('recording');
    btnRecord.textContent = 'Record voiceover';
    btnDiscard.disabled = false;
    btnFinalize.disabled = true;
    postControls.style.display = 'flex';
    btnSaveFinal.disabled = false;
    loadWaveform('raw');
    loadTakes();
    // Fetch the final path from the server and auto-play the muxed result
    // so the user can immediately hear their voiceover over the sped-up
    // video. Bust the browser's cache on the video element by appending
    // a timestamp, otherwise a prior /final-video fetch will be reused.
    (async () => {
      try {
        const s = await (await fetch('state')).json();
        const finalPath = s.finalizePath || '(unknown path)';
        setStatus('Final narrated video saved to:\n  ' + finalPath + '\n\nPlaying the raw mux now. Click Auto-level audio to normalize loudness (EBU R128), or Save as final if you are happy with it.', 'success');
        vid.src = 'final-video?t=' + Date.now();
        vid.load();
        setNowPlaying('raw');
        vid.currentTime = 0;
        vid.play().catch(e => console.warn('final video autoplay failed:', e.message));
      } catch (e) {
        setStatus('Done, but could not read final state: ' + e.message, 'error');
      }
    })();
  } else if (phase === 'error') {
    btnRecord.disabled = false;
    btnDiscard.disabled = false;
    btnFinalize.disabled = false;
    setStatus('An error occurred. Check the server logs.', 'error');
  }
}

// --- speedup timeline ------------------------------------------------
// Renders a visual of the source video with speedup regions highlighted.
// Drives from GET /timeline which parses the markers file passed on
// the CLI. Two bars are drawn: the source timeline (full input duration)
// and the after-speedup timeline (compressed). Both bars are stretched
// to the SAME width so the difference between bar segments visually
// shows how much shorter the output is.
const timelineCard = document.getElementById('timelineCard');
const tlInputDur = document.getElementById('tlInputDur');
const tlOutputDur = document.getElementById('tlOutputDur');
const tlSaved = document.getElementById('tlSaved');
const tlCount = document.getElementById('tlCount');
const timelineSource = document.getElementById('timelineSource');
const timelineOutput = document.getElementById('timelineOutput');

function fmtDurHms(s) {
  if (!isFinite(s) || s < 0) return '--';
  const m = Math.floor(s / 60);
  const sec = s - m * 60;
  if (m > 0) return m + 'm ' + sec.toFixed(1) + 's';
  return sec.toFixed(1) + 's';
}

function tlSegTooltip(seg) {
  if (seg.kind === 'speedup') {
    return [
      seg.label + ' (' + seg.speed + 'x speedup)',
      '',
      'ORIGINAL  ' + fmtDurHms(seg.duration) + '  (' + seg.start.toFixed(1) + 's to ' + seg.end.toFixed(1) + 's)',
      'AFTER     ' + fmtDurHms(seg.compressedDuration),
      'SAVED     ' + fmtDurHms(seg.duration - seg.compressedDuration),
      '',
      'This stretch of the source video is played back at ' + seg.speed + 'x with a red overlay caption labeled "' + seg.speed + 'x SPEEDUP: ' + seg.label + '" on top.'
    ].join('\n');
  }
  return [
    'Normal segment',
    '',
    'FROM   ' + seg.start.toFixed(1) + 's',
    'TO     ' + seg.end.toFixed(1) + 's',
    'DUR    ' + fmtDurHms(seg.duration),
    '',
    'This stretch of the source video is played back at normal speed with no overlay caption.'
  ].join('\n');
}

async function loadTimeline() {
  try {
    const r = await fetch('timeline');
    const d = await r.json();
    if (!d.ok || !d.available) {
      timelineCard.style.display = 'none';
      return;
    }
    timelineCard.style.display = 'block';
    tlInputDur.textContent = fmtDurHms(d.inputDuration);
    tlOutputDur.textContent = fmtDurHms(d.outputDuration);
    const saved = d.savedSeconds;
    tlSaved.textContent = saved > 0
      ? ('Saved ' + fmtDurHms(saved) + ' (' + d.reductionPct.toFixed(1) + '% shorter)')
      : 'No speedup applied';
    tlCount.textContent = String(d.speedupSegmentCount || 0);

    // Render source bar with segment widths proportional to their real
    // durations. Each segment's flex-basis is its duration in seconds
    // of the input; flex-grow 0 pins the basis. Total basis = input
    // duration, so widths are proportional.
    timelineSource.innerHTML = '';
    for (const seg of d.segments) {
      const el = document.createElement('div');
      el.className = 'timeline-seg ' + seg.kind;
      el.style.flex = '0 0 ' + (seg.duration / d.inputDuration * 100) + '%';
      el.setAttribute('data-tooltip', tlSegTooltip(seg));
      if (seg.kind === 'speedup') {
        el.textContent = seg.speed + 'x';
      } else {
        el.textContent = fmtDurHms(seg.duration);
      }
      timelineSource.appendChild(el);
    }

    // Render output bar. Each segment's width is its COMPRESSED duration
    // over the total output duration, so speedup segments look tiny
    // compared to the source bar.
    timelineOutput.innerHTML = '';
    for (const seg of d.segments) {
      const el = document.createElement('div');
      el.className = 'timeline-seg ' + seg.kind;
      const effective = seg.kind === 'speedup' ? seg.compressedDuration : seg.duration;
      el.style.flex = '0 0 ' + (effective / d.outputDuration * 100) + '%';
      el.setAttribute('data-tooltip', tlSegTooltip(seg));
      if (seg.kind === 'speedup') {
        el.textContent = seg.speed + 'x';
      } else {
        el.textContent = fmtDurHms(seg.duration);
      }
      timelineOutput.appendChild(el);
    }
  } catch (e) {
    console.warn('loadTimeline failed:', e.message);
  }
}

// --- waveform chart --------------------------------------------------
// Shows the audio envelope of the current result so the user can see
// (not just hear) how loudnorm changed the levels. Raw (committed mux)
// goes in the top slot; post-normalize preview goes in the bottom slot.
const wavCard = document.getElementById('wavCard');
const wavOrigWrap = document.getElementById('wavOrigWrap');
const wavOrigMeta = document.getElementById('wavOrigMeta');
const wavNormBlock = document.getElementById('wavNormBlock');
const wavNormWrap = document.getElementById('wavNormWrap');
const wavNormMeta = document.getElementById('wavNormMeta');

const RAW_TOOLTIP = 'The raw audio exactly as Hydrogen captured it from your mic, then muxed with the sped-up video. Nothing has been changed. Because Hydrogen uses Chrome\'s default mic settings (automatic gain control on, noise suppression on), the first 1-2 seconds may be louder than the rest, and quiet moments may be slightly squashed. This is the "before" snapshot for comparison.';

const NORM_TOOLTIP = [
  'EBU R128 is a loudness standard used by broadcast TV, podcasts, Spotify, YouTube, and most streaming platforms. It measures how loud audio feels to a human, not just the peak voltage.',
  '',
  '-16 LUFS target:',
  'We re-balanced the overall loudness so the typical level of your voice sits around -16 LUFS, which is the standard target for web and podcast playback. (LUFS means Loudness Units Full Scale; more negative = quieter. -16 is a comfortable medium for headphones and laptop speakers.)',
  '',
  '-1.5 dBTP peak ceiling:',
  'We prevented any single moment from hitting the digital clipping ceiling. The loudest peaks now cap at 1.5 dB below maximum, which leaves safe headroom so nothing distorts when played back on different devices. dBTP means "decibels true peak" and it catches sharp spikes that regular peak meters miss.',
  '',
  'Practical effect:',
  'Quiet moments got slightly louder so you can hear them. Loud moments (like the Chrome AGC warmup bump at the start of the recording) got slightly softer. The whole thing now has consistent volume, and nothing clips. Your original raw file is still on disk, untouched; this is a separate preview sidecar.'
].join('\n');

function fmtBytes(n) {
  if (n < 1024) return n + ' B';
  if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
  return (n/1024/1024).toFixed(2) + ' MB';
}
function fmtAgo(epochSec) {
  const delta = Math.max(0, Math.floor(Date.now()/1000) - epochSec);
  if (delta < 60) return delta + 's ago';
  if (delta < 3600) return Math.floor(delta/60) + 'm ago';
  if (delta < 86400) return Math.floor(delta/3600) + 'h ago';
  return Math.floor(delta/86400) + 'd ago';
}
function basename(p) { return (p.split('/').pop() || p); }

// Client-side tracking of the active tab. The server's /takes endpoint
// can only mark a tab as selected if its path matches preview_path or
// finalize_path; the synthetic "Original" tab has an empty path and
// therefore would never show as selected on server-returned data. We
// overlay this client-side value on top of /takes so the visual state
// always matches what the user actually clicked.
let clientSelectedKey = null;  // 'original' | path string | null
function keyForTake(t) {
  return t.kind === 'original' ? 'original' : t.path;
}

async function switchToTake(t) {
  // Handles clicks from both the tab strip and the history card.
  // For the synthetic "Original" tab (kind === "original") we swap
  // vid.src to the source /video endpoint. For everything else we
  // call /select-take to set preview_path server-side, then swap
  // the src to /final-video which follows preview_path.
  console.log('switchToTake', t.kind, t.label, t.path);
  clientSelectedKey = keyForTake(t);
  try {
    if (t.kind === 'original') {
      // Clear preview_path server-side so /final-video reflects the
      // previously-selected committed version, and load the source
      // directly (it's what the user would record voiceover against).
      await fetch('revert-to-raw', { method: 'POST' }).catch(() => {});
      vid.src = 'video?t=' + Date.now();
      vid.load();
      vid.currentTime = 0;
      vid.play().catch(e => console.warn('original play failed:', e.message));
      setStatus('Playing the original silent sped-up video (no voiceover). Click a take tab to hear one of your voiceovers, or click Record voiceover to make a new take.', 'success');
    } else {
      const resp = await fetch('select-take', {
        method: 'POST',
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify({path: t.path})
      });
      const result = await resp.json();
      if (!result.ok) {
        setStatus('Select failed: ' + (result.error || 'unknown'), 'error');
        return;
      }
      vid.src = 'final-video?t=' + Date.now();
      vid.load();
      vid.currentTime = 0;
      vid.play().catch(e => console.warn('take play failed:', e.message));
      setStatus('Playing: ' + t.label + '\n(' + basename(t.path) + ')\n\nClick Save as final to commit this version to the canonical output path, or keep clicking tabs to compare.', 'success');
    }
    await loadTakes();
  } catch (e) {
    setStatus('Tab switch failed: ' + e.message, 'error');
  }
}

function fmtAbsTime(epochSec) {
  if (!epochSec) return '(unknown time)';
  const d = new Date(epochSec * 1000);
  const pad = n => String(n).padStart(2, '0');
  return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
         ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}

function takeTooltip(t) {
  // Build a rich tooltip with the descriptive blurb up top, followed by a
  // dashed rule and a file-metadata section (path, size, mtime) in
  // monospace so the technical detail is always visible on hover.
  let blurb = '';
  if (t.kind === 'original') {
    blurb = 'The silent sped-up source video.\n\nThis is what plays while you record your voiceover (no audio baked in), and what you can come back to if you want to start over from nothing. Click this tab to preview the source; click Record voiceover to start a new take against it.';
  } else if (t.kind === 'raw') {
    blurb = 'Take ' + t.take + ' - RAW MUX.\n\nYour voice exactly as Hydrogen captured it, combined with the sped-up video. No loudness processing has been applied. Use this tab to hear what you literally recorded, including any AGC warmup bump, clipping, or quiet sections. Its auto-leveled sibling (Take ' + t.take + ' auto-leveled) is usually what you actually want to save.';
  } else if (t.kind === 'normalized') {
    blurb = 'Take ' + t.take + ' - AUTO-LEVELED.\n\nThe raw mux of Take ' + t.take + ' run through ffmpeg loudnorm (EBU R128 broadcast standard: -16 LUFS target, -1.5 dBTP peak ceiling, 11 LRA loudness range). Volume differences across the take have been evened out, loud peaks are capped so nothing clips, and the AGC warmup bump at the start is smoothed out. This is usually the one you want to Save as final.';
  } else if (t.kind === 'committed') {
    blurb = 'Canonical final file.\n\nThis is whatever got copied to the real output path last time Save as final was clicked, OR the recovered mux from a previous session that the app found on disk at startup. Clicking Save as final while another tab is selected will overwrite this slot.';
  } else if (t.kind === 'legacy') {
    blurb = 'Legacy auto-leveled pass.\n\nA loudness-normalized file from an earlier manual Auto-level run, before the app started auto-normalizing on every Finalize. Kept on disk so you can still hear it if it was the one you liked best. Safe to ignore if you do not remember making it.';
  } else {
    blurb = t.label;
  }

  const rows = [];
  rows.push('FILE   ' + (t.path || '(synthetic, no file)'));
  if (typeof t.size === 'number' && t.size > 0) {
    rows.push('SIZE   ' + fmtBytes(t.size));
  }
  if (t.mtime) {
    rows.push('SAVED  ' + fmtAbsTime(t.mtime) + '  (' + fmtAgo(t.mtime) + ')');
  }
  if (typeof t.take === 'number') {
    rows.push('TAKE   #' + t.take + '  (' + (t.kind === 'raw' ? 'raw mux' : 'auto-leveled') + ')');
  }
  rows.push('KIND   ' + t.kind);
  if (t.selected) {
    rows.push('STATE  currently playing in the video player');
  }

  return blurb + '\n\n----------\n' + rows.join('\n');
}

async function loadTakes() {
  try {
    const r = await fetch('takes');
    const d = await r.json();
    if (!d.ok) return;
    const takes = d.takes || [];
    // Determine the effective selected tab. If the user has clicked a
    // specific tab client-side (including the synthetic Original), that
    // wins. Otherwise fall back to whatever the server says.
    const effectiveKey = clientSelectedKey
      || (takes.find(x => x.selected) ? keyForTake(takes.find(x => x.selected)) : null);

    // --- Tab strip (quick switcher) ---
    videoTabs.innerHTML = '';
    for (const t of takes) {
      const isSel = keyForTake(t) === effectiveKey;
      const tab = document.createElement('div');
      tab.className = 'video-tab ' + t.kind + (isSel ? ' selected' : '');
      tab.setAttribute('data-tooltip', takeTooltip({ ...t, selected: isSel }));
      tab.innerHTML = '<span class="dot"></span><span>' + t.label + '</span>';
      tab.addEventListener('click', () => switchToTake(t));
      videoTabs.appendChild(tab);
    }
    // --- History card (rich detail) ---
    if (takes.length === 0) { takesCard.style.display = 'none'; return; }
    takesCard.style.display = 'block';
    takesList.innerHTML = '';
    for (const t of takes) {
      if (t.kind === 'original') continue; // no row for the synthetic tab
      const isSel = keyForTake(t) === effectiveKey;
      const row = document.createElement('div');
      row.className = 'takes-row' + (isSel ? ' selected' : '');
      row.setAttribute('data-tooltip', takeTooltip({ ...t, selected: isSel }));
      const sz = t.size ? fmtBytes(t.size) : '';
      const mt = t.mtime ? fmtAgo(t.mtime) : '';
      row.innerHTML =
        '<div class="kind-pill ' + t.kind + '">' + (t.kind === 'normalized' ? 'auto-leveled' : t.kind) + '</div>' +
        '<div class="path" title="' + (t.path || '') + '">' + t.label + '</div>' +
        '<div class="size">' + sz + '</div>' +
        '<div class="mtime">' + mt + '</div>' +
        '<div class="status-badge">selected</div>';
      row.addEventListener('click', () => switchToTake(t));
      takesList.appendChild(row);
    }
  } catch (e) {
    console.warn('loadTakes failed:', e.message);
  }
}

function loadWaveform(kind) {
  wavCard.style.display = 'block';
  if (kind === 'raw') {
    wavOrigWrap.classList.remove('empty');
    wavOrigWrap.innerHTML = '<img alt="raw waveform" src="waveform-raw?t=' + Date.now() + '">';
    wavOrigMeta.textContent = 'pre-normalize';
    wavOrigMeta.setAttribute('data-tooltip', RAW_TOOLTIP);
  } else if (kind === 'preview') {
    wavNormBlock.style.display = 'flex';
    wavNormWrap.classList.remove('empty');
    wavNormWrap.innerHTML = '<img alt="normalized waveform" src="waveform-preview?t=' + Date.now() + '">';
    wavNormMeta.textContent = 'EBU R128 -16 LUFS / -1.5 dBTP';
    wavNormMeta.setAttribute('data-tooltip', NORM_TOOLTIP);
  } else if (kind === 'reset') {
    wavNormBlock.style.display = 'none';
    wavNormWrap.innerHTML = '';
  }
}

async function pollState() {
  try {
    const resp = await fetch('state');
    if (!resp.ok) return;
    const s = await resp.json();
    applyPhase(s.phase);
  } catch (e) {
    // transient; next tick will retry
  }
}
setInterval(pollState, 400);
pollState();
// Populate tabs on first load regardless of phase, so pre-existing
// takes from previous sessions (recovered state, etc.) show up.
loadTakes();
// Fetch + render the speedup timeline if markers are available.
loadTimeline();

async function startRecording() {
  btnRecord.disabled = true;
  setStatus('Requesting 3-2-1 countdown from Hydrogen...');
  try {
    const resp = await fetch('start-recording', { method: 'POST' });
    const result = await resp.json().catch(() => ({}));
    if (!result.ok) {
      setStatus('Hydrogen audio start failed:\n' + (result.error || 'unknown error'), 'error');
      btnRecord.disabled = false;
      return;
    }
    // Server has already run the 3-2-1 caption countdown and started audio.
    // The state poller will pick up phase=recording on the next tick and
    // drive the video playback + button animation.
  } catch (e) {
    setStatus('Failed to start Hydrogen audio: ' + e.message, 'error');
    btnRecord.disabled = false;
  }
}

async function stopRecording() {
  btnRecord.disabled = true;
  setStatus('Stopping audio recording...');
  try {
    const resp = await fetch('stop-recording', { method: 'POST' });
    const result = await resp.json().catch(() => ({}));
    if (!result.ok) {
      setStatus('Hydrogen audio stop failed:\n' + (result.error || 'unknown error'), 'error');
      btnRecord.disabled = false;
    }
    // State poller picks up phase=recorded and drives the UI.
  } catch (e) {
    setStatus('Failed to stop Hydrogen audio: ' + e.message, 'error');
    btnRecord.disabled = false;
  }
}

btnRecord.addEventListener('click', () => {
  // Record button is a toggle based on current server phase.
  if (lastPhase === 'recording') stopRecording();
  else startRecording();
});

btnDiscard.addEventListener('click', () => {
  // Discard is UI-only reset. The saved audio file is left on disk so the
  // user can still recover it if they change their mind before finalizing.
  // The state poller will push us back to idle once the server is reset.
  fetch('discard', { method: 'POST' }).catch(() => {});
  setStatus('Discarded. Click Record voiceover to try again from the top.');
});

btnReveal.addEventListener('click', async () => {
  try {
    const resp = await fetch('reveal', { method: 'POST' });
    const result = await resp.json();
    if (!result.ok) {
      setStatus('Reveal failed: ' + (result.error || 'unknown'), 'error');
      return;
    }
    setStatus('Opened in VS Code:\n  ' + result.path, 'success');
  } catch (e) {
    setStatus('Reveal request failed: ' + e.message, 'error');
  }
});

btnSaveFinal.addEventListener('click', async () => {
  btnSaveFinal.disabled = true;
  setStatus('Saving the selected tab as the canonical final output...');
  try {
    const resp = await fetch('save-final', { method: 'POST' });
    const result = await resp.json();
    if (!result.ok) {
      setStatus('Save failed:\n' + (result.error || 'unknown error'), 'error');
      btnSaveFinal.disabled = false;
      return;
    }
    setStatus('Saved. Canonical final video is at:\n  ' + result.path + (result.note ? '\n\n(' + result.note + ')' : ''), 'success');
    loadTakes();
    btnSaveFinal.disabled = false;
  } catch (e) {
    setStatus('Save request failed: ' + e.message, 'error');
    btnSaveFinal.disabled = false;
  }
});

btnFinalize.addEventListener('click', async () => {
  btnFinalize.disabled = true;
  btnDiscard.disabled = true;
  btnRecord.disabled = true;
  setStatus('Running ffmpeg mux...');
  try {
    const finalize = await fetch('finalize', { method: 'POST' });
    const result = await finalize.json();
    if (result.ok) {
      // The state poller will pick up phase=done and handle the video
      // swap + auto-playback of the final muxed result. No auto-shutdown.
    } else {
      setStatus('Finalize failed:\n' + (result.error || 'unknown error'), 'error');
      btnRecord.disabled = false;
      btnDiscard.disabled = false;
      btnFinalize.disabled = false;
    }
  } catch (e) {
    setStatus('Finalize failed: ' + e.message, 'error');
    btnRecord.disabled = false;
    btnDiscard.disabled = false;
    btnFinalize.disabled = false;
  }
});
</script>
</body>
</html>