PageSourceSearch

https://still-wildflower-dc6f.akdmediax.workers.dev/binder/

html still-wildflower-dc6f.akdmediax.workers.dev collected 2026-10-03 08:10:57 UTC 230,926 bytes, 4,291 lines download raw bytes

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 &middot; Re-cut without re-paying the meter &middot; 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 &middot; MP4 / WebM / MOV &middot; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &amp; bind it &rarr;</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 &mdash; 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) &rarr;</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">&ndash;</b>&times; 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 &rarr; 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) &rarr; timestamps + labels, ready to paste into a YouTube description</button>
299    <button class="go ter" id="thumbzip">Export Thumbnails (.zip) &rarr; 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 &mdash; 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 &mdash; 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 &amp; 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 &mdash; <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 &mdash; <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 &mdash; <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 &mdash; 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 &mdash; talking head, camera</option>
428    <option value="fit">Fit whole frame &mdash; 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) &rarr; 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 &rarr; 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 &rarr; 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 &mdash; 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&nbsp;<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&nbsp;<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) &rarr;</button>
480      <div class="note">
481        Exports WebM (all browsers) or MP4 where supported &mdash; 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 &mdash; 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 &mdash; 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&#10;Line two&#10;Line three"></textarea>
491      <button class="go sec" id="exportsubclip" style="margin-top:10px;width:100%">Export Clip with Captions Burned In &rarr; 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 &mdash; a YouTube video, a webinar, a course &mdash; 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 &mdash; no file needed.</p>
501    <button class="go sec" id="tabcap">Share a tab into the Binder &rarr;</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 &mdash; 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 &middot; It watches</b>Your browser decodes the frames; the reader samples them in bounded breaths &mdash; 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 &middot; 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 &middot; 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 &mdash; 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 &amp; L&amp;D</b>One-hour training video &rarr; 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 &mdash; 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 &mdash; 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 &rarr;</a></div>
523</main>
524<footer>AKDmediaX &mdash;
524 A Creative Colabor Environment Where Tech Bows to Presence<br>&copy; 2026 AKDmediaX &middot; 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 &mdash; 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           "&gt;&gt;" — YouTube's speaker marker — straight onto the burned frame. */
1019        return l.replace(/<[^>]+>/g,"")
1020                .replace(/&gt;/g, ">").replace(/&lt;/g, "<")
1021                .replace(/&amp;/g, "&").replace(/&quot;/g, '"')
1022                .replace(/&#39;|&apos;/g, "'").replace(/&nbsp;/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'>&#10003; "
1146        + "submitted" + (mins !== null ? " after " + mins + " min" : "")
1147        + " &mdash; 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) &mdash; 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 &mdash; 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 = "&#9201; " + 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'>&#10003; Your file is ready &mdash; " + 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>&#9654; " + 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> &mdash; <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 &mdash; 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 &rarr;</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 + " &mdash; " + fmtT(c.t) + " (+" + c.length +
1770            "s)&hellip; " + 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 &middot; " + lens.join("+") + " &rarr; " + Math.round(tot) + "s one file" +
3153        (r.label ? " <span style='opacity:.7'>(" + r.label + ")</span>" : "") + storyBits;
3154    } else {
3155      label = "SEPARATE &middot; " + fmt(r.a) + " &rarr; " + 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'>&times;</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.