app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Adom Video Post</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-600-normal.woff2') format('woff2');
font-weight: 600; 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;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');
font-weight: 500; font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');
font-weight: 600; font-display: swap;
}
:root {
--bg: #0d1117;
--surface: #161b22;
--elevated: #1c2128;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text2: #7d8590;
--text3: #484f58;
--accent: #00b8b0;
--accent-bright: #00e6dc;
--accent-glow: rgba(0,184,176,0.25);
--purple: #8C6BF7;
--purple-light: #a890ff;
--green: #4aa857;
--red: #f85149;
--yellow: #d4aa3e;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Familjen Grotesk', 'Satoshi', -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
}
/* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.
Single row, 44 px tall + phase tabs as a separate row beneath. */
header {
padding: 0;
border-bottom: 1px solid var(--border-muted);
background: var(--bg);
position: relative;
}
header .header-row {
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
font-family: 'Familjen Grotesk', sans-serif;
}
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 {
display: flex;
align-items: center;
gap: 6px;
color: var(--text2);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
}
header .subject-meta .sep { color: var(--text3); }
@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; }
/* Phase selector: three tabs below the title */
.phase-tabs {
display: flex;
gap: 0;
margin-top: 14px;
border-bottom: 1px solid var(--border-muted);
}
.phase-tab {
padding: 12px 24px;
background: transparent;
border: none;
color: var(--text2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.phase-tab:hover { color: var(--text); }
.phase-tab.active {
color: var(--accent-bright);
border-bottom-color: var(--accent);
}
.phase-tab .step {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
background: var(--elevated);
color: var(--text3);
border-radius: 50%;
font-size: 10px;
font-weight: 700;
margin-right: 8px;
}
.phase-tab.active .step {
background: var(--accent);
color: var(--bg);
}
.phase-panel { display: none; }
.phase-panel.active { display: block; }
/* ─── Review phase (storyboard) ──────────────────────────── */
.finals {
margin: 18px 32px 0;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.finals .card {
flex: 1;
min-width: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.finals h3 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text3);
}
.timeline {
display: flex;
align-items: center;
gap: 6px;
background: var(--elevated);
border: 1px solid var(--border);
padding: 14px 18px;
margin: 18px 32px;
border-radius: 8px;
overflow-x: auto;
white-space: nowrap;
}
.tl-step {
flex-shrink: 0;
padding: 7px 13px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text);
cursor: pointer;
text-decoration: none;
display: inline-flex;
flex-direction: column;
gap: 2px;
}
.tl-step.speedup { border-color: var(--purple); background: #1f1530; }
.tl-step:hover { border-color: var(--accent-bright); }
.tl-step .num { color: var(--text3); font-size: 10px; font-weight: 600; }
.tl-step .mark { color: var(--purple); font-size: 10px; }
.steps { padding: 0 32px 80px; }
.step-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
margin-top: 20px;
}
.step-card h2 {
margin: 0 0 6px;
font-size: 22px;
color: var(--accent-bright);
display: flex;
align-items: center;
gap: 12px;
}
.step-card h2 .badge {
background: var(--accent);
color: var(--bg);
padding: 4px 10px;
border-radius: 16px;
font-size: 13px;
font-weight: 700;
}
.step-card .desc { color: var(--text2); font-size: 13px; margin-bottom: 14px; }
.speedup-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
background: var(--purple);
color: #fff;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}
.actions-list {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text2);
margin-top: 12px;
max-height: 160px;
overflow-y: auto;
}
.warnings {
background: #2d2412;
border: 1px solid #a8791e;
color: #f0c260;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
margin-top: 12px;
}
.clip-row { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 18px; }
.clip-block { display: flex; flex-direction: column; gap: 6px; }
.clip-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.clip-label.raw { color: var(--accent-bright); }
.clip-label.fast { color: var(--purple); }
.video-wrap {
position: relative;
aspect-ratio: 16 / 10;
background: #000;
border-radius: 6px;
border: 1px solid var(--border);
overflow: hidden;
}
.video-wrap video {
width: 100%;
height: 100%;
display: block;
background: #000;
object-fit: contain;
}
.video-wrap .expand {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.75);
color: #fff;
border: 1px solid rgba(255,255,255,0.3);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
z-index: 5;
transition: background 0.15s;
}
.video-wrap .expand:hover {
background: rgba(0,0,0,0.95);
border-color: var(--accent-bright);
color: var(--accent-bright);
}
/* ─── Narrate phase (voiceover) ─────────────────────────── */
.narrate {
padding: 24px 32px 80px;
max-width: 1100px;
margin: 0 auto;
}
.narrate .big-video {
background: #000;
border: 1px solid var(--border);
border-radius: 10px;
aspect-ratio: 16 / 10;
overflow: hidden;
margin-bottom: 18px;
}
.narrate .big-video video {
width: 100%;
height: 100%;
object-fit: contain;
}
.narrate-controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.narrate button {
background: var(--elevated);
color: var(--text);
border: 1px solid var(--border);
padding: 10px 18px;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.narrate button:hover:not(:disabled) {
border-color: var(--accent);
background: rgba(0,184,176,0.08);
}
.narrate button:disabled { opacity: 0.4; cursor: not-allowed; }
.narrate .btn-record {
background: var(--red);
color: #fff;
border-color: var(--red);
padding: 12px 22px;
font-weight: 600;
box-shadow: 0 4px 14px rgba(248,81,73,0.25);
}
.narrate .btn-record:hover:not(:disabled) {
background: #ff5a52;
color: #fff;
}
.narrate .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;
}
.narrate .btn-record.recording::before { content: '⏹ '; }
.narrate .btn-finalize {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 500;
}
.narrate .btn-finalize:hover:not(:disabled) {
background: var(--accent-bright);
border-color: var(--accent-bright);
color: var(--bg);
}
@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); }
}
.narrate .status {
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
color: var(--text2);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.narrate .not-ready {
background: #2d2412;
border: 1px solid #a8791e;
color: #f0c260;
border-radius: 8px;
padding: 20px 24px;
font-size: 13px;
margin-bottom: 18px;
line-height: 1.6;
}
.narrate .not-ready .prepare-btn {
display: inline-block;
margin-top: 14px;
background: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
padding: 11px 22px;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.narrate .not-ready .prepare-btn:hover:not(:disabled) {
background: var(--accent-bright);
color: var(--bg);
}
.narrate .not-ready .prepare-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.narrate .not-ready .prepare-hint {
display: block;
margin-top: 10px;
color: #caa860;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
/* ─── Publish phase ─────────────────────────────────────── */
.publish {
padding: 32px;
max-width: 780px;
margin: 0 auto;
color: var(--text2);
font-size: 14px;
}
.publish h3 { color: var(--text); margin: 0 0 14px; font-size: 20px; font-weight: 700; }
.publish p { margin: 0 0 14px; }
.publish .inline-code {
background: var(--elevated);
padding: 2px 8px;
border-radius: 4px;
color: var(--accent-bright);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.publish form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 24px;
margin-top: 18px;
}
.publish label {
display: block;
margin-bottom: 14px;
}
.publish label .lbl {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
color: var(--text3);
margin-bottom: 6px;
}
.publish input[type="text"] {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
}
.publish input[type="text"]:focus {
border-color: var(--accent);
outline: none;
}
.publish .prompt-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 18px;
margin-top: 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.publish .prompt-actions {
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
}
.publish .copy-btn {
background: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
padding: 10px 18px;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.publish .copy-btn:hover { background: var(--accent-bright); color: var(--bg); }
.publish .copy-btn.copied {
background: var(--green);
border-color: var(--green);
color: var(--bg);
}
.publish .publish-warning {
background: #2d2412;
border: 1px solid #a8791e;
color: #f0c260;
border-radius: 8px;
padding: 14px 18px;
font-size: 13px;
margin-top: 16px;
}
/* Lightbox + video fullscreen — shared across phases */
.lightbox {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0,0,0,0.92);
align-items: center;
justify-content: center;
padding: 40px;
cursor: zoom-out;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
border: 1px solid var(--border);
}
.lightbox .close {
position: absolute;
top: 20px;
right: 32px;
color: var(--text);
font-size: 32px;
cursor: pointer;
background: none;
border: none;
}
.video-fullscreen {
display: none;
position: fixed;
inset: 0;
z-index: 2000;
background: #000;
align-items: center;
justify-content: center;
}
.video-fullscreen.open { display: flex; }
.video-fullscreen video {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.video-fullscreen .close {
position: absolute;
top: 16px;
right: 20px;
z-index: 2001;
background: rgba(0,0,0,0.85);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
font-size: 20px;
cursor: pointer;
padding: 8px 14px;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.
Single row + phase tabs row underneath. -->
<header>
<div class="header-row">
<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">review · narrate · publish</span>
</div>
<div class="vsep" aria-hidden="true"></div>
<div class="subject">
<span class="subject-name" id="title">Loading…</span>
<span class="subject-meta" id="stats"></span>
</div>
</div>
<div class="phase-tabs">
<button class="phase-tab active" data-phase="review">
<span class="step">1</span>Review
</button>
<button class="phase-tab" data-phase="narrate">
<span class="step">2</span>Narrate
</button>
<button class="phase-tab" data-phase="publish">
<span class="step">3</span>Publish
</button>
</div>
<div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
<div class="popover-title">adom-video-post</div>
<div class="popover-row"><span class="k">about</span>
<span class="v">Per-clip review, voiceover narration, and final concat for Adom feature-tour demos.</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> — house TTS engine + pronunciation overrides shared by every Adom tool that speaks. Mispronounced terms? Add them once there.</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);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', 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>
<!-- ─── Review phase ─────────────────────────────────────── -->
<section id="phase-review" class="phase-panel active">
<div id="finals" class="finals" style="display:none;"></div>
<div id="timeline" class="timeline" style="display:none;"></div>
<div id="steps" class="steps"></div>
</section>
<!-- ─── Narrate phase ────────────────────────────────────── -->
<section id="phase-narrate" class="phase-panel">
<div class="narrate">
<div id="narrateNotReady" class="not-ready" style="display:none;">
<strong>Narration isn't ready yet.</strong> The manifest has no
<code>final_fast_path</code> — your sped-up clips need to be
concatenated into a single video first.
<div>
<button id="prepareBtn" class="prepare-btn">Prepare for narration</button>
<span class="prepare-hint">
Runs <code>ffmpeg concat</code> over every clip's
<code>fast_path</code> and writes the result back into the
manifest. Re-enables this phase as soon as it's done.
</span>
</div>
</div>
<div class="big-video">
<video id="narrateVideo" controls preload="metadata" src="voiceover/video"></video>
</div>
<div class="narrate-controls">
<button id="narrateRecord" class="btn-record">Record voiceover</button>
<button id="narrateDiscard" disabled>Discard & redo</button>
<button id="narrateFinalize" class="btn-finalize" disabled>Finalize & mux</button>
</div>
<div id="narrateStatus" class="status">Click Record voiceover for a 3-2-1 countdown, then the video plays silently while Hydrogen captures your mic. Click Finalize & mux when done.</div>
</div>
</section>
<!-- ─── Publish phase ────────────────────────────────────── -->
<section id="phase-publish" class="phase-panel">
<div class="publish">
<h3>Publish to the Adom wiki</h3>
<p>
Fill in the target wiki page and a caption, then copy the
generated prompt and paste it into Claude Code. Claude will
use the <span class="inline-code">adom-video-post</span> +
<span class="inline-code">adom-wiki</span> skills to upload
the narrated final video.
</p>
<form onsubmit="event.preventDefault(); return false;">
<label>
<span class="lbl">Wiki page ref</span>
<input type="text" id="pubPage" placeholder="apps/adom-desktop" value="apps/adom-desktop">
</label>
<label>
<span class="lbl">Caption</span>
<input type="text" id="pubCaption" placeholder="Fusion 360 deep dive: BQ25792 manufacturing pipeline">
</label>
<div class="prompt-box" id="pubPrompt">(fill in the fields above to see the prompt)</div>
<div class="prompt-actions">
<button class="copy-btn" id="pubCopyBtn">Copy prompt</button>
<span id="pubPromptHint" style="color:var(--text3);font-size:11px;"></span>
</div>
<div id="pubNoVideo" class="publish-warning" style="display:none;">
⚠ No narrated video available yet. Finalize a voiceover in
the <strong>Narrate</strong> phase first, or paste the prompt
to Claude Code and it will pick up whatever final video
exists when it runs.
</div>
</form>
</div>
</section>
<!-- Shared overlays -->
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="close" onclick="closeLightbox()">×</button>
<img id="lightbox-img" alt="">
</div>
<div id="video-fs" class="video-fullscreen" onclick="closeVideoFs(event)">
<button class="close" onclick="closeVideoFs()">× Close</button>
<video id="video-fs-player" controls autoplay></video>
</div>
<!-- EVAL_CLIENT -->
<script>
window.__appCtx = window.__appCtx || {};
window.__appCtx.subcommand = 'app';
window.__appCtx.phase = 'review';
window.__appCtx.manifest = null;
window.__appCtx.version = 0;
// ─── console forwarding ──────────────────────────────────
(function() {
const orig = { log: console.log, warn: console.warn, error: console.error };
function forward(level, args) {
orig[level].apply(console, args);
try {
fetch('console', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level: level,
message: args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '),
ts: Date.now(),
}),
}).catch(() => {});
} catch (_) {}
}
console.log = function() { forward('log', arguments); };
console.warn = function() { forward('warn', arguments); };
console.error = function() { forward('error', arguments); };
})();
function esc(s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ─── phase selector ──────────────────────────────────────
document.querySelectorAll('.phase-tab').forEach(btn => {
btn.addEventListener('click', () => {
const phase = btn.dataset.phase;
switchPhase(phase);
});
});
function switchPhase(phase) {
window.__appCtx.phase = phase;
document.querySelectorAll('.phase-tab').forEach(b => {
b.classList.toggle('active', b.dataset.phase === phase);
});
document.querySelectorAll('.phase-panel').forEach(p => {
p.classList.toggle('active', p.id === 'phase-' + phase);
});
console.log('phase switched:', phase);
}
// ─── lightbox + fullscreen helpers ───────────────────────
window.closeLightbox = function(e) {
if (e && e.target && e.target.tagName === 'IMG') return;
document.getElementById('lightbox').classList.remove('open');
};
window.expandVideo = function(btn) {
const wrap = btn.parentElement;
const src = wrap.querySelector('video');
if (!src) return;
const fs = document.getElementById('video-fs');
const player = document.getElementById('video-fs-player');
player.src = src.getAttribute('src') || src.src;
player.currentTime = src.currentTime || 0;
fs.classList.add('open');
};
window.closeVideoFs = function(e) {
if (e && e.target && e.target.tagName === 'VIDEO') return;
const fs = document.getElementById('video-fs');
const player = document.getElementById('video-fs-player');
player.pause();
player.src = '';
fs.classList.remove('open');
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeLightbox(); closeVideoFs(); }
});
// ─── Review phase rendering ──────────────────────────────
function renderReview(manifest) {
const clipCount = manifest.clips.length;
const totalSpeedups = manifest.clips.reduce((n, c) => n + (c.speedup_events?.length || 0), 0);
const withFast = manifest.clips.filter(c => c.fast_path).length;
const parts = [`${clipCount} clip${clipCount === 1 ? '' : 's'}`];
if (totalSpeedups > 0) parts.push(`${totalSpeedups} speedup${totalSpeedups === 1 ? '' : 's'}`);
if (withFast > 0) parts.push(`${withFast} sped-up`);
document.getElementById('stats').innerHTML = parts.map(esc).join('<span class="sep">·</span>');
document.getElementById('title').textContent = manifest.title || 'Adom Video Post';
// Finals
const finalsEl = document.getElementById('finals');
let finalsHtml = '';
if (manifest.final_raw_path) {
finalsHtml += `<div class="card"><h3>Raw concat</h3>
<div class="video-wrap">
<button class="expand" onclick="expandVideo(this)">⛶ Expand</button>
<video controls preload="metadata" src="final-video/raw"></video>
</div></div>`;
}
if (manifest.final_fast_path) {
finalsHtml += `<div class="card"><h3>Fast concat</h3>
<div class="video-wrap">
<button class="expand" onclick="expandVideo(this)">⛶ Expand</button>
<video controls preload="metadata" src="final-video/fast"></video>
</div></div>`;
}
finalsEl.innerHTML = finalsHtml;
finalsEl.style.display = finalsHtml ? 'flex' : 'none';
// Timeline strip
const timelineEl = document.getElementById('timeline');
if (clipCount > 0) {
timelineEl.style.display = 'flex';
timelineEl.innerHTML = manifest.clips.map((c, i) => {
const n = i + 1;
const hasSpeedup = (c.speedup_events || []).length > 0;
const cls = hasSpeedup ? 'tl-step speedup' : 'tl-step';
const markHtml = hasSpeedup
? `<span class="mark">⚡ ${esc(c.speedup_events[0].speed)}x</span>`
: '';
return `<a class="${cls}" href="#clip-${esc(c.id)}">
<span class="num">${n}</span>
<span>${esc(c.title || c.id).slice(0, 30)}</span>
${markHtml}
</a>`;
}).join('');
} else {
timelineEl.style.display = 'none';
}
// Step cards
const stepsEl = document.getElementById('steps');
if (clipCount === 0) {
stepsEl.innerHTML = `<div style="padding:40px 32px;color:var(--text3);text-align:center;font-family:'JetBrains Mono',monospace;">
<strong style="color:var(--text);">No clips yet.</strong><br><br>
Your demo script should call <code>adom-video-post manifest add</code> once per step.<br>
The UI live-updates as clips are added.
</div>`;
return;
}
stepsEl.innerHTML = manifest.clips.map((c, i) => renderClip(c, i + 1)).join('');
}
function renderClip(c, n) {
const speedupPill = (c.speedup_events || []).length > 0
? `<span class="speedup-pill">⚡ ${esc(c.speedup_events[0].speed)}x ${esc(c.speedup_events[0].label)}</span>`
: '';
const description = c.description ? `<div class="desc">${esc(c.description)}</div>` : '';
const warnings = (c.warnings || []).length > 0
? `<div class="warnings">⚠ ${c.warnings.map(esc).join('<br>⚠ ')}</div>`
: '';
const actions = (c.actions || []).length > 0
? `<div class="actions-list">${c.actions.map(a => esc(a)).map(a => '• ' + a).join('<br>')}</div>`
: '';
let rawBlock = '';
if (c.raw_path) {
rawBlock = `<div class="clip-block">
<div class="clip-label raw">Raw (as recorded)</div>
<div class="video-wrap">
<button class="expand" onclick="expandVideo(this)">⛶ Expand</button>
<video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/raw"></video>
</div>
</div>`;
}
let fastBlock = '';
if (c.fast_path) {
fastBlock = `<div class="clip-block">
<div class="clip-label fast">Fast (after speedup)</div>
<div class="video-wrap">
<button class="expand" onclick="expandVideo(this)">⛶ Expand</button>
<video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/fast"></video>
</div>
</div>`;
}
return `<div class="step-card" id="clip-${esc(c.id)}">
<h2><span class="badge">${n}</span>${esc(c.title || c.id)}${speedupPill}</h2>
${description}
${warnings}
<div class="clip-row">${rawBlock}${fastBlock}</div>
</div>`;
}
// ─── Narrate phase ───────────────────────────────────────
const narrateRecord = document.getElementById('narrateRecord');
const narrateDiscard = document.getElementById('narrateDiscard');
const narrateFinalize = document.getElementById('narrateFinalize');
const narrateStatus = document.getElementById('narrateStatus');
const narrateNotReady = document.getElementById('narrateNotReady');
const narrateVideo = document.getElementById('narrateVideo');
const prepareBtn = document.getElementById('prepareBtn');
// ─── Prepare for narration: POST /concat ─────────────────
prepareBtn.addEventListener('click', async () => {
prepareBtn.disabled = true;
const originalText = prepareBtn.textContent;
prepareBtn.textContent = 'Concatenating clips…';
try {
const r = await fetch('concat', { method: 'POST' });
const result = await r.json();
if (result.ok) {
prepareBtn.textContent = `Concatenated ${result.clipCount} clips ✓`;
console.log('concat complete:', result.path);
// The backend bumps the version counter, so the next pollLoop
// tick will re-fetch /manifest, see the new final_fast_path,
// and auto-unhide the record button + hide this card.
} else {
prepareBtn.textContent = originalText;
prepareBtn.disabled = false;
narrateStatus.textContent = 'Concat failed: ' + (result.error || 'unknown');
console.warn('concat failed:', result);
}
} catch (e) {
prepareBtn.textContent = originalText;
prepareBtn.disabled = false;
narrateStatus.textContent = 'Concat failed: ' + e.message;
console.warn('concat error:', e.message);
}
});
narrateRecord.addEventListener('click', async () => {
if (narrateRecord.classList.contains('recording')) {
// Stop
narrateVideo.pause();
narrateRecord.disabled = true;
narrateStatus.textContent = 'Stopping recording…';
try {
const r = await fetch('voiceover/stop-recording', { method: 'POST' });
const result = await r.json();
if (result.ok) {
narrateStatus.textContent = 'Recorded. Click Finalize & mux to produce the narrated video.';
narrateRecord.classList.remove('recording');
narrateRecord.textContent = 'Record voiceover';
narrateRecord.disabled = false;
narrateFinalize.disabled = false;
narrateDiscard.disabled = false;
} else {
narrateStatus.textContent = 'Stop failed: ' + (result.error || 'unknown');
narrateRecord.disabled = false;
}
} catch (e) {
narrateStatus.textContent = 'Stop failed: ' + e.message;
narrateRecord.disabled = false;
}
} else {
// Start
narrateRecord.disabled = true;
narrateStatus.textContent = 'Starting recording…';
try {
const r = await fetch('voiceover/start-recording', { method: 'POST' });
const result = await r.json();
if (result.ok) {
narrateRecord.classList.add('recording');
narrateRecord.textContent = 'Stop recording';
narrateRecord.disabled = false;
narrateVideo.currentTime = 0;
narrateVideo.play().catch(e => console.warn('play:', e.message));
narrateStatus.textContent = 'Recording — narrate while the video plays.';
} else {
narrateStatus.textContent = 'Start failed: ' + (result.error || 'unknown');
narrateRecord.disabled = false;
}
} catch (e) {
narrateStatus.textContent = 'Start failed: ' + e.message;
narrateRecord.disabled = false;
}
}
});
narrateFinalize.addEventListener('click', async () => {
narrateFinalize.disabled = true;
narrateStatus.textContent = 'Running ffmpeg mux…';
try {
const r = await fetch('voiceover/finalize', { method: 'POST' });
const result = await r.json();
if (result.ok) {
narrateStatus.textContent = 'Done. Final path: ' + (result.rawPath || result.path || 'unknown');
} else {
narrateStatus.textContent = 'Finalize failed: ' + (result.error || 'unknown');
}
} catch (e) {
narrateStatus.textContent = 'Finalize failed: ' + e.message;
}
narrateFinalize.disabled = false;
});
narrateDiscard.addEventListener('click', async () => {
await fetch('voiceover/discard', { method: 'POST' }).catch(() => {});
narrateRecord.classList.remove('recording');
narrateRecord.textContent = 'Record voiceover';
narrateFinalize.disabled = true;
narrateDiscard.disabled = true;
narrateStatus.textContent = 'Discarded. Ready for a fresh take.';
});
// ─── Publish phase: build a Claude Code prompt ────────────
const pubPage = document.getElementById('pubPage');
const pubCaption = document.getElementById('pubCaption');
const pubPrompt = document.getElementById('pubPrompt');
const pubCopyBtn = document.getElementById('pubCopyBtn');
const pubPromptHint = document.getElementById('pubPromptHint');
const pubNoVideo = document.getElementById('pubNoVideo');
function renderPublishPrompt() {
const m = window.__appCtx.manifest;
const title = (m && m.title) ? m.title : 'this demo';
const finalFast = (m && m.final_fast_path) || '';
// The voiceover subcommand writes the narrated result to
// <input>.webm → <input>-narrated.webm per its default naming.
// Derive that here so Claude's prompt references the actual
// narrated output rather than the silent concat.
let narratedPath = '';
if (finalFast) {
narratedPath = finalFast.replace(/\.webm$/, '-take-1-norm.webm');
}
const page = (pubPage.value || 'apps/adom-desktop').trim();
const caption = (pubCaption.value || title).trim();
pubNoVideo.style.display = finalFast ? 'none' : 'block';
const videoLine = narratedPath
? `The narrated final video is at \`${narratedPath}\` (if that's missing, use whatever the latest narrated take is under the same directory).`
: 'The narrated final video is whatever came out of the Narrate phase of the Adom Video Post app — look in the same directory as the manifest for the most recent `*-narrated*.webm` or `*-take-*-norm.webm`.';
const prompt = `Publish my narrated "${title}" demo video to the Adom wiki.
${videoLine}
Upload it to the \`${page}\` wiki page with the caption:
"${caption}"
Use the \`adom-video-post\` and \`adom-wiki\` skills. Confirm the upload succeeded by returning the wiki asset URL.`;
pubPrompt.textContent = prompt;
}
pubPage.addEventListener('input', renderPublishPrompt);
pubCaption.addEventListener('input', renderPublishPrompt);
pubCopyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pubPrompt.textContent);
pubCopyBtn.textContent = 'Copied ✓';
pubCopyBtn.classList.add('copied');
pubPromptHint.textContent = 'Paste it into your Claude Code chat.';
setTimeout(() => {
pubCopyBtn.textContent = 'Copy prompt';
pubCopyBtn.classList.remove('copied');
}, 2000);
} catch (e) {
pubPromptHint.textContent = 'Copy failed: ' + e.message + ' — select the text above manually.';
console.warn('clipboard.writeText failed:', e.message);
}
});
// ─── fetch + live watch ──────────────────────────────────
async function fetchManifest() {
const r = await fetch('manifest');
if (!r.ok) throw new Error('GET /manifest ' + r.status);
return r.json();
}
async function fetchState() {
const r = await fetch('state');
if (!r.ok) throw new Error('GET /state ' + r.status);
return r.json();
}
async function pollLoop() {
let lastFinalFast = null;
while (true) {
try {
const s = await fetchState();
if (s.version !== window.__appCtx.version) {
const m = await fetchManifest();
window.__appCtx.manifest = m;
window.__appCtx.version = s.version;
renderReview(m);
// Narrate phase availability depends on final_fast_path
const narrateReady = !!m.final_fast_path;
narrateNotReady.style.display = narrateReady ? 'none' : 'block';
narrateRecord.disabled = !narrateReady;
// If final_fast_path just appeared (or changed), tell the
// narrate video element to reload. Without this, the
// element keeps its NETWORK_NO_SOURCE state from the
// initial 404 when the concat hadn't run yet, and clicking
// Play shows an empty black player.
if (m.final_fast_path !== lastFinalFast) {
lastFinalFast = m.final_fast_path;
if (narrateReady) {
narrateVideo.load();
console.log('narrate video reloaded for new final_fast_path:', m.final_fast_path);
}
}
// Re-render the Publish-phase prompt with fresh context
// (title, derived narrated path, etc.) each time the
// manifest changes.
renderPublishPrompt();
console.log('manifest loaded: version', s.version, 'clips', m.clips.length, 'narrateReady', narrateReady);
}
} catch (e) {
console.warn('pollLoop error:', e.message);
}
await new Promise(r => setTimeout(r, 800));
}
}
window.addEventListener('load', pollLoop);
</script>
</body>
</html>