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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Storyboard</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: brand | subject (title + state-pill + meta) |
hover popovers for app info + subject info. Matches adom-step etc. */
header {
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-muted);
background: var(--surface);
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:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
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;
cursor: default;
}
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 .state-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 10px;
font-family: 'Familjen Grotesk', sans-serif;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.3px;
text-transform: none;
background: var(--elevated);
border: 1px solid var(--border);
color: var(--text2);
flex-shrink: 0;
}
header .state-pill::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text3);
}
header .state-pill.live::before { background: var(--red); animation: livepulse 1.6s ease-in-out infinite; }
header .state-pill.live { color: var(--red); border-color: var(--red); }
header .state-pill.review::before { background: var(--accent); }
header .state-pill.review { color: var(--accent); border-color: var(--accent-glow); }
header .state-pill.preview::before { background: var(--purple); }
header .state-pill.preview { color: var(--purple); border-color: var(--purple); }
@keyframes livepulse {
0%,100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }
50% { box-shadow: 0 0 0 4px rgba(248,81,73,0); }
}
/* Interactive hover-popover */
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.compact { min-width: 220px; left: auto; right: 16px; }
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; }
/* Timeline strip โ scrolls with the rest of the page. */
.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; }
.finals {
margin: 0 32px 18px;
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);
}
.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;
}
/* "Just updated" affordance. Sentence case (per human-ui-patterns
ยง1e โ never ALL CAPS for status pills). No emoji glyph (the ๐
character renders as a colored "NEW" badge in most fonts and
would double up with the label). Tooltip on the pill explains
what "Just updated" means + when it changed (per ยง1a). */
.new-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
background: var(--accent-bright);
color: var(--bg);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
margin-left: 8px;
cursor: help;
}
.new-pill .new-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--bg);
animation: new-pulse 1.6s ease-in-out infinite;
}
.new-pill .new-pill-when {
color: rgba(13,17,23,0.65);
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.step-card.is-new {
border-color: var(--accent-bright);
box-shadow: 0 0 0 1px var(--accent-glow), 0 0 20px var(--accent-glow);
}
.stat-new { color: var(--accent-bright); font-weight: 600; }
@keyframes new-pulse {
0%,100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.4); opacity: 1; }
}
.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;
}
.actions-list .muted { color: var(--text3); }
.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); }
.clip-label-hint {
color: var(--text3);
font-weight: 400;
text-transform: none;
letter-spacing: 0.2px;
font-size: 10px;
margin-left: 6px;
}
/* Collapsible TTS narration disclosure โ folded by default. */
.narration-fold {
margin-top: 10px;
border: 1px solid var(--border-muted);
border-radius: 6px;
background: var(--elevated);
}
.narration-fold summary {
cursor: pointer;
padding: 8px 12px;
color: var(--text2);
font-size: 12px;
font-weight: 500;
user-select: none;
list-style: none;
}
.narration-fold summary::-webkit-details-marker { display: none; }
.narration-fold summary:hover { color: var(--text); }
.narration-fold[open] summary { color: var(--accent); border-bottom: 1px solid var(--border-muted); }
.narration-fold .narration-body {
padding: 10px 14px 12px;
color: var(--text);
font-size: 13px;
line-height: 1.55;
font-family: 'Familjen Grotesk', sans-serif;
white-space: pre-wrap;
}
.clip-url {
color: var(--text3);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
margin-top: 4px;
}
.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;
}
/* Lightbox for image_path + console_png_path */
.step-card .thumb {
cursor: zoom-in;
transition: opacity 0.15s;
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--border);
}
.step-card .thumb:hover { opacity: 0.85; }
.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;
padding: 0;
}
.lightbox .caption {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
color: var(--text2);
text-align: center;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
}
/* Fullscreen video overlay โ works inside iframes that block the
native Fullscreen API because it's pure CSS positioning. */
.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;
background: #000;
}
.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;
font-family: inherit;
}
.video-fullscreen .close:hover { border-color: var(--accent-bright); color: var(--accent-bright); }
.video-fullscreen .label {
position: absolute;
top: 16px;
left: 20px;
z-index: 2001;
color: var(--text2);
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
background: rgba(0,0,0,0.85);
padding: 8px 12px;
border-radius: 4px;
}
.empty-state {
margin: 60px 32px;
padding: 40px;
background: var(--elevated);
border: 1px dashed var(--border);
border-radius: 10px;
text-align: center;
color: var(--text3);
font-family: 'JetBrains Mono', monospace;
}
.empty-state strong { color: var(--text); }
</style>
</head>
<body>
<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">storyboard</span>
</div>
<div class="vsep" aria-hidden="true"></div>
<div class="subject" data-tooltip-target="subject-info">
<span class="subject-name" id="title">Storyboard</span>
<span id="statePill" class="state-pill">Loading</span>
<span class="subject-meta" id="stats"></span>
</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">screen</span><span class="v">storyboard ยท per-clip review</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" data-tooltip="Open the adom-tts wiki page โ shared edge-tts wrapper with pronunciation overrides. Run `adom-tts` to launch the playground locally.">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>
<div class="hover-popover compact" id="subject-info" role="dialog">
<div class="popover-row"><span class="k">manifest</span><span class="v" id="tipManifest">โ</span></div>
<div class="popover-row"><span class="k">clips</span><span class="v" id="tipClips">โ</span></div>
<div class="popover-row"><span class="k">duration</span><span class="v" id="tipDuration">โ</span></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();
});
});
document.addEventListener('click', e => {
document.querySelectorAll('header .hover-popover.show').forEach(p => {
if (!p.contains(e.target)) {
const trigger = document.querySelector(`[data-tooltip-target="${p.id}"]`);
if (!trigger || !trigger.contains(e.target)) p.classList.remove('show');
}
});
}, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
</script>
<div id="finals" class="finals" style="display:none;"></div>
<div id="timeline" class="timeline" style="display:none;"></div>
<div id="steps" class="steps"></div>
<!-- Lightbox for click-to-zoom on screenshot thumbs -->
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="close" onclick="closeLightbox()">×</button>
<img id="lightbox-img" alt="">
<div class="caption" id="lightbox-caption"></div>
</div>
<!-- Fullscreen video overlay -->
<div id="video-fs" class="video-fullscreen" onclick="closeVideoFs(event)">
<button class="close" onclick="closeVideoFs()">× Close</button>
<div class="label" id="video-fs-label"></div>
<video id="video-fs-player" controls autoplay></video>
</div>
<!-- EVAL_CLIENT -->
<script>
// Expose appCtx early so /eval snippets can inspect even before
// the first /manifest fetch resolves.
window.__appCtx = window.__appCtx || {};
window.__appCtx.subcommand = 'storyboard';
window.__appCtx.manifest = null;
window.__appCtx.version = 0;
// --- console forwarding (same pattern as voiceover/ui.html) ---
(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, '"');
}
// --- lightbox / fullscreen helpers ---
window.closeLightbox = function(e) {
if (e && e.target && e.target.tagName === 'IMG') return;
document.getElementById('lightbox').classList.remove('open');
};
// expandVideo() removed: the iframe now has fullscreen permissions
// granted (Adom Hydrogen + pup proxy) so the native <video> element's
// fullscreen control handles enlargement directly. The custom Expand
// button + overlay player are no longer needed.
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(); }
});
// --- render the manifest ---
let liveTimer = null;
// "Just updated" tracking. Each manifest+clip gets its own localStorage
// entry recording the highest mtime ever observed. When fetchManifest
// returns a clip whose mtime > the stored last-seen, mark it as
// recently updated. After the user has had ~30 s to look at the page,
// bump last-seen so it's no longer flagged on the next reload.
//
// IMPORTANT: on the very first visit to a manifest (no localStorage
// keys yet) we record current mtimes silently so NOTHING is flagged.
// "Everything is new" is a useless state โ the user wants to know
// what changed *between* visits, not what existed on first load.
function newSinceLastSeenKey(manifest, clip, kind) {
const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');
return `vp:lastseen:${slug}:${clip.id}:${kind}`;
}
function manifestEverSeenKey(manifest) {
const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');
return `vp:everseen:${slug}`;
}
function isFirstVisit(manifest) {
return !localStorage.getItem(manifestEverSeenKey(manifest));
}
function isNewSinceLastSeen(manifest, clip) {
if (isFirstVisit(manifest)) return false;
const rawMtime = clip.raw_mtime_ms || 0;
if (!rawMtime) return false;
const seen = parseInt(localStorage.getItem(newSinceLastSeenKey(manifest, clip, 'raw')) || '0', 10);
return rawMtime > seen;
}
function markClipSeen(manifest, clip) {
if (clip.raw_mtime_ms) {
localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'raw'), String(clip.raw_mtime_ms));
}
if (clip.fast_mtime_ms) {
localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'fast'), String(clip.fast_mtime_ms));
}
}
function markManifestEverSeen(manifest) {
localStorage.setItem(manifestEverSeenKey(manifest), String(Date.now()));
}
// Friendly relative-time string for "this clip was updated <when>".
function relativeUpdated(mtimeMs) {
if (!mtimeMs) return '';
const ageMs = Date.now() - mtimeMs;
const sec = Math.round(ageMs / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.round(hr / 24)}d ago`;
}
function renderManifest(manifest, version) {
document.title = manifest.title || 'Storyboard';
document.getElementById('title').textContent = manifest.title || 'Storyboard';
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 newCount = manifest.clips.filter(c => isNewSinceLastSeen(manifest, c)).length;
// Stats row (subject-meta) + tooltip mirrors
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`);
if (newCount > 0) parts.push(`<span class="stat-new">${newCount} new</span>`);
document.getElementById('stats').innerHTML = parts
.map(p => `<span>${p}</span>`)
.join('<span class="sep">ยท</span>');
const tipClips = document.getElementById('tipClips');
const tipManifest = document.getElementById('tipManifest');
if (tipClips) tipClips.textContent = `${clipCount} (${withFast} sped-up)`;
if (tipManifest && manifest.title) tipManifest.textContent = manifest.title;
// State pill โ preview if no fast clips yet, review if all present,
// live if the last manifest mtime change was recent.
const pill = document.getElementById('statePill');
if (window.__appCtx.lastManifestChange && (Date.now() - window.__appCtx.lastManifestChange) < 5000) {
pill.className = 'state-pill live';
pill.textContent = 'Live';
} else if (withFast === clipCount && clipCount > 0) {
pill.className = 'state-pill review';
pill.textContent = 'Review';
} else {
pill.className = 'state-pill preview';
pill.textContent = 'Preview';
}
// 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">
<video controls preload="metadata" src="final-video/raw" data-label="Final raw"></video>
</div>
</div>`;
}
if (manifest.final_fast_path) {
finalsHtml += `<div class="card">
<h3>Fast concat</h3>
<div class="video-wrap">
<video controls preload="metadata" src="final-video/fast" data-label="Final 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 class="empty-state">
<strong>No clips yet.</strong><br><br>
Your demo script should call <code>adom-video-post manifest add</code> once per completed step.<br>
The UI live-updates as clips are added.
</div>`;
return;
}
stepsEl.innerHTML = manifest.clips.map((c, i) => renderClip(c, i + 1)).join('');
// Wire up lightbox on newly-rendered thumbnails
document.querySelectorAll('.step-card .thumb').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const img = document.getElementById('lightbox-img');
const cap = document.getElementById('lightbox-caption');
img.src = el.src;
cap.textContent = el.getAttribute('alt') || '';
document.getElementById('lightbox').classList.add('open');
});
});
}
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>`
: '';
// "Just updated" badge โ clip's raw_mtime_ms exceeds the user's
// last-seen marker. Captured during render so layout knows whether
// to add the .is-new class to the card for ring highlighting.
const isNew = (window.__appCtx && window.__appCtx.manifest)
? isNewSinceLastSeen(window.__appCtx.manifest, c)
: false;
const updatedAt = isNew ? relativeUpdated(c.raw_mtime_ms) : '';
// Sentence case (per human-ui-patterns ยง1e: never ALL CAPS for status
// pills). Tooltip explains when it changed AND why a NEW badge is
// showing โ the user shouldn't have to guess (per ยง1a). No emoji
// (the ๐ glyph already contains the word "NEW", produces a
// double-decker label).
const newTooltip = isNew
? `Updated ${updatedAt}. This clip was re-recorded since your last reload โ focus your review here. The badge auto-clears after 30 seconds on the page.`
: '';
const newPill = isNew
? `<span class="new-pill" data-tooltip="${esc(newTooltip)}">
<span class="new-pill-dot" aria-hidden="true"></span>
<span class="new-pill-label">Just updated</span>
<span class="new-pill-when">ยท ${esc(updatedAt)}</span>
</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>`
: '';
// Thumbnails: image_path (step screenshot) + console_png_path (data close-up)
const thumbs = [];
if (c.image_path) {
thumbs.push(`<img class="thumb" src="image/${esc(c.id)}" alt="${esc(c.title)} โ screenshot">`);
}
if (c.console_png_path) {
thumbs.push(`<img class="thumb" src="console-png/${esc(c.id)}" alt="${esc(c.title)} โ console output">`);
}
const thumbRow = thumbs.length > 0
? `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;">${thumbs.join('')}</div>`
: '';
// Video blocks. Label is "Recorded clip" โ note this is post-mux: TTS
// narration audio has been merged into the silent screen recording.
// The truly-raw silent capture isn't surfaced (the narrated version is
// what the demo actually ships).
let rawBlock = '';
if (c.raw_path) {
rawBlock = `<div class="clip-block">
<div class="clip-label raw">Recorded clip <span class="clip-label-hint">(silent capture + TTS audio muxed)</span></div>
<div class="video-wrap">
<video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/raw" data-label="${esc(c.title)} 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">
<video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/fast" data-label="${esc(c.title)} fast"></video>
</div>
</div>`;
}
// Collapsible narration script (TTS source text). Distinct from the
// intent description above. Folded by default; click to read what
// the viewer will hear.
const narrationBlock = c.narration
? `<details class="narration-fold">
<summary>โธ TTS narration</summary>
<div class="narration-body">${esc(c.narration)}</div>
</details>`
: '';
return `<div class="step-card${isNew ? ' is-new' : ''}" id="clip-${esc(c.id)}" data-clip-id="${esc(c.id)}">
<h2><span class="badge">${n}</span>${esc(c.title || c.id)}${speedupPill}${newPill}</h2>
${description}
${narrationBlock}
${warnings}
${thumbRow}
${actions}
<div class="clip-row">${rawBlock}${fastBlock}</div>
</div>`;
}
// --- 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();
}
// Auto-mark NEW clips as seen after a delay so the badge fades next reload.
// Triggered after every render โ we wait 30 s once the user has had a real
// chance to look at fresh clips. Resets on every render that adds new
// "is-new" cards (so re-shoots restart the timer).
let __markSeenTimer = null;
function scheduleMarkSeen(manifest) {
if (__markSeenTimer) clearTimeout(__markSeenTimer);
__markSeenTimer = setTimeout(() => {
if (!manifest || !manifest.clips) return;
manifest.clips.forEach(c => markClipSeen(manifest, c));
console.log('auto mark-seen: cleared NEW badge state for', manifest.clips.length, 'clips');
}, 30000);
}
async function pollLoop() {
while (true) {
try {
const s = await fetchState();
if (s.version !== window.__appCtx.version) {
const prevVersion = window.__appCtx.version;
const m = await fetchManifest();
// First-visit: silently record current mtimes so nothing
// is flagged "Just updated" on initial load. Only flag clips
// whose mtime > previously-stored last-seen on later visits.
const firstVisit = isFirstVisit(m);
if (firstVisit) {
m.clips.forEach(c => markClipSeen(m, c));
markManifestEverSeen(m);
}
window.__appCtx.manifest = m;
window.__appCtx.version = s.version;
if (!firstVisit) scheduleMarkSeen(m);
if (prevVersion > 0) {
// Version bumped AFTER initial load โ this means a live
// watcher update just fired. Mark it so the pill shows
// "Live" for 5 seconds.
window.__appCtx.lastManifestChange = Date.now();
}
renderManifest(m, s.version);
console.log('manifest loaded: version', s.version, 'clips', m.clips.length);
} else if (window.__appCtx.lastManifestChange &&
(Date.now() - window.__appCtx.lastManifestChange) >= 5000) {
// Recompute pill when the "Live" timeout expires.
if (window.__appCtx.manifest) {
renderManifest(window.__appCtx.manifest, s.version);
}
window.__appCtx.lastManifestChange = null;
}
} catch (e) {
console.warn('pollLoop error:', e.message);
}
await new Promise(r => setTimeout(r, 800));
}
}
// --- URL hash remote-control (same contract as demo-dry-run.py) ---
function handleTestHash() {
const m = /^#test=expand-(final|\S+)-(raw|fast)$/.exec(window.location.hash);
if (!m) return;
const which = m[1], variant = m[2];
let needle;
if (which === 'final') {
needle = variant === 'raw' ? 'Final raw' : 'Final fast';
} else {
// Per-clip expand: find the video whose data-label starts with
// the clip's title. Use the clip id as the needle first because
// ids are stable; fall back to title.
const clip = (window.__appCtx.manifest?.clips || []).find(c => c.id === which);
if (clip) {
needle = clip.title + ' ' + variant;
} else {
return;
}
}
const target = Array.from(document.querySelectorAll('.video-wrap video'))
.find(v => (v.getAttribute('data-label') || '').indexOf(needle) !== -1);
if (target) {
const btn = target.parentElement.querySelector('.expand');
if (btn) btn.click();
}
}
window.addEventListener('load', () => {
pollLoop();
setTimeout(handleTestHash, 500);
});
window.addEventListener('hashchange', handleTestHash);
</script>
</body>
</html>