File size: 46,831 Bytes
be07307
 
 
 
 
 
 
 
 
 
b46a11d
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
be07307
 
 
 
 
 
 
b46a11d
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
 
be07307
 
 
 
 
 
 
 
b46a11d
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb97fbe
b46a11d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
 
 
be07307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b46a11d
 
be07307
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
// Clipwhit app: long video -> Whisper transcript -> ranked moments -> 9:16 face-tracked, captioned shorts.
// Everything runs in this browser tab (transformers.js, MediaPipe, ffmpeg.wasm). The only optional network call with
// your content is the AI re-rank, which sends transcript text (never audio or video) and is off unless you tick it.
// © 2026 CyberMax. All rights reserved.
import { FFmpeg } from './vendor/ffmpeg/index.js';
import { toSegments, toWords, envelope, pickMoments, clock } from './lib/scoring.js';
import { STYLES, buildAss, buildSrt } from './lib/captions.js';
import { cropBox, cropPath, cropExpr, thinKeys } from './lib/crop.js';
import { zipParts } from './lib/zip.js';
import * as lic from './lib/licence.js';
import { classify, matchEpisode, mb, MEDIA_EXT } from './lib/links.js';

const API = /\.workers\.dev$/.test(location.hostname) || location.hostname === 'localhost' && location.port === '8787'
  ? location.origin : 'https://clipwhit.cybermax-tools.workers.dev';
const CORE = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm';
const MP = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21';
const FACE_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite';
const FONTS = { Anton: 'https://cdn.jsdelivr.net/fontsource/fonts/anton@latest/latin-400-normal.ttf', Inter: 'https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-800-normal.ttf' };
const FREE_CLIPS = 3;
const MARK = 'Made with Clipwhit';

const $ = (id) => document.getElementById(id);
const el = (tag, attrs = {}, ...kids) => {
  const n = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) {
    if (k === 'class') n.className = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else if (v !== false && v != null) n.setAttribute(k, v === true ? '' : v);
  }
  for (const c of kids.flat()) if (c != null) n.append(c.nodeType ? c : document.createTextNode(String(c)));
  return n;
};
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const store = { get: (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} } };
const baseName = (n) => String(n || 'video').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').slice(0, 60) || 'video';
const parseClock = (s) => { const p = String(s).trim().split(':').map(Number); if (p.some((x) => !Number.isFinite(x))) return NaN; return p.reduce((a, b) => a * 60 + b, 0); };

// ---------------------------------------------------------------- state
const S = {
  tier: 'free', jobs: [], active: null, busy: false, ff: null, ffLoaded: false, detector: null, worker: null,
  timings: {}, // measured per stage, shown in the status line and read by the test
};
window.clipwhit = S; // test/automation hook

function setStatus(t, frac) {
  $('status').textContent = t;
  if (frac != null) $('bar').style.width = `${Math.round(Math.max(0, Math.min(1, frac)) * 100)}%`;
}
const pro = () => S.tier === 'pro' || S.tier === 'agency';

// ---------------------------------------------------------------- licence
function renderTier() {
  const t = S.tier;
  $('tier').textContent = t === 'agency' ? 'Agency' : t === 'pro' ? 'Pro' : 'Free';
  $('tier').dataset.tier = t;
  document.body.dataset.tier = t;
  $('res').querySelector('option[value="1080"]').disabled = !pro();
  if (!pro()) $('res').value = '720';
  for (const o of $('style').options) o.disabled = !pro() && !STYLES[o.value].free;
  if (!pro() && !STYLES[$('style').value].free) $('style').value = 'pop';
  $('kitbox').hidden = !pro();
  $('kitnew').hidden = S.tier !== 'agency';
  $('upsell').hidden = pro();
  renderKits();
  if (S.active) renderMoments(S.active);
}

async function applyKey(key, force = false) {
  const r = await lic.verify(API, key, { force });
  if (r.valid) { S.tier = r.tier === 'agency' ? 'agency' : 'pro'; $('keymsg').textContent = `${S.tier === 'agency' ? 'Agency' : 'Pro'} unlocked on this browser.`; }
  else { S.tier = 'free'; $('keymsg').textContent = r.reason === 'offline' ? 'Could not reach the licence server; try again when online.' : 'That key was not accepted. Check it, or buy Pro below.'; }
  renderTier();
  return r;
}

// ---------------------------------------------------------------- brand kits (Pro: one, Agency: many)
const kits = () => store.get('clipwhit.kits', [{ id: 'k1', name: 'My brand', base: '#FFFFFF', hi: '#FFD23F', font: 'Anton', handle: '' }]);
function renderKits() {
  const list = kits(), sel = $('kit');
  const cur = store.get('clipwhit.kit', list[0].id);
  sel.replaceChildren(el('option', { value: '' }, 'No brand kit (style colours)'), ...list.map((k) => el('option', { value: k.id, selected: k.id === cur }, k.name)));
  const k = list.find((x) => x.id === sel.value);
  for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).value = k ? k[f] : '';
  $('kitfields').hidden = !k;
}
function saveKit() {
  const list = kits(), id = $('kit').value, k = list.find((x) => x.id === id);
  if (!k) return;
  for (const f of ['name', 'base', 'hi', 'font', 'handle']) k[f] = $(`kit-${f}`).value.slice(0, 40);
  store.set('clipwhit.kits', list);
  store.set('clipwhit.kit', id);
  $('kit').selectedOptions[0].textContent = k.name;
}
function activeKit() {
  if (!pro()) return null;
  return kits().find((x) => x.id === $('kit').value) || null;
}

