app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
← Commit history
Publish 0.2.0
25 files changed
+1974−299
Cargo.lock+1−1Cargo.toml+1−1README.md+3−3assets/favicon.svg+1assets/narrate.css+112assets/narrate.html+83−266assets/narrate.js+385assets/vendor/VENDORED.md+9assets/vendor/adom-video.js+842assets/vendor/base.css+130assets/vendor/fonts/familjen-grotesk-700.woff2assets/vendor/fonts/jetbrains-mono-400.woff2assets/vendor/fonts/jetbrains-mono-500.woff2assets/vendor/fonts/satoshi-400.woff2assets/vendor/fonts/satoshi-500.woff2assets/vendor/icons.js+23assets/vendor/settings-cog-outline.svg+10assets/vendor/ui.js+187bin/adom-aiflow-videoflows/flow.json+1−1package.json+1−1page.json+1−1skills/adom-aiflow-video/SKILL.md+16−6src/main.rs+31−4src/narrate.rs+137−15Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.1.6"+version = "0.2.0" dependencies = [ "clap", "serde",
Cargo.toml+1−1@@ -2,7 +2,7 @@ # Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow. [package] name = "adom-aiflow-video"-version = "0.1.6"+version = "0.2.0" edition = "2021" license = "MIT"
README.md+3−3@@ -34,7 +34,7 @@ AI Flow: Video is a dynamic skill, like AI Flow itself: a small Rust binary that | plan, capture | Short highlight clips of the moments worth showing, never a continuous recording | | walkthrough | The part-by-part 3D walkthrough, with the ready-made director (`adom-aiflow-video director`, `adom-aiflow-video guide`) | | script, cut | Narration with adom-tts, footage fitted to it, captions and chapters as separate files |-| voice | Keep the AI voiceover, or narrate it yourself: read along to a teleprompter copy in your own words, and the film is rebuilt and re-captioned around your recording |+| voice | Keep the AI voiceover, or narrate it yourself on the narration page: calibrate your microphone, rehearse, record in your own words with a clock and a wrap-up warning, circle things on the film as you talk, review, and hand the take to your AI | | privacy | Looks for email addresses, keys, file paths and other personal details before anything is published | | review | You watch the draft in the Adom video player and ask for changes | | publish | The `::adom-video` player at the top of your wiki page; YouTube through adom-google: uploaded private first so you can review it there, then published when you say so |@@ -46,11 +46,11 @@ AI Flow: Video is a dynamic skill, like AI Flow itself: a small Rust binary that init [--release <board-release.json>] [--target <machine>] next steps ledger disk --clips N --seconds S clip start|stop mark <step> --note "..." script <script.json> scan <files...> track <path> clean [--yes]-director --out <file.js> guide narrate --video <teleprompter.mp4>+director --out <file.js> guide narrate --video <draft.mp4> ``` Every command answers `OK:` with `Hint:` lines. The skill is in [skills/adom-aiflow-video/SKILL.md](skills/adom-aiflow-video/SKILL.md); the walkthrough guide is in [docs/walkthrough.md](docs/walkthrough.md). ## Status -0.1.6, first cut. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, and the narration page for recording your own voice. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.+0.2.0. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, and the narration page for recording your own voice. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.
assets/favicon.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5V21"/><path d="M8.5 21h7"/></svg>
assets/narrate.cssadded+112@@ -0,0 +1,112 @@+/* AI Flow Video, the narration page. Tokens, header, tooltips, toasts and the settings dialog come from base.css. */+html, body { overflow: hidden; }+body { display: flex; flex-direction: column; }+.app-header { flex: none; }+main { flex: 1; min-height: 0; height: auto; grid-template-columns: minmax(0, 1fr) 320px; }+.stage { position: relative; }+adom-video { width: min(100%, calc((100dvh - 44px - 64px - 24px) * var(--ar, 1.7778))); }+aside { overflow: auto; scrollbar-width: thin; }+.target-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; white-space: nowrap; }+.target-chip b { color: var(--text); font-weight: 500; }+.led { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }+.led.ok { background: var(--green); } .led.bad { background: var(--red); }+.help-pop { max-width: 420px; } .help-pop ol { margin: 0; padding-left: 18px; } .help-pop li { margin: 6px 0; font-size: 13px; }++.btn { font: 600 14px var(--font-body); border-radius: 8px; padding: 8px 14px; border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent); cursor: pointer; transition: background .15s ease, border-color .15s ease, transform .05s ease; white-space: nowrap; }+.btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }+.btn:active { transform: translateY(1px); }+.btn:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 2px; }+.btn.ghost { background: transparent; color: var(--text-body); border-color: var(--border); }+.btn.ghost:hover { border-color: var(--accent); color: var(--text); background: var(--elevated); }+.btn.rec { background: var(--red); border-color: var(--red); color: #1a0504; }+.btn.rec:hover { background: #ff6a63; border-color: #ff6a63; }+.btn:disabled { opacity: .4; cursor: default; }+.btn.lg { font-size: 16px; padding: 11px 20px; }++.steps-list { list-style: none; margin: 0 0 14px; padding: 0; }+.steps-list li { display: flex; gap: 10px; align-items: flex-start; padding: 7px 8px; border-radius: 8px; border: 1px solid transparent; }+.steps-list li.now { border-color: var(--accent); background: var(--elevated); }+.steps-list .n { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border); display: inline-flex; align-items: center; justify-content: center; font: 500 12px var(--font-mono); color: var(--muted); }+.steps-list li.done .n { background: var(--green); border-color: var(--green); color: #04130a; }+.steps-list b { display: block; color: var(--text); font-weight: 600; }+.steps-list small { color: var(--muted); font-size: 12px; }+.select.wide { width: 100%; align-self: stretch; }+.meter { height: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; position: relative; margin-top: 8px; }+.meter i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .06s linear; }+.meter i.hot { background: var(--red); }+.meter em { position: absolute; left: 66%; top: 0; bottom: 0; border-left: 2px dashed var(--text-2); }+.heard { font-size: 12px; color: var(--muted); margin: 4px 0 8px; min-height: 17px; }+.boost-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }+.boost-row input { flex: 1; accent-color: var(--accent); }+.boost-row b { color: var(--text); font-weight: 500; min-width: 44px; text-align: right; }+.btn-row { display: flex; gap: 8px; margin: 10px 0 16px; }+.takes { list-style: none; margin: 0 0 14px; padding: 0; }+.takes li { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }+.takes li.chosen { border-color: var(--green); }+.takes li.empty { border-style: dashed; color: var(--muted); font-size: 13px; }+.takes .head { display: flex; justify-content: space-between; gap: 8px; color: var(--text); font-weight: 600; }+.takes .sub { font-size: 12px; color: var(--muted); margin: 2px 0 8px; }+.takes .acts { display: flex; gap: 6px; flex-wrap: wrap; }+.takes .btn { font-size: 12px; padding: 5px 9px; }+.ainote { font-size: 13px; color: var(--muted); border: 1px dashed var(--border); border-radius: 8px; padding: 8px 10px; }+.ainote.live { color: var(--text-body); border-style: solid; border-color: var(--accent); }++.recbar { flex: none; height: 64px; display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--surface); border-top: 1px solid var(--line); }+.clock { font-size: 15px; color: var(--muted); white-space: nowrap; } .clock b { color: var(--text); font-weight: 500; font-size: 20px; }+.left { font-size: 13px; color: var(--muted); white-space: nowrap; min-width: 78px; }+.left.soon { color: var(--yellow); } .left.now { color: var(--red); font-weight: 500; }+.progress { flex: 1; min-width: 80px; height: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; position: relative; overflow: hidden; }+.progress i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--accent); opacity: .85; }+.progress.rec i { background: var(--red); }+.segs { position: absolute; inset: 0; z-index: 1; }+.segs span { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--surface); }+.chap { min-width: 0; max-width: 300px; display: flex; flex-direction: column; line-height: 1.2; }+.chap b { color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.chap small { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.mode { font: 600 12px var(--font-head); letter-spacing: .04em; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; white-space: nowrap; }+.mode.rec { color: var(--red); border-color: var(--red); } .mode.play { color: var(--accent-bright); border-color: var(--accent); }++.overlay { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; background: rgba(13,17,23,.78); z-index: 5; text-align: center; }+.overlay.on { display: flex; }+.pre-num { font: 700 clamp(90px, 14vw, 220px)/1 var(--font-head); color: var(--text); font-variant-numeric: tabular-nums; }+.pre-text { font: 500 clamp(18px, 1.8vw, 30px) var(--font-body); color: var(--text-body); margin-top: 8px; max-width: 80%; }+.wrapup { position: absolute; top: 22px; left: 50%; transform: translateX(-50%); z-index: 6; display: none; align-items: baseline; gap: 10px; background: rgba(22,27,34,.94); border: 2px solid var(--yellow); border-radius: 12px; padding: 8px 18px; color: var(--text); font: 500 clamp(16px, 1.5vw, 24px) var(--font-body); white-space: nowrap; }+.wrapup.on { display: flex; } .wrapup.final { border-color: var(--red); }+.wrapup b { font: 700 clamp(30px, 3.2vw, 56px)/1 var(--font-head); font-variant-numeric: tabular-nums; }+.recbadge { position: absolute; top: 22px; left: 28px; z-index: 6; display: none; align-items: center; gap: 8px; background: rgba(22,27,34,.9); border: 1px solid var(--red); border-radius: 999px; padding: 5px 12px; color: var(--text); font: 600 13px var(--font-body); }+.recbadge.on { display: flex; }+.recbadge i { width: 10px; height: 10px; border-radius: 50%; background: var(--red); animation: pulse 1s infinite; }+.recbadge.review { border-color: var(--accent); } .recbadge.review i { background: var(--accent); animation: none; }+@keyframes pulse { 50% { opacity: .25; } }++.modal { position: fixed; inset: 0; z-index: 9000; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,.3); }+.modal.on { display: flex; }+.card { width: min(1100px, 82vw); max-height: 88vh; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 26px 30px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }+.card .pills { display: flex; gap: 8px; margin-bottom: 16px; white-space: nowrap; }+.card .pills span { font-size: clamp(12px, .9vw, 16px); padding: 3px 12px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }+.card .pills span.now { border-color: var(--accent); color: var(--accent-bright); } .card .pills span.done { color: var(--text); }+.card h2 { margin: 0 0 8px; font: 700 clamp(24px, 2.3vw, 44px) var(--font-head); color: var(--text); }+.card p { margin: 0 0 14px; color: var(--text-2); font-size: clamp(15px, 1.15vw, 22px); }+.card .read { font: 600 clamp(28px, 2.8vw, 56px)/1.25 var(--font-head); color: var(--text); margin: 12px 0 18px; }+.card .count { display: flex; align-items: center; gap: 16px; }+.card .count b { font: 700 clamp(48px, 4.6vw, 92px)/1 var(--font-head); color: var(--text); min-width: 1.4em; font-variant-numeric: tabular-nums; }+.card .prog { flex: 1; height: clamp(10px, .8vw, 16px); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }+.card .prog i { display: block; height: 100%; width: 0; background: var(--accent); }+.card .bigmeter { height: clamp(20px, 1.6vw, 32px); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: 16px; position: relative; }+.card .bigmeter i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .06s linear; }+.card .bigmeter em { position: absolute; left: 66%; top: 0; bottom: 0; border-left: 2px dashed var(--text-2); }+.card table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: clamp(15px, 1.2vw, 24px); }+.card td { padding: 8px; border-bottom: 1px solid var(--border); color: var(--text-body); } .card td:first-child { color: var(--text-2); width: 42%; }+.card b.to { color: var(--accent-bright); }+.card .verdict { font-size: clamp(15px, 1.2vw, 24px); margin-bottom: 18px; color: var(--text-body); }+.card .next { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin: 0 0 18px; color: var(--text-body); font-size: clamp(14px, 1.05vw, 20px); }+.card .next b { color: var(--text); }+.card .row { display: flex; gap: 10px; flex-wrap: wrap; }+.good { color: var(--green); font-weight: 700; } .warn { color: var(--yellow); font-weight: 700; } .bad { color: var(--red); font-weight: 700; }+@media (max-width: 860px) { html, body { overflow: auto; } main { grid-template-columns: 1fr; } .recbar { height: auto; flex-wrap: wrap; padding: 10px 16px; } .chap, .mode { display: none; } }+.drawbar { position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); z-index: 6; display: none; align-items: center; gap: 10px; background: rgba(22,27,34,.94); border: 1px solid var(--border); border-radius: 999px; padding: 6px 8px 6px 14px; font-size: 13px; color: var(--text-2); white-space: nowrap; }+.drawbar.on { display: flex; }+.drawbar b { color: var(--text); font-weight: 600; }+.drawbar .pen { width: 12px; height: 12px; border-radius: 50%; background: var(--accent-bright); box-shadow: 0 0 0 3px rgba(0,230,220,.2); }+.drawbar .btn { font-size: 12px; padding: 4px 10px; border-radius: 999px; }+kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; margin-left: 4px; color: var(--text-2); }
assets/narrate.html+83−266@@ -3,280 +3,97 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">-<title>Narrate your video</title>-<style>- :root { --bg:#0b1114; --panel:#121b20; --line:#22323a; --text:#e8f1f3; --dim:#8fa3ab; --teal:#14c4b8; --red:#ff5d5d; }- * { box-sizing: border-box; }- html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 system-ui, "Segoe UI", sans-serif; }- body { display: grid; grid-template-rows: auto 1fr auto; gap: 12px; padding: 16px; }- header { display: flex; align-items: center; gap: 16px; white-space: nowrap; }- h1 { font-size: 20px; margin: 0; font-weight: 650; }- .state { color: var(--dim); overflow: hidden; text-overflow: ellipsis; }- .state b { color: var(--text); }- .stage { min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }- video { max-width: 100%; max-height: 100%; }- .bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }- button { font: inherit; font-weight: 600; color: var(--bg); background: var(--teal); border: 0; border-radius: 8px; padding: 9px 16px; cursor: pointer; }- button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }- button.rec { background: var(--red); color: #fff; }- button:disabled { opacity: .45; cursor: default; }- select { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; max-width: 320px; }- .meter { width: 260px; height: 16px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }- .meter i { display: block; height: 100%; width: 0; background: var(--teal); transition: width .06s linear; }- .meter i.hot { background: var(--red); }- .spacer { flex: 1; }- .takes { color: var(--dim); font-size: 14px; }- .takes a { color: var(--teal); }- .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--red); margin-right: 6px; animation: pulse 1s infinite; }- @keyframes pulse { 50% { opacity: .25; } }- #toast { position: fixed; left: 50%; top: 64px; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--teal); border-radius: 10px; padding: 10px 16px; opacity: 0; transition: opacity .25s; pointer-events: none; max-width: 70vw; z-index: 20; }- #toast.on { opacity: 1; }- #aicur { position: fixed; left: 0; top: 0; z-index: 30; pointer-events: none; opacity: 0; transition: transform .35s cubic-bezier(.3,.7,.2,1), opacity .2s; }- #aicur.on { opacity: 1; }- #aicur i { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px rgba(20,196,184,.25); }- #aicur span { position: absolute; left: 20px; top: -4px; white-space: nowrap; font-size: 13px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; }- .ai { color: var(--teal); font-size: 13px; }- #calbox { position: fixed; inset: 0; z-index: 15; display: none; align-items: center; justify-content: center; background: rgba(6,10,12,.86); }- #calbox.on { display: flex; }- .card { width: min(1500px, 78vw); background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 28px 32px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }- .steps { display: flex; gap: 8px; margin-bottom: 18px; white-space: nowrap; }- .steps span { font-size: clamp(13px, 1vw, 20px); padding: 4px 12px; border-radius: 20px; border: 1px solid var(--line); color: var(--dim); }- .steps span.now { border-color: var(--teal); color: var(--teal); }- .steps span.done { color: var(--text); }- .card h2 { margin: 0 0 8px; font-size: clamp(28px, 2.6vw, 52px); }- .card p { margin: 0 0 14px; color: var(--dim); font-size: clamp(17px, 1.3vw, 26px); }- .read { font-size: clamp(34px, 3.2vw, 64px); line-height: 1.3; font-weight: 600; color: var(--text); margin: 14px 0 18px; }- .count { display: flex; align-items: center; gap: 16px; }- .count b { font-size: clamp(54px, 5vw, 100px); min-width: 1.4em; font-variant-numeric: tabular-nums; }- .prog { flex: 1; height: clamp(10px, .8vw, 16px); background: var(--bg); border-radius: 6px; overflow: hidden; border: 1px solid var(--line); }- .prog i { display: block; height: 100%; width: 0; background: var(--teal); }- .bigmeter { height: clamp(22px, 1.8vw, 36px); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-top: 16px; position: relative; }- .bigmeter i { display: block; height: 100%; width: 0; background: var(--teal); transition: width .06s linear; }- .bigmeter em { position: absolute; left: 66%; top: 0; bottom: 0; border-left: 2px dashed var(--dim); }- table.res { width: 100%; border-collapse: collapse; margin: 14px 0 18px; font-size: clamp(18px, 1.4vw, 28px); }- table.res td { padding: 9px 8px; border-bottom: 1px solid var(--line); }- table.res td:first-child { color: var(--dim); }- table.res b.to { color: var(--teal); }- .verdict { font-size: clamp(18px, 1.4vw, 28px); margin-bottom: 18px; }- .card button { font-size: clamp(16px, 1.2vw, 24px); padding: .55em 1.1em; }- .row { display: flex; gap: 10px; flex-wrap: wrap; }-</style>+<title>AI Flow Video: narrate</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="stylesheet" href="static/base.css">+<link rel="stylesheet" href="static/narrate.css"> </head> <body>-<header>- <h1>Narrate your video</h1>- <div class="state" id="state">Pick your microphone, press <b>Record</b>, and read each line as it appears, in your own words.</div>+<header class="app-header">+ <div class="brand" id="brand" tabindex="0" aria-label="About AI Flow Video">+ <span class="brand-icon" id="brand-icon"></span><span class="app-name">AI Flow Video</span><span class="app-sub">narrate your film</span>+ </div>+ <div class="vsep"></div>+ <div class="subject"><span class="subject-name" id="title">Loading</span><span class="subject-meta mono" id="meta"></span></div>+ <div class="actions">+ <span class="target-chip mono" id="target" data-tooltip="This page is served from your workspace. Your microphone is read by this browser, on this computer."><i class="led" id="led"></i>target: <b>this machine</b></span>+ <button class="icon-btn" id="help-btn" aria-label="How this works" data-icon="info"></button>+ <button class="icon-btn" id="settings-btn" aria-label="Settings" data-tooltip="Settings: countdown length, wrap-up warning, caption size. Stored in your workspace."><img src="static/settings-cog-outline.svg" width="18" height="18" alt=""></button>+ </div> </header>-<div class="stage"><video id="v" src="/video" preload="auto" playsinline muted></video></div>-<div class="bar">- <button id="rec" title="Start the teleprompter from the top and record your voice with it">Record</button>- <button id="stop" class="ghost" disabled title="Stop and save this take">Stop and save</button>- <button id="test" class="ghost" title="Check your microphone level without recording">Test mic</button>- <button id="cal" class="ghost" title="Two seconds of quiet, then read one sentence: the boost is set for your voice">Calibrate</button>- <select id="mic" title="Which microphone to record"></select>- <div class="meter" title="Microphone level: aim for the bar to reach about two thirds when you speak"><i id="lvl"></i></div>- <span class="ai" id="heard" style="min-width:150px">mic not tested yet</span>- <label class="ai" style="color:var(--dim)" title="Mic boost: raise it if you are quiet; Calibrate sets it for you">Boost <input id="boost" type="range" min="1" max="16" step="0.1" value="1" style="vertical-align:middle;width:120px;accent-color:var(--teal)"> <b id="boostv" style="color:var(--text)">1.0x</b></label>- <span class="spacer"></span>- <div class="takes" id="takes">No takes yet.</div>+<div class="popover" id="brand-pop"></div>+<div class="popover help-pop" id="help-pop">+ <div class="pop-title">How narrating works</div>+ <ol>+ <li><b>Check your microphone.</b> Calibrate sets the level for your voice in about 15 seconds.</li>+ <li><b>Rehearse</b> if you like: the film plays with its captions and nothing is recorded.</li>+ <li><b>Record.</b> The film plays, muted, with a line to read under it. Say it your own way. The bar shows how much is left and warns you before the end.</li>+ <li><b>Review.</b> Play the film with your voice on it. Record again if you want.</li>+ <li><b>Use this take.</b> Your AI puts your voice on the film, writes new captions and shows you the result.</li>+ </ol> </div>-<div id="calbox"><div class="card" id="calcard"></div></div>-<div id="toast"></div>-<div id="aicur"><i></i><span id="aicurlbl"></span></div>-<script>-const $ = id => document.getElementById(id);-const v = $('v'), state = $('state');-let say, open;-let heardAt = -1e9, level = 0, rawLevel = 0, boost = 1, gainNode = null, rawAn = null, recStream = null, sampler = null, cal = null, calPhase = null;-try { boost = Math.max(1, Math.min(16, +localStorage.getItem('narrate.boost') || 1)); } catch (_) {}-let stream = null, rec = null, chunks = [], ctx = null, raf = 0, t = {}, peak = 0, takes = [];-say = html => state.innerHTML = html; -async function mics() {- const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'audioinput');- const keep = $('mic').value;- $('mic').innerHTML = devs.map((d, i) => `<option value="${d.deviceId}">${d.label || 'Microphone ' + (i + 1)}</option>`).join('');- if (keep) $('mic').value = keep;-}-open = async function () {- if (stream) stream.getTracks().forEach(x => x.stop());- const id = $('mic').value;- stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: id ? { exact: id } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } });- await mics();- ctx = ctx || new AudioContext();- if (ctx.state !== 'running') await ctx.resume();- // microphone -> boost -> limiter -> what gets recorded; the meter shows the boosted level- const src = ctx.createMediaStreamSource(stream);- rawAn = ctx.createAnalyser(); rawAn.fftSize = 1024; src.connect(rawAn);- gainNode = ctx.createGain(); gainNode.gain.value = boost;- const lim = ctx.createDynamicsCompressor(); lim.threshold.value = -3; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.002; lim.release.value = 0.1;- const an = ctx.createAnalyser(); an.fftSize = 1024;- const dest = ctx.createMediaStreamDestination();- src.connect(gainNode); gainNode.connect(lim); lim.connect(an); lim.connect(dest);- recStream = dest.stream;- const buf = new Float32Array(an.fftSize), raw = new Float32Array(rawAn.fftSize);- cancelAnimationFrame(raf);- (function loop() {- an.getFloatTimeDomainData(buf); rawAn.getFloatTimeDomainData(raw);- let m = 0; for (const x of buf) m = Math.max(m, Math.abs(x));- let r = 0; for (const x of raw) r = Math.max(r, Math.abs(x));- rawLevel = r; if (sampler) sampler(r);- peak = Math.max(peak, m); level = m;- const db = m > 0 ? 20 * Math.log10(m) : -100; // 0 dB = full scale- $('lvl').style.width = Math.max(0, Math.min(100, (db + 54) / 54 * 100)) + '%';- $('lvl').classList.toggle('hot', m > 0.95);- if (m > 0.03) heardAt = performance.now();- const live = performance.now() - heardAt < 700;- $('heard').textContent = m > 0.95 ? 'too loud: back off a little' : live ? (m < 0.12 ? 'I hear you (a bit quiet)' : 'I hear you: good level') : 'listening... say something';- $('heard').style.color = m > 0.95 ? 'var(--red)' : live ? 'var(--teal)' : 'var(--dim)';- raf = requestAnimationFrame(loop);- })();-};-function setBoost(x) {- boost = Math.max(1, Math.min(16, +x || 1));- if (gainNode) gainNode.gain.value = boost;- $('boost').value = boost; $('boostv').textContent = boost.toFixed(1) + 'x';- try { localStorage.setItem('narrate.boost', String(boost)); } catch (_) {}-}-const dB = x => x > 0 ? 20 * Math.log10(x) : -100;-const wait = ms => new Promise(r => setTimeout(r, ms));-async function listen(ms) { const xs = []; sampler = x => xs.push(x); await wait(ms); sampler = null; return xs.sort((p, q) => p - q); }-let lines = ['This is how I will sound when I narrate my video.'];-fetch('/captions').then(r => r.json()).then(d => { if (d.lines && d.lines.length) lines = d.lines; }).catch(() => {});-function sample() { // a real line or two from this video, about 6 s of reading- let out = [], n = 0;- for (const l of lines) { const w = l.split(/\s+/).length; if (out.length && n + w > 22) break; out.push(l); n += w; if (n >= 12) break; }- return out.join(' ');-}-const stepsBar = k => `<div class="steps">${['1 Quiet', '2 Read', '3 Result'].map((s, i) => `<span class="${i < k ? 'done' : i === k ? 'now' : ''}">${s}</span>`).join('')}</div>`;-async function phase(k, title, sub, big, secs) { // one counted-down step with a live meter; returns the raw samples- $('calcard').innerHTML = stepsBar(k) + `<h2>${title}</h2><p>${sub}</p>` + (big ? `<div class="read">${big}</div>` : '') +- `<div class="count"><b id="cnum">${secs}</b><div class="prog"><i id="cprog"></i></div></div><div class="bigmeter"><i id="cmeter"></i><em title="aim for about here"></em></div>`;- calPhase = { step: k + 1, title, secondsLeft: secs }; report();- const xs = [], t0 = performance.now(), ms = secs * 1000;- sampler = x => { const el = performance.now() - t0; if (el > 350) xs.push(x); const d = x * boost > 0 ? 20 * Math.log10(x * boost) : -100; const m = $('cmeter'); if (m) m.style.width = Math.max(0, Math.min(100, (d + 54) / 54 * 100)) + '%'; };- while (performance.now() - t0 < ms) {- const el = performance.now() - t0, left = Math.ceil((ms - el) / 1000);- if ($('cnum')) { $('cnum').textContent = left; $('cprog').style.width = (el / ms * 100) + '%'; }- if (calPhase.secondsLeft !== left) { calPhase.secondsLeft = left; report(); }- await wait(60);- }- sampler = null;- return xs.sort((p, q) => p - q);-}-const pct = (xs, p) => xs[Math.min(xs.length - 1, Math.floor(xs.length * p))] || 0;-async function calibrate() {- try { await open(); } catch (e) { return say('The browser did not give access to a microphone (' + e.message + ').'); }- $('calbox').classList.add('on'); $('cal').disabled = true;- const before = boost, text = sample(), readSecs = Math.max(6, Math.ceil(text.split(/\s+/).length / 2.4) + 2);- await phase(0, 'Get ready', 'Calibration takes about ' + (3 + 3 + readSecs) + ' seconds: a moment of quiet, then one line to read out loud.', '', 3);- const room = await phase(0, 'Stay quiet', 'I am listening to the room. Do not talk or type.', '', 3);- const talk = await phase(1, 'Read this out loud', 'Use the voice you will narrate with. Start now.', '“' + text + '”', readSecs);- const noise = pct(room, 0.6), voice = pct(talk, 0.95);- cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1), boostBefore: +before.toFixed(2) };- const heard = voice >= Math.max(noise * 2.5, 0.004);- let rows, verdict;- if (heard) {- setBoost(0.5 / voice);- cal.boost = +boost.toFixed(2); cal.result = 'ok';- const after = dB(voice * boost);- rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x → <b class="to">${boost.toFixed(1)}x</b></td></tr>- <tr><td>Your voice, loudest parts</td><td>${cal.voiceDb} dB → <b class="to">${after.toFixed(1)} dB</b> (the target is about -6 dB)</td></tr>- <tr><td>The room when you were quiet</td><td>${cal.noiseDb} dB → ${dB(noise * boost).toFixed(1)} dB after the boost</td></tr>- <tr><td>Your voice above the room</td><td><b>${cal.gapDb} dB</b></td></tr>`;- verdict = (0.5 / voice > 16 ? 'That is the most boost I will add. For a stronger signal, move closer or raise the microphone level in your sound settings. ' : '') +- (cal.gapDb >= 30 ? '<b style="color:var(--teal)">Good.</b> Your voice is well clear of the room.' : cal.gapDb >= 20 ? '<b style="color:var(--teal)">Fine.</b> A little room noise will be audible in the pauses.' : '<b style="color:var(--red)">Noisy.</b> The room is close to as loud as your voice. A closer microphone, or a quieter room, will sound much better.');- } else {- cal.result = 'not heard';- rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x, <b>not changed</b></td></tr>- <tr><td>While you read</td><td>${cal.voiceDb} dB</td></tr>- <tr><td>While you were quiet</td><td>${cal.noiseDb} dB</td></tr>- <tr><td>Difference</td><td><b>${cal.gapDb} dB</b> (I need at least 8 dB to tell your voice from the room)</td></tr>`;- verdict = '<b style="color:var(--red)">I could not tell your voice from the room.</b> Either something made noise during the quiet part, or this microphone is not picking you up. Check the microphone picked below (' + ($('mic').selectedOptions[0]?.textContent || '') + '), move closer, and try again.';- }- $('calcard').innerHTML = stepsBar(2) + `<h2>${heard ? 'Calibrated' : 'Calibration did not work'}</h2><table class="res">${rows}</table><div class="verdict">${verdict}</div>- <div class="row">${heard ? '<button id="cgo" class="rec">Record now</button>' : ''}<button id="cagain" class="${heard ? 'ghost' : ''}">Calibrate again</button><button id="cclose" class="ghost">Close</button></div>`;- const close = () => { $('calbox').classList.remove('on'); calPhase = null; report(); };- $('cclose').onclick = close;- $('cagain').onclick = () => { close(); calibrate(); };- if (heard) $('cgo').onclick = () => { close(); $('rec').click(); };- $('cal').disabled = false; calPhase = { step: 3, title: 'result' };- say(heard ? '<b>Calibrated: boost ' + before.toFixed(1) + 'x to ' + boost.toFixed(1) + 'x.</b> Voice ' + cal.voiceDb + ' dB, room ' + cal.noiseDb + ' dB (' + cal.gapDb + ' dB apart).' : '<b>Calibration did not work:</b> voice ' + cal.voiceDb + ' dB, room ' + cal.noiseDb + ' dB. Boost not changed.');-}-$('mic').onchange = () => open().catch(e => say('Could not open that microphone: ' + e.message));-$('test').onclick = () => { say('Opening the microphone...'); return open().then(() => say('<b>Microphone on: ' + ($('mic').selectedOptions[0]?.textContent || '') + '.</b> Say something: the bar should reach about two thirds. Red means too loud.')).catch(e => say('The browser did not give access to a microphone (' + e.message + '). Allow it in the address bar and try again.')); };+<main>+ <section class="stage" id="stage">+ <adom-video id="player" video="video" chapters="chapters.json" captions="captions.vtt" muted></adom-video>+ <div class="overlay" id="preroll"><div class="pre-num" id="pre-num">5</div><div class="pre-text" id="pre-text">Get ready</div></div>+ <div class="wrapup" id="wrapup"><b id="wrap-num">10</b><span id="wrap-text">seconds left: wrap up</span></div>+ <div class="recbadge" id="recbadge"><i></i><span id="recbadge-text">Recording</span></div>+ </section> -$('rec').onclick = async () => {- try { await open(); } catch (e) { return say('The browser did not give access to a microphone (' + e.message + '). Allow it in the address bar and try again.'); }- chunks = []; peak = 0; t = {};- const type = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(x => MediaRecorder.isTypeSupported(x)) || '';- rec = new MediaRecorder(recStream, type ? { mimeType: type, audioBitsPerSecond: 192000 } : undefined);- rec.ondataavailable = e => e.data.size && chunks.push(e.data);- rec.onstart = () => { t.rec = performance.now(); v.currentTime = 0; v.play(); };- rec.onstop = save;- v.onplaying = () => { if (t.rec && t.play === undefined) t.play = performance.now(); };- rec.start(1000);- $('rec').disabled = true; $('stop').disabled = false; $('mic').disabled = true;- say('<span class="dot"></span><b>Recording.</b> Read each line as it appears. It stops by itself at the end.');-};-$('stop').onclick = () => rec && rec.state !== 'inactive' && rec.stop();-v.onended = () => rec && rec.state !== 'inactive' && rec.stop();+ <aside>+ <h2>Steps</h2>+ <ol class="steps-list" id="steps">+ <li id="st-mic"><span class="n">1</span><div><b>Check your microphone</b><small id="st-mic-s">Not checked yet</small></div></li>+ <li id="st-rec"><span class="n">2</span><div><b>Record your narration</b><small id="st-rec-s">No takes yet</small></div></li>+ <li id="st-rev"><span class="n">3</span><div><b>Review and send to your AI</b><small id="st-rev-s">Nothing to review yet</small></div></li>+ </ol> -async function save() {- v.pause();- $('rec').disabled = false; $('stop').disabled = true; $('mic').disabled = false;- const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });- const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((t.play ?? t.rec) - t.rec), recordedMs: Math.round(performance.now() - t.rec), videoStoppedAtS: +v.currentTime.toFixed(2), videoDurationS: +v.duration.toFixed(2), peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal };- say('Saving the take...');- try {- const r = await (await fetch('/take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json();- takes.push(r);- $('takes').innerHTML = takes.map((x, i) => `Take ${i + 1}: ${(x.seconds / 60 | 0)}:${String(Math.round(x.seconds % 60)).padStart(2, '0')} <a href="/takes/${x.file}" target="_blank">listen</a>`).join(' · ');- const note = meta.peak > 0.98 ? ' It clipped in places (too loud): move back a little and try another take.' : meta.peak < 0.08 ? ' It is very quiet: check the right microphone is picked.' : '';- say(`<b>Take ${takes.length} saved.</b> Tell your AI it is ready, or press Record for another take.` + note);- } catch (e) { say('The take could not be saved (' + e.message + '). It is still in this page: do not close it, and tell your AI.'); }-}-$('boost').oninput = e => setBoost(e.target.value);-$('cal').onclick = calibrate;-setBoost(boost);-navigator.mediaDevices.addEventListener('devicechange', mics);-mics();+ <h2>Microphone</h2>+ <select class="select wide" id="mic" aria-label="Microphone" data-tooltip="Which microphone to record. Any microphone your computer sees is listed here."></select>+ <div class="meter" data-tooltip="Your level after the boost. Aim for the dashed line when you speak. Red means too loud."><i id="lvl"></i><em></em></div>+ <div class="heard" id="heard">Microphone not opened yet</div>+ <div class="boost-row" data-tooltip="Mic boost. Calibrate sets it for your voice; drag to set it yourself.">+ <span>Boost</span><input id="boost" type="range" min="1" max="16" step="0.1" value="1" aria-label="Microphone boost"><b class="mono" id="boostv">1.0x</b>+ </div>+ <div class="btn-row">+ <button class="btn ghost" id="cal" data-tooltip="About 15 seconds: a moment of quiet, then one line from your film to read. Sets the boost for your voice and tells you what changed.">Calibrate</button>+ <button class="btn ghost" id="test" data-tooltip="Open the microphone and watch the level. Nothing is recorded.">Test mic</button>+ </div> -// ---- the live channel: the AI drives this page through the app's CLI, and the page reports back-let micAccess = 'not asked yet', lastStatus = '';-const _say = say; say = html => { _say(html); lastStatus = state.textContent; report(); };-function report() {- const body = { mics: [...$('mic').options].map(o => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, recording: !!rec && rec.state === 'recording', t: +v.currentTime.toFixed(2), duration: +(v.duration || 0).toFixed(2), level: +level.toFixed(3), peak: +peak.toFixed(3), rawLevel: +rawLevel.toFixed(3), boost: +boost.toFixed(2), calibration: cal, calibrating: calPhase, takes: takes.length, status: lastStatus };- fetch('/state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {});-}-setInterval(report, 1000);-const _open = open; open = async () => { try { await _open(); micAccess = 'allowed'; } catch (e) { micAccess = 'blocked: ' + e.message; report(); throw e; } report(); };+ <h2>Takes</h2>+ <ul class="takes" id="takes"><li class="empty">Your recordings will be listed here.</li></ul> -function toast(text) { const el = $('toast'); el.textContent = text; el.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove('on'), 4000); }-async function cursorTo(el, label) {- const c = $('aicur'), r = el.getBoundingClientRect();- $('aicurlbl').textContent = label || '';- c.classList.add('on'); c.style.transform = `translate(${r.left + r.width / 2 - 8}px, ${r.top + r.height / 2 - 8}px)`;- await new Promise(r => setTimeout(r, 420));-}-async function aiClick(el, label) { if (!el || el.disabled) return; await cursorTo(el, label); el.click(); setTimeout(() => $('aicur').classList.remove('on'), 900); }-const es = new EventSource('/events');-const on = (name, fn) => es.addEventListener(name, e => { let d = {}; try { d = JSON.parse(e.data); } catch (_) {} fn(d); });-on('record', d => aiClick($('rec'), (d.by || 'AI') + ': start recording'));-on('stop', d => aiClick($('stop'), (d.by || 'AI') + ': stop and save'));-on('test', d => aiClick($('test'), (d.by || 'AI') + ': test the microphone'));-on('mic', async d => { const o = [...$('mic').options].find(o => o.textContent.toLowerCase().includes(String(d.name || '').toLowerCase())); if (!o) return toast('No microphone matching "' + d.name + '"'); await cursorTo($('mic'), (d.by || 'AI') + ': pick ' + o.textContent); $('mic').value = o.value; $('mic').onchange(); setTimeout(() => $('aicur').classList.remove('on'), 900); });-on('calibrate', d => aiClick($('cal'), (d.by || 'AI') + ': calibrate the microphone'));-on('gain', async d => { await cursorTo($('boost'), (d.by || 'AI') + ': boost ' + d.value + 'x'); setBoost(d.value); report(); setTimeout(() => $('aicur').classList.remove('on'), 900); });-on('seek', d => { v.currentTime = Math.max(0, +d.seconds || 0); report(); });-on('play', () => v.play());-on('pause', () => v.pause());-on('toast', d => toast(d.text || ''));-on('click', d => aiClick(document.querySelector(d.selector), d.by || 'AI'));-on('reveal', d => document.querySelector(d.selector)?.scrollIntoView({ block: 'center', behavior: 'smooth' }));-on('cursor', d => { const el = document.querySelector(d.selector); el && cursorTo(el, d.by || 'AI'); });-on('reload', () => location.reload());-on('state', d => { if (d.takes && d.takes.length > takes.length) report(); });-</script>+ <h2>From your AI</h2>+ <div class="ainote" id="ainote">Nothing yet. Your AI can see this page and will tell you here what it is doing with your recording.</div>+ </aside>+</main>++<footer class="recbar">+ <button class="btn rec" id="rec" data-tooltip="Start a take from the top. A short countdown, then the film plays muted and your voice is recorded with it.">Record</button>+ <button class="btn ghost" id="rehearse" data-tooltip="Play the film with its captions to practise. Nothing is recorded.">Rehearse</button>+ <button class="btn ghost" id="stop" disabled data-tooltip="Stop now. If you are recording, the take is saved.">Stop</button>+ <div class="clock mono" data-tooltip="Time into the film, and how long it is"><b id="t-now">0:00</b><span> / </span><span id="t-dur">0:00</span></div>+ <div class="progress" id="progress" data-tooltip="Where you are in the film. Each segment is a chapter."><div class="segs" id="segs"></div><i id="prog-fill"></i></div>+ <div class="left mono" data-tooltip="Time left in the film"><span id="t-left">0:00</span> left</div>+ <div class="chap" id="chap" data-tooltip="The chapter on screen now, and the one coming next"><b id="chap-now">Ready</b><small id="chap-next"></small></div>+ <div class="mode" id="mode">Ready</div>+</footer>++<div class="modal" id="modal"><div class="card" id="card"></div></div>++<dialog id="settings" aria-label="Settings">+ <div class="dlg-head"><h3>Settings</h3><button class="icon-btn" id="settings-close" aria-label="Close settings" data-tooltip="Close settings (Esc)" data-icon="close"></button></div>+ <div class="dlg-body" id="settings-body"></div>+ <footer class="settings-footer">+ <p class="footer-note">Settings are stored in your workspace and apply to every film you narrate. Restoring returns every option above to its default.</p>+ <button class="restore-btn" id="restore" data-tooltip="Return every setting to its default. Your takes are kept." data-icon="restore" data-icon-size="16"> Restore defaults</button>+ </footer>+</dialog>++<script src="static/adom-video.js"></script>+<script src="static/icons.js"></script>+<script src="static/ui.js"></script>+<script src="static/narrate.js"></script> </body> </html>
assets/narrate.jsadded+385@@ -0,0 +1,385 @@+// AI Flow Video, the narration page. The film plays in the real <adom-video> player; this script adds+// the microphone, calibration, recording with a visible clock, drawing on the film, review and the hand-off+// to the AI. State lives on the server (static/ui.js gives tooltips, toasts, settings, the AI cursor).+(() => {+ const $ = (id) => document.getElementById(id);+ const { api, toast, hoverPopover, initSettings, connect, uiCommon, glide } = UI;+ // this app's mark: a microphone, the same line drawing as the tab icon+ window.ICONS.mark = '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5V21"/><path d="M8.5 21h7"/>';+ const p = $('player');+ const fmt = (s) => { s = Math.max(0, Math.round(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));+ const dB = (x) => (x > 0 ? 20 * Math.log10(x) : -100);+ const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));++ let S = { preRoll: 5, wrapUp: 10, drawFade: 2 }; // settings, from the server+ let info = {}, mode = 'idle', takes = [], chosen = null, lines = [], note = '';+ let stream = null, recStream = null, rec = null, chunks = [], ctx = null, raf = 0, gainNode = null, rawAn = null, sampler = null;+ let boost = 1, level = 0, rawLevel = 0, peak = 0, heardAt = -1e9, micAccess = 'not asked yet', cal = null, calPhase = null, tRec = {}, lastStatus = '';+ let strokes = [], liveStroke = null, reviewAudio = null, reviewTake = null, stopFlag = false;+ const v = () => p.v;++ // ---------------- header ----------------+ $('brand-icon').innerHTML = icon('mark', 18);+ document.querySelectorAll('[data-icon]').forEach((b) => b.insertAdjacentHTML('afterbegin', icon(b.dataset.icon, Number(b.dataset.iconSize || 18))));+ hoverPopover($('help-btn'), $('help-pop'));+ initSettings();+ function header() {+ $('title').textContent = info.title || 'Your film';+ $('meta').textContent = [fmt(v().duration), (p.chapters.length > 1 ? p.chapters.length + ' chapters' : ''), takes.length ? takes.length + (takes.length === 1 ? ' take' : ' takes') : ''].filter(Boolean).join(' · ');+ $('brand-pop').innerHTML = `<div class="pop-title">AI Flow Video</div>+ <div class="pop-row"><span>Version</span><span class="mono">${esc(info.version || '')}</span></div>+ <div class="pop-row"><span>Server</span><span class="mono">port ${esc(info.port || '')}</span></div>+ <div class="pop-row"><span>Player</span><span>the Adom video player (::adom-video)</span></div>+ <div class="pop-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-aiflow-video" target="_blank" rel="noopener">adom/adom-aiflow-video</a></div>`;+ }+ hoverPopover($('brand'), $('brand-pop'));++ // ---------------- status + reporting to the server ----------------+ function setMode(m, label) {+ mode = m;+ const el = $('mode'); el.textContent = label || { idle: 'Ready', preroll: 'Get ready', rehearse: 'Rehearsing', record: 'Recording', review: 'Reviewing' }[m];+ el.className = 'mode' + (m === 'record' ? ' rec' : m === 'idle' ? '' : ' play');+ $('progress').classList.toggle('rec', m === 'record');+ $('rec').disabled = $('rehearse').disabled = m !== 'idle';+ $('stop').disabled = m === 'idle';+ $('mic').disabled = m === 'record' || m === 'preroll';+ canvas.style.pointerEvents = m === 'record' || m === 'rehearse' ? 'auto' : 'none';+ $('drawbar').classList.toggle('on', m === 'record' || m === 'rehearse');+ $('recbadge').classList.toggle('on', m === 'record' || m === 'review');+ $('recbadge').classList.toggle('review', m === 'review');+ steps(); report();+ }+ function say(text) { lastStatus = text; report(); }+ function steps() {+ const micOk = cal && cal.result === 'ok';+ $('st-mic').className = micOk ? 'done' : (takes.length ? '' : 'now');+ $('st-mic-s').textContent = micOk ? `Calibrated: boost ${boost.toFixed(1)}x, voice ${cal.gapDb} dB above the room` : micAccess === 'allowed' ? 'Microphone on. Calibrate to set the level for your voice.' : 'Not checked yet';+ $('st-rec').className = takes.length ? 'done' : (micOk ? 'now' : '');+ $('st-rec-s').textContent = mode === 'record' ? 'Recording now' : takes.length ? takes.length + (takes.length === 1 ? ' take recorded' : ' takes recorded') : 'No takes yet';+ $('st-rev').className = chosen ? 'done' : (takes.length ? 'now' : '');+ $('st-rev-s').textContent = chosen ? `Take ${chosen.n} sent to your AI` : takes.length ? 'Review a take, then send the one you like' : 'Nothing to review yet';+ }+ function report() {+ const d = v() ? v().duration || 0 : 0, t = v() ? v().currentTime : 0, c = v() ? p._chapterAt?.(t) : null;+ const body = { mode, t: +t.toFixed(2), duration: +d.toFixed(2), left: +(d - t).toFixed(1), chapter: c ? c.title : '', mics: [...$('mic').options].map((o) => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess,+ recording: mode === 'record', level: +level.toFixed(3), rawLevel: +rawLevel.toFixed(3), peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, calibrating: calPhase, takes: takes.length, chosen: chosen ? chosen.file : null, strokes: strokes.length, status: lastStatus };+ fetch('state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {});+ }+ setInterval(report, 1000);++ // ---------------- the clock, the bar, chapters, the wrap-up warning ----------------+ function segs() {+ const d = v().duration || 0; if (!d) return;+ $('segs').innerHTML = p.chapters.slice(1).map((c) => `<span style="left:${(c.t / d * 100).toFixed(3)}%"></span>`).join('');+ $('t-dur').textContent = fmt(d);+ }+ function tick() {+ requestAnimationFrame(tick);+ if (!v() || !v().duration) return;+ const d = v().duration, t = v().currentTime, left = d - t;+ $('t-now').textContent = fmt(t); $('t-left').textContent = fmt(left);+ $('prog-fill').style.width = (t / d * 100) + '%';+ const live = mode === 'record' || mode === 'rehearse' || mode === 'review';+ $('t-left').parentElement.className = 'left mono' + (live && left <= 3 ? ' now' : live && left <= S.wrapUp ? ' soon' : '');+ const ch = p.chapters, i = ch.findLastIndex ? ch.findLastIndex((c) => t + 0.05 >= c.t) : -1, cur = ch[i], nxt = ch[i + 1];+ $('chap-now').textContent = live && cur && cur.title ? cur.title : mode === 'idle' ? 'Ready when you are' : '';+ $('chap-next').textContent = live && nxt ? `Next: ${nxt.title} in ${fmt(nxt.t - t)}` : live ? 'Last chapter' : (ch.length > 1 ? ch.length + ' chapters, ' + fmt(d) : fmt(d));+ const w = $('wrapup'), warn = (mode === 'record' || mode === 'rehearse') && S.wrapUp > 0 && left <= S.wrapUp && left > 0.05;+ w.classList.toggle('on', warn);+ if (warn) { $('wrap-num').textContent = Math.ceil(left); w.classList.toggle('final', left <= 3); $('wrap-text').textContent = left <= 3 ? 'finish your sentence' : 'seconds left: wrap up'; }+ drawFrame();+ }++ // ---------------- microphone: boost, limiter, meter ----------------+ async function mics() {+ const devs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audioinput');+ const keep = $('mic').value;+ $('mic').innerHTML = devs.map((d, i) => `<option value="${d.deviceId}">${esc(d.label || 'Microphone ' + (i + 1))}</option>`).join('');+ if (keep) $('mic').value = keep;+ }+ async function openMic() {+ try {+ if (stream) stream.getTracks().forEach((x) => x.stop());+ const id = $('mic').value;+ stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: id ? { exact: id } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } });+ micAccess = 'allowed';+ } catch (e) { micAccess = 'blocked: ' + e.message; toast('The browser did not give access to a microphone. Allow it in the address bar, then try again.', 'error'); steps(); report(); throw e; }+ await mics();+ ctx = ctx || new AudioContext(); if (ctx.state !== 'running') await ctx.resume();+ const src = ctx.createMediaStreamSource(stream);+ rawAn = ctx.createAnalyser(); rawAn.fftSize = 1024; src.connect(rawAn);+ gainNode = ctx.createGain(); gainNode.gain.value = boost;+ const lim = ctx.createDynamicsCompressor(); lim.threshold.value = -3; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.002; lim.release.value = 0.1;+ const an = ctx.createAnalyser(); an.fftSize = 1024; const dest = ctx.createMediaStreamDestination();+ src.connect(gainNode); gainNode.connect(lim); lim.connect(an); lim.connect(dest); recStream = dest.stream;+ const buf = new Float32Array(an.fftSize), raw = new Float32Array(rawAn.fftSize);+ cancelAnimationFrame(raf);+ (function loop() {+ an.getFloatTimeDomainData(buf); rawAn.getFloatTimeDomainData(raw);+ let m = 0; for (const x of buf) m = Math.max(m, Math.abs(x));+ let r = 0; for (const x of raw) r = Math.max(r, Math.abs(x));+ rawLevel = r; if (sampler) sampler(r); peak = Math.max(peak, m); level = m;+ $('lvl').style.width = Math.max(0, Math.min(100, (dB(m) + 54) / 54 * 100)) + '%'; $('lvl').classList.toggle('hot', m > 0.95);+ if (m > 0.03) heardAt = performance.now();+ const live = performance.now() - heardAt < 700;+ $('heard').textContent = m > 0.95 ? 'Too loud: back off a little' : live ? (m < 0.12 ? 'I hear you (a bit quiet)' : 'I hear you: good level') : 'Listening... say something';+ $('heard').style.color = m > 0.95 ? 'var(--red)' : live ? 'var(--accent-bright)' : 'var(--muted)';+ raf = requestAnimationFrame(loop);+ })();+ steps(); report();+ }+ function setBoost(x, quiet) {+ boost = Math.max(1, Math.min(16, +x || 1)); if (gainNode) gainNode.gain.value = boost;+ $('boost').value = boost; $('boostv').textContent = boost.toFixed(1) + 'x';+ try { localStorage.setItem('narrate.boost', String(boost)); } catch (_) {}+ if (!quiet) report();+ }+ try { boost = Math.max(1, Math.min(16, +localStorage.getItem('narrate.boost') || 1)); } catch (_) {}+ $('boost').oninput = (e) => setBoost(e.target.value);+ $('boost').onchange = () => toast(`Microphone boost: ${boost.toFixed(1)}x`, 'success');+ $('mic').onchange = () => openMic().then(() => toast('Microphone: ' + $('mic').selectedOptions[0].textContent, 'success')).catch(() => {});+ $('test').onclick = () => openMic().then(() => { toast('Microphone on: ' + ($('mic').selectedOptions[0]?.textContent || '') + '. Say something and watch the level.', 'success'); say('Microphone on, testing the level'); }).catch(() => {});++ // ---------------- the card (calibration, finish, sent) ----------------+ const modal = (html) => { $('card').innerHTML = html; $('modal').classList.add('on'); };+ const closeModal = () => { $('modal').classList.remove('on'); calPhase = null; report(); };+ const pills = (names, k) => `<div class="pills">${names.map((s, i) => `<span class="${i < k ? 'done' : i === k ? 'now' : ''}">${s}</span>`).join('')}</div>`;+ const CAL = ['1 Quiet', '2 Read', '3 Result'];+ function sample() { let out = [], n = 0; for (const l of lines) { const w = l.split(/\s+/).length; if (out.length && n + w > 22) break; out.push(l); n += w; if (n >= 12) break; } return out.join(' ') || 'This is how I will sound when I narrate my film.'; }+ async function phase(k, title, sub, big, secs) {+ modal(pills(CAL, k) + `<h2>${title}</h2><p>${sub}</p>` + (big ? `<div class="read">${big}</div>` : '') + `<div class="count"><b id="cnum">${secs}</b><div class="prog"><i id="cprog"></i></div></div><div class="bigmeter"><i id="cmeter"></i><em></em></div>`);+ calPhase = { step: k + 1, title, secondsLeft: secs }; report();+ const xs = [], t0 = performance.now(), ms = secs * 1000;+ sampler = (x) => { if (performance.now() - t0 > 350) xs.push(x); const m = $('cmeter'); if (m) m.style.width = Math.max(0, Math.min(100, (dB(x * boost) + 54) / 54 * 100)) + '%'; };+ while (performance.now() - t0 < ms) {+ const el = performance.now() - t0, left = Math.ceil((ms - el) / 1000);+ if ($('cnum')) { $('cnum').textContent = left; $('cprog').style.width = (el / ms * 100) + '%'; }+ if (calPhase.secondsLeft !== left) { calPhase.secondsLeft = left; report(); }+ await wait(60);+ }+ sampler = null; return xs.sort((a, b) => a - b);+ }+ const pct = (xs, q) => xs[Math.min(xs.length - 1, Math.floor(xs.length * q))] || 0;+ async function calibrate() {+ if (mode !== 'idle') return toast('Stop the film first, then calibrate.', 'info');+ try { await openMic(); } catch (_) { return; }+ $('cal').disabled = true;+ const before = boost, text = sample(), readSecs = Math.max(6, Math.ceil(text.split(/\s+/).length / 2.4) + 2);+ await phase(0, 'Get ready', `Calibration takes about ${3 + 3 + readSecs} seconds: a moment of quiet, then one line to read out loud.`, '', 3);+ const room = await phase(0, 'Stay quiet', 'I am listening to the room. Do not talk or type.', '', 3);+ const talk = await phase(1, 'Read this out loud', 'Use the voice you will narrate with. Start now.', '“' + esc(text) + '”', readSecs);+ const noise = pct(room, 0.6), voice = pct(talk, 0.95), heard = voice >= Math.max(noise * 2.5, 0.004);+ cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1), boostBefore: +before.toFixed(2) };+ let rows, verdict;+ if (heard) {+ setBoost(0.5 / voice, true); cal.boost = +boost.toFixed(2); cal.result = 'ok';+ rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x → <b class="to">${boost.toFixed(1)}x</b></td></tr>+ <tr><td>Your voice, loudest parts</td><td>${cal.voiceDb} dB → <b class="to">${dB(voice * boost).toFixed(1)} dB</b> (the target is about -6 dB)</td></tr>+ <tr><td>The room when you were quiet</td><td>${cal.noiseDb} dB → ${dB(noise * boost).toFixed(1)} dB after the boost</td></tr>+ <tr><td>Your voice above the room</td><td><b>${cal.gapDb} dB</b></td></tr>`;+ verdict = (0.5 / voice > 16 ? 'That is the most boost I will add. For a stronger signal, move closer or raise the microphone level in your sound settings. ' : '') ++ (cal.gapDb >= 30 ? '<span class="good">Good.</span> Your voice is well clear of the room.' : cal.gapDb >= 20 ? '<span class="warn">Fine.</span> A little room noise will be audible in the pauses.' : '<span class="bad">Noisy.</span> The room is nearly as loud as your voice. A closer microphone or a quieter room will sound much better.');+ } else {+ cal.result = 'not heard';+ rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x, <b>not changed</b></td></tr><tr><td>While you read</td><td>${cal.voiceDb} dB</td></tr><tr><td>While you were quiet</td><td>${cal.noiseDb} dB</td></tr>+ <tr><td>Difference</td><td><b>${cal.gapDb} dB</b> (I need at least 8 dB to tell your voice from the room)</td></tr>`;+ verdict = '<span class="bad">I could not tell your voice from the room.</span> Either something made noise during the quiet part, or this microphone is not picking you up. Check the microphone (' + esc($('mic').selectedOptions[0]?.textContent || '') + '), move closer, and try again.';+ }+ modal(pills(CAL, 2) + `<h2>${heard ? 'Calibrated' : 'Calibration did not work'}</h2><table>${rows}</table><div class="verdict">${verdict}</div>+ <div class="row">${heard ? '<button class="btn rec lg" id="c-go">Record now</button><button class="btn ghost lg" id="c-reh">Rehearse first</button>' : ''}<button class="btn ${heard ? 'ghost' : ''} lg" id="c-again">Calibrate again</button><button class="btn ghost lg" id="c-close">Close</button></div>`);+ $('c-close').onclick = closeModal; $('c-again').onclick = () => { closeModal(); calibrate(); };+ if (heard) { $('c-go').onclick = () => { closeModal(); record(); }; $('c-reh').onclick = () => { closeModal(); rehearse(); }; }+ $('cal').disabled = false; calPhase = { step: 3, title: 'result' }; steps();+ say(heard ? `Calibrated: boost ${before.toFixed(1)}x to ${boost.toFixed(1)}x, voice ${cal.gapDb} dB above the room` : 'Calibration did not work: boost not changed');+ toast(heard ? `Calibrated: boost ${boost.toFixed(1)}x` : 'Calibration did not work', heard ? 'success' : 'error');+ }+ $('cal').onclick = calibrate;++ // ---------------- drawing on the film ----------------+ const canvas = document.createElement('canvas'); canvas.id = 'draw'; canvas.style.cssText = 'position:absolute;z-index:4;pointer-events:none;cursor:crosshair;touch-action:none';+ $('stage').appendChild(canvas);+ const bar = document.createElement('div'); bar.id = 'drawbar'; bar.className = 'drawbar';+ bar.innerHTML = `<span class="pen"></span><b>Draw on the film</b><span>Drag to circle or point at something. It fades after <i id="fade-s">2</i> s.</span><button class="btn ghost" id="wipe" data-tooltip="Wipe your drawing now. Useful when the film moves to another shot.">Wipe now <kbd>Space</kbd></button>`;+ $('stage').appendChild(bar);+ const g = canvas.getContext('2d');+ function fit() { const r = p.getBoundingClientRect(), s = $('stage').getBoundingClientRect(); canvas.style.left = (r.left - s.left) + 'px'; canvas.style.top = (r.top - s.top) + 'px'; canvas.style.width = r.width + 'px'; canvas.style.height = r.height + 'px'; const k = devicePixelRatio || 1; if (canvas.width !== Math.round(r.width * k)) { canvas.width = Math.round(r.width * k); canvas.height = Math.round(r.height * k); } }+ addEventListener('resize', fit);+ const pos = (e) => { const r = canvas.getBoundingClientRect(); return [Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), Math.max(0, Math.min(1, (e.clientY - r.top) / r.height))]; };+ canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); const [x, y] = pos(e); liveStroke = { t0: +v().currentTime.toFixed(3), pts: [[x, y, 0]], end: null, wiped: null, wall: performance.now() }; strokes.push(liveStroke); });+ canvas.addEventListener('pointermove', (e) => { if (!liveStroke) return; const [x, y] = pos(e); liveStroke.pts.push([+x.toFixed(4), +y.toFixed(4), +((performance.now() - liveStroke.wall) / 1000).toFixed(3)]); });+ const penUp = () => { if (liveStroke) { liveStroke.end = +v().currentTime.toFixed(3); liveStroke.endWall = performance.now(); liveStroke = null; } };+ canvas.addEventListener('pointerup', penUp); canvas.addEventListener('pointercancel', penUp);+ function wipe() { const t = +v().currentTime.toFixed(3), now = performance.now(); let n = 0; for (const s of strokes) if (s.wiped === null && (s.end === null || now - s.endWall < S.drawFade * 1000)) { if (s.end === null) { s.end = t; s.endWall = now; } s.wiped = t; s.wipedWall = now; n++; } liveStroke = null; if (n) toast('Drawing wiped', 'info'); }+ $('wipe').onclick = wipe;+ addEventListener('keydown', (e) => { if ((mode === 'record' || mode === 'rehearse') && (e.code === 'Space' || e.key === ' ')) { e.preventDefault(); e.stopImmediatePropagation(); wipe(); } }, true);+ // what is visible at film time t: live strokes use wall time; a review replays by film time+ function drawFrame() {+ fit(); const W = canvas.width, H = canvas.height; g.clearRect(0, 0, W, H);+ const now = performance.now(), t = v() ? v().currentTime : 0, replay = mode === 'review';+ const list = replay && reviewTake ? (reviewTake.strokes || []) : strokes;+ for (const s of list) {+ let alpha = 1, upto = Infinity;+ if (replay) {+ if (t < s.t0) continue; upto = t - s.t0;+ const end = s.end ?? s.t0, gone = s.wiped ?? end + S.drawFade; if (t > gone) continue;+ if (t > end) alpha = s.wiped !== null && s.wiped !== undefined ? 1 : Math.max(0, 1 - (t - end) / S.drawFade);+ } else {+ if (s.wiped !== null) continue;+ if (s.end !== null) { const a = (now - s.endWall) / (S.drawFade * 1000); if (a >= 1) continue; alpha = 1 - Math.max(0, a - 0.6) / 0.4; }+ }+ g.lineJoin = g.lineCap = 'round';+ for (const [col, wdt] of [['rgba(5,34,31,' + (0.55 * alpha) + ')', 0.0105], ['rgba(0,230,220,' + alpha + ')', 0.0062]]) {+ g.strokeStyle = col; g.lineWidth = Math.max(3, wdt * W); g.beginPath();+ let started = false;+ for (const [x, y, dt] of s.pts) { if (dt > upto) break; if (!started) { g.moveTo(x * W, y * H); started = true; } else g.lineTo(x * W, y * H); }+ if (s.pts.length === 1 || (replay && upto < 0.03)) g.lineTo(s.pts[0][0] * W + 0.01, s.pts[0][1] * H);+ g.stroke();+ }+ }+ }++ // ---------------- rehearse, record, stop ----------------+ async function preroll(text) {+ const n = Math.max(0, Math.round(S.preRoll)); if (!n) return true;+ $('preroll').classList.add('on'); $('pre-text').textContent = text;+ for (let i = n; i > 0 && !stopFlag; i--) { $('pre-num').textContent = i; await wait(1000); }+ $('preroll').classList.remove('on'); return !stopFlag;+ }+ function capsOn() { if (p.cues && p.cues.length && !p.capOn) p.captions(); }+ async function rehearse() {+ if (mode !== 'idle') return; stopFlag = false; strokes = [];+ setMode('rehearse'); say('Rehearsing: the film plays with its captions, nothing is recorded');+ capsOn(); v().muted = false; p.seekTo(0); await v().play().catch(() => {});+ toast('Rehearsing. Nothing is being recorded. Try drawing on the film.', 'info');+ }+ async function record() {+ if (mode !== 'idle') return;+ try { await openMic(); } catch (_) { return; }+ if (!(cal && cal.result === 'ok')) toast('Tip: Calibrate first so your voice is recorded at a good level.', 'info');+ stopFlag = false; strokes = []; chunks = []; peak = 0; tRec = {};+ setMode('preroll'); say('Countdown before recording');+ v().pause(); p.seekTo(0); capsOn(); v().muted = true;+ if (!(await preroll('Recording starts when this reaches zero. Read each line in your own words.'))) { setMode('idle'); say('Recording cancelled'); return; }+ const type = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || '';+ rec = new MediaRecorder(recStream, type ? { mimeType: type, audioBitsPerSecond: 192000 } : undefined);+ rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);+ rec.onstart = () => { tRec.rec = performance.now(); v().play(); };+ rec.onstop = save;+ v().addEventListener('playing', () => { if (tRec.rec && tRec.play === undefined) tRec.play = performance.now(); }, { once: true });+ rec.start(1000);+ setMode('record'); $('recbadge-text').textContent = 'Recording your voice'; say('Recording');+ }+ function stop() {+ stopFlag = true;+ if (mode === 'record' && rec && rec.state !== 'inactive') return rec.stop();+ if (mode === 'review') return endReview();+ if (mode === 'rehearse') { v().pause(); setMode('idle'); say('Rehearsal stopped'); toast('Rehearsal stopped', 'info'); }+ }+ $('rec').onclick = record; $('rehearse').onclick = rehearse; $('stop').onclick = stop;++ async function save() {+ v().pause(); const whole = v().duration - v().currentTime < 1.5;+ const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });+ const drawn = strokes.map((s) => ({ t0: s.t0, end: s.end ?? +v().currentTime.toFixed(3), wiped: s.wiped, pts: s.pts }));+ const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), videoStoppedAtS: +v().currentTime.toFixed(2), videoDurationS: +v().duration.toFixed(2), whole, peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, strokes: drawn.length, drawFade: S.drawFade };+ setMode('idle', 'Saving'); say('Saving the take');+ modal(`<h2>Saving your recording...</h2><p>Keep this tab open.</p>`);+ try {+ const r = await (await fetch('take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json();+ if (drawn.length) await fetch('draw?file=' + encodeURIComponent(r.file), { method: 'POST', body: JSON.stringify(drawn) });+ const take = { ...meta, ...r, strokes: drawn, n: takes.length + 1 }; takes.push(take); renderTakes(); header();+ finishCard(take); setMode('idle'); say(`Take ${take.n} saved (${fmt(take.seconds)})`);+ } catch (e) { modal(`<h2>The take could not be saved</h2><p>${esc(e.message)}. It is still in this page: do not close the tab, and tell your AI.</p>`); setMode('idle'); say('Save failed: ' + e.message); }+ }+ function quality(tk) {+ if (tk.peak > 0.985) return ['warn', 'It got very loud in places. The limiter kept it from distorting, but another take a little further from the microphone may sound better.'];+ if (tk.peak < 0.1) return ['bad', 'It is very quiet. Check the right microphone is picked and calibrate before the next take.'];+ return ['good', 'The level is good.'];+ }+ function finishCard(tk) {+ const [cls, q] = quality(tk);+ modal(`<h2>Recording complete</h2>+ <table><tr><td>Take ${tk.n}</td><td><b>${fmt(tk.seconds)}</b> of your voice${tk.whole ? ', the whole film' : `, stopped at ${fmt(tk.videoStoppedAtS)} of ${fmt(tk.videoDurationS)}`}</td></tr>+ <tr><td>Level</td><td><span class="${cls}">${cls === 'good' ? 'Good' : cls === 'warn' ? 'Loud' : 'Quiet'}.</span> ${q}</td></tr>+ <tr><td>Drawings</td><td>${tk.strokes.length ? tk.strokes.length + ' saved with the take; they will be drawn into the final film' : 'none'}</td></tr>+ <tr><td>Saved</td><td>safely, in your project. Your AI can already see it.</td></tr></table>+ <div class="next"><b>What happens next.</b> Review it with the film if you like. When you press <b>Use this take</b>, your AI puts your voice on the film, removes the on-screen lines, writes new captions from what you said, and shows you the result. Nothing is published until you say so.</div>+ <div class="row"><button class="btn lg" id="f-use">Use this take</button><button class="btn ghost lg" id="f-rev">Review with the film</button><button class="btn ghost lg" id="f-again">Record again</button><button class="btn ghost lg" id="f-close">Close</button></div>`);+ $('f-use').onclick = () => choose(tk); $('f-rev').onclick = () => { closeModal(); review(tk); };+ $('f-again').onclick = () => { closeModal(); record(); }; $('f-close').onclick = closeModal;+ toast(`Take ${tk.n} saved`, 'success');+ }+ async function choose(tk) {+ const r = await api('choose', { file: tk.file }).catch((e) => ({ error: e.message }));+ if (r.error) return toast('Could not tell your AI: ' + r.error, 'error');+ chosen = tk; renderTakes(); steps(); say(`Take ${tk.n} sent to the AI`);+ modal(`<h2>Sent to your AI</h2><p>Take ${tk.n} is with your AI now. It will put your voice on the film, write new captions and show you the result. Watch the <b>From your AI</b> box on the right: it will tell you what it is doing.</p>+ <div class="row"><button class="btn lg" id="s-ok">OK</button><button class="btn ghost lg" id="s-rev">Review it meanwhile</button></div>`);+ $('s-ok').onclick = closeModal; $('s-rev').onclick = () => { closeModal(); review(tk); };+ toast(`Take ${tk.n} sent to your AI`, 'success');+ }++ // ---------------- review: the film with your voice on it ----------------+ function review(tk) {+ if (mode !== 'idle') stop();+ reviewTake = tk; reviewAudio = new Audio('takes/' + tk.file); reviewAudio.preload = 'auto';+ const off = (tk.videoStartsAtMs || 0) / 1000;+ const sync = () => { if (!reviewAudio) return; const want = v().currentTime + off; if (Math.abs(reviewAudio.currentTime - want) > 0.25) reviewAudio.currentTime = want; };+ v().muted = true; p.seekTo(0); capsOn();+ v().onplay = () => { sync(); reviewAudio && reviewAudio.play().catch(() => {}); }; v().onpause = () => reviewAudio && reviewAudio.pause(); v().onseeked = sync; v().ontimeupdate = sync;+ v().onended = () => mode === 'review' && endReview(true);+ setMode('review', `Reviewing take ${tk.n}`); $('recbadge-text').textContent = `Take ${tk.n}: your voice with the film`;+ say(`Reviewing take ${tk.n} with the film`); v().play().catch(() => {});+ toast(`Reviewing take ${tk.n}. Use the player to pause or jump around; Stop ends the review.`, 'info');+ }+ function endReview(done) {+ v().pause(); if (reviewAudio) { reviewAudio.pause(); reviewAudio = null; }+ v().onplay = v().onpause = v().onseeked = v().ontimeupdate = v().onended = null;+ const tk = reviewTake; reviewTake = null; setMode('idle'); say('Review ended');+ if (done && tk && !chosen) finishCard(tk);+ }+ function renderTakes() {+ $('takes').innerHTML = takes.length ? '' : '<li class="empty">Your recordings will be listed here.</li>';+ for (const tk of takes) {+ const li = document.createElement('li'); if (chosen && chosen.file === tk.file) li.className = 'chosen';+ li.innerHTML = `<div class="head"><span>Take ${tk.n}</span><span class="mono">${fmt(tk.seconds)}</span></div><div class="sub">${chosen && chosen.file === tk.file ? 'Sent to your AI. ' : ''}${tk.whole === false ? 'Stopped early. ' : ''}${(tk.strokes || []).length ? (tk.strokes.length + ' drawings. ') : ''}Peak ${(dB(tk.peak || 0)).toFixed(0)} dB</div>+ <div class="acts"><button class="btn ghost" data-a="rev" data-tooltip="Play the film with this take's voice on it">Review</button><button class="btn" data-a="use" data-tooltip="Send this take to your AI. It puts your voice on the film and writes new captions.">Use this take</button></div>`;+ li.querySelector('[data-a=rev]').onclick = () => review(tk); li.querySelector('[data-a=use]').onclick = () => choose(tk);+ $('takes').appendChild(li);+ }+ steps();+ }++ // ---------------- the live channel: the AI drives this page ----------------+ const act = async (id, label) => { const el = await glide('#' + id, label); if (el && !el.disabled) el.click(); setTimeout(() => document.querySelector('.ai-cursor')?.classList.remove('on'), 900); };+ function setNote(text) { note = text || ''; $('ainote').textContent = note || 'Nothing yet. Your AI can see this page and will tell you here what it is doing with your recording.'; $('ainote').classList.toggle('live', !!note); }+ const es = connect({+ state: (d) => { if (d.note !== undefined) setNote(d.note); if (d.settings) { S = { ...S, ...d.settings }; $('fade-s').textContent = S.drawFade; } },+ ui: (d) => uiCommon(d),+ record: (d) => act('rec', (d.by || 'AI') + ': start recording'), rehearse: (d) => act('rehearse', (d.by || 'AI') + ': rehearse'), stop: (d) => act('stop', (d.by || 'AI') + ': stop'),+ test: (d) => act('test', (d.by || 'AI') + ': test the microphone'), calibrate: (d) => act('cal', (d.by || 'AI') + ': calibrate'),+ gain: async (d) => { await glide('#boost', (d.by || 'AI') + ': boost ' + d.value + 'x'); setBoost(d.value); toast(`Your AI set the microphone boost to ${boost.toFixed(1)}x`, 'info'); },+ mic: async (d) => { const o = [...$('mic').options].find((o) => o.textContent.toLowerCase().includes(String(d.name || '').toLowerCase())); if (!o) return toast('No microphone matching "' + d.name + '"', 'error'); await glide('#mic', (d.by || 'AI') + ': pick ' + o.textContent); $('mic').value = o.value; $('mic').onchange(); },+ seek: (d) => p.seekTo(Math.max(0, +d.seconds || 0)), play: () => v().play(), pause: () => v().pause(),+ review: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? review(tk) : toast('No such take', 'error'); },+ use: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? choose(tk) : toast('No such take', 'error'); },+ wipe: () => wipe(), note: (d) => { setNote(d.text); if (d.text) toast('Your AI: ' + d.text, 'info'); },+ });+ es.onopen = () => $('led').className = 'led ok'; es.addEventListener('error', () => $('led').className = 'led bad');++ // ---------------- start ----------------+ (async () => {+ const st = await api('state').catch(() => ({}));+ info = st; S = { ...S, ...(st.settings || {}) }; $('fade-s').textContent = S.drawFade; setNote(st.note || '');+ lines = (await fetch('captions').then((r) => r.json()).catch(() => ({ lines: [] }))).lines || [];+ await p.adomDrive.ready(); await wait(300);+ takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));+ if (st.chosen) chosen = takes.find((t) => t.file === st.chosen) || null;+ segs(); header(); renderTakes(); setBoost(boost, true); setMode('idle'); mics(); tick();+ navigator.mediaDevices.addEventListener('devicechange', mics);+ v().addEventListener('ended', () => { if (mode === 'record' && rec && rec.state !== 'inactive') rec.stop(); else if (mode === 'rehearse') { setMode('idle'); say('Rehearsal finished'); toast('That was the whole film. Press Record when you are ready.', 'success'); } });+ v().addEventListener('durationchange', () => { segs(); header(); });+ say('Ready');+ })();+})();
assets/vendor/VENDORED.mdadded+9@@ -0,0 +1,9 @@+# Vendored, unchanged++These files are copied without edits so the narration page looks and behaves like every other Adom app. Do not edit them here; copy them again when the source changes.++| File | From |+|---|---|+| adom-video.js | adom/adom-video-player 0.4.2 (public/adom-video.js), which is adom/widgets widgets/video-widget.js: the `::adom-video` player |+| ui.js, icons.js, base.css, fonts/ | adom/adom-video-player 0.4.2 (public/): tooltips, toasts, header popover, settings dialog, AI cursor, live channel, brand fonts |+| settings-cog-outline.svg | adom/adom-ui-design assets/icons: the one Adom settings cog |
assets/vendor/adom-video.jsadded+842@@ -0,0 +1,842 @@+// <adom-video>: a YouTube-style video player for wiki pages.+//+// ::adom-video{video="/OWNER/SLUG/render/demo.mp4" title="Demo" chapters="/OWNER/SLUG/render/demo.chapters.json"}+//+// Every YouTube keyboard shortcut, a timeline you can actually hit (a 24px grab zone around a+// 4px bar, drag to scrub, hover for a frame preview and the time), chapters as bar segments,+// captions (WebVTT or SRT, drawn by the player so + / - can resize them), speed and loop in a+// settings menu, picture-in-picture, fullscreen. Self-contained: Shadow DOM, no dependencies.+//+// Attributes: video (required; never pass a video as `src`: the wiki inlines a registered tag's `src`+// file into the frame), src (optional JSON manifest {video, title, poster, chapters, captions}, which+// the wiki inlines, so chapters and captions work on private pages too), title, poster, chapters (JSON [{t,title}] or a WebVTT chapter+// file), captions (.vtt or .srt), start (seconds or 1m30s), loop, muted, autoplay.+//+// Inside a wiki widget frame the browser blocks fullscreen (the frame carries no allow=+// "fullscreen"), so F opens the same player full-window in a new tab at the current time,+// where F is real fullscreen. Keys reach the player once it is clicked, as on YouTube.+(() => {+ if (customElements.get('adom-video')) return;++ const ICON = {+ play: '<path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14z"/>',+ pause: '<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/>',+ replay: '<path d="M12 5V2L7 6l5 4V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8z"/>',+ vol: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/>',+ vollow: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/>',+ mute: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z"/>',+ cc: '<path d="M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z"/>',+ gear: '<path d="M19.4 13a7.7 7.7 0 0 0 0-2l2.1-1.6a.5.5 0 0 0 .1-.6l-2-3.5a.5.5 0 0 0-.6-.2l-2.5 1a7.4 7.4 0 0 0-1.7-1l-.4-2.6A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.4l-.4 2.7a7.6 7.6 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.4a.5.5 0 0 0 .1.7L4.6 11a7.7 7.7 0 0 0 0 2l-2.1 1.6a.5.5 0 0 0-.1.6l2 3.5c.1.2.4.3.6.2l2.5-1c.5.4 1.1.7 1.7 1l.4 2.6c0 .3.2.5.5.5h4c.3 0 .5-.2.5-.4l.4-2.7c.6-.3 1.2-.6 1.7-1l2.5 1c.2.1.5 0 .6-.2l2-3.5a.5.5 0 0 0-.1-.6L19.4 13zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"/>',+ pip: '<path d="M19 11h-8v6h8v-6zm4 8V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 0H3V5h18v14z"/>',+ full: '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>',+ exitfull: '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>',+ popout: '<path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.6l-9.8 9.8 1.4 1.4L19 6.4V10h2V3h-7z"/>',+ back: '<path d="M20 18V6l-8.5 6 8.5 6zm-8.5 0V6L3 12l8.5 6z"/>',+ fwd: '<path d="M4 6v12l8.5-6L4 6zm8.5 0v12l8.5-6-8.5-6z"/>',+ fstepf: '<path d="M6 5.5v13l9-6.5-9-6.5zM16 5h2.5v14H16z"/>',+ fstepb: '<path d="M18 5.5v13L9 12l9-6.5zM5.5 5H8v14H5.5z"/>',+ chev: '<path d="M9.3 6.7 10.7 5.3 17.4 12l-6.7 6.7-1.4-1.4 5.3-5.3z"/>',+ chevl: '<path d="M14.7 6.7 13.3 5.3 6.6 12l6.7 6.7 1.4-1.4L9.4 12z"/>',+ check: '<path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/>',+ keys: '<path d="M20 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/>',+ loop: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',+ speed: '<path d="M20.4 8.6 19.2 10.4a8 8 0 0 1-.2 7.6H5a8 8 0 0 1 10.6-11l1.8-1.2A10 10 0 0 0 3.3 19a2 2 0 0 0 1.7 1h14a2 2 0 0 0 1.7-1 10 10 0 0 0-.3-10.4zm-9.8 6.8a2 2 0 0 0 2.8 0l5.7-8.5-8.5 5.7a2 2 0 0 0 0 2.8z"/>',+ dl: '<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>',+ };+ const svg = (n, s = 24) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" aria-hidden="true">${ICON[n]}</svg>`;+ const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4];+ const CAP_TEXT = [100, 75, 50, 25], CAP_WIN = [75, 50, 25, 0, 100];+ const clamp = (x, a, b) => Math.min(b, Math.max(a, x));+ const fmt = (t) => {+ if (!isFinite(t) || t < 0) t = 0;+ t = Math.floor(t);+ const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = String(t % 60).padStart(2, '0');+ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;+ };+ const fmtMs = (t) => { if (!isFinite(t) || t < 0) t = 0; const s = (t % 60).toFixed(3).padStart(6, '0'), m = Math.floor(t / 60) % 60, h = Math.floor(t / 3600); return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`; };+ // "90", "1:30", "1m30s", "1h2m3s" -> seconds+ const parseT = (v) => {+ if (v == null || v === '') return 0;+ v = String(v).trim();+ if (/^\d+(\.\d+)?$/.test(v)) return Number(v);+ if (v.includes(':')) return v.split(':').reduce((a, x) => a * 60 + Number(x || 0), 0);+ const m = v.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/);+ return m ? (Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0)) : 0;+ };+ const vttTime = (s) => { const p = s.trim().replace(',', '.').split(':').map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1]; };+ // WebVTT or SRT -> [{start, end, text}]+ function parseCues(txt) {+ const out = [];+ txt.replace(/\r/g, '').split(/\n\n+/).forEach((block) => {+ const lines = block.split('\n').filter((l) => l.trim() !== '');+ const k = lines.findIndex((l) => l.includes('-->'));+ if (k < 0) return;+ const [a, b] = lines[k].split('-->');+ out.push({ start: vttTime(a), end: vttTime(b.trim().split(/\s+/)[0]), text: lines.slice(k + 1).join('\n') });+ });+ return out;+ }+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));+ // caption markup: keep <b> <i> <u>, drop the rest (voice tags, classes, timestamps)+ const capHTML = (s) => esc(s).replace(/<(\/?)(b|i|u)>/g, '<$1$2>').replace(/<[^&]*?>/g, '').replace(/\n/g, '<br>');++ const SHORTCUTS = [+ ['Play / pause', 'Space or K'], ['Back 10 s / forward 10 s', 'J / L'], ['Back 5 s / forward 5 s', '← / →'],+ ['Volume up / down', '↑ / ↓'], ['Mute', 'M'], ['Jump to 0% to 90%', '0 to 9'], ['Start / end', 'Home / End'],+ ['Previous / next frame (pauses)', ', / .'], ['Slower / faster (0.25x to 4x)', '< / >'], ['2x while held', 'Hold the mouse on the video'],+ ['Previous / next chapter', 'Ctrl + ← / →'], ['Previous / next video on the page', 'Shift + P / Shift + N'],+ ['Captions on / off', 'C'], ['Caption size', '+ / −'], ['Caption text opacity', 'O'], ['Caption background opacity', 'W'],+ ['Fullscreen', 'F or double-click'], ['Open large in a new tab', 'T'], ['Picture-in-picture', 'I'], ['Close menu / exit', 'Esc'],+ ['Loop', 'R'], ['Media keys', 'Play/Pause, Stop, Next'], ['This list', '?'],+ ];++ const CSS = `+ :host{display:block;max-width:100%;--accent:#00b8b1;--accent-hi:#00e6dc;color-scheme:dark;font:400 13px/1.3 "Inter","Roboto","Segoe UI",system-ui,sans-serif}+ *{box-sizing:border-box}+ button{font:inherit;color:inherit;background:none;border:0;padding:0;margin:0;cursor:pointer}+ .p{position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:12px;overflow:hidden;color:#fff;outline:none;user-select:none;-webkit-user-select:none;container-type:inline-size}+ .p:focus-visible{box-shadow:0 0 0 2px var(--accent)}+ .p.fs,.p.page{border-radius:0;aspect-ratio:auto;width:100%;height:100%}+ :host(.page){position:fixed;inset:0;max-width:none}+ .p.hide-ui{cursor:none}+ video.main{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;display:block}+ .click{position:absolute;inset:0}+ /* bottom chrome */+ .grad{position:absolute;left:0;right:0;bottom:0;height:120px;background:linear-gradient(transparent,rgba(0,0,0,.72));pointer-events:none;transition:opacity .25s}+ .top{position:absolute;left:0;right:0;top:0;padding:14px 16px 40px;background:linear-gradient(rgba(0,0,0,.6),transparent);font-size:17px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;transition:opacity .25s}+ .top:empty{display:none}+ .chrome{position:absolute;left:0;right:0;bottom:0;padding:0 12px 4px;transition:opacity .25s;z-index:3}+ .hide-ui .chrome,.hide-ui .grad,.hide-ui .top{opacity:0;pointer-events:none}+ .hide-ui .chrome *{pointer-events:none}+ /* the timeline: a 24px grab zone around a 4px bar */+ .bar{position:relative;height:24px;margin:0 0 2px;cursor:pointer;touch-action:none;outline:none}+ .segs{position:absolute;left:0;right:0;bottom:8px;height:4px;display:flex;gap:2px;transition:height .1s,bottom .1s}+ .bar:hover .segs,.bar.drag .segs,.bar:focus-visible .segs{height:6px;bottom:7px}+ .seg{position:relative;height:100%;background:rgba(255,255,255,.22);overflow:hidden;transition:transform .1s}+ .seg.hov{transform:scaleY(1.6)}+ .seg i{position:absolute;left:0;top:0;bottom:0;width:0}+ .seg .b{background:rgba(255,255,255,.42)}+ .seg .h{background:rgba(255,255,255,.5)}+ .seg .f{background:var(--accent)}+ .bar:not(:hover):not(.drag) .seg .h{width:0!important}+ .knob{position:absolute;bottom:11px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--accent);transform:translateY(50%) scale(0);transition:transform .1s;pointer-events:none}+ .bar:hover .knob,.bar.drag .knob,.bar:focus-visible .knob{transform:translateY(50%) scale(1)}+ .tip{position:absolute;bottom:30px;transform:translateX(-50%);display:none;flex-direction:column;align-items:center;gap:4px;pointer-events:none;text-shadow:0 0 3px rgba(0,0,0,.9)}+ .bar:hover .tip,.bar.drag .tip{display:flex}+ .tip .pv{display:none;width:170px;border:2px solid #fff;border-radius:8px;background:#000;overflow:hidden}+ .tip .pv.on{display:block}+ .tip .pv video{display:block;width:100%;height:100%;object-fit:cover}+ .tip .ch{font-size:12px;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .tip .ch:empty{display:none}+ .tip .t{font-size:13px;font-weight:600;background:rgba(0,0,0,.6);padding:2px 6px;border-radius:4px}+ /* control row */+ .row{display:flex;align-items:center;height:44px;gap:2px}+ .row .sp{flex:1;min-width:4px}+ .b48{width:44px;height:40px;display:inline-flex;align-items:center;justify-content:center;border-radius:20px;opacity:.95;flex:none;position:relative}+ .b48:hover{opacity:1;background:rgba(255,255,255,.1)}+ .b48 svg{fill:#fff;filter:drop-shadow(0 0 2px rgba(0,0,0,.5))}+ .b48.on::after{content:"";position:absolute;left:12px;right:12px;bottom:6px;height:3px;border-radius:2px;background:var(--accent)}+ .b48[hidden]{display:none}+ .vol{display:flex;align-items:center}+ .vslide{width:0;height:40px;position:relative;overflow:hidden;transition:width .2s;cursor:pointer;touch-action:none;outline:none}+ .vol:hover .vslide,.vol:focus-within .vslide,.vslide.drag{width:64px;margin-right:6px}+ .vslide .rail{position:absolute;left:6px;right:6px;top:50%;height:3px;margin-top:-1.5px;background:rgba(255,255,255,.3);border-radius:2px}+ .vslide .lvl{position:absolute;left:0;top:0;bottom:0;background:#fff;border-radius:2px}+ .vslide .dot{position:absolute;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff}+ .time{padding:0 8px;white-space:nowrap;font-variant-numeric:tabular-nums;text-shadow:0 0 2px rgba(0,0,0,.5);flex:none}+ .chap{display:flex;align-items:center;min-width:0;padding:0 4px 0 2px;opacity:.92;white-space:nowrap;overflow:hidden;cursor:pointer;border-radius:16px;height:32px}+ .chap:hover{opacity:1;background:rgba(255,255,255,.1)}+ .chap span{overflow:hidden;text-overflow:ellipsis}+ .chap svg{fill:#fff;flex:none}+ .chap:empty,.chap[hidden]{display:none}+ .dotsep{opacity:.7;padding:0 4px}+ /* button tooltips */+ .tt{position:absolute;bottom:62px;padding:5px 9px;background:rgba(28,28,28,.92);border-radius:6px;font-size:12px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;z-index:5}+ .tt.on{opacity:1}+ /* center overlays */+ .bezel{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none}+ .bezel svg{fill:#fff}+ .bezel.go{animation:bz .5s linear}+ @keyframes bz{0%{opacity:1;transform:scale(.9)}100%{opacity:0;transform:scale(1.6)}}+ .hud{position:absolute;left:50%;top:12%;transform:translateX(-50%);padding:8px 14px;background:rgba(0,0,0,.6);border-radius:6px;font-size:15px;font-weight:500;opacity:0;pointer-events:none;transition:opacity .25s;white-space:nowrap}+ .hud.on{opacity:1;transition:none}+ .hold{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:none;align-items:center;gap:6px;padding:6px 14px;background:rgba(0,0,0,.6);border-radius:16px;font-size:14px;font-weight:600;pointer-events:none;z-index:4}+ .hold.on{display:flex}+ .fstep{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:6px 14px 6px 10px;background:rgba(0,0,0,.66);border-radius:16px;font-size:14px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;z-index:4;opacity:0;transition:opacity .3s}+ .fstep.on{opacity:1;transition:none}+ .fstep svg{fill:#fff;flex:none}+ .fstep b{font-weight:700}+ .fstep .dim{opacity:.7}+ .fstep.go svg{animation:fs .18s ease-out}+ @keyframes fs{0%{transform:scale(1.35)}100%{transform:scale(1)}}+ .hold svg{fill:#fff}+ .seekfx{position:absolute;top:0;bottom:0;width:30%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;background:rgba(255,255,255,.08);opacity:0;pointer-events:none;font-weight:500;transition:opacity .4s}+ .seekfx.l{left:0;border-radius:0 50% 50% 0/0 50% 50% 0}+ .seekfx.r{right:0;border-radius:50% 0 0 50%/50% 0 0 50%}+ .seekfx.on{opacity:1;transition:none}+ .seekfx svg{fill:#fff}+ .big{position:absolute;left:50%;top:50%;width:72px;height:50px;margin:-25px 0 0 -36px;border-radius:14px;background:rgba(15,15,15,.78);display:flex;align-items:center;justify-content:center;transition:background .15s}+ .big svg{fill:#fff}+ .big:hover,.p:hover .big{background:var(--accent)}+ .big[hidden]{display:none}+ .spin{position:absolute;left:50%;top:50%;width:52px;height:52px;margin:-26px 0 0 -26px;border:4px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:sp 0.9s linear infinite;display:none;pointer-events:none}+ .p.wait .spin{display:block}+ @keyframes sp{to{transform:rotate(360deg)}}+ .err{position:absolute;inset:0;display:none;align-items:center;justify-content:center;text-align:center;padding:24px;background:#111;font-size:14px;line-height:1.5}+ .err.on{display:flex}+ .err code{background:rgba(255,255,255,.1);padding:1px 5px;border-radius:4px;word-break:break-all}+ /* captions */+ .caps{position:absolute;left:5%;right:5%;bottom:5%;display:flex;flex-wrap:wrap;justify-content:center;pointer-events:none;transition:bottom .25s;z-index:2}+ .p:not(.hide-ui) .caps{bottom:76px}+ .caps span{background:rgba(8,8,8,var(--capbg,.75));opacity:var(--captxt,1);padding:2px 8px;border-radius:3px;text-align:center;font-size:calc(var(--cap,100) * clamp(13px, 3.4cqw, 30px) / 100);line-height:1.35;box-decoration-break:clone;-webkit-box-decoration-break:clone}+ .caps:empty{display:none}+ /* settings menu */+ .menu{position:absolute;right:12px;bottom:60px;min-width:250px;max-height:calc(100% - 72px);overflow:auto;background:rgba(28,28,28,.95);border-radius:12px;padding:8px 0;display:none;z-index:6;text-shadow:none}+ .menu.on{display:block}+ .mi{display:flex;align-items:center;width:100%;height:40px;padding:0 12px 0 14px;gap:14px;text-align:left}+ .mi:hover,.mi:focus-visible{background:rgba(255,255,255,.1);outline:none}+ .mi svg{fill:#fff;flex:none}+ .mi .lab{flex:1}+ .mi .val{opacity:.8}+ .mi .ck{width:24px;display:inline-flex}+ .mhead{display:flex;align-items:center;gap:10px;height:44px;padding:0 12px;border-bottom:1px solid rgba(255,255,255,.15);margin-bottom:6px;font-weight:500;width:100%;text-align:left}+ .mhead svg{fill:#fff}+ .tog{width:36px;height:14px;border-radius:7px;background:rgba(255,255,255,.3);position:relative;flex:none}+ .tog::after{content:"";position:absolute;top:-3px;left:-2px;width:20px;height:20px;border-radius:50%;background:#bbb;transition:left .15s,background .15s}+ .tog.on{background:color-mix(in srgb,var(--accent) 60%,transparent)}+ .tog.on::after{left:18px;background:#fff}+ /* shortcuts sheet */+ .help{position:absolute;inset:0;background:rgba(0,0,0,.82);display:none;z-index:7;overflow:auto;padding:18px 22px}+ .help.on{display:block}+ .help h3{margin:0 0 10px;font-size:16px;font-weight:600;display:flex;justify-content:space-between;align-items:center}+ .help table{border-collapse:collapse;width:100%;font-size:13px}+ .help td{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.08)}+ .help td:last-child{text-align:right;white-space:nowrap;padding-left:12px}+ .help kbd{font:inherit;background:rgba(255,255,255,.14);padding:1px 6px;border-radius:4px}+ .help .x{font-size:20px;width:32px;height:32px;border-radius:16px}+ .help .x:hover{background:rgba(255,255,255,.12)}+ .help .cols{columns:2 260px;column-gap:28px}+ @container (max-width: 480px){ .chap,.dotsep,.b48.opt{display:none} .time{padding:0 4px} }+ @container (max-width: 340px){ .vol .vslide{display:none} }+ `;++ // one player owns the keyboard at a time: the one last clicked or focused (or the first on the page)+ let active = null;++ class AdomVideo extends HTMLElement {+ static get observedAttributes() { return ['video', 'src', 'title', 'poster', 'chapters', 'captions', 'start', 'loop', 'muted']; }++ constructor() {+ super();+ this.chapters = []; this.cues = []; this.capOn = false; this.capSize = 100;+ this.started = false; this.drag = null; this._t = {};+ const r = this.attachShadow({ mode: 'open' });+ r.innerHTML = `<style>${CSS}</style>+ <div class="p" tabindex="0" role="region" aria-label="Video player">+ <video class="main" playsinline preload="metadata"></video>+ <div class="click"></div>+ <div class="caps" aria-live="polite"></div>+ <div class="top"></div>+ <div class="seekfx l">${svg('back', 32)}<span></span></div>+ <div class="seekfx r">${svg('fwd', 32)}<span></span></div>+ <div class="bezel"></div>+ <div class="hud"></div>+ <div class="fstep" aria-live="polite"></div>+ <div class="hold">2x ${svg('fwd', 18)}</div>+ <div class="spin"></div>+ <button class="big" aria-label="Play (k)">${svg('play', 34)}</button>+ <div class="grad"></div>+ <div class="chrome">+ <div class="bar" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">+ <div class="segs"></div><div class="knob"></div>+ <div class="tip"><div class="pv"></div><div class="ch"></div><div class="t"></div></div>+ </div>+ <div class="row">+ <button class="b48" data-k="play" data-tip="Play (k)">${svg('play')}</button>+ <div class="vol">+ <button class="b48" data-k="mute" data-tip="Mute (m)">${svg('vol')}</button>+ <div class="vslide" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100"><div class="rail"><div class="lvl"></div><div class="dot"></div></div></div>+ </div>+ <div class="time">0:00 / 0:00</div>+ <span class="dotsep" hidden>•</span>+ <button class="chap" hidden data-tip="Next chapter (Ctrl + →)"><span></span>${svg('chev', 18)}</button>+ <div class="sp"></div>+ <button class="b48" data-k="cc" data-tip="Subtitles/closed captions (c)" hidden>${svg('cc')}</button>+ <button class="b48" data-k="gear" data-tip="Settings">${svg('gear')}</button>+ <button class="b48 opt" data-k="pip" data-tip="Picture-in-picture (i)">${svg('pip')}</button>+ <button class="b48 opt" data-k="pop" data-tip="Open large in a new tab (t)">${svg('popout')}</button>+ <button class="b48" data-k="fs" data-tip="Full screen (f)">${svg('full')}</button>+ </div>+ </div>+ <div class="tt"></div>+ <div class="menu" role="menu"></div>+ <div class="help" role="dialog" aria-label="Keyboard shortcuts"></div>+ <div class="err"></div>+ </div>`;+ const $ = (s) => r.querySelector(s);+ this.$ = $;+ this.p = $('.p'); this.v = $('video.main'); this.bar = $('.bar'); this.segs = $('.segs');+ this._wire();+ }++ connectedCallback() {+ if (!active) active = this;+ if (location.pathname.includes('/widget/')) {+ if (window.top === window) { this.classList.add('page'); this.p.classList.add('page'); }+ // the frame grows to fit the player; a scrollbar would narrow it, shrink the player and stick there+ else document.documentElement.style.overflow = 'hidden';+ }+ // the wiki inlines the manifest as a child script, which is not parsed yet when a head script connects us+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this._load(), { once: true });+ else this._load();+ }+ disconnectedCallback() { if (active === this) active = null; clearTimeout(this._t.hide); this.v.pause(); }+ attributeChangedCallback(n, a, b) { if (a !== b && this.isConnected && this._loaded) { if (n === 'video' || n === 'src') this._load(); else if (n === 'chapters') this._chapters(); else if (n === 'captions') this._captions(); else if (n === 'title') this.$('.top').textContent = b || ''; } }++ // absolute URL for a wiki path (the frame is opaque-origin, so resolve against the real location)+ _abs(u) { try { return new URL(u, location.href).href; } catch { return u; } }+ // the page render route answers a video's Range requests with the whole file (200), which makes it+ // unseekable; the files API answers 206, so stream /OWNER/SLUG/render/PATH from there+ _media(u) {+ const a = this._abs(u);+ try {+ const url = new URL(a);+ if (url.hostname !== 'wiki.adom.inc' && url.origin !== location.origin) return a;+ const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/render\/(.+)$/);+ if (m) url.pathname = `/api/pages/${m[1]}/${m[2]}/files/${m[3]}`;+ return url.href;+ } catch { return a; }+ }++ // a JSON manifest (the wiki inlines a registered tag's `src` file as a child script):+ // { "video": "...", "title", "poster", "chapters": [{t,title}] or a path, "captions": a path or WebVTT text }+ _manifest() {+ if (this._man !== undefined) return this._man;+ this._man = null;+ const el = this.querySelector('script[type="application/json"]');+ if (el) { try { const j = JSON.parse(el.textContent); if (j && typeof j === 'object' && !Array.isArray(j)) this._man = j; } catch {} }+ return this._man;+ }+ _opt(n) { const a = this.getAttribute(n); if (a != null && a !== '') return a; const m = this._manifest(); return m && m[n] != null ? m[n] : null; }++ _load() {+ this._loaded = true; this._man = undefined;+ const m = this._manifest();+ const src = this.getAttribute('video') || (m && m.video) || (m ? null : this.getAttribute('src'));+ const v = this.v;+ this.$('.top').textContent = this._opt('title') || '';+ if (!src) return this._error('No video: add <code>video="/OWNER/SLUG/render/video.mp4"</code> to the tag.');+ this.$('.err').classList.remove('on');+ if (this._opt('poster')) v.poster = this._abs(this._opt('poster'));+ v.loop = this.hasAttribute('loop');+ v.muted = this.hasAttribute('muted');+ this._startAt = parseT(this.getAttribute('start') || (location.hash.match(/[#&]t=([^&]+)/) || [])[1]);+ v.src = this._media(src);+ v.addEventListener('loadedmetadata', () => {+ if (v.videoWidth && !this.p.classList.contains('page')) this.p.style.aspectRatio = `${v.videoWidth} / ${v.videoHeight}`;+ if (this._startAt > 0 && this._startAt < v.duration) { v.currentTime = this._startAt; this._started(); }+ this._segments(); this._tick();+ }, { once: true });+ if (this.hasAttribute('autoplay')) { v.muted = true; v.play().then(() => this._started(), () => {}); }+ this._chapters(); this._captions(); this._volUI();+ }++ _error(html) { const e = this.$('.err'); e.innerHTML = `<div>${html}</div>`; e.classList.add('on'); this.p.classList.remove('wait'); }++ async _fetchText(u) {+ const r = await fetch(this._abs(u)).catch(() => null);+ return r && r.ok ? r.text() : null;+ }+ async _chapters() {+ const u = this._opt('chapters'); this.chapters = [];+ if (u) {+ const txt = typeof u === 'string' ? await this._fetchText(u) : JSON.stringify(u);+ if (txt) {+ try { const j = JSON.parse(txt); this.chapters = (Array.isArray(j) ? j : j.chapters || []).map((c) => ({ t: parseT(c.t ?? c.time ?? c.start), title: String(c.title ?? c.name ?? '') })); }+ catch { this.chapters = parseCues(txt).map((c) => ({ t: c.start, title: c.text.replace(/\n/g, ' ') })); }+ this.chapters.sort((a, b) => a.t - b.t);+ if (this.chapters.length && this.chapters[0].t > 0) this.chapters.unshift({ t: 0, title: '' });+ }+ }+ this._segments(); this._tick();+ }+ async _captions() {+ const u = this._opt('captions'); this.cues = [];+ if (u) { const txt = /-->/.test(u) ? u : await this._fetchText(u); if (txt) this.cues = parseCues(txt); }+ this.$('[data-k=cc]').hidden = !this.cues.length;+ this.capOn = this.cues.length > 0 && this.capOn;+ this._caps(); this._sync();+ }++ // ---------- timeline ----------+ _segments() {+ const d = this.v.duration;+ const ch = d && this.chapters.length > 1 ? this.chapters : [{ t: 0, title: '' }];+ this.segs.innerHTML = '';+ this._seg = ch.map((c, k) => {+ const end = k + 1 < ch.length ? ch[k + 1].t : (d || 1);+ const el = document.createElement('div'); el.className = 'seg';+ el.style.flex = `${Math.max(0.0001, end - c.t)} 1 0`;+ el.innerHTML = '<i class="b"></i><i class="h"></i><i class="f"></i>';+ this.segs.appendChild(el);+ return { el, t0: c.t, t1: end, title: c.title, b: el.children[0], h: el.children[1], f: el.children[2] };+ });+ }+ _fill(key, t) {+ (this._seg || []).forEach((s) => { s[key].style.width = (100 * clamp((t - s.t0) / Math.max(1e-6, s.t1 - s.t0), 0, 1)) + '%'; });+ }+ // fraction across the bar for a pointer x, mapped through the segment gaps so a click lands where it looks+ _frac(x) {+ const r = this.segs.getBoundingClientRect();+ const d = this.v.duration || 0;+ if (!this._seg || this._seg.length < 2 || !d) return clamp((x - r.left) / r.width, 0, 1);+ for (const s of this._seg) {+ const b = s.el.getBoundingClientRect();+ if (x <= b.right + 1) return clamp((s.t0 + clamp((x - b.left) / b.width, 0, 1) * (s.t1 - s.t0)) / d, 0, 1);+ }+ return 1;+ }+ _xOf(t) {+ const r = this.segs.getBoundingClientRect(), br = this.bar.getBoundingClientRect();+ const d = this.v.duration || 0;+ if (!d) return 0;+ const s = (this._seg || []).find((s) => t < s.t1) || (this._seg || [])[this._seg.length - 1];+ if (!s || this._seg.length < 2) return (t / d) * r.width + r.left - br.left;+ const b = s.el.getBoundingClientRect();+ return b.left - br.left + clamp((t - s.t0) / (s.t1 - s.t0), 0, 1) * b.width;+ }+ _chapterAt(t) { let c = null; for (const x of this.chapters) if (t + 0.05 >= x.t) c = x; return c; }++ _hover(x) {+ const d = this.v.duration; if (!d) return;+ const t = this._frac(x) * d;+ this._fill('h', t);+ (this._seg || []).forEach((s) => s.el.classList.toggle('hov', this._seg.length > 1 && t >= s.t0 && t < s.t1));+ const tip = this.$('.tip'), br = this.bar.getBoundingClientRect();+ tip.querySelector('.t').textContent = fmt(t);+ const c = this.chapters.length > 1 ? this._chapterAt(t) : null;+ tip.querySelector('.ch').textContent = c ? c.title : '';+ this._preview(t);+ const w = tip.offsetWidth || 60;+ tip.style.left = clamp(x - br.left, w / 2, br.width - w / 2) + 'px';+ }+ // a second, muted video seeks to the hovered time and shows that frame (lazy: made on first hover)+ _preview(t) {+ const box = this.$('.pv');+ if (!this._pv) {+ if (this._pvFailed) return;+ const pv = document.createElement('video');+ pv.muted = true; pv.preload = 'auto'; pv.playsInline = true; pv.src = this.v.currentSrc || this.v.src;+ pv.addEventListener('seeked', () => { box.classList.add('on'); if (this._pvWant != null && Math.abs(pv.currentTime - this._pvWant) > 0.3) pv.currentTime = this._pvWant; else this._pvWant = null; });+ pv.addEventListener('error', () => { this._pvFailed = true; box.classList.remove('on'); });+ box.appendChild(pv); this._pv = pv;+ const ar = this.v.videoWidth ? this.v.videoHeight / this.v.videoWidth : 9 / 16;+ box.style.height = Math.round(170 * ar) + 'px';+ }+ if (this._pv.seeking) { this._pvWant = t; return; }+ this._pv.currentTime = t;+ }++ _scrubTo(x, final) {+ const v = this.v, d = v.duration; if (!d) return;+ const t = this._frac(x) * d;+ this._hover(x);+ this._fill('f', t);+ this.$('.knob').style.left = this._xOf(t) + 'px';+ this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;+ if (final || !this._seekBusy) {+ this._seekBusy = true;+ if (!final && v.fastSeek) v.fastSeek(t); else v.currentTime = t;+ } else this._seekPending = t;+ }++ _tick() {+ const v = this.v, d = v.duration || 0, t = this.drag ? null : v.currentTime;+ if (t != null) {+ this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;+ this._fill('f', t);+ this.$('.knob').style.left = this._xOf(t) + 'px';+ this.bar.setAttribute('aria-valuemax', String(Math.floor(d)));+ this.bar.setAttribute('aria-valuenow', String(Math.floor(t)));+ this.bar.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(d)}`);+ }+ if (v.buffered.length) { let e = 0; for (let i = 0; i < v.buffered.length; i++) if (v.buffered.start(i) <= v.currentTime + 1) e = Math.max(e, v.buffered.end(i)); this._fill('b', e); }+ const c = this.chapters.length > 1 ? this._chapterAt(v.currentTime) : null;+ const chap = this.$('.chap');+ chap.hidden = !(c && c.title); this.$('.dotsep').hidden = chap.hidden;+ if (c) chap.firstChild.textContent = c.title;+ const pb = this.$('[data-k=play]');+ const ended = v.ended && !v.loop, want = ended ? 'replay' : v.paused ? 'play' : 'pause';+ if (pb.dataset.s !== want) { pb.dataset.s = want; pb.innerHTML = svg(want); pb.dataset.tip = want === 'pause' ? 'Pause (k)' : want === 'replay' ? 'Replay' : 'Play (k)'; }+ this._caps();+ }+ _caps() {+ const box = this.$('.caps');+ if (!this.capOn) { if (box.innerHTML) box.innerHTML = ''; return; }+ const t = this.v.currentTime;+ const html = this.cues.filter((c) => t >= c.start && t < c.end).map((c) => `<span>${capHTML(c.text)}</span>`).join('<br>');+ if (box.innerHTML !== html) box.innerHTML = html;+ }++ // ---------- actions ----------+ _started() { if (!this.started) { this.started = true; this.$('.big').hidden = true; } }+ toggle() {+ const v = this.v;+ if (v.paused || v.ended) { v.play().then(() => this._started(), (e) => { if (e && e.name === 'NotAllowedError') this._hud('Press play to start'); }); this._bezel('play'); }+ else { v.pause(); this._bezel('pause'); }+ }+ seekBy(s, fx) {+ const v = this.v; if (!v.duration) return;+ v.currentTime = clamp(v.currentTime + s, 0, v.duration); this._started();+ if (fx) this._seekfx(s);+ this._show();+ }+ seekTo(t) { const v = this.v; if (!v.duration) return; v.currentTime = clamp(t, 0, v.duration); this._started(); this._show(); }+ // one frame at the video's measured frame rate (default 30 fps), landing mid-frame so rounding never repeats one+ frame(n) {+ const v = this.v, f = this._fdt || 1 / 30;+ if (!v.paused) v.pause();+ const k = Math.floor(v.currentTime / f + 0.02) + n;+ v.currentTime = clamp((k + 0.5) * f, 0, v.duration || 0);+ this._started(); this._show();+ this._frameHud(n, f);+ }+ // the frame-step pill: direction, frame N of total, the exact time, the frame rate+ _frameHud(n, f) {+ const el = this.$('.fstep'), v = this.v;+ const idx = Math.max(0, Math.floor(v.currentTime / f + 0.02)), total = Math.max(1, Math.round((v.duration || 0) / f));+ const fps = 1 / f, fpsTxt = Math.abs(fps - Math.round(fps)) < 0.05 ? Math.round(fps) : fps.toFixed(2);+ el.innerHTML = `${svg(n > 0 ? 'fstepf' : 'fstepb', 18)}<span>Frame <b>${(idx + 1).toLocaleString()}</b> <span class="dim">of ${total.toLocaleString()}</span></span><span class="dim">·</span><span>${fmtMs(v.currentTime)}</span><span class="dim">· ${fpsTxt} fps${this._fdt ? '' : ' (assumed)'}</span>`;+ el.classList.remove('go'); void el.offsetWidth; el.classList.add('go', 'on');+ this.$('.top').style.opacity = '0';+ clearTimeout(this._t.fstep);+ this._t.fstep = setTimeout(() => this._frameHudOff(), 2000);+ }+ _frameHudOff() { clearTimeout(this._t.fstep); this.$('.fstep').classList.remove('on'); this.$('.top').style.opacity = ''; }+ volBy(d) { const v = this.v; v.muted = false; v.volume = clamp(Math.round((v.volume + d) * 100) / 100, 0, 1); this._hud(`${Math.round(v.volume * 100)}%`); this._bezel(v.volume === 0 ? 'mute' : d > 0 ? 'vol' : 'vollow'); }+ mute() { const v = this.v; v.muted = !v.muted; if (!v.muted && v.volume === 0) v.volume = 0.5; this._bezel(v.muted ? 'mute' : 'vol'); }+ speed(s) { this.v.playbackRate = s; this._hud(s === 1 ? 'Normal speed' : `${s}x`); this._menuRefresh(); }+ speedStep(d) { const r = this.v.playbackRate; this.speed(clamp(Math.round((r + 0.25 * d) * 4) / 4, 0.25, 4)); }+ capOpacity(which) {+ if (!this.cues.length) return this._hud('No captions for this video');+ const list = which === 'text' ? CAP_TEXT : CAP_WIN, key = which === 'text' ? '_capTxt' : '_capWin';+ const i = (list.indexOf(this[key] ?? list[0]) + 1) % list.length; this[key] = list[i];+ this.p.style.setProperty(which === 'text' ? '--captxt' : '--capbg', String(list[i] / 100));+ if (!this.capOn) { this.capOn = true; this._caps(); this._sync(); }+ this._hud(`Caption ${which === 'text' ? 'text' : 'background'} opacity ${list[i]}%`);+ }+ // Shift+N / Shift+P: the next or previous player in this document (each wiki widget frame holds one)+ sibling(dir) {+ const all = [...document.querySelectorAll('adom-video')], k = all.indexOf(this), n = all[k + dir];+ if (!n) return this._hud(dir > 0 ? 'No next video on this page' : 'No previous video on this page');+ this.v.pause(); active = n; n.scrollIntoView({ block: 'center', behavior: 'smooth' }); n.p.focus({ preventScroll: true }); n.toggle();+ }+ // press and hold on the picture: 2x while held, like YouTube+ _holdStart() { const v = this.v; if (v.paused || this._hold) return; this._hold = { rate: v.playbackRate }; v.playbackRate = Math.max(2, v.playbackRate); this.$('.hold').classList.add('on'); }+ _holdEnd() { if (!this._hold) return false; this.v.playbackRate = this._hold.rate; this._hold = null; this.$('.hold').classList.remove('on'); return true; }+ loop() { this.v.loop = !this.v.loop; this._hud(this.v.loop ? 'Loop on' : 'Loop off'); this._menuRefresh(); }+ captions() { if (!this.cues.length) return this._hud('No captions for this video'); this.capOn = !this.capOn; this._hud(this.capOn ? 'Captions on' : 'Captions off'); this._caps(); this._sync(); this._menuRefresh(); }+ capFont(d) { if (!this.cues.length) return this._hud('No captions for this video'); this.capSize = clamp(this.capSize + d, 50, 300); this.p.style.setProperty('--cap', this.capSize); this._hud(`Caption size ${this.capSize}%`); }+ chapter(dir) {+ const ch = this.chapters; if (ch.length < 2) return this._hud('This video has no chapters');+ const t = this.v.currentTime;+ const n = dir > 0 ? ch.find((c) => c.t > t + 0.5) : [...ch].reverse().find((c) => c.t < t - 1.5) || ch[0];+ if (n) { this.seekTo(n.t); if (n.title) this._hud(n.title); }+ }+ async pip() {+ try {+ if (document.pictureInPictureElement) await document.exitPictureInPicture();+ else { await this.v.requestPictureInPicture(); this._started(); }+ } catch { this._hud('Picture-in-picture is not available here'); }+ }+ // the player alone, full window, in a new tab at the current time+ popout() {+ const t = Math.floor(this.v.currentTime);+ let url;+ if (location.pathname.includes('/widget/')) { const u = new URL(location.href); u.searchParams.set('start', String(t)); url = u.href; }+ else url = (this.v.currentSrc || this.v.src).split('#')[0] + `#t=${t}`;+ this.v.pause();+ this._openTab(url);+ this._hud('Opened in a new tab');+ }+ // window.open with noopener always returns null, so open through a link (the frame allows popups)+ _openTab(url) { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener'; this.shadowRoot.appendChild(a); a.click(); a.remove(); }+ async fullscreen() {+ if (document.fullscreenElement) { await document.exitFullscreen().catch(() => {}); return; }+ if (document.fullscreenEnabled && this.p.requestFullscreen) {+ try { await this.p.requestFullscreen(); return; } catch {}+ }+ // a sandboxed wiki frame cannot go fullscreen: open the player full-window instead+ this.popout();+ }++ // ---------- feedback ----------+ _bezel(icon) { const b = this.$('.bezel'); b.innerHTML = svg(icon, 36); b.classList.remove('go'); void b.offsetWidth; b.classList.add('go'); }+ _hud(txt) { const h = this.$('.hud'); h.textContent = txt; h.classList.add('on'); clearTimeout(this._t.hud); this._t.hud = setTimeout(() => h.classList.remove('on'), 800); }+ _seekfx(s) {+ const el = this.$(s < 0 ? '.seekfx.l' : '.seekfx.r');+ this._acc = (this._accSide === Math.sign(s) && el.classList.contains('on')) ? this._acc + Math.abs(s) : Math.abs(s);+ this._accSide = Math.sign(s);+ el.querySelector('span').textContent = `${this._acc} seconds`;+ el.classList.add('on'); clearTimeout(this._t.fx); this._t.fx = setTimeout(() => el.classList.remove('on'), 650);+ }+ _show() {+ this.p.classList.remove('hide-ui');+ clearTimeout(this._t.hide);+ this._t.hide = setTimeout(() => {+ if (this.v.paused || this.drag || this._overChrome || this.$('.menu').classList.contains('on') || this.$('.help').classList.contains('on')) return;+ this.p.classList.add('hide-ui'); this._tipOff();+ }, 2500);+ }+ _volUI() {+ const v = this.v, lv = v.muted ? 0 : v.volume;+ const s = this.$('.vslide'), w = 52;+ s.querySelector('.lvl').style.width = (lv * w) + 'px';+ s.querySelector('.dot').style.left = (lv * w) + 'px';+ s.setAttribute('aria-valuenow', String(Math.round(lv * 100)));+ const mb = this.$('[data-k=mute]');+ const ic = lv === 0 ? 'mute' : lv < 0.5 ? 'vollow' : 'vol';+ if (mb.dataset.s !== ic) { mb.dataset.s = ic; mb.innerHTML = svg(ic); mb.dataset.tip = lv === 0 ? 'Unmute (m)' : 'Mute (m)'; }+ }+ _sync() {+ const on = (k, x) => { const b = this.$(`[data-k=${k}]`); b.classList.toggle('on', x); b.setAttribute('aria-pressed', String(x)); };+ on('cc', this.capOn);+ const f = this.$('[data-k=fs]'), isFs = document.fullscreenElement === this.p;+ this.p.classList.toggle('fs', isFs);+ f.innerHTML = svg(isFs ? 'exitfull' : 'full'); f.dataset.tip = isFs ? 'Exit full screen (f)' : 'Full screen (f)';+ }+ _tipOn(btn) {+ const tt = this.$('.tt'), t = btn.dataset.tip; if (!t || this.$('.menu').classList.contains('on')) return;+ tt.textContent = t; tt.classList.add('on');+ const pr = this.p.getBoundingClientRect(), br = btn.getBoundingClientRect();+ tt.style.left = clamp(br.left - pr.left + br.width / 2 - tt.offsetWidth / 2, 6, pr.width - tt.offsetWidth - 6) + 'px';+ }+ _tipOff() { this.$('.tt').classList.remove('on'); }++ // ---------- settings menu ----------+ _menu(view) {+ const m = this.$('.menu'); this._menuView = view;+ const v = this.v;+ if (view === 'speed') {+ m.innerHTML = `<button class="mhead" data-m="main">${svg('chevl')}Playback speed</button>` ++ (SPEEDS.includes(v.playbackRate) ? '' : `<button class="mi" data-speed="${v.playbackRate}"><span class="ck">${svg('check')}</span><span class="lab">${v.playbackRate} (custom)</span></button>`) ++ SPEEDS.map((s) => `<button class="mi" role="menuitemradio" data-speed="${s}"><span class="ck">${v.playbackRate === s ? svg('check') : ''}</span><span class="lab">${s === 1 ? 'Normal' : s}</span></button>`).join('');+ } else {+ const sp = v.playbackRate === 1 ? 'Normal' : v.playbackRate + 'x';+ m.innerHTML =+ `<button class="mi" data-m="speed">${svg('speed')}<span class="lab">Playback speed</span><span class="val">${sp}</span>${svg('chev', 18)}</button>` ++ (this.cues.length ? `<button class="mi" data-act="cc">${svg('cc')}<span class="lab">Captions</span><span class="tog ${this.capOn ? 'on' : ''}"></span></button>` : '') ++ `<button class="mi" data-act="loop">${svg('loop')}<span class="lab">Loop</span><span class="tog ${v.loop ? 'on' : ''}"></span></button>` ++ `<button class="mi" data-act="keys">${svg('keys')}<span class="lab">Keyboard shortcuts</span><span class="val">?</span></button>` ++ `<button class="mi" data-act="dl">${svg('dl')}<span class="lab">Open the video file</span></button>`;+ }+ m.classList.add('on'); this._tipOff();+ this.$('[data-k=gear]').classList.add('on');+ }+ _menuRefresh() { if (this.$('.menu').classList.contains('on')) this._menu(this._menuView); }+ _menuClose() { this.$('.menu').classList.remove('on'); this.$('[data-k=gear]').classList.remove('on'); this._show(); }+ _help(on) {+ const h = this.$('.help');+ if (on == null) on = !h.classList.contains('on');+ if (on) {+ h.innerHTML = `<h3>Keyboard shortcuts <button class="x" aria-label="Close">×</button></h3><div class="cols"><table>${SHORTCUTS.map(([a, k]) => `<tr><td>${a}</td><td>${k.split(' or ').map((x) => `<kbd>${esc(x)}</kbd>`).join(' or ')}</td></tr>`).join('')}</table></div>`;+ h.querySelector('.x').onclick = () => this._help(false);+ this._menuClose(); if (!this.v.paused) { this._wasPlaying = true; this.v.pause(); }+ } else if (this._wasPlaying) { this._wasPlaying = false; this.v.play().catch(() => {}); }+ h.classList.toggle('on', on);+ }++ // ---------- wiring ----------+ _wire() {+ const v = this.v, p = this.p, bar = this.bar, $ = this.$;+ ['timeupdate', 'play', 'pause', 'seeked', 'progress', 'durationchange', 'ratechange', 'ended'].forEach((e) => v.addEventListener(e, () => this._tick()));+ v.addEventListener('durationchange', () => this._segments());+ v.addEventListener('volumechange', () => this._volUI());+ v.addEventListener('play', () => { this._started(); this._show(); this._frameHudOff(); });+ v.addEventListener('pause', () => this._show());+ v.addEventListener('waiting', () => p.classList.add('wait'));+ ['playing', 'canplay', 'seeked', 'pause'].forEach((e) => v.addEventListener(e, () => p.classList.remove('wait')));+ v.addEventListener('seeked', () => { this._seekBusy = false; if (this._seekPending != null) { const t = this._seekPending; this._seekPending = null; this._seekBusy = true; v.fastSeek ? v.fastSeek(t) : (v.currentTime = t); } });+ v.addEventListener('error', () => {+ const s = this.getAttribute('video') || this.getAttribute('src') || '';+ this._error(`This video could not be loaded.<br><code>${esc(s)}</code><br><br>Check the path is <code>/OWNER/SLUG/render/FILE.mp4</code> and that the file is on the page. On a private page the widget frame may not be signed in.`);+ });+ document.addEventListener('fullscreenchange', () => this._sync());+ if (v.requestVideoFrameCallback) {+ let last = null;+ const onFrame = (now, md) => {+ // consecutive presented frames at 1x give the frame duration; keep the median of the last 31+ if (last && md.presentedFrames === last.pf + 1 && md.mediaTime > last.mt && v.playbackRate === 1) {+ const dt = md.mediaTime - last.mt;+ if (dt > 0.004 && dt < 0.2) {+ (this._dts = this._dts || []).push(dt); if (this._dts.length > 31) this._dts.shift();+ this._fdt = [...this._dts].sort((x, y) => x - y)[this._dts.length >> 1];+ }+ }+ last = { pf: md.presentedFrames, mt: md.mediaTime };+ v.requestVideoFrameCallback(onFrame);+ };+ v.requestVideoFrameCallback(onFrame);+ }+ v.addEventListener('play', () => {+ try {+ const ms = navigator.mediaSession; if (!ms) return;+ ms.metadata = new MediaMetadata({ title: this._opt('title') || 'Video' });+ ms.setActionHandler('play', () => v.play()); ms.setActionHandler('pause', () => v.pause());+ ms.setActionHandler('stop', () => { v.pause(); this.seekTo(0); });+ ms.setActionHandler('seekbackward', () => this.seekBy(-10, true)); ms.setActionHandler('seekforward', () => this.seekBy(10, true));+ ms.setActionHandler('nexttrack', () => this.sibling(1)); ms.setActionHandler('previoustrack', () => this.sibling(-1));+ } catch {}+ });++ // clicks on the picture: play/pause; double-click: fullscreen; touch: tap shows controls, double-tap a side seeks+ const click = $('.click');+ let lastTap = 0, tapTimer = 0, down = null, holdTimer = 0;+ click.addEventListener('pointerdown', (e) => {+ if (e.button !== 0) return;+ down = e.pointerId; clearTimeout(holdTimer);+ holdTimer = setTimeout(() => this._holdStart(), 450);+ });+ const cancelHold = () => { clearTimeout(holdTimer); if (this._holdEnd()) down = null; };+ click.addEventListener('pointerleave', cancelHold);+ click.addEventListener('pointercancel', () => { cancelHold(); down = null; });+ click.addEventListener('contextmenu', (e) => { if (this._hold) e.preventDefault(); });+ click.addEventListener('pointerup', (e) => {+ if (e.button !== 0) return;+ clearTimeout(holdTimer);+ const mine = down === e.pointerId; down = null;+ if (this._holdEnd()) return; // a hold was 2x, not a click+ if (!mine) return; // the press began elsewhere (a focus click, a drag from outside)+ if ($('.menu').classList.contains('on')) { this._menuClose(); return; }+ if (e.pointerType === 'touch') {+ const now = Date.now(), r = p.getBoundingClientRect(), fx = (e.clientX - r.left) / r.width;+ if (now - lastTap < 300 && (fx < 0.35 || fx > 0.65)) { clearTimeout(tapTimer); this.seekBy(fx < 0.5 ? -10 : 10, true); lastTap = now; return; }+ lastTap = now;+ tapTimer = setTimeout(() => { if (p.classList.contains('hide-ui')) this._show(); else this.toggle(); }, 280);+ return;+ }+ this.toggle();+ });+ click.addEventListener('dblclick', (e) => { e.preventDefault(); this.fullscreen(); });+ $('.big').addEventListener('click', () => this.toggle());++ p.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') this._show(); });+ p.addEventListener('pointerdown', (e) => { active = this; if (!this.shadowRoot.activeElement || e.composedPath()[0] === v || e.target === this) setTimeout(() => { if (!this.shadowRoot.activeElement) p.focus({ preventScroll: true }); }); });+ p.addEventListener('focusin', () => { active = this; });+ p.addEventListener('pointerleave', () => { if (!v.paused && !this.drag) { clearTimeout(this._t.hide); this._t.hide = setTimeout(() => { if (!v.paused && !this.drag && !$('.menu').classList.contains('on')) p.classList.add('hide-ui'); }, 600); } });+ const chrome = $('.chrome');+ chrome.addEventListener('pointerenter', () => { this._overChrome = true; });+ chrome.addEventListener('pointerleave', () => { this._overChrome = false; this._show(); });++ // the timeline+ bar.addEventListener('pointermove', (e) => { if (!this.drag) this._hover(e.clientX); });+ bar.addEventListener('pointerleave', () => { if (!this.drag) (this._seg || []).forEach((s) => s.el.classList.remove('hov')); });+ bar.addEventListener('pointerdown', (e) => {+ if (e.button !== 0 || !v.duration) return;+ e.preventDefault(); bar.setPointerCapture(e.pointerId); bar.classList.add('drag');+ this.drag = { resume: !v.paused }; v.pause(); this._started();+ this._scrubTo(e.clientX);+ });+ bar.addEventListener('pointermove', (e) => { if (this.drag) this._scrubTo(e.clientX); });+ const end = (e) => {+ if (!this.drag) return;+ const resume = this.drag.resume; this.drag = null; bar.classList.remove('drag');+ this._seekPending = null; this._scrubTo(e.clientX, true);+ if (resume) v.play().catch(() => {});+ this._show();+ };+ bar.addEventListener('pointerup', end); bar.addEventListener('pointercancel', end);+ bar.addEventListener('keydown', (e) => {+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); this.seekBy(e.key === 'ArrowLeft' ? -5 : 5, true); }+ });++ // volume slider+ const vs = $('.vslide');+ const setVol = (x) => { const r = vs.getBoundingClientRect(); v.muted = false; v.volume = clamp((x - r.left - 6) / 52, 0, 1); };+ vs.addEventListener('pointerdown', (e) => { e.preventDefault(); vs.setPointerCapture(e.pointerId); vs.classList.add('drag'); setVol(e.clientX); });+ vs.addEventListener('pointermove', (e) => { if (vs.classList.contains('drag')) setVol(e.clientX); });+ const vend = () => vs.classList.remove('drag');+ vs.addEventListener('pointerup', vend); vs.addEventListener('pointercancel', vend);+ vs.addEventListener('wheel', (e) => { e.preventDefault(); this.volBy(e.deltaY < 0 ? 0.05 : -0.05); }, { passive: false });++ // buttons+ const act = {+ play: () => this.toggle(), mute: () => this.mute(), cc: () => this.captions(), pip: () => this.pip(),+ pop: () => this.popout(), fs: () => this.fullscreen(),+ gear: () => ($('.menu').classList.contains('on') ? this._menuClose() : this._menu('main')),+ };+ $('.row').addEventListener('click', (e) => { const b = e.target.closest('[data-k]'); if (b && act[b.dataset.k]) { act[b.dataset.k](); this._tipOff(); } });+ $('.chap').addEventListener('click', () => this.chapter(1));+ $('.row').addEventListener('pointerover', (e) => { const b = e.target.closest('[data-tip]'); if (b) this._tipOn(b); });+ $('.row').addEventListener('pointerout', (e) => { if (e.target.closest('[data-tip]')) this._tipOff(); });++ $('.menu').addEventListener('click', (e) => {+ const b = e.target.closest('button'); if (!b) return;+ if (b.dataset.m) this._menu(b.dataset.m);+ else if (b.dataset.speed) { this.speed(Number(b.dataset.speed)); this._menu('main'); }+ else if (b.dataset.act === 'cc') this.captions();+ else if (b.dataset.act === 'loop') this.loop();+ else if (b.dataset.act === 'keys') this._help(true);+ else if (b.dataset.act === 'dl') { this._openTab((v.currentSrc || v.src).split('#')[0]); this._menuClose(); }+ });++ // YouTube's keyboard shortcuts, for whichever player owns the keyboard+ document.addEventListener('keydown', (e) => {+ if (active !== this) return;+ const tgt = e.composedPath()[0];+ if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || '') || (tgt && tgt.isContentEditable)) return;+ if (e.metaKey || e.altKey) return;+ // outside our own frame, only take keys while the player has focus+ const inFrame = location.pathname.includes('/widget/');+ if (!inFrame && !this.shadowRoot.activeElement && document.activeElement !== this) return;+ const k = e.key; let hit = true;+ if ($('.help').classList.contains('on') && k !== '?' && k !== 'Escape') return;+ if (e.ctrlKey) { if (k === 'ArrowLeft') this.chapter(-1); else if (k === 'ArrowRight') this.chapter(1); else return; }+ else if (k === ' ' || k === 'k' || k === 'K') this.toggle();+ else if (k === 'j' || k === 'J') this.seekBy(-10, true);+ else if (k === 'l' || k === 'L') this.seekBy(10, true);+ else if (k === 'ArrowLeft') this.seekBy(-5, true);+ else if (k === 'ArrowRight') this.seekBy(5, true);+ else if (k === 'ArrowUp') this.volBy(0.05);+ else if (k === 'ArrowDown') this.volBy(-0.05);+ else if (k === ',') this.frame(-1);+ else if (k === '.') this.frame(1);+ else if (k === '<') this.speedStep(-1);+ else if (k === '>') this.speedStep(1);+ else if (k === 'Home') this.seekTo(0);+ else if (k === 'End') this.seekTo(v.duration || 0);+ else if (/^[0-9]$/.test(k)) this.seekTo((v.duration || 0) * Number(k) / 10);+ else if (k === 'f' || k === 'F') this.fullscreen();+ else if (k === 't' || k === 'T') this.popout();+ else if (k === 'i' || k === 'I') this.pip();+ else if (k === 'm' || k === 'M') this.mute();+ else if (k === 'c' || k === 'C') this.captions();+ else if (k === 'r' || k === 'R') this.loop();+ else if (k === 'o' || k === 'O') this.capOpacity('text');+ else if (k === 'w' || k === 'W') this.capOpacity('win');+ else if (k === 'N' && e.shiftKey) this.sibling(1);+ else if (k === 'P' && e.shiftKey) this.sibling(-1);+ else if (k === 'MediaPlayPause') this.toggle();+ else if (k === 'MediaStop') { v.pause(); this.seekTo(0); }+ else if (k === 'MediaTrackNext') this.sibling(1);+ else if (k === 'MediaTrackPrevious') this.sibling(-1);+ else if (k === '+' || k === '=') this.capFont(25);+ else if (k === '-' || k === '_') this.capFont(-25);+ else if (k === '?') this._help();+ else if (k === 'Escape') { if ($('.help').classList.contains('on')) this._help(false); else if ($('.menu').classList.contains('on')) this._menuClose(); else hit = false; }+ else hit = false;+ if (hit) { e.preventDefault(); e.stopPropagation(); }+ }, true);+ }++ // for the wiki's widget driver and AI checks+ get adomDrive() {+ return {+ ready: () => new Promise((ok) => (this.v.readyState >= 1 ? ok() : this.v.addEventListener('loadedmetadata', () => ok(), { once: true }))),+ state: () => ({ state: this.v.paused ? 'paused' : 'playing', t: this.v.currentTime, duration: this.v.duration, speed: this.v.playbackRate, chapters: this.chapters.length, captions: this.cues.length }),+ set: (x) => { if (x === 'play' || x === 'playing') return this.v.play(); if (x === 'pause' || x === 'paused') return this.v.pause(); if (!isNaN(parseT(x))) this.seekTo(parseT(x)); },+ };+ }+ }+ customElements.define('adom-video', AdomVideo);+})();
assets/vendor/base.cssadded+130@@ -0,0 +1,130 @@+@font-face { font-family: 'Familjen Grotesk'; font-weight: 600 700; src: url('fonts/familjen-grotesk-700.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'Satoshi'; font-weight: 400; src: url('fonts/satoshi-400.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'Satoshi'; font-weight: 500 700; src: url('fonts/satoshi-500.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; font-weight: 400; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url('fonts/jetbrains-mono-500.woff2') format('woff2'); font-display: swap; }++:root {+ --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d; --border: #30363d; --line: #30363d;+ --text: #e6edf3; --text-body: #c9d1d9; --text-2: #8b949e; --muted: #8b949e; --text-3: #484f58;+ --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --on-accent: #05221f; --teal: #00b8b1;+ --blue: #64abff; --blue-fill: #0061ef; --purple: #8c6bf7; --green: #3fb950; --red: #f85149; --yellow: #d29922;+ --font-head: 'Familjen Grotesk', sans-serif; --font-body: 'Satoshi', sans-serif; --font-mono: 'JetBrains Mono', monospace;+}+* { box-sizing: border-box; }+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text-body); font: 400 14px/1.45 var(--font-body); }+.mono { font-family: var(--font-mono); }+a { color: var(--blue); }++/* ---- the 44px three-zone header, edge to edge ---- */+.app-header { height: 44px; display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--line); }+.brand { display: flex; align-items: center; gap: 8px; flex: none; cursor: default; outline: none; }+.brand-icon { display: inline-flex; color: var(--text); }+.app-name { font: 600 15px var(--font-head); color: var(--text); letter-spacing: -.01em; }+.app-sub { font-size: 13px; color: var(--muted); font-weight: 400; }+.vsep { width: 1px; height: 20px; background: var(--line); flex: none; }+.subject { display: flex; align-items: baseline; gap: 10px; min-width: 0; flex: 1; }+.subject-name { font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject-meta { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.actions { display: flex; gap: 6px; margin-left: auto; align-items: center; }+.icon-btn { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: none; border: 1px solid transparent;+ border-radius: 8px; color: var(--muted); cursor: pointer; padding: 0; transition: color .15s ease, border-color .15s ease, background .15s ease; text-decoration: none; }+.icon-btn:hover { color: var(--teal); background: var(--elevated); }+.icon-btn:active { transform: translateY(1px); }+.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }+.icon-btn.lg { width: 34px; height: 34px; border-color: var(--border); background: var(--surface); }+.icon-btn img { display: block; }+.popover { position: fixed; z-index: 99990; display: none; background: rgba(22,27,34,.98); border: 1px solid var(--border); border-radius: 12px;+ padding: 12px 14px; box-shadow: 0 12px 40px rgba(0,0,0,.55); max-width: min(460px, calc(100vw - 16px)); }+.popover.open { display: block; }+.pop-title { font: 600 15px var(--font-head); color: var(--text); margin-bottom: 8px; }+.pop-row { display: flex; gap: 14px; justify-content: space-between; font-size: 13px; padding: 2px 0; }+.pop-row span:first-child { color: var(--muted); }++/* ---- layout ---- */+main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; height: calc(100% - 44px); }+.stage { display: flex; align-items: center; justify-content: center; padding: 12px 16px; min-height: 0; min-width: 0; }+/* the wiki's <adom-video>: as wide as the stage allows while the whole picture stays on screen */+adom-video { width: min(100%, calc((100dvh - 44px - 24px) * var(--ar, 1.7778))); }+aside { border-left: 1px solid var(--line); padding: 12px 14px; overflow: auto; }+aside h2 { font: 600 12px var(--font-head); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 6px 0 8px; }+.list { list-style: none; margin: 0 0 18px; padding: 0; }+.list li { padding: 6px 8px; border-radius: 8px; cursor: pointer; display: flex; gap: 8px; border: 1px solid transparent; }+.list li:hover { background: var(--elevated); }+.list li.active { background: var(--elevated); border-color: var(--accent); color: var(--text); }+.list li .t { font: 500 12px var(--font-mono); color: var(--accent-bright); flex: none; }+.list li.empty { color: var(--muted); cursor: default; font-size: 13px; }+.list li.gone { opacity: .5; }+.revealed { outline: 2px solid var(--accent); outline-offset: 2px; transition: outline .2s ease; }+.lib-main { display: block; padding: 16px; height: auto; max-width: 980px; }+.lib-intro { color: var(--muted); margin: 0 0 12px; }+.lib-list li { justify-content: space-between; align-items: baseline; }+.lib-title { color: var(--text); font-weight: 500; }+.lib-path { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++/* ---- shortcuts list ---- */+.sc-pop table { border-collapse: collapse; }+.sc-pop td { padding: 3px 16px 3px 0; font-size: 13px; vertical-align: top; }+.sc-pop td:first-child { font: 500 12px var(--font-mono); color: var(--accent-bright); white-space: nowrap; }+.sc-foot { color: var(--muted); font-size: 12px; margin-top: 8px; }++/* ---- tooltip: body-level, fixed, 99999 ---- */+#global-tooltip { position: fixed; z-index: 99999; left: 0; top: 0; display: none; opacity: 0; pointer-events: none; max-width: min(320px, calc(100vw - 40px));+ background: rgba(22,27,34,.96); color: var(--text); border: 1px solid rgba(0,184,177,.28); border-radius: 10px; padding: 9px 13px;+ font: 400 13px/1.4 var(--font-body); box-shadow: 0 12px 40px rgba(0,0,0,.55); text-transform: none; letter-spacing: 0; transition: opacity .18s ease; }+#global-tooltip.visible { display: block; }+#global-tooltip.shown { opacity: 1; }++/* ---- toasts: bottom center, three types, max three ---- */+.toast-stack { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 400; display: flex; flex-direction: column-reverse; gap: 10px; align-items: center; pointer-events: none; }+.toast { pointer-events: auto; display: flex; align-items: center; gap: 12px; min-width: 280px; max-width: min(520px, calc(100vw - 32px)); background: var(--surface);+ border: 1px solid var(--border); border-left: 3px solid var(--toast-accent, var(--accent)); border-radius: 12px; padding: 12px 14px; box-shadow: 0 18px 50px rgba(0,0,0,.5);+ color: var(--text-body); animation: toast-in 180ms cubic-bezier(.2,.8,.2,1) both; }+.toast.success { --toast-accent: var(--green); } .toast.error { --toast-accent: var(--red); } .toast.info { --toast-accent: var(--accent); }+.toast .ticon { color: var(--toast-accent); display: inline-flex; }+.toast .tmsg { flex: 1; }+.toast .taction { margin-left: auto; color: var(--blue); font-weight: 600; background: none; border: none; cursor: pointer; }+.toast .tclose { background: none; border: none; color: var(--muted); cursor: pointer; display: inline-flex; padding: 2px; }+.toast.leaving { animation: toast-out 140ms ease both; }+@keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }+@keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(4px); } }+@media (prefers-reduced-motion: reduce) { .toast, .toast.leaving { animation: none; } }++/* ---- dialogs: dim, never blur ---- */+dialog { background: var(--surface); color: var(--text-body); border: 1px solid var(--border); border-radius: 12px; padding: 0; width: min(560px, calc(100vw - 32px));+ max-height: calc(100vh - 64px); box-shadow: 0 24px 64px rgba(0,0,0,.6); }+dialog::backdrop { background: rgba(0,0,0,.25); }+.dlg-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }+.dlg-head h3 { margin: 0; font: 600 16px var(--font-head); color: var(--text); }+.dlg-body { padding: 8px 16px 12px; overflow: auto; max-height: calc(100vh - 220px); scrollbar-width: thin; scrollbar-color: var(--border) transparent; }+.set-row { padding: 10px 0; display: flex; flex-direction: column; gap: 4px; border-bottom: 1px solid var(--overlay); }+.set-name { margin: 0; font: 600 15px var(--font-head); color: #8fd3d4; }+.set-help { margin: 0; font-size: 13px; color: var(--muted); }+.default-hint { font-size: 12px; color: var(--text-2); }+.select { align-self: flex-start; background: var(--overlay); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 5px 8px; font: 400 13px var(--font-body); }+.toggle { background: none; border: none; padding: 2px; cursor: pointer; flex-shrink: 0; align-self: flex-start; transition: transform .06s ease; }+.toggle-track { display: block; width: 32px; height: 18px; background: var(--overlay); border: 1px solid var(--border); border-radius: 9px; position: relative; transition: background .15s ease, border-color .15s ease; }+.toggle:hover .toggle-track { border-color: var(--accent); }+.toggle:active { transform: translateY(1px) scale(.97); }+.toggle.active .toggle-track { background: var(--accent); border-color: var(--accent); }+.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: var(--text); border-radius: 50%; transition: transform .18s ease, width .12s ease; }+.toggle.active .toggle-thumb { transform: translateX(14px); }+.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 10px; }+.settings-footer { padding: 12px 16px 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }+.footer-note { margin: 0; font-size: 12px; color: var(--muted); }+.restore-btn { align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; background: var(--overlay); color: var(--text-body); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; cursor: pointer; font: 500 13px var(--font-body); }+.restore-btn.armed { border-color: var(--red); color: var(--red); }++/* ---- fake AI cursor ---- */+.ai-cursor { position: fixed; left: 0; top: 0; z-index: 99998; pointer-events: none; opacity: 0; transition: transform .32s cubic-bezier(.2,.8,.2,1), opacity .2s ease; display: flex; align-items: flex-start; gap: 4px; }+.ai-cursor.on { opacity: 1; }+.ai-label { font: 500 12px var(--font-body); background: var(--accent); color: var(--on-accent); padding: 2px 7px; border-radius: 999px; margin-top: 14px; white-space: nowrap; }+.ai-label:empty { display: none; }++/* ---- responsive and touch ---- */+@media (max-width: 860px) { main { grid-template-columns: 1fr; height: auto; } aside { border-left: none; border-top: 1px solid var(--line); } adom-video { width: 100%; } }+@media (max-width: 720px) { .app-sub, .subject-meta, .vsep { display: none; } }+@media (hover: none), (pointer: coarse) {+ .icon-btn { min-width: 44px; min-height: 44px; }+ .list li { padding: 12px 10px; }+}
assets/vendor/fonts/familjen-grotesk-700.woff2added⋯ 1 unchanged line ⋯
assets/vendor/fonts/jetbrains-mono-400.woff2added⋯ 1 unchanged line ⋯
assets/vendor/fonts/jetbrains-mono-500.woff2added⋯ 1 unchanged line ⋯
assets/vendor/fonts/satoshi-400.woff2added⋯ 1 unchanged line ⋯
assets/vendor/fonts/satoshi-500.woff2added⋯ 1 unchanged line ⋯
assets/vendor/icons.jsadded+23@@ -0,0 +1,23 @@+// Line icons (stroke, currentColor), 24 viewBox, 1.8 weight: the adom-ui-design icon rules.+window.ICONS = {+ mark: '<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M10 9.2v5.6l4.6-2.8z"/><path d="M2.5 16.5h19"/>',+ download: '<path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M5 19.5h14"/>',+ link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',+ keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M6 13.5h.01M18 13.5h.01M8.5 14.5h7"/>',+ close: '<path d="M6 6l12 12M18 6 6 18"/>',+ check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.8h.01"/>',+ alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/>',+ restore: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4.5v4h4"/>',+ back10: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4.5v4h4"/><path d="M10 10v5M13 10h2v5h-2z"/>',+ fwd10: '<path d="M20 12a8 8 0 1 1-2.4-5.7"/><path d="M20 4.5v4h-4"/><path d="M10 10v5M13 10h2v5h-2z"/>',+ prevF: '<path d="M7 5v14"/><path d="M18 6.5v11l-8-5.5z"/>',+ nextF: '<path d="M17 5v14"/><path d="M6 6.5v11l8-5.5z"/>',+ play: '<path d="M8 5.5v13l10.5-6.5z"/>',+ pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',+ cc: '<rect x="3" y="5.5" width="18" height="13" rx="2"/><path d="M10.5 10.2a2.2 2.2 0 1 0 0 3.6M17 10.2a2.2 2.2 0 1 0 0 3.6"/>',+ pip: '<rect x="2.5" y="4.5" width="19" height="15" rx="2"/><rect x="12" y="11.5" width="7" height="5.5" rx="1"/>',+ full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',+ loop: '<path d="M17 3.5 20 6.5 17 9.5"/><path d="M4 11.5v-1a4 4 0 0 1 4-4h12"/><path d="M7 20.5 4 17.5 7 14.5"/><path d="M20 12.5v1a4 4 0 0 1-4 4H4"/>',+};+window.icon = (n, s = 18) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${window.ICONS[n] || ''}</svg>`;
assets/vendor/settings-cog-outline.svgadded+10@@ -0,0 +1,10 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+ fill="none" stroke="#e6edf3" stroke-width="1.8"+ stroke-linecap="round" stroke-linejoin="round">+ <!-- THE Adom settings cog: a true line drawing (strokes, no filled silhouette).+ Hardcoded #e6edf3 because this file is loaded as its own document via+ <img src> / favicon, where currentColor would resolve to black. Inline+ copies pasted into markup may swap the stroke to currentColor. -->+ <circle cx="12" cy="12" r="3"/>+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>+</svg>
assets/vendor/ui.jsadded+187@@ -0,0 +1,187 @@+// Shared UI layer for every adom-video-player page, following adom-ui-design:+// body-appended tooltips with a 600ms delay and viewport clamp, bottom-center+// toasts, the header info popover, the Settings dialog (server-side settings),+// a console relay to the server, the SSE live channel, and the fake AI cursor.+(() => {+ const BASE = location.pathname.replace(/watch\/[^/]*$/, '').replace(/[^/]*$/, ''); // relative root, proxy-safe+ const HUMAN = 'human (player page)';+ const $ = (s, r = document) => r.querySelector(s);+ const api = (p, body) => fetch(BASE + p, body === undefined ? { cache: 'no-store' } :+ { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ aiThread: HUMAN, ...body }) }).then((r) => r.json());++ // ---------- console relay: the AI reads /console instead of DevTools ----------+ const queue = []; let flushT;+ const flush = () => { const lines = queue.splice(0); if (lines.length) navigator.sendBeacon?.(BASE + 'console', new Blob([JSON.stringify({ lines })], { type: 'application/json' })) || fetch(BASE + 'console', { method: 'POST', body: JSON.stringify({ lines }) }); };+ for (const level of ['log', 'info', 'warn', 'error']) {+ const orig = console[level].bind(console);+ console[level] = (...a) => { orig(...a); queue.push({ level, page: location.pathname, msg: a.map((x) => typeof x === 'string' ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })()).join(' ') }); clearTimeout(flushT); flushT = setTimeout(flush, 300); };+ }+ addEventListener('error', (e) => console.error('window.onerror:', e.message, e.filename + ':' + e.lineno));+ addEventListener('unhandledrejection', (e) => console.error('unhandledrejection:', String(e.reason)));++ // ---------- tooltips: one body-level fixed div, 600ms, clamped on both axes ----------+ const tip = document.createElement('div'); tip.id = 'global-tooltip'; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip);+ let cx = 0, cy = 0, tipT, tipFor = null;+ addEventListener('mousemove', (e) => { cx = e.clientX; cy = e.clientY; }, true);+ function place(el, w, h, anchorRect) {+ const O = 14, vw = innerWidth, vh = innerHeight;+ const a = anchorRect || { left: cx, right: cx, top: cy, bottom: cy };+ // cursor quadrants first, then just below / above the trigger so the tip never covers it+ const cands = [{ x: cx + O, y: cy + O }, { x: cx - w - O, y: cy + O }, { x: cx + O, y: cy - h - O }, { x: cx - w - O, y: cy - h - O },+ { x: cx - w / 2, y: a.bottom + 8 }, { x: cx - w / 2, y: a.top - h - 8 }, { x: a.right + 8, y: cy - h / 2 }, { x: a.left - w - 8, y: cy - h / 2 }];+ const score = (c) => {+ let p = 0;+ if (c.x < 8 || c.x + w > vw - 8) p += 1000; if (c.y < 8 || c.y + h > vh - 8) p += 1000;+ if (anchorRect) { const ox = Math.max(0, Math.min(c.x + w, anchorRect.right) - Math.max(c.x, anchorRect.left)); const oy = Math.max(0, Math.min(c.y + h, anchorRect.bottom) - Math.max(c.y, anchorRect.top)); p += ox * oy; }+ return p;+ };+ const clamp = (c) => ({ x: Math.max(8, Math.min(c.x, vw - w - 8)), y: Math.max(8, Math.min(c.y, vh - h - 8)) });+ const b = cands.map((c) => ({ c, s: score(c) + score(clamp(c)) })).sort((p, z) => p.s - z.s)[0].c;+ return { x: Math.max(8, Math.min(b.x, vw - w - 8)), y: Math.max(8, Math.min(b.y, vh - h - 8)) };+ }+ function hideTip() { clearTimeout(tipT); tip.classList.remove('visible', 'shown'); tipFor = null; }+ document.addEventListener('mouseover', (e) => {+ const t = e.target.closest?.('[data-tooltip]');+ if (t === tipFor) return;+ hideTip(); if (!t || matchMedia('(hover: none)').matches) return;+ tipFor = t;+ tipT = setTimeout(() => {+ tip.textContent = t.getAttribute('data-tooltip'); tip.classList.add('visible');+ const { x, y } = place(tip, tip.offsetWidth, tip.offsetHeight, t.getBoundingClientRect());+ tip.style.left = x + 'px'; tip.style.top = y + 'px';+ requestAnimationFrame(() => tip.classList.add('shown'));+ }, 600);+ });+ addEventListener('scroll', hideTip, true); addEventListener('mousedown', hideTip, true);++ // a hover popover (the brand info card, the shortcuts list): 600ms show, 220ms hide grace, clamped+ function hoverPopover(trigger, pop) {+ let showT, hideT;+ const show = () => {+ clearTimeout(hideT); hideTip();+ showT = setTimeout(() => {+ pop.classList.add('open'); const r = trigger.getBoundingClientRect();+ const w = pop.offsetWidth, h = pop.offsetHeight;+ let x = Math.min(r.left, innerWidth - w - 8), y = r.bottom + 6;+ if (y + h > innerHeight - 8) y = Math.max(8, r.top - h - 6);+ pop.style.left = Math.max(8, x) + 'px'; pop.style.top = Math.max(8, Math.min(y, innerHeight - h - 8)) + 'px';+ }, 600);+ };+ const hide = () => { clearTimeout(showT); hideT = setTimeout(() => pop.classList.remove('open'), 220); };+ for (const el of [trigger, pop]) { el.addEventListener('mouseenter', show); el.addEventListener('mouseleave', hide); }+ }++ // ---------- toasts ----------+ const stack = document.createElement('div'); stack.className = 'toast-stack'; document.body.appendChild(stack);+ function toast(text, kind = 'info', action) {+ const el = document.createElement('div'); el.className = `toast ${kind}`;+ el.setAttribute('role', kind === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite');+ el.innerHTML = `<span class="ticon">${icon(kind === 'success' ? 'check' : kind === 'error' ? 'alert' : 'info', 16)}</span><span class="tmsg"></span>`;+ el.querySelector('.tmsg').textContent = text;+ if (action) { const a = document.createElement('button'); a.className = 'taction'; a.textContent = action.label; a.onclick = () => { action.run(); close(); }; el.appendChild(a); }+ const x = document.createElement('button'); x.className = 'tclose'; x.setAttribute('aria-label', 'Dismiss'); x.innerHTML = icon('close', 14); x.onclick = () => close(); el.appendChild(x);+ stack.prepend(el);+ while (stack.children.length > 3) stack.lastChild.remove();+ let left = kind === 'error' ? Infinity : 4000, t0 = Date.now(), timer;+ const arm = () => { if (isFinite(left)) timer = setTimeout(close, left); t0 = Date.now(); };+ el.addEventListener('mouseenter', () => { clearTimeout(timer); left -= Date.now() - t0; });+ el.addEventListener('mouseleave', arm);+ function close() { clearTimeout(timer); el.classList.add('leaving'); setTimeout(() => el.remove(), 150); }+ arm(); return el;+ }++ // ---------- header: brand info popover + icons ----------+ function header(state) {+ $('#brand-icon').innerHTML = icon('mark', 18);+ document.querySelectorAll('[data-icon]').forEach((b) => { b.insertAdjacentHTML('afterbegin', icon(b.dataset.icon, Number(b.dataset.iconSize || 18))); });+ const pop = $('#brand-pop');+ pop.innerHTML = `<div class="pop-title">Video Player</div>+ <div class="pop-row"><span>Version</span><span class="mono">${state.version}</span></div>+ <div class="pop-row"><span>Server</span><span class="mono">port ${state.port}, started by ${state.startedBy}</span></div>+ <div class="pop-row"><span>Engine</span><span>Range-capable Node server, SSE live channel</span></div>+ <div class="pop-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-video-player" target="_blank" rel="noopener">adom/adom-video-player</a></div>`;+ hoverPopover($('#brand'), pop);+ }++ // ---------- Settings dialog: built from the server's schema ----------+ let settingsState = { settings: {}, schema: {} };+ function renderSettings() {+ const body = $('#settings-body'); body.innerHTML = '';+ for (const [k, s] of Object.entries(settingsState.schema)) {+ const v = settingsState.settings[k];+ const row = document.createElement('div'); row.className = 'set-row';+ const label = document.createElement('h4'); label.className = 'set-name'; label.textContent = s.label;+ const help = document.createElement('p'); help.className = 'set-help'; help.textContent = s.help;+ let ctl;+ if (s.type === 'boolean') {+ ctl = document.createElement('button'); ctl.className = 'toggle' + (v ? ' active' : ''); ctl.setAttribute('role', 'switch'); ctl.setAttribute('aria-checked', String(!!v));+ ctl.setAttribute('aria-label', s.label); ctl.innerHTML = '<span class="toggle-track"><span class="toggle-thumb"></span></span>';+ ctl.onclick = () => save(k, !settingsState.settings[k]);+ } else if (s.type === 'enum') {+ ctl = document.createElement('select'); ctl.className = 'select'; ctl.setAttribute('aria-label', s.label);+ for (const o of s.values) { const op = document.createElement('option'); op.value = o; op.textContent = s.labels?.[o] || (typeof o === 'number' ? o + 'x' : o); if (String(o) === String(v)) op.selected = true; ctl.appendChild(op); }+ ctl.onchange = () => save(k, ctl.value);+ } else {+ ctl = document.createElement('input'); ctl.type = 'number'; ctl.className = 'select'; ctl.min = s.min; ctl.max = s.max; ctl.value = v; ctl.setAttribute('aria-label', s.label);+ ctl.onchange = () => save(k, Number(ctl.value));+ }+ const def = document.createElement('span'); def.className = 'default-hint';+ def.textContent = 'Default: ' + (s.type === 'boolean' ? (s.default ? 'On' : 'Off') : (s.labels?.[s.default] || (s.type === 'enum' && typeof s.default === 'number' ? s.default + 'x' : s.default)));+ row.append(label, help, ctl, def); body.appendChild(row);+ }+ }+ async function save(k, v) {+ const r = await api('settings', { [k]: v });+ if (!r.ok) return toast(r.error, 'error');+ settingsState.settings = r.settings; renderSettings();+ toast(`${settingsState.schema[k].label}: ${typeof v === 'boolean' ? (v ? 'on' : 'off') : (settingsState.schema[k].labels?.[v] || v)}`, 'success');+ }+ function initSettings() {+ const dlg = $('#settings');+ $('#settings-btn').onclick = async () => { const r = await api('settings'); settingsState = r; renderSettings(); dlg.showModal(); };+ $('#settings-close').onclick = () => dlg.close();+ dlg.addEventListener('click', (e) => {+ if (e.target !== dlg) return; const r = dlg.getBoundingClientRect();+ if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) dlg.close();+ });+ const rb = $('#restore'); let armed = false, armT;+ rb.onclick = async () => {+ if (!armed) { armed = true; rb.classList.add('armed'); rb.lastChild.textContent = ' Click again to confirm'; armT = setTimeout(() => { armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults'; }, 4000); return; }+ clearTimeout(armT); armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults';+ const r = await api('settings', { restoreDefaults: true }); settingsState.settings = r.settings; renderSettings(); toast('Settings restored to defaults', 'success');+ };+ }++ // ---------- fake AI cursor ----------+ const cursor = document.createElement('div'); cursor.className = 'ai-cursor';+ cursor.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 3l14 8-6 1.5L10 19z" fill="#00b8b1" stroke="#05221f" stroke-width="1.2" stroke-linejoin="round"/></svg><span class="ai-label"></span>';+ document.body.appendChild(cursor);+ function glide(selector, label) {+ const el = document.querySelector(selector); if (!el) { toast(`AI could not find ${selector}`, 'error'); return Promise.resolve(null); }+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });+ const r = el.getBoundingClientRect();+ cursor.querySelector('.ai-label').textContent = label || '';+ cursor.classList.add('on'); cursor.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`;+ return new Promise((res) => setTimeout(() => res(el), 380));+ }++ // ---------- SSE live channel ----------+ function connect(handlers) {+ const es = new EventSource(BASE + 'events');+ for (const [ev, fn] of Object.entries(handlers)) es.addEventListener(ev, (m) => { try { fn(JSON.parse(m.data)); } catch (e) { console.error('event', ev, e); } });+ es.onerror = () => console.warn('live channel reconnecting');+ return es;+ }+ async function uiCommon(d) {+ if (d.type === 'toast') toast(d.text || '', d.kind || 'info');+ else if (d.type === 'reload') location.reload();+ else if (d.type === 'cursor') await glide(d.selector, d.label || 'AI');+ else if (d.type === 'click') { const el = await glide(d.selector, d.label || 'AI clicks'); el?.click(); setTimeout(() => cursor.classList.remove('on'), 900); }+ else if (d.type === 'reveal') { const el = document.querySelector(d.selector); el?.closest('details')?.setAttribute('open', ''); el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); el?.classList.add('revealed'); setTimeout(() => el?.classList.remove('revealed'), 1500); }+ else if (d.type === 'eval') { try { const v = await (0, eval)(d.js); console.log('eval result:', v); } catch (e) { console.error('eval error:', String(e)); } }+ else return false;+ return true;+ }++ window.UI = { BASE, HUMAN, api, toast, tip, hoverPopover, header, initSettings, connect, uiCommon, glide, setSettings: (s) => { settingsState = s; } };+})();
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
flows/flow.json+1−1@@ -49,7 +49,7 @@ "done": [ "voice.json" ],- "do": "Offer the human two ways to finish the sound, and let them pick. (1) Keep the AI voiceover and go on. (2) Narrate it themselves: play them a private working copy of the draft with the captions showing, as a teleprompter, and let them record their own voice while they watch, in their own words (they need not read it exactly). Then rebuild the film without the on-screen captions, fit each scene to what they actually said, and write new captions and chapters from their recording (check part names and numbers against the script). The teleprompter copy is never published. `mark voice --note \"ai voiceover\"` or `mark voice --note \"own narration\"`."+ "do": "Offer the human two ways to finish the sound, and let them pick. (1) Keep the AI voiceover and go on. (2) Narrate it themselves: `narrate --video <draft.mp4>` serves a page where they calibrate their microphone, rehearse, record their own words while the film plays (with a clock, a wrap-up warning and drawing on the film), review it and press Use this take. Run `narrate wait` for the hand-off, tell them on the page what you are doing (`narrate note \"...\"`), then put their voice on the film, write new captions and chapters from what they said and draw their strokes in. A recorder of their own (a wireless mic that records on the device, a phone) works too. `mark voice --note \"ai voiceover\"` or `mark voice --note \"own narration\"`." }, { "name": "privacy",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.1.6",+ "version": "0.2.0", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.1.6",+ "version": "0.2.0", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
skills/adom-aiflow-video/SKILL.md+16−6@@ -18,7 +18,8 @@ adom-aiflow-video script script.json # feedback on the narration adom-aiflow-video scan <text files> # personal details in text that will be published adom-aiflow-video mark <step> --note "..." --ai-thread <you> # record a step done in your own words adom-aiflow-video track <path> [--machine <name>] [--keep] --ai-thread <you>-adom-aiflow-video narrate --video <teleprompter.mp4> # a page that records the human's voice in step with the film+adom-aiflow-video narrate --video <draft.mp4> # the narration page: calibrate, rehearse, record with drawing, review, hand-off+adom-aiflow-video narrate wait | status | note "..." # the AI's side of that page adom-aiflow-video clean [--yes] # what the run left behind; --yes removes it ``` @@ -63,12 +64,21 @@ A film reads as machine-made when it could be about any board. Keep it about thi Let the human choose, and make either easy. - **AI voiceover**: fine to publish as it is. Say so; do not make them feel it is second best.-- **Their own voice**: the biggest step up in quality. Make a private working copy of the draft with the captions showing as a teleprompter, and let them record while they watch, in their own words; they do not have to read it exactly. Then rebuild the film without on-screen captions, fit each scene's footage to what they actually said, and write the captions and chapters again from their recording (check part names and numbers against the script; a transcript gets those wrong). The teleprompter copy is a working file and is never published; published captions stay sidecar files.+- **Their own voice**: the biggest step up in quality. `adom-aiflow-video narrate --video <draft.mp4> [--captions <draft.vtt>] [--chapters <chapters.json>] [--title "..."]` serves the narration page (it finds the captions and chapters beside the film when they share its name). No special copy of the film is needed: the page plays the draft, muted, in the Adom video player with the captions as the lines to read. Open its address in their own browser on the machine with the microphone and bring that window to the front (a browser holds a microphone request from a background window). - Ask which microphone they want to use; a better mic is worth it:- - **A recorder of their own** (a wireless lavalier or handheld that records on the device, a field recorder, a phone): they record while watching the teleprompter copy, plug the recorder in or send the file, and you fetch the newest recording. Start the teleprompter with a countdown and ask them to say "start" as it ends, so you can line the recording up; then match each scene by what they said.- - **The computer's microphone** (built in, or any USB mic the computer sees): `adom-aiflow-video narrate --video <teleprompter.mp4>` serves a page that plays the teleprompter copy and records them in step with it. Open its address in their own browser on the machine with the microphone (a pup window or their default browser), they pick the microphone, check the level, press Record and read along. Each take lands in `video-flow/voice/take-N.webm` with `take-N.json` beside it: which microphone, the peak level, and how many milliseconds into the recording the video started, so every word lines up with the film. You can drive the open page yourself, and the human sees your cursor do it: `narrate status` (which microphones it sees, whether access was allowed, the level, where the video is, the takes), `narrate test`, `narrate mic <name>`, `narrate record`, `narrate stop`, `narrate seek <seconds>`, `narrate toast "..."` to tell them something on the page. Suggest `narrate calibrate` before the first take. The page walks them through it full-screen with a countdown for each part: three seconds to get ready, three of quiet, then a line from their own video to read out loud (keep the captions file beside the teleprompter copy, same name, so it has one). It ends on a result card that says what changed: the boost before and after, their voice level before and after, the room level, how far apart the two are, and whether that is good, fine or noisy; when it could not tell their voice from the room it says so, changes nothing and offers another go. `narrate status` shows the step and seconds left while it runs, and the numbers afterwards. `narrate gain <x>` sets the boost by hand; a limiter keeps a boosted voice from clipping. The browser's own microphone permission is theirs to click. Bring the page's window to the front before a mic test or a take: a browser holds a microphone request from a window that is in the background, so nothing happens until it is visible.- Either way, listen to the result before you cut: check for clipping, room noise and missed scenes, and offer a retake of single scenes rather than the whole film.+ What the page gives them, so you do not have to explain it:+ - **Calibrate**: a counted-down quiet moment, then a line from their own film to read; it sets the microphone boost for their voice and shows what changed and how far their voice is above the room.+ - **Rehearse**: the film with its captions, nothing recorded.+ - **Record**: a countdown, then the film plays with a clock, the chapter on screen and the next one, time left, and a wrap-up warning before the end. They speak in their own words; they need not read the lines exactly.+ - **Drawing**: they can circle or point at things on the film while they talk. A drawing fades after two seconds and Space wipes it at once.+ - **Review**: the film with their voice on it, with the player's own pause and timeline.+ - **Use this take**: the hand-off to you.++ Your side: run `narrate wait` (it returns when they press Use this take, with the file and its facts), and keep them informed on the page with `narrate note "..."`, which shows in its From your AI box; never leave them wondering whether their recording was used. `narrate status` shows what the page is doing, `narrate console` its log; you can also press its buttons (`narrate calibrate|rehearse|record|stop|review <n>|use <n>|wipe`) and they see your cursor do it.++ Then: put their voice on the film (each take's `take-N.json` says how many milliseconds into the recording the film started), drop the AI voice, write the captions and chapters again from what they actually said (check part names and numbers against the script; a transcript gets those wrong), and draw their strokes into the film: `take-N.draw.json` lists each one as `{t0, end, wiped, pts: [[x, y, dt], ...]}` with x and y from 0 to 1 across the picture, `t0` the film time it started, each point `dt` seconds after that; show it from `t0`, and fade it over the fade time after `end` unless `wiped` says when it was cleared. Listen to the result before you show it: clipping, room noise, missed scenes. Offer a retake of single scenes rather than the whole film.++- **A recorder of their own** (a wireless lavalier or handheld that records on the device, a field recorder, a phone): they record while the narration page rehearses the film, then plug the recorder in or send the file, and you fetch the newest recording. Ask them to say "start" as the countdown ends so you can line it up; then match each scene by what they said. ## Privacy
src/main.rs+31−4@@ -59,8 +59,8 @@ enum Cmd { /// Print the walkthrough guide: the steps file, how to record a take, and what we learned Guide, /// Let the human narrate with their computer's microphone: `narrate --video teleprompter.mp4` serves a page that plays it and records them in step- /// With an action it drives the open page: `narrate status|test|calibrate|record|stop|play|pause|reload`, `narrate gain <x>`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>`- Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16 },+ /// With an action it drives the open page: `narrate status|wait|console|test|calibrate|rehearse|record|stop|play|pause|wipe|reload`, `narrate review|use <take number>`, `narrate note "<text>"`, `narrate gain <x>`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>`+ Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] captions: Option<String>, #[arg(long)] chapters: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16, #[arg(long, default_value_t = 3600)] timeout: u64 }, } fn thread(cli: &Cli) -> String {@@ -199,16 +199,40 @@ fn main() { ok(&format!("walkthrough director written to {out}"), &["Inject it into the project's Adom 3D viewer page with pup_evaluate, then GHW.setupCase / GHW.setupBoard / GHW.mergeBoard / GHW.run. `guide` explains the steps file and the take.".into()]); } Cmd::Guide => print!("{}", include_str!("../docs/walkthrough.md")),- Cmd::Narrate { action, arg, video, out, port } => match action.as_deref() {+ Cmd::Narrate { action, arg, video, captions, chapters, title, out, port, timeout } => match action.as_deref() { None | Some("serve") => { let Some(video) = video else { err("narrate needs the teleprompter copy: --video <teleprompter.mp4>", &["Make a working copy of the draft with the captions showing and a short countdown at the start.".into(), "To drive a page that is already open: narrate status | test | record | stop | mic <name> | seek <seconds> | toast \"...\"".into()]) }; if !Path::new(video).is_file() { err(&format!("could not find the teleprompter video: {video}"), &["Make a working copy of the draft with the captions showing and a short countdown at the start, then pass it with --video.".into()]); } let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap()); let dir = out.as_ref().map(PathBuf::from).or_else(|| ledger::Project::find(&start, FLOW_DIR).map(|p| p.path("voice"))).unwrap_or_else(|| start.join("voice"));- if let Err(e) = narrate::serve(Path::new(video), &dir, *port) { err(&e, &["Another narration page may still be running on that port; leave --port off to take a free one.".into()]); }+ if let Err(e) = narrate::serve(Path::new(video), &dir, *port, captions.as_ref().map(Path::new), chapters.as_ref().map(Path::new), title.as_deref()) { err(&e, &["Another narration page may still be running on that port; leave --port off to take a free one.".into()]); } } Some(act) => { let p = if *port != 0 { *port } else { narrate::find_port().unwrap_or_else(|| err("no narration page is running", &["Start one: adom-aiflow-video narrate --video <teleprompter.mp4>, then open its address in the human's browser.".into()])) };+ if act == "wait" {+ // block until the human presses Use this take (or the timeout): the hand-off to the AI+ let t0 = std::time::Instant::now();+ loop {+ let (_, body) = narrate::call(p, "GET", "/state", None, "").unwrap_or_else(|e| err(&e, &["The narration page's server stopped.".into()]));+ let st: Value = serde_json::from_str(&body).unwrap_or(json!({}));+ if let Some(f) = st["chosen"].as_str() {+ let dir = st["out"].as_str().unwrap_or("");+ let tk = st["takes"].as_array().and_then(|a| a.iter().find(|t| t["file"] == f).cloned()).unwrap_or(json!({}));+ ok(&format!("the human chose {dir}/{f}"), &[+ format!("{:.0} s, peak {}, microphone {}; the film starts {} ms into the recording; {} drawing(s){}", tk["seconds"].as_f64().unwrap_or(0.0), tk["peak"], tk["mic"].as_str().unwrap_or("?"), tk["videoStartsAtMs"], tk["strokes"], if tk["strokes"].as_u64().unwrap_or(0) > 0 { format!(" in {dir}/{}", Path::new(f).with_extension("draw.json").display()) } else { String::new() }),+ "Tell them on the page what you are doing (`narrate note \"...\"`), put their voice on the film, write new captions from what they said, render their drawings, and show them the result before anything is published.".into()]);+ return;+ }+ if t0.elapsed().as_secs() >= *timeout { ok("still waiting: no take chosen yet", &[format!("{} take(s) recorded so far; run `narrate wait` again, or `narrate status`.", st["takes"].as_array().map(|a| a.len()).unwrap_or(0))]); return; }+ std::thread::sleep(std::time::Duration::from_secs(2));+ }+ }+ if act == "console" {+ let (_, body) = narrate::call(p, "GET", "/console", None, "").unwrap_or_else(|e| err(&e, &[]));+ let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));+ for l in v["lines"].as_array().cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); }+ return;+ } if act == "status" { let (_, body) = narrate::call(p, "GET", "/state", None, "").unwrap_or_else(|e| err(&e, &[])); let st: Value = serde_json::from_str(&body).unwrap_or(json!({}));@@ -216,6 +240,7 @@ fn main() { let mut h = vec![format!("page open in {} browser tab(s); teleprompter {}", st["pagesOpen"], st["video"].as_str().unwrap_or(""))]; if pg.is_object() && !pg.as_object().unwrap().is_empty() { h.push(format!("microphone: {} ({} available: {}); access {}", pg["mic"].as_str().unwrap_or("not picked"), pg["mics"].as_array().map(|a| a.len()).unwrap_or(0), pg["mics"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" | ")).unwrap_or_default(), pg["micAccess"].as_str().unwrap_or("not asked yet")));+ h.push(format!("mode {}; chapter \"{}\"; {} s left; chosen take: {}", pg["mode"].as_str().unwrap_or("?"), pg["chapter"].as_str().unwrap_or(""), pg["left"], st["chosen"].as_str().unwrap_or("none yet"))); h.push(format!("{}; video at {:.1} of {:.1} s; level now {:.2}, peak {:.2}", if pg["recording"].as_bool().unwrap_or(false) { "RECORDING" } else { "not recording" }, pg["t"].as_f64().unwrap_or(0.0), pg["duration"].as_f64().unwrap_or(0.0), pg["level"].as_f64().unwrap_or(0.0), pg["peak"].as_f64().unwrap_or(0.0))); h.push(format!("boost {}x (raw microphone level {:.3}); calibration: {}", pg["boost"], pg["rawLevel"].as_f64().unwrap_or(0.0), if pg["calibrating"].is_object() && pg["calibrating"]["step"] != 3 { format!("RUNNING, step {}: {} ({} s left)", pg["calibrating"]["step"], pg["calibrating"]["title"].as_str().unwrap_or(""), pg["calibrating"]["secondsLeft"]) } else if pg["calibration"].is_object() { pg["calibration"].to_string() } else { "not done yet (`narrate calibrate`)".into() })); h.push(format!("the page says: {}", pg["status"].as_str().unwrap_or("")));@@ -229,6 +254,8 @@ fn main() { ("mic", Some(a)) => json!({"name": a}), ("gain", Some(a)) => json!({"value": a.parse::<f64>().unwrap_or(1.0)}), ("toast", Some(a)) => json!({"text": a, "kind": "info"}),+ ("note", a) => json!({"text": a.clone().unwrap_or_default()}),+ ("review" | "use", Some(a)) => json!({"take": a.parse::<u64>().unwrap_or(0)}), ("click" | "reveal" | "cursor", Some(a)) => json!({"selector": a}), _ => json!({}), };
src/narrate.rs+137−15@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use serde_json::{json, Value}; /// What every connection shares: the open pages' event streams and the state they sync to.-pub struct Shared { clients: Vec<TcpStream>, state: Value }+pub struct Shared { clients: Vec<TcpStream>, state: Value, console: Vec<String> } /// Push one event to every open page; a page that has gone away is dropped. fn broadcast(sh: &Arc<Mutex<Shared>>, event: &str, data: &Value) {@@ -19,15 +19,19 @@ fn broadcast(sh: &Arc<Mutex<Shared>>, event: &str, data: &Value) { } fn takes_in(out: &Path) -> Vec<Value> {- let mut v: Vec<Value> = std::fs::read_dir(out).map(|d| d.flatten().filter(|e| e.file_name().to_string_lossy().ends_with(".json") && e.file_name().to_string_lossy().starts_with("take-"))- .filter_map(|e| std::fs::read_to_string(e.path()).ok().and_then(|t| serde_json::from_str(&t).ok())).collect()).unwrap_or_default();- v.sort_by_key(|x: &Value| x["file"].as_str().unwrap_or("").to_string());+ let mut v: Vec<Value> = std::fs::read_dir(out).map(|d| d.flatten().filter(|e| { let f = e.file_name().to_string_lossy().to_string(); f.strip_prefix("take-").and_then(|r| r.strip_suffix(".json")).map(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())).unwrap_or(false) })+ .filter_map(|e| std::fs::read_to_string(e.path()).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|mut v| {+ let d = e.path().with_extension("").with_extension("draw.json");+ if let Some(s) = std::fs::read_to_string(&d).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { v["strokesData"] = s; }+ v+ })).collect()).unwrap_or_default();+ v.sort_by_key(|x: &Value| x["file"].as_str().unwrap_or("").trim_start_matches("take-").split('.').next().and_then(|d| d.parse::<u32>().ok()).unwrap_or(0)); v } -/// The text lines of the .srt or .vtt that sits beside the video (same name), in order.+/// The text lines of a .srt or .vtt file, in order. fn caption_lines(video: &Path) -> Vec<String> {- let text = ["srt", "vtt"].iter().find_map(|e| std::fs::read_to_string(video.with_extension(e)).ok()).unwrap_or_default();+ let text = std::fs::read_to_string(video).unwrap_or_default(); let mut out: Vec<String> = vec![]; for l in text.lines().map(str::trim) { if l.is_empty() || l == "WEBVTT" || l.contains("-->") || l.chars().all(|c| c.is_ascii_digit()) { continue; }@@ -39,6 +43,41 @@ fn caption_lines(video: &Path) -> Vec<String> { const PAGE: &str = include_str!("../assets/narrate.html"); +/// The page's own files and the vendored house UI layer (assets/vendor/VENDORED.md), all compiled in.+fn asset(name: &str) -> Option<(&'static str, &'static [u8])> {+ Some(match name {+ "narrate.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/narrate.js")),+ "narrate.css" => ("text/css; charset=utf-8", include_bytes!("../assets/narrate.css")),+ "adom-video.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/adom-video.js")),+ "ui.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/ui.js")),+ "icons.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/icons.js")),+ "base.css" => ("text/css; charset=utf-8", include_bytes!("../assets/vendor/base.css")),+ "settings-cog-outline.svg" => ("image/svg+xml", include_bytes!("../assets/vendor/settings-cog-outline.svg")),+ "fonts/familjen-grotesk-700.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/familjen-grotesk-700.woff2")),+ "fonts/satoshi-400.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/satoshi-400.woff2")),+ "fonts/satoshi-500.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/satoshi-500.woff2")),+ "fonts/jetbrains-mono-400.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/jetbrains-mono-400.woff2")),+ "fonts/jetbrains-mono-500.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/jetbrains-mono-500.woff2")),+ _ => return None,+ })+}++/// The settings a person can change in the page's Settings window; stored for every film.+fn schema() -> Value {+ json!({+ "preRoll": {"type": "number", "min": 0, "max": 10, "default": 5, "label": "Countdown before recording", "help": "Seconds to get ready after you press Record, before the film starts and your voice is recorded."},+ "wrapUp": {"type": "number", "min": 0, "max": 30, "default": 10, "label": "Wrap-up warning", "help": "How many seconds before the end of the film the page tells you to wrap up. 0 turns the warning off."},+ "drawFade": {"type": "number", "min": 1, "max": 10, "default": 2, "label": "Drawings fade after", "help": "Seconds a circle or line you draw on the film stays before it fades. Space wipes it at once."}+ })+}+fn settings_file() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".adom/adom-aiflow-video/settings.json") }+fn load_settings() -> Value {+ let mut s: Value = std::fs::read_to_string(settings_file()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ for (k, d) in schema().as_object().unwrap() { if !s[k].is_number() { s[k] = d["default"].clone(); } }+ s+}+fn sibling(video: &Path, exts: &[&str]) -> Option<PathBuf> { exts.iter().map(|e| video.with_extension(e)).find(|p| p.is_file()) }+ fn respond(s: &mut TcpStream, status: &str, ctype: &str, extra: &str, body: &[u8]) { let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n{extra}\r\n", body.len()); let _ = s.write_all(body);@@ -103,10 +142,81 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let path = target.split('?').next().unwrap_or("/"); match (method.as_str(), path) { ("GET", "/") => respond(&mut s, "200 OK", "text/html; charset=utf-8", "", PAGE.as_bytes()),+ ("GET", "/favicon.svg") => respond(&mut s, "200 OK", "image/svg+xml", "", include_bytes!("../assets/favicon.svg")),+ ("GET", p) if p.starts_with("/static/") => match asset(p.trim_start_matches("/static/")) {+ Some((ctype, body)) => respond(&mut s, "200 OK", ctype, "", body),+ None => respond(&mut s, "404 Not Found", "text/plain", "", b"not found"),+ },+ // the film's chapters and captions, for the player+ ("GET", "/chapters.json") => {+ let f = sh.lock().unwrap().state["chaptersFile"].as_str().map(PathBuf::from);+ let body = f.and_then(|f| std::fs::read(f).ok()).unwrap_or_else(|| b"[]".to_vec());+ respond(&mut s, "200 OK", "application/json", "", &body)+ }+ ("GET", "/captions.vtt") => {+ let f = sh.lock().unwrap().state["captionsFile"].as_str().map(PathBuf::from);+ let body = f.and_then(|f| std::fs::read(f).ok()).unwrap_or_else(|| b"WEBVTT\n\n".to_vec());+ respond(&mut s, "200 OK", "text/vtt; charset=utf-8", "", &body)+ }+ ("GET", "/settings") => {+ let st = sh.lock().unwrap().state["settings"].clone();+ respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "settings": st, "schema": schema()}).to_string().as_bytes())+ }+ ("POST", "/settings") => {+ let mut body = vec![0u8; len];+ let _ = rd.read_exact(&mut body);+ let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));+ let sc = schema();+ let mut st = if req["restoreDefaults"].as_bool().unwrap_or(false) { json!({}) } else { sh.lock().unwrap().state["settings"].clone() };+ for (k, d) in sc.as_object().unwrap() {+ if let Some(x) = req[k].as_f64() { st[k] = json!(x.clamp(d["min"].as_f64().unwrap_or(0.0), d["max"].as_f64().unwrap_or(1e9))); }+ if !st[k].is_number() { st[k] = d["default"].clone(); }+ }+ let _ = std::fs::create_dir_all(settings_file().parent().unwrap());+ let _ = std::fs::write(settings_file(), serde_json::to_string_pretty(&st).unwrap_or_default());+ sh.lock().unwrap().state["settings"] = st.clone();+ broadcast(&sh, "state", &json!({"settings": st}));+ respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "settings": st}).to_string().as_bytes())+ }+ // the page's console, so the AI can read it without DevTools+ ("POST", "/console") => {+ let mut body = vec![0u8; len];+ let _ = rd.read_exact(&mut body);+ if let Ok(v) = serde_json::from_slice::<Value>(&body) {+ let mut g = sh.lock().unwrap();+ for l in v["lines"].as_array().cloned().unwrap_or_default() { g.console.push(format!("{} {}", l["level"].as_str().unwrap_or("log"), l["msg"].as_str().unwrap_or(""))); }+ let extra = g.console.len().saturating_sub(300); g.console.drain(..extra);+ }+ respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")+ }+ ("GET", "/console") => { let body = json!({"lines": sh.lock().unwrap().console}).to_string(); respond(&mut s, "200 OK", "application/json", "", body.as_bytes()) }+ // the human picked a take: this is the hand-off to the AI+ ("POST", "/choose") => {+ let mut body = vec![0u8; len];+ let _ = rd.read_exact(&mut body);+ let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));+ let file = req["file"].as_str().unwrap_or("").to_string();+ if file.is_empty() || file.contains('/') || !out.join(&file).is_file() { return respond(&mut s, "404 Not Found", "application/json", "", b"{\"error\":\"no such take\"}"); }+ sh.lock().unwrap().state["chosen"] = file.clone().into();+ println!("OK: the human chose {}", out.join(&file).display());+ println!("Hint: put their voice on the film (the take's json says how far into the recording the film started), write new captions from what they said, render any drawings (take-N.draw.json), and tell them on the page what you are doing: `narrate note \"...\"`. Show them the result before anything is published.");+ respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "file": file}).to_string().as_bytes())+ }+ // the drawings made during a take: [{t0, end, wiped, pts: [[x, y, dt], ...]}], x and y as 0..1 of the picture+ ("POST", p) if p == "/draw" => {+ let mut body = vec![0u8; len];+ let _ = rd.read_exact(&mut body);+ let file = target.split_once("file=").map(|x| pct_decode(x.1)).unwrap_or_default();+ if file.is_empty() || file.contains('/') { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"which take\"}"); }+ let stem = Path::new(&file).with_extension("");+ let _ = std::fs::write(out.join(stem).with_extension("draw.json"), &body);+ respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")+ } ("GET", "/video") => serve_file(&mut s, &video, range.as_deref()), // the spoken lines of this video, so the calibration reads a real one ("GET", "/captions") => {- let lines = caption_lines(&video);+ let f = sh.lock().unwrap().state["captionsFile"].as_str().map(PathBuf::from);+ let lines = f.map(|f| caption_lines(&f)).unwrap_or_default(); respond(&mut s, "200 OK", "application/json", "", json!({"lines": lines}).to_string().as_bytes()) } // the live channel: the page opens this once and every directive arrives on it@@ -140,12 +250,17 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let mut data: Value = serde_json::from_slice(&body).unwrap_or(json!({})); if who.is_empty() { who = data["aiThread"].as_str().unwrap_or("").to_string(); } if who.is_empty() { return respond(&mut s, "400 Bad Request", "application/json", "", json!({"error": "caller_identity_required", "hint": "say who is driving the page: pass --ai-thread \"<your thread name>\" (header X-AI-Thread)"}).to_string().as_bytes()); }- if !["record", "stop", "test", "calibrate", "gain", "mic", "seek", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {- return respond(&mut s, "404 Not Found", "application/json", "", json!({"error": "unknown directive", "hint": "record, stop, test, calibrate, gain, mic, seek, play, pause, toast, click, reveal, cursor, reload"}).to_string().as_bytes());+ if !["record", "rehearse", "stop", "test", "calibrate", "gain", "mic", "seek", "review", "use", "wipe", "note", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {+ return respond(&mut s, "404 Not Found", "application/json", "", json!({"error": "unknown directive", "hint": "record, rehearse, stop, test, calibrate, gain, mic, seek, play, pause, review, use, wipe, note, toast, click, reveal, cursor, reload"}).to_string().as_bytes()); } data["by"] = who.clone().into(); let pages = sh.lock().unwrap().clients.len();- broadcast(&sh, &verb, &data);+ if verb == "note" { sh.lock().unwrap().state["note"] = data["text"].clone(); }+ if ["toast", "click", "reveal", "cursor", "reload"].contains(&verb.as_str()) {+ data["type"] = verb.clone().into();+ if data["label"].is_null() { data["label"] = who.clone().into(); }+ broadcast(&sh, "ui", &data);+ } else { broadcast(&sh, &verb, &data); } sh.lock().unwrap().state["lastDirective"] = json!({"verb": verb, "by": who, "data": data}); respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "verb": verb, "pagesOpen": pages}).to_string().as_bytes()) }@@ -158,12 +273,13 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let mut body = vec![0u8; len]; if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); } let _ = std::fs::create_dir_all(&out);- let n = std::fs::read_dir(&out).map(|d| d.flatten().filter(|e| e.file_name().to_string_lossy().starts_with("take-") && !e.file_name().to_string_lossy().ends_with(".json")).count()).unwrap_or(0) + 1;+ let n = takes_in(&out).len() + 1; let ext = if meta.contains("audio/mp4") { "m4a" } else { "webm" }; let file = format!("take-{n}.{ext}"); if std::fs::write(out.join(&file), &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write the take\"}"); }- let secs = seconds_of(&out.join(&file)); let mut m: serde_json::Value = serde_json::from_str(&meta).unwrap_or(serde_json::json!({}));+ let mut secs = seconds_of(&out.join(&file));+ if secs < 0.5 { secs = m["recordedMs"].as_f64().unwrap_or(0.0) / 1000.0; } m["file"] = file.clone().into(); m["bytes"] = body.len().into(); m["seconds"] = secs.into(); m["video"] = video.display().to_string().into(); let _ = std::fs::write(out.join(format!("take-{n}.json")), serde_json::to_string_pretty(&m).unwrap_or_default() + "\n"); println!("OK: take {n} saved: {} ({:.0} s, peak {})", out.join(&file).display(), secs, m["peak"]);@@ -199,15 +315,21 @@ pub fn call(port: u16, method: &str, path: &str, ai_thread: Option<&str>, body: } /// Serve until stopped. Returns only when the port cannot be bound.-pub fn serve(video: &Path, out: &Path, port: u16) -> Result<(), String> {+pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapters: Option<&Path>, title: Option<&str>) -> Result<(), String> { let l = TcpListener::bind(("0.0.0.0", port)).map_err(|e| format!("could not listen on port {port}: {e}"))?; let port = l.local_addr().map(|a| a.port()).unwrap_or(port);- let sh = Arc::new(Mutex::new(Shared { clients: vec![], state: json!({"app": "adom-aiflow-video narrate", "port": port, "video": video.display().to_string(), "out": out.display().to_string(), "takes": takes_in(out), "page": {}}) }));+ let caps = captions.map(Path::to_path_buf).or_else(|| sibling(video, &["vtt", "srt"]));+ let chaps = chapters.map(Path::to_path_buf).or_else(|| sibling(video, &["chapters.json"])).or_else(|| video.parent().map(|d| d.join("chapters.json")).filter(|p| p.is_file()));+ let name = title.map(str::to_string).unwrap_or_else(|| video.file_stem().map(|x| x.to_string_lossy().replace(['-', '_'], " ")).unwrap_or_default());+ let sh = Arc::new(Mutex::new(Shared { clients: vec![], console: vec![], state: json!({"app": "adom-aiflow-video narrate", "version": env!("CARGO_PKG_VERSION"), "port": port, "title": name, "video": video.display().to_string(), "out": out.display().to_string(),+ "captionsFile": caps.as_ref().map(|p| p.display().to_string()), "chaptersFile": chaps.as_ref().map(|p| p.display().to_string()), "settings": load_settings(), "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) }));+ if caps.is_none() { println!("Hint: no captions file beside the film (same name, .vtt or .srt), so the human has no lines to read: pass --captions <file>."); }+ if chaps.is_none() { println!("Hint: no chapters file (same name, .chapters.json): pass --chapters <file> so the timeline shows where each part starts."); } let _ = std::fs::create_dir_all(registry()); let _ = std::fs::write(registry().join(format!("narrate-{port}.json")), json!({"port": port, "pid": std::process::id(), "video": video.display().to_string(), "out": out.display().to_string()}).to_string()); println!("OK: narration page at http://localhost:{port}/ (teleprompter: {})", video.display()); println!("Hint: open it in the human's own browser on the machine with the microphone (a pup window or their default browser; a browser allows the microphone on localhost). They pick the mic, press Record and read along; each take is saved in {}.", out.display());- println!("Hint: you can drive the open page yourself: `narrate status`, `narrate test`, `narrate record`, `narrate stop`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast \"...\"` (each with --ai-thread).");+ println!("Hint: you can drive the open page yourself: `narrate status`, `narrate calibrate`, `narrate rehearse`, `narrate record`, `narrate stop`, `narrate review <n>`, `narrate note \"...\"` (each with --ai-thread). `narrate wait` returns when they press Use this take."); println!("Hint: leave this running while they record; stop it (Ctrl-C) when they are done."); let hb = sh.clone(); std::thread::spawn(move || loop {