1<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 2<title>Clip Lab â vertical packs ready to post, or a written manual. No credit meter.</title> 3<!-- e2e-bump-20260807c --> 4<meta name="description" content="Make clips from long video for TikTok, Shorts, Reels. Captions burned. High-impact first. Manual from the same pass. Re-cuts without re-paying the meter. Day pass $2.22."> 5<style> 6:root{--ink:#14110e;--paper:#faf7f2;--acc:#b4552d;--soft:#f0e9df;--line:#e2d8c8;--dark:#3a2a1e} 7*{box-sizing:border-box}body{margin:0;font:17px/1.6 Georgia,"Times New Roman",serif;background:var(--paper);color:var(--ink)} 8header{text-align:center;padding:60px 20px 26px} 9h1{font-size:clamp(32px,6vw,54px);margin:0 0 10px;letter-spacing:-.5px} 10h1 em{color:var(--acc);font-style:normal} 11.sub{color:#6b5f4e;font-size:20px;max-width:640px;margin:0 auto} 12.chip{display:inline-block;background:var(--soft);border-radius:20px;padding:6px 16px;font:13px system-ui;color:#6b5f4e;margin-top:14px} 13main{max-width:880px;margin:0 auto;padding:14px 20px} 14#drop{border:3px dashed var(--acc);border-radius:18px;padding:56px 20px;text-align:center;background:var(--soft);cursor:pointer;transition:.15s} 15#drop.hot{background:#f7e9de;transform:scale(1.005)} 16#drop b{font-size:22px} 17#drop small{display:block;color:#6b5f4e;margin-top:8px;font-family:system-ui} 18.capdrop{text-align:center;font:13px system-ui;color:#6b5f4e;margin-top:8px} 19#meterrow{display:none;font:13px monospace;color:#6b5f4e;padding:14px 4px;gap:20px;flex-wrap:wrap} 20#meterrow b{color:var(--acc)} 21video{display:none} 22#film{display:none;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin:18px 0} 23#film figure{margin:0;background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:pointer;transition:.1s} 24#film figure:hover{border-color:var(--acc)} 25#film figure.sel{border:2px solid var(--acc);background:var(--soft)} 26#film img{width:100%;display:block} 27#film figcaption{font:11px monospace;padding:5px 8px;color:#6b5f4e} 28#makerow{display:none;text-align:center;margin:20px 0;flex-wrap:wrap;gap:10px;justify-content:center} 29button.go{background:var(--acc);color:#fff;border:0;border-radius:10px;font:700 18px Georgia,serif;padding:15px 42px;cursor:pointer} 30button.go.sec{background:#6b5f4e;font-size:15px;padding:12px 26px} 31button.go.ter{background:transparent;color:var(--acc);border:2px solid var(--acc);font-size:14px;padding:10px 22px} 32#status{text-align:center;color:var(--acc);font-weight:700;min-height:24px;margin:8px 0;font-family:system-ui;font-size:14px} 33 34/* ââ CREATOR BENCH ââ */ 35#bench{display:none;border-top:2px solid var(--line);margin-top:32px;padding-top:28px} 36#bench h2{font-size:22px;margin:0 0 6px;color:var(--dark)} 37#bench .benchsub{color:#6b5f4e;font-family:system-ui;font-size:14px;margin:0 0 20px} 38.benchgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px} 39.benchtile{background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px} 40.benchtile h3{font-size:16px;margin:0 0 6px;color:var(--dark)} 41.benchtile p{font:13px/1.5 system-ui;color:#6b5f4e;margin:0 0 12px} 42.benchtile label{font:12px system-ui;color:#6b5f4e;display:block;margin-bottom:4px} 43.benchtile input[type=range]{width:100%;accent-color:var(--acc)} 44.benchtile .tval{font:12px monospace;color:var(--acc)} 45.benchtile textarea{width:100%;font:12px system-ui;border:1px solid var(--line);border-radius:6px;padding:8px;resize:vertical;min-height:60px;color:var(--ink);background:var(--paper)} 46.benchtile .note{font:11px system-ui;color:#9a8a7a;margin-top:8px;line-height:1.4} 47#tabdoor{background:var(--soft);border:1px solid var(--line);border-radius:14px;padding:20px;margin-top:16px} 48#tabdoor h3{font-size:16px;margin:0 0 6px;color:var(--dark)} 49#tabdoor p{font:13px/1.5 system-ui;color:#6b5f4e;margin:0 0 12px} 50#tabdoor .note{font:11px system-ui;color:#9a8a7a;margin-top:8px;line-height:1.4} 51 52/* progress bar for clip export */ 53#clipbar{display:none;background:var(--soft);border-radius:8px;height:10px;overflow:hidden;margin:8px 0} 54#clipprog{height:100%;background:var(--acc);width:0%;transition:width .1s} 55#clipstatus{font:12px system-ui;color:var(--acc);min-height:16px;margin:4px 0 0} 56 57.how{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;
57margin:36px 0} 58.how div{background:#fff;border:1px solid var(--line);border-radius:12px;padding:20px} 59.how b{color:var(--acc);display:block;margin-bottom:6px} 60.pro{border:2px solid var(--acc);border-radius:16px;padding:28px;margin:30px 0;text-align:center;background:#fff} 61.pro a{display:inline-block;background:var(--acc);color:#fff;font-weight:700;text-decoration:none;padding:13px 38px;border-radius:10px;margin-top:10px;font-family:system-ui} 62footer{text-align:center;color:#a5988a;font-size:13px;padding:28px;font-style:italic} 63.howorder{display:flex;flex-direction:column;gap:10px;margin:18px 0 14px;text-align:left} 64.howorder>div{position:relative;padding:12px 16px 12px 52px;background:var(--paper,#f6f2ea);border:1px solid var(--line);border-radius:10px;font:14px/1.6 system-ui;color:var(--dim,#6b6354)} 65.howorder>div>span{position:absolute;left:14px;top:12px;width:26px;height:26px;border-radius:50%;background:var(--acc,#1f6e50);color:#fff;font:700 13px/26px system-ui;text-align:center} 66.howorder>div>b{display:block;color:var(--ink,#25201a);font-size:15px;margin-bottom:3px} 67/* 2026-08-06 â outcome formats first */ 68.fmtpick{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;max-width:840px;margin:18px auto 8px;text-align:left} 69@media (max-width:720px){.fmtpick{grid-template-columns:1fr}} 70.fmtcard{border:2px solid var(--line);border-radius:12px;padding:14px 14px 12px;background:#fff;cursor:pointer;transition:.12s} 71.fmtcard:hover{border-color:var(--acc)} 72.fmtcard.on{border-color:var(--acc);background:var(--soft);box-shadow:0 0 0 1px var(--acc)} 73.fmtcard b{display:block;font:700 16px system-ui;color:var(--dark);margin-bottom:4px} 74.fmtcard span{font:13px/1.45 system-ui;color:#6b5f4e} 75.packbox{display:none;max-width:840px;margin:12px auto 0;border:1px solid var(--line);border-radius:12px;background:#fff;padding:14px 16px;text-align:left} 76.packbox.on{display:block} 77.packbox label{font:12px system-ui;color:#6b5f4e;display:block;margin-bottom:4px} 78.packbox input[type=text],.packbox select{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:14px system-ui;margin-bottom:8px;background:#fff;color:var(--ink)} 79.packpresets{display:flex;flex-wrap:wrap;gap:8px;margin:6px 0 10px} 80.packpresets button{font:12px system-ui;padding:6px 12px;border:1px solid var(--line);border-radius:20px;background:var(--soft);cursor:pointer;color:var(--ink)} 81.packpresets button:hover{border-color:var(--acc);color:var(--acc)} 82main{max-width:880px;margin:0 auto;padding:14px 20px} 83.fmt-hide-manual #makerow button#make, 84.fmt-hide-manual #makerow button#chaps, 85.fmt-hide-manual #makerow button#thumbzip{opacity:.35} 86.fmt-hide-clip #bench #tile_clip, 87.fmt-hide-clip #bench #tile_style, 88.fmt-hide-clip #bench #tile_sub{opacity:.45} 89</style></head><body> 90<a href="../" style="position:fixed;top:14px;left:14px;z-index:9999;display:flex;align-items:center;gap:8px;text-decoration:none;background:rgba(255,255,255,.94);border:1px solid rgba(0,0,0,.10);border-radius:999px;padding:6px 14px 6px 8px;box-shadow:0 2px 10px rgba(0,0,0,.08);backdrop-filter:blur(6px)" title="Back to the rooms"><img src="../assets/akd_heart_emerald.png" alt="" style="height:26px;width:auto"><span style="font:700 13px/1 system-ui,-apple-system,sans-serif;color:#1f6e50;letter-spacing:.3px">Home</span></a> 91 92<header> 93<h1>Turn one long video into a <em>week of posts.</em></h1> 94 95<p class="lede" style="color:#6b5f4e;font-size:18px;max-width:640px;margin:0 auto 6px">Out of credits? Opus too expensive on re-cuts? <b>Drop the episode once. Export verticals ready for TikTok, Shorts, and Reels</b> â captions burned, sound on the cut, high-impact moments first. Same pass can write the manual. Re-cut and restyle without paying the meter again. Four hours costs the same day pass as four minutes.</p> 96 97<div class="fmtpick" id="fmtpick" role="radiogroup" aria-label="Outcome format"> 98 <div class="fmtcard on" data-format="clipping" role="radio" aria-checked="true" tabindex="0"> 99 <b>Clipping format</b> 100 <span>Vertical 9:16 packs at the lengths you ask for. Captions burned in. Sound on the cut. Ready for TikTok, Shorts, Reels â built to pull people back to the full episode.</sp
100an> 101 </div> 102 <div class="fmtcard" data-format="manual" role="radio" aria-checked="false" tabindex="0"> 103 <b>Manual format</b> 104 <span>Searchable typeset manual with real screenshots, chapters with timestamps, contact sheet, and a description list ready to paste.</span> 105 </div> 106 <div class="fmtcard" data-format="both" role="radio" aria-checked="false" tabindex="0"> 107 <b>Both</b> 108 <span>Packs + manual from one pass. Same recording, both finished products.</span> 109 </div> 110</div> 111 112<div class="packbox on" id="packbox"> 113 <p style="font:13.5px/1.55 system-ui;color:#4a4034;margin:0 0 12px;max-width:52ch"> 114 <b>Two kinds of file you can order:</b><br> 115 <b>Separate</b> = each moment is its <em>own</em> vertical file (one post per file â the usual TikTok / Shorts / Reels post).<br> 116 <b>Bound / one reel</b> = several moments <em>merged into one continuous file</em> (one upload, multiple beats â like a highlight reel). 117 </p> 118 <p style="font:12.5px/1.5 system-ui;color:#6b5f4e;margin:0 0 12px;max-width:52ch"> 119 <b>How long should a clip be?</b> Campaign floors are often <b>10s+</b>. The usual sweet spot from real clipper workflows is <b>15â60s</b>. 120 <b>30s</b> is a common default for a âweek of posts,â not a platform law. <b>60s</b> fits deeper hooks. <b>15s</b> fits pure hooks. 121 Use <b>hook 15 + body 45 bound</b> when you want open + payoff in <em>one</em> file. 122 </p> 123 <label for="pack_spec">Your order (edit anytime; rebuild without re-paying the meter)</label> 124 <input type="text" id="pack_spec" placeholder="Example: 7 à 30s separate · or 4 à 30s separate; 1 min of 15s bound" value="7 à 30s separate"> 125 <div class="packpresets" style="display:flex;flex-direction:column;gap:8px;margin:10px 0"> 126 <div style="font:11px system-ui;letter-spacing:.04em;text-transform:uppercase;color:#8a7a68">Separate files (one post each)</div> 127 <div style="display:flex;flex-wrap:wrap;gap:8px"> 128 <button type="button" data-spec="7 à 30s separate" id="week_of_shorts">Week of posts â 7 separate 30s</button> 129 <button type="button" data-spec="5 à 15s separate">5 separate 15s hooks</button> 130 <button type="button" data-spec="5 à 60s separate">5 separate 60s</button> 131 <button type="button" data-spec="3 à 60s separate">3 separate 60s</button> 132 </div> 133 <div style="font:11px system-ui;letter-spacing:.04em;text-transform:uppercase;color:#8a7a68;margin-top:4px">Merged into one file (bound reel)</div> 134 <div style="display:flex;flex-wrap:wrap;gap:8px"> 135 <button type="button" data-spec="1 min of 15s bound">One 60s reel â four 15s beats merged</button> 136 <button type="button" data-spec="mix 15,15,30,60 into one reel">One reel â 15+15+30+60 merged</button> 137 <button type="button" data-spec="hook 15s + body 45s bound">One reel â 15s hook + 45s body merged</button> 138 </div> 139 <div style="font:11px system-ui;letter-spacing:.04em;text-transform:uppercase;color:#8a7a68;margin-top:4px">Combine</div> 140 <div style="display:flex;flex-wrap:wrap;gap:8px"> 141 <button type="button" data-spec="4 à 30s separate; 1 min of 15s bound">4 separate 30s + one merged 60s reel</button> 142 <button type="button" data-spec="5 à 30s separate; 1 min of 15s bound; 1 à 60s separate; manual; chapters">Packs + merged reel + 60s + manual</button> 143 </div> 144 </div> 145 <label for="pack_mode">Which moments get used</label> 146 <select id="pack_mode"> 147 <option value="story">Story arc â hook â tension â reaction/reveal â payoff</option> 148 <option value="impact">High-impact first (best for separate posting / bounties)</option> 149 <option value="sections">One cut per detected section</option> 150 <option value="firsthalf">Prefer the first half of the recording</option> 151 </select> 152 <details id="reference_lab" style="margin-top:10px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:8px"> 153 <summary style="font:600 12.5px system-ui;cursor:pointer;color:#4a4034">Optional current reference â teach this run what ânowâ looks like</summary> 154 <div style="font:12px/1.5 system-ui;color:#6b5f4e;margin-top:8px"> 155 Drop one current winning/reference clip. It stays in this browser. Clip Lab measures its cut rhythm and visual-change density, then uses those measurements only for this run. 156 </div> 157 <input type="file" id="refVideo" accept="video/*" style="margin-top:8px;width:100%;font:12px system-ui"> 158 <div id="refStatus" style="font:12px/1.5 system-ui;color:var(--acc);margin-top:6px">No reference loaded â generic triage only.</div> 159 <label for="scopedTerms" style="display:block;margin-top:8px">Campaign / creator vocabulary (optional, comma-separated)</label> 160 <input id="scopedTerms" type="text" placeholder="creator name, product, event, recurring phrase" style="width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--line);border-radius:6px;font:12px system-ui"> 161 <div class="note">Scoped terms disappear with the page/run. They never become universal Clip Lab rules.</div> 162 <label for="attentionContract" style="display:block;margin-top:10px">
162Campaign attention contract (optional JSON)</label> 163 <input type="file" id="attentionContract" accept=".json,application/json" style="margin-top:6px;width:100%;font:12px system-ui"> 164 <div id="attentionStatus" class="note">No campaign contract loaded. A contract can set run-scoped vocabulary, minimum length, preferred clip lengths, Story/Impact mode, caption density, and CTA timing without sending anything to a cloud.</div> 165 </details> 166 <div id="recipe_preview" style="font:12px system-ui;color:#3d3429;line-height:1.55;margin-top:8px;padding:10px 12px;background:var(--soft);border-radius:8px;border:1px solid var(--line)"> 167 <b>What you walk away with:</b> one pass over the recording, then as many packs as you need â 168 separate post files, merged reels (several beats in one file), mixed lengths, manual, chapters. 169 Change the order and build again without paying to re-open the source. 170 Use <code>;</code> between jobs. 171 Example: <code>5 Ã 30s separate; 1 min of 15s bound; manual</code>. 172 </div> 173</div> 174 175<div class="howorder" style="max-width:840px;margin:16px auto 8px"> 176 <div><span>1</span><b>Choose the format</b>Clipping packs, written manual, or both â above.</div> 177 <div><span>2</span><b>Bring the video in</b>Drop an MP4/WebM/MOV, paste a public YouTube link, or share a playing tab.</div> 178 <div><span>3</span><b>It watches and maps the moments</b>Sections and (when available) spoken words land as a timeline you can export from.</div> 179 <div><span>4</span><b>Finish the result</b>Build duration packs â export verticals with captions and cut sounds â and/or export the PDF manual, chapter list, and thumbnails.</div> 180</div> 181 182<div class="chip" style="margin-top:10px">Any length · Re-cut without re-paying the meter · Day pass $2.22 (does not renew)</div> 183</header> 184<!-- 2026-07-31 â `</header>` WAS MISSING ENTIRELY. `<header>` opened at the top and never 185 closed, so the browser put the ENTIRE PAGE inside it and every element on the Binder 186 inherited `header{text-align:center;padding:60px 20px 26px}`. The intro is meant to be 187 centred; the working surface below it is not. The header ends here, where the reading 188 ends and the doing starts. --> 189 190<!-- Psychology: action before education. Clippers will not sit a long film before dropping a file. Walkthrough shows packs and manual â no duration claim in the label. 191 Full walkthrough is optional below the drop zone. --> 192<div id="drop"><b>Drop a video file here</b><small>or click to choose · MP4 / WebM / MOV · any length, no meter</small> 193<input type="file" id="file" accept="video/*" style="display:none"></div> 194<div class="capdrop">Have captions? Drop a .srt or .vtt file too — every section carries its spoken words. <input type="file" id="caps" accept=".srt,.vtt"></div> 195<!-- D003, 2026-08-20: the watch pass finds every scene CHANGE; which one is worth 196 POSTING is a language question the captions already answer. This button reads the 197 loaded captions and badges the strongest moments, so a 90-minute source becomes a 198 shortlist instead of hundreds of thumbnails. Heuristic, and labelled as one. --> 199<div style="margin-top:10px"> 200 <button class="go sec" id="findmoments" style="font-size:13px;padding:6px 14px">Find the moments — rank the strongest sections from the captions</button> 201 <span id="momentstatus" style="display:block;font-size:12px;color:var(--dim);margin-top:6px"></span> 202</div> 203<!-- D006, 2026-08-20: one examination should pay for a WEEK of posts, not one clip. 204 The trade's own rule is that volume requires posting across the week rather than 205 all at once, so the plan is shown before anything is encoded. --> 206<div style="margin-top:10px"> 207 <button class="go sec" id="planweek" style="font-size:13px;padding:6px 14px">Plan a week of posts — then cut them all</button> 208 <div id="batchstatus" style="font-size:12px;color:var(--dim);margin-top:8px"></div> 209</div> 210<!-- ONE of these, 2026-07-30. This block was in the page THREE TIMES â an append that ran 211 three times, the same defect that had put three
211<script src="captions.js"></script>
vendor: 1 bytes, line 211
211
212<script src="semantic_impact.js"></script>
vendor: 1 bytes, line 212
212
213<script src="semantic_impact.js"> tags in the 214 head. It read as clutter and was worse than that: three elements shared id="capgen", 215 so getElementById returned the FIRST one and only the first button was ever wired. 216 Two of the three were dead controls that looked live. --> 217<div class="capdrop" style="margin-top:8px"> 218 No caption file? <button class="go sec" id="capgen" style="font-size:13px;padding:6px 14px">Generate captions from the audio — burn them into the packs</button> 219 <span id="capgenstatus" style="display:block;font-size:12px;color:var(--dim);margin-top:6px"></span> 220</div> 221 222<!-- MULTI-PLATFORM / OTHER-TAB VIDEO ENTRY POINT (punch-list item 7, expanded 2026-07-20) â 223 A URL-paste box cannot work here: this page runs entirely in your browser 224 with no server, and browsers are not allowed to fetch a video's stream 225 directly from these sites (blocked by design, from any page but their own). 226 So there is exactly ONE primary path for a link or anything already 227 playing in a tab: a small server-side fetch step, or share that tab in. 228 Real extension, 2026-07-20: this now covers YouTube, Facebook, 229 (Rumble removed 2026-07-30 â measured 403, Cloudflare anti-bot; see the handler.) 230 TikTok, and X â the fetch service already ran real, current yt-dlp 231 extractors for all four. Honest scope, stated plainly: PUBLIC content 232 only â nothing here logs in on your behalf, so a private/login-walled 233 link will report that clearly instead of failing silently. --> 234<div id="ytbox" style="margin-top:14px;border:1px solid var(--line);border-radius:12px;background:#fff;padding:16px 18px"> 235 <!-- 2026-07-31, rewritten to the truth. The old copy promised "paste it, we'll pull the 236 real file" and the button was dead. Measured today: YouTube refuses a stream to any 237 server-side client that is not signed in â five different client identities were tried 238 and the plainest answer was "Sign in to confirm you're not a bot." That is not a bug in 239 our relay, it is YouTube's posture toward datacenters, and no relay we own can change 240 it. It used to appear to work only because it ran on one person's home connection. 241 So the link box no longer promises a pull. It offers the two ways that work on 242 EVERY machine, and it says why. --> 243 <div style="font:13px system-ui;color:#6b5f4e;font-weight:700;margin-bottom:10px">Paste a YouTube link. The file comes to you, and the work happens here.</div> 244 <div style="font:13px system-ui;color:var(--ink);line-height:1.8;margin-bottom:12px"> 245 Paste the link and the video comes to you — then it works exactly like a file you dropped 246 in yourself.<br> 247 Public videos only. Nothing is signed into on your behalf, and no account is created.<br> 248 <span style="color:#6b5f4e">If a particular video can't be pulled, the box below says so and names the 249 two paths that always work: download it yourself and drop it in, or play it in another tab and 250 share the tab.</span> 251 </div> 252 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px"> 253 <input type="text" id="yturl_top" placeholder="https://www.youtube.com/watch?v=â¦" style="flex:1;min-width:220px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:13px system-ui"> 254 <button class="go sec" id="ytfetch_top" style="padding:10px 22px;font-size:14px">Fetch it & bind it →</button> 255 </div> 256 <div id="ytstatus_top" style="font:12px system-ui;color:var(--acc);margin-top:4px;min-height:16px"></div> 257 <details style="margin-top:10px"><summary style="font:12px system-ui;color:#6b5f4e;cursor:pointer">Fetch step not reachable? Share a live tab instead (slower, real-time only)</summary> 258 <div class="note" style="margin-top:8px"> 259 A shared tab is a live video feed, not a file — there's no way to jump ahead in something 260 that's still playing, so this runs at the same speed you watch: a 40-minute video takes 40 261 minutes. Use this only when the fetch step above is down or the source has no working link. 262 </div> 263 <button class="go sec" id="tabcap_top" style="padding:12px 26px;font-size:15px;margin-top:8px">Share a playing tab (real-time only) →</button> 264 <div id="tabstatus_top" style="font:12px system-ui;color:var(--acc);margin-top:8px;min-height:16px"></div> 265 </details> 266</div> 267 268<details style="margin:14px 0 8px;border:1px solid var(--line);border-radius:12px;background:#fff;padding:10px 14px;text-align:left"> 269 <summary style="font:600 14px system-ui;color:var(--dark);cursor:pointer">Prefer a full walkthrough first? (real run on this product)</summary> 270 <div style="margin-top:12px;border-radius:10px;overflow:hidden;background:#000"> 271 <video src="../assets/binder_how_to_clip.mp4" controls preload="metadata" playsinline 272 poster="../assets/clip_lesson_poster.jpg" style="width:100%;display:block;max-height:60vh" 273 aria-label="Clip Lab full walkthrough: packs and manual"> 274 <track kind="captions" src="../assets/binder_how_to_clip.vtt" srclang="en" label="English" default> 275 </video> 276 </div> 277 <p style="font:13px system-ui;color:#6b5f4e;margin:10px 0 0;line-height:1.5">Full walkthrough of Clipping and Manual: orders, export, and the finished files on screen â the vertical you post, the PDF manual, the chapter list. That is
277what a day pass buys.</p> 278</details> 279 280<div id="meterrow" style="display:none"><span>watched <b id="m_t">0:00</b></span><span>breaths <b id="m_b">0</b></span><span>scenes kept <b id="m_s">0</b></span><span>dropped <b id="m_d">0</b></span><span>speed <b id="m_x">–</b>× realtime</span></div> 281<div id="status"></div> 282<div id="film"></div> 283 <div id="clipack" style="display:none;max-width:52ch;margin:12px auto 0;padding:12px 14px;background:#f7f1e6;border:1px solid #d9cbb3;border-radius:8px;font:13px/1.5 system-ui;color:#3a3228;text-align:left"> 284 <b>Lock the package before you post.</b> Publishing spends the one free test. Two payouts off this cut â campaign bounty, and your own channel where the brief allows. Clip Lab is in the description, never the title. 285 <label style="display:block;margin:8px 0 4px">Title (no hashtags, â¤100)</label> 286 <input id="clipack_title" style="width:100%;box-sizing:border-box;padding:6px 8px;font:13px system-ui"> 287 <label style="display:block;margin:8px 0 4px">Description (hashtags first, then Clip Lab)</label> 288 <textarea id="clipack_desc" rows="9" style="width:100%;box-sizing:border-box;padding:6px 8px;font:12px/1.45 ui-monospace,monospace"></textarea> 289 <label style="display:block;margin:8px 0 4px">Backend tags (search phrases, â¤500 chars)</label> 290 <input id="clipack_tags" style="width:100%;box-sizing:border-box;padding:6px 8px;font:12px ui-monospace,monospace"> 291 <p id="clipack_why" style="margin:8px 0 0;color:#6b5f4e"></p> 292 <button type="button" id="clipack_copy" class="go sec" style="margin-top:8px">Copy title + description</button> 293 <span id="clipack_copied" style="margin-left:8px;color:#2a6"></span> 294 </div> 295<div id="makerow" style="display:none;flex-direction:column;align-items:center;gap:10px"> 296 <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center"> 297 <button class="go" id="make">Export PDF Manual → a formatted document with your screenshots and steps, ready to print or send</button> 298 <button class="go sec" id="chaps">Export Chapter List (.txt) → timestamps + labels, ready to paste into a YouTube description</button> 299 <button class="go ter" id="thumbzip">Export Thumbnails (.zip) → every screenshot as its own .jpg, for when you want the images alone</button> 300 </div> 301 <div style="font:12px system-ui;color:#6b5f4e;max-width:640px;text-align:center;line-height:1.6"> 302 <b>Export PDF Manual</b> produces one PDF with every kept scene, its screenshot, and the spoken words next to it — a finished manual, ready to print or share. 303 <b>Export Chapter List</b> produces a plain .txt file of timestamps and short labels, formatted to paste straight into a YouTube description. 304 <b>Chapter thumbnails</b> produces a .zip of every kept scene's screenshot as its own .jpg file, named by timestamp — for when you want the images alone, not the bound manual. 305 </div> 306</div> 307<video id="v" muted playsinline preload="auto"></video> 308<canvas id="c" style="display:none"></canvas> 309<canvas id="cprev" style="display:none"></canvas> 310<canvas id="cburn" style="display:none"></canvas> 311 312<!-- âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 313 EXPORT BENCH (was "Creator bench" â internal name, not visitor language) 314 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 315 316<!-- Your files tray: every artifact produced this session registers here --> 317<div id="files_tray" style="display:none;border:1px solid var(--line);border-radius:10px;background:var(--paper);padding:16px 20px;margin-top:20px"> 318 <div style="font-family:system-ui;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:var(--acc);margin-bottom:10px">Your files this session</div> 319 <div id="files_tray_list" style="display:flex;flex-direction:column;gap:8px"></div> 320</div> 321 322<div id="bench"> 323 <h2>Finish the stack</h2> 324 <p class="benchsub">One watch. Mix separate shorts, bound reels, styles, and shapes â export everything ready to post. Re-cut free.</p> 325
326 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--soft)"> 327 <div style="padding:14px 16px;border-right:1px solid var(--line);font-family:system-ui;font-size:13px"> 328 <b style="color:var(--acc);font-size:18px;display:block;margin-bottom:4px">1 Map</b> 329 Drop a video. It watches once and builds moments + words (when captions load). 330 </div> 331 <div style="padding:14px 16px;border-right:1px solid var(--line);font-family:system-ui;font-size:13px"> 332 <b style="color:var(--acc);font-size:18px;display:block;margin-bottom:4px">2 Recipe</b> 333 Mix jobs: separate shorts <em>and</em> bound reels, mixed lengths, hook+body â not one preset. 334 </div> 335 <div style="padding:14px 16px;font-family:system-ui;font-size:13px"> 336 <b style="color:var(--acc);font-size:18px;display:block;margin-bottom:4px">3 Style & ship</b> 337 Caption packs, 9:16 / 1:1, sound on the cut. Export all from this bench. 338 </div> 339 </div> 340<!-- 2026-07-31 â SECOND STRAY `</div>` REMOVED FROM HERE, and this was the one doing the 341 damage. The 1-Watch / 2-Pick / 3-Style strip closes on the line above. This extra closer 342 shut `<div id="bench">` right here, so `.benchgrid` and all three tool panels below it 343 lived OUTSIDE the bench and no `display:none` could ever reach them. 344 Removing only the first stray closer made the file's total `<div>`/`</div>` count balance 345 at zero â which is exactly why a count is not a structure check. Two errors in opposite 346 directions cancel in a total and both survive it. The parse tree is the only honest test: 347 `document.querySelector('.benchtile').closest('#bench')` returned null, and that one 348 question found in a second what three greps had missed. --> 349 350 <div class="benchgrid"> 351 352 <!-- CAPTION STYLE PANEL --> 353 <div class="benchtile" id="tile_style"> 354 <h3>Style your captions</h3> 355 <p>Named packs + fine controls. Applies to every export on this pass (re-cut free â change pack, export again).</p> 356 <div class="packpresets" id="stylePacks" style="margin-bottom:10px"> 357 <button type="button" data-pack="shorts" class="active" aria-pressed="true">Shorts default (karaoke + yellow claims)</button> 358 <button type="button" data-pack="clean">Clean bar</button> 359 <button type="button" data-pack="punch">Bold punch</button> 360 <button type="button" data-pack="statement">Big statement</button> 361 <button type="button" data-pack="hormozi">High energy yellow</button> 362 </div> 363 <label>Font</label> 364 <select id="capFont" style="width:100%;padding:6px;border:1px solid var(--line);border-radius:6px;font:13px system-ui;margin-bottom:8px;background:#fff;color:var(--ink)"> 365 <option value="Arial,sans-serif">Clean sans (Arial)</option> 366 <option value="Georgia,serif">Serif (Georgia)</option> 367 <option value="Impact,'Arial Black',sans-serif">Bold impact</option> 368 <option value="'Courier New',monospace">Monospace</option> 369 </select> 370 <label>Size — <span class="tval" id="capSize_lbl">100%</span></label> 371 <input type="range" id="capSize" min="60" max="160" value="100" step="5"> 372 <div style="display:flex;gap:12px;margin-top:8px"> 373 <div style="flex:1"><label>Text color</label><input type="color" id="capColor" value="#ffffff" style="width:100%;height:32px;border:1px solid var(--line);border-radius:6px;padding:2px"></div> 374 <div style="flex:1"><label>Outline color</label><input type="color" id="capOutline" value="#000000" style="width:100%;height:32px;border:1px solid var(--line);border-radius:6px;padding:2px"></div> 375 </div> 376 <label style="margin-top:8px">Position</label> 377 <select id="capPos" style="width:100%;padding:6px;border:1px solid var(--line);border-radius:6px;font:13px system-ui;background:#fff;color:var(--ink)"> 378 <option value="bottom">Bottom (classic)</option> 379 <option value="top">Top</option> 380 <option value="middle">Middle (big statement style)</option> 381 </select> 382 <label style="margin-top:8px">
382Caption density</label> 383 <select id="capDensity" style="width:100%;padding:6px;border:1px solid var(--line);border-radius:6px;font:13px system-ui;background:#fff;color:var(--ink)"> 384 <option value="selective" selected>Key beats â hook / stakes / reactions / payoff</option> 385 <option value="full">Full transcript â maximum accessibility</option> 386 <option value="minimal">Minimal â only strongest hook/payoff lines</option> 387 </select> 388 <div class="note">Competitive default: leave breathing room. Story mode uses key beats; choose Full when every spoken line needs captions.</div> 389 <label style="margin-top:8px;display:flex;align-items:center;gap:6px;font:12px system-ui;color:#6b5f4e"><input type="checkbox" id="capBg" style="width:auto"> Dark bar behind text (Reels/TikTok style)</label> 390 <label style="margin-top:8px;display:flex;align-items:center;gap:6px;font:12px system-ui;color:#6b5f4e"><input type="checkbox" id="capEmph" checked style="width:auto"> Punch impact words (never, money, secretâ¦)</label> 391 <div style="margin-top:10px;background:#222;border-radius:8px;overflow:hidden"><canvas id="capPreview" width="300" height="90" style="width:100%;display:block"></canvas></div> 392 <div class="note">Packs set the look; fine controls still win. Style is paint-time â no re-watch.</div> 393 </div> 394 395 <!-- CLIP CUTTER + MULTI-CLIP LIST --> 396 <div class="benchtile" id="tile_clip"> 397 <!-- The same lesson, where the cutting actually happens. A person standing at these 398 sliders should not have to scroll back to the top of the page to be told what the 399 two taps do. --> 400 <video src="../assets/binder_how_to_clip.mp4" controls preload="metadata" playsinline 401 poster="../assets/clip_lesson_poster.jpg" 402 style="width:100%;display:block;border-radius:8px;margin-bottom:10px;background:#000;max-height:40vh" 403 aria-label="Full Clip Lab walkthrough: packs and manual"> 404 <track kind="captions" src="../assets/binder_how_to_clip.vtt" srclang="en" label="English" default> 405 </video> 406 <h3>Cut clips â build a batch</h3> 407 <p>Tap two scene thumbnails above to set a start and end, add it to your list, then pick another. Export the whole list in one pass, captions styled and burned in.</p> 408 <label>Start — <span class="tval" id="clipA_lbl">0:00</span></label> 409 <input type="range" id="clipA" min="0" value="0" step="1"> 410 <label style="margin-top:8px">End — <span class="tval" id="clipB_lbl">0:00</span></label> 411 <input type="range" id="clipB" min="0" value="0" step="1"> 412 <button class="go ter" id="addToClipList" style="margin-top:10px;width:100%">+ Add this range to clip list</button> 413 <div id="clipList" style="margin-top:10px;display:flex;flex-direction:column;gap:6px"></div> 414 <div id="clipbar"><div id="clipprog"></div></div> 415 <div id="clipstatus"></div> 416 <!-- Aspect picker. 9:16 is the whole reason a clipper would use this room; without 417 it every export is letterboxed into a third of a phone screen. --> 418<label style="font:13px system-ui;margin-right:10px">Shape: 419 <select id="aspectsel" style="font:13px system-ui;padding:5px 8px;border:1px solid var(--line);border-radius:6px"> 420 <option value="source">Same as source (16:9)</option> 421 <option value="9:16" selected>Vertical 9:16 — TikTok, Reels, Shorts</option> 422 <option value="1:1">Square 1:1</option> 423 </select> 424</label> 425<label style="font:13px system-ui;margin-right:10px">Fill: 426 <select id="fitsel" style="font:13px system-ui;padding:5px 8px;border:1px solid var(--line);border-radius:6px"> 427 <option value="crop">Crop to fill — talking head, camera</option> 428 <option value="fit">Fit whole frame — screen recording, keeps the text</option> 429 </select> 430</label> 431<label style="font:12px system-ui;display:flex;align-items:center;gap:6px;margin-top:8px;color:#6b5f4e"> 432 <input type="checkbox" id="alsoSquare" style="width:auto"> Also export 1:1 for every clip (same cuts, second pass â multi-aspect from one map) 433</label> 434<button class="go sec" id="exportclip" style="margin-top:10px;width:100%">Export this clip (captions burned when loaded) → ready to post</button> 435 <!-- ONE PASS FROM A LONG FILE TO N CLIPS. The batch exporter already existed, but 436 clipList had to be filled by hand: tap two thumbnails, press Add, repeat. The room 437 ALREADY detects sections for the manual, so the queue was one bridge away. This is 438 the gap that separates "a clipper can use this" from "a clipper will pay monthly" â 439 nobody cutting a 60-minute podcast is tapping thumbnails forty times. --> 440<button class="go" id="buildPacks" style="margin-top:10px;width:100%">Build the packs from my lengths → duration-aware, high-impact first</button> 441<button class="go sec" id="clipsFromScenes" style="margin-top:10px;width:100%">Queue every detected section as a clip → then export the batch in one pass</button> 442<div id="packstatus" style="font:12px system-ui;color:var(--acc);margin-top:6px;min-height:16px"></div> 443<div id="coverBox" style="margin-top:12px;padding:10px 12px;background:var(--soft);border-radius:8px;font:13px system-ui"> 444 <b>Post covers â from the moments you already chose</b> 445 <p style="margin:6px 0 8px;opacity:.75;line-height:1.45"> 446 Story reels use the hook, a middle tension/reaction beat, and the payoff. Separate packs use the first selected cuts. 447 Nothing is uploaded. Edit the headline if you want; leave it blank and Clip Lab pulls a short line from that moment. 448 </p> 449 <input id="coverHeadline" type="text" maxlength="72" placeholder="Optional cover headline â e.g. 50 SINGLES · 7 DAYS"
450 style="width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--line);border-radius:6px;font:13px system-ui"> 451 <button class="go ter" id="buildCovers" style="margin-top:8px;width:100%">Build 3 cover options from this recipe</button> 452 <div id="coverStatus" style="font:12px system-ui;color:var(--acc);margin-top:6px;min-height:16px"></div> 453 <div id="coverResults" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:8px"></div> 454</div> 455<div id="foleyBox" style="margin-top:12px;padding:10px 12px;background:var(--soft);border-radius:8px;font:13px system-ui"> 456 <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:700"> 457 <input type="checkbox" id="foleyOn" checked> Put a sound on the cut 458 </label> 459 <p style="margin:6px 0 8px;opacity:.75;line-height:1.45"> 460 Every clip queued from a section starts <em>on</em> a cut. A cut with no sound reads as a 461 glitch; a cut with a sound on it reads as an edit. Editors call this Foley. The sounds 462 here are generated for you from an equation — there is no sample pack to 463 download, nothing to license, and nothing is uploaded. 464 </p> 465 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 466 <label>Head <select id="foleyKind" style="font:13px system-ui;padding:3px 6px"> 467 <option value="whoosh">Whoosh</option> 468 <option value="riser">Riser</option> 469 <option value="impact">Impact</option> 470 <option value="click">Click</option> 471 <option value="none">None</option> 472 </select></label> 473 <label>Level <input type="range" id="foleyLevel" min="0" max="100" value="35" style="vertical-align:middle"></label> 474 <button class="go ter" id="foleyPreview" style="padding:4px 12px;margin:0;width:auto">Hear it</button> 475 </div> 476 <p id="foleystatus" style="margin:8px 0 0;opacity:.7"></p> 477 </div><!-- /foleyBox â closes the div opened above. I deleted this closer once today on a 478 wrong reading and put it straight back; noting it so nobody deletes it again. --> 479<button class="go" id="exportAllClips" style="margin-top:10px;width:100%;display:none">Export All Clips in List (captions burned in) →</button> 480 <div class="note"> 481 Exports WebM (all browsers) or MP4 where supported — uses WebCodecs + mp4-muxer when the browser has it, MediaRecorder canvas capture otherwise. WebCodecs is the faster of the two. The clip plays at realtime during canvas capture — plan for it. 482 </div> 483 </div> 484 485 <!-- SUBTITLE BURNER --> 486 <div class="benchtile" id="tile_sub"> 487 <h3>Burn captions onto a clip</h3> 488 <p>The captions you loaded (or type lines below) are drawn onto the clip frames using the style you set above — baked in permanently.</p> 489 <label>Custom caption lines (one per line, or leave blank to use loaded .srt/.vtt/transcript):</label> 490 <textarea id="customsubs" placeholder="Line one Line two Line three"></textarea> 491 <button class="go sec" id="exportsubclip" style="margin-top:10px;width:100%">Export Clip with Captions Burned In → same clip, captions permanently visible, no separate subtitle file needed</button> 492 <div class="note">Uses the same start/end range set in Cut clips above. Caption cues are matched by timestamp if a transcript is loaded; custom lines are spread evenly across the clip.</div> 493 </div> 494 495 </div> 496 497 <!-- TAB CAPTURE DOOR --> 498 <div id="tabdoor"> 499 <h3>Bring in a playing tab</h3> 500 <p>Playing something in another tab — a YouTube video, a webinar, a course — and want the Binder to work on it? Hit the button below. Your browser will ask which tab to share; pick the one that's playing. The video feeds straight in — no file needed.</p> 501 <button class="go sec" id="tabcap">Share a tab into the Binder →</button> 502 <div id="tabstatus" style="font:12px system-ui;color:var(--acc);margin-top:8px;min-height:16px"></div> 503 <div class="note">Works on Chrome and Edge on desktop. Captures at the tab's own playback speed — the Binder watches in real time as it plays. Firefox and mobile browsers do not support tab audio capture; drop the file directly for those.</div> 504 </div> 505</div> 506<!-- end creator bench --> 507 508<div class="how"> 509<div><b>1 · It watches</b>Your browser decodes the frames; the reader samples them in bounded breaths — one frame held at a time, compared, kept or dropped. Flat, light, quick. Works on any video you can play in a browser tab.</div> 510<div><b>2 · It organises</b>Scene changes become chapters; each kept moment carries its timestamp so the manual and your clips point back to the right second.</div> 511<div><b>3 · It binds and cuts</b>A typeset PDF manual with chapters, key frames, note lines, and a contact sheet. Separate shorts or one bound story reel assembled from the moments you choose — or Story mode chooses by hook, tension, reaction/reveal, and payoff. A chapter list to paste straight into a YouTube description.</div> 512</div> 513 514<div class="how" style="margin-top:0"> 515<div><b>For creators</b>Pick a whole batch of clips from one long video, style your captions once â font, color, position, a dark bar behind the text â then burn them into every clip in one pass. Bind your course content into a workbook too. Everything on a day use, as many as you want.</div> 516<div><b>For training & L&D</b>One-hour training video → indexed manual your new hire can search and print. Same price as a five-minute one.</div> 517<div><b>For anyone with a video</b>Drop the file for the fast path — Clip Lab measures the actual speed on this machine and shows it during the run. It works on recordings, lectures, webinars, tutorials, anything you can save. Sharing a live tab instead only ever runs at realtime, not faster; save that for when downloading truly isn't possible.</div> 518</div> 519 520<div class="pro" id="passes"> 521<span style="color:#6b5f4e;font-family:system-ui;font-size:15px">A day use is $2.22. A week use is $5.55. Everything unlocks — as many clips, manuals, and exports as you run. The passes don't auto-renew: if the work didn't earn your return, we don't get it.</span> 522<br><a href="../index.html#pricing">See passes →</a></div> 523</main> 524<footer>AKDmediaX —
524 A Creative Colabor Environment Where Tech Bows to Presence<br>© 2026 AKDmediaX · All Rights Reserved<br>everything happens in this page, and you can watch it happen</footer> 525
526<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
vendor: 1 bytes, line 526
526
527<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/mp4-muxer.js"></script>
vendor: 1 bytes, line 527
527
528<script src="../probe.js"></script>
vendor: 1 bytes, line 528
528
529<script> 530/* ---- Paywall gate (same contract as vaultpdf/index.html â shared via localStorage) ---- 531 Reaper, 2026-07-20: nothing here was actually gated â exports ran unlimited free. 532 3 free exports per device, then blocked until an active pass exists. */ 533window.CH_FREE_LIMIT = 3; 534 535/* AKD Media X attribution â business tags only, never visitor file/content data. 536 A social post can land here with: 537 ?src=<post-or-campaign-id>&artifact=<clip-id>&platform=tiktok&account=akdmediax&cta=clip_demo 538 Those bounded tags survive locally so a later checkout can be joined back to the post. */ 539function chAttrSafe(v, max) { 540 return String(v || "").replace(/[^A-Za-z0-9_.:@-]/g, "_").slice(0, max || 64); 541} 542function chLoadAttribution() { 543 var q = new URLSearchParams(location.search); 544 var prev = {}; 545 try { prev = JSON.parse(localStorage.getItem("akd_attr") || "{}") || {}; } catch(e) {} 546 ["src","artifact","platform","account","cta"].forEach(function(k) { 547 if (q.get(k)) prev[k] = chAttrSafe(q.get(k), k === "src" ? 96 : 64); 548 }); 549 prev.room = "binder"; 550 prev.last_at = Date.now(); 551 try { localStorage.setItem("akd_attr", JSON.stringify(prev)); } catch(e) {} 552 return prev; 553} 554function chAttribution() { 555 try { return JSON.parse(localStorage.getItem("akd_attr") || "{}") || {}; } 556 catch(e) { return {}; } 557} 558function chBuyUrl(kind) { 559 var a = chAttribution(), q = new URLSearchParams(); 560 ["src","artifact","platform","account","cta"].forEach(function(k){ if(a[k]) q.set(k,a[k]); }); 561 q.set("room","binder"); 562 q.set("trial_used", String(Math.min(CH_FREE_LIMIT, +(localStorage.getItem("ch_free_used") || 0)))); 563 q.set("return","/binder/"); 564 return "/buy/" + kind + "?" + q.toString(); 565} 566chLoadAttribution(); 567 568function chPassActive() { 569 try { 570 var p = JSON.parse(localStorage.getItem("ch_pass") || "null"); 571 if (!p) return false; 572 /* âââ LEGACY FREE PASSES REVOKED â 2026-07-31 âââ 573 Reaper, on his own phone this morning: "my page we're supposed to be making money on 574 has been free all night and straight up says your day pass is active. Every room is 575 open. Go work." 576 577 He was right, and the hole was not in today's code â it was in yesterday's storage. 578 Before 2026-07-30 two bugs handed out passes for nothing: `?pass=month` typed into the 579 address bar, and an "Already paid?" button that granted a day with no check. Both were 580 closed. NOTHING REVOKED THE PASSES THEY HAD ALREADY WRITTEN. A month granted that way 581 is valid for 31 days, so every device that touched either bug has been running the 582 whole paid site free ever since â including his. 583 584 The tell is exact: a pass minted by the real path always carries `session`, the tail 585 of the Stripe receipt id it was verified against. A pass with no `session` was never 586 paid for and never checked. Drop it. Nobody who actually paid loses anything. */ 587 if (!p.session) { localStorage.removeItem("ch_pass"); return false; } 588 var ms = { day: 864e5, week: 6048e5, month: 26784e5 }[p.kind] || 0; 589 return (Date.now() - p.at) <= ms; 590 } catch (e) { return false; } 591} 592function chShowPaywall() { 593 if (document.getElementById("ch_gate_overlay")) return; 594 var o = document.createElement("div"); 595 o.id = "ch_gate_overlay"; 596 o.style.cssText = "position:fixed;inset:0;background:rgba(20,15,10,.55);display:flex;align-items:center;justify-content:center;z-index:999"; 597 o.innerHTML = "<div style='background:#fff;border-radius:14px;padding:32px 34px;max-width:380px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.3)'>" + 598 "<div style='font-size:16px;font-weight:700;margin-bottom:8px'>You've used your " + CH_FREE_LIMIT + " free exports on this device</div>" + 599 "<p style='font-size:14px;color:#555;margin:0 0 18px'>You already proved it on this machine. Pick how long you want the full Clip Lab unlocked.</p>" + 600 "<div style='display:flex;gap:8px;justify-content:center;flex-wrap:wrap'>" + 601 "<a id='ch_buy_day' href='#' style='display:inline-block;background:var(--acc);color:#fff;font-weight:700;padding:11px 16px;border-radius:8px;text-decoration:none'>$2.22 day</a>" + 602 "<a id='ch_buy_week' href='#' style='display:inline-block;background:#355f51;color:#fff;font-weight:700;padding:11px 16px;border-radius:8px;text-decoration:none'>$5.55 week</a>" + 603 "<a id='ch_buy_month' href='#' style='display:inline-block;background:#493d33;color:#fff;font-weight:700;padding:11px 16px;border-radius:8px;text-decoration:none'>$16 month</a>" + 604 "</div>" + 605 "<div style='margin-top:9px'><a href='../index.html#pricing' style='color:#6b5f4e;font-size:12px'>See full pricing details</a></div>" + 606 "<div style='margin-top:16px;padding-top:14px;border-top:1px solid #eee'>" + 607 "<button id='ch_gate_restore' style='background:none;border:0;color:#1f6e50;font-size:12.5px;cursor:pointer;text-decoration:underline'>Already paid and still seeing this? Unlock it here</button>" + 608 "<div style='font-size:11px;color:#999;margin-top:6px'>Passes are held on this device only — a different browser, or clearing your data, starts over.</div></div>" + 609"<div style='margin-top:14px'><button id='ch_gate_close' style='background:none;border:0;color:#888;font-size:13px;
609cursor:pointer;text-decoration:underline'>Not now</button></div></div>"; 610 document.body.appendChild(o); 611 if ($("ch_buy_day")) $("ch_buy_day").href = chBuyUrl("day"); 612 if ($("ch_buy_week")) $("ch_buy_week").href = chBuyUrl("week"); 613 if ($("ch_buy_month")) $("ch_buy_month").href = chBuyUrl("month"); 614 document.getElementById("ch_gate_close").onclick = function () { o.remove(); }; 615 /* THE PAY-AND-STAY-LOCKED-OUT FALLBACK (2026-07-27). 616 A pass is granted only when Stripe redirects the buyer back to 617 ...workers.dev/?pass=day|week|month. That redirect lives in ONE field in the 618 Stripe dashboard, per payment link â it is not in this repository, nothing on 619 this site can read it, and I could not verify it without buying. If it is 620 wrong or missing, a customer pays and stays blocked, which is the worst 621 failure this business has. 622 This site has no server and no accounts by design, so there is nothing here 623 that could verify a receipt anyway; the counter is an honesty gate, not DRM, 624 and anyone set on bypassing it can already open devtools. Given that, a paying 625 customer must never be stuck. Shown only to someone already at the wall. 626 THE REAL FIX IS STILL THE DASHBOARD FIELD â see TEST_FUNCTIONS.md. */ 627 /* âââ THE FREE-PASS HOLE, CLOSED 2026-07-30 âââ 628 This handler used to be one line: write a day pass into localStorage and dismiss the 629 wall. It gave a full day of everything to ANYONE who clicked "Already paid?". The 630 comment above explained the reason â there was no server that could check a receipt. 631 There is one now, with a live Stripe key, so the receipt gets checked. A paying 632 customer is still never stuck; they just prove it, and now they can. */ 633 var rb = document.getElementById("ch_gate_restore"); 634 if (rb) rb.onclick = async function () { 635 var note = document.getElementById("ch_gate_note"); 636 /* 2026-08-01 â TWO DOORS, NOT ONE. This box only ever accepted a Stripe receipt 637 id, so a pass bought anywhere that is not our own checkout could not be 638 redeemed at all: Gumroad, Etsy and every other marketplace take the money 639 themselves and hand the buyer a code, never a `cs_...`. One box, either 640 kind, and the code decides which check runs. */ 641 var sid = (window.prompt( 642 "Paste your pass code or your receipt id.\n\n" + 643 "A code looks like AKD-DAY-XXXXXXXX-XXXXXXXXXXXX and comes in the email from " + 644 "wherever you bought it. A receipt id starts with cs_ and is in the address bar " + 645 "right after you pay here.") || "").trim(); 646 if (!sid) return; 647 var isCode = /^AKD-/i.test(sid); 648 if (note) note.textContent = isCode ? "Checking that code\u2026" : "Checking that receipt\u2026"; 649 try { 650 var r = await fetch(isCode ? "/pass/redeem" : "/pass/verify", { method: "POST", 651 headers: { "content-type": "application/json" }, 652 body: JSON.stringify(isCode ? { code: sid } : { session_id: sid }) }); 653 var d = await r.json(); 654 if (!d.ok) { if (note) note.textContent = d.error || "That could not be confirmed."; return; } 655 localStorage.setItem("ch_pass", JSON.stringify({ 656 kind: d.kind, at: Date.now(), expires: d.expires, session: sid.slice(-12) })); 657 o.remove(); 658 } catch (e) { 659 if (note) note.textContent = "Could not reach the check just now: " + e.message; 660 } 661 }; 662 663} 664/* Trial accounting is transactional: permission is checked BEFORE work, but a 665 free use is consumed only AFTER a usable result exists. A failed encode, 666 cancelled/invalid operation, or transcription failure must not spend one 667 of the three demonstrations. */ 668window.__ch_trial_pending = false; 669function chGate() { 670 if (chPassActive()) { window.__ch_trial_pending = false; return true; } 671 var used = +(localStorage.getItem("ch_free_used") || 0); 672 if (used >= CH_FREE_LIMIT) { window.__ch_trial_pending = false; chShowPaywall(); return false; } 673 window.__ch_trial_pending = true; 674 return true; 675} 676function chCommitTrial(kind) { 677 if (chPassActive()) { window.__ch_trial_pending = false; return; } 678 if (!window.__ch_trial_pending) return; 679 var used = +(localStorage.getItem("ch_free_used") || 0); 680 if (used < CH_FREE_LIMIT) localStorage.setItem("ch_free_used", used + 1); 681 window.__ch_trial_pending = false;
682 try { 683 localStorage.setItem("ch_last_free_success", JSON.stringify({ 684 kind: kind || "output", at: Date.now(), used: Math.min(CH_FREE_LIMIT, used + 1) 685 })); 686 } catch (e) {} 687} 688function chReleaseTrial() { 689 window.__ch_trial_pending = false; 690} 691 692/* hardware-aware door: measure the machine, promise only what it can run */ 693var CAP = PROBE.run(); 694/* 2026-07-31 â THIS LINE THREW ON EVERY PAGE LOAD. `#probe_strip` was removed from the markup 695 when the machine-capability strip came off the page (a strip reading "your machine is slow" 696 is a flaw put in front of a customer, and it went for that reason). The JS that wrote to it 697 stayed behind, so `getElementById` returned null, `.innerHTML =` threw 698 "Cannot set properties of null", and EVERY STATEMENT AFTER IT IN THIS BLOCK NEVER RAN â 699 including the LITE-tier door immediately below, which is the thing that stops a browser 700 that cannot decode video from being handed a video to decode. 701 The error was visible in the console on every load and I read past it twice while filming 702 this page. A page error is not cosmetic; it is an unexploded line of dead code below it. */ 703if (document.getElementById("probe_strip")) { 704 document.getElementById("probe_strip").innerHTML = PROBE.strip(CAP); 705} 706if (CAP.tier === "LITE") { 707 var d = document.getElementById("drop"); 708 d.style.opacity = ".5"; d.style.pointerEvents = "none"; 709 d.querySelector("small").textContent = "this browser can't decode video â the PDF tools at VaultPDF still run perfectly here"; 710} 711</script>
vendor: 1 bytes, line 711
711
712<script> 713 /* Generic moment triage fallback. 714 Historical niche vocabularies are NOT global quality law. This ranker only narrows 715 a long transcript when no stronger current-reference/campaign model is available. 716 A current encounter may add scoped terms through setScopedTerms(); those terms live 717 for this page/run only and do not mutate the universal fallback. */ 718 window.ClipLabRank = (function () { 719 var scopedTerms = []; 720 var attentionContract = null; 721 var RE = { 722 contradiction: /\b(but|actually|instead|without|no one|nobody|impossible|can't|cannot|never|only|just one|single|whole|entire)\b/i, 723 number: /\b(\d[\d,\.]*)\s*(minutes?|hours?|days?|dollars?|thousand|million|billion|percent|times|x)?\b/i, 724 bareNumber: /\b\d[\d,\.]{2,}\b/, 725 value: /(\$\s?\d|\b\d[\d,\.]*\s*(?:dollars?|k\b|grand)|\b(?:cost|price|money|pay|paid|save|saving|budget|revenue|profit|loss)\b)/i, 726 time: /\b(minutes?|hours?|days?|weeks?|months?|years?|fast|faster|quick|quickly|instantly|overnight|deadline)\b/i, 727 emotion: /\b(shocked|surprised|angry|excited|scared|afraid|love|hate|laughed|laughing|crying|crazy|insane|wow|unbelievable|couldn't believe)\b/i, 728 reveal: /\b(turns out|the truth|actually|finally|secret|nobody knew|what happened|the result|here's why|here is why)\b/i, 729 dangling: /^\s*(and|so|then|because|which|that's why|also|plus|but then|anyway|it|they|this|these|those)\b/i 730 }; 731 function score(text) { 732 var t = String(text || ''), s = 0, why = []; 733 if (RE.contradiction.test(t)) { s += 4; why.push('contradiction'); } 734 var n = t.match(RE.number) || t.match(RE.bareNumber); 735 if (n) { s += 3; why.push('number:' + String(n[0]).slice(0, 12)); } 736 if (RE.value.test(t)) { s += 2; why.push('value'); } 737 if (RE.time.test(t)) { s += 3; why.push('time/stakes'); } 738 if (RE.emotion.test(t)) { s += 3; why.push('emotion/reaction'); } 739 if (RE.reveal.test(t)) { s += 4; why.push('reveal'); } 740 if (/\?/.test(t)) { s += 2; why.push('question'); } 741 for (var sti = 0; sti < scopedTerms.length; sti++) { 742 var term = scopedTerms[sti]; 743 if (term && t.toLowerCase().indexOf(term) >= 0) { s += 2; why.push('scoped:' + term); } 744 } 745 if (attentionContract && Array.isArray(attentionContract.psychology_families)) { 746 var fam = attentionContract.psychology_families, pb = 0; 747 for (var fi = 0; fi < fam.length; fi++) { 748 var f = fam[fi], hit = false; 749 if (f === "nostalgia") hit = /\b(remember|back then|classic|again|used to|old school|return|returns)\b/i.test(t); 750 else if (f === "identity_tribe") hit = /\b(we|our|players?|fans?|community|team|guild|faction|tribe)\b/i.test(t); 751 else if (f === "novelty_fomo") hit = /\b(new|beta|live|now|first|today|just dropped|before everyone|early access)\b/i.test(t); 752 else if (f === "spectacle") hit = /\b(insane|massive|huge|beautiful|look at|unreal|crazy|changed|transformed)\b/i.test(t); 753 else if (f === "mastery_status") hit = /\b(secret|hidden|how|guide|best|pro|detail|did you know|most people miss)\b/i.test(t); 754 else if (f === "social_proof") hit = /\b(everyone|millions?|viral|blowing up|views?|people are|all over)\b/i.test(t); 755 else if (f === "conflict_stakes") hit = /\b(risk|versus|vs\.?|fight|battle|deadline|lose|lost|betray|wrong|problem)\b/i.test(t); 756 else if (f === "humor_meme") hit = /\b(lol|funny|hilarious|chaos|wild|what just happened|no way)\b/i.test(t); 757 else if (f === "aspiration") hit = /\b(want|become|upgrade|dream|goal|better|level up)\b/i.test(t); 758 else if (f === "utility") hit = /\b(how to|step|do this|fix|use|works|method)\b/i.test(t); 759 if (hit) { pb += 1; why.push("psych:" + f); } 760 } 761 if (pb) s += Math.min(4, pb); 762 } 763 if (RE.dangling.test(t)) { s -= 4; why.push('NEEDS-CONTEXT'); } 764 if (t.split(/\s+/).filter(Boolean).length < 6) { s -= 2; why.push('too-thin'); } 765 /* Why THIS clip: self-contained payoff, hard open, share-not-like. 766 Soft openers kill Phase 1. Desire/conflict/reveal is why a clipper picks it. */ 767 var soft = /^(so yeah|um+|uh+|you know|i mean|anyway|right so|okay so|hey guys|welcome back)\b/i; 768 if (soft.test(t.trim())) { s -= 6; why.push('SOFT-OPEN'); } 769 if (/\b(never told|nobody|the truth is|turns out|secret|quit|fired|broke|actually)\b/i.test(t)) { s += 4; why.push('reveal'); } 770 if (/\b(share|send this|tell|show them)\b/i.test(t)) { s += 2; why.push('share-shape'); } 771 return { score: s, why: why }; 772 } 773 return { 774 weights: { contradiction: 4, number: 3, value: 2, timeStakes: 3, 775 emotionReaction: 3, reveal: 4, question: 2, 776 scopedTerm: 2, needsContext: -4, tooThin: -2 }, 777 setScopedTerms: function (terms) { 778 scopedTerms = (terms || []).map(function (x) { 779 return String(x || "").trim().toLowerCase(); 780 }
780).filter(Boolean).slice(0, 40); 781 return scopedTerms.slice(); 782 }, 783 setAttentionContract: function (contract) { 784 attentionContract = contract && typeof contract === "object" ? contract : null; 785 return attentionContract; 786 }, 787 getAttentionContract: function () { return attentionContract; }, 788 getScopedTerms: function () { return scopedTerms.slice(); }, 789 score: score, 790 /* Rank a moment list in place-ish: returns a NEW sorted array, each item gaining 791 .rankScore and .rankWhy. Keeps every moment â ranking is not deletion, so a clipper 792 can still reach the long tail if they want it. */ 793 rank: function (moments, textKey) { 794 var key = textKey || 'text'; 795 var out = (moments || []).map(function (m) { 796 var r = score(m && m[key]); 797 m.rankScore = r.score; 798 m.rankWhy = r.why; 799 return m; 800 }); 801 out.sort(function (a, b) { return (b.rankScore || 0) - (a.rankScore || 0); }); 802 return out; 803 }, 804 /* The clippable set: what is actually worth a clipper's time. */ 805 strong: function (moments, textKey, floor) { 806 var f = (floor === undefined) ? 12 : floor; 807 return this.rank(moments, textKey).filter(function (m) { 808 return (m.rankScore || 0) >= f; 809 }); 810 } 811 }; 812 })(); 813 814 /* D018, 2026-08-20 â chosen by the mind seat and confirmed by measurement. 815 Reduce the per-instant framing samples with a MEDIAN, not a mean: one bright 816 visual outlier can drag a mean off the subject while the median ignores it. 817 Measured worst drift from the median crop across three 818 real 30s windows was 0.105 / 0.146 / 0.070 of frame width, all inside the 0.20 819 threshold set before measuring â so a single well-chosen crop is sufficient and a 820 moving crop would only add "jittering crop syndrome" (the mind's own phrase for the 821 failure mode of the per-frame alternative). */ 822 window.ClipLabMedian = function (xs) { 823 var a = (xs || []).filter(function (v) { 824 return typeof v === "number" && isFinite(v); 825 }).sort(function (p, q) { return p - q; }); 826 if (!a.length) return null; 827 var m = Math.floor(a.length / 2); 828 return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2; 829 }; 830 831/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 832 THE BREATH MOTION â binder core 833 Their machine decodes; we sample one frame per step into a small canvas 834 (the breath), diff it against only the previous breath, keep it if the 835 scene moved, drop it either way. Nothing accumulates except small kept 836 JPEGs. Flat. 837 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 838var $ = function(id){ return document.getElementById(id); }; 839var S = function(t){ $("status").textContent = t; }; 840var scenes = [], vname = "video", cues = [], vidDur = 0; 841window.ClipLabReference = null; 842 843function refSeek(v, t) { 844 return new Promise(function (resolve) { 845 var done = false; 846 var to = setTimeout(function () { if (!done) { done = true; resolve(); } }, 2500); 847 v.onseeked = function () { 848 if (done) return; 849 done = true; 850 clearTimeout(to); 851 resolve(); 852 }; 853 try { v.currentTime = Math.min(Math.max(0, t), Math.max(0, (v.duration || t) - 0.03)); } 854 catch (e) { clearTimeout(to); resolve(); } 855 }); 856} 857 858function refFrameVector(ctx, v, w, h) { 859 ctx.drawImage(v, 0, 0, w, h); 860 var d = ctx.getImageData(0, 0, w, h).data; 861 var out = new Uint8Array(w * h); 862 for (var i = 0, j = 0; i < d.length; i += 4, j++) { 863 out[j] = Math.round(d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114); 864 } 865 return out; 866} 867 868function refMeanDiff(a, b) { 869 if (!a || !b || a.length !== b.length) return 0; 870 var sum = 0; 871 for (var i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]); 872 return sum / a.length; 873} 874 875async function analyzeReferenceFile(file) { 876 var status = $("refStatus"); 877 if (!file) return null; 878 var url = URL.createObjectURL(file); 879 var v = document.createElement("video"); 880 v.muted = true; v.preload = "auto";
880 v.playsInline = true; v.src = url; 881 try { 882 await new Promise(function (resolve, reject) { 883 var to = setTimeout(function () { reject(new Error("reference metadata timed out")); }, 8000); 884 v.onloadedmetadata = function () { clearTimeout(to); resolve(); }; 885 v.onerror = function () { clearTimeout(to); reject(new Error("reference video could not be decoded")); }; 886 }); 887 var dur = +v.duration || 0; 888 if (!dur || dur < 1) throw new Error("reference has no usable duration"); 889 var w = 96, h = 54, c = document.createElement("canvas"); 890 c.width = w; c.height = h; 891 var ctx = c.getContext("2d", {willReadFrequently:true}); 892 /* Reference learning is a scoped PRIOR, not frame-perfect scene detection. On the 893 2014 dual-core test machine, ~80 sequential seeks made this control itself heavier 894 than the product it was meant to guide. Cap the encounter to ~32 observations; 895 the main Clip Lab watch still performs the detailed source map. */ 896 var step = Math.max(0.70, dur / 32); 897 var samples = [], prev = null, t = Math.min(0.1, dur / 10); 898 var count = Math.max(2, Math.floor((dur - t) / step)); 899 for (var si = 0; si <= count; si++) { 900 var at = Math.min(dur - 0.04, t + si * step); 901 if (at < 0) at = 0; 902 if (status && si % 8 === 0) status.textContent = "Watching reference locally⦠" + Math.min(100, Math.round(si / Math.max(1,count) * 100)) + "%"; 903 await refSeek(v, at); 904 var vec = refFrameVector(ctx, v, w, h); 905 if (prev) samples.push({t:at, diff:refMeanDiff(prev, vec)}); 906 prev = vec; 907 } 908 var diffs = samples.map(function (x) { return x.diff; }); 909 var med = window.ClipLabMedian(diffs) || 0; 910 var dev = diffs.map(function (x) { return Math.abs(x - med); }); 911 var mad = window.ClipLabMedian(dev) || 0; 912 var threshold = Math.max(14, med + 3 * mad); 913 var cuts = [], last = -99; 914 for (var ci = 0; ci < samples.length; ci++) { 915 if (samples[ci].diff >= threshold && samples[ci].t - last >= Math.max(0.35, step * 0.75)) { 916 cuts.push(samples[ci].t); last = samples[ci].t; 917 } 918 } 919 var intervals = []; 920 for (var ii = 1; ii < cuts.length; ii++) intervals.push(cuts[ii] - cuts[ii - 1]); 921 var medianBeat = intervals.length ? window.ClipLabMedian(intervals) : (cuts.length ? dur / (cuts.length + 1) : dur); 922 var meanChange = diffs.length ? diffs.reduce(function (a,b) { return a+b; },0) / diffs.length : 0; 923 var result = { 924 name:file.name, duration:+dur.toFixed(2), sampleStep:+step.toFixed(3), 925 samples:diffs.length, estimatedCuts:cuts.length, 926 cutTimes:cuts.map(function (x) { return +x.toFixed(2); }), 927 medianVisualBeat:+medianBeat.toFixed(2), 928 meanVisualChange:+meanChange.toFixed(2), 929 cutThreshold:+threshold.toFixed(2), 930 scope:"this browser run only" 931 }; 932 window.ClipLabReference = result; 933 if (status) status.textContent = "Reference learned locally: " + result.duration + "s · ~" + 934 result.estimatedCuts + " hard changes · median visual beat " + result.medianVisualBeat + 935 "s · mean change " + result.meanVisualChange + ". Story ranking will use this rhythm as a scoped prior."; 936 return result; 937 } finally { 938 try { v.removeAttribute("src"); v.load(); } catch (e) {} 939 URL.revokeObjectURL(url); 940 } 941} 942 943if ($("refVideo")) $("refVideo").addEventListener("change", function (e) { 944 var f = e.target.files && e.target.files[0]; 945 analyzeReferenceFile(f).catch(function (err) { 946 window.ClipLabReference = null; 947 if ($("refStatus")) $("refStatus").textContent = "Reference analysis failed: " + err.message; 948 }); 949}); 950if ($("scopedTerms")) $("scopedTerms").addEventListener("input", function () { 951 var terms = this.value.split(",").map(function (x) { return x.trim(); }).filter(Boolean); 952 if (window.ClipLabRank && window.ClipLabRank.setScopedTerms) window.ClipLabRank.setScopedTerms(terms); 953}); 954 955window.ClipLabAttention = null; 956if ($("attentionContract")) $("attentionContract").addEventListener("change", function (e) { 957 var f = e.target.files && e.target.files[0]; 958 var status = $("attentionStatus"); 959 if (!f) return; 960 f.text().then(function (txt) { 961 var c = JSON.parse(txt); 962 if (!c || c.schema !== "cliplab.attention_contract.v1") throw new Error("not a Clip Lab attention contract"); 963 window.ClipLabAttention = c; 964 if (window.ClipLabRank && window.ClipLabRank.setAttentionContract) window.ClipLabRank.setAttentionContract(c); 965 var terms = Array.isArray(c.scoped_terms) ? c.scoped_terms : []; 966 if ($("scopedTerms")) $("scopedTerms").value = terms.join(", "); 967 if (window.ClipLabRank && window.ClipLabRank.setScopedTerms) window.ClipLabRank.setScopedTerms(terms); 968 if ($("pack_mode") && ["story","impact","sections","firsthalf"].indexOf(c.pack_mode) >= 0) $("pack_mode").value = c.pack_mode; 969 if ($("capDensity") && ["full","selective","minimal"].indexOf(c.caption_density) >= 0) $("capDensity").value = c.caption_density; 970 window.ClipLabMinSeconds = +c.minimum_seconds || 0; 971 window.ClipLabPreferredSeconds = Array.isArray(c.preferred_clip_seconds) ? c.preferred_clip_seconds.slice() : []; 972 if (status) status.textContent = 973 "Loaded " + (c.name || c.campaign_id || "campaign") + " · " + 974 (c.attention_archetype || "attention") + " · minimum " + (window.ClipLabMinSeconds || "?") + 975 "s · preferred " + (window.ClipLabPreferredSeconds.join("/") || "not set") + 976 "s · " + (c.caption_density || "default") + " captions · post-caption CTA " +
977 (c.post_caption_cta_position || "brief-defined") + " · video CTA " + 978 (c.video_cta_timing_prior || "optional/brief-defined") + 979 ". All guidance stays in this browser run."; 980 }).catch(function (err) { 981 window.ClipLabAttention = null; 982 window.ClipLabMinSeconds = 0; 983 window.ClipLabPreferredSeconds = []; 984 if (window.ClipLabRank && window.ClipLabRank.setAttentionContract) window.ClipLabRank.setAttentionContract(null); 985 if (status) status.textContent = "Attention contract failed: " + err.message; 986 }); 987}); 988 989/* Lanes are ON by default. `?lanes=0` in the address bar forces the old serial 990 watch â kept as a real switch so the two paths can be measured against each 991 other on the SAME machine and the same file, which is the only comparison 992 that means anything (aleph-colabor: a number without its conditions is a lie). */ 993var global_lanesEnabled = new URLSearchParams(location.search).get("lanes") !== "0"; 994 995/* ââ caption parser: .srt/.vtt â [{t, text}] ââ */ 996$("caps").onchange = function(e) { 997 var f = e.target.files[0]; 998 if (!f) return; 999 f.text().then(function(txt) { 1000 cues = []; 1001 var re = /(\d{1,2}):(\d{2}):(\d{2})[.,](\d{3})\s*-->/g; 1002 var blocks = txt.split(/\n\s*\n/); 1003 for (var bi = 0; bi < blocks.length; bi++) { 1004 var b = blocks[bi]; 1005 var m = re.exec(b); re.lastIndex = 0; 1006 if (!m) continue; 1007 var t = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]); 1008 /* D010, measured 2026-08-20: YouTube auto-captions ROLL â each cue repeats the 1009 previous line plus one new one. Joining verbatim tripled the words (65,906 1010 words carrying 22,539 "sentence ends", i.e. one per two words) and produced 1011 cues like "like being a triplet that like prepares" three times over. Every 1012 downstream caption defect came from here, so dedupe at the parse 1013 (aleph-root-not-fruit). A clean .srt is unaffected. */ 1014 var rawLines = b.split("\n").filter(function(l){ 1015 return !l.includes("-->") && !/^\d+$/.test(l.trim()) && !/^WEBVTT/.test(l); 1016 }).map(function(l){ 1017 /* D011: the parser stripped <tags> but not ENTITIES, so 1,016 cues carried 1018 ">>" â YouTube's speaker marker â straight onto the burned frame. */ 1019 return l.replace(/<[^>]+>/g,"") 1020 .replace(/>/g, ">").replace(/</g, "<") 1021 .replace(/&/g, "&").replace(/"/g, '"') 1022 .replace(/'|'/g, "'").replace(/ /g, " ") 1023 .replace(/^\s*>>\s*/, "") /* speaker marker at line start */ 1024 .replace(/\s*>>\s*/g, " ") 1025 .trim(); 1026 }).filter(Boolean); 1027 var kept = []; 1028 for (var li = 0; li < rawLines.length; li++) { 1029 var ln = rawLines[li]; 1030 var sofar = kept.join(" "); 1031 if (sofar && sofar.indexOf(ln) >= 0) continue; /* already said in this cue */ 1032 kept.push(ln); 1033 } 1034 var text = kept.join(" ").replace(/\s+/g, " ").trim(); 1035 1036 /* collapse an immediately repeated phrase inside one cue */ 1037 var cw = text.split(" "); 1038 for (var span = Math.floor(cw.length / 2); span >= 3; span--) { 1039 var head = cw.slice(0, span).join(" "); 1040 var next = cw.slice(span, span * 2).join(" "); 1041 if (head && head === next) { cw.splice(span, span); span = Math.floor(cw.length / 2) + 1; } 1042 } 1043 text = cw.join(" "); 1044 1045 /* rolling window across cues: keep only what is new since the last cue */ 1046 if (text && cues.length) { 1047 var prev = cues[cues.length - 1].text || ""; 1048 if (prev && text.indexOf(prev) === 0 && text.length > prev.length) { 1049 text = text.slice(prev.length).trim(); 1050 } else { 1051 /* overlap by tail/head, the common rolling shape */ 1052 var pw = prev.split(" "), tw = text.split(" "); 1053 var maxOv = Math.min(pw.length, tw.length, 24); 1054 for (var ov = maxOv; ov >= 3; ov--) { 1055 if (pw.slice(pw.length - ov).join(" ") === tw.slice(0, ov).join(" ")) { 1056 text = tw.slice(ov).join(" "); 1057 break; 1058 } 1059 } 1060 } 1061 } 1062 if (text) cues.push({t: t, text: text}); 1063 } 1064 S(cues.length + " caption cues loaded â sections will carry their spoken words."); 1065 }); 1066}; 1067 1068function cuesBetween(a, b) { 1069 if (!cues || !cues.length) return ""; 1070 var pad = 0.35; 1071 var hits = cues.filter(function(c){ return c.t >
1071= (a - pad) && c.t < (b + pad); }); 1072 if (!hits.length) { 1073 /* nearest cue inside or just before the section â never leave the manual blank without trying */ 1074 var best = null, bestD = 1e9; 1075 for (var i = 0; i < cues.length; i++) { 1076 var d = Math.abs(cues[i].t - a); 1077 if (d < bestD) { bestD = d; best = cues[i]; } 1078 } 1079 if (best && bestD < 12) hits = [best]; 1080 } 1081 return hits.map(function(c){ return c.text; }).filter(Boolean).join(" "); 1082} 1083 1084/* Output moment â shows a "Your file is ready" panel with a Save again link after any export. 1085 For jsPDF (doc.save) we can't easily intercept the blob, so we show a status banner; 1086 for blob-URL exports (chapters, thumbs, clips) we pass the url directly. 1087 Every produced artifact is also registered into the "Your files" tray. */ 1088function registerInTray(name, blobUrl) { 1089 var tray = $("files_tray"); 1090 var list = $("files_tray_list"); 1091 if (!tray || !list) return; 1092 tray.style.display = "block"; 1093 var row = document.createElement("div"); 1094 row.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--paper);"; 1095 var nameSpan = "<span style='font-family:monospace;font-size:12px;color:var(--ink)'>" + name + "</span>"; 1096 var saveBtn = blobUrl 1097 ? "<a href='" + blobUrl + "' download='" + name + "' style='font-family:system-ui;font-size:11px;background:var(--acc);color:#fff;padding:5px 12px;border-radius:5px;text-decoration:none;white-space:nowrap;cursor:pointer'>Save</a>" 1098 : "<span style='font-size:11px;color:#9a8a7a'>in Downloads</span>"; 1099 row.innerHTML = nameSpan + saveBtn; 1100 list.appendChild(row); 1101} 1102 1103/* ââ D007 · THE SUBMIT CLOCK, 2026-08-20 ââââââââââââââââââââââââââââââââââââ 1104 Campaigns pay on VERIFIED views, and the verification window starts when you 1105 submit â not when you post. The practitioner rule is blunt: "make sure you submit 1106 before 1 hour from the time of posting... so that they start paying." Views earned 1107 before submission may never be counted. 1108 1109 That made the most expensive mistake in clipping completely invisible: export, post, 1110 get distracted, submit 90 minutes later, and a clip with 400k views pays for almost 1111 none of them. Nothing in this page tracked it. So the clock lives in the panel the 1112 clipper is already looking at when the file lands. */ 1113var CLIPLAB_SUBMIT_KEY = "cliplab_submit_clock_v1"; 1114/* D007b, measured 2026-08-20: the window is 30 MINUTES on some campaigns, not 60 â 1115 "if you submit after 30 minutes, you will not be able to submit." A clock counting 1116 from 60 would show green while the form was already closed, which is worse than no 1117 clock. Default to the shorter measured figure; the brief is the authority. */ 1118var CLIPLAB_SUBMIT_WINDOW_MIN = 30; 1119 1120function clipLabStartSubmitClock(name) { 1121 try { 1122 var rec = {name: name, exportedAt: Date.now(), 1123 windowMin: CLIPLAB_SUBMIT_WINDOW_MIN}; 1124 localStorage.setItem(CLIPLAB_SUBMIT_KEY, JSON.stringify(rec)); 1125 clipLabRenderSubmitClock(); 1126 } catch (e) { /* never let the clock break an export */ } 1127} 1128 1129function clipLabSubmitted() { 1130 try { 1131 var raw = localStorage.getItem(CLIPLAB_SUBMIT_KEY); 1132 var mins = null; 1133 if (raw) { 1134 var rec = JSON.parse(raw); 1135 mins = Math.round((Date.now() - rec.exportedAt) / 60000); 1136 var log = [];
1137 try { log = JSON.parse(localStorage.getItem("cliplab_submit_log_v1") || "[]"); } 1138 catch (e2) { log = []; } 1139 log.push({name: rec.name, minutes: mins, at: new Date().toISOString()}); 1140 localStorage.setItem("cliplab_submit_log_v1", JSON.stringify(log.slice(-50))); 1141 } 1142 localStorage.removeItem(CLIPLAB_SUBMIT_KEY); 1143 var el = document.getElementById("cliplab_clock"); 1144 if (el) { 1145 el.innerHTML = "<span style='color:var(--acc);font-weight:700'>✓ " 1146 + "submitted" + (mins !== null ? " after " + mins + " min" : "") 1147 + " — inside the hour is what gets paid.</span>"; 1148 el.style.background = "transparent"; 1149 el.style.border = "none"; 1150 } 1151 } catch (e) { /* ignore */ } 1152} 1153 1154function clipLabSetWindow(mins) { 1155 try { 1156 CLIPLAB_SUBMIT_WINDOW_MIN = mins; 1157 var raw = localStorage.getItem(CLIPLAB_SUBMIT_KEY); 1158 if (raw) { 1159 var rec = JSON.parse(raw); 1160 rec.windowMin = mins; 1161 localStorage.setItem(CLIPLAB_SUBMIT_KEY, JSON.stringify(rec)); 1162 } 1163 clipLabRenderSubmitClock(); 1164 } catch (e) { /* ignore */ } 1165} 1166 1167function clipLabRenderSubmitClock() { 1168 try { 1169 var host = document.getElementById("cliplab_clock"); 1170 if (!host) return; 1171 var raw = localStorage.getItem(CLIPLAB_SUBMIT_KEY); 1172 if (!raw) { host.innerHTML = ""; return; } 1173 var rec = JSON.parse(raw); 1174 var winMin = rec.windowMin || CLIPLAB_SUBMIT_WINDOW_MIN; 1175 var total = winMin * 60; 1176 var left = total - Math.floor((Date.now() - rec.exportedAt) / 1000); 1177 var mm = Math.floor(Math.abs(left) / 60), ss = Math.abs(left) % 60; 1178 var stamp = (left < 0 ? "-" : "") + mm + ":" + (ss < 10 ? "0" : "") + ss; 1179 /* amber at a third remaining, so the warning scales with the window */ 1180 var colour = left > total / 3 ? "#3d6b3d" : (left > 0 ? "#9a7b3f" : "#a33"); 1181 var msg = left > 0 1182 ? "Submit to the campaign within <b>" + stamp + "</b> (" + winMin 1183 + "-minute window) — and the submission needs the post link, the video " 1184 + "file, AND a screenshot of your audience graph." 1185 : "<b>" + stamp + " past the " + winMin + "-minute window.</b> On many campaigns " 1186 + "the submit form is now CLOSED — that is not a penalty, it is the whole " 1187 + "earning for this clip. Check the brief."; 1188 host.style.cssText = "margin-top:10px;padding:8px 12px;border-radius:8px;" 1189 + "font:13px system-ui;color:" + colour + ";background:rgba(154,123,63,0.08);" 1190 + "border:1px solid rgba(154,123,63,0.25)"; 1191 host.innerHTML = "⏱ " + msg 1192 + " <button type=\"button\" onclick=\"clipLabSubmitted()\" style=\"margin-left:8px;" 1193 + "font:12px system-ui;padding:4px 10px;border-radius:5px;border:1px solid " 1194 + "#ddd;background:#fff;cursor:pointer\">I submitted it</button>" 1195 + " <button type=\"button\" onclick=\"clipLabSetWindow(60)\" style=\"margin-left:4px;" 1196 + "font:12px system-ui;padding:4px 8px;border-radius:5px;border:1px solid " 1197 + "#ddd;background:#fff;cursor:pointer\">brief says 60 min</button>"; 1198 } catch (e) { /* ignore */ } 1199} 1200setInterval(clipLabRenderSubmitClock, 1000); 1201document.addEventListener("DOMContentLoaded", clipLabRenderSubmitClock); 1202 1203function showBinderOutputMoment(name, blobUrl) { 1204 var existing = document.getElementById("binder_output_moment"); 1205 if (existing) existing.remove(); 1206 var panel = document.createElement("div"); 1207 panel.id = "binder_output_moment"; 1208 panel.style.cssText = "margin:12px 0;padding:14px 18px;background:rgba(180,85,45,0.08);border:1px solid rgba(180,85,45,0.3);border-radius:10px;text-align:center;"; 1209 clipLabStartSubmitClock(name); 1210 var inner = "<div style='color:var(--acc);font-weight:700;font-size:15px;margin-bottom:4px;font-family:Georgia,serif'>✓ Your file is ready — " + name + "</div>" + 1211 "<div style='font-size:13px;color:#6b5f4e;margin-bottom:8px'>It also saved to your Downloads folder. Find all this session's files in the tray below the video.</div>"; 1212 if (blobUrl) { 1213 inner += "<a href='" + blobUrl + "' download='" + name + "' style='display:inline-block;background:var(--acc);color:#fff;font-weight:700;font-size:13px;padding:9px 24px;border-radius:8px;text-decoration:none;cursor:pointer'>Save again</a>"; 1214 } 1215 panel.innerHTML = inner + "<div id='cliplab_clock'></div>"; 1216 var makerow = $("makerow"); 1217 if (makerow && makerow.parentNode) makerow.parentNode.insertBefore(panel, makerow.nextSibling); 1218 else document.querySelector("main").appendChild(panel); 1219 /* also register in the tray */ 1220 registerInTray(name, blobUrl); 1221} 1222 1223/* ââ top-of-page tab-share button (punch-list item 7) ââ 1224 This is the ONE primary action for a YouTube video or anything else already 1225 playing in a tab â there is no working URL-paste path (see note above the 1226 button: no server on this page, and browsers cannot fetch a YouTube stream 1227 directly). Wired to the exact same real capture function the Creator Bench's 1228 "Share a tab into the Binder" button uses further down the page â same 1229 mechanism, just also offered where a visitor actually needs it first. */ 1230$("tabcap_top").onclick = async function() { 1231 if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { 1232 $("tabstatus_top").textContent = "Tab sharing isn't available in this browser â download the video file and drop it in the box above instead."; 1233 return; 1234 } 1235 $("tabstatus_top").textContent = "Waiting for you to pick a tab in the browser pickerâ¦"; 1236 var stream; 1237 try { 1238 stream = await navigator.mediaDevices.getDisplayMedia({ 1239 video: {frameRate: {ideal: 30}}, 1240 audio: {systemAudio: "include"} 1241 }); 1242 } catch (err) { 1243 $("tabstatus_top").textContent = "No tab shared: " + err.message; 1244 return; 1245 } 1246 $("tabstatus_top").textContent = "Tab captured â recording into a local buffer⦠play the video in the other tab now."; 1247 startFromStream(stream); 1248}; 1249 1250/* ââ real YouTube fetch: a small server-side step (video_fetch_service.py, run 1251 over a Cloudflare Tunnel) pulls the actual video bytes down, since a page with 1252 no server genuinely cannot reach YouTube itself. Everything AFTER this one 1253 fetch â the scene sampling, the manual, the clips â is the same fast, fully 1254 local pipeline as any dropped file. Reaper, 2026-07-20: proven end to end, 1255 real 629KB test file fetched over a real public tunnel before this was wired in. */ 1256/* âââ THE ENDPOINT IS NOT HARDCODED ANY MORE â 2026-07-30 âââ 1257 These three constants used to carry a literal trycloudflare hostname baked in at 1258 deploy time. A quick tunnel gets a NEW random hostname every restart, so the moment 1259 cloudflared dropped (it died 2026-07-28) this page went on advertising "paste a 1260 YouTube link" against an address that no longer existed. The button could only fail. 1261 Now: read the current address from /tunnel.json, then PROBE it, and only enable the 1262 control if something answered. A page may not offer what it cannot do. */ 1263/* âââ 2026-07-31 â THE RELAY IS THE SITE, NOT A LAPTOP âââ 1264 This function used to read a trycloudflare hostname out of /tunnel.json and probe 1265 a quick tunnel into the publisher's own machine. When that machine slept, the 1266 headline feature of this page died for every visitor on earth, and the page had 1267 to say so: "this step runs on the publisher's own machine." That was the wrong 1268 architecture and it is gone. 1269 1270 Reaper: "Every machine shows up and works. The site is just there to use THEIR 1271 computer to do the work." 1272 1273 The relay now lives in the site's own worker at /yt/*. It is always on, it belongs 1274 to nobody's desk, and it does exactly one thing: fetch the PUBLIC url the visitor 1275 typed, because a browser is not allowed to fetch youtube.com itself. Everything 1276 after that byte â watching, scenes, clips, export â is on the visitor's machine 1277 exactly as it always was, and the visitor's own files still never move. */ 1278var YT_FETCH_ENDPOINT = "/yt/fetch"; 1279var YT_CAPTIONS_ENDPOINT = "/yt/captions"; 1280 1281async function vpBindFetchEndpoint() { 1282 var btn = document.getElementById("ytfetch_top"); 1283 var st = document.getElementById("ytstatus_top"); 1284 /* Same-origin and always deployed with the page, so there is nothing to probe 1285 and nothing that can be "offline" independently of the site itself. A probe 1286 here would only ever be a chance to disable a working button. */ 1287 if (btn) { btn.disabled = false; btn.style.opacity = "";
1287 btn.style.cursor = ""; } 1288 if (st) st.textContent = ""; 1289 return true; 1290} 1291window.addEventListener("load", vpBindFetchEndpoint); 1292 1293/* ââ real fix, 2026-07-20: YouTube auto-caption fetch, closing the missing- 1294 transcript gap for the YouTube-link path. Found via direct live testing 1295 (dropped a real 25s clip, generated the PDF, inspected it with pdftotext -- 1296 every section had a timestamp and zero spoken words, confirmed empirically, 1297 not assumed). Root cause: cues[] only ever got populated from a manually 1298 uploaded .srt/.vtt (see $("caps").onchange above) -- there was no automatic 1299 path at all. 1300 1301 First attempt (direct browser fetch to YouTube's timedtext endpoint) was 1302 tried and found unreliable on real live testing -- YouTube's caption access 1303 now needs a signed track URL pulled from internal player-response data, 1304 not a stable public endpoint. Routed through the SAME real, already-proven 1305 server-side yt-dlp tool this service already uses to fetch the video file 1306 itself (video_fetch_service.py's new /captions route) -- yt-dlp is 1307 actively maintained against exactly that kind of API churn, so this reuses 1308 proven infrastructure instead of reinventing a fragile direct call. 1309 Verified live: real 200 response, 1074 real caption events for a real test 1310 video, via this exact endpoint, before this was wired in here. 1311 1312 Honest scope: this only covers the YouTube-link path. A dropped local file
1313 still has no automatic transcript source -- that gap is real and open, 1314 named in the BINDER_EXPANSION notes, not silently ignored. */ 1315/* 2026-07-31: endpoint moved to the site's own /yt/captions (see the note above the 1316 fetch binder). The shape changed with it â the worker hands back WebVTT, which is 1317 the format the caption <track> and the drop-a-.vtt path already parse, instead of 1318 YouTube's internal json3. One parser instead of two. */ 1319async function fetchYTCaptions(url) { 1320 try { 1321 var res = await fetch(YT_CAPTIONS_ENDPOINT, { 1322 method: "POST", 1323 headers: { "Content-Type": "application/json" }, 1324 body: JSON.stringify({ url: url }) 1325 }); 1326 if (!res.ok) return false; 1327 var data = await res.json(); 1328 if (!data.found || !data.vtt) return false; 1329 cues = parseVTTCues(data.vtt); 1330 return cues.length > 0; 1331 } catch (e) { 1332 return false; 1333 } 1334} 1335 1336/* Minimal WebVTT reader: timestamps and their text, nothing else. Cue settings, 1337 styling blocks and NOTE blocks are skipped rather than half-understood. */ 1338function parseVTTCues(vtt) { 1339 var out = []; 1340 var lines = String(vtt).replace(/\r/g, "").split("\n"); 1341 var re = /(\d{2}:)?\d{2}:\d{2}\.\d{3}\s*-->\s*(\d{2}:)?\d{2}:\d{2}\.\d{3}/; 1342 for (var i = 0; i < lines.length; i++) { 1343 if (!re.test(lines[i])) continue; 1344 var start = lines[i].split("-->")[0].trim(); 1345 var p = start.split(":"); 1346 var secs = p.length === 3 1347 ? (+p[0]) * 3600 + (+p[1]) * 60 + parseFloat(p[2]) 1348 : (+p[0]) * 60 + parseFloat(p[1]); 1349 var text = []; 1350 for (var j = i + 1; j < lines.length && lines[j].trim() !== ""; j++) { 1351 text.push(lines[j].replace(/<[^>]*>/g, "").trim()); 1352 } 1353 var t = text.join(" ").trim(); 1354 if (t) out.push({ t: secs, text: t }); 1355 i = j; 1356 } 1357 return out; 1358} 1359 1360/* ââ real extension, 2026-07-20: more than YouTube ââ 1361 Reaper asked directly whether this could cover Rumble, Facebook, TikTok, 1362 X too. Checked live on the real machine: yt-dlp already has working 1363 extractors for all four (confirmed via --list-extractors, version 1364 2026.07.04, current). This was a front-end validation restriction, not a 1365 real technical wall -- video_fetch_service.py now routes all five by 1366 domain. Honest scope, same as the server: public content only. */ 1367function detectPlatform(url) { 1368 if (/youtube\.com|youtu\.be/i.test(url)) return "youtube"; 1369 if (/rumble\.com/i.test(url)) return "rumble"; 1370 if (/facebook\.com|fb\.watch/i.test(url)) return "facebook"; 1371 if (/tiktok\.com/i.test(url)) return "tiktok"; 1372 if (/twitter\.com|x\.com/i.test(url)) return "twitter"; 1373 return null; 1374} 1375 1376$("ytfetch_top").onclick = async function () { 1377 var url = ($("yturl_top").value || "").trim(); 1378 if (!url) { $("ytstatus_top").textContent = "Paste a link first."; return; } 1379 var platform = detectPlatform(url); 1380 if (!platform) { $("ytstatus_top").textContent = "That doesn't look like a YouTube, Facebook, TikTok or X link."; return; } 1381 /* MEASURED 2026-07-30: Rumble answers automated fetches with HTTP 403 â a Cloudflare 1382 anti-bot challenge â so link-pulling cannot work there, for us or for anything else. 1383 The page used to list Rumble as a paste target anyway. Say the real reason and point 1384 at the path that does work, rather than failing with something generic. */ 1385 if (platform === "rumble") { 1386 $("ytstatus_top").innerHTML = "<b>Rumble blocks automated fetching</b> (it answers with a 403 anti-bot challenge), " + 1387 "so no tool can pull the file from a link there. Two things that do work: download it from Rumble yourself and " + 1388 "drop the file above, or open it in another tab and use <b>Share a playing tab</b> below."; 1389 return; 1390 } 1391 $("ytstatus_top").textContent = "Fetching the real fileâ¦"; 1392 try { 1393 /* âââ ASK FOR THE ADDRESS, THEN GO AND GET IT âââââââââââââââââââââââââââââââ 1394 2026-07-31. Two things were measured before this changed: 1395 · asking YouTube for the address from this page â **403** (foreign origin), 1396 so the lookup has to be done for us; and 1397 · asking the media host for the FILE from this page â **206, with 1398 `access-control-allow-origin: *`** â so the file itself can come 1399 straight here. 1400 It used to come the long way round: pulled down somewhere else, then pushed 1401 back out to this tab. Every megabyte travelled twice, cost twice, and moved 1402 at whatever the slowest hop allowed. Now only a couple of
1402kilobytes of 1403 address take that route and the video comes the short way, which is faster 1404 for the person waiting and cheaper for us on every single video. 1405 If the short way is refused for any reason, the long way still works and is 1406 taken automatically â nobody sees a failure that has a working answer. */ 1407 var res, direct = false; 1408 var meta = await fetch(YT_FETCH_ENDPOINT + "?as=json", { 1409 method: "POST", headers: { "Content-Type": "application/json" }, 1410 body: JSON.stringify({ url: url }) 1411 }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); 1412 1413 /* `direct` is decided upstream, not here: some addresses are tied to whoever 1414 asked for them and will simply hang if anyone else uses them. A ten-minute 1415 stall on "Found itâ¦" with no error is worse than the slower route, so this 1416 only takes the short way when it is explicitly offered, and it gives up on 1417 it after 12 seconds rather than waiting forever. */ 1418 if (meta && meta.ok && meta.direct && meta.url) { 1419 $("ytstatus_top").textContent = "Found it â bringing the file inâ¦"; 1420 var ac = new AbortController(); 1421 var giveUp = setTimeout(function () { ac.abort(); }, 12000); 1422 res = await fetch(meta.url, { signal: ac.signal }).catch(function () { return null; }); 1423 clearTimeout(giveUp); 1424 if (res && res.ok) { direct = true; } 1425 } 1426 if (!direct) $("ytstatus_top").textContent = "Found it â bringing the file inâ¦"; 1427 if (!direct) { 1428 res = await fetch(YT_FETCH_ENDPOINT, { 1429 method: "POST", 1430 headers: { "Content-Type": "application/json" }, 1431 body: JSON.stringify({ url: url }) 1432 }); 1433 } 1434 if (!res || !res.ok) { 1435 var errBody = res ? await res.json().catch(function () { return {}; }) : {}; 1436 $("ytstatus_top").textContent = "Fetch failed (" + (res ? res.status : "no reply") + "): " + (errBody.error || "unknown error") + " â try sharing a tab instead below."; 1437 return; 1438 } 1439 /* âââ REAL PROGRESS, 2026-07-30 âââ 1440 This used to be `await res.blob()` â one await with nothing shown until it finished. 1441 Measured on a real 54-minute interview: 153 MB, and the page sat on "Fetching the 1442 real fileâ¦" for over 190 seconds with no sign of life. A silent three-minute wait 1443 reads as broken, which is the same defect the voice button had with "Starting micâ¦". 1444 Read the stream and say what is actually happening. */ 1445 var total = parseInt(res.headers.get("content-length") || "0", 10); 1446 var chunks = [], got = 0, t0 = Date.now(); 1447 var reader = res.body && res.body.getReader ? res.body.getReader() : null; 1448 var blob; 1449 if (reader) { 1450 while (true) { 1451 var r = await reader.read(); 1452 if (r.done) break; 1453 chunks.push(r.value); got += r.value.length; 1454 var secs = (Date.now() - t0) / 1000; 1455 var mb = got / 1e6, rate = mb / Math.max(secs, 0.1); 1456 $("ytstatus_top").textContent = total 1457 ? "Pulling the file â " + mb.toFixed(1) + " of " + (total / 1e6).toFixed(1) + 1458 " MB (" + Math.round(got / total * 100) + "%, " + rate.toFixed(1) + " MB/s)" 1459 : "Pulling the file â " + mb.toFixed(1) + " MB so far (" + rate.toFixed(1) + " MB/s)"; 1460 } 1461 blob = new Blob(chunks, { type: "video/mp4" }); 1462 chunks = null; 1463 } else { 1464 blob = await res.blob(); 1465 } 1466 $("ytstatus_top").textContent = "Got it (" + (blob.size / 1e6).toFixed(1) + " MB) â reading it nowâ¦"; 1467 var file = new File([blob], platform + "_video.mp4", { type: "video/mp4" }); 1468 /* auto-captions are a YouTube-specific feature (yt-dlp --write-auto-sub); 1469 other platforms fall through to the existing local transcription 1470 (transcribeLocally, in start()) automatically when cues stays empty. */ 1471 var gotCaps = (platform === "youtube") ? await fetchYTCaptions(url) : false; 1472 $("ytstatus_top").textContent += gotCaps 1473 ? " Real captions found â sections will carry their spoken words." 1474 : (platform === "youtube"
1475 ? " No real captions found for this video â sections will show note-lines unless you drop a .srt/.vtt below." 1476 : " Binding it now â this platform doesn't carry auto-captions the way YouTube does, so this'll transcribe for these packs after watching if no captions are loaded."); 1477 start(file); 1478 } catch (err) { 1479 $("ytstatus_top").textContent = "Fetch step unreachable: " + err.message + " â try sharing a tab instead below."; 1480 } 1481}; 1482 1483/* ââ file drop / pick ââ */ 1484var drop = $("drop"); 1485drop.onclick = function(){ $("file").click(); }; 1486drop.ondragover = function(e){ e.preventDefault(); drop.classList.add("hot"); }; 1487drop.ondragleave = function(){ drop.classList.remove("hot"); }; 1488drop.ondrop = function(e){ e.preventDefault(); drop.classList.remove("hot"); if (e.dataTransfer.files[0]) start(e.dataTransfer.files[0]); }; 1489$("file").onchange = function(e){ if (e.target.files[0]) start(e.target.files[0]); }; 1490 1491var fmt = function(s){ return Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0"); }; 1492 1493/* ââ one breath: seek â draw â measure luminance signature ââ */ 1494function grab(v, t) { 1495 // Guard: if currentTime is already at t, skip the seek (headless Chromium 1496 // doesn't fire onseeked when the value doesn't change). 1497 if (Math.abs(v.currentTime - t) < 0.001) { 1498 var c = $("c"), p = $("cprev"); 1499 c.width = 640; c.height = Math.round(640 * v.videoHeight / v.videoWidth) || 360; 1500 c.getContext("2d").drawImage(v, 0, 0, c.width, c.height); 1501 p.width = 32; p.height = 18; 1502 p.getContext("2d").drawImage(c, 0, 0, 32, 18); 1503 return Promise.resolve(p.getContext("2d").getImageData(0, 0, 32, 18).data); 1504 } 1505 return new Promise(function(r){ 1506 v.onseeked = r; v.currentTime = t; 1507 }).then(function(){ 1508 var c = $("c"), p = $("cprev"); 1509 c.width = 640; c.height = Math.round(640 * v.videoHeight / v.videoWidth) || 360; 1510 c.getContext("2d").drawImage(v, 0, 0, c.width, c.height); 1511 p.width = 32; p.height = 18; 1512 p.getContext("2d").drawImage(c, 0, 0, 32, 18); 1513 return p.getContext("2d").getImageData(0, 0, 32, 18).data; 1514 }); 1515} 1516 1517function diff(a, b) { 1518 if (!a) return 1; 1519 var d = 0; 1520 for (var i = 0; i < a.length; i += 4) 1521 d += Math.abs(a[i]-b[i]) + Math.abs(a[i+1]-b[i+1]) + Math.abs(a[i+2]-b[i+2]); 1522 return d / (a.length / 4) / 765; 1523} 1524 1525/* ââ local-file transcription, 2026-07-20 ââ 1526 Real fix, built fully, not left as a named gap: closes the missing- 1527 transcript bug for the one path the YouTube-caption fix (above) can't 1528 reach -- a video with no separate caption file and no YouTube URL. 1529 1530 FIRST ATTEMPT (kept here as real history, not hidden): in-browser Whisper 1531 via transformers.js (WASM/WebGPU). Built, deployed, live-tested -- hit a 1532 genuine, unresolved third-party CORS/CDN "Failed to fetch" error even 1533 after adding the COOP/COEP headers the codebase had already named as the 1534 next step. Root cause not fully diagnosable within a third-party CDN's 1535 own infrastructure. 1536 1537 REAL FIX: routes through the SAME proven server-side breath.cpp pipeline 1538 (watch_video.py's watch()) already validated end-to-end all session for 1539 YouTube videos -- same SSSE3-patched whisper, same warm server. Honest 1540 tradeoff, stated plainly on the page, not hidden: this path uploads audio 1541 only (video frames / scene detection stay 100% client-side) -- same 1542 category of disclosed compromise as the redact MCP endpoint earlier 1543 today. Only runs if cues[] is still empty when start() is called -- a 1544 manually uploaded .srt/.vtt or a YouTube-fetched caption set always takes 1545 priority and this step is skipped entirely when either already populated 1546 cues. */ 1547/* âââ REMOVED 2026-07-31 â THIS ONE WAS THE WORST OF THE THREE âââ 1548 This function uploaded THE VISITOR'S OWN AUDIO to a tunnel into the publisher's 1549 laptop, while the status line it printed said "for these packs ⦠nothing leaves 1550 this tab." That line was not true, and no disclosure elsewhere on the page makes 1551 a false sentence at the moment of the upload acceptable. 1552 1553 It also could not have worked since 2026-07-28 â the tunnel returns 000 â so for 1554 three days it was a promise that failed silently and left the caption gap 1555 unexplained. 1556 1557 Nothing replaces it yet, and the page now SAYS so instead of pretending. A local 1558 file with no captions gets note-lines, and the visitor is told they can drop a 1559 .srt or .vtt to fix it. In-browser transcription is the right answer and it is a 1560 real build (a first attempt through a third-party CDN failed on CORS and is 1561 recorded in this file's history) â it is open work, not a shipped feature. */ 1562async function transcribeLocally(file) { 1563 return false;
1564} 1565 1566/* ââ main watch loop ââ */ 1567async function start(file) { 1568 vname = file.name.replace(/\.[^.]+$/,""); 1569 /* real bug found + fixed 2026-07-20, caught by live testing: cues[] is a 1570 module-level global that was never reset here, so a NEW dropped file 1571 could silently inherit stale caption data left over from a PREVIOUS 1572 video/YouTube fetch in the same page session -- confirmed live when a 1573 local-file test skipped its own transcription step because cues[] still 1574 held a prior video's captions. Always reset for a fresh start(); a 1575 caption file dropped for THIS run (before or shortly after) still 1576 re-populates cues via its own independent onchange handler. */ 1577 cues = []; 1578 scenes = []; $("film").innerHTML = ""; $("film").style.display = "grid"; 1579 $("meterrow").style.display = "flex"; $("makerow").style.display = "none"; 1580 $("bench").style.display = "none"; 1581 var srcUrl = URL.createObjectURL(file); 1582 var v = $("v"); v.src = srcUrl; 1583 await new Promise(function(r){ v.onloadedmetadata = r; }); 1584 vidDur = v.duration; 1585 /* Align serial fallback sample count with lanes.sampleBudget when present */ 1586 var sampleN = (window.AKDLanes && window.AKDLanes.sampleBudget) 1587 ? window.AKDLanes.sampleBudget(vidDur) : 400; 1588 var dur = vidDur, step = Math.max(0.75, dur / sampleN); 1589 var t0 = performance.now(), prev = null, breaths = 0, dropped = 0; 1590 1591 /* ââ paint one kept scene into the film strip âââââââââââââââââââââââââââ 1592 Shared by both paths so the strip is built exactly one way. */ 1593 function pushScene(sc) { 1594 scenes.push(sc); 1595 var fig = document.createElement("figure"); 1596 fig.innerHTML = "<img src='" + sc.jpg + "' alt='scene at " + fmt(sc.t) + "'><figcaption>▶ " + fmt(sc.t) + "<br><span style='font-size:9px;opacity:.65;font-family:system-ui'>tap to set clip point</span></figcaption>"; 1597 fig.dataset.idx = scenes.length - 1; 1598 fig.title = "Tap to set this as your clip start or end"; 1599 $("film").appendChild(fig); 1600 } 1601 1602 /* ââ MOMENT RANKING, 2026-08-20 ââââââââââââââââââââââââââââââââââââââââ 1603 D003. The watch pass finds every scene CHANGE, which is a picture question. 1604 Which scene is worth POSTING is a language question, and the answer is already 1605 in the captions the clipper just loaded. Measured on a real paid podcast source: 1606 3,255 cues over 1h29m produced 8 distinct candidates whose reasons a human 1607 immediately agreed with (a reveal, a conflict, a laugh, a number). 1608 1609 This is a HEURISTIC that ranks candidates for a person to choose from. It is not 1610 a prediction of views and is never labelled as one. With no captions loaded it 1611 declines and changes nothing. */ 1612 document.addEventListener("click", function (e) { 1613 var b = e.target && e.target.closest ? e.target.closest("#findmoments") : null; 1614 if (!b) return; 1615 e.preventDefault(); 1616 try { window.ClipLabMoments.annotate(30); } 1617 catch (err) { 1618 var st = document.getElementById("momentstatus"); 1619 if (st) st.textContent = "ranking failed: " + err; 1620 } 1621 }); 1622 1623 /* ââ D006 · BATCH EXPORT AND THE WEEK PLAN, 2026-08-20 ââââââââââââââââââ 1624 The wedge of this tool is that one examination pays for many products 1625 (map-once, re-order-free â measured at 76.9x realtime on a 90-minute source). 1626 But the UI still made a clipper click once per clip, so the wedge never reached 1627 them. `aleph-clipper-economics` states the standing rule this closes: 1628 "the volume that pays requires posting across the week, not all at once." 1629 1630 Plans first, cuts second: the clipper sees the whole week before anything is 1631 encoded, because a batch that starts before you agree with it is worse than no 1632 batch at all. */ 1633 window.ClipLabBatch = (function () { 1634 1635 /* A week of posts, spread. Defaults chosen from the trade, not taste: 1636 2 posts/day is the sustainable beginner cadence, 3 platforms per clip is where 1637 the same cut earns three times, and 30s sits inside every campaign minimum 1638 (>=10s) and under the ~60s monetisation band. */ 1639 /* MEASURED CADENCE CEILING, 2026-08-20 â this is a safety limit, not a dial. 1640 TikTok: 1-3 posts/day/platform optimal · 6-10 risky · 10+ or several within 1641 minutes likely flagged. X: near-duplicates posted simultaneously is the named 1642 failure. Instagram: 7-14 days warm-up before mass posting. A limited account 1643 earns nothing at any CPM, so the planner refuses an unsafe cadence rather than 1644 offering it. Volume comes from BREADTH across platforms, not depth on one. */
1645 var CADENCE_MAX_PER_DAY = 3; 1646 1647 function plan(opts) { 1648 opts = opts || {}; 1649 var days = opts.days || 7; 1650 var asked = opts.perDay || 2; 1651 var perDay = Math.max(1, Math.min(CADENCE_MAX_PER_DAY, asked)); 1652 var clamped = (asked > perDay) ? asked : null; 1653 var want = days * perDay; 1654 var len = opts.length || 30; 1655 var platforms = opts.platforms || 1656 ["TikTok", "YouTube Shorts", "Instagram Reels"]; 1657 1658 var picks = [], why = "ranked by captions"; 1659 try { 1660 var r = window.ClipLabMoments && window.ClipLabMoments.rank(len); 1661 if (r && r.ok && r.ranked.length) { 1662 picks = r.ranked.slice(0, want).map(function (m) { 1663 return {t: m.t, score: m.score, reasons: m.reasons}; 1664 }); 1665 } 1666 } catch (e) { /* fall through to even spacing */ } 1667 1668 if (picks.length < want) { 1669 /* No captions, or not enough scored moments: space evenly across the source 1670 and SAY SO rather than pretending these are ranked picks. */ 1671 why = picks.length ? "part ranked, part evenly spaced" 1672 : "evenly spaced (no captions loaded)"; 1673 var total = (typeof vidDur === "number" && vidDur) ? vidDur : 0; 1674 if (total > len * 2) { 1675 var need = want - picks.length; 1676 for (var i = 0; i < need; i++) { 1677 var t = (total - len) * (i + 0.5) / need; 1678 var clash = picks.some(function (p) { return Math.abs(p.t - t) < len; }); 1679 if (!clash) picks.push({t: t, score: null, reasons: ["spaced"]}); 1680 } 1681 } 1682 } 1683 1684 picks.sort(function (a, b) { return a.t - b.t; }); 1685 picks = picks.slice(0, want); 1686 1687 var out = [], day = 0, slot = 0; 1688 /* Slots are at least 4 hours apart: "several within minutes" is the pattern that 1689 gets flagged, independently of the daily count. */ 1690 var slots = ["09:00", "14:00", "19:00"]; 1691 for (var k = 0; k < picks.length; k++) { 1692 out.push({ 1693 n: k + 1, 1694 day: day + 1, 1695 when: slots[slot % slots.length], 1696 t: picks[k].t, 1697 length: len, 1698 score: picks[k].score, 1699 reasons: picks[k].reasons, 1700 platforms: platforms.slice(0), 1701 /* the same cut posted on three platforms is three earning events */ 1702 earning_events: platforms.length 1703 }); 1704 slot++; 1705 if (slot % perDay === 0) { day++; slot = 0; } 1706 } 1707 return {ok: out.length > 0, why: why, clips: out, 1708 posts: out.length * platforms.length, 1709 perPlatformPerDay: perDay, 1710 clamped: clamped, 1711 days: days, perDay: perDay}; 1712 } 1713 1714 function fmtT(s) { 1715 var m = Math.floor(s / 60), ss = Math.round(s % 60); 1716 return m + "m" + (ss < 10 ? "0" : "") + ss + "s"; 1717 } 1718 1719 function showPlan(opts) { 1720 var host = document.getElementById("batchstatus"); 1721 var p = plan(opts); 1722 if (!host) return p; 1723 if (!p.ok) { 1724 host.innerHTML = "Load a video first â then this plans a week from its moments."; 1725 return p; 1726 } 1727 var rows = p.clips.map(function (c) { 1728 return "<tr><td style='padding:2px 8px'>day " + c.day + "</td>" + 1729 "<td style='padding:2px 8px'>" + c.when + "</td>" + 1730 "<td style='padding:2px 8px;font-family:monospace'>" + fmtT(c.t) + "</td>" + 1731 "<td style='padding:2px 8px'>" + 1732 (c.score !== null ? "score " + c.score : "spaced") + "</td>" + 1733 "<td style='padding:2px 8px;color:#6b5f4e'>" + 1734 (c.reasons || []).slice(0, 2).join(", ") + "</td></tr>"; 1735 }).join(""); 1736 var warn = p.clamped 1737 ? "<div style='font:12px system-ui;color:#a33;margin-bottom:6px'>Asked for " + 1738 p.clamped + "/day and planned " + p.perDay + " instead. Above 3 a day on one " + 1739 "platform is where accounts start getting limited, and a limited account " + 1740 "earns nothing.</div>" 1741 : ""; 1742 host.innerHTML = warn + 1743 "<div style='font:13px system-ui;margin-bottom:6px'><b>" + p.clips.length + 1744 " clips over " + p.days + " days</b> — <b>" + p.perPlatformPerDay + 1745 " per day on each platform</b> (the number that matters), across " + 1746 p.clips[0].platforms.length + " platforms = <b>" + p.posts + 1747 " posts</b>. Picks " + p.why + ".</div>" + 1748 "<table style='font:12px system-ui;border-collapse:collapse'>" + rows + "</table>" + 1749 "<div style='font:12px system-ui;color:#6b5f4e;margin-top:8px'>" + 1750 "Cutting happens one clip at a time — the page encodes in sequence. " + 1751 "Nothing is posted for you; the schedule is yours to follow.</div>" + 1752 "<button type='button' id='batchgo' style='margin-top:8px;font:13px system-ui;" + 1753 "padding:6px 14px;border-radius:6px;border:1px solid #ddd;background:#fff;" +
1754 "cursor:pointer'>Cut these " + p.clips.length + " clips now →</button>"; 1755 window.ClipLabBatch._plan = p; 1756 return p; 1757 } 1758 1759 /* Cut every planned clip, in sequence, reporting progress honestly. */ 1760 async function exportAll() { 1761 var p = window.ClipLabBatch._plan; 1762 var host = document.getElementById("batchstatus"); 1763 if (!p || !p.ok) return; 1764 var done = 0, failed = 0; 1765 for (var i = 0; i < p.clips.length; i++) { 1766 var c = p.clips[i]; 1767 if (host) { 1768 host.innerHTML = "<div style='font:13px system-ui'>Cutting " + (i + 1) + 1769 " of " + p.clips.length + " — " + fmtT(c.t) + " (+" + c.length + 1770 "s)… " + done + " done, " + failed + " failed.</div>"; 1771 } 1772 try { 1773 /* use the page's own single-clip door so QC and captions behave identically */ 1774 await exportOneProduct(c.t, c.t + c.length, 1775 true /* burn subs */, null, i + 1, null, null); 1776 done++; 1777 } catch (e) { 1778 failed++; 1779 } 1780 await new Promise(function (r) { setTimeout(r, 400); }); 1781 } 1782 if (host) { 1783 host.innerHTML = "<div style='font:13px system-ui'><b>" + done + 1784 " clips cut</b>" + (failed ? ", " + failed + " failed" : "") + 1785 ". They are in your Downloads and in the files tray above.<br>" + 1786 "Remember the hour: submit each one to the campaign within 60 minutes of " + 1787 "posting it, or the views before submission may not be counted.</div>"; 1788 } 1789 } 1790 1791 return {plan: plan, showPlan: showPlan, exportAll: exportAll, _plan: null}; 1792 })(); 1793 1794 document.addEventListener("click", function (e) { 1795 var t = e.target; 1796 if (!t || !t.closest) return; 1797 if (t.closest("#planweek")) { 1798 e.preventDefault(); 1799 try { window.ClipLabBatch.showPlan({days: 7, perDay: 2, length: 30}); } 1800 catch (err) { 1801 var h = document.getElementById("batchstatus"); 1802 if (h) h.textContent = "planning failed: " + err; 1803 } 1804 } 1805 if (t.closest("#batchgo")) { 1806 e.preventDefault(); 1807 window.ClipLabBatch.exportAll(); 1808 } 1809 }); 1810 1811 window.ClipLabMoments = (function () { 1812 var HOOKS = { 1813 reveal: ["never told","never said","first time","nobody knows","the truth is", 1814 "turns out","secret","confession","i've never","real reason","admit"], 1815 number: ["million","thousand","percent","dollars","years old","hundred","billion"], 1816 conflict: ["hated","fought","argument","wrong","disagree","worst","mistake", 1817 "regret","quit","fired","broke","refused"], 1818 emotion: ["cried","scared","terrified","insane","crazy","wild","unbelievable", 1819 "shocked","freaked","lost it"], 1820 story: ["so i","and then","one day","the moment","i remember","happened"], 1821 laugh: ["[laughter]","laughing","hilarious","dying"] 1822 }; 1823 var SOFT = ["so yeah","um","uh","you know","i mean","like i said","anyway"]; 1824 var BOILER = ["subscribe","like and","smash that","link in","sponsor","patreon","merch"]; 1825 1826 function textBetween(a, b) { 1827 if (!window.cues || !cues.length) return ""; 1828 var out = []; 1829 for (var i = 0; i < cues.length; i++) { 1830 if (cues[i].t >= a && cues[i].t < b) out.push(cues[i].text || ""); 1831 } 1832 return out.join(" "); 1833 } 1834 1835 function scoreWindow(a, b) { 1836 var txt = textBetween(a, b).toLowerCase(); 1837 if (!txt.trim()) return {score: 0, reasons: [], words: 0}; 1838 var score = 0, reasons = []; 1839 Object.keys(HOOKS).forEach(function (kind) { 1840 var hit = null; 1841 for (var i = 0; i < HOOKS[kind].length; i++) { 1842 if (txt.indexOf(HOOKS[kind][i]) >= 0) { hit = HOOKS[kind][i]; break; } 1843 } 1844 if (hit) { 1845 score += (kind === "reveal" || kind === "conflict") ? 3 : 2; 1846 reasons.push(kind); 1847 } 1848 }); 1849 var head = txt.split(/\s+/).slice(0, 12).join(" "); 1850 for (var s = 0; s < SOFT.length; s++) { 1851 if (head.indexOf(SOFT[s]) === 0) { score -= 4; reasons.push("soft-open"); break; } 1852 } 1853 for (var t2 = 0; t2 < BOILER.length; t2++) { 1854 if (txt.indexOf(BOILER[t2]) >= 0) { score -= 6; reasons.push("promo"); break; } 1855 } 1856 var wc = txt.split(/\s+/).length; 1857 if (wc < 40) { score -= 3; reasons.push("sparse"); } 1858 else if (wc > 90) { score += 2; reasons.push("dense"); } 1859 if (txt.indexOf("?") >= 0) { score += 2; reasons.push("question"); } 1860 return {score: score, reasons: reasons, words: wc}; 1861 } 1862 1863 function rank(len) { 1864 len = len || 30; 1865 if (!window.cues || !cues.length) { 1866 return {ok: false, why: "no captions loaded â drop a .srt/.vtt to rank moments"}; 1867 } 1868 var out = []; 1869 for (var i = 0; i < scenes.length; i++) { 1870 var a = scenes[i].t; 1871 var r = scoreWindow(a, a + len); 1872 if (r.score > 0) out.push({idx: i, t: a, score: r.score, 1873 reasons: r.reasons, words: r.words}
1873); 1874 } 1875 out.sort(function (p, q) { return q.score - p.score; }); 1876 /* keep candidates from crowding the same stretch of source */ 1877 var kept = []; 1878 for (var k = 0; k < out.length; k++) { 1879 var clash = false; 1880 for (var j = 0; j < kept.length; j++) { 1881 if (Math.abs(out[k].t - kept[j].t) < len * 1.5) { clash = true; break; } 1882 } 1883 if (!clash) kept.push(out[k]); 1884 } 1885 return {ok: true, ranked: kept, scanned: scenes.length, cues: cues.length}; 1886 } 1887 1888 /* Annotate the filmstrip in place: badge each ranked scene and move the best 1889 ones to the front, so 528 thumbnails become a shortlist. */ 1890 function annotate(len) { 1891 var r = rank(len); 1892 var st = document.getElementById("momentstatus"); 1893 if (!r.ok) { if (st) st.textContent = r.why; return r; } 1894 var film = document.getElementById("film"); 1895 var figs = film ? film.querySelectorAll("figure") : []; 1896 for (var i = 0; i < figs.length; i++) { 1897 var old = figs[i].querySelector(".momentbadge"); 1898 if (old) old.remove(); 1899 figs[i].classList.remove("momenttop"); 1900 } 1901 r.ranked.slice(0, 12).forEach(function (m, place) { 1902 var fig = figs[m.idx]; 1903 if (!fig) return; 1904 var b = document.createElement("div"); 1905 b.className = "momentbadge"; 1906 b.style.cssText = "position:relative;font:11px system-ui;padding:3px 6px;" + 1907 "background:var(--soft,#f4efe4);border-top:1px solid var(--line,#ddd);" + 1908 "color:var(--ink,#25201a)"; 1909 b.textContent = "#" + (place + 1) + " score " + m.score + 1910 (m.reasons.length ? " · " + m.reasons.slice(0, 3).join(", ") : ""); 1911 fig.appendChild(b); 1912 fig.classList.add("momenttop"); 1913 fig.style.order = String(place); 1914 }); 1915 if (film) film.style.display = "flex", film.style.flexWrap = "wrap"; 1916 if (st) { 1917 st.textContent = "ranked " + r.ranked.length + " candidate moments from " + 1918 r.cues + " caption lines across " + r.scanned + " scenes â " + 1919 "best ones badged and moved to the front. This ranks candidates for you " + 1920 "to choose from; it is not a prediction of views."; 1921 } 1922 return r; 1923 } 1924 1925 return {rank: rank, annotate: annotate, scoreWindow: scoreWindow}; 1926 })(); 1927 1928 /* ââ LANES, 2026-07-30 ââââââââââââââââââââââââââââââââââââââââââââââââââ 1929 The loop below this used to be the whole watch pass: one <video>, seeked 1930 400 times, one seek at a time. It measured 9à realtime on an 8-core phone 1931 and 19à on a 2-core laptop, and the reason was not the phone â the page had 1932 no parallelism at all and never read `hardwareConcurrency`, so the number 1933 was single-core seek latency either way. 1934 1935 `lanes.js` gives each slice of the timeline its own decoder so K seeks are 1936 in flight at once. It is tried first and the serial loop is kept, untouched, 1937 as the fallback: if any lane fails to load or a seek stalls, we take the 1938 slow road rather than hand back a half-scanned film strip. */ 1939 var laneResult = null; 1940 if (global_lanesEnabled && window.AKDLanes && dur > 20) { 1941 try { 1942 S("watching across " + window.AKDLanes.laneCount(dur) + 1943 " lanes â reading it nowâ¦"); 1944 laneResult = await window.AKDLanes.watchInLanes(srcUrl, dur, { 1945 onProgress: function(p) { 1946 $("m_t").textContent = fmt(p.t); $("m_b").textContent = p.breaths; 1947 $("m_s").textContent = p.kept; $("m_d").textContent = p.dropped; 1948 $("m_x").textContent = p.speed.toFixed(0); 1949 } 1950 }); 1951 laneResult.scenes.forEach(pushScene); 1952 breaths = laneResult.breaths; dropped = laneResult.dropped; 1953 } catch (e) { 1954 /* Named out loud rather than swallowed â a silent fallback is how a 1955 performance regression lives for a month without anyone knowing. */ 1956 console.warn("lanes failed, falling back to the serial watch:", e && e.message); 1957 laneResult = null; 1958 scenes = []; $("film").innerHTML = ""; 1959 } 1960 } 1961 1962 if (!laneResult) { 1963 for (var t = 0; t < dur; t += step) { 1964 var sig = await grab(v, Math.min(t, dur - 0.1)); breaths++; 1965 if (diff(prev, sig) > 0.085 || scenes.length === 0) { 1966 pushScene({t: t, jpg: $("c").toDataURL("image/jpeg", 0.72)}); 1967 } else dropped++; 1968 prev = sig; 1969 $("m_t").textContent = fmt(t); $("m_b").textContent = breaths; 1970 $("m_s").textContent = scenes.length; $("m_d").textContent = dropped; 1971 $("m_x").textContent = (t / ((performance.now() - t0) / 1000)).toFixed(0); 1972 S("watchingâ¦"); 1973 } 1974 } 1975 1976 const finalSpeed = (dur / ((performance.now() - t0) / 1000)).toFixed(1);
1977 const laneNote = laneResult ? " across " + laneResult.lanes + " lanes" : ""; 1978 var elastNote = ""; 1979 if (laneResult && laneResult.elasticity) { 1980 var el = laneResult.elasticity; 1981 elastNote = " · elasticity pressure " + el.pressure + 1982 (el.heapMB != null ? (", heap " + el.heapMB + " MB") : "") + 1983 " (FO1: expand when free, yield when hot)"; 1984 } 1985 S("watched " + fmt(dur) + " in " + ((performance.now() - t0) / 1000).toFixed(0) + 1986 "s â " + finalSpeed + "à realtime for these packs" + laneNote + elastNote + 1987 ", measured just now â " + scenes.length + " key moments kept. Bind it, or cut clips below."); 1988 if (cues.length === 0) { 1989 var gotLocal = await transcribeLocally(file); 1990 S((gotLocal ? "Transcribed for these packs â sections will carry their spoken words. " : "No captions came with this file, so sections will show note-lines. Drop a .srt or .vtt above and every section picks up its spoken words. ") + 1991 "watched " + fmt(dur) + " â " + finalSpeed + "à realtime â " + scenes.length + " key moments kept. Bind it, or cut clips below."); 1992 } 1993 $("makerow").style.display = "flex"; 1994 if (window.ClipLabPackage) window.ClipLabPackage.render(); 1995 showBench(dur); 1996} 1997 1998/* ââ wire scene thumbnails as clip snap points ââ */ 1999function showBench(dur) { 2000 $("bench").style.display = "block"; 2001 var slA = $("clipA"), slB = $("clipB"); 2002 slA.max = Math.floor(dur); slB.max = Math.floor(dur); 2003 slA.value = 0; slB.value = Math.floor(dur); 2004 $("clipA_lbl").textContent = fmt(0); $("clipB_lbl").textContent = fmt(Math.floor(dur)); 2005 slA.oninput = function(){ $("clipA_lbl").textContent = fmt(+slA.value); }; 2006 slB.oninput = function(){ $("clipB_lbl").textContent = fmt(+slB.value); }; 2007 /* clicking a thumbnail snaps sliders */ 2008 /* Two-tap clip selection: first tap = start, second tap = end, third resets */ 2009 var _tapCount = 0; 2010 $("film").onclick = function(e) { 2011 var fig = e.target.closest("figure"); 2012 if (!fig) return; 2013 var idx = +fig.dataset.idx; 2014 var sc = scenes[idx]; 2015 if (!sc) return; 2016 _tapCount++; 2017 if (_tapCount === 1) { 2018 /* first tap: set start, clear end selection */ 2019 document.querySelectorAll("#film figure").forEach(function(f){ f.classList.remove("sel"); f.style.outline = ""; }); 2020 fig.classList.add("sel"); fig.style.outline = "3px solid var(--acc)"; 2021 slA.value = Math.floor(sc.t); 2022 $("clipA_lbl").textContent = fmt(Math.floor(sc.t)); 2023 S("Start set to " + fmt(Math.floor(sc.t)) + " â now tap a second thumbnail to set the end."); 2024 } else if (_tapCount === 2) { 2025 /* second tap: set end */ 2026 fig.style.outline = "3px dashed var(--acc)"; 2027 slB.value = Math.max(Math.floor(sc.t), +slA.value + 1); 2028 $("clipB_lbl").textContent = fmt(Math.floor(sc.t)); 2029 S("Clip set: " + $("clipA_lbl").textContent + " to " + $("clipB_lbl").textContent + " â use Export clip below."); 2030 } else { 2031 /* third tap: reset */ 2032 _tapCount = 0; 2033 document.querySelectorAll("#film figure").forEach(function(f){ f.classList.remove("sel"); f.style.outline = ""; }); 2034 S("Selection cleared â tap two thumbnails again to set a new clip."); 2035 } 2036 }; 2037 /* Show tap-hint on thumbnails */ 2038 var filmEl = $("film"); 2039 if (filmEl) filmEl.title = "Tap two scenes to set your clip start and end"; 2040} 2041 2042/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2043 PDF BINDER EXPORT 2044 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2045$("make").onclick = function() { 2046 if (!chGate()) return; 2047 var jsPDF = window.jspdf.jsPDF; 2048 var doc = new jsPDF({unit:"pt",format:"letter"}); 2049 var W = 612, M = 50; 2050 doc.setFillColor("#b4552d"); doc.rect(0, 0, W, 200, "F"); 2051 doc.setTextColor("#ffffff"); doc.setFont("times","bold"); doc.setFontSize(30); 2052 doc.text(doc.splitTextToSize(vname, W - 2*M), M, 90); 2053 doc.setFontSize(12); doc.setFont("helvetica","normal"); 2054 doc.text("A Binder manual · " + scenes.length + " sections · " + 2055 new Date().toLocaleDateString(), M, 170); 2056 var y = 240;
2057 scenes.forEach(function(s, i) { 2058 var ih = 152, iw = 270; 2059 if (y + ih + 60 > 760) { doc.addPage(); y = 60; } 2060 doc.setFont("times","bold"); doc.setFontSize(14); doc.setTextColor("#b4552d"); 2061 doc.text("Section " + (i+1) + " · " + fmt(s.t), M, y); 2062 doc.addImage(s.jpg, "JPEG", M, y+10, iw, ih); 2063 var next = scenes[i+1] ? scenes[i+1].t : s.t + 9999; 2064 var spoken = cuesBetween(s.t, next); 2065 if (!spoken || !String(spoken).trim()) { 2066 spoken = "(No spoken words mapped for this section yet. Drop an SRT/VTT or generate captions from the audio, then export the manual again â words appear next to each screenshot.)"; 2067 } 2068 doc.setFont("helvetica","normal"); doc.setFontSize(8.5); doc.setTextColor("#4a4034"); 2069 var lines = doc.splitTextToSize(spoken, W - M - (M + iw + 18)).slice(0, 18); 2070 lines.forEach(function(l, k){ doc.text(l, M+iw+18, y+22+k*11); }); 2071 y += ih + 44; 2072 }); 2073 /* contact sheet: the whole video on one page */ 2074 doc.addPage(); 2075 doc.setFont("times","bold"); doc.setFontSize(16); doc.setTextColor("#b4552d"); 2076 doc.text("Contact sheet â the whole video at a glance", M, 60); 2077 var cols = 4, cw = (W - 2*M - (cols-1)*10) / cols, ch = cw * 0.5625; 2078 var rowsPer = Math.floor((760 - 80) / (ch + 26)), perPage = cols * rowsPer; 2079 scenes.forEach(function(s, i) { 2080 if (i > 0 && i % perPage === 0) doc.addPage(); 2081 var k = i % perPage; 2082 var cx = M + (k % cols) * (cw + 10);
2083 var cy = 80 + Math.floor(k / cols) * (ch + 26); 2084 doc.addImage(s.jpg, "JPEG", cx, cy, cw, ch); 2085 doc.setFontSize(7); doc.setTextColor("#6b5f4e"); doc.setFont("helvetica","normal"); 2086 doc.text(fmt(s.t), cx, cy+ch+9); 2087 }); 2088 var n = doc.getNumberOfPages(); 2089 for (var pi = 1; pi <= n; pi++) { 2090 doc.setPage(pi); doc.setFontSize(8); doc.setTextColor("#a5988a"); 2091 doc.text("The Binder · page " + pi + "/" + n, M, 775); 2092 } 2093 doc.save(vname + "_binder.pdf"); 2094 chCommitTrial("binder_pdf"); 2095 S("bound. Check your downloads â your packs are ready to download."); 2096 showBinderOutputMoment(vname + "_binder.pdf", null); 2097}; 2098 2099/* ââ chapter list (.txt) ââ */ 2100$("chaps").onclick = function() { 2101 if (!scenes.length) { S("watch a video first."); return; } 2102 if (!chGate()) return; 2103 var lines = scenes.map(function(s, i) { 2104 var next = scenes[i+1] ? scenes[i+1].t : s.t + 9999; 2105 var spoken = cuesBetween(s.t, next); 2106 var label = spoken ? spoken.split(" ").slice(0,7).join(" ") : "Section " + (i+1); 2107 return fmt(s.t).padStart(5,"0") + " " + label; 2108 }); 2109 var a = document.createElement("a"); 2110 var chapUrl = URL.createObjectURL(new Blob([lines.join("\n")], {type:"text/plain"})); 2111 a.href = chapUrl; 2112 a.download = vname + "_chapters.txt"; a.click(); 2113 chCommitTrial("chapter_list"); 2114 S("chapter list saved â paste it straight into a YouTube description."); 2115 showBinderOutputMoment(vname + "_chapters.txt", chapUrl); 2116}; 2117 2118/* ââ chapter thumbnails ZIP export ââ */ 2119/* Uses a minimal STORE-mode ZIP builder (no compression needed â JPEGs are already compressed). 2120 fflate is an ES module and would need type=module; this inline builder avoids that complexity. */ 2121$("thumbzip").onclick = function() { 2122 if (!scenes.length) { S("watch a video first."); return; } 2123 if (!chGate()) return; 2124 S("packing thumbnailsâ¦"); 2125 2126 /* convert base64 JPEG data URL to Uint8Array */ 2127 function b64toArr(b64) { 2128 var bin = atob(b64.split(",")[1]); 2129 var arr = new Uint8Array(bin.length); 2130 for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); 2131 return arr; 2132 } 2133 2134 /* minimal STORE-mode ZIP: local file headers + central directory + EOCD */ 2135 var entries = scenes.map(function(s, i) { 2136 return {name: "thumb_" + String(i+1).padStart(3,"0") + "_" + fmt(s.t).replace(":","m") + "s.jpg", 2137 data: b64toArr(s.jpg)}; 2138 }); 2139 2140 function crc32(buf) { 2141 var c = 0xFFFFFFFF; 2142 var t = new Uint32Array(256); 2143 for (var n = 0; n < 256; n++) { var v = n; for (var k = 0; k < 8; k++) v = (v & 1) ? (0xEDB88320 ^ (v >>> 1)) : (v >>> 1); t[n] = v; } 2144 for (var i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); 2145 return (c ^ 0xFFFFFFFF) >>> 0; 2146 } 2147 2148 function le16(v) { return [v & 0xFF, (v >> 8) & 0xFF]; } 2149 function le32(v) { return [v & 0xFF, (v>>8)&0xFF, (v>>16)&0xFF, (v>>24)&0xFF]; } 2150 2151 var parts = [], offsets = [], total = 0; 2152 var enc = new TextEncoder(); 2153 entries.forEach(function(e) { 2154 offsets.push(total); 2155 var nm = enc.encode(e.name); 2156 var crc = crc32(e.data); 2157 var hdr = [0x50,0x4B,0x03,0x04, 0x14,0x00, 0x00,0x00, 0x00,0x00, 2158 0x00,0x00,0x00,0x00 /* mod time/date */].concat( 2159 le32(crc), le32(e.data.length), le32(e.data.length), 2160 le16(nm.length), le16(0)); 2161 var h = new Uint8Array(hdr.concat(Array.from(nm))); 2162 parts.push(h); total += h.length; 2163 parts.push(e.data); total += e.data.length; 2164 }); 2165 2166 var cdStart = total; 2167 entries.forEach(function(e, i) { 2168 var nm = enc.encode(e.name); 2169 var crc = crc32(e.data); 2170 var cd = [0x50,0x4B,0x01,0x02, 0x14,0x00,0x14,0x00, 0x00,0x00, 0x00,0x00, 2171 0x00,0x00,0x00,0x00].concat( 2172 le32(crc), le32(e.data.length), le32(e.data.length), 2173 le16(nm.length), le16(0),le16(0),le16(0),le16(0), 2174 le32(0),le32(offsets[i])).concat(Array.from(nm)); 2175 var b = new Uint8Array(cd); 2176 parts.push(b); total += b.length; 2177 }); 2178 var cdSize = total - cdStart; 2179 var eocd = [0x50,0x4B,0x05,0x06, 0x00,0x00,0x00,0x00].concat( 2180 le16(entries.length), le16(entries.length), 2181 le32(cdSize), le32(cdStart), le16(0)); 2182 var eocdArr = new Uint8Array(eocd); 2183 parts.push(eocdArr); total += eocdArr.length; 2184 2185 var out = new Uint8Array(total), pos = 0;
2186 parts.forEach(function(p){ out.set(p, pos); pos += p.length; }); 2187 2188 var thumbZipUrl = URL.createObjectURL(new Blob([out], {type:"application/zip"})); 2189 var a = document.createElement("a"); 2190 a.href = thumbZipUrl; 2191 a.download = vname + "_thumbs.zip"; a.click(); 2192 chCommitTrial("thumbnail_zip"); 2193 S(scenes.length + " thumbnails zipped and downloaded."); 2194 showBinderOutputMoment(vname + "_thumbs.zip", thumbZipUrl); 2195}; 2196 2197/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2198 CREATOR BENCH â clip export (with optional subtitle burn) 2199 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2200 2201 STRATEGY: 2202 Primary path: WebCodecs VideoEncoder + mp4-muxer (Chrome 94+, Edge 94+, Firefox 130+). 2203 - Decode each frame with VideoDecoder by seeking the <video> element 2204 - Draw onto canvas (optionally with caption text burned in) 2205 - Encode with VideoEncoder (H.264 avc1 / VP9) 2206 - Mux with Mp4Muxer â ArrayBuffer â download 2207 - Speed: near-realtime encode, MUCH faster than playback 2208 Fallback path: MediaRecorder + canvas.captureStream() 2209 - Draw each frame onto canvas (with captions) at the video's own frame rate 2210 - Record the canvas stream via MediaRecorder â WebM chunks â download 2211 - Honest limit: must play the clip at realtime (1Ã speed) to capture 2212 - Format: WebM (VP8/VP9 â universally playable; can rename to .mp4 though codec differs) 2213 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2214 2215var hasWebCodecs = (typeof VideoEncoder !== "undefined" && typeof VideoDecoder !== "undefined"); 2216 2217function getClipRange() { 2218 var a = +$("clipA").value, b = +$("clipB").value; 2219 if (b <= a) b = Math.min(a + 5, vidDur); 2220 return {a: a, b: b}; 2221} 2222 2223/* ââ real fix, 2026-07-20: chunk long cues into short, readable captions ââ 2224 Found via live testing right after building the style panel: the 2225 server-side transcription (watch_video.py) sometimes returns very few, 2226 very LONG segments (one real run produced a single cue spanning the 2227 entire clip). Burning that raw would put the whole transcript as one 2228 wall of text on every frame â not a real caption. Real creator caption 2229 tools pace text in short groups (a handful of words at a time), 2230 timed across the segment's own span. This chunks each cue's words into 2231 ~6-word groups and spreads them evenly across that cue's real time 2232 window (its own start to the next cue's start, or the video's end for 2233 the last cue) â same style panel applies to every chunk. */ 2234/* D011, measured 2026-08-20: cue-local chunking cannot produce readable cards. 2235 The deduped cue stream is min 1 / median 7 / max 12 words, and 29% of cues are 2236 SHORTER than one 6-word card, so 63% of cards ended on a dangling word ("like 2237 getting ranked like a", "from like her doctor's"). Three boundary rules plateaued 2238 at 52%. The fix is to re-flow the whole stream into phrase-length cards and re-time 2239 them, not to keep re-cutting inside a 7-word cue. Falls back to the old per-cue path 2240 if re-flow yields nothing. */ 2241/* MEASURED, not guessed. engine/tools/derive_dangler_list.js computes, over the real 2242 caption corpus, the probability that each word is followed by more words before a 2243 sentence end; a word seen >=25 times that continues >=97% of the time is a dangler. 2244 The hand-written list this replaces MISSED 35 real danglers â including "then", the 2245 exact word that ended a card in shipped output â and wrongly INCLUDED three that are 2246 not danglers in real speech (who 89%, it 84%, you 90%). Both directions were wrong, 2247 which is the argument for deriving it. Re-run the deriver on a new corpus to update. 2248 "laughter" is a deliberate catch: it comes from "[laughter]" and a card must never
2249 end on a stage direction. */ 2250var CAP_DANGLE = {"and":1,"the":1,"a":1,"it's":1,"we":1,"my":1,"just":1,"i'm":1, 2251 "but":1,"or":1,"our":1,"then":1,"kind":1,"when":1,"he":1,"if":1,"where":1, 2252 "laughter":1,"they":1,"that's":1,"you're":1,"feel":1,"your":1,"got":1,"we're":1, 2253 "as":1,"also":1,"because":1,"had":1,"were":1,"being":1,"there's":1,"always":1, 2254 "he's":1,"i'd":1,"an":1,"see":1,"every":1,"make":1,"some":1,"need":1,"i've":1, 2255 "even":1,"never":1,"put":1,"different":1,"little":1,"like":1,"i":1,"for":1, 2256 "to":1,"of":1,"cuz":1,"going":1,"don't":1,"was":1,"be":1,"think":1,"so":1, 2257 "are":1,"in":1,"would":1,"is":1,"get":1, 2258 /* kept from the hand list where the corpus agreed or had too few samples */ 2259 "at":1,"with":1,"from":1,"by":1,"been":1,"that":1,"this":1,"her":1,"his":1, 2260 "their":1,"its":1,"how":1,"what":1,"very":1,"really":1,"getting":1,"gonna":1, 2261 "wanna":1,"she":1,"on":1}; 2262 2263function reflowCuesForBurn(sourceCues, boundEnd, groupSize) { 2264 /* 1 · one word stream, each word carrying an interpolated time */ 2265 var stream = []; 2266 for (var i = 0; i < sourceCues.length; i++) { 2267 var cue = sourceCues[i]; 2268 var end = (i + 1 < sourceCues.length) ? sourceCues[i + 1].t : boundEnd; 2269 var ws = (cue.text || "").split(/\s+/).filter(Boolean); 2270 if (!ws.length) continue; 2271 var span = Math.max(end - cue.t, 0.25); 2272 for (var w = 0; w < ws.length; w++) { 2273 stream.push({t: cue.t + (span * w / ws.length), word: ws[w]}); 2274 } 2275 } 2276 if (stream.length < groupSize) return null; 2277 2278 /* 2 · break into cards on sentence/clause ends, never on a dangling word */ 2279 var out = []; 2280 var minWords = Math.max(3, Math.floor(groupSize / 2)); 2281 /* D011-tail-2, measured: the old `hardCap` broke the card the instant it hit 2282 groupSize+4 WITHOUT testing the tail, which defeated the dangling-tail guard â 2283 traced on real text as `"...And then they're going to be like oh" via HARDCAP(10)`. 2284 So the cap is now SOFT: past groupSize we keep looking for a clean tail for up to 2285 `slack` more words, and only a true ceiling forces the break. A card still cannot 2286 grow unbounded. */ 2287 var softCap = groupSize + 4; 2288 var hardCeil = groupSize + 7; 2289 var cur = []; 2290 for (var s = 0; s < stream.length; s++) { 2291 cur.push(stream[s]); 2292 var wd = stream[s].word; 2293 var bare = wd.toLowerCase().replace(/[^a-z']/g, ""); 2294 var endsSentence = /[.!?\u2026]["')\]]?$/.test(wd); 2295 var endsClause = /[,;:]$/.test(wd); 2296 /* A contraction ending in 're/'m/'ve/'ll/'d is an auxiliary awaiting its 2297 complement, so it is a dangler BY CONSTRUCTION. Deriving this beats listing it: 2298 "they're" was missing from the hand-transcribed list while "you're" was present, 2299 purely because of a frequency threshold. */ 2300 var isAux = /'(re|m|ve|ll|d)$/.test(bare); 2301 var tailOk = !CAP_DANGLE[bare] && !isAux; 2302 var enough = cur.length >= minWords; 2303 if (cur.length >= hardCeil || 2304 (endsSentence && enough) || 2305 (endsClause && enough && tailOk) || 2306 (cur.length >= groupSize && tailOk) || 2307 (cur.length >= softCap && tailOk)) { 2308 out.push({t: cur[0].t, 2309 text: cur.map(function (x) { return x.word; }).join(" ")}); 2310 cur = []; 2311 } 2312 } 2313 if (cur.length) { 2314 if (out.length && cur.length < minWords) { 2315 out[out.length - 1].text += " " + 2316 cur.map(function (x) { return x.word; }).join(" "); 2317 } else { 2318 out.push({t: cur[0].t, 2319 text: cur.map(function (x) { return x.word; }).join(" ")}); 2320 } 2321 } 2322 return out.length ? out : null; 2323} 2324 2325function chunkCuesForBurn(sourceCues, boundEnd) { 2326 var out = []; 2327 var pack = CAP_STYLE_PACKS[activeCapPackName()] || {}; 2328 /* D017d, measured 2026-08-20: karaoke used 3-word cards, which produced 2329 "a tour amazing" / "of a mile." on a source whose sentences were already correct 2330 (179 cues, 1 stop per 15.2 words). A 3-word window cannot hold a clause, and it 2331 also starves the D011 dangling-tail rules â minWords collapses to 3 and softCap to 2332 7, so the guard has no room to find a clean break. 7 words is ~3s on screen at 2333 Shorts pace and can carry a clause. */ 2334 var groupSize = pack.karaoke ? 7 : 7; 2335 var reflowed = reflowCuesForBurn(sourceCues, boundEnd, groupSize); 2336 if (reflowed) return reflowed; 2337 for (var i = 0; i < sourceCues.length; i++) { 2338 var cue = sourceCues[i]; 2339 var end = (i + 1 < sourceCues.length) ? sourceCues[i + 1].t : boundEnd;
2340 var words = (cue.text || "").split(/\s+/).filter(Boolean); 2341 if (!words.length) continue; 2342 /* D008, measured 2026-08-20: chunking by WORD COUNT alone put a group boundary 2343 one word after a full stop, so a burned caption read "business. Like" then 2344 "he" â judged "a technical error that would definitely draw complaints". 2345 A count is not a sentence. So break on sentence ends first, pack words up to 2346 groupSize inside each sentence, and never leave a 1-2 word orphan standing 2347 alone. A cue with no sentence punctuation behaves exactly as before. */ 2348 /* D008b, measured 2026-08-20: the first version split on EVERY sentence end and 2349 still produced fragments ("from like her doctor's"). Measuring the real caption 2350 file said why â 65,906 words carried 22,539 sentence-enders, one per two words. 2351 Auto-captions sprinkle punctuation as an artefact, so punctuation is a HINT, not 2352 a boundary. A break is therefore only taken once a card has enough words to 2353 stand alone, and any short tail folds back. Reliable punctuation still shapes 2354 cards; noisy punctuation can no longer make a fragment. */ 2355 var groups = []; 2356 var minWords = Math.max(3, Math.floor(groupSize / 2)); 2357 var cur = []; 2358 for (var wi = 0; wi < words.length; wi++) { 2359 cur.push(words[wi]); 2360 var endsSentence = /[.!?\u2026]["')\]]?$/.test(words[wi]); 2361 var longEnough = cur.length >= minWords; 2362 var full = cur.length >= groupSize; 2363 if (full || (endsSentence && longEnough)) { 2364 groups.push(cur.join(" ")); 2365 cur = []; 2366 } 2367 } 2368 if (cur.length) { 2369 if (groups.length && cur.length < minWords) { 2370 groups[groups.length - 1] += " " + cur.join(" "); 2371 } else { 2372 groups.push(cur.join(" ")); 2373 } 2374 } 2375 var span = Math.max(end - cue.t, 0.5); 2376 var per = span / groups.length; 2377 groups.forEach(function (g, gi) { out.push({t: cue.t + gi * per, text: g}); }); 2378 } 2379 return out; 2380} 2381var _chunkedCues = null, _chunkedCuesSrc = null; 2382function getChunkedCues() { 2383 if (_chunkedCuesSrc !== cues) { 2384 _chunkedCues = chunkCuesForBurn(cues, vidDur || 9999); 2385 _chunkedCuesSrc = cues; 2386 } 2387 return _chunkedCues; 2388} 2389 2390/* Caption density is editorial, not only paint. 2391 Full = every transcript chunk. 2392 Selective = important chunks + alternating support chunks (breathing room). 2393 Minimal = only strongly hook/stakes/reaction/payoff-shaped chunks. */ 2394var CAP_KEY = /\b(never|secret|wait|lost|million|billion|actually|nobody|everyone|mistake|truth|insane|crazy|wrong|risk|deadline|days?|hours?|money|free|proof|finally|result|follow|watch|why|how|what|can't|won't|we did|we made|it worked|turns out)\b/i; 2395var CAP_MIN = /\b(million|billion|money|proof|wrong|risk|deadline|finally|result|follow|watch|why|how|what|never|secret|we did|we made|it worked|turns out)\b/i; 2396function captionDensityAllows(text, idx) { 2397 var density = $("capDensity") ? $("capDensity").value : "selective"; 2398 if (density === "full") return true; 2399 var important = CAP_KEY.test(text) || /\d/.test(text) || /[?!]/.test(text); 2400 if (density === "minimal") return important && (CAP_MIN.test(text) || /\d/.test(text) || /[?!]/.test(text)); 2401 return important || (idx % 2 === 0); 2402} 2403 2404/* resolve which caption text to show at time t within the clip */ 2405function captionAt(t, clipStart, customLines) { 2406 if (customLines && customLines.length) { 2407 /* spread custom lines evenly across clip duration */ 2408 var range = getClipRange(); 2409 var clipLen = range.b - range.a; 2410 var idx = Math.min(Math.floor((t - clipStart) / clipLen * customLines.length), customLines.length - 1); 2411 return customLines[idx] || ""; 2412 } 2413 /* use loaded transcript cues, chunked into short readable pieces */ 2414 var chunked = getChunkedCues(); 2415 var match = null, matchIdx = -1; 2416 for (var i = 0; i < chunked.length; i++) { 2417 if (chunked[i].t <= t) { match = chunked[i]; matchIdx = i; } 2418 if (chunked[i].t > t) break; 2419 } 2420 if (!match) return ""; 2421 return captionDensityAllows(match.text, matchIdx) ? match.text : ""; 2422} 2423 2424/* ââ real caption styling, 2026-07-20 ââ 2425 Was hardcoded (white/black-outline/bottom-center/Arial, zero user control) â 2426 the exact gap Reaper called out after watching two creator-editing videos: 2427 real tools let you pick font, size, color, outline, position, and a 2428 background bar behind the text (the common Reels/TikTok caption look). 2429 Applies identically whether the text came from a real transcript (cues) 2430 or typed custom lines â style is independent of caption source. */ 2431function getCapStyle() { 2432 var pack = CAP_STYLE_PACKS[typeof activeCapPackName === "function" ? activeCapPackName() : "shorts"] || {}; 2433 return { 2434 font: $("capFont") ? $("capFont").value : "Arial,sans-serif", 2435 sizePct: $("capSize") ? +$("capSize").value : 100, 2436 color: $("capColor") ? $("capColor").value : "#ffffff", 2437 outline: $("capOutline") ? $("capOutline").value : "#000000", 2438 pos: $("capPos") ? $("capPos").value : "bottom", 2439 bg: $("capBg") ? $("capBg").checked : false, 2440 emph: $("capEmph") ? $("capEmph").checked : true, 2441 emphColor: pack.claimColor || "#FFE600", 2442 claimColor: pack.claimColor || "#FFE600", 2443 hookColor: pack.hookColor || "#00F0FF" 2444 }; 2445} 2446 2447/* Named style packs â paint-time only; no re-watch (creator re-cut free). */ 2448var CAP_STYLE_PACKS = { 2449 clean: { font: "Arial,sans-serif", size: "100", color: "#ffffff", outline: "#000000", pos: "bottom", bg: true, emph: false, karaoke: false }, 2450 punch: { font: "Impact,'Arial Black',sans-serif", size: "120", color: "#ffffff", outline: "#000000", pos: "bottom", bg: true, emph: true, karaoke: true }, 2451 statement: { font: "Georgia,serif", size: "125", color: "#ffffff", outline: "#111111", pos: "middle", bg: false, emph: true, karaoke: false }, 2452 hormozi: { font: "Impact,'Arial Black',sans-serif", size: "135", color: "#FFE600", outline: "#000000", pos: "middle", bg: true, emph: true, karaoke: true }, 2453 /* Market default 2026 short-form: karaoke chunks + yellow claim words, lower-mid, high contrast */ 2454 shorts: { font: "Impact,'Arial Black',sans-serif", size: "128", color: "#ffffff", outline: "#000000", pos: "bottom", bg: true, emph: true, karaoke: true, claimColor: "#FFE600", hookColor: "#00F0FF" } 2455}; 2456function applyCapStylePack(name) { 2457 var p = CAP_STYLE_PACKS[name]; 2458 if (!p) return; 2459 window.__capPack = name; 2460 try { 2461 document.querySelectorAll("#stylePacks button").forEach(function (b) { 2462 b.classList.toggle("active", b.getAttribute("data-pack") === name); 2463 b.setAttribute("aria-pressed", b.getAttribute("data-pack") === name ? "true" : "false"); 2464 }); 2465 } catch (e) {} 2466 if ($("capFont")) $("capFont").value = p.font; 2467 if ($("capSize")) { $("capSize").value = p.size; if ($("capSize_lbl")) $("capSize_lbl").textContent = p.size + "%"; } 2468 if ($("capColor")) $("capColor").value = p.color; 2469 if ($("capOutline")) $("capOutline").value = p.outline; 2470 if ($("capPos")) $("capPos").value = p.pos; 2471 if ($("capBg")) $("capBg").checked = !!p.bg; 2472 if ($("capEmph")) $("capEmph").checked = !!p.emph; 2473 ["capFont", "capSize", "capColor", "capOutline", "capPos", "capBg", "capEmph"].forEach(function (id) { 2474 var el = $(id); if (el) el.dispatchEvent(new Event(id === "capBg" || id === "capEmph" ? "change" : "input")); 2475 }); 2476 if (typeof renderCapPreview === "function") renderCapPreview(); 2477} 2478 2479var EMPH_WORD = /^(never|secret|wait|lost|million|billion|actually|nobody|everyone|mistake|truth|insane|crazy|money|free|hack|tip|proof|always|finally|don't|won't|can't|need|real|credits|opus|tiktok|
2479shorts|reels|clip|clips|paid|views|hook|stop|listen|wrong|right|here|why|how|what|now|today|watch|post)$/i; 2480/* Per-moment caption tags for Shorts/TikTok look â not one flat style for every word. */ 2481var CAP_TAG_RULES = { 2482 hook: { color: "#00F0FF", sizeMul: 1.12, pos: "middle" }, 2483 claim: { color: "#FFE600", sizeMul: 1.08, pos: "bottom" }, 2484 number:{ color: "#FFE600", sizeMul: 1.10, pos: "bottom" }, 2485 body: { color: "#ffffff", sizeMul: 1.00, pos: "bottom" } 2486}; 2487function tagWord(word, opts) { 2488 opts = opts || {}; 2489 var w = String(word || "").replace(/[^a-zA-Z0-9'%]/g, ""); 2490 if (!w) return "body"; 2491 if (opts.inHook) return "hook"; 2492 if (/\d/.test(w) || /%|k|m/i.test(w)) return "number"; 2493 if (EMPH_WORD.test(w)) return "claim"; 2494 return "body"; 2495} 2496function activeCapPackName() { 2497 var el = document.querySelector("#stylePacks button.active, #stylePacks button[aria-pressed='true']"); 2498 if (el && el.getAttribute("data-pack")) return el.getAttribute("data-pack"); 2499 if (window.__capPack) return window.__capPack; 2500 return "shorts"; 2501} 2502 2503 2504function burnCaption(ctx, text, cw, ch, style) { 2505 if (!text) return; 2506 style = style || getCapStyle(); 2507 var pack = CAP_STYLE_PACKS[activeCapPackName()] || {}; 2508 var claimColor = pack.claimColor || style.emphColor || "#FFE600"; 2509 var hookColor = pack.hookColor || "#00F0FF"; 2510 ctx.save(); 2511 var baseSize = Math.round(ch * 0.048 * (style.sizePct / 100)); 2512 var maxW = cw * 0.88; 2513 ctx.font = "bold " + baseSize + "px " + style.font; 2514 ctx.textAlign = "center"; 2515 var words = String(text).split(/\s+/).filter(Boolean); 2516 /* Karaoke default: 2â4 word chunks for Shorts readability */ 2517 var groupN = pack.karaoke ? 3 : 6; 2518 if (words.length > groupN * 2 && pack.karaoke) { 2519 /* already chunked upstream; keep as one block of short words */ 2520 } 2521 var lines = []; 2522 var line = ""; 2523 for (var wi = 0; wi < words.length; wi++) { 2524 var trial = line ? line + " " + words[wi] : words[wi]; 2525 if (ctx.measureText(trial).width > maxW && line) { lines.push(line); line = words[wi]; } 2526 else line = trial; 2527 } 2528 if (line) lines.push(line); 2529 if (!lines.length) { ctx.restore(); return; } 2530 var lineH = baseSize * 1.28; 2531 var blockH = lines.length * lineH; 2532 var y0; 2533 if (style.pos === "top") { ctx.textBaseline = "top"; y0 = Math.round(ch * 0.06); } 2534 else if (style.pos === "middle") { ctx.textBaseline = "middle"; y0 = Math.round(ch * 0.52 - blockH / 2 + lineH / 2); } 2535 else { ctx.textBaseline = "bottom"; y0 = ch - Math.round(ch * 0.10) - (lines.length - 1) * lineH; } 2536 if (style.bg) { 2537 /* D021c · THE SCRIM WAS "A TRANSPARENT BLACK BOX FLOATING AROUND THE CLIP" 2538 (Reaper, 2026-08-20, from WATCHING the shipped clips â a thing no metadata check 2539 could see, see aleph-watch-the-thing-you-shipped). 2540 2541 CAUSE: boxW was sized to the WIDEST LINE OF THE CURRENT CARD and re-centred every 2542 time. With 7-word karaoke cards changing every ~2 s, the bar grew, shrank and shifted 2543 on every card â so it read as a box drifting around rather than a background behind 2544 text. The guard at "if (!lines.length) return" was already correct; the defect was 2545 INSTABILITY, not spurious drawing. 2546 2547 FIX: one stable width for the whole clip (the wrap width plus padding, capped to the 2548 frame) and a stable height for the tallest expected block. A background must be 2549 furniture, not motion. */ 2550 var padX = baseSize * 0.55, padY = baseSize * 0.38; 2551 var boxW = Math.min(maxW + padX * 2, cw * 0.94); 2552 var boxH = blockH + padY * 2; 2553 var boxY = style.pos === "top" ? y0 - padY 2554 : (style.pos === "middle" ? y0 - lineH / 2 - padY : y0 - blockH + lineH - padY); 2555 ctx.fillStyle = "rgba(0,0,0,0.62)"; 2556 ctx.fillRect(cw / 2 - boxW / 2, boxY, boxW, boxH); 2557 } 2558 ctx.lineWidth = Math.round(baseSize * 0.14) + 2; 2559 ctx.strokeStyle = style.outline; 2560 var inHook = !!(style.inHook || (style.clipRel != null && style.clipRel < 0.12)); 2561 for (var lj = 0; lj < lines.length; lj++) { 2562 var yy = y0 + lj * lineH; 2563 var wds = lines[lj].split(/\s+/); 2564 if (!style.emph && !pack.karaoke) { 2565 ctx.fillStyle = style.color; 2566 ctx.strokeText(lines[lj], cw / 2, yy); 2567 ctx.fillText(lines[lj], cw / 2, yy); 2568 continue; 2569 } 2570 var totalW = 0, parts = []; 2571 for (var wi2 = 0; wi2 < wds.length; wi2++) { 2572 var ww = ctx.measureText(wds[wi2] + (wi2 < wds.length - 1 ? " " : "")).width; 2573 var tag = tagWord(wds[wi2], { inHook: inHook }); 2574 var col = style.color; 2575 if (tag === "hook") col = hookColor; 2576 else if (tag === "claim" || tag === "number") col = claimColor; 2577 parts.push({ t: wds[wi2], w: ww, col: col }); 2578 totalW += ww; 2579 } 2580 var x = cw / 2 - totalW / 2; 2581 ctx.textAlign = "left";
2582 for (var pi = 0; pi < parts.length; pi++) { 2583 ctx.fillStyle = parts[pi].col; 2584 ctx.strokeText(parts[pi].t, x, yy); 2585 ctx.fillText(parts[pi].t, x, yy); 2586 x += parts[pi].w; 2587 } 2588 ctx.textAlign = "center"; 2589 } 2590 ctx.restore(); 2591} 2592 2593/* live style preview â updates as soon as any control changes, no video needed */ 2594function renderCapPreview() { 2595 var cv = $("capPreview"); 2596 if (!cv) return; 2597 var ctx = cv.getContext("2d"); 2598 ctx.fillStyle = "#222"; ctx.fillRect(0, 0, cv.width, cv.height); 2599 burnCaption(ctx, "Sample caption text", cv.width, cv.height, getCapStyle()); 2600} 2601["capFont", "capSize", "capColor", "capOutline", "capPos", "capBg", "capEmph"].forEach(function (id) { 2602 var el = $(id); 2603 if (!el) return; 2604 el.addEventListener("input", function () { 2605 if (id === "capSize") $("capSize_lbl").textContent = $("capSize").value + "%"; 2606 renderCapPreview(); 2607 }); 2608 el.addEventListener("change", function () { renderCapPreview(); }); 2609}); 2610document.querySelectorAll("#stylePacks button").forEach(function (b) { 2611 b.addEventListener("click", function () { 2612 applyCapStylePack(b.getAttribute("data-pack")); 2613 }); 2614}); 2615renderCapPreview(); 2616 2617 2618/* âââ VERTICAL (9:16) CLIP EXPORT â added 2026-07-28 âââ 2619 Clip export copied the SOURCE aspect ratio (cburn.width = v.videoWidth), so it could 2620 only ever produce 16:9. Clipping is a phone-shaped medium: TikTok, Reels and Shorts are 2621 9:16, and a 16:9 clip posted there gets letterboxed into a third of the screen. 2622 The market research (MARKET_2026-07-28.md) put this as the single highest-value gap on 2623 the site â brands pay $1-$6 CPM for short-form and there are more campaigns than 2624 clippers to fill them, and the incumbent charges per MINUTE OF INPUT while this runs on 2625 the clipper's own machine for $2.22 a day. 2626 Center-crop rather than letterbox: a clipper wants the frame filled, and the subject in 2627 a screen recording or a talking head is almost always centred. Letterboxing would be 2628 "supported" without being usable, which is worse than not shipping it. */ 2629function vpAspect() { 2630 var el = document.getElementById("aspectsel"); 2631 return el ? el.value : "source"; 2632} 2633function vpTargetSize(v) { 2634 var a = vpAspect(); 2635 if (a === "9:16") return { w: 1080, h: 1920 }; 2636 if (a === "1:1") return { w: 1080, h: 1080 }; 2637 return { w: v.videoWidth || 1280, h: v.videoHeight || 720 }; 2638} 2639/* Two ways to reshape, because the right one depends on what was filmed â verified by 2640 looking at the output rather than assuming: a center-crop of a 16:9 SCREEN RECORDING 2641 cuts both sides off and takes the text with them ("JANE RAMIREZ" became "RAMIREZ"). 2642 For a talking head, crop is right and fills the frame. For a screen recording, fit is 2643 right and the bars are the price of keeping the words. Shipping only crop would have 2644 been "supported" without being usable for our own content. */ 2645/* SUBJECT-AWARE FRAMING, 2026-08-20. Measured defect, not a preference: 2646 centring is right for a screen recording and WRONG for a two-person podcast, which 2647 is what paid clipping campaigns actually hand you. A real Binder export from a paid 2648 podcast source was judged "subject pushed significantly to the right... large empty 2649 block of negative space on the left... the cut-off legs in the bottom right corner 2650 looks like a mistake in the cut". That is churn language from a paying clipper. 2651 2652 ClipLabFraming.xBias/yBias are 0..1 where 0.5 reproduces the old centre exactly. 2653 Nothing that fails to set them changes behaviour, so this can only ever improve 2654 the frame or leave it as it was. */ 2655window.ClipLabFraming = window.ClipLabFraming || { 2656 xBias: 0.5, yBias: 0.5, auto: true, measured: false, note: "", 2657 /* D012, measured 2026-08-20: a clip that PASSED freeze QC was judged unshippable by a 2658 human reviewer because the 9:16 crop kept part of the source's own lower-third â 2659 "a partially visible graphic that reads ', Oliver Manilow - Si' ... distracting and 2660 unprofessional for a paid clip". Podcasts burn name straps, tickers and logos into 2661 the picture, and QC cannot see them because it checks streams and duration, not what 2662 the pixels SAY. So trim the bands where source graphics live before scaling. 2663 trimBottom/trimTop are fractions of the SOURCE height; 0/0 reproduces the old 2664 behaviour exactly. */ 2665 trimBottom: 0.12, trimTop: 0.05 2666}; 2667
2668/* Cheap in-page subject finder: sample a few frames, diff them, and find which 2669 vertical band actually MOVES. The moving thing in a talking-head or podcast shot is 2670 the person; a wall does not move. No model, no download, runs in the page. */ 2671function clipLabMeasureSubject(v) { 2672 try { 2673 var F = window.ClipLabFraming; 2674 if (F.measured || !F.auto) return; 2675 var sw = v.videoWidth, sh = v.videoHeight; 2676 if (!sw || !sh) return; 2677 var cols = 7, rows = 4; 2678 var c = document.createElement("canvas"); 2679 c.width = 160; c.height = Math.max(2, Math.round(160 * sh / sw)); 2680 var cx2 = c.getContext("2d", { willReadFrequently: true }); 2681 var prev = null, colE = new Array(cols).fill(0), rowE = new Array(rows).fill(0); 2682 var t0 = v.currentTime, taken = 0; 2683 /* Use whatever is already decoded: draw the current frame a few times across a 2684 short span. Export already has the video positioned, so this is nearly free. */ 2685 for (var s = 0; s < 5; s++) { 2686 cx2.drawImage(v, 0, 0, c.width, c.height); 2687 var d = cx2.getImageData(0, 0, c.width, c.height).data; 2688 if (prev) { 2689 for (var y = 0; y < c.height; y++) { 2690 for (var x = 0; x < c.width; x++) { 2691 var i = (y * c.width + x) * 4; 2692 var diff = Math.abs(d[i] - prev[i]) + Math.abs(d[i+1] - prev[i+1]) + 2693 Math.abs(d[i+2] - prev[i+2]); 2694 if (diff > 24) { 2695 colE[Math.min(cols - 1, Math.floor(x / c.width * cols))] += 1; 2696 rowE[Math.min(rows - 1, Math.floor(y / c.height * rows))] += 1; 2697 } 2698 } 2699 } 2700 taken++; 2701 } 2702 prev = d.slice(0); 2703 } 2704 if (!taken) return; 2705 var total = colE.reduce(function(a, b) { return a + b; }, 0); 2706 if (total < 40) { F.note = "no motion measured, kept centre"; F.measured = true; return; } 2707 /* energy-weighted centroid, not just the peak band: steadier on real footage */ 2708 var wsum = 0, acc = 0; 2709 for (var k = 0; k < cols; k++) { acc += colE[k] * (k + 0.5) / cols; wsum += colE[k]; } 2710 var xb = wsum ? acc / wsum : 0.5; 2711 var rsum = 0, racc = 0; 2712 for (var r = 0; r < rows; r++) { racc += rowE[r] * (r + 0.5) / rows; rsum += rowE[r]; } 2713 var yb = rsum ? racc / rsum : 0.5; 2714 /* Talking heads read best with the face above middle: pull the window up a little. 2715 Clamp so a bad measurement can never crop harder than the old centre did. */ 2716 yb = Math.max(0.28, Math.min(0.62, yb - 0.06)); 2717 xb = Math.max(0.2, Math.min(0.8, xb)); 2718 /* D009, measured 2026-08-20: following the motion centroid exactly slams the 2719 subject to the frame edge ("technically risky... pushed close to the edge"). 2720 Blend toward centre so the crop ALWAYS moves toward the subject but never 2721 all the way â this keeps gaze room instead of deleting it. */ 2722 xb = 0.5 + 0.62 * (xb - 0.5); 2723 yb = 0.5 + 0.75 * (yb - 0.5); 2724 F.xBias = xb; F.yBias = yb; F.measured = true; 2725 F.note = "subject measured xBias=" + xb.toFixed(3) + " yBias=" + yb.toFixed(3); 2726 } catch (e) { 2727 window.ClipLabFraming.note = "measure failed: " + e; 2728 window.ClipLabFraming.measured = true; 2729 } 2730} 2731 2732function vpDrawCover(ctx, v, W, H) { 2733 var sw = v.videoWidth || W, sh = v.videoHeight || H; 2734 var mode = (document.getElementById("fitsel") || {}).value || "crop"; 2735 /* D012: exclude the source's burned-in graphic bands from what we scale up. Applied 2736 only in crop mode â in fit mode the whole frame is the point. */ 2737 var _F0 = window.ClipLabFraming || {}; 2738 var srcY = 0, srcH = sh; 2739 if (mode !== "fit") { 2740 var tTop = Math.max(0, Math.min(0.3, _F0.trimTop || 0)); 2741 var tBot = Math.max(0, Math.min(0.3, _F0.trimBottom || 0)); 2742 srcY = Math.round(sh * tTop); 2743 srcH = Math.round(sh * (1 - tTop - tBot)); 2744 if (srcH < sh * 0.4) { srcY = 0; srcH = sh; } /* never trim past sanity */ 2745 } 2746 var scale = (mode === "fit") ? Math.min(W / sw, H / srcH) 2747 : Math.max(W / sw, H / srcH); 2748 var dw = sw * scale, dh = srcH * scale; 2749 if (mode === "fit") { ctx.fillStyle = "#000"; ctx.fillRect(0, 0, W, H); } 2750 var F = window.ClipLabFraming || { xBias: 0.5, yBias: 0.5 }; 2751 if (mode !== "fit") clipLabMeasureSubject(v); 2752 var bx = (typeof F.xBias === "number") ? F.xBias : 0.5; 2753 var by = (typeof F.yBias === "number") ? F.yBias : 0.5; 2754 /* Offset only within the overflow, so the frame is always fully covered â the 2755 subject moves into view without ever exposing an edge. */ 2756 var ox = (W - dw) * bx; 2757 var oy = (H - dh) * by; 2758 if (mode === "fit") { ox = (W - dw) / 2; oy = (H - dh) / 2; } 2759 /* 9-arg drawImage: take only the untrimmed band of the SOURCE */ 2760 ctx.drawImage(v, 0, srcY, sw, srcH, ox, oy, dw, dh); 2761} 2762 2763/* ââ WebCodecs path ââ */ 2764/* ranges: [{a,b}
2764, ...] source spans laid end-to-end on the OUTPUT timeline. 2765 Single clip = one range. Bound highlight reel = many ranges (1 min of 15s cuts). */ 2766async function exportClipWebCodecs(clipA, clipB, burnSubs, customLines, clipIdx, rangesOpt) { 2767 var v = $("v"); 2768 var cburn = $("cburn"); 2769 var _t = vpTargetSize(v); 2770 cburn.width = _t.w; 2771 cburn.height = _t.h; 2772 var ctx = cburn.getContext("2d"); 2773 var style = getCapStyle(); 2774 2775 var ranges = (rangesOpt && rangesOpt.length) ? rangesOpt.slice() 2776 : [{ a: clipA, b: clipB }]; 2777 /* Drop junk */ 2778 ranges = ranges.filter(function (r) { return r && (r.b - r.a) >= 0.4; }); 2779 if (!ranges.length) throw new Error("No highlight spans to export."); 2780 2781 /* âââ THE CLIP HAD NO AUDIO TRACK AT ALL â fixed 2026-07-30 âââ 2782 This muxer was built with a `video:` config and nothing else, so every clip this 2783 tool has ever exported was silent. Measured with ffprobe on the two real exports 2784 already on disk: one stream each, h264, no audio. A clipper cutting a talking-head 2785 podcast got a picture of someone talking and no sound. Foley was the ask; this was 2786 underneath it. */ 2787 var vpWantAudio = !!(window.VPFoley && window.VPAudioBus && typeof AudioEncoder !== "undefined"); 2788 var vpRate = 0, vpCodec = null; 2789 if (vpWantAudio) { 2790 try { vpRate = window.VPAudioBus.rate($("v")); } 2791 catch (e) { vpWantAudio = false; console.warn("no audio bus:", e.message); } 2792 } 2793 /* ASK, do not assume â AAC is licensed and not in every build; Opus always is. 2794 Getting this wrong closes the encoder asynchronously and the error surfaces two 2795 calls later as "Cannot call flush on a closed codec", which names the wrong thing. */ 2796 if (vpWantAudio) { 2797 vpCodec = await window.VPFoley.pickAudioCodec(vpRate, 2); 2798 if (!vpCodec) { vpWantAudio = false; console.warn("no supported audio codec in this browser"); } 2799 } 2800 /* Adaptive frame rate / bitrate by TOTAL output length (bound reel = sum of pieces). */ 2801 var clipLenPre = 0; 2802 for (var ri0 = 0; ri0 < ranges.length; ri0++) clipLenPre += Math.max(0, ranges[ri0].b - ranges[ri0].a); 2803 clipLenPre = Math.max(0.1, clipLenPre); 2804 var vpFps = clipLenPre >= 45 ? 20 : (clipLenPre >= 20 ? 24 : 25); 2805 var vpBitrate = clipLenPre >= 45 ? 1800000 : (clipLenPre >= 20 ? 2200000 : 2500000); 2806 var vpMuxCfg = { 2807 target: new Mp4Muxer.ArrayBufferTarget(), 2808 video: {codec: "avc", width: cburn.width, height: cburn.height, frameRate: vpFps}, 2809 fastStart: "in-memory" 2810 }; 2811 if (vpWantAudio) vpMuxCfg.audio = {codec: vpCodec.mux, numberOfChannels: 2, sampleRate: vpRate}; 2812 var muxer = new Mp4Muxer.Muxer(vpMuxCfg); 2813 2814 var encoder = new VideoEncoder({ 2815 output: function(chunk, meta) { muxer.addVideoChunk(chunk, meta); }, 2816 error: function(e) { console.error("VideoEncoder:", e); } 2817 }); 2818 /* âââ CODEC PROBED, NOT ASSUMED â fixed 2026-07-28 âââ 2819 This was hardcoded to "avc1.42001f" (Baseline 3.1). On a browser that does not 2820 support that exact string, configure() fails ASYNCHRONOUSLY, the codec closes, and 2821 the very next encode() throws "Cannot call 'encode' on a closed codec" â which reads 2822 like a size or a length problem and is neither. Clip export was dead here in BOTH 2823 16:9 and 9:16, so this was never about vertical. 2824 Measured for these packs: avc1.42001f -> NOT supported at any size tested; 2825 avc1.640028 (High 4.0) -> supported, including 1080x1920. 2826 WebCodecs will tell you which strings it accepts, so ask it rather than guessing. 2827 The list runs high-to-low so quality degrades gracefully instead of failing. */ 2828 var _codecs = ["avc1.640028", "avc1.4d0028", "avc1.42E01E", "avc1.42001f"]; 2829 var _chosen = null; 2830 for (var _i = 0; _i < _codecs.length; _i++) { 2831 var _cfg = { codec: _codecs[_i], width: cburn.width, height: cburn.height, 2832 bitrate: vpBitrate, framerate: vpFps }; 2833 try { 2834 var _sup = await VideoEncoder.isConfigSupported(_cfg); 2835 if (_sup && _sup.supported) { _chosen = _cfg; break; } 2836 } catch (e) { /* try the next one */ } 2837 } 2838 if (!_chosen) { 2839 throw new Error("This browser will not encode H.264 at " + cburn.width + "x" +
2840 cburn.height + ". Chrome or Edge on desktop can; Safari and most mobile browsers cannot yet."); 2841 } 2842 encoder.configure(_chosen); 2843 2844 /* Audio first: one realtime pass PER source span, timestamps chained (bound reel). 2845 Raising playbackRate would pitch-shift speech â not done. */ 2846 var vpAudioReport = null; 2847 var audioBreaths = 0, audioSeconds = 0, audioBase = 0; 2848 if (vpWantAudio) { 2849 for (var ari = 0; ari < ranges.length; ari++) { 2850 var ra = ranges[ari]; 2851 $("clipstatus").textContent = "reading audio for highlight " + (ari + 1) + 2852 " / " + ranges.length + "â¦"; 2853 try { 2854 var oneRep = await window.VPFoley.captureClipAudio($("v"), ra.a, ra.b, muxer, 2855 function (m) { $("clipstatus").textContent = m; }, vpCodec, audioBase); 2856 if (oneRep) { 2857 audioBreaths += oneRep.breaths; 2858 audioSeconds += oneRep.seconds; 2859 audioBase += (ra.b - ra.a); 2860 vpAudioReport = oneRep; 2861 vpAudioReport.seconds = +audioSeconds.toFixed(2); 2862 vpAudioReport.breaths = audioBreaths; 2863 } 2864 } catch (e) { console.warn("clip audio failed on span " + ari + ":", e); } 2865 } 2866 if (!vpAudioReport) console.warn("clip audio produced nothing â exporting picture only"); 2867 } 2868 2869 /* Picture: seek-accurate across every span, continuous OUTPUT timeline. 2870 Bound reel = many source windows, one continuous video. No frozen tails. */ 2871 var step = 1 / vpFps; 2872 var total = Math.max(1, Math.round(clipLenPre / step)); 2873 var done = 0; 2874 var ts = 0; /* seconds of OUTPUT timeline */ 2875 2876 async function encodeSeekFrame(tSrc) { 2877 await new Promise(function (r) { 2878 var settled = false; 2879 var to = setTimeout(function () { 2880 if (!settled) { settled = true; r(); } 2881 }, 4000); 2882 v.onseeked = function () { 2883 if (settled) return; 2884 settled = true; 2885 clearTimeout(to); 2886 r(); 2887 }; 2888 try { 2889 v.currentTime = Math.min(Math.max(0, tSrc), Math.max(0, (vidDur || tSrc) - 0.05)); 2890 } catch (e) { 2891 clearTimeout(to); 2892 r(); 2893 } 2894 }); 2895 await new Promise(function (r) { 2896 if (typeof v.requestVideoFrameCallback === "function") { 2897 var doneRv = false; 2898 var to2 = setTimeout(function () { if (!doneRv) { doneRv = true; r(); } }, 120); 2899 v.requestVideoFrameCallback(function () { 2900 if (doneRv) return; 2901 doneRv = true; 2902 clearTimeout(to2); 2903 r(); 2904 }); 2905 } else { 2906 requestAnimationFrame(function () { requestAnimationFrame(r); }); 2907 } 2908 }); 2909 vpDrawCover(ctx, v, cburn.width, cburn.height); 2910 /* Captions use source time so transcript lines match the spoken moment */ 2911 if (burnSubs) { 2912 var st1 = Object.assign({}, style || getCapStyle()); 2913 var span1 = Math.max(0.01, (ranges[ranges.length-1].b - ranges[0].a)); 2914 st1.clipRel = (tSrc - ranges[0].a) / span1; 2915 st1.inHook = st1.clipRel < 0.12; 2916 burnCaption(ctx, captionAt(tSrc, ranges[0].a, customLines), cburn.width, cburn.height, st1); 2917 } 2918 var frame = new VideoFrame(cburn, { 2919 timestamp: Math.round(ts * 1e6), 2920 duration: Math.round(step * 1e6) 2921 }); 2922 encoder.encode(frame, { keyFrame: done % 50 === 0 }); 2923 frame.close(); 2924 ts += step; 2925 done++; 2926 } 2927 2928 $("clipstatus").textContent = ranges.length > 1 2929 ? "encoding bound highlights (seek-accurate)â¦" 2930 : "encoding picture (seek-accurate, full timeline)â¦"; 2931 v.muted = true; 2932 try { v.pause(); } catch (e) {} 2933 v.playbackRate = 1; 2934 for (var rsi = 0; rsi < ranges.length; rsi++) { 2935 var rr = ranges[rsi]; 2936 for (var tSrc = rr.a; tSrc < rr.b - 0.0001; tSrc += step) { 2937 await encodeSeekFrame(tSrc); 2938 if (done % 10 === 0) { 2939 $("clipprog").style.width = Math.min(99, Math.round(done / total * 100)) + "%"; 2940 $("clipstatus").textContent = "encoding frame " + done + " / " + total + 2941 (ranges.length > 1 ? " (highlight " + (rsi + 1) + "/" + ranges.length + ")" : ""); 2942 await new Promise(function (r) { setTimeout(r, 0); }); 2943 } 2944 } 2945 } 2946 if (done < total) { 2947 console.warn("encode short by " + (total - done) + " frames â filling from last span"); 2948 var lastR = ranges[ranges.length - 1]; 2949 while (done < total) { 2950 await encodeSeekFrame(Math.min(lastR.b - step, lastR.a + (done * step) % Math.max(0.1, lastR.b - lastR.a))); 2951 } 2952 } 2953 await encoder.flush(); 2954 muxer.finalize(); 2955 2956 var buf = muxer.target.buffer; 2957 var blob = new Blob([buf], {type: "video/mp4"}); 2958 var nameHint = ranges.length > 1 2959 ? ("_highlights_" + ranges.length + "x" + Math.round(ranges[0].b - ranges[0].a) + "s") 2960 : ("_clip" + (clipIdx ? clipIdx : "") + "_" + fmt(ranges[0].a).replace(":", "m") + "s"); 2961 var clipNameMp4 = vname + nameHint + ".mp4"; 2962 var clipUrlMp4 = URL.createObjectURL(blob); 2963 var a = document.createElement("a"); 2964 a.href = clipUrlMp4; 2965 a.download = clipNameMp4; 2966 a.click(); 2967 $("clipprog").style.width = "100%"; 2968 var boundNote = ranges.length > 1 2969 ? (" Bound " + ranges.length + " highlights into " + clipLenPre.toFixed(0) + "s.") 2970 : ""; 2971 $("clipstatus").textContent = "MP4 clip saved via WebCodecs" + boundNote + (vpAudioReport 2972 ? " \u2014 " + vpAudioReport.seconds + "s of audio read in " + vpAudioReport.breaths + 2973 " breaths of " + window.VPFoley.BREATH + " frames as " + vpAudioReport.codec + " (" + 2974 Math.round(vpAudioReport.workingSetBytes / 1024) + " KB flat), cut sound " + vpAudioReport.foley + "." 2975 : " (picture only \u2014 this browser would not give up the clip's audio)."); 2976 showBinderOutputMoment(clipNameMp4, clipUrlMp4); 2977} 2978 2979/* ââ MediaRecorder canvas capture fallback ââ */ 2980async function exportClipMediaRecorder(clipA, clipB, burnSubs, customLines, clipIdx) { 2981 var v = $("v"); 2982 var cburn = $("cburn"); 2983 var _t2 = vpTargetSize(v); 2984 cburn.width = _t2.w; 2985 cburn.height = _t2.h; 2986 var ctx = cburn.getContext("2d"); 2987 var style = getCapStyle(); 2988 var stream = cburn.captureStream(25); 2989 /* captureStream() on a canvas carries NO audio. That is the whole reason the fallback 2990 path produced silent clips too. This path already runs at realtime, so the sound 2991 rides along in the same pass, Foley mixed into the outgoing breath. */ 2992 var vpMrAudio = (window.VPFoley && window.VPAudioBus) 2993 ? window.VPFoley.attachRealtimeAudio($("v"), stream, clipA, clipB) : null; 2994 var chunks = [];
2995 var mime = (MediaRecorder.isTypeSupported("video/webm;codecs=vp9") ? "video/webm;codecs=vp9" : "video/webm"); 2996 var mr = new MediaRecorder(stream, {mimeType: mime, videoBitsPerSecond: 2500000}); 2997 mr.ondataavailable = function(e){ if (e.data.size > 0) chunks.push(e.data); }; 2998 mr.start(); 2999 3000 /* play the clip section at realtime into the canvas */ 3001 v.muted = false; /* the bus reads this element; muting it silences the export */ 3002 var step = 1 / 25; 3003 var total = Math.round((clipB - clipA) / step); 3004 var done = 0; 3005 for (var t = clipA; t < clipB; t += step) { 3006 await new Promise(function(r){ v.onseeked = r; v.currentTime = Math.min(t, vidDur - 0.05); }); 3007 vpDrawCover(ctx, v, cburn.width, cburn.height); 3008 if (burnSubs) { 3009 var st2 = Object.assign({}, style || getCapStyle()); 3010 st2.clipRel = (t - clipA) / Math.max(0.01, clipB - clipA); 3011 st2.inHook = st2.clipRel < 0.12; 3012 burnCaption(ctx, captionAt(t, clipA, customLines), cburn.width, cburn.height, st2); 3013 } 3014 done++; 3015 if (done % 10 === 0) { 3016 $("clipprog").style.width = Math.round(done / total * 100) + "%"; 3017 $("clipstatus").textContent = "capturing frame " + done + " / " + total + " (canvas capture at realtime)"; 3018 await new Promise(function(r){ setTimeout(r, 0); }); 3019 } 3020 /* pace to ~25fps to avoid flooding the MediaRecorder */ 3021 await new Promise(function(r){ setTimeout(r, 40); }); 3022 } 3023 3024 mr.stop(); 3025 await new Promise(function(r){ mr.onstop = r; }); 3026 if (vpMrAudio) vpMrAudio.stop(); 3027 3028 var blob = new Blob(chunks, {type: mime}); 3029 var clipNameWebm = vname + "_clip" + (clipIdx ? clipIdx : "") + "_" + fmt(clipA).replace(":","m") + "s.webm"; 3030 var clipUrlWebm = URL.createObjectURL(blob); 3031 var a = document.createElement("a"); 3032 a.href = clipUrlWebm; 3033 a.download = clipNameWebm; 3034 a.click(); 3035 $("clipprog").style.width = "100%"; 3036 $("clipstatus").textContent = "WebM clip saved via MediaRecorder (canvas capture). Playable in all browsers; rename to .mp4 if a player insists â the content is the same."; 3037 showBinderOutputMoment(clipNameWebm, clipUrlWebm); 3038} 3039 3040/* ââ shared clip launcher ââ */ 3041/* 2026-08-06 QC: primary export used to call doExportClip(false) â so the main 3042 "Export This One Clip" button NEVER burned captions even when a transcript was 3043 loaded. Batch export burned; single export did not. Clip Lab promises burned 3044 captions; the default path has to match. Always burn when text exists; warn 3045 clearly when it does not. */ 3046function vpCaptionSourceReady() { 3047 var custom = ($("customsubs") && $("customsubs").value || "").split("\n").filter(function (l) { return l.trim(); }); 3048 if (custom.length) return { ok: true, custom: custom, kind: "custom" }; 3049 if (typeof cues !== "undefined" && cues && cues.length) return { ok: true, custom: [], kind: "cues:" + cues.length }; 3050 return { ok: false, custom: [], kind: "none" }; 3051} 3052 3053async function exportOneProduct(clipA, clipB, burnSubs, customLines, clipIdx, boundSegs, aspectOverride) { 3054 var prev = $("aspectsel") ? $("aspectsel").value : "9:16"; 3055 if (aspectOverride && $("aspectsel")) $("aspectsel").value = aspectOverride; 3056 try { 3057 if (hasWebCodecs && typeof Mp4Muxer !== "undefined" && Mp4Muxer.Muxer) { 3058 await exportClipWebCodecs(clipA, clipB, burnSubs, customLines, clipIdx, boundSegs); 3059 } else if (!boundSegs) { 3060 await exportClipMediaRecorder(clipA, clipB, burnSubs, customLines, clipIdx); 3061 } else { 3062 throw new Error("Bound reels need WebCodecs (Chrome/Edge)."); 3063 } 3064 } finally { 3065 if ($("aspectsel")) $("aspectsel").value = prev; 3066 } 3067} 3068 3069async function doExportClip(burnSubs) { 3070 if (!$("v").src || !vidDur) { S("load a video first."); return false; } 3071 var range = getClipRange(); 3072 var clipA = range.a, clipB = range.b; 3073 /* Prefer bound reel on clipList[0] when that is what was just built */ 3074 var boundSegs = null; 3075 if (clipList.length === 1 && clipList[0].bound && clipList[0].segments && clipList[0].segments.length) { 3076 boundSegs = clipList[0].segments; 3077 clipA = boundSegs[0].a; 3078 clipB = boundSegs[0].b; 3079 } 3080 if (!boundSegs && clipB - clipA < 0.5) { 3081 $("clipstatus").textContent = "Set a start and end that are at least a second apart."; 3082 return false;
3083 } 3084 if (!chGate()) return false; 3085 var capSrc = vpCaptionSourceReady(); 3086 var customLines = burnSubs ? capSrc.custom : []; 3087 if (burnSubs && !capSrc.ok) { 3088 $("clipstatus").textContent = "No captions loaded â export will be picture + audio only. Drop a .srt/.vtt, generate captions, or type lines above, then export again for burned-in text."; 3089 S("Tip: generate captions or drop a .srt/.vtt so packs ship with words on screen."); 3090 } 3091 $("clipbar").style.display = "block"; 3092 $("clipprog").style.width = "0%"; 3093 $("clipstatus").textContent = "startingâ¦"; 3094 try { 3095 $("clipstatus").textContent = boundSegs 3096 ? ("Binding " + boundSegs.length + " highlights into one fileâ¦") 3097 : ("Encodingâ¦" + (burnSubs && capSrc.ok ? " (burning captions)" : "")); 3098 await exportOneProduct(clipA, clipB, burnSubs, customLines, null, boundSegs, null); 3099 /* Multi-aspect: same map, second pass â no re-watch (compute advantage) */ 3100 if ($("alsoSquare") && $("alsoSquare").checked) { 3101 $("clipstatus").textContent = "Second pass: 1:1 from the same cutsâ¦"; 3102 await exportOneProduct(clipA, clipB, burnSubs, customLines, "sq", boundSegs, "1:1"); 3103 } 3104 chCommitTrial("clip_export"); 3105 return true; 3106 } catch(err) { 3107 chReleaseTrial(); 3108 $("clipstatus").textContent = "Export failed: " + err.message + " â if WebCodecs rejected the codec, try a shorter clip or a different browser."; 3109 console.error(err); 3110 return false; 3111 } 3112} 3113 3114/* Primary export burns captions when available (same as the dedicated burn button). */ 3115$("exportclip").onclick = function(){ doExportClip(true); }; 3116$("exportsubclip").onclick = function(){ doExportClip(true); }; 3117 3118/* ââ real multi-clip batch, 2026-07-20 ââ 3119 Was hard-capped to ONE clip range at a time (2-tap select, 3rd tap reset) 3120 â the exact gap Reaper called out: creators cutting a long video into 3121 several shorts in one pass, not one clip at a time. Adds a real list: 3122 tap two thumbnails, "+ Add" pushes that range into clipList, pick more, 3123 then export the whole batch in one click â captions styled per the 3124 panel above, applied to every clip. The original single-range tap 3125 selection above is untouched; this is additive. */ 3126var clipList = []; 3127function renderClipList() { 3128 var wrap = $("clipList"); 3129 if (!wrap) return; 3130 wrap.innerHTML = ""; 3131 clipList.forEach(function (r, i) { 3132 var row = document.createElement("div"); 3133 row.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--soft);border-radius:6px;font:12px system-ui"; 3134 var label; 3135 if (r.bound && r.segments && r.segments.length) { 3136 var tot = 0; 3137 var lens = []; 3138 for (var si = 0; si < r.segments.length; si++) { 3139 var ln = r.segments[si].b - r.segments[si].a; 3140 tot += ln; 3141 lens.push(Math.round(ln) + "s"); 3142 } 3143 var storyBits = ""; 3144 if (r.segments.some(function (s) { return !!s.role; })) { 3145 storyBits = "<div style='margin-top:4px;font-size:11px;line-height:1.45;color:var(--dim)'>" + 3146 r.segments.map(function (s) { 3147 var why = s.why && s.why.length ? s.why.join(", ") : "no transcript evidence"; 3148 var mark = s.roleMatch === false ? "â " : "â "; 3149 return mark + "<b>" + (s.role || "beat") + "</b>: " + why; 3150 }).join("<br>") + "</div>"; 3151 } 3152 label = "BOUND · " + lens.join("+") + " → " + Math.round(tot) + "s one file" + 3153 (r.label ? " <span style='opacity:.7'>(" + r.label + ")</span>" : "") + storyBits; 3154 } else { 3155 label = "SEPARATE · " + fmt(r.a) + " → " + fmt(r.b) + 3156 " (" + Math.round(r.b - r.a) + "s)"; 3157 } 3158 row.innerHTML = "<span>" + label + "</span>" + 3159 "<button data-i='" + i + "' style='background:none;border:0;color:var(--acc);cursor:pointer;font-weight:700;font-size:16px;line-height:1'>×</button>"; 3160 wrap.appendChild(row); 3161 }); 3162 $("exportAllClips").style.display = clipList.length ? "block" : "none"; 3163 wrap.onclick = function (e) { 3164 var b = e.target.closest("button"); 3165 if (!b) return; 3166 clipList.splice(+b.dataset.i, 1); 3167 renderClipList(); 3168 }; 3169} 3170 3171/* ââ Cover candidates from the actual recipe âââââââââââââââââââââââââââââââ 3172 A cover is an OUTPUT of the selected story/cuts, not a separate template library. 3173 Story bound reels: hook + middle tension/reaction + payoff. 3174 Separate lists: first three real clips. One successful set = one free trial use. */ 3175function coverRecipeMoments() { 3176 var out = []; 3177 var firstBound = clipList.find(function(x){ return x && x.bound && x.segments && x.segments.length; }); 3178 if (firstBound) { 3179 var ss = firstBound.segments; 3180 var roles = firstBound.roles || []; 3181 var idxs = ss.length >= 3 ? [0, Math.floor((ss.length - 1) / 2), ss.length - 1] 3182 : ss.map(function(_,i){ return i; }); 3183 idxs.forEach(function(i) { 3184 var s = ss[i]; 3185 out.push({ t: s.a + Math.min(0.45, Math.max(0.05,(s.b-s.a)*0.12)), 3186 a:s.a,b:s.b,role:roles[i] || (i===0?"hook":(i===ss.length-1?"payoff":"middle")) }); 3187 }); 3188 } else { 3189 var real = clipList.filter(function(x){ return x && !x.kind && !x.bound && x.b>x.a; }).slice(0,3); 3190 if (real.length >= 3) { 3191 real.forEach(function(s,i){ 3192 out.push({t:s.a+Math.min(0.45,(s.b-s.a)*0.12),a:s.a,b:s.b, 3193 role:i===0?"hook":(i===2?"payoff":"middle")}); 3194 }); 3195 } else if (real.length === 2) { 3196 var s0=real[0], s1=real[1]; 3197 out.push({t:s0.a+Math.min(0.35,(s0.b-s0.a)*0.10),a:s0.a,b:s0.b,role:"hook"}); 3198 out.push({t:s1.a+Math.min(0.35,(s1.b-s1.a)*0.15),a:s1.a,b:s1.b,role:"middle"}); 3199 out.push({t:Math.max(s1.a,s1.b-Math.min(0.35,(s1.b-s1.a)*0.08)),a:s1.a,b:s1.b,role:"payoff"}); 3200 } else if (real.length === 1) { 3201 var s=real[0], span=s.b-s.a; 3202 out.push({t:s.a+Math.min(0.30,span*0.08),a:s.a,b:s.b,role:"hook"}); 3203 out.push({t:s.a+span*0.50,a:s.a,b:s.b,role:"middle"}); 3204 out.push({t:Math.max(s.a,s.b-Math.min(0.30,span*0.08)),a:s.a,b:s.b,role:"payoff"}); 3205 } 3206 } 3207 if (!out.length) { 3208 var r=getClipRange(); 3209 if (r.b-r.a>=1) { 3210 out=[ 3211 {t:r.a+0.2,a:r.a,b:r.b,role:"hook"}, 3212 {t:r.a+(r.b-r.a)*0.50,a:r.a,b:r.b,role:"middle"}, 3213 {t:Math.max(r.a,r.b-0.35),a:r.a,b:r.b,role:"payoff"} 3214 ]; 3215 } 3216 } 3217 return out.slice(0,3); 3218} 3219function coverHeadlineFor(m) { 3220 var manual = $("coverHeadline") ? $("coverHeadline").value.trim() : ""; 3221 if (manual) return manual; 3222 var txt = ""; 3223 try { txt = cueText(m.a, Math.min(m.b, m.a + 4)) || ""; } catch(e) {} 3224 txt = txt.replace(/\s+/g," ").trim(); 3225 if (!txt) return m.role === "payoff" ? "THE PAYOFF" : (m.role === "hook" ? "WATCH THIS" : "THE TURN"); 3226 var words=txt.split(/\s+/).slice(0,7); 3227 return words.join(" "); 3228} 3229function coverLines(text,maxChars) {
3230 var words=String(text||"").toUpperCase().split(/\s+/).filter(Boolean), lines=[], cur=""; 3231 for (var i=0;i<words.length;i++) { 3232 var trial=cur ? cur+" "+words[i] : words[i]; 3233 if (trial.length>(maxChars||17) && cur) { lines.push(cur); cur=words[i]; } 3234 else cur=trial; 3235 if (lines.length===2) break; 3236 } 3237 if (cur && lines.length<3) lines.push(cur); 3238 return lines.slice(0,3); 3239} 3240async function coverSeek(v,t) { 3241 await new Promise(function(resolve){ 3242 var done=false; 3243 var to=setTimeout(function(){ if(!done){done=true;resolve();}},2500); 3244 v.onseeked=function(){ if(done)return; done=true; clearTimeout(to); resolve(); }; 3245 try { v.currentTime=Math.max(0,Math.min(t,Math.max(0,(vidDur||t)-0.03))); } 3246 catch(e){clearTimeout(to);resolve();} 3247 }); 3248 if (typeof v.requestVideoFrameCallback==="function") { 3249 await new Promise(function(resolve){ 3250 var done=false,to=setTimeout(function(){if(!done){done=true;resolve();}},120); 3251 v.requestVideoFrameCallback(function(){if(done)return;done=true;clearTimeout(to);resolve();}); 3252 }); 3253 } else { 3254 await new Promise(function(r){requestAnimationFrame(function(){requestAnimationFrame(r);});}); 3255 } 3256} 3257function drawCoverTitle(ctx,W,H,text) { 3258 var lines=coverLines(text,17); 3259 var fs=Math.max(42,Math.round(W*0.078)); 3260 ctx.textAlign="center"; ctx.textBaseline="top"; ctx.font="900 "+fs+"px Arial,sans-serif"; 3261 var y=Math.round(H*0.055), pad=Math.round(fs*0.22), gap=Math.round(fs*0.20); 3262 lines.forEach(function(line){ 3263 while(ctx.measureText(line).width>W*0.82 && fs>34) { 3264 fs-=4; ctx.font="900 "+fs+"px Arial,sans-serif"; 3265 } 3266 var w=ctx.measureText(line).width, h=Math.round(fs*1.12), x=W/2; 3267 ctx.fillStyle="rgba(0,0,0,.88)"; 3268 var rx=x-w/2-pad, ry=y-pad/2, rw=w+pad*2, rh=h+pad; 3269 ctx.beginPath(); 3270 if (ctx.roundRect) ctx.roundRect(rx,ry,rw,rh,Math.max(10,pad*.6)); 3271 else ctx.rect(rx,ry,rw,rh); 3272 ctx.fill(); 3273 ctx.lineWidth=Math.max(3,Math.round(fs*.055)); ctx.strokeStyle="#000"; ctx.fillStyle="#fff"; 3274 ctx.strokeText(line,x,y); ctx.fillText(line,x,y); 3275 y += h+gap; 3276 }); 3277} 3278async function buildCoverSet() { 3279 if (!$("v").src || !vidDur) { $("coverStatus").textContent="Load and map a video first."; return; } 3280 if (!chGate()) return; 3281 var moments=coverRecipeMoments(); 3282 if (!moments.length) { chReleaseTrial(); $("coverStatus").textContent="Build a pack or choose a real clip range first."; return; } 3283 var v=$("v"), before=v.currentTime, wrap=$("coverResults"); 3284 wrap.innerHTML=""; $("coverStatus").textContent="Reading the selected story framesâ¦"; 3285 try { 3286 for (var i=0;i<moments.length;i++) { 3287 var m=moments[i]; 3288 await coverSeek(v,m.t); 3289 var c=document.createElement("canvas"), W=1080,H=1920; c.width=W;c.height=H; 3290 var ctx=c.getContext("2d"); 3291 vpDrawCover(ctx,v,W,H); 3292 var headline=coverHeadlineFor(m); 3293 drawCoverTitle(ctx,W,H,headline); 3294 var blob=await new Promise(function(resolve){c.toBlob(resolve,"image/jpeg",0.92);}); 3295 if (!blob) throw new Error("browser did not produce JPEG bytes"); 3296 var u=URL.createObjectURL(blob); 3297 var card=document.createElement("div"); 3298 card.style.cssText="background:#fff;border:1px solid var(--line);border-radius:8px;padding:6px;text-align:center"; 3299 card.innerHTML="<img alt='"+m.role+" cover option' style='width:100%;aspect-ratio:9/16;object-fit:cover;border-radius:5px;display:block' src='"+u+"'>"+ 3300 "<div style='font:700 11px system-ui;margin:5px 0'>"+m.role.toUpperCase()+"</div>"+ 3301 "<a download='"+vname.replace(/\.[^.]+$/,"")+"_"+m.role+"_cover.jpg' href='"+u+"' style='font:12px system-ui;color:var(--acc)'>Save JPEG</a>"; 3302 wrap.appendChild(card); 3303 } 3304 chCommitTrial("cover-set"); 3305 $("coverStatus").textContent="Built "+moments.length+" cover options from the actual recipe. One successful cover set used one free trial."; 3306 } catch(e) { 3307 chReleaseTrial(); 3308 $("coverStatus").textContent="Cover build failed â no free trial used: "+(e.message||e); 3309 } finally { 3310 try { v.currentTime=before; } catch(e) {} 3311 } 3312} 3313if ($("buildCovers")) $("buildCovers").onclick=buildCoverSet; 3314 3315$("addToClipList").onclick = function () { 3316 var range = getClipRange(); 3317 if (range.b - range.a < 0.5) { S("Pick a start and end at least a second apart before adding."); return; } 3318 clipList.push({a: range.a, b: range.b}); 3319 renderClipList(); 3320 S("Added clip " + clipList.length + " (" + fmt(range.a) + "â" + fmt(range.b) + ") to the list. Tap two more thumbnails to add another, or export the batch below."); 3321}; 3322/* Sections -> clip queue. Each clip runs from its own section start to the next one, 3323 with the last running to the end of the video. Sub-second slivers are skipped rather 3324 than queued: a 0.4s "clip" is not a clip, and silently queueing junk would make the 3325 batch look broken when it is the detector that was noisy. */ 3326$("clipsFromScenes").onclick = function () { 3327 var v = $("v"); 3328 if (typeof scenes === "undefined" || !scenes || !scenes.length) { 3329 S("No sections yet â drop a video and let it watch first so it can find them."); 3330 return; 3331 } 3332 var dur = v && v.duration ? v.duration : 0; 3333 var added = 0, skipped = 0; 3334 for (var i = 0; i < scenes.length; i++) { 3335 var a = scenes[i].t; 3336 var b = (i + 1 < scenes.length) ? scenes[i + 1].t : (dur || a + 30); 3337 if (b - a < 1.0) { skipped++; continue; } 3338 clipList.push({ a: a, b: b }); 3339 added++; 3340 } 3341 renderClipList(); 3342 S("Queued " + added + " clip" + (added === 1 ? "" : "s") + " from the detected sections" + 3343 (skipped ? " (" + skipped + " under a second, skipped)" : "") + 3344 ". Pick the shape above, then export the batch."); 3345}; 3346 3347/* ââ 2026-08-06: duration-aware impact packs âââââââââââââââââââââââââââââ 3348 Software-only packer on the existing section map + caption cues. 3349 score = section length fitness + keyword hits + mid/early position + not-tiny. 3350 Stitch adjacent high spans when one continuous window is too short. */ 3351(function () { 3352 var STRONG = /\b(never|secret|wait|lost|million|billion|actually|nobody|everyone|mistake|truth|insane|crazy|wait until|here's why|the real|i lost|i made|you need|don't|won't|can't|always|finally|proof|money|free|hack|tip)\b/i;
3353 var NUMISH = /\b\d+(\.\d+)?\s*(k|m|%|dollars?|years?|days?|hours?)?\b/i; 3354 3355 /* ââ Outcome recipe: MIX AND MATCH jobs from one watch âââââââââââââââââ 3356 Jobs separated by ; or newlines. Each job is separate shorts OR one bound reel. 3357 Examples: 3358 5 Ã 30s separate; 1 min of 15s bound 3359 mix 15,15,30,60 into one reel 3360 hook 15s + body 45s bound 3361 7 Ã 30s separate 3362 */ 3363 3364 function parseOneLengthToken(tok) { 3365 tok = (tok || "").trim().toLowerCase(); 3366 var mm = tok.match(/(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes)?/); 3367 if (!mm) return null; 3368 var L = parseFloat(mm[1]); 3369 var u = (mm[2] || "s").charAt(0); 3370 if (u === "m") L *= 60; 3371 if (L >= 3 && L <= 600) return L; 3372 return null; 3373 } 3374 3375 function parseLengthsList(s) { 3376 var out = []; 3377 var parts = s.split(/[,+&]|and/i); 3378 for (var j = 0; j < parts.length; j++) { 3379 var L = parseOneLengthToken(parts[j]); 3380 if (L != null) out.push(L); 3381 } 3382 return out; 3383 } 3384 3385 function parseBoundJob(s) { 3386 s = (s || "").trim().toLowerCase(); 3387 /* mix 15,15,30,60 into one reel | mix 15s + 30s + 60s bound */ 3388 var mix = s.match(/mix\s+(.+?)(?:\s+into\s+one\s+reel|\s+bound|\s+together)?\s*$/); 3389 if (mix) { 3390 var lens = parseLengthsList(mix[1]); 3391 if (lens.length >= 2) { 3392 var tot = 0; for (var i = 0; i < lens.length; i++) tot += lens[i]; 3393 return { bind: true, pieces: lens, total: tot, label: "mixed " + lens.join("+") + "s reel" }; 3394 } 3395 } 3396 /* hook 15s + body 45s bound */ 3397 var hook = s.match(/hook\s+(\d+)\s*s?\s*\+\s*body\s+(\d+)\s*s?/); 3398 if (hook) { 3399 var h = +hook[1], b = +hook[2]; 3400 return { bind: true, pieces: [h, b], total: h + b, label: "hook " + h + "s + body " + b + "s" }; 3401 } 3402 /* 15s + 15s + 30s bound */ 3403 if (/\bbound\b|\breel\b|\btogether\b/.test(s) && /[+,]/.test(s) && !/[xÃ*]/.test(s)) { 3404 var lens2 = parseLengthsList(s.replace(/\b(bound|reel|together|highlights?)\b/g, " ")); 3405 if (lens2.length >= 2) { 3406 var t2 = 0; for (var k = 0; k < lens2.length; k++) t2 += lens2[k]; 3407 return { bind: true, pieces: lens2, total: t2, label: "bound " + lens2.join("+") + "s" }; 3408 } 3409 } 3410 /* 1 min of 15s highlights */ 3411 var m = s.match(/(\d+(?:\.\d+)?)\s*(m|min|mins|minute|minutes)\s*(?:of|as)?\s*(\d+)\s*(s|sec|secs)?/); 3412 if (m) { 3413 var total = parseFloat(m[1]) * 60; 3414 var piece = +m[3]; 3415 if (piece >= 5 && piece <= 120 && total >= piece) { 3416 var n = Math.max(1, Math.round(total / piece)); 3417 var pieces = []; for (var p = 0; p < n; p++) pieces.push(piece); 3418 return { bind: true, pieces: pieces, piece: piece, count: n, total: n * piece, 3419 label: n + " Ã " + piece + "s bound (" + Math.round(n * piece) + "s reel)" }; 3420 } 3421 } 3422 /* 4 Ã 15s bound | bound 4x15 | 4x15 highlights */ 3423 m = s.match(/(\d+)\s*[xÃ*]\s*(\d+)\s*s?\s*(bound|stitch|reel|together|highlights?)/); 3424 if (m) { 3425 var n2 = +m[1], piece2 = +m[2], pieces2 = []; 3426 for (var q = 0; q < n2; q++) pieces2.push(piece2); 3427 return { bind: true, pieces: pieces2, piece: piece2, count: n2, total: n2 * piece2, 3428 label: n2 + " Ã " + piece2 + "s bound" }; 3429 } 3430 m = s.match(/(bound|stitch|reel)\s*(\d+)\s*[xÃ*]\s*(\d+)/); 3431 if (m) { 3432 var n3 = +m[2], piece3 = +m[3], pieces3 = []; 3433 for (var r = 0; r < n3; r++) pieces3.push(piece3); 3434 return { bind: true, pieces: pieces3, piece: piece3, count: n3, total: n3 * piece3, 3435 label: n3 + " Ã " + piece3 + "s bound" }; 3436 } 3437 m = s.match(/(\d+)\s*[xÃ*]\s*(\d+)\s*s?\s+highlights?/); 3438 if (m) { 3439 var n4 = +m[1], piece4 = +m[2], pieces4 = []; 3440 for (var u = 0; u < n4; u++) pieces4.push(piece4); 3441 return { bind: true, pieces: pieces4, piece: piece4, count: n4, total: n4 * piece4, 3442 label: n4 + " Ã " + piece4 + "s highlights bound" }; 3443 } 3444 return null; 3445 } 3446 3447 function parseSeparateJob(s) { 3448 s = (s || "").trim().toLowerCase().replace(/\bseparate\b/g, " ").trim(); 3449 var out = []; 3450 var m = s.match(/(\d+)\s*[xÃ*]\s*(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes)?/i); 3451 if (m) { 3452 var n = +m[1], len = +m[2]; 3453 var unit = (m[3] || "s").charAt(0); 3454 if (unit === "m") len *= 60; 3455 for (var i = 0; i < n; i++) out.push(len); 3456 return out.length ? { bind: false, pieces: out, label: n + " Ã " + len + "s separate" } : null; 3457 } 3458 out = parseLengthsList(s); 3459 if (out.length) { 3460 return { bind: false, pieces: out, label: out.map(function (x) { return Math.round(x) + "s"; }).join(" + ") + " separate" }; 3461 } 3462 return null; 3463 } 3464 3465 /* Full recipe â array of jobs {bind, pieces, label} */ 3466 function parseRecipe(s) { 3467 s = (s || "").trim(); 3468 if (!s) s = "4 Ã 30s separate"; 3469 var chunks = s.split(/\s*;\s*|\n+/); 3470 var jobs = []; 3471 for (var i = 0; i < chunks.length; i++) { 3472 var c = chunks[i].trim(); 3473 if (!c) continue; 3474 if (/^manual\b|^pdf\b|^binder\b/i.test(c)) { 3475 jobs.push({ kind: "manual", bind: false, pieces: [], label: "PDF manual (same map)" }); 3476 continue; 3477 } 3478 if (/^chapters?\b|^timestamps?\b/i.test(c)) { 3479 jobs.push({ kind: "chapters", bind: false, pieces: [], label: "Chapter list .txt" }); 3480 continue; 3481 } 3482 var bound = parseBoundJob(c); 3483 if (bound) { jobs.push(bound);
3483 continue; } 3484 /* explicit separate, or default NxL / list â separate */ 3485 var sep = parseSeparateJob(c); 3486 if (sep) { jobs.push(sep); continue; } 3487 /* bare "1 min of 15s" without word bound still binds (highlight language) */ 3488 var b2 = parseBoundJob(c + " highlights"); 3489 if (b2) { jobs.push(b2); continue; } 3490 } 3491 if (!jobs.length) { 3492 jobs.push({ bind: false, pieces: [30, 30, 30, 30], label: "4 à 30s separate (default)" }); 3493 } 3494 return jobs; 3495 } 3496 3497 /* Back-compat name used by older call sites */ 3498 function parsePackSpec(s) { 3499 var jobs = parseRecipe(s); 3500 if (jobs.length === 1 && jobs[0].bind) return jobs[0]; 3501 if (jobs.length === 1 && !jobs[0].bind) return jobs[0].pieces; 3502 return jobs; /* multi-job */ 3503 } 3504 3505 function previewRecipe(s) { 3506 var jobs = parseRecipe(s); 3507 var lines = jobs.map(function (j, i) { 3508 return (i + 1) + ". " + (j.label || (j.bind ? "bound reel" : "separate")) + 3509 (j.pieces ? " â [" + j.pieces.map(function (p) { return Math.round(p) + "s"; }).join(", ") + "]" : "") + 3510 (j.bind ? " â ONE file" : " â " + (j.pieces ? j.pieces.length : 0) + " files"); 3511 }); 3512 return "Plan (" + jobs.length + " job" + (jobs.length === 1 ? "" : "s") + "):\n" + lines.join("\n"); 3513 } 3514 3515 3516 function cueText(a, b) { 3517 if (typeof cues === "undefined" || !cues || !cues.length) return ""; 3518 return cues.filter(function (c) { return c.t >= a && c.t < b; }).map(function (c) { return c.text; }).join(" "); 3519 } 3520 3521 function scoreSpan(a, b, dur, mode, target) { 3522 var len = b - a; 3523 if (len < 1) return -1e9; 3524 target = target > 0 ? target : 30; 3525 var txt = cueText(a, b); 3526 var low = (txt || "").toLowerCase(); 3527 var kw = 0; 3528 if (STRONG.test(txt)) kw += 2.4; 3529 if (NUMISH.test(txt)) kw += 1.4; 3530 if (txt.length > 40) kw += 0.5; 3531 if (txt.length > 90) kw += 0.35; 3532 /* Hard open / claim language for postable hooks */ 3533 if (/\b(wait|stop|listen|here's|here is|the real|you need|never|actually|nobody|secret|proof|money|credits|opus)\b/i.test(txt)) kw += 1.1; 3534 /* Soft open / dead air openers lose rank (so yeahâ¦, um, okay so) */ 3535 if (/^(so |um |uh |okay |ok |yeah |alright |well |like )/i.test(low.trim()) || /\b(subscribe|like and subscribe|thanks for watching|see you next)\b/i.test(low)) kw -= 1.6; 3536 /* Prefer mid-episode energy over pure cold open credits when impact mode */ 3537 var scale = Math.max(target, 20); 3538 var lenFit = 1 - Math.min(Math.abs(len - target) / scale, 1); 3539 var mid = (a + b) / 2; 3540 var pos = 0; 3541 if (mode === "firsthalf") pos = mid < dur * 0.5 ? 1.2 : 0.2; 3542 else if (mode === "impact") { 3543 /* peak band ~8%â70% of source: real hooks, not cold title + not end credits */ 3544 if (mid >= dur * 0.08 && mid <= dur * 0.70) pos = 1.15; 3545 else if (mid < dur * 0.08) pos = 0.35; 3546 else pos = 0.45; 3547 } else pos = 0.5; 3548 var notTiny = len >= 4 ? 1 : 0; 3549 var notHuge = len > Math.max(180, target * 4) ? -1 : 0; 3550 /* Question marks and imperative density = scroll-stopping */ 3551 var q = (txt.match(/\?/g) || []).length * 0.25; 3552 var base = kw * 2.2 + lenFit * 1.15 + pos + notTiny + notHuge + q; 3553 3554 /* Current-reference rhythm prior: small and scoped. It can break ties between 3555 semantically good windows; it cannot rescue a weak moment. */ 3556 try { 3557 var ref = window.ClipLabReference; 3558 if (ref && ref.medianVisualBeat && typeof scenes !== "undefined" && scenes && scenes.length) { 3559 var cutsIn = 0; 3560 for (var rsi = 0; rsi < scenes.length; rsi++) { 3561 if (scenes[rsi].t > a && scenes[rsi].t < b) cutsIn++; 3562 } 3563 var observedBeat = len / Math.max(1, cutsIn + 1); 3564 var ratio = observedBeat / Math.max(0.15, ref.medianVisualBeat); 3565 var distance = Math.abs(Math.log(Math.max(0.05, ratio))); 3566 var rhythmFit = Math.max(0, 1 - distance / 1.6); 3567 base += rhythmFit * 1.25; 3568 } 3569 } catch (eRef) { /* reference is optional; never block selection */ } 3570 3571 /* potion semantic vote when pack is local (no LLM) 3572 REPAIRED 2026-08-19: this try block was left open by a truncated edit â 3573 no closing brace on the if, no catch, and `return base` was lost. A 3574 syntax error here killed the ENTIRE 2062-line script block, so start(), 3575 grab(), parseRecipe and exportClipWebCodecs were never defined at all 3576 and the page silently did nothing when a file was dropped. Found by 3577 asking the parser (node --check per inline block), not by counting 3578 braces â see aleph-a-count-is-not-a-structure. 3579 The semantic vote is a fast path: per aleph-architecture rule 5 it may 3580 only ever decline (catch -> no bonus), never assert a wrong yes. */ 3581 try { 3582 if (window.SemanticImpact && window.SemanticImpact.ready && window.SemanticImpact.ready() 3583 && typeof cues !== "undefined" && cues && cues.length) { 3584 var sem = window.SemanticImpact.scoreWindow(a, b, cues); 3585 base += 1.4 * sem; 3586 } 3587 } catch (e) { /* ranker stays purely lexical when the vote is unavailable */ } 3588 return base; 3589 } 3590 3591 function storyRoles(n) { 3592 if (n <= 1) return ["hook"]; 3593 if (n === 2) return ["hook", "payoff"]; 3594 if (n === 3) return ["hook", "tension", "payoff"]; 3595 if (n === 4) return ["hook", "tension", "reaction", "payoff"]; 3596 var roles = ["hook"]; 3597 for (var i = 1; i < n - 1; i++) roles.push(i === n - 2 ? "reaction" : "tension"); 3598 roles.push("payoff"); 3599 return roles; 3600 } 3601 3602 /* Story roles are evidence-bearing. A good span can serve more than one role 3603 (e.g. "seven days... what could go wrong?" is both hook AND tension). 3604 We score the requested role independently and preserve WHY. A fallback may 3605 still be offered for human adjustment, but it is never silently called a match. */ 3606 function roleEvidenceSpan(a, b, dur, mode, target, role) { 3607 var base = scoreSpan(a, b, dur, mode === "story" ? "impact" : mode, target); 3608 var txt = cueText(a, b); 3609 var low = (txt || "").toLowerCase(); 3610 var bonus = 0, why = [], penalties = []; 3611 function hit(re, label, points) { 3612 if (re.test(txt)) { bonus += points; why.push(label); return true; } 3613 return false;
3614 } 3615 if (role === "hook") { 3616 hit(/\b\d+(\.\d+)?\b/, "specific number", 1.4); 3617 hit(/\?/, "opens a question", 1.5); 3618 hit(/\b(wait|stop|here'?s|what if|you need|the truth|never|secret|why|how|this is|i made|i lost|we have|we've got)\b/i, "direct hook language", 1.5); 3619 hit(/\b(but|instead|actually|despite|even though|everyone thinks|most people|common belief)\b/i, "belief contrast / contradiction", 1.5); 3620 hit(/\b(risk|wrong|deadline|days?|hours?|lost|failed|broke|challenge|pressure)\b/i, "stakes / tension", 1.2); 3621 if (/^(so |um |uh |okay |ok |yeah |well |like )/i.test(low.trim())) { 3622 bonus -= 2.0; penalties.push("soft opener"); 3623 } 3624 if (a < dur * 0.35) { bonus += 0.35; why.push("early source position"); } 3625 } else if (role === "tension") { 3626 hit(/\b(problem|wrong|risk|deadline|days?|hours?|can't|won't|failed|broke|lost|stuck|but|except|until|before|need to|have to|pressure|challenge|doubt|maybe not)\b/i, "stakes / obstacle / doubt", 2.0); 3627 hit(/\?/, "unresolved question", 0.8); 3628 } else if (role === "reaction") { 3629 hit(/\b(wow|what|no way|crazy|insane|surprised|shocked|laughed|laugh|reaction|couldn't believe|didn't expect|actually|oh)\b/i, "reaction / surprise language", 2.0); 3630 hit(/!/, "exclamation / intensity", 0.7); 3631 if (txt.length && txt.length < 130) { bonus += 0.3; why.push("short reaction-sized beat"); } 3632 } else if (role === "payoff") { 3633 hit(/\b(finally|result|so we|that's why|that is why|we did|we made|it worked|turns out|now we|therefore|follow|watch|see what|in the end|worth it|the point|the answer|going to be good)\b/i, "resolution / result / continuation", 2.0); 3634 if (a > dur * 0.45) { bonus += 0.6; why.push("late source position"); } 3635 } 3636 var roleMatch = why.some(function (x) { 3637 return x !== "early source position" && x !== "late source position" && x !== "short reaction-sized beat"; 3638 }); 3639 return { 3640 score: base + bonus * 2.0, 3641 role: role, 3642 roleMatch: roleMatch, 3643 why: why, 3644 penalties: penalties, 3645 text: txt 3646 }; 3647 } 3648 3649 function roleScoreSpan(a, b, dur, mode, target, role) { 3650 return roleEvidenceSpan(a, b, dur, mode, target, role).score; 3651 } 3652 3653 function pickStorySpans(lengths, used, dur) { 3654 var roles = storyRoles(lengths.length); 3655 var segs = []; 3656 var localUsed = used.slice(); 3657 var unresolvedRoles = []; 3658 for (var i = 0; i < lengths.length; i++) { 3659 var piece = lengths[i]; 3660 var role = roles[i]; 3661 var cands = candidateSpans(dur, "impact", piece).slice(); 3662 for (var j = 0; j < cands.length; j++) { 3663 var re = roleEvidenceSpan(cands[j].a, cands[j].b, dur, "story", piece, role); 3664 cands[j] = { a: cands[j].a, b: cands[j].b, score: re.score, 3665 roleMatch: re.roleMatch, why: re.why, penalties: re.penalties, text: re.text }; 3666 } 3667 /* Real role matches first; score only breaks ties within the same truth class. */ 3668 cands.sort(function (x, y) { 3669 if (!!x.roleMatch !== !!y.roleMatch) return x.roleMatch ? -1 : 1; 3670 return y.score - x.score; 3671 }); 3672 var found = null; 3673 for (var k = 0; k < cands.length; k++) { 3674 var c = cands[k]; 3675 var len = Math.min(piece, c.b - c.a); 3676 if (len < Math.max(3, piece * 0.55)) continue; 3677 var a = c.a, b = Math.min(dur, a + piece); 3678 if (b - a < piece * 0.90) a = Math.max(0, b - piece); 3679 if (!overlapsUsed(a, b, localUsed)) { 3680 found = { a: a, b: b, role: role, roleScore: c.score, 3681 roleMatch: !!c.roleMatch, why: c.why || [], penalties: c.penalties || [], 3682 cueText: c.text || "" }; 3683 break; 3684 } 3685 } 3686 if (!found) { 3687 var fallback = pickCompleteSpans([piece], localUsed, dur, "impact"); 3688 if (fallback && fallback.length) { 3689 found = { a: fallback[0].a, b: fallback[0].b, role: role, roleScore: null, 3690 roleMatch: false, why: [], penalties: ["no role-evidence candidate available"], 3691 cueText: cueText(fallback[0].a, fallback[0].b) }; 3692 } 3693 } 3694 if (found) { 3695 if (!found.roleMatch) unresolvedRoles.push(role); 3696 segs.push(found);
3697 localUsed.push({ a: found.a, b: found.b }); 3698 } 3699 } 3700 return { 3701 segments: segs, 3702 roles: roles.slice(0, segs.length), 3703 unresolvedRoles: unresolvedRoles, 3704 storyReady: segs.length === lengths.length && unresolvedRoles.length === 0 3705 }; 3706 } 3707 3708 function candidateSpans(dur, mode, target) { 3709 var cands = []; 3710 target = target > 0 ? target : 30; 3711 var win = Math.max(20, Math.min(90, target * 1.5)); 3712 if (typeof scenes !== "undefined" && scenes && scenes.length) { 3713 for (var i = 0; i < scenes.length; i++) { 3714 var a = scenes[i].t; 3715 var b = (i + 1 < scenes.length) ? scenes[i + 1].t : dur; 3716 if (b - a < 1) continue; 3717 /* also offer sub-windows if a section is long */ 3718 if (b - a > target * 1.8) { 3719 for (var t = a; t + target * 0.6 < b; t += Math.max(8, target * 0.4)) { 3720 var e = Math.min(t + win, b); 3721 cands.push({ a: t, b: e, score: scoreSpan(t, e, dur, mode, target) }); 3722 } 3723 } else { 3724 cands.push({ a: a, b: b, score: scoreSpan(a, b, dur, mode, target) }); 3725 } 3726 } 3727 } 3728 /* caption-phrase windows if scenes are sparse */ 3729 if (typeof cues !== "undefined" && cues && cues.length) { 3730 for (var k = 0; k < cues.length; k++) { 3731 var ca = Math.max(0, cues[k].t - 1); 3732 var cb = Math.min(dur, cues[k].t + Math.max(20, target * 0.95)); 3733 if (cb - ca >= 8) cands.push({ a: ca, b: cb, score: scoreSpan(ca, cb, dur, mode, target) + 0.3 }); 3734 } 3735 } 3736 /* Sparse film strip (few scene cuts): seed a grid across the whole timeline 3737 so a "week of shorts" still spreads â not seven back-to-back tiles from t=0. 3738 Score stays lower than real scene/cue hits so strong moments still win. */ 3739 if (cands.length < 12 && dur > target * 2) { 3740 var step = Math.max(target * 0.85, dur / 14); 3741 for (var g = 0; g + target * 0.7 < dur; g += step) { 3742 var ga = g, gb = Math.min(dur, g + target); 3743 cands.push({ a: ga, b: gb, score: scoreSpan(ga, gb, dur, mode, target) + 0.15 }); 3744 } 3745 } 3746 cands.sort(function (x, y) { return y.score - x.score; }); 3747 return cands; 3748 } 3749 3750 function stitchToLength(target, cands, used, dur) { 3751 /* Prefer one continuous high-score span near target length */ 3752 var best = null, bestDiff = 1e9; 3753 for (var i = 0; i < cands.length; i++) { 3754 var c = cands[i]; 3755 if (overlapsUsed(c.a, c.b, used)) continue; 3756 var len = c.b - c.a; 3757 var d = Math.abs(len - target); 3758 if (d < bestDiff && len >= target * 0.55 && len <= target * 1.35) { 3759 bestDiff = d; best = { a: c.a, b: c.b }; 3760 } 3761 } 3762 if (best) { 3763 /* trim or pad slightly toward target using scene edges only */ 3764 if (best.b - best.a > target + 2) best.b = best.a + target; 3765 return best; 3766 } 3767 /* Stitch 2â3 nearby high spans */ 3768 var picks = []; 3769 for (var j = 0; j < cands.length && picks.length < 6; j++) { 3770 if (!overlapsUsed(cands[j].a, cands[j].b, used) && !overlapsUsed(cands[j].a, cands[j].b, picks)) 3771 picks.push(cands[j]); 3772 } 3773 picks.sort(function (x, y) { return x.a - y.a; }); 3774 var acc = 0, a0 = null, b0 = null; 3775 for (var p = 0; p < picks.length; p++) { 3776 if (a0 === null) { a0 = picks[p].a; b0 = picks[p].b; acc = b0 - a0; } 3777 else if (picks[p].a - b0 < 12) { /* close enough to feel one conversation */ 3778 b0 = Math.max(b0, picks[p].b); acc = b0 - a0; 3779 } else if (acc < target * 0.7) { 3780 /* gap stitch: keep extending if still short */ 3781 b0 = picks[p].b; acc = b0 - a0; 3782 } 3783 if (acc >= target * 0.9) break; 3784 } 3785 if (a0 === null) { 3786 /* last resort: take from start of unused time */ 3787 var start = 0; 3788 for (var u = 0; u < used.length; u++) start = Math.max(start, used[u].b); 3789 a0 = Math.min(start, Math.max(0, dur - target)); 3790 b0 = Math.min(dur, a0 + target); 3791 } 3792 if (b0 - a0 > target + 1) b0 = a0 + target; 3793 if (b0 > dur) { b0 = dur; a0 = Math.max(0, b0 - target); } 3794 return { a: a0, b: Math.max(a0 + 3, b0) }; 3795 } 3796 3797 function overlapsUsed(a, b, used) { 3798 for (var i = 0; i < used.length; i++) { 3799 var u = used[i]; 3800 if (a < u.b - 2 && b > u.a + 2) return true; 3801 } 3802 return false;
3803 } 3804 3805 function diversifyCands(cands, used, dur) { 3806 if (!used.length) return cands; 3807 return cands.slice().sort(function (x, y) { 3808 function minDist(c) { 3809 var mid = (c.a + c.b) / 2, best = 1e9; 3810 for (var u = 0; u < used.length; u++) { 3811 var um = (used[u].a + used[u].b) / 2; 3812 best = Math.min(best, Math.abs(mid - um)); 3813 } 3814 return best; 3815 } 3816 var sx = x.score + Math.min(minDist(x) / Math.max(dur, 1), 0.35); 3817 var sy = y.score + Math.min(minDist(y) / Math.max(dur, 1), 0.35); 3818 return sy - sx; 3819 }); 3820 } 3821 3822 /* Pick complete source windows for each requested length; mark used so jobs don't steal. */ 3823 function pickCompleteSpans(lengths, used, dur, mode) { 3824 var segs = []; 3825 for (var li = 0; li < lengths.length; li++) { 3826 var piece = lengths[li]; 3827 var found = null; 3828 var candsB = diversifyCands(candidateSpans(dur, mode, piece), used, dur); 3829 var packB = stitchToLength(piece, candsB, used, dur); 3830 if (packB && packB.a + piece <= dur + 0.05) { 3831 found = { a: packB.a, b: packB.a + piece }; 3832 } 3833 if ((!found || overlapsUsed(found.a, found.b, used))) { 3834 var nudge = 0; 3835 for (var u = 0; u < used.length; u++) nudge = Math.max(nudge, used[u].b); 3836 if (nudge + piece <= dur) found = { a: nudge, b: nudge + piece }; 3837 else if (dur >= piece) { 3838 /* last resort: scan for free window of exact piece length */ 3839 for (var t = 0; t + piece <= dur; t += Math.max(2, piece * 0.25)) { 3840 if (!overlapsUsed(t, t + piece, used)) { found = { a: t, b: t + piece }; break; } 3841 } 3842 } 3843 } 3844 if (!found || found.b - found.a < piece * 0.98) continue; 3845 segs.push(found); 3846 used.push(found); 3847 } 3848 return segs; 3849 } 3850 3851 function buildPacks() { 3852 try { if (window.SemanticImpact) window.SemanticImpact.ensure({ quiet: true }); } catch (e0) {} 3853 var v = $("v"); 3854 var dur = v && v.duration ? v.duration : 0; 3855 var ps = $("packstatus"); 3856 var rp = $("recipe_preview"); 3857 if (!dur || dur < 5) { 3858 var msg = "Drop a video and let it watch first â then build the packs."; 3859 S(msg); if (ps) ps.textContent = msg; return; 3860 } 3861 if ((!scenes || !scenes.length) && (!cues || !cues.length)) { 3862 var msg2 = "No sections or captions yet â wait for the watch pass (or generate captions), then try again."; 3863 S(msg2); if (ps) ps.textContent = msg2; return; 3864 } 3865 var recipeStr = ($("pack_spec") && $("pack_spec").value) || "4 Ã 30s separate"; 3866 var jobs = parseRecipe(recipeStr); 3867 var minFloor = +(window.ClipLabMinSeconds || 0); 3868 if (minFloor > 0) { 3869 var tooShort = []; 3870 for (var fj = 0; fj < jobs.length; fj++) { 3871 var fp = jobs[fj].pieces || []; 3872 for (var fi = 0; fi < fp.length; fi++) { 3873 if (+fp[fi] > 0 && +fp[fi] < minFloor) tooShort.push(fp[fi]); 3874 } 3875 } 3876 if (tooShort.length) { 3877 var floorMsg = "Campaign contract blocks this recipe: requested " + 3878 tooShort.join(", ") + "s piece(s), but the current campaign minimum is " + minFloor + 3879 "s. Change the recipe before encoding."; 3880 S(floorMsg); if (ps) ps.textContent = floorMsg; return; 3881 } 3882 } 3883 var mode = ($("pack_mode") && $("pack_mode").value) || "impact"; 3884 var used = []; 3885 clipList = []; 3886 var summaryLines = []; 3887 var fileCount = 0; 3888 3889 for (var ji = 0; ji < jobs.length; ji++) { 3890 var job = jobs[ji]; 3891 if (job.kind === "manual") { 3892 clipList.push({ kind: "manual", a: 0, b: 0, bound: false, label: job.label }); 3893 summaryLines.push("Job " + (ji + 1) + ": MANUAL PDF (use Export PDF Manual â same map)"); 3894 continue; 3895 } 3896 if (job.kind === "chapters") { 3897 clipList.push({ kind: "chapters", a: 0, b: 0, bound: false, label: job.label }); 3898 summaryLines.push("Job " + (ji + 1) + ": CHAPTERS (use Export Chapter List â same map)"); 3899 continue; 3900 } 3901 var pieces = job.pieces || []; 3902 if (!pieces.length) continue; 3903 3904 if (job.bind) { 3905 var storyPick = mode === "story" ? pickStorySpans(pieces, used, dur) : null; 3906 var segs = storyPick ? storyPick.segments : pickCompleteSpans(pieces, used, dur, mode); 3907 if (!segs.length) { 3908 summaryLines.push("Job " + (ji + 1) + " (" + job.label + "): no complete cuts found"); 3909 continue; 3910 } 3911 if (storyPick) { 3912 for (var sui = 0; sui < segs.length; sui++) used.push({a:segs[sui].a,b:segs[sui].b}); 3913 } 3914 var totalOut = 0; 3915 for (var si = 0; si < segs.length; si++) totalOut += segs[si].b - segs[si].a; 3916 clipList.push({
3917 a: segs[0].a, 3918 b: segs[0].b, 3919 target: totalOut, 3920 bound: true, 3921 segments: segs, 3922 roles: storyPick ? storyPick.roles : null, 3923 storyReady: storyPick ? !!storyPick.storyReady : null, 3924 unresolvedRoles: storyPick ? (storyPick.unresolvedRoles || []) : [], 3925 pieces: pieces.slice(0, segs.length), 3926 piece: pieces[0], 3927 label: job.label 3928 }); 3929 fileCount += 1; 3930 var roleLabel = storyPick && storyPick.roles && storyPick.roles.length 3931 ? " [" + storyPick.roles.join(" â ") + "]" : ""; 3932 var readiness = storyPick 3933 ? (storyPick.storyReady ? " â story roles evidenced" 3934 : " â review " + (storyPick.unresolvedRoles || []).join(", ")) 3935 : ""; 3936 summaryLines.push("Job " + (ji + 1) + ": BOUND " + segs.length + " cuts â " + 3937 Math.round(totalOut) + "s ONE file (" + job.label + ")" + roleLabel + readiness); 3938 } else { 3939 var segsS = pickCompleteSpans(pieces, used, dur, mode); 3940 for (var ti = 0; ti < segsS.length; ti++) { 3941 clipList.push({ 3942 a: segsS[ti].a, 3943 b: segsS[ti].b, 3944 target: pieces[ti] || (segsS[ti].b - segsS[ti].a), 3945 bound: false, 3946 label: "separate " + Math.round(segsS[ti].b - segsS[ti].a) + "s" 3947 }); 3948 fileCount++; 3949 } 3950 summaryLines.push("Job " + (ji + 1) + ": SEPARATE " + segsS.length + " file(s) (" + job.label + ")"); 3951 } 3952 } 3953 3954 renderClipList(); 3955 if ($("aspectsel")) $("aspectsel").value = "9:16"; 3956 var summary = "Recipe built â " + fileCount + " download(s) from " + jobs.length + 3957 " job(s).\n" + summaryLines.join("\n") + 3958 "\nExport all to finish every product from this one watch."; 3959 S(summary.replace(/\n/g, " · ")); 3960 if (ps) ps.textContent = summary.replace(/\n/g, " · "); 3961 if (rp) rp.innerHTML = "<b>Plan after map:</b><br>" + 3962 summaryLines.map(function (l) { return "⢠" + l; }).join("<br>") + 3963 (fileCount ? "<br><span style='color:var(--acc)'>Ready â Export all</span>" : ""); 3964 } 3965 3966 var bp = $("buildPacks"); 3967 if (bp) bp.onclick = buildPacks; 3968 /* For CIOS / tests / agents: full recipe API, not one preset. */ 3969 window.ClipLabRecipe = { 3970 parse: parseRecipe, 3971 preview: previewRecipe, 3972 build: buildPacks, 3973 storyRoles: storyRoles, 3974 roleEvidenceSpan: roleEvidenceSpan, 3975 pickStorySpans: pickStorySpans 3976 }; 3977 3978 /* Format picker + hash deep-links from homepage. 3979 Hash always wins over sessionStorage so home links like #format=both land correctly 3980 even if a prior visit saved "clipping". Also re-apply on hashchange. */ 3981 function setFormat(fmt) { 3982 fmt = fmt || "clipping"; 3983 if (fmt !== "clipping" && fmt !== "manual" && fmt !== "both") fmt = "clipping"; 3984 document.querySelectorAll("#fmtpick .fmtcard").forEach(function (c) { 3985 var on = c.getAttribute("data-format") === fmt; 3986 c.classList.toggle("on", on); 3987 c.setAttribute("aria-checked", on ? "true" : "false"); 3988 }); 3989 var pb = $("packbox"); 3990 if (pb) pb.classList.toggle("on", fmt === "clipping" || fmt === "both"); 3991 document.body.classList.toggle("fmt-hide-manual", fmt === "clipping"); 3992 document.body.classList.toggle("fmt-hide-clip", fmt === "manual"); 3993 try { sessionStorage.setItem("binder_format", fmt); } catch (e) {} 3994 return fmt; 3995 } 3996 function formatFromLocation() { 3997 var hash = (location.hash || "").replace(/^#/, ""); 3998 var fromHash = (hash.match(/(?:^|[?&#])format=([a-z]+)/i) || hash.match(/format=([a-z]+)/i) || [])[1]; 3999 if (fromHash) return setFormat(fromHash); 4000 var saved = null; 4001 try { saved = sessionStorage.getItem("binder_format"); } catch (e) {} 4002 return setFormat(saved || "clipping"); 4003 } 4004 window.applyBinderFormat = setFormat; 4005 window.binderFormatFromLocation = formatFromLocation; 4006 document.querySelectorAll("#fmtpick .fmtcard").forEach(function (c) { 4007 c.addEventListener("click", function () { setFormat(c.getAttribute("data-format")); }); 4008 c.addEventListener("keydown", function (e) { 4009 if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setFormat(c.getAttribute("data-format")); } 4010 }); 4011 }); 4012 document.querySelectorAll(".packpresets button").forEach(function (b) { 4013 b.addEventListener("click", function () { 4014 if ($("pack_spec")) $("pack_spec").value = b.getAttribute("data-spec"); 4015 var rp = $("recipe_preview"); 4016 if (rp && typeof previewRecipe === "function") { 4017 try { 4018 rp.innerHTML = "<b>Recipe plan:</b><br>" + 4019 previewRecipe(b.getAttribute("data-spec")).replace(/\n/g, "<br>"); 4020 } catch (e) {} 4021 } 4022 }); 4023 }); 4024 var packSpecEl = $("pack_spec"); 4025 if (packSpecEl) { 4026 packSpecEl.addEventListener("input", function () { 4027 var rp = $("recipe_preview"); 4028 if (!rp) return;
4029 try { 4030 rp.innerHTML = "<b>Recipe plan:</b><br>" + 4031 previewRecipe(packSpecEl.value).replace(/\n/g, "<br>"); 4032 } catch (e) {} 4033 }); 4034 } 4035 formatFromLocation(); 4036 window.addEventListener("hashchange", formatFromLocation); 4037})(); 4038 4039$("exportAllClips").onclick = async function () { 4040 if (!clipList.length) { S("Build or add at least one clip first."); return; } 4041 if (!chGate()) return; 4042 var capSrc = vpCaptionSourceReady(); 4043 var customLines = capSrc.custom; 4044 if (!capSrc.ok) { 4045 S("No captions loaded â batch will export picture + audio only. Generate captions or drop a .srt/.vtt first for burned-in text."); 4046 } 4047 $("clipbar").style.display = "block"; 4048 var alsoSq = $("alsoSquare") && $("alsoSquare").checked; 4049 var successes = 0, failures = 0; 4050 for (var i = 0; i < clipList.length; i++) { 4051 var r = clipList[i]; 4052 $("clipprog").style.width = "0%"; 4053 var isBound = !!(r.bound && r.segments && r.segments.length); 4054 $("clipstatus").textContent = isBound 4055 ? ("Exporting bound reel " + (i + 1) + " (" + r.segments.length + " highlights)â¦") 4056 : ("Exporting clip " + (i + 1) + " of " + clipList.length + " (" + fmt(r.a) + "â" + fmt(r.b) + ")" + 4057 (capSrc.ok ? " with captionsâ¦" : " (no caption source)â¦")); 4058 try { 4059 await exportOneProduct(r.a, r.b, true, customLines, i + 1, 4060 isBound ? r.segments : null, null); 4061 if (alsoSq) { 4062 $("clipstatus").textContent = "1:1 pass for item " + (i + 1) + "â¦"; 4063 await exportOneProduct(r.a, r.b, true, customLines, (i + 1) + "sq", 4064 isBound ? r.segments : null, "1:1"); 4065 } 4066 successes++; 4067 } catch (err) { 4068 failures++; 4069 $("clipstatus").textContent = "Clip " + (i + 1) + " failed: " + err.message; 4070 console.error(err); 4071 } 4072 } 4073 if (successes > 0) chCommitTrial("clip_batch"); 4074 else chReleaseTrial(); 4075 $("clipstatus").textContent = successes + " of " + clipList.length + " item" + 4076 (clipList.length === 1 ? "" : "s") + " exported" + 4077 (failures ? " · " + failures + " failed" : "") + 4078 (capSrc.ok ? " · captions burned in" : " · no captions were available to burn") + 4079 " â check your Downloads and the files tray."; 4080}; 4081 4082/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4083 TAB CAPTURE DOOR 4084 getDisplayMedia({video:true, audio:{systemAudio:'include'}}) 4085 Works: Chrome 74+ / Edge / Opera on desktop (tab share + tab audio) 4086 Firefox: API exists but audio is ignored (video only) 4087 Mobile: not supported 4088 The captured stream feeds startFromStream() â same watch loop, live. 4089 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 4090$("tabcap").onclick = async function() { 4091 if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { 4092 $("tabstatus").textContent = "getDisplayMedia is not available in this browser."; 4093 return; 4094 } 4095 $("tabstatus").textContent = "Waiting for you to pick a tab in the browser pickerâ¦"; 4096 var stream; 4097 try { 4098 stream = await navigator.mediaDevices.getDisplayMedia({ 4099 video: {frameRate: {ideal: 30}}, 4100 audio: {systemAudio: "include"} /* Chrome/Edge: shares the tab's own audio */ 4101 }); 4102 } catch(err) { 4103 $("tabstatus").textContent = "No tab shared: " + err.message; 4104 return; 4105 } 4106 $("tabstatus").textContent = "Tab captured â recording into a local buffer⦠Play the video in the other tab now."; 4107 startFromStream(stream); 4108}; 4109 4110async function startFromStream(stream) { 4111 /* record the stream into a local buffer, then hand it to start() as a Blob */ 4112 var mime = (MediaRecorder.isTypeSupported("video/webm;codecs=vp9") ? "video/webm;codecs=vp9" : "video/webm"); 4113 var mr = new MediaRecorder(stream, {mimeType: mime}); 4114 var chunks = []; 4115 mr.ondataavailable = function(e){ if (e.data.size > 0) chunks.push(e.data); }; 4116 4117 /* let it record; stop when the user stops sharing the tab */ 4118 stream.getVideoTracks()[0].onended = function() { 4119 if (mr.state !== "inactive") mr.stop(); 4120 }; 4121 mr.onstop = function() { 4122 var blob = new Blob(chunks, {type: mime}); 4123 $("tabstatus").textContent = "Tab recording finished (" + (blob.size / 1024 / 1024).toFixed(1) + " MB) â analysing nowâ¦";
4124 var file = new File([blob], "tab_capture.webm", {type: mime}); 4125 start(file); 4126 }; 4127 mr.start(1000); /* collect in 1-second chunks */ 4128 $("tabstatus").textContent = "Recording â play the video in the other tab. Stop sharing to finish and hand it to the Binder."; 4129} 4130</script>
vendor: 107 bytes, line 4130
4130<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon="{"token": "
413020278c4591b54db0b2e9e82f64bd931c
vendor: 13 bytes, line 4130
4130"}"></script>
4130<script src="audio_bus.js"></script>
vendor: 1 bytes, line 4130
4130
4131<script src="lanes.js"></script>
vendor: 1 bytes, line 4131
4131
4132<script src="foley.js"></script>
vendor: 1 bytes, line 4132
4132
4133<script src="captions.js"></script>
vendor: 1 bytes, line 4133
4133
4134<script> 4135document.getElementById("capgen").onclick = async function () { 4136 if (!document.getElementById("v").duration) { document.getElementById("capgenstatus").textContent = "Load a video first."; return; } 4137 if (typeof chGate === "function" && !chGate()) return; 4138 this.disabled = true; 4139 try { 4140 /* speed 1.5 â see captions.js 2026-08-06 QC (4Ã produced 0 lines on short speech) */ 4141 var r = await window.binderGenerateCaptions({speed: 1.5}); 4142 if (r && r.srt) { 4143 var u = URL.createObjectURL(new Blob([r.srt], {type:"text/plain"})); 4144 var a = document.createElement("a"); a.href = u; a.download = (vname||"video") + ".srt"; a.click(); 4145 chCommitTrial("captions"); 4146 } else { 4147 chReleaseTrial(); 4148 document.getElementById("capgenstatus").textContent = "No usable captions were produced â this attempt did not use a free trial."; 4149 } 4150 } catch (e) { 4151 chReleaseTrial(); 4152 document.getElementById("capgenstatus").textContent = "Could not transcribe: " + (e.message || e) + " â this attempt did not use a free trial."; 4153 } 4154 this.disabled = false; 4155}; 4156</script>
vendor: 1 bytes, line 4156
4156
4157<script> 4158/* Default Shorts caption pack on load â postable karaoke emphasis without CapCut surgery */ 4159(function bootCapDefault() { 4160 function go() { 4161 if (typeof applyCapStylePack === "function" && CAP_STYLE_PACKS.shorts) { 4162 applyCapStylePack("shorts"); 4163 } 4164 } 4165 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", go); 4166 else go(); 4167})(); 4168</script>
4168 4169</body></html>
4170<script> 4171 4172 window.ClipLabPackage = (function () { 4173 var CLIPLAB = "Clips cut with Clip Lab â a week of shorts from one long video, in your browser, no per-minute meter: https://still-wildflower-dc6f.akdmediax.workers.dev/binder/"; 4174 var STOP = {the:1,and:1,a:1,an:1,of:1,to:1,in:1,is:1,it:1,for:1,on:1,with:1,that:1,this:1,you:1,we:1,they:1,at:1,as:1,by:1,be:1,are:1,was:1,from:1,so:1,but:1,or:1,not:1,have:1,just:1,like:1,about:1}; 4175 function firstCue() { 4176 var cues = (window.VTT && window.VTT.cues) || (window.caps && window.caps.cues) || []; 4177 var texts = []; 4178 for (var i = 0; i < (cues && cues.length || 0); i++) { 4179 var tx = (cues[i].text || cues[i].t || "").replace(/\s+/g, " ").trim(); 4180 if (tx) texts.push(tx); 4181 } 4182 if (window.ClipLabRank && texts.length) { 4183 var ranked = window.ClipLabRank.rank(texts.map(function (x) { return { text: x }; })); 4184 for (var j = 0; j < ranked.length; j++) { 4185 if ((ranked[j].rankWhy || []).indexOf("SOFT-OPEN") < 0) 4186 return ranked[j].text.slice(0, 90); 4187 } 4188 } 4189 if (texts[0]) return texts[0].slice(0, 90); 4190 var st = document.getElementById("status"); 4191 return (st && st.textContent || "Watch this.").slice(0, 90); 4192 } 4193 function compose(hook, about) { 4194 hook = (hook || firstCue() || "Watch this.").replace(/#/g, "").trim().slice(0, 92); 4195 about = (about || hook).trim(); 4196 var title = hook.slice(0, 100); 4197 var hashes = ["#Shorts", "#ClipLab"]; 4198 var desc = [ 4199 hashes.join(" "), 4200 "", 4201 about, 4202 "", 4203 "Two ways this cut can pay: (1) the campaign bounty on verified views, (2) your own channel where the brief allows. Submit the campaign link inside the window â views before submit often do not count.", 4204 "", 4205 CLIPLAB 4206 ].join("\n"); 4207 var tags = [], budget = 500; 4208 (about + " " + hook).toLowerCase().replace(/[a-z0-9][a-z0-9'\-]*/g, function (w) { 4209 if (STOP[w] || w.length < 3 || tags.indexOf(w) >= 0) return w; 4210 if (tags.length >= 8 || w.length + 1 > budget) return w; 4211 tags.push(w); budget -= w.length + 1; 4212 return w; 4213 }); 4214 while (tags.length < 5) { 4215 var extra = ["shorts", "clipping", "short form video", "clip lab"][tags.length] || "vertical video"; 4216 if (tags.indexOf(extra) < 0) tags.push(extra); else break; 4217 } 4218 return { title: title, description: desc, hashtags: hashes, tags: tags, clipLab: CLIPLAB }; 4219 } 4220 function render() { 4221 var p = compose(); 4222 var ti = document.getElementById("clipack_title"); 4223 var de = document.getElementById("clipack_desc"); 4224 var ta = document.getElementById("clipack_tags"); 4225 var wh = document.getElementById("clipack_why"); 4226 if (ti) ti.value = p.title; 4227 if (de) de.value = p.description; 4228 if (ta) ta.value = p.tags.join(", "); 4229 if (wh) { 4230 var r = window.ClipLabRank && window.ClipLabRank.score(p.title); 4231 wh.textContent = r 4232 ? ("Why this opening scores " + r.score + ": " + (r.why.join(", ") || "thin â rewrite the first 2 seconds before you post.")) 4233 : "Rank the opening: contradiction, number, cost, hard open. Soft openers (um, hey guys) kill Phase 1."; 4234 } 4235 var box = document.getElementById("clipack"); 4236 if (box) box.style.display = "block"; 4237 return p; 4238 } 4239 function copy() { 4240 var ti = document.getElementById("clipack_title"); 4241 var de = document.getElementById("clipack_desc"); 4242 var blob = "TITLE\n" + (ti && ti.value || "") + "\n\nDESCRIPTION\n" + (de && de.value || ""); 4243 if (navigator.clipboard && navigator.clipboard.writeText) { 4244 navigator.clipboard.writeText(blob).then(function () { 4245 var s = document.getElementById("clipack_copied"); 4246 if (s) s.textContent = "copied"; 4247 }); 4248 } 4249 } 4250 return { compose: compose, render: render, copy: copy, CLIPLAB: CLIPLAB }; 4251 })(); 4252 var b = document.getElementById("clipack_copy"); 4253 if (b) b.onclick = function () { window.ClipLabPackage && window.ClipLabPackage.copy(); }; 4254</script>
vendor: 1 bytes, line 4254
4254
4255<script> 4256/* Foley preview â one synthesised hit straight out of the speakers, no file involved. */ 4257(function () { 4258 var b = document.getElementById("foleyPreview"); 4259 if (!b) return; 4260 b.onclick = function (e) { 4261 e.preventDefault(); 4262 var k = document.getElementById("foleyKind").value; 4263 var s = document.getElementById("foleystatus"); 4264 if (k === "none") { s.textContent = "Head sound is set to None."; return; } 4265 var ok = window.VPFoley && window.VPFoley.preview(k); 4266 s.textContent = ok 4267 ? "That is the " + k + " \u2014 generated here, 0.40s, no download." 4268 : "This browser will not open an audio context for the preview."; 4269 }; 4270})(); 4271 4272/* Coming back from a successful checkout, the URL carries ?session_id=cs_... â verify it 4273 once and grant the pass, so a buyer never meets the wall they just paid to pass. */ 4274(function () { 4275 try { 4276 var q = new URLSearchParams(location.search); 4277 var sid = q.get("session_id"); 4278 if (!sid || !/^cs_/.test(sid)) return; 4279 fetch("/pass/verify", { method: "POST", headers: { "content-type": "application/json" }, 4280 body: JSON.stringify({ session_id: sid }) }) 4281 .then(function (r) { return r.json(); }) 4282 .then(function (d) { 4283 if (!d.ok) return; 4284 localStorage.setItem("ch_pass", JSON.stringify({ 4285 kind: d.kind, at: Date.now(), expires: d.expires, session: sid.slice(-12) })); 4286 var u = new URL(location.href); u.searchParams.delete("session_id"); 4287 history.replaceState({}, "", u); 4288 }).catch(function () {}); 4289 } catch (e) {} 4290})(); 4291</script>
vendor: 1 bytes, line 4291
4291
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.