// ---------------------------------------------------------------- engines (lazy)
async function blobURL(url, type) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(`download failed (${r.status})`);
  return URL.createObjectURL(new Blob([await r.arrayBuffer()], { type }));
}
async function ffmpeg() {
  if (S.ffLoaded) return S.ff;
  const t0 = performance.now();
  setStatus('Loading the video engine (ffmpeg.wasm, about 31 MB, cached after the first time)…');
  S.ff = new FFmpeg();
  S.ff.on('log', ({ message }) => { S.lastLog = (S.lastLog || []).slice(-40); S.lastLog.push(message); });
  await S.ff.load({ coreURL: await blobURL(`${CORE}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await blobURL(`${CORE}/ffmpeg-core.wasm`, 'application/wasm') });
  await S.ff.createDir('/fonts');
  for (const [name, url] of Object.entries(FONTS)) {
    const r = await fetch(url);
    await S.ff.writeFile(`/fonts/${name}.ttf`, new Uint8Array(await r.arrayBuffer()));
  }
  await S.ff.createDir('/in');
  S.ffLoaded = true;
  S.timings.engineLoad = (performance.now() - t0) / 1000;
  return S.ff;
}
async function mountFile(job) {
  const ff = await ffmpeg();
  if (S.mounted === job.id) return `/in/${job.file.name}`;
  if (S.mounted) { try { await ff.unmount('/in'); } catch {} }
  await ff.mount('WORKERFS', { files: [job.file] }, '/in');
  S.mounted = job.id;
  return `/in/${job.file.name}`;
}
async function faceDetector() {
  if (S.detector) return S.detector;
  const { FaceDetector, FilesetResolver } = await import(`${MP}/vision_bundle.mjs`);
  const fs = await FilesetResolver.forVisionTasks(`${MP}/wasm`);
  S.detector = await FaceDetector.createFromOptions(fs, { baseOptions: { modelAssetPath: FACE_MODEL, delegate: 'CPU' }, runningMode: 'IMAGE', minDetectionConfidence: 0.5 });
  return S.detector;
}

// ---------------------------------------------------------------- jobs
let nextId = 1;
async function addFiles(files, meta = {}) {
  const list = [...files].filter((f) => /^(video|audio)\//.test(f.type) || /\.(mp4|mov|m4v|webm|mkv|mp3|m4a|wav)$/i.test(f.name));
  if (!list.length) return setStatus('That is not a video or audio file. Try MP4, MOV, WebM, MKV, MP3 or M4A.');
  if (!pro() && (list.length > 1 || S.jobs.length)) {
    $('batchmsg').hidden = false;
    S.jobs = [];
  }
  for (const file of (pro() ? list : list.slice(0, 1))) {
    const job = { id: nextId++, file, name: file.name, url: URL.createObjectURL(file), status: 'ready', moments: [], clips: [], art: meta.art || null, show: meta.show || '', source: meta.source || '' };
    await probe(job);
    S.jobs.push(job);
  }
  select(S.jobs[S.jobs.length - (pro() ? list.length : 1)]);
  renderQueue();
}
function probe(job) {
  return new Promise((res) => {
    const v = el('video', { preload: 'metadata', muted: true, src: job.url });
    const timer = setTimeout(() => fallback(), 4000);
    const fallback = async () => {
      clearTimeout(timer); v.onloadedmetadata = v.onerror = null;
      // The browser can't read this codec/container: ask ffmpeg.wasm instead (loads the engine once).
      try {
        const ff = await ffmpeg();
        const path = await mountFile(job);
        S.lastLog = [];
        await ff.exec(['-hide_banner', '-i', path]);
        const log = S.lastLog.join('\n');
        const d = log.match(/Duration: (\d+):(\d+):([\d.]+)/);
        const vs = log.match(/Video:.*?(\d{2,5})x(\d{2,5})/);
        if (!d) throw new Error('no duration');
        job.duration = +d[1] * 3600 + +d[2] * 60 + +d[3];
        if (vs) { job.w = +vs[1]; job.h = +vs[2]; }
        job.nativePreview = false;
      } catch { job.duration = 0; job.error = 'That file could not be read. Convert it to MP4 (H.264) first.'; }
      res();
    };
    v.onloadedmetadata = () => {
      if (!v.duration || !Number.isFinite(v.duration) || (v.videoWidth === 0 && /^video\//.test(job.file.type))) return fallback();
      clearTimeout(timer); job.duration = v.duration; job.w = v.videoWidth; job.h = v.videoHeight; job.nativePreview = true; res();
    };
    v.onerror = () => fallback();
  });
}
function select(job) {
  S.active = job;
  $('work').hidden = false;
  $('preview').src = job.url;
  const d = job.duration || 0;
  $('fileinfo').textContent = `${job.name} · ${clock(d)}${job.w ? ` · ${job.w}×${job.h}` : ' · audio only'}`;
  $('from').value = clock(job.range?.[0] ?? 0);
  $('to').value = clock(job.range?.[1] ?? d);
  $('find').disabled = S.busy || !!job.error;
  if (job.error) setStatus(job.error);
  else setStatus(job.moments.length ? `${job.moments.length} moments found. Pick clips and press Render.` : 'Ready. Choose a range if you like, then press Find the best moments.');
  renderMoments(job);
  renderResults(job);
}
function renderQueue() {
  $('queue').hidden = S.jobs.length < 2;
  $('queuelist').replaceChildren(...S.jobs.map((j) => el('li', {}, el('button', { class: `q${j === S.active ? ' on' : ''}`, type: 'button', onclick: () => select(j) }, j.name), ` ${j.status}`)));
}

// ---------------------------------------------------------------- 1. audio + transcript
async function extractAudio(job, start, end) {
  const t0 = performance.now();
  try {
    const ff = await ffmpeg();
    const path = await mountFile(job);
    setStatus('Reading the audio track in your browser…', 0.02);
    const rc = await ff.exec(['-hide_banner', '-ss', String(start), '-t', String(end - start), '-i', path, '-vn', '-ac', '1', '-ar', '16000', '-f', 's16le', '/audio.raw']);
    if (rc !== 0) throw new Error('ffmpeg could not read the audio');
    const raw = await ff.readFile('/audio.raw');
    await ff.deleteFile('/audio.raw');
    const pcm = new Int16Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 2));
    const out = new Float32Array(pcm.length);
    for (let i = 0; i < pcm.length; i++) out[i] = pcm[i] / 32768;
    S.timings.audio = (performance.now() - t0) / 1000;
    return out;
  } catch (e) {
    // Fallback: the browser's own decoder (whole file in memory; fine for shorter files)
    const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
    try {
      const buf = await ctx.decodeAudioData(await job.file.arrayBuffer());
      const ch = buf.getChannelData(0);
      S.timings.audio = (performance.now() - t0) / 1000;
      return ch.slice(Math.floor(start * 16000), Math.floor(end * 16000));
    } finally { ctx.close?.(); }
  }
}

function transcribe(audio, model) {
  return new Promise((resolve, reject) => {
    if (!S.worker) S.worker = new Worker(new URL('./asr-worker.js', import.meta.url), { type: 'module' });
    const total = Math.max(1, Math.ceil(audio.length / 16000 / 25));
    const files = new Map();
    const t0 = performance.now();
    let tLoad = null;
    S.worker.onerror = (e) => { e.preventDefault?.(); reject(new Error(e.message || 'the speech engine could not start')); };
    S.worker.onmessage = ({ data: m }) => {
      if (m.type === 'load' && m.file && m.total) {
        files.set(m.file, [m.loaded || 0, m.total]);
        let l = 0, t = 0; for (const [a, b] of files.values()) { l += a; t += b; }
        setStatus(`Downloading the speech model: ${(l / 1e6).toFixed(0)} of ${(t / 1e6).toFixed(0)} MB (cached afterwards)`, t ? l / t : 0);
      } else if (m.type === 'info') setStatus(m.message);
      else if (m.type === 'ready') { tLoad = performance.now(); S.timings.modelLoad = (tLoad - t0) / 1000; S.timings.device = m.device; setStatus(`Transcribing on your ${m.device === 'webgpu' ? 'GPU (WebGPU)' : 'CPU (WASM)'}…`, 0); }
      else if (m.type === 'partial') $('partial').textContent = ($('partial').textContent + m.text).slice(-220);
      else if (m.type === 'chunk') { setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`, m.done / total); $('partial').textContent = ''; }
      else if (m.type === 'done') { S.timings.transcribe = (performance.now() - (tLoad || t0)) / 1000; resolve(m); }
      else if (m.type === 'error') reject(new Error(m.message));
    };
    const copy = audio.slice();
    S.worker.postMessage({ type: 'run', audio: copy, model, device: $('device').value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : $('device').value }, [copy.buffer]);
  });
}

