Spaces:
Running
Running
Download app.js from CyberMax-tools/clipwhit: direct link, hf CLI and curl.
- Browser
- Download file 46.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/clipwhit/resolve/main/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/clipwhit/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/clipwhit/resolve/main/app.js
46.8 kB
| // 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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(); | |