async function aiRerank(job) {
  try {
    const items = job.moments.slice(0, 12).map((m) => ({ id: m.id, text: m.text.slice(0, 1200) }));
    const r = await fetch(`${API}/api/rank`, { method: 'POST', headers: { 'content-type': 'application/json', ...(lic.current().key ? { 'x-licence': lic.current().key } : {}) }, body: JSON.stringify({ items }) });
    const j = await r.json();
    if (!r.ok) return setStatus(j.message || 'AI re-rank is not available right now; the local ranking is used.');
    for (const a of j.items || []) {
      const m = job.moments.find((x) => x.id === a.id);
      if (!m || !Number.isFinite(a.score)) continue;
      m.ai = a.score;
      m.score = Math.round((m.score + a.score * 0.8) * 100) / 100;
      if (a.title) m.title = String(a.title).slice(0, 90);
      if (Array.isArray(a.hashtags) && a.hashtags.length) m.hashtags = a.hashtags.slice(0, 5).map((h) => `#${String(h).replace(/^#/, '').replace(/[^\p{L}\p{N}_]/gu, '')}`);
      m.reasons = [...m.reasons, `AI rated ${a.score}/10`];
    }
    job.moments.sort((a, b) => b.score - a.score);
  } catch { setStatus('AI re-rank is not reachable; the local ranking is used.'); }
}

async function findMoments(job = S.active) {
  if (S.busy || !job) return;
  const start = Math.max(0, parseClock($('from').value) || 0);
  let end = parseClock($('to').value);
  if (!Number.isFinite(end) || end <= start) end = job.duration;
  end = Math.min(end, job.duration);
  if (end - start < 20) return setStatus('Pick a range of at least 20 seconds.');
  job.range = [start, end];
  S.busy = true; $('find').disabled = true; job.status = 'transcribing'; renderQueue();
  const t0 = performance.now();
  try {
    const audio = await extractAudio(job, start, end);
    const res = await transcribe(audio, $('model').value);
    const segs = toSegments(res.chunks, end - start).map((s) => ({ ...s, start: s.start + start, end: s.end + start }));
    job.segments = segs;
    job.words = toWords(segs);
    const env = envelope(audio);
    job.env = { step: env.step, values: env.values, offset: start };
    const shifted = { step: env.step, values: new Float32Array(Math.ceil(start / env.step) + env.values.length) };
    shifted.values.set(env.values, Math.ceil(start / env.step));
    job.moments = pickMoments(segs, shifted, { count: 12, minLen: +$('minlen').value, maxLen: +$('maxlen').value, target: (+$('minlen').value + +$('maxlen').value) / 2 });
    if ($('ai').checked && job.moments.length) { setStatus('Asking the AI to re-rank the transcript moments (text only)…'); await aiRerank(job); }
    job.moments.forEach((m, i) => { m.rank = i + 1; m.pick = i < FREE_CLIPS; });
    job.status = `${job.moments.length} moments`;
    S.timings.find = (performance.now() - t0) / 1000;
    setStatus(job.moments.length ? `Found ${job.moments.length} moments in ${S.timings.find.toFixed(0)} s. The best ${Math.min(FREE_CLIPS, job.moments.length)} are ticked; press Render.` : 'No speech was found in that range.', 1);
    renderMoments(job);
  } catch (e) {
    job.status = 'error';
    setStatus(`Something went wrong: ${e.message || e}. Try the Tiny model, a shorter range, or Chrome/Edge.`);
  } finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); }
}

// ---------------------------------------------------------------- moments UI
function renderMoments(job) {
  const box = $('moments');
  $('momentsbox').hidden = !job?.moments.length;
  if (!job?.moments.length) return box.replaceChildren();
  box.replaceChildren(...job.moments.map((m, i) => {
    const locked = !pro() && i >= FREE_CLIPS;
    if (locked) m.pick = false;
    const card = el('li', { class: `moment${locked ? ' locked' : ''}` },
      el('label', { class: 'pick' }, el('input', { type: 'checkbox', checked: m.pick, disabled: locked, onchange: (e) => { m.pick = e.target.checked; updateRenderBtn(); } }), el('b', {}, `#${i + 1}`)),
      el('div', { class: 'mbody' },
        el('div', { class: 'mhead' },
          el('span', { class: 'time' }, `${clock(m.start)}–${clock(m.end)} · ${Math.round(m.end - m.start)} s`),
          el('span', { class: 'score', title: 'Moment score' }, `score ${m.score.toFixed(1)}`),
          el('button', { type: 'button', class: 'mini', onclick: () => { const v = $('preview'); v.currentTime = m.start; v.play(); } }, '▶ Preview')),
        el('input', { class: 'title', value: m.title, 'aria-label': 'Clip title', oninput: (e) => { m.title = e.target.value; } }),
        el('p', { class: 'excerpt' }, m.text.length > 260 ? `${m.text.slice(0, 260)}…` : m.text),
        el('div', { class: 'chips' }, ...m.reasons.slice(0, 4).map((r) => el('span', { class: 'chip' }, r)), el('span', { class: 'chip tags' }, m.hashtags.join(' '))),
        el('div', { class: 'nudge' }, 'Start ', el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'start', -3) }, '−3 s'), el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'start', 3) }, '+3 s'),
          ' End ', el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'end', -3) }, '−3 s'), el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'end', 3) }, '+3 s')),
        locked ? el('p', { class: 'lockmsg' }, 'Free plan: 3 clips per video. ', el('a', { href: '#pricing' }, 'Unlock every moment with Pro')) : null));
    return card;
  }));
  updateRenderBtn();
}
function nudge(m, k, d) {
  m[k] = Math.max(0, Math.min(S.active.duration, m[k] + d));
  if (m.end - m.start < 5) m[k] -= d;
  renderMoments(S.active);
}
function updateRenderBtn() {
  const n = S.active?.moments.filter((m) => m.pick).length || 0;
  $('render').textContent = n ? `Render ${n} clip${n === 1 ? '' : 's'} (9:16, captions)` : 'Tick at least one moment';
  $('render').disabled = !n || S.busy;
}

// ---------------------------------------------------------------- 2. face tracking + render
async function trackFaces(job, m) {
  if (!job.w) return [];
  // Frames come from ffmpeg (not a <video> element), so any codec ffmpeg reads is tracked, in every browser.
  const det = await faceDetector();
  const ff = await ffmpeg();
  const path = await mountFile(job);
  const dur = m.end - m.start, fps = dur > 50 ? 0.67 : 1;
  const W = 320, H = Math.round((W * job.h) / job.w / 2) * 2;
  const rc = await ff.exec(['-hide_banner', '-ss', m.start.toFixed(2), '-t', dur.toFixed(2), '-i', path, '-an', '-vf', `fps=${fps},scale=${W}:${H}`, '-f', 'rawvideo', '-pix_fmt', 'rgba', '/frames.raw']);
  if (rc !== 0) return [];
  const raw = await ff.readFile('/frames.raw');
  await ff.deleteFile('/frames.raw');
  const size = W * H * 4, out = [];
  for (let i = 0; (i + 1) * size <= raw.byteLength; i++) {
    const img = new ImageData(new Uint8ClampedArray(raw.buffer, raw.byteOffset + i * size, size), W, H);
    const faces = det.detect(img).detections || [];
    // the biggest face is usually the person talking to camera
    const f = faces.sort((a, b) => b.boundingBox.width * b.boundingBox.height - a.boundingBox.width * a.boundingBox.height)[0];
    out.push({ t: i / fps, cx: f ? ((f.boundingBox.originX + f.boundingBox.width / 2) / W) * job.w : null });
  }
  return out;
}

async function renderClip(job, m, idx, total) {
  const ff = await ffmpeg();
  const path = await mountFile(job);
  const res = pro() && $('res').value === '1080' ? 1080 : 720;
  const OW = res, OH = Math.round((res * 16) / 9);
  const kit = activeKit();
  const t0 = performance.now();
  setStatus(`Clip ${idx}/${total}: finding the face…`, 0);
  const samples = await trackFaces(job, m);
  const keys = job.w ? thinKeys(cropPath(samples, job.w, job.h), 24) : [];
  const faceHits = samples.filter((s) => s.cx != null).length;
  const tFace = (performance.now() - t0) / 1000;
  const style = $('style').value;
  const brand = kit ? { base: kit.base, hi: kit.hi, font: kit.font } : null;
  const mark = pro() ? (kit?.handle || null) : MARK;
  await ff.writeFile('/c.ass', buildAss(job.words, m.start, m.end, { style, brand, mark }));
  let vf;
  if (job.w) {
    const { w, h } = cropBox(job.w, job.h);
    vf = `crop=w=${w}:h=${h}:x='${cropExpr(keys)}':y=${Math.round((job.h - h) / 2)},scale=${OW}:${OH}:flags=bicubic,setsar=1,subtitles=filename=/c.ass:fontsdir=/fonts`;
  } else {
    vf = null; // audio-only source: an audiogram (artwork + moving waveform + the same captions)
    await ff.writeFile('/bg.jpg', await audiogramBackground(job, OW, OH, kit));
  }
  const out = `/clip-${idx}.mp4`;
  const t1 = performance.now();
  const onProg = ({ progress }) => setStatus(`Clip ${idx}/${total}: rendering ${OW}×${OH} with captions… ${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`, progress);
  ff.on('progress', onProg);
  const dur = (m.end - m.start).toFixed(2);
  const args = vf
    ? ['-hide_banner', '-ss', m.start.toFixed(2), '-t', dur, '-i', path, '-vf', vf, '-r', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', res === 1080 ? '21' : '23', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out]
    : ['-hide_banner', '-loop', '1', '-framerate', '30', '-i', '/bg.jpg', '-ss', m.start.toFixed(2), '-t', dur, '-i', path, '-filter_complex', waveGraph(OW, OH, kit), '-map', '[v]', '-map', '1:a', '-t', dur, '-r', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', res === 1080 ? '21' : '23', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out];
  const rc = await ff.exec(args);
  ff.off('progress', onProg);
  if (rc !== 0) throw new Error(`the renderer stopped (${(S.lastLog || []).slice(-2).join(' ')})`);
  const data = await ff.readFile(out);
  // poster frame (shown before playback; also where a browser can't play H.264 inline)
  let poster = null;
  try {
    if (await ff.exec(['-hide_banner', '-ss', String(Math.min(3, (m.end - m.start) / 3)), '-i', out, '-frames:v', '1', '-vf', 'scale=270:-2', '-q:v', '4', '/poster.jpg']) === 0) {
      poster = URL.createObjectURL(new Blob([await ff.readFile('/poster.jpg')], { type: 'image/jpeg' }));
      await ff.deleteFile('/poster.jpg');
    }
  } catch {}
  await ff.deleteFile(out);
  const tRender = (performance.now() - t1) / 1000;
  const name = `${baseName(job.name)}-clip-${String(idx).padStart(2, '0')}`;
  const srt = buildSrt(job.words, m.start, m.end);
  const meta = `${m.title}\n\n${m.description}\n\n${m.hashtags.join(' ')}\n\nFrom ${job.name} at ${clock(m.start)}–${clock(m.end)}\n`;
  return { name, poster, mp4: new Blob([data], { type: 'video/mp4' }), bytes: data.byteLength, srt, meta, m, width: OW, height: OH, faceHits, samples: samples.length, keys: keys.length, tFace, tRender, style };
}

// ---------------------------------------------------------------- audiograms (audio-only sources)
// The still layer (blurred artwork, the artwork square, the show name) is drawn once per clip on a canvas; ffmpeg adds
// the moving waveform (showwaves) and burns in the same word-by-word captions.
const AG = { artTop: 0.12, artSize: 0.74, waveY: 0.555, waveH: 0.09 };
function waveGraph(OW, OH, kit) {
  const pad = Math.round(OW * 0.08), ww = Math.round((OW - 2 * pad) / 2) * 2, wh = Math.round((OH * AG.waveH) / 2) * 2;
  const col = (kit?.hi || STYLES[$('style').value]?.hi || '#FFD23F').replace('#', '0x');
  return `[1:a]aformat=channel_layouts=mono,showwaves=s=${ww}x${wh}:mode=cline:rate=30:scale=sqrt:draw=full:colors=${col},format=rgba[wv];`
    + `[0:v]scale=${OW}:${OH},setsar=1,format=rgba[bg];[bg][wv]overlay=${pad}:${Math.round(OH * AG.waveY)}:shortest=1,subtitles=filename=/c.ass:fontsdir=/fonts,format=yuv420p[v]`;
}
async function audiogramBackground(job, OW, OH, kit) {
  const c = document.createElement('canvas'); c.width = OW; c.height = OH;
  const g = c.getContext('2d');
  let img = null;
  try { const art = await job.art; if (art) img = await createImageBitmap(art); } catch { img = null; }
  if (img) {
    // blurred, darkened cover of the artwork (downscale + upscale = a blur that works in every browser)
    const t = document.createElement('canvas'); t.width = 18; t.height = 32;
    const s = Math.max(18 / img.width, 32 / img.height);
    t.getContext('2d').drawImage(img, (18 - img.width * s) / 2, (32 - img.height * s) / 2, img.width * s, img.height * s);
    g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high';
    g.drawImage(t, 0, 0, OW, OH);
    g.fillStyle = 'rgba(10,9,16,.45)'; g.fillRect(0, 0, OW, OH);
  } else {
    const grad = g.createLinearGradient(0, 0, OW, OH);
    grad.addColorStop(0, '#2A1A66'); grad.addColorStop(1, '#0F0E15');
    g.fillStyle = grad; g.fillRect(0, 0, OW, OH);
  }
  const sq = Math.round(OW * AG.artSize), x = (OW - sq) / 2, y = Math.round(OH * AG.artTop), r = Math.round(OW * 0.04);
  g.save(); g.shadowColor = 'rgba(0,0,0,.45)'; g.shadowBlur = OW * 0.04; g.shadowOffsetY = OW * 0.012;
  g.beginPath(); g.roundRect ? g.roundRect(x, y, sq, sq, r) : g.rect(x, y, sq, sq); g.fillStyle = '#1A1824'; g.fill(); g.restore();
  g.save(); g.beginPath(); g.roundRect ? g.roundRect(x, y, sq, sq, r) : g.rect(x, y, sq, sq); g.clip();
  if (img) { const s = Math.max(sq / img.width, sq / img.height); g.drawImage(img, x + (sq - img.width * s) / 2, y + (sq - img.height * s) / 2, img.width * s, img.height * s); }
  else { // no artwork: brand gradient with simple equaliser bars
    const gg = g.createLinearGradient(x, y, x + sq, y + sq); gg.addColorStop(0, '#6C3CF0'); gg.addColorStop(1, '#2A1A66');
    g.fillStyle = gg; g.fillRect(x, y, sq, sq); g.fillStyle = kit?.hi || '#FFD23F';
    const hs = [0.35, 0.6, 0.85, 0.5, 0.7, 0.4, 0.25], bw = sq / 16;
    hs.forEach((h, i) => { const bh = sq * 0.5 * h; g.fillRect(x + sq / 2 + (i - 3) * bw * 1.6 - bw / 2, y + sq / 2 - bh / 2, bw, bh); });
  }
  g.restore();
  // show / episode name above the artwork
  const label = (job.show || baseName(job.name).replace(/[-_]+/g, ' ')).slice(0, 80);
  g.fillStyle = '#FFFFFF'; g.textAlign = 'center'; g.textBaseline = 'middle';
  g.font = `800 ${Math.round(OW * 0.042)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
  const words = label.split(/\s+/), lines = [];
  let cur = '';
  for (const w of words) { const t = cur ? `${cur} ${w}` : w; if (g.measureText(t).width > OW * 0.84 && cur) { lines.push(cur); cur = w; } else cur = t; }
  if (cur) lines.push(cur);
  lines.slice(0, 2).forEach((l, i, a) => g.fillText(i === 1 && lines.length > 2 ? `${l}…` : l, OW / 2, y * 0.55 + (i - (a.length - 1) / 2) * OW * 0.055));
  const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.9));
  img?.close?.();
  return new Uint8Array(await blob.arrayBuffer());
}

async function renderPicked(job = S.active) {
  if (S.busy || !job) return;
  const picked = job.moments.filter((m, i) => m.pick && (pro() || i < FREE_CLIPS));
  if (!picked.length) return;
  S.busy = true; $('render').disabled = true; $('find').disabled = true;
  job.status = 'rendering'; renderQueue();
  const t0 = performance.now();
  try {
    for (let i = 0; i < picked.length; i++) {
      const clip = await renderClip(job, picked[i], i + 1, picked.length);
      job.clips.push(clip);
      renderResults(job);
    }
    S.timings.render = (performance.now() - t0) / 1000;
    job.status = `${job.clips.length} clips`;
    setStatus(`Done: ${picked.length} clip${picked.length === 1 ? '' : 's'} in ${S.timings.render.toFixed(0)} s. Download below.`, 1);
  } catch (e) {
    job.status = 'error';
    setStatus(`Rendering failed: ${e.message || e}. Try 720p or a shorter clip.`);
  } finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); }
}

// ---------------------------------------------------------------- results + export
function dl(blob, name) {
  const a = el('a', { href: URL.createObjectURL(blob), download: name });
  document.body.append(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(a.href), 10000);
}
function renderResults(job) {
  $('resultsbox').hidden = !job?.clips.length;
  if (!job?.clips.length) return $('results').replaceChildren();
  $('results').replaceChildren(...job.clips.map((c) => {
    const url = c.url || (c.url = URL.createObjectURL(c.mp4));
    return el('li', { class: 'clip' },
      el('video', { src: url, poster: c.poster, controls: true, playsinline: true, preload: 'none', width: 216, height: 384 }),
      el('div', { class: 'cbody' },
        el('b', {}, c.m.title),
        el('p', { class: 'muted' }, `${c.width}×${c.height} · ${(c.bytes / 1e6).toFixed(1)} MB · ${Math.round(c.m.end - c.m.start)} s · face found in ${c.faceHits}/${c.samples} checks`),
        el('p', {}, c.m.description),
        el('p', { class: 'tags' }, c.m.hashtags.join(' ')),
        el('div', { class: 'row' },
          el('button', { type: 'button', class: 'btn sm', onclick: () => dl(c.mp4, `${c.name}.mp4`) }, 'MP4'),
          el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.srt], { type: 'application/x-subrip' }), `${c.name}.srt`) }, 'SRT'),
          el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.meta], { type: 'text/plain' }), `${c.name}.txt`) }, 'Title + tags'),
          el('button', { type: 'button', class: 'btn sm ghost', onclick: () => navigator.clipboard?.writeText(`${c.m.title}\n\n${c.m.description}\n\n${c.m.hashtags.join(' ')}`) }, 'Copy text'))));
  }));
}
function zipFor(jobs) {
  const files = [];
  for (const j of jobs) for (const c of j.clips) files.push({ c, prefix: jobs.length > 1 ? `${baseName(j.name)}/` : '' });
  return Promise.all(files.map(async ({ c, prefix }) => [
    { name: `${prefix}${c.name}.mp4`, data: new Uint8Array(await c.mp4.arrayBuffer()) },
    { name: `${prefix}${c.name}.srt`, data: c.srt }, { name: `${prefix}${c.name}.txt`, data: c.meta }])).then((x) => x.flat());
}
async function zipAll(all) {
  const jobs = all ? S.jobs.filter((j) => j.clips.length) : [S.active];
  const files = await zipFor(jobs);
  const blob = new Blob(zipParts(files), { type: 'application/zip' });
  S.lastZip = { bytes: blob.size, files: files.length };
  dl(blob, all ? 'clipwhit-all-clips.zip' : `${baseName(S.active.name)}-clips.zip`);
}

// Batch (Pro): find + render the top N of every queued video, one after another.
async function autoAll() {
  const n = Math.max(1, Math.min(20, +$('autoN').value || 5));
  for (const j of S.jobs) {
    if (j.clips.length) continue;
    select(j);
    await findMoments(j);
    j.moments.forEach((m, i) => { m.pick = i < n; });
    await renderPicked(j);
  }
  setStatus(`Batch done: ${S.jobs.reduce((a, j) => a + j.clips.length, 0)} clips from ${S.jobs.length} videos.`, 1);
}

// ---------------------------------------------------------------- paste a link
// Links are classified in the browser (lib/links.js). Media comes through our Worker's /fetch proxy and becomes a File,
// exactly like a dropped one (mounted read-only into ffmpeg, never stored by us). YouTube, TikTok, Instagram, X, Twitch
// and Facebook are never fetched: their terms forbid it, so we show how to download your own original instead.
const L = { ctrl: null };
const EXT = { 'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/webm': 'webm', 'video/x-matroska': 'mkv', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/x-m4a': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/ogg': 'ogg', 'audio/opus': 'opus', 'audio/flac': 'flac' };
const TYPE = Object.fromEntries(Object.entries(EXT).map(([k, v]) => [v, k]));
const linkHeaders = () => { const k = lic.current().key; return { 'x-clipwhit-app': '1', ...(k && pro() ? { 'x-licence': k } : {}) }; };
const linkMsg = (...nodes) => $('linkmsg').replaceChildren(...nodes.flat().filter(Boolean));
const warnP = (t) => el('p', { class: 'warn' }, t);
async function api(path) {
  const r = await fetch(`${API}${path}`, { headers: linkHeaders() });
  let j = {}; try { j = await r.json(); } catch {}
  return { ok: r.ok, status: r.status, j };
}
const pickFile = () => el('button', { type: 'button', class: 'btn sm', onclick: () => $('file').click() }, 'Choose the downloaded file');

function helper(c) {
  const cap = c.captions ? c.captions.split('Hook Finder') : null;
  return el('div', { class: 'helper', 'data-platform': c.platform },
    el('b', {}, `${c.name} links can't be clipped directly`),
    el('p', {}, c.how),
    cap ? el('p', {}, cap[0], el('a', { href: 'hooks/' }, 'Hook Finder'), cap[1] || '') : null,
    el('p', { class: 'note' }, `${c.name}'s terms don't allow other apps to download its videos, so Clipwhit never fetches them. Only clip videos you own or have permission to use.`),
    el('div', { class: 'row' }, pickFile()));
}

async function loadLink(raw) {
  if (L.ctrl || S.busy) return;
  const c = classify(raw);
  S.lastLink = c;
  $('linkpanel').hidden = false; $('feedbox').hidden = true; $('dlbox').hidden = true; linkMsg();
  if (c.kind === 'invalid') return linkMsg(warnP(c.reason));
  if (c.kind === 'blocked') { linkMsg(helper(c)); return $('linkpanel').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
  if (c.kind === 'apple') return loadApple(c);
  if (c.kind === 'feed') return loadFeed(c.url);
  if (c.kind === 'vimeo') return loadVimeo(c);
  return download(c.url, { name: c.name });
}

async function loadApple(c) {
  linkMsg(el('p', { class: 'note' }, 'Finding the show’s public RSS feed with Apple’s lookup service…'));
  try {
    const { ok, j } = await api(`/apple?id=${c.id}${c.episode ? `&ep=${c.episode}` : ''}`);
    if (!ok) return linkMsg(warnP(j.message || 'Apple’s lookup service did not answer. Paste the show’s RSS feed link instead.'));
    let want = j.episode && !j.episode.missing ? j.episode : null;
    if (c.episode && !want) {
      // Apple refuses episode lookups from our server, but its Lookup API allows browsers (CORS *): ask from here.
      try {
        const r = await fetch(`https://itunes.apple.com/lookup?id=${c.id}&entity=podcastEpisode&limit=200`);
        const e = ((await r.json()).results || []).find((x) => String(x.trackId) === c.episode);
        if (e) want = { title: e.trackName || '', guid: e.episodeGuid || '', url: e.episodeUrl || '' };
      } catch {}
      want = want || { title: '\u0000' }; // still say "not among recent episodes"
    }
    return loadFeed(j.feedUrl, { want, art: j.image, apple: true });
  } catch { linkMsg(warnP('Could not reach the lookup service. Check your connection and try again.')); }
}

async function loadVimeo(c) {
  linkMsg(el('p', { class: 'note' }, 'Checking whether the owner allows downloads…'));
  let j = {};
  try { j = (await api(`/vimeo?id=${c.id}`)).j; } catch {}
  if (j.download) return download(j.download, { name: `${baseName(j.title || `vimeo-${c.id}`)}.mp4` });
  linkMsg(el('div', { class: 'helper', 'data-platform': 'vimeo' },
    el('b', {}, j.reason === 'private' ? 'That Vimeo video is private' : 'Download it from Vimeo first'),
    el('p', {}, 'Open the video on vimeo.com and press Download under the player (it appears when the owner allows downloads), then drop the file here. Your own video? vimeo.com → your video → ⋯ → Download, or switch downloads on in its settings.'),
    el('p', { class: 'note' }, 'Clipwhit only fetches Vimeo files when the owner has made a download public; streaming copies are never saved.'),
    el('div', { class: 'row' }, pickFile())));
}

async function loadFeed(url, opt = {}) {
  linkMsg(el('p', { class: 'note' }, 'Reading the podcast feed…'));
  let res;
  try { res = await api(`/feed?url=${encodeURIComponent(url)}`); } catch { return linkMsg(warnP('Could not reach the feed. Check your connection and try again.')); }
  const { ok, j } = res;
  if (!ok) { if (j.error === 'media') return download(url, {}); return linkMsg(warnP(j.message || 'That feed could not be read.')); }
  S.feed = j;
  $('feedbox').hidden = false;
  const art = j.image || opt.art || '';
  $('feedart').src = art; $('feedart').hidden = !art;
  $('feedtitle').textContent = j.title;
  $('feedby').textContent = [j.author, `${j.episodes.length} recent episode${j.episodes.length === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
  const hit = opt.want ? matchEpisode(j.episodes, opt.want) : -1;
  $('episodes').replaceChildren(...j.episodes.map((ep, i) => el('li', { class: `ep${i === hit ? ' match' : ''}` },
    el('div', {}, el('b', {}, ep.title), el('span', {}, [ep.date ? new Date(ep.date).toDateString().slice(4) : '', ep.duration ? clock(ep.duration) : '', ep.bytes ? mb(ep.bytes) : '', /^video\//.test(ep.type) ? 'video' : 'audio'].filter(Boolean).join(' · '))),
    el('button', { type: 'button', class: 'btn sm', onclick: () => pickEpisode(j, ep, art) }, 'Clip this'))));
  linkMsg(hit >= 0 ? el('p', { class: 'note' }, `Found the episode from your link: “${j.episodes[hit].title}”. Fetching it…`)
    : opt.want ? el('p', { class: 'note' }, 'That episode is not among the feed’s recent episodes; pick one below.') : null);
  if (hit >= 0) { $('episodes').children[hit].scrollIntoView({ block: 'nearest' }); return pickEpisode(j, j.episodes[hit], art); }
}

async function loadArt(url) {
  if (!url) return null;
  try {
    const r = await fetch(`${API}/fetch?kind=image&url=${encodeURIComponent(url)}`, { headers: linkHeaders() });
    return r.ok ? await r.blob() : null;
  } catch { return null; }
}

function pickEpisode(feed, ep, art) {
  const ext = (ep.url.split('?')[0].match(MEDIA_EXT) || [])[1] || EXT[ep.type] || 'mp3';
  const art$ = loadArt(ep.image || art).then((b) => b || (ep.image && art ? loadArt(art) : null));
  return download(ep.url, { name: `${baseName(ep.title)}.${ext}`, art: art$, show: feed.title, type: ep.type });
}

async function download(url, { name = '', art = null, show = '', type = '' } = {}) {
  if (L.ctrl) return;
  const ctrl = new AbortController(); L.ctrl = ctrl;
  $('dlbox').hidden = false; $('linkgo').disabled = true; $('dlbar').style.width = '0%';
  $('dltext').textContent = 'Connecting…';
  const t0 = performance.now();
  let got = 0, cap = 0;
  try {
    const r = await fetch(`${API}/fetch?url=${encodeURIComponent(url)}`, { headers: linkHeaders(), signal: ctrl.signal });
    if (!r.ok) {
      let j = {}; try { j = await r.json(); } catch {}
      if (j.error === 'feed') { L.ctrl = null; $('dlbox').hidden = true; $('linkgo').disabled = false; return loadFeed(url); }
      if (j.error === 'platform') { const c = classify(url); throw Object.assign(new Error(j.message), { node: c.kind === 'blocked' ? helper(c) : null }); }
      throw Object.assign(new Error(j.message || `The download failed (${r.status}).`), { node: j.error === 'too_big' && !pro() ? el('p', {}, el('a', { href: '#pricing' }, 'Pro fetches links up to 2 GB.')) : null });
    }
    const total = +r.headers.get('x-file-size') || +r.headers.get('content-length') || 0;
    cap = +r.headers.get('x-size-cap') || 0;
    const ctype = (r.headers.get('content-type') || '').split(';')[0].trim();
    let fname = name || decodeURIComponent(r.headers.get('x-file-name') || '') || 'linked-video';
    if (!MEDIA_EXT.test(fname)) fname = `${baseName(fname)}.${EXT[ctype] || EXT[type] || 'mp4'}`;
    const label = fname.length > 48 ? `${fname.slice(0, 45)}…` : fname;
    const reader = r.body.getReader();
    const parts = []; let batch = [], batchBytes = 0, last = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      batch.push(value); batchBytes += value.byteLength; got += value.byteLength;
      if (batchBytes >= 64e6) { parts.push(new Blob(batch)); batch = []; batchBytes = 0; } // let the browser page big downloads out of memory
      const now = performance.now();
      if (now - last > 200) {
        last = now;
        const rate = got / Math.max(0.001, (now - t0) / 1000);
        $('dltext').textContent = `Downloading ${label}: ${mb(got)}${total ? ` of ${mb(total)}` : ''} · ${(rate / 1e6).toFixed(1)} MB/s`;
        $('dlbar').style.width = total ? `${Math.min(100, (got / total) * 100).toFixed(1)}%` : `${Math.min(95, (got / (cap || 5e8)) * 100).toFixed(1)}%`;
      }
    }
    if (total && got < total) throw new Error(`The download stopped at ${mb(got)} of ${mb(total)}. Try again, or download the file and drop it here.`);
    parts.push(new Blob(batch));
    const ftype = /^(video|audio)\//.test(ctype) ? ctype : TYPE[(fname.match(MEDIA_EXT) || [])[1]?.toLowerCase()] || type || 'video/mp4';
    const file = new File(parts, fname, { type: ftype });
    const secs = (performance.now() - t0) / 1000;
    S.timings.linkDownload = secs; S.lastDownload = { bytes: got, total, name: fname, type: ftype, seconds: secs };
    $('dlbar').style.width = '100%';
    $('dltext').textContent = `Downloaded ${label}: ${mb(got)} in ${secs.toFixed(0)} s. Opening it…`;
    L.ctrl = null;
    await addFiles([file], { art, show, source: url });
    $('dlbox').hidden = true;
    linkMsg(el('p', { class: 'note' }, `Loaded “${label}” (${mb(got)}) from the link${/^audio\//.test(ftype) ? '. Audio only: your clips will be audiograms with the show artwork and a moving waveform' : ''}. Now press Find the best moments.`));
    $('work').scrollIntoView({ behavior: 'smooth', block: 'start' });
  } catch (e) {
    $('dlbox').hidden = true;
    if (ctrl.signal.aborted) linkMsg(el('p', { class: 'note' }, `Download cancelled${got ? ` at ${mb(got)}` : ''}.`));
    else if (cap && got >= cap) linkMsg(warnP(`That file is bigger than ${mb(cap)}, the limit for links on your plan. Download it and drop it here instead.`));
    else linkMsg(warnP(e.message && !/fetch|network/i.test(e.message) ? e.message : 'The download was interrupted. Check your connection and try again, or download the file and drop it here.'), e.node || null);
  } finally { L.ctrl = null; $('linkgo').disabled = false; }
}

// ---------------------------------------------------------------- wiring
function wire() {
  const drop = $('drop');
  drop.addEventListener('click', () => $('file').click());
  drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('file').click(); } });
  $('file').addEventListener('change', () => addFiles($('file').files));
  ['dragenter', 'dragover'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.add('over'); }));
  ['dragleave', 'drop'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.remove('over'); }));
  drop.addEventListener('drop', (e) => addFiles(e.dataTransfer.files));
  $('sample').addEventListener('click', async () => {
    setStatus('Loading the 74-second sample episode…');
    const r = await fetch(new URL('./sample/growth-show-sample.mp4', import.meta.url));
    await addFiles([new File([await r.blob()], 'growth-show-sample.mp4', { type: 'video/mp4' })]);
  });
  $('linkform').addEventListener('submit', (e) => { e.preventDefault(); loadLink($('link').value); });
  $('link').addEventListener('paste', () => setTimeout(() => { if (/^\s*(https?:\/\/|www\.)\S+\s*$/i.test($('link').value)) loadLink($('link').value); }, 0));
  $('dlcancel').addEventListener('click', () => L.ctrl?.abort());
  $('find').addEventListener('click', () => findMoments());
  $('render').addEventListener('click', () => renderPicked());
  $('zip').addEventListener('click', () => zipAll(false));
  $('zipall').addEventListener('click', () => zipAll(true));
  $('auto').addEventListener('click', () => autoAll());
  $('keyform').addEventListener('submit', (e) => { e.preventDefault(); applyKey($('key').value, true); });
  $('kit').addEventListener('change', () => { store.set('clipwhit.kit', $('kit').value); renderKits(); });
  for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).addEventListener('change', saveKit);
  $('kitnew').addEventListener('click', () => {
    const list = kits(); const id = `k${Date.now()}`;
    list.push({ id, name: `Client ${list.length + 1}`, base: '#FFFFFF', hi: '#3DDC97', font: 'Anton', handle: '' });
    store.set('clipwhit.kits', list); store.set('clipwhit.kit', id); renderKits();
  });
  $('style').replaceChildren(...Object.entries(STYLES).map(([k, s]) => el('option', { value: k }, `${s.label}${s.free ? '' : ' (Pro)'}`)));
}

async function init() {
  wire();
  const hash = new URLSearchParams(location.hash.slice(1));
  const fromHash = hash.get('key');
  if (fromHash) history.replaceState(null, '', location.pathname + location.search);
  const shared = new URLSearchParams(location.search).get('link') || hash.get('link');
  if (shared) { $('link').value = shared; setTimeout(() => loadLink(shared), 0); }
  const cur = lic.current();
  S.tier = cur.tier;
  renderTier();
  const key = fromHash || cur.key;
  if (key) { $('key').value = key; await applyKey(key, !!fromHash); }
  const cap = 'gpu' in navigator ? 'WebGPU available: transcription uses your graphics card.' : 'No WebGPU in this browser: transcription runs on the CPU (slower; Chrome or Edge on desktop is fastest).';
  $('caps').textContent = cap;
}
init();