Spaces:
Running
Running
Deploy clipwhit
Browse files- .gitattributes +6 -0
- README.md +37 -5
- app.js +534 -0
- asr-worker.js +52 -0
- brand/cover.png +3 -0
- brand/icon.png +0 -0
- brand/icon.svg +1 -0
- brand/image-1-output.png +3 -0
- brand/image-2-inside.png +0 -0
- brand/image-3-savings.png +0 -0
- brand/phone-clip.png +3 -0
- brand/shot-clips.png +3 -0
- brand/shot-moments.png +3 -0
- hooks/index.html +67 -0
- index.html +166 -17
- lib/captions.js +103 -0
- lib/crop.js +69 -0
- lib/licence.js +34 -0
- lib/scoring.js +218 -0
- lib/zip.js +41 -0
- sample/growth-show-sample.mp4 +3 -0
- styles.css +67 -0
- vendor/ffmpeg/NOTICE.txt +3 -0
- vendor/ffmpeg/classes.js +306 -0
- vendor/ffmpeg/const.js +23 -0
- vendor/ffmpeg/errors.js +4 -0
- vendor/ffmpeg/index.js +2 -0
- vendor/ffmpeg/types.js +9 -0
- vendor/ffmpeg/utils.js +7 -0
- vendor/ffmpeg/worker.js +161 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
brand/cover.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
brand/image-1-output.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
brand/phone-clip.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
brand/shot-clips.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
brand/shot-moments.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
sample/growth-show-sample.mp4 filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,10 +1,42 @@
|
|
| 1 |
---
|
| 2 |
-
title: Clipwhit
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Clipwhit — Podcast to Captioned Shorts
|
| 3 |
+
emoji: ✂️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: other
|
| 10 |
+
short_description: Podcast to 9:16 captioned shorts in your browser, no upload
|
| 11 |
+
tags:
|
| 12 |
+
- video
|
| 13 |
+
- shorts
|
| 14 |
+
- podcast
|
| 15 |
+
- captions
|
| 16 |
+
- whisper
|
| 17 |
+
- transformers.js
|
| 18 |
+
- mediapipe
|
| 19 |
+
- ffmpeg
|
| 20 |
+
- webgpu
|
| 21 |
+
models:
|
| 22 |
+
- onnx-community/whisper-tiny.en
|
| 23 |
+
- onnx-community/whisper-base.en
|
| 24 |
+
- onnx-community/whisper-base
|
| 25 |
---
|
| 26 |
|
| 27 |
+
# Clipwhit: podcast to captioned shorts, in your browser
|
| 28 |
+
|
| 29 |
+
Drop a long podcast, webinar or stream. Clipwhit:
|
| 30 |
+
|
| 31 |
+
1. transcribes it with Whisper **on your device** (transformers.js, WebGPU with a CPU fallback);
|
| 32 |
+
2. scores every 20–55 s stretch for a scroll-stopping first line, a finished payoff, pace, energy and clean cuts;
|
| 33 |
+
3. crops each moment to 9:16 around the speaker's face (MediaPipe face detector);
|
| 34 |
+
4. burns in animated word-by-word captions and renders the MP4 with ffmpeg.wasm;
|
| 35 |
+
5. writes a title, a description and hashtags per clip. Export MP4, SRT, or everything as one ZIP.
|
| 36 |
+
|
| 37 |
+
**Your video is never uploaded.** Free: 3 clips per video at 720p with a small corner mark. Pro ($12/month, $120/year or $39 lifetime at launch) unlocks unlimited clips, 1080p, no mark, every caption style, a brand kit and a batch queue. Agency ($49/month) adds a brand kit per client and bulk export.
|
| 38 |
+
|
| 39 |
+
- Full app, pricing and FAQ: https://clipwhit.cybermax-tools.workers.dev/
|
| 40 |
+
- Free transcript-only tool: [Podcast Hook Finder](https://clipwhit.cybermax-tools.workers.dev/hooks/)
|
| 41 |
+
|
| 42 |
+
Built by CyberMax. © 2026 CyberMax. All rights reserved. Whisper weights Apache-2.0 (onnx-community builds); transformers.js Apache-2.0, MediaPipe Apache-2.0 and @ffmpeg/core loaded from the jsDelivr CDN; @ffmpeg/ffmpeg wrapper MIT (vendor/ffmpeg/NOTICE.txt); fonts Anton and Inter (SIL OFL). The sample episode uses a public-domain generated face (Wikimedia Commons "Woman 1.jpg") and synthetic speech.
|
app.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit app: long video -> Whisper transcript -> ranked moments -> 9:16 face-tracked, captioned shorts.
|
| 2 |
+
// Everything runs in this browser tab (transformers.js, MediaPipe, ffmpeg.wasm). The only optional network call with
|
| 3 |
+
// your content is the AI re-rank, which sends transcript text (never audio or video) and is off unless you tick it.
|
| 4 |
+
// © 2026 CyberMax. All rights reserved.
|
| 5 |
+
import { FFmpeg } from './vendor/ffmpeg/index.js';
|
| 6 |
+
import { toSegments, toWords, envelope, pickMoments, clock } from './lib/scoring.js';
|
| 7 |
+
import { STYLES, buildAss, buildSrt } from './lib/captions.js';
|
| 8 |
+
import { cropBox, cropPath, cropExpr, thinKeys } from './lib/crop.js';
|
| 9 |
+
import { zipParts } from './lib/zip.js';
|
| 10 |
+
import * as lic from './lib/licence.js';
|
| 11 |
+
|
| 12 |
+
const API = /\.workers\.dev$/.test(location.hostname) || location.hostname === 'localhost' && location.port === '8787'
|
| 13 |
+
? location.origin : 'https://clipwhit.cybermax-tools.workers.dev';
|
| 14 |
+
const CORE = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm';
|
| 15 |
+
const MP = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21';
|
| 16 |
+
const FACE_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite';
|
| 17 |
+
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' };
|
| 18 |
+
const FREE_CLIPS = 3;
|
| 19 |
+
const MARK = 'Made with Clipwhit';
|
| 20 |
+
|
| 21 |
+
const $ = (id) => document.getElementById(id);
|
| 22 |
+
const el = (tag, attrs = {}, ...kids) => {
|
| 23 |
+
const n = document.createElement(tag);
|
| 24 |
+
for (const [k, v] of Object.entries(attrs)) {
|
| 25 |
+
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);
|
| 26 |
+
}
|
| 27 |
+
for (const c of kids.flat()) if (c != null) n.append(c.nodeType ? c : document.createTextNode(String(c)));
|
| 28 |
+
return n;
|
| 29 |
+
};
|
| 30 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
| 31 |
+
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 {} } };
|
| 32 |
+
const baseName = (n) => String(n || 'video').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').slice(0, 60) || 'video';
|
| 33 |
+
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); };
|
| 34 |
+
|
| 35 |
+
// ---------------------------------------------------------------- state
|
| 36 |
+
const S = {
|
| 37 |
+
tier: 'free', jobs: [], active: null, busy: false, ff: null, ffLoaded: false, detector: null, worker: null,
|
| 38 |
+
timings: {}, // measured per stage, shown in the status line and read by the test
|
| 39 |
+
};
|
| 40 |
+
window.clipwhit = S; // test/automation hook
|
| 41 |
+
|
| 42 |
+
function setStatus(t, frac) {
|
| 43 |
+
$('status').textContent = t;
|
| 44 |
+
if (frac != null) $('bar').style.width = `${Math.round(Math.max(0, Math.min(1, frac)) * 100)}%`;
|
| 45 |
+
}
|
| 46 |
+
const pro = () => S.tier === 'pro' || S.tier === 'agency';
|
| 47 |
+
|
| 48 |
+
// ---------------------------------------------------------------- licence
|
| 49 |
+
function renderTier() {
|
| 50 |
+
const t = S.tier;
|
| 51 |
+
$('tier').textContent = t === 'agency' ? 'Agency' : t === 'pro' ? 'Pro' : 'Free';
|
| 52 |
+
$('tier').dataset.tier = t;
|
| 53 |
+
document.body.dataset.tier = t;
|
| 54 |
+
$('res').querySelector('option[value="1080"]').disabled = !pro();
|
| 55 |
+
if (!pro()) $('res').value = '720';
|
| 56 |
+
for (const o of $('style').options) o.disabled = !pro() && !STYLES[o.value].free;
|
| 57 |
+
if (!pro() && !STYLES[$('style').value].free) $('style').value = 'pop';
|
| 58 |
+
$('kitbox').hidden = !pro();
|
| 59 |
+
$('kitnew').hidden = S.tier !== 'agency';
|
| 60 |
+
$('upsell').hidden = pro();
|
| 61 |
+
renderKits();
|
| 62 |
+
if (S.active) renderMoments(S.active);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function applyKey(key, force = false) {
|
| 66 |
+
const r = await lic.verify(API, key, { force });
|
| 67 |
+
if (r.valid) { S.tier = r.tier === 'agency' ? 'agency' : 'pro'; $('keymsg').textContent = `${S.tier === 'agency' ? 'Agency' : 'Pro'} unlocked on this browser.`; }
|
| 68 |
+
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.'; }
|
| 69 |
+
renderTier();
|
| 70 |
+
return r;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ---------------------------------------------------------------- brand kits (Pro: one, Agency: many)
|
| 74 |
+
const kits = () => store.get('clipwhit.kits', [{ id: 'k1', name: 'My brand', base: '#FFFFFF', hi: '#FFD23F', font: 'Anton', handle: '' }]);
|
| 75 |
+
function renderKits() {
|
| 76 |
+
const list = kits(), sel = $('kit');
|
| 77 |
+
const cur = store.get('clipwhit.kit', list[0].id);
|
| 78 |
+
sel.replaceChildren(el('option', { value: '' }, 'No brand kit (style colours)'), ...list.map((k) => el('option', { value: k.id, selected: k.id === cur }, k.name)));
|
| 79 |
+
const k = list.find((x) => x.id === sel.value);
|
| 80 |
+
for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).value = k ? k[f] : '';
|
| 81 |
+
$('kitfields').hidden = !k;
|
| 82 |
+
}
|
| 83 |
+
function saveKit() {
|
| 84 |
+
const list = kits(), id = $('kit').value, k = list.find((x) => x.id === id);
|
| 85 |
+
if (!k) return;
|
| 86 |
+
for (const f of ['name', 'base', 'hi', 'font', 'handle']) k[f] = $(`kit-${f}`).value.slice(0, 40);
|
| 87 |
+
store.set('clipwhit.kits', list);
|
| 88 |
+
store.set('clipwhit.kit', id);
|
| 89 |
+
$('kit').selectedOptions[0].textContent = k.name;
|
| 90 |
+
}
|
| 91 |
+
function activeKit() {
|
| 92 |
+
if (!pro()) return null;
|
| 93 |
+
return kits().find((x) => x.id === $('kit').value) || null;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---------------------------------------------------------------- engines (lazy)
|
| 97 |
+
async function blobURL(url, type) {
|
| 98 |
+
const r = await fetch(url);
|
| 99 |
+
if (!r.ok) throw new Error(`download failed (${r.status})`);
|
| 100 |
+
return URL.createObjectURL(new Blob([await r.arrayBuffer()], { type }));
|
| 101 |
+
}
|
| 102 |
+
async function ffmpeg() {
|
| 103 |
+
if (S.ffLoaded) return S.ff;
|
| 104 |
+
const t0 = performance.now();
|
| 105 |
+
setStatus('Loading the video engine (ffmpeg.wasm, about 31 MB, cached after the first time)…');
|
| 106 |
+
S.ff = new FFmpeg();
|
| 107 |
+
S.ff.on('log', ({ message }) => { S.lastLog = (S.lastLog || []).slice(-40); S.lastLog.push(message); });
|
| 108 |
+
await S.ff.load({ coreURL: await blobURL(`${CORE}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await blobURL(`${CORE}/ffmpeg-core.wasm`, 'application/wasm') });
|
| 109 |
+
await S.ff.createDir('/fonts');
|
| 110 |
+
for (const [name, url] of Object.entries(FONTS)) {
|
| 111 |
+
const r = await fetch(url);
|
| 112 |
+
await S.ff.writeFile(`/fonts/${name}.ttf`, new Uint8Array(await r.arrayBuffer()));
|
| 113 |
+
}
|
| 114 |
+
await S.ff.createDir('/in');
|
| 115 |
+
S.ffLoaded = true;
|
| 116 |
+
S.timings.engineLoad = (performance.now() - t0) / 1000;
|
| 117 |
+
return S.ff;
|
| 118 |
+
}
|
| 119 |
+
async function mountFile(job) {
|
| 120 |
+
const ff = await ffmpeg();
|
| 121 |
+
if (S.mounted === job.id) return `/in/${job.file.name}`;
|
| 122 |
+
if (S.mounted) { try { await ff.unmount('/in'); } catch {} }
|
| 123 |
+
await ff.mount('WORKERFS', { files: [job.file] }, '/in');
|
| 124 |
+
S.mounted = job.id;
|
| 125 |
+
return `/in/${job.file.name}`;
|
| 126 |
+
}
|
| 127 |
+
async function faceDetector() {
|
| 128 |
+
if (S.detector) return S.detector;
|
| 129 |
+
const { FaceDetector, FilesetResolver } = await import(`${MP}/vision_bundle.mjs`);
|
| 130 |
+
const fs = await FilesetResolver.forVisionTasks(`${MP}/wasm`);
|
| 131 |
+
S.detector = await FaceDetector.createFromOptions(fs, { baseOptions: { modelAssetPath: FACE_MODEL, delegate: 'CPU' }, runningMode: 'IMAGE', minDetectionConfidence: 0.5 });
|
| 132 |
+
return S.detector;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// ---------------------------------------------------------------- jobs
|
| 136 |
+
let nextId = 1;
|
| 137 |
+
async function addFiles(files) {
|
| 138 |
+
const list = [...files].filter((f) => /^(video|audio)\//.test(f.type) || /\.(mp4|mov|m4v|webm|mkv|mp3|m4a|wav)$/i.test(f.name));
|
| 139 |
+
if (!list.length) return setStatus('That is not a video or audio file. Try MP4, MOV, WebM, MKV, MP3 or M4A.');
|
| 140 |
+
if (!pro() && (list.length > 1 || S.jobs.length)) {
|
| 141 |
+
$('batchmsg').hidden = false;
|
| 142 |
+
S.jobs = [];
|
| 143 |
+
}
|
| 144 |
+
for (const file of (pro() ? list : list.slice(0, 1))) {
|
| 145 |
+
const job = { id: nextId++, file, name: file.name, url: URL.createObjectURL(file), status: 'ready', moments: [], clips: [] };
|
| 146 |
+
await probe(job);
|
| 147 |
+
S.jobs.push(job);
|
| 148 |
+
}
|
| 149 |
+
select(S.jobs[S.jobs.length - (pro() ? list.length : 1)]);
|
| 150 |
+
renderQueue();
|
| 151 |
+
}
|
| 152 |
+
function probe(job) {
|
| 153 |
+
return new Promise((res) => {
|
| 154 |
+
const v = el('video', { preload: 'metadata', muted: true, src: job.url });
|
| 155 |
+
const timer = setTimeout(() => fallback(), 4000);
|
| 156 |
+
const fallback = async () => {
|
| 157 |
+
clearTimeout(timer); v.onloadedmetadata = v.onerror = null;
|
| 158 |
+
// The browser can't read this codec/container: ask ffmpeg.wasm instead (loads the engine once).
|
| 159 |
+
try {
|
| 160 |
+
const ff = await ffmpeg();
|
| 161 |
+
const path = await mountFile(job);
|
| 162 |
+
S.lastLog = [];
|
| 163 |
+
await ff.exec(['-hide_banner', '-i', path]);
|
| 164 |
+
const log = S.lastLog.join('\n');
|
| 165 |
+
const d = log.match(/Duration: (\d+):(\d+):([\d.]+)/);
|
| 166 |
+
const vs = log.match(/Video:.*?(\d{2,5})x(\d{2,5})/);
|
| 167 |
+
if (!d) throw new Error('no duration');
|
| 168 |
+
job.duration = +d[1] * 3600 + +d[2] * 60 + +d[3];
|
| 169 |
+
if (vs) { job.w = +vs[1]; job.h = +vs[2]; }
|
| 170 |
+
job.nativePreview = false;
|
| 171 |
+
} catch { job.duration = 0; job.error = 'That file could not be read. Convert it to MP4 (H.264) first.'; }
|
| 172 |
+
res();
|
| 173 |
+
};
|
| 174 |
+
v.onloadedmetadata = () => {
|
| 175 |
+
if (!v.duration || !Number.isFinite(v.duration) || (v.videoWidth === 0 && /^video\//.test(job.file.type))) return fallback();
|
| 176 |
+
clearTimeout(timer); job.duration = v.duration; job.w = v.videoWidth; job.h = v.videoHeight; job.nativePreview = true; res();
|
| 177 |
+
};
|
| 178 |
+
v.onerror = () => fallback();
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
function select(job) {
|
| 182 |
+
S.active = job;
|
| 183 |
+
$('work').hidden = false;
|
| 184 |
+
$('preview').src = job.url;
|
| 185 |
+
const d = job.duration || 0;
|
| 186 |
+
$('fileinfo').textContent = `${job.name} · ${clock(d)}${job.w ? ` · ${job.w}×${job.h}` : ' · audio only'}`;
|
| 187 |
+
$('from').value = clock(job.range?.[0] ?? 0);
|
| 188 |
+
$('to').value = clock(job.range?.[1] ?? d);
|
| 189 |
+
$('find').disabled = S.busy || !!job.error;
|
| 190 |
+
if (job.error) setStatus(job.error);
|
| 191 |
+
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.');
|
| 192 |
+
renderMoments(job);
|
| 193 |
+
renderResults(job);
|
| 194 |
+
}
|
| 195 |
+
function renderQueue() {
|
| 196 |
+
$('queue').hidden = S.jobs.length < 2;
|
| 197 |
+
$('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}`)));
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// ---------------------------------------------------------------- 1. audio + transcript
|
| 201 |
+
async function extractAudio(job, start, end) {
|
| 202 |
+
const t0 = performance.now();
|
| 203 |
+
try {
|
| 204 |
+
const ff = await ffmpeg();
|
| 205 |
+
const path = await mountFile(job);
|
| 206 |
+
setStatus('Reading the audio track in your browser…', 0.02);
|
| 207 |
+
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']);
|
| 208 |
+
if (rc !== 0) throw new Error('ffmpeg could not read the audio');
|
| 209 |
+
const raw = await ff.readFile('/audio.raw');
|
| 210 |
+
await ff.deleteFile('/audio.raw');
|
| 211 |
+
const pcm = new Int16Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 2));
|
| 212 |
+
const out = new Float32Array(pcm.length);
|
| 213 |
+
for (let i = 0; i < pcm.length; i++) out[i] = pcm[i] / 32768;
|
| 214 |
+
S.timings.audio = (performance.now() - t0) / 1000;
|
| 215 |
+
return out;
|
| 216 |
+
} catch (e) {
|
| 217 |
+
// Fallback: the browser's own decoder (whole file in memory; fine for shorter files)
|
| 218 |
+
const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
| 219 |
+
try {
|
| 220 |
+
const buf = await ctx.decodeAudioData(await job.file.arrayBuffer());
|
| 221 |
+
const ch = buf.getChannelData(0);
|
| 222 |
+
S.timings.audio = (performance.now() - t0) / 1000;
|
| 223 |
+
return ch.slice(Math.floor(start * 16000), Math.floor(end * 16000));
|
| 224 |
+
} finally { ctx.close?.(); }
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function transcribe(audio, model) {
|
| 229 |
+
return new Promise((resolve, reject) => {
|
| 230 |
+
if (!S.worker) S.worker = new Worker(new URL('./asr-worker.js', import.meta.url), { type: 'module' });
|
| 231 |
+
const total = Math.max(1, Math.ceil(audio.length / 16000 / 25));
|
| 232 |
+
const files = new Map();
|
| 233 |
+
const t0 = performance.now();
|
| 234 |
+
let tLoad = null;
|
| 235 |
+
S.worker.onerror = (e) => { e.preventDefault?.(); reject(new Error(e.message || 'the speech engine could not start')); };
|
| 236 |
+
S.worker.onmessage = ({ data: m }) => {
|
| 237 |
+
if (m.type === 'load' && m.file && m.total) {
|
| 238 |
+
files.set(m.file, [m.loaded || 0, m.total]);
|
| 239 |
+
let l = 0, t = 0; for (const [a, b] of files.values()) { l += a; t += b; }
|
| 240 |
+
setStatus(`Downloading the speech model: ${(l / 1e6).toFixed(0)} of ${(t / 1e6).toFixed(0)} MB (cached afterwards)`, t ? l / t : 0);
|
| 241 |
+
} else if (m.type === 'info') setStatus(m.message);
|
| 242 |
+
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); }
|
| 243 |
+
else if (m.type === 'partial') $('partial').textContent = ($('partial').textContent + m.text).slice(-220);
|
| 244 |
+
else if (m.type === 'chunk') { setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`, m.done / total); $('partial').textContent = ''; }
|
| 245 |
+
else if (m.type === 'done') { S.timings.transcribe = (performance.now() - (tLoad || t0)) / 1000; resolve(m); }
|
| 246 |
+
else if (m.type === 'error') reject(new Error(m.message));
|
| 247 |
+
};
|
| 248 |
+
const copy = audio.slice();
|
| 249 |
+
S.worker.postMessage({ type: 'run', audio: copy, model, device: $('device').value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : $('device').value }, [copy.buffer]);
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
async function aiRerank(job) {
|
| 254 |
+
try {
|
| 255 |
+
const items = job.moments.slice(0, 12).map((m) => ({ id: m.id, text: m.text.slice(0, 1200) }));
|
| 256 |
+
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 }) });
|
| 257 |
+
const j = await r.json();
|
| 258 |
+
if (!r.ok) return setStatus(j.message || 'AI re-rank is not available right now; the local ranking is used.');
|
| 259 |
+
for (const a of j.items || []) {
|
| 260 |
+
const m = job.moments.find((x) => x.id === a.id);
|
| 261 |
+
if (!m || !Number.isFinite(a.score)) continue;
|
| 262 |
+
m.ai = a.score;
|
| 263 |
+
m.score = Math.round((m.score + a.score * 0.8) * 100) / 100;
|
| 264 |
+
if (a.title) m.title = String(a.title).slice(0, 90);
|
| 265 |
+
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, '')}`);
|
| 266 |
+
m.reasons = [...m.reasons, `AI rated ${a.score}/10`];
|
| 267 |
+
}
|
| 268 |
+
job.moments.sort((a, b) => b.score - a.score);
|
| 269 |
+
} catch { setStatus('AI re-rank is not reachable; the local ranking is used.'); }
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
async function findMoments(job = S.active) {
|
| 273 |
+
if (S.busy || !job) return;
|
| 274 |
+
const start = Math.max(0, parseClock($('from').value) || 0);
|
| 275 |
+
let end = parseClock($('to').value);
|
| 276 |
+
if (!Number.isFinite(end) || end <= start) end = job.duration;
|
| 277 |
+
end = Math.min(end, job.duration);
|
| 278 |
+
if (end - start < 20) return setStatus('Pick a range of at least 20 seconds.');
|
| 279 |
+
job.range = [start, end];
|
| 280 |
+
S.busy = true; $('find').disabled = true; job.status = 'transcribing'; renderQueue();
|
| 281 |
+
const t0 = performance.now();
|
| 282 |
+
try {
|
| 283 |
+
const audio = await extractAudio(job, start, end);
|
| 284 |
+
const res = await transcribe(audio, $('model').value);
|
| 285 |
+
const segs = toSegments(res.chunks, end - start).map((s) => ({ ...s, start: s.start + start, end: s.end + start }));
|
| 286 |
+
job.segments = segs;
|
| 287 |
+
job.words = toWords(segs);
|
| 288 |
+
const env = envelope(audio);
|
| 289 |
+
job.env = { step: env.step, values: env.values, offset: start };
|
| 290 |
+
const shifted = { step: env.step, values: new Float32Array(Math.ceil(start / env.step) + env.values.length) };
|
| 291 |
+
shifted.values.set(env.values, Math.ceil(start / env.step));
|
| 292 |
+
job.moments = pickMoments(segs, shifted, { count: 12, minLen: +$('minlen').value, maxLen: +$('maxlen').value, target: (+$('minlen').value + +$('maxlen').value) / 2 });
|
| 293 |
+
if ($('ai').checked && job.moments.length) { setStatus('Asking the AI to re-rank the transcript moments (text only)…'); await aiRerank(job); }
|
| 294 |
+
job.moments.forEach((m, i) => { m.rank = i + 1; m.pick = i < FREE_CLIPS; });
|
| 295 |
+
job.status = `${job.moments.length} moments`;
|
| 296 |
+
S.timings.find = (performance.now() - t0) / 1000;
|
| 297 |
+
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);
|
| 298 |
+
renderMoments(job);
|
| 299 |
+
} catch (e) {
|
| 300 |
+
job.status = 'error';
|
| 301 |
+
setStatus(`Something went wrong: ${e.message || e}. Try the Tiny model, a shorter range, or Chrome/Edge.`);
|
| 302 |
+
} finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); }
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// ---------------------------------------------------------------- moments UI
|
| 306 |
+
function renderMoments(job) {
|
| 307 |
+
const box = $('moments');
|
| 308 |
+
$('momentsbox').hidden = !job?.moments.length;
|
| 309 |
+
if (!job?.moments.length) return box.replaceChildren();
|
| 310 |
+
box.replaceChildren(...job.moments.map((m, i) => {
|
| 311 |
+
const locked = !pro() && i >= FREE_CLIPS;
|
| 312 |
+
if (locked) m.pick = false;
|
| 313 |
+
const card = el('li', { class: `moment${locked ? ' locked' : ''}` },
|
| 314 |
+
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}`)),
|
| 315 |
+
el('div', { class: 'mbody' },
|
| 316 |
+
el('div', { class: 'mhead' },
|
| 317 |
+
el('span', { class: 'time' }, `${clock(m.start)}–${clock(m.end)} · ${Math.round(m.end - m.start)} s`),
|
| 318 |
+
el('span', { class: 'score', title: 'Moment score' }, `score ${m.score.toFixed(1)}`),
|
| 319 |
+
el('button', { type: 'button', class: 'mini', onclick: () => { const v = $('preview'); v.currentTime = m.start; v.play(); } }, '▶ Preview')),
|
| 320 |
+
el('input', { class: 'title', value: m.title, 'aria-label': 'Clip title', oninput: (e) => { m.title = e.target.value; } }),
|
| 321 |
+
el('p', { class: 'excerpt' }, m.text.length > 260 ? `${m.text.slice(0, 260)}…` : m.text),
|
| 322 |
+
el('div', { class: 'chips' }, ...m.reasons.slice(0, 4).map((r) => el('span', { class: 'chip' }, r)), el('span', { class: 'chip tags' }, m.hashtags.join(' '))),
|
| 323 |
+
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'),
|
| 324 |
+
' 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')),
|
| 325 |
+
locked ? el('p', { class: 'lockmsg' }, 'Free plan: 3 clips per video. ', el('a', { href: '#pricing' }, 'Unlock every moment with Pro')) : null));
|
| 326 |
+
return card;
|
| 327 |
+
}));
|
| 328 |
+
updateRenderBtn();
|
| 329 |
+
}
|
| 330 |
+
function nudge(m, k, d) {
|
| 331 |
+
m[k] = Math.max(0, Math.min(S.active.duration, m[k] + d));
|
| 332 |
+
if (m.end - m.start < 5) m[k] -= d;
|
| 333 |
+
renderMoments(S.active);
|
| 334 |
+
}
|
| 335 |
+
function updateRenderBtn() {
|
| 336 |
+
const n = S.active?.moments.filter((m) => m.pick).length || 0;
|
| 337 |
+
$('render').textContent = n ? `Render ${n} clip${n === 1 ? '' : 's'} (9:16, captions)` : 'Tick at least one moment';
|
| 338 |
+
$('render').disabled = !n || S.busy;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// ---------------------------------------------------------------- 2. face tracking + render
|
| 342 |
+
async function trackFaces(job, m) {
|
| 343 |
+
if (!job.w) return [];
|
| 344 |
+
// Frames come from ffmpeg (not a <video> element), so any codec ffmpeg reads is tracked, in every browser.
|
| 345 |
+
const det = await faceDetector();
|
| 346 |
+
const ff = await ffmpeg();
|
| 347 |
+
const path = await mountFile(job);
|
| 348 |
+
const dur = m.end - m.start, fps = dur > 50 ? 0.67 : 1;
|
| 349 |
+
const W = 320, H = Math.round((W * job.h) / job.w / 2) * 2;
|
| 350 |
+
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']);
|
| 351 |
+
if (rc !== 0) return [];
|
| 352 |
+
const raw = await ff.readFile('/frames.raw');
|
| 353 |
+
await ff.deleteFile('/frames.raw');
|
| 354 |
+
const size = W * H * 4, out = [];
|
| 355 |
+
for (let i = 0; (i + 1) * size <= raw.byteLength; i++) {
|
| 356 |
+
const img = new ImageData(new Uint8ClampedArray(raw.buffer, raw.byteOffset + i * size, size), W, H);
|
| 357 |
+
const faces = det.detect(img).detections || [];
|
| 358 |
+
// the biggest face is usually the person talking to camera
|
| 359 |
+
const f = faces.sort((a, b) => b.boundingBox.width * b.boundingBox.height - a.boundingBox.width * a.boundingBox.height)[0];
|
| 360 |
+
out.push({ t: i / fps, cx: f ? ((f.boundingBox.originX + f.boundingBox.width / 2) / W) * job.w : null });
|
| 361 |
+
}
|
| 362 |
+
return out;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
async function renderClip(job, m, idx, total) {
|
| 366 |
+
const ff = await ffmpeg();
|
| 367 |
+
const path = await mountFile(job);
|
| 368 |
+
const res = pro() && $('res').value === '1080' ? 1080 : 720;
|
| 369 |
+
const OW = res, OH = Math.round((res * 16) / 9);
|
| 370 |
+
const kit = activeKit();
|
| 371 |
+
const t0 = performance.now();
|
| 372 |
+
setStatus(`Clip ${idx}/${total}: finding the face…`, 0);
|
| 373 |
+
const samples = await trackFaces(job, m);
|
| 374 |
+
const keys = job.w ? thinKeys(cropPath(samples, job.w, job.h), 24) : [];
|
| 375 |
+
const faceHits = samples.filter((s) => s.cx != null).length;
|
| 376 |
+
const tFace = (performance.now() - t0) / 1000;
|
| 377 |
+
const style = $('style').value;
|
| 378 |
+
const brand = kit ? { base: kit.base, hi: kit.hi, font: kit.font } : null;
|
| 379 |
+
const mark = pro() ? (kit?.handle || null) : MARK;
|
| 380 |
+
await ff.writeFile('/c.ass', buildAss(job.words, m.start, m.end, { style, brand, mark }));
|
| 381 |
+
let vf;
|
| 382 |
+
if (job.w) {
|
| 383 |
+
const { w, h } = cropBox(job.w, job.h);
|
| 384 |
+
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`;
|
| 385 |
+
} else {
|
| 386 |
+
vf = null; // audio-only source: captions on a plain background
|
| 387 |
+
}
|
| 388 |
+
const out = `/clip-${idx}.mp4`;
|
| 389 |
+
const t1 = performance.now();
|
| 390 |
+
const onProg = ({ progress }) => setStatus(`Clip ${idx}/${total}: rendering ${OW}×${OH} with captions… ${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`, progress);
|
| 391 |
+
ff.on('progress', onProg);
|
| 392 |
+
const dur = (m.end - m.start).toFixed(2);
|
| 393 |
+
const args = vf
|
| 394 |
+
? ['-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]
|
| 395 |
+
: ['-hide_banner', '-f', 'lavfi', '-i', `color=c=0x14161f:s=${OW}x${OH}:r=30`, '-ss', m.start.toFixed(2), '-t', dur, '-i', path, '-vf', 'subtitles=filename=/c.ass:fontsdir=/fonts', '-map', '0:v', '-map', '1:a', '-shortest', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out];
|
| 396 |
+
const rc = await ff.exec(args);
|
| 397 |
+
ff.off('progress', onProg);
|
| 398 |
+
if (rc !== 0) throw new Error(`the renderer stopped (${(S.lastLog || []).slice(-2).join(' ')})`);
|
| 399 |
+
const data = await ff.readFile(out);
|
| 400 |
+
// poster frame (shown before playback; also where a browser can't play H.264 inline)
|
| 401 |
+
let poster = null;
|
| 402 |
+
try {
|
| 403 |
+
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) {
|
| 404 |
+
poster = URL.createObjectURL(new Blob([await ff.readFile('/poster.jpg')], { type: 'image/jpeg' }));
|
| 405 |
+
await ff.deleteFile('/poster.jpg');
|
| 406 |
+
}
|
| 407 |
+
} catch {}
|
| 408 |
+
await ff.deleteFile(out);
|
| 409 |
+
const tRender = (performance.now() - t1) / 1000;
|
| 410 |
+
const name = `${baseName(job.name)}-clip-${String(idx).padStart(2, '0')}`;
|
| 411 |
+
const srt = buildSrt(job.words, m.start, m.end);
|
| 412 |
+
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`;
|
| 413 |
+
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 };
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
async function renderPicked(job = S.active) {
|
| 417 |
+
if (S.busy || !job) return;
|
| 418 |
+
const picked = job.moments.filter((m, i) => m.pick && (pro() || i < FREE_CLIPS));
|
| 419 |
+
if (!picked.length) return;
|
| 420 |
+
S.busy = true; $('render').disabled = true; $('find').disabled = true;
|
| 421 |
+
job.status = 'rendering'; renderQueue();
|
| 422 |
+
const t0 = performance.now();
|
| 423 |
+
try {
|
| 424 |
+
for (let i = 0; i < picked.length; i++) {
|
| 425 |
+
const clip = await renderClip(job, picked[i], i + 1, picked.length);
|
| 426 |
+
job.clips.push(clip);
|
| 427 |
+
renderResults(job);
|
| 428 |
+
}
|
| 429 |
+
S.timings.render = (performance.now() - t0) / 1000;
|
| 430 |
+
job.status = `${job.clips.length} clips`;
|
| 431 |
+
setStatus(`Done: ${picked.length} clip${picked.length === 1 ? '' : 's'} in ${S.timings.render.toFixed(0)} s. Download below.`, 1);
|
| 432 |
+
} catch (e) {
|
| 433 |
+
job.status = 'error';
|
| 434 |
+
setStatus(`Rendering failed: ${e.message || e}. Try 720p or a shorter clip.`);
|
| 435 |
+
} finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); }
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// ---------------------------------------------------------------- results + export
|
| 439 |
+
function dl(blob, name) {
|
| 440 |
+
const a = el('a', { href: URL.createObjectURL(blob), download: name });
|
| 441 |
+
document.body.append(a); a.click(); a.remove();
|
| 442 |
+
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
| 443 |
+
}
|
| 444 |
+
function renderResults(job) {
|
| 445 |
+
$('resultsbox').hidden = !job?.clips.length;
|
| 446 |
+
if (!job?.clips.length) return $('results').replaceChildren();
|
| 447 |
+
$('results').replaceChildren(...job.clips.map((c) => {
|
| 448 |
+
const url = c.url || (c.url = URL.createObjectURL(c.mp4));
|
| 449 |
+
return el('li', { class: 'clip' },
|
| 450 |
+
el('video', { src: url, poster: c.poster, controls: true, playsinline: true, preload: 'none', width: 216, height: 384 }),
|
| 451 |
+
el('div', { class: 'cbody' },
|
| 452 |
+
el('b', {}, c.m.title),
|
| 453 |
+
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`),
|
| 454 |
+
el('p', {}, c.m.description),
|
| 455 |
+
el('p', { class: 'tags' }, c.m.hashtags.join(' ')),
|
| 456 |
+
el('div', { class: 'row' },
|
| 457 |
+
el('button', { type: 'button', class: 'btn sm', onclick: () => dl(c.mp4, `${c.name}.mp4`) }, 'MP4'),
|
| 458 |
+
el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.srt], { type: 'application/x-subrip' }), `${c.name}.srt`) }, 'SRT'),
|
| 459 |
+
el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.meta], { type: 'text/plain' }), `${c.name}.txt`) }, 'Title + tags'),
|
| 460 |
+
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'))));
|
| 461 |
+
}));
|
| 462 |
+
}
|
| 463 |
+
function zipFor(jobs) {
|
| 464 |
+
const files = [];
|
| 465 |
+
for (const j of jobs) for (const c of j.clips) files.push({ c, prefix: jobs.length > 1 ? `${baseName(j.name)}/` : '' });
|
| 466 |
+
return Promise.all(files.map(async ({ c, prefix }) => [
|
| 467 |
+
{ name: `${prefix}${c.name}.mp4`, data: new Uint8Array(await c.mp4.arrayBuffer()) },
|
| 468 |
+
{ name: `${prefix}${c.name}.srt`, data: c.srt }, { name: `${prefix}${c.name}.txt`, data: c.meta }])).then((x) => x.flat());
|
| 469 |
+
}
|
| 470 |
+
async function zipAll(all) {
|
| 471 |
+
const jobs = all ? S.jobs.filter((j) => j.clips.length) : [S.active];
|
| 472 |
+
const files = await zipFor(jobs);
|
| 473 |
+
const blob = new Blob(zipParts(files), { type: 'application/zip' });
|
| 474 |
+
S.lastZip = { bytes: blob.size, files: files.length };
|
| 475 |
+
dl(blob, all ? 'clipwhit-all-clips.zip' : `${baseName(S.active.name)}-clips.zip`);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
// Batch (Pro): find + render the top N of every queued video, one after another.
|
| 479 |
+
async function autoAll() {
|
| 480 |
+
const n = Math.max(1, Math.min(20, +$('autoN').value || 5));
|
| 481 |
+
for (const j of S.jobs) {
|
| 482 |
+
if (j.clips.length) continue;
|
| 483 |
+
select(j);
|
| 484 |
+
await findMoments(j);
|
| 485 |
+
j.moments.forEach((m, i) => { m.pick = i < n; });
|
| 486 |
+
await renderPicked(j);
|
| 487 |
+
}
|
| 488 |
+
setStatus(`Batch done: ${S.jobs.reduce((a, j) => a + j.clips.length, 0)} clips from ${S.jobs.length} videos.`, 1);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// ---------------------------------------------------------------- wiring
|
| 492 |
+
function wire() {
|
| 493 |
+
const drop = $('drop');
|
| 494 |
+
drop.addEventListener('click', () => $('file').click());
|
| 495 |
+
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('file').click(); } });
|
| 496 |
+
$('file').addEventListener('change', () => addFiles($('file').files));
|
| 497 |
+
['dragenter', 'dragover'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.add('over'); }));
|
| 498 |
+
['dragleave', 'drop'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.remove('over'); }));
|
| 499 |
+
drop.addEventListener('drop', (e) => addFiles(e.dataTransfer.files));
|
| 500 |
+
$('sample').addEventListener('click', async () => {
|
| 501 |
+
setStatus('Loading the 74-second sample episode…');
|
| 502 |
+
const r = await fetch(new URL('./sample/growth-show-sample.mp4', import.meta.url));
|
| 503 |
+
await addFiles([new File([await r.blob()], 'growth-show-sample.mp4', { type: 'video/mp4' })]);
|
| 504 |
+
});
|
| 505 |
+
$('find').addEventListener('click', () => findMoments());
|
| 506 |
+
$('render').addEventListener('click', () => renderPicked());
|
| 507 |
+
$('zip').addEventListener('click', () => zipAll(false));
|
| 508 |
+
$('zipall').addEventListener('click', () => zipAll(true));
|
| 509 |
+
$('auto').addEventListener('click', () => autoAll());
|
| 510 |
+
$('keyform').addEventListener('submit', (e) => { e.preventDefault(); applyKey($('key').value, true); });
|
| 511 |
+
$('kit').addEventListener('change', () => { store.set('clipwhit.kit', $('kit').value); renderKits(); });
|
| 512 |
+
for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).addEventListener('change', saveKit);
|
| 513 |
+
$('kitnew').addEventListener('click', () => {
|
| 514 |
+
const list = kits(); const id = `k${Date.now()}`;
|
| 515 |
+
list.push({ id, name: `Client ${list.length + 1}`, base: '#FFFFFF', hi: '#3DDC97', font: 'Anton', handle: '' });
|
| 516 |
+
store.set('clipwhit.kits', list); store.set('clipwhit.kit', id); renderKits();
|
| 517 |
+
});
|
| 518 |
+
$('style').replaceChildren(...Object.entries(STYLES).map(([k, s]) => el('option', { value: k }, `${s.label}${s.free ? '' : ' (Pro)'}`)));
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
async function init() {
|
| 522 |
+
wire();
|
| 523 |
+
const hash = new URLSearchParams(location.hash.slice(1));
|
| 524 |
+
const fromHash = hash.get('key');
|
| 525 |
+
if (fromHash) history.replaceState(null, '', location.pathname + location.search);
|
| 526 |
+
const cur = lic.current();
|
| 527 |
+
S.tier = cur.tier;
|
| 528 |
+
renderTier();
|
| 529 |
+
const key = fromHash || cur.key;
|
| 530 |
+
if (key) { $('key').value = key; await applyKey(key, !!fromHash); }
|
| 531 |
+
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).';
|
| 532 |
+
$('caps').textContent = cap;
|
| 533 |
+
}
|
| 534 |
+
init();
|
asr-worker.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit speech-to-text worker: Whisper in the browser with transformers.js (WebGPU when available, else WASM).
|
| 2 |
+
// Audio arrives as 16 kHz mono Float32Array; nothing is uploaded. © 2026 CyberMax. All rights reserved.
|
| 3 |
+
import { pipeline, WhisperTextStreamer, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0';
|
| 4 |
+
|
| 5 |
+
env.allowLocalModels = false;
|
| 6 |
+
let asr = null, loadedKey = null;
|
| 7 |
+
|
| 8 |
+
async function load(model, device, post) {
|
| 9 |
+
const key = `${model}|${device}`;
|
| 10 |
+
if (asr && loadedKey === key) return asr;
|
| 11 |
+
if (asr) { try { await asr.dispose(); } catch {} asr = null; loadedKey = null; }
|
| 12 |
+
const dtype = device === 'webgpu' ? { encoder_model: 'fp32', decoder_model_merged: 'q4' } : 'q8';
|
| 13 |
+
asr = await pipeline('automatic-speech-recognition', model, {
|
| 14 |
+
device, dtype,
|
| 15 |
+
progress_callback: (p) => post({ type: 'load', file: p.file, loaded: p.loaded, total: p.total }),
|
| 16 |
+
});
|
| 17 |
+
loadedKey = key;
|
| 18 |
+
return asr;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
self.onmessage = async ({ data }) => {
|
| 22 |
+
if (data?.type !== 'run') return;
|
| 23 |
+
const post = (m) => self.postMessage(m);
|
| 24 |
+
try {
|
| 25 |
+
let device = data.device, p;
|
| 26 |
+
try { p = await load(data.model, device, post); } catch (e) {
|
| 27 |
+
if (device !== 'webgpu') throw e;
|
| 28 |
+
post({ type: 'info', message: 'WebGPU could not start; using the CPU instead.' });
|
| 29 |
+
device = 'wasm';
|
| 30 |
+
p = await load(data.model, device, post);
|
| 31 |
+
}
|
| 32 |
+
post({ type: 'ready', device });
|
| 33 |
+
const english = /\.en$/.test(data.model);
|
| 34 |
+
const opts = { chunk_length_s: 30, stride_length_s: 5, return_timestamps: true, ...(english ? {} : { language: data.language || null, task: 'transcribe' }) };
|
| 35 |
+
let done = 0, out;
|
| 36 |
+
try {
|
| 37 |
+
const time_precision = p.processor.feature_extractor.config.chunk_length / p.model.config.max_source_positions;
|
| 38 |
+
const streamer = new WhisperTextStreamer(p.tokenizer, {
|
| 39 |
+
time_precision, skip_prompt: true,
|
| 40 |
+
callback_function: (text) => post({ type: 'partial', text }),
|
| 41 |
+
on_finalize: () => post({ type: 'chunk', done: ++done }),
|
| 42 |
+
});
|
| 43 |
+
out = await p(data.audio, { ...opts, streamer });
|
| 44 |
+
} catch (e) {
|
| 45 |
+
if (!/stream/i.test(String(e?.message || e))) throw e;
|
| 46 |
+
out = await p(data.audio, opts);
|
| 47 |
+
}
|
| 48 |
+
post({ type: 'done', chunks: out.chunks || [], text: out.text, device });
|
| 49 |
+
} catch (e) {
|
| 50 |
+
post({ type: 'error', message: String(e?.message || e) });
|
| 51 |
+
}
|
| 52 |
+
};
|
brand/cover.png
ADDED
|
Git LFS Details
|
brand/icon.png
ADDED
|
|
brand/icon.svg
ADDED
|
|
brand/image-1-output.png
ADDED
|
Git LFS Details
|
brand/image-2-inside.png
ADDED
|
brand/image-3-savings.png
ADDED
|
brand/phone-clip.png
ADDED
|
Git LFS Details
|
brand/shot-clips.png
ADDED
|
Git LFS Details
|
brand/shot-moments.png
ADDED
|
Git LFS Details
|
hooks/index.html
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>Podcast Hook Finder: Best Clip Moments, Free</title>
|
| 7 |
+
<meta name="description" content="Paste a podcast transcript (SRT, VTT or text) and get the 10 strongest moments to clip, with timestamps, hook lines, titles and hashtags. Free, runs in your browser.">
|
| 8 |
+
<link rel="canonical" href="https://clipwhit.cybermax-tools.workers.dev/hooks/">
|
| 9 |
+
<link rel="icon" href="../brand/icon.svg" type="image/svg+xml">
|
| 10 |
+
<meta property="og:title" content="Free Podcast Hook Finder">
|
| 11 |
+
<meta property="og:description" content="Paste a transcript, get the 10 best moments to clip with timestamps and titles.">
|
| 12 |
+
<meta property="og:image" content="https://clipwhit.cybermax-tools.workers.dev/brand/cover.png">
|
| 13 |
+
<link rel="stylesheet" href="../styles.css">
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<header class="w"><div class="top"><a class="me" href="../"><img src="../brand/icon.svg" alt="" width="34" height="34"><span>Clipwhit</span></a><nav><a href="../#app">Clip a video</a><a href="../#pricing">Pricing</a></nav></div></header>
|
| 17 |
+
<main class="w">
|
| 18 |
+
<p class="tagline">Free tool · no sign-up · nothing leaves your browser</p>
|
| 19 |
+
<h1>Find the best clip moments in any podcast transcript</h1>
|
| 20 |
+
<p class="lede">Paste an SRT, VTT or plain-text transcript. You get the 10 strongest moments to turn into shorts: where they start, the hook line, a title and hashtags.</p>
|
| 21 |
+
<div class="app">
|
| 22 |
+
<label for="t" class="note" style="font-weight:700">Transcript (SRT, VTT, or text with optional [mm:ss] stamps)</label>
|
| 23 |
+
<textarea id="t" rows="10" style="width:100%;font:14px/1.5 ui-monospace,Menlo,monospace;padding:12px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--ink);margin-top:6px" placeholder="1 00:00:01,000 --> 00:00:04,000 Welcome back to the show…"></textarea>
|
| 24 |
+
<div class="row" style="margin-top:10px"><button id="go" class="btn" type="button">Find hooks</button><button id="ex" class="btn ghost sm" type="button">Load an example</button><label class="btn ghost sm" style="cursor:pointer">Open a file<input id="f" type="file" accept=".srt,.vtt,.txt,text/plain" hidden></label></div>
|
| 25 |
+
<p id="msg" class="status" role="status"></p>
|
| 26 |
+
<ol id="out" class="moments"></ol>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="band"><div><h2>Turn these moments into finished shorts</h2><p>Clipwhit transcribes the video, crops to 9:16 around the speaker and burns in animated captions. Free for 3 clips per video.</p></div><a class="btn" href="../#app">Open Clipwhit</a></div>
|
| 29 |
+
<h2>How the moments are ranked</h2>
|
| 30 |
+
<p class="sub">Each 15–45 second stretch gets points for a scroll-stopping first line (a number, a secret, a mistake, a question), a clear payoff, a lively pace and a clean start and end, and loses points for filler words and stopping mid-sentence. It's a set of editing rules, not magic: always watch the moment before you post it.</p>
|
| 31 |
+
</main>
|
| 32 |
+
<footer class="w">© 2026 CyberMax · <a href="../">Clipwhit: podcast to captioned shorts</a> · <a href="https://cybermax.cybermax-tools.workers.dev/store/?utm_source=hookfinder">CyberMax Store</a></footer>
|
| 33 |
+
<script type="module">
|
| 34 |
+
import { parseTranscript, pickMoments, clock } from '../lib/scoring.js';
|
| 35 |
+
const $ = (id) => document.getElementById(id);
|
| 36 |
+
const EX = `[00:00] Welcome back to the show. Today we are talking about growing a podcast without ads.
|
| 37 |
+
[00:08] Here is the secret nobody tells you. The first three seconds decide everything.
|
| 38 |
+
[00:16] If your opening line does not make people curious, they scroll away, and the rest does not matter.
|
| 39 |
+
[00:25] So start with the result. Say the surprising number first, then explain how you got there.
|
| 40 |
+
[00:34] We posted the same interview twice. The first version got two hundred views. The second opened with the best answer and got ten thousand views in a week.
|
| 41 |
+
[00:48] That is the whole trick. Your best moment is usually buried in the middle of the episode.
|
| 42 |
+
[00:58] Um, so yeah, anyway, we kind of talked about the weather for a bit.
|
| 43 |
+
[01:10] The second mistake is posting once and giving up. Post every day for a month before you judge anything.
|
| 44 |
+
[01:22] And captions. Most people watch with the sound off, so no captions means you lose them.
|
| 45 |
+
[01:32] Honestly, I wish someone had told me this five years ago. It would have saved me hundreds of hours.`;
|
| 46 |
+
function run() {
|
| 47 |
+
const segs = parseTranscript($('t').value);
|
| 48 |
+
if (segs.length < 3) { $('msg').textContent = 'Paste a longer transcript (at least a few sentences).'; $('out').replaceChildren(); return; }
|
| 49 |
+
const m = pickMoments(segs, null, { count: 10, minLen: 15, maxLen: 45, target: 30 });
|
| 50 |
+
$('msg').textContent = `${m.length} moments from ${segs.length} transcript lines (${clock(segs[segs.length - 1].end)} long).`;
|
| 51 |
+
$('out').replaceChildren(...m.map((x, i) => {
|
| 52 |
+
const li = document.createElement('li'); li.className = 'moment';
|
| 53 |
+
li.innerHTML = `<div class="pick"><b>#${i + 1}</b></div><div class="mbody"><div class="mhead"><span class="time"></span><span class="score"></span></div><p class="title"></p><p class="excerpt"></p><div class="chips"></div></div>`;
|
| 54 |
+
li.querySelector('.time').textContent = `${clock(x.start)}–${clock(x.end)}`;
|
| 55 |
+
li.querySelector('.score').textContent = `score ${x.score.toFixed(1)}`;
|
| 56 |
+
li.querySelector('.title').textContent = x.title;
|
| 57 |
+
li.querySelector('.excerpt').textContent = x.text.length > 240 ? `${x.text.slice(0, 240)}…` : x.text;
|
| 58 |
+
li.querySelector('.chips').append(...x.reasons.slice(0, 4).map((r) => Object.assign(document.createElement('span'), { className: 'chip', textContent: r })), Object.assign(document.createElement('span'), { className: 'chip tags', textContent: x.hashtags.join(' ') }));
|
| 59 |
+
return li;
|
| 60 |
+
}));
|
| 61 |
+
}
|
| 62 |
+
$('go').onclick = run;
|
| 63 |
+
$('ex').onclick = () => { $('t').value = EX; run(); };
|
| 64 |
+
$('f').onchange = async () => { const f = $('f').files[0]; if (f) { $('t').value = await f.text(); run(); } };
|
| 65 |
+
</script>
|
| 66 |
+
</body>
|
| 67 |
+
</html>
|
index.html
CHANGED
|
@@ -1,19 +1,168 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>Clipwhit: Podcast to Shorts Clipper, No Upload</title>
|
| 7 |
+
<meta name="description" content="Turn a long podcast or video into captioned 9:16 shorts in your browser. AI finds the best moments, tracks the face, burns in animated captions. Nothing is uploaded. Free for 3 clips; Pro $12/month.">
|
| 8 |
+
<meta name="keywords" content="podcast to shorts, podcast clipper, AI clip generator, video to shorts, auto captions, 9:16 crop, Opus Clip alternative, no upload">
|
| 9 |
+
<link rel="canonical" href="https://clipwhit.cybermax-tools.workers.dev/">
|
| 10 |
+
<link rel="icon" href="brand/icon.svg" type="image/svg+xml">
|
| 11 |
+
<meta property="og:title" content="Clipwhit: podcast to captioned shorts, in your browser">
|
| 12 |
+
<meta property="og:description" content="AI finds the best moments, crops to 9:16 around the speaker and burns in animated captions. No upload, no minute caps.">
|
| 13 |
+
<meta property="og:image" content="https://clipwhit.cybermax-tools.workers.dev/brand/cover.png">
|
| 14 |
+
<meta property="og:type" content="website">
|
| 15 |
+
<meta name="twitter:card" content="summary_large_image">
|
| 16 |
+
<link rel="stylesheet" href="styles.css">
|
| 17 |
+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"Clipwhit","applicationCategory":"MultimediaApplication","operatingSystem":"Any (web browser)","url":"https://clipwhit.cybermax-tools.workers.dev/","image":"https://clipwhit.cybermax-tools.workers.dev/brand/cover.png","description":"In-browser podcast to vertical shorts clipper: Whisper transcription, moment scoring, 9:16 face-tracked crop, animated burned-in captions, MP4 + SRT + ZIP export. Nothing is uploaded.","publisher":{"@type":"Organization","name":"CyberMax"},"offers":[{"@type":"Offer","name":"Free","price":"0","priceCurrency":"USD"},{"@type":"Offer","name":"Pro monthly","price":"12","priceCurrency":"USD"},{"@type":"Offer","name":"Pro yearly","price":"120","priceCurrency":"USD"},{"@type":"Offer","name":"Pro lifetime (launch price)","price":"39","priceCurrency":"USD"},{"@type":"Offer","name":"Agency monthly","price":"49","priceCurrency":"USD"}]}</script>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<header class="w"><div class="top"><a class="me" href="./"><img src="brand/icon.svg" alt="" width="34" height="34"><span>Clipwhit</span></a>
|
| 21 |
+
<nav><a href="#app">Try it</a><a class="h" href="#compare">Compare</a><a href="#pricing">Pricing</a><a class="h" href="#faq">FAQ</a><a class="h" href="hooks/">Hook finder</a></nav></div></header>
|
| 22 |
+
|
| 23 |
+
<main class="w">
|
| 24 |
+
<section class="hero">
|
| 25 |
+
<div>
|
| 26 |
+
<p class="tagline">For podcasters, video editors and clip agencies</p>
|
| 27 |
+
<h1>Your podcast, cut into captioned shorts. No upload.</h1>
|
| 28 |
+
<p class="lede">Drop in a long episode. Clipwhit transcribes it, finds the moments worth posting, crops to 9:16 around the speaker and burns in animated captions, all inside your browser. Your video never leaves your computer.</p>
|
| 29 |
+
<div class="badges"><span class="badge">✓ Free: 3 clips per video</span><span class="badge">✓ Pro $12/month, no minute caps</span><span class="badge">✓ MP4 + SRT + titles + hashtags</span><span class="badge">✓ No sign-up to try</span></div>
|
| 30 |
+
<div class="cta"><a class="btn" href="#app">Make my first clips free</a><a class="btn ghost" href="#pricing">Pro from $12/month</a></div>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="phone"><img src="brand/phone-clip.png" alt="A finished 9:16 clip from the sample episode with yellow animated captions" width="300" height="533"></div>
|
| 33 |
+
</section>
|
| 34 |
+
|
| 35 |
+
<section id="app" class="app" aria-label="Clipwhit app">
|
| 36 |
+
<div class="apphead"><h2>Clip a video</h2><span id="tier" data-tier="free">Free</span></div>
|
| 37 |
+
<div id="drop" class="drop" role="button" tabindex="0" aria-label="Choose a video">
|
| 38 |
+
<b>Drop a video or audio file here</b>
|
| 39 |
+
<span>or click to choose · MP4, MOV, WebM, MKV, MP3, M4A · it stays on your device</span>
|
| 40 |
+
<input id="file" type="file" accept="video/*,audio/*,.mkv" multiple hidden>
|
| 41 |
+
</div>
|
| 42 |
+
<div class="row" style="margin-top:10px"><button id="sample" class="btn sm ghost" type="button">No video handy? Use our 74-second sample</button><span id="caps" class="note"></span></div>
|
| 43 |
+
<p id="batchmsg" class="note warn" hidden>The free plan clips one video at a time. Pro adds a batch queue.</p>
|
| 44 |
+
|
| 45 |
+
<div id="work" hidden>
|
| 46 |
+
<div class="work">
|
| 47 |
+
<div>
|
| 48 |
+
<video id="preview" controls playsinline preload="metadata"></video>
|
| 49 |
+
<p id="fileinfo" class="note"></p>
|
| 50 |
+
<div class="opts">
|
| 51 |
+
<label>From (h:mm:ss)<input id="from" value="0:00" inputmode="numeric"></label>
|
| 52 |
+
<label>To<input id="to" value="" inputmode="numeric"></label>
|
| 53 |
+
<label>Speech model<select id="model"><option value="onnx-community/whisper-tiny.en" selected>Fast (Whisper Tiny, English)</option><option value="onnx-community/whisper-base.en">Better (Whisper Base, English)</option><option value="onnx-community/whisper-base">Other languages (Whisper Base)</option></select></label>
|
| 54 |
+
<label>Engine<select id="device"><option value="auto">Auto (GPU if available)</option><option value="wasm">CPU only</option><option value="webgpu">GPU (WebGPU)</option></select></label>
|
| 55 |
+
<label>Shortest clip (s)<input id="minlen" type="number" min="10" max="60" value="20"></label>
|
| 56 |
+
<label>Longest clip (s)<input id="maxlen" type="number" min="20" max="120" value="55"></label>
|
| 57 |
+
</div>
|
| 58 |
+
<label class="check"><input id="ai" type="checkbox"> <span>Also ask CyberMax's AI to re-rank the moments and write titles. Sends transcript <b>text only</b> (never audio or video) to our server; free daily limit.</span></label>
|
| 59 |
+
<button id="find" class="btn" type="button">Find the best moments</button>
|
| 60 |
+
<p id="status" class="status" role="status" aria-live="polite">Choose a video to start.</p>
|
| 61 |
+
<div class="track"><div id="bar"></div></div>
|
| 62 |
+
<p id="partial"></p>
|
| 63 |
+
</div>
|
| 64 |
+
<div>
|
| 65 |
+
<div id="momentsbox" hidden>
|
| 66 |
+
<h3 style="margin:0">Best moments</h3>
|
| 67 |
+
<p class="note">Ranked by hook strength, a complete payoff, pace, energy and clean start/end. Tick the ones to render.</p>
|
| 68 |
+
<div class="opts">
|
| 69 |
+
<label>Caption style<select id="style"></select></label>
|
| 70 |
+
<label>Resolution<select id="res"><option value="720">720×1280</option><option value="1080">1080×1920 (Pro)</option></select></label>
|
| 71 |
+
</div>
|
| 72 |
+
<div id="kitbox" class="panel" hidden>
|
| 73 |
+
<h3>Brand kit</h3>
|
| 74 |
+
<div class="row"><select id="kit" style="flex:1"></select><button id="kitnew" class="btn sm ghost" type="button" hidden>+ New kit (Agency)</button></div>
|
| 75 |
+
<div id="kitfields" class="opts">
|
| 76 |
+
<label>Kit name<input id="kit-name"></label>
|
| 77 |
+
<label>Caption colour<input id="kit-base" type="color" value="#FFFFFF"></label>
|
| 78 |
+
<label>Highlight colour<input id="kit-hi" type="color" value="#FFD23F"></label>
|
| 79 |
+
<label>Font<select id="kit-font"><option>Anton</option><option>Inter</option></select></label>
|
| 80 |
+
<label>Corner handle (optional)<input id="kit-handle" placeholder="@yourshow"></label>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
<button id="render" class="btn" type="button" disabled>Render</button>
|
| 84 |
+
<ol id="moments" class="moments"></ol>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
<div id="resultsbox" hidden>
|
| 89 |
+
<h3>Your clips</h3>
|
| 90 |
+
<div class="row"><button id="zip" class="btn sm" type="button">Download all (ZIP: MP4 + SRT + titles)</button><button id="zipall" class="btn sm ghost agency-only" type="button">Bulk export every video (Agency)</button></div>
|
| 91 |
+
<ol id="results" class="results"></ol>
|
| 92 |
+
</div>
|
| 93 |
+
<div id="queue" class="panel" hidden>
|
| 94 |
+
<h3>Batch queue</h3>
|
| 95 |
+
<ul id="queuelist"></ul>
|
| 96 |
+
<div class="row"><label class="note">Clips per video <input id="autoN" type="number" min="1" max="20" value="5" style="width:80px"></label><button id="auto" class="btn sm" type="button">Clip every video in the queue</button></div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="panel">
|
| 101 |
+
<h3>Have a Pro or Agency key?</h3>
|
| 102 |
+
<form id="keyform" class="row"><input id="key" placeholder="CLIPWHIT-…" autocomplete="off" style="flex:1;min-width:0"><button class="btn sm" type="submit">Unlock</button></form>
|
| 103 |
+
<p id="keymsg" class="note"></p>
|
| 104 |
+
<p id="upsell" class="note">Free: 3 clips per video at 720p with a small corner mark. <a href="#pricing">Pro</a>: unlimited clips, 1080p, no mark, every caption style, brand kit, batch queue.</p>
|
| 105 |
+
</div>
|
| 106 |
+
</section>
|
| 107 |
+
|
| 108 |
+
<h2>From a 60-minute episode to posts in three steps</h2>
|
| 109 |
+
<div class="grid3">
|
| 110 |
+
<div class="card"><span class="n">1</span><b>Transcribe on your device</b><p>OpenAI's Whisper runs in the browser tab (on your graphics card where the browser allows it). Pick the whole episode or just a range.</p></div>
|
| 111 |
+
<div class="card"><span class="n">2</span><b>Find the moments</b><p>Every stretch of 20–55 s is scored for a scroll-stopping first line, a complete payoff, pace, energy and a clean start and end. You see why each one ranked.</p></div>
|
| 112 |
+
<div class="card"><span class="n">3</span><b>Crop, caption, export</b><p>Face tracking keeps the speaker in a 9:16 frame, captions pop word by word, and you get the MP4, an SRT file, a title, a description and hashtags for each clip.</p></div>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<h2>What it looks like</h2>
|
| 116 |
+
<div class="shots">
|
| 117 |
+
<figure><img src="brand/shot-moments.png" alt="Clipwhit listing ranked moments with scores and reasons" loading="lazy" width="1280" height="800"><figcaption>Ranked moments from the sample episode, with the reasons each one scored.</figcaption></figure>
|
| 118 |
+
<figure><img src="brand/shot-clips.png" alt="Rendered 9:16 clips with download buttons" loading="lazy" width="1280" height="800"><figcaption>Rendered clips: MP4, SRT and title + hashtags per clip, or one ZIP.</figcaption></figure>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<h2 id="compare">Clipwhit vs the popular clippers</h2>
|
| 122 |
+
<p class="sub">Rival prices from their public pricing pages, checked 29 Sep 2026.</p>
|
| 123 |
+
<div class="scroll"><table class="tbl">
|
| 124 |
+
<thead><tr><th></th><th>Clipwhit</th><th>Opus Clip</th><th>OpenShorts (cloud)</th><th>Klap</th></tr></thead>
|
| 125 |
+
<tbody>
|
| 126 |
+
<tr><td>Entry paid plan</td><td class="us"><b>$12/month</b> or $39 once (launch)</td><td>$15/month (Starter)</td><td>$12/month (100 min)</td><td>$14/month billed yearly (100 clips)</td></tr>
|
| 127 |
+
<tr><td>Your video is uploaded</td><td class="us"><b>No</b>, it stays on your device</td><td>Yes</td><td>Yes (self-hosting is free but needs your own server)</td><td>Yes</td></tr>
|
| 128 |
+
<tr><td>Minutes or credit caps on paid plan</td><td class="us"><b>None</b></td><td>Monthly credits</td><td>100 minutes/month</td><td>100 clips/month</td></tr>
|
| 129 |
+
<tr><td>Free plan</td><td class="us">3 clips per video, 720p, small mark, no sign-up</td><td>Yes, watermark</td><td>First video up to 60 min, then 20 min/month, watermark</td><td>Free trial</td></tr>
|
| 130 |
+
<tr><td>Captions, 9:16 face crop, titles + hashtags</td><td class="us">Yes</td><td>Yes</td><td>Yes</td><td>Yes</td></tr>
|
| 131 |
+
<tr><td>Speed</td><td class="us">Depends on your computer (see FAQ)</td><td>Server speed</td><td>Server speed</td><td>Server speed</td></tr>
|
| 132 |
+
</tbody></table></div>
|
| 133 |
+
|
| 134 |
+
<h2 id="pricing">Pricing</h2>
|
| 135 |
+
<p class="sub">Try everything on the free plan first, no card and no sign-up. Checkout by Stripe; your key appears on screen the moment payment finishes.</p>
|
| 136 |
+
<div class="plans">
|
| 137 |
+
<div class="pl"><b>Free</b><div class="amt">$0</div><ul><li>3 clips per video</li><li>720×1280</li><li>Small "Made with Clipwhit" corner mark</li><li>Bold Pop captions, MP4 + SRT + ZIP</li></ul><a class="btn ghost" href="#app">Start free</a></div>
|
| 138 |
+
<div class="pl best"><b>Pro</b><div class="amt">$12<small>/month</small></div><ul><li>Unlimited clips, no minute caps</li><li>1080×1920, no mark</li><li>All caption styles + your brand kit</li><li>Batch queue: clip many episodes in a row</li></ul><a class="btn" href="https://clipwhit.cybermax-tools.workers.dev/buy/pro-monthly?s=landing">Get Pro</a><a class="alt" href="https://clipwhit.cybermax-tools.workers.dev/buy/pro-yearly?s=landing">or $120/year (2 months free)</a></div>
|
| 139 |
+
<div class="pl"><b>Pro lifetime</b><div class="amt">$39<small> once</small></div><p>Launch price. Everything in Pro, forever, one payment, no subscription.</p><a class="btn ghost" href="https://clipwhit.cybermax-tools.workers.dev/buy/pro-lifetime?s=landing">Buy lifetime</a></div>
|
| 140 |
+
<div class="pl"><b>Agency</b><div class="amt">$49<small>/month</small></div><ul><li>Everything in Pro</li><li>Unlimited brand kits, one per client</li><li>Bulk export: every clip of every video in one ZIP</li></ul><a class="btn ghost" href="https://clipwhit.cybermax-tools.workers.dev/buy/agency-monthly?s=landing">Get Agency</a></div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<h2>For clip agencies</h2>
|
| 144 |
+
<div class="card"><p style="margin:0">Editors who run a clipping service for podcasters usually pay per upload minute on server tools, then pass that cost on. Clipwhit's Agency plan is a flat $49/month with no minute caps, one brand kit per client (caption colours, font and handle) and one-click bulk export, so a batch of episodes becomes a folder of ready-to-post clips per client. What you charge your clients is up to you; we sell the tool, not a promise of income.</p></div>
|
| 145 |
+
|
| 146 |
+
<h2 id="faq">Questions</h2>
|
| 147 |
+
<details><summary>Why not Opus Clip?</summary><p>Opus Clip is a good server tool: it's fast and it's polished. You upload every episode to it, and paid plans meter your usage. Clipwhit does the work on your own computer, so nothing is uploaded, there's no minute cap, and Pro costs $12/month (or $39 once at launch) against Opus Clip's $15 Starter and $29 Pro. The trade-off is speed: a server farm is faster than a laptop on long episodes.</p></details>
|
| 148 |
+
<details><summary>Is my video really not uploaded?</summary><p>Yes. The speech model, face detector and video encoder download once into your browser and run there. The only optional network call with your content is the AI re-rank box, which sends transcript text (no audio, no video) and is off unless you tick it. The licence check sends only your key.</p></details>
|
| 149 |
+
<details id="faq-speed"><summary>How long does a 60-minute episode take?</summary><p id="speed">Measured on 29 Sep 2026 in a headless Chrome engine on a 4-core cloud computer with no graphics card (the slowest path): a 60-minute episode was transcribed in about 24 minutes with the Fast model, then 5 clips of 35–40 seconds rendered at 720p in 2 minutes 39 seconds (about 30 seconds per clip, face tracking included). So plan on roughly half an hour per hour of audio on a CPU-only machine. A browser with WebGPU (Chrome or Edge on most laptops) moves transcription to the graphics card, which is usually much faster, but we have not measured that yet. Tip: set a From/To range to clip only the part you want. The speech model (about 44 MB) and video engine (about 31 MB) download once and are cached.</p></details>
|
| 150 |
+
<details><summary>Which browsers work?</summary><p>Chrome and Edge on a desktop or laptop are the tested and fastest choice (WebGPU speeds up transcription). Firefox and Safari use the CPU path; if one of them has trouble with a file, use Chrome or Edge. Phones can open the app, but long episodes need a computer: rendering video in a browser needs memory and time.</p></details>
|
| 151 |
+
<details><summary>What do I get with each clip?</summary><p>A 9:16 MP4 with burned-in captions, an SRT subtitle file, and a text file with a title, a short description and hashtags. "Download all" gives you one ZIP.</p></details>
|
| 152 |
+
<details><summary>How accurate are the transcript and the picks?</summary><p>Whisper Tiny is fast and good on clear speech; switch to Base for more accuracy. Moment scoring is a set of editing rules (a strong first line, a finished thought, pace and energy), so always watch a clip before you post it. You can nudge start and end by 3 seconds and edit every title.</p></details>
|
| 153 |
+
<details><summary>How do I get and use my Pro key?</summary><p>After checkout, Stripe sends you back to a page that shows your key and an "Open Clipwhit with Pro" button. The key unlocks Pro in any browser you paste it into. Subscriptions can be cancelled any time from the manage page; the key works until the paid period ends.</p></details>
|
| 154 |
+
<details><summary>Can I clip any podcast?</summary><p>Clip your own shows, or shows you have permission to repost. Clipwhit is an editing tool; the rights to the content stay with its owner.</p></details>
|
| 155 |
+
|
| 156 |
+
<div class="band"><div><h2>Turn this week's episode into clips</h2><p>Free for 3 clips per video. Pro is $12/month.</p></div><a class="btn" href="#app">Start free</a></div>
|
| 157 |
+
|
| 158 |
+
<h2>Also from CyberMax</h2>
|
| 159 |
+
<div class="grid3">
|
| 160 |
+
<div class="card"><b><a href="hooks/">Podcast Hook Finder (free)</a></b><p>Paste a transcript and see the 10 strongest opening lines and where they are.</p></div>
|
| 161 |
+
<div class="card"><b><a href="https://huggingface.co/spaces/CyberMax-tools/hushscribe">Hearlark</a></b><p>Private in-browser transcription with TXT, SRT and VTT export.</p></div>
|
| 162 |
+
<div class="card"><b><a href="https://cybermax.cybermax-tools.workers.dev/store/?utm_source=clipwhit">CyberMax Store</a></b><p>APIs, data briefs and creator tools.</p></div>
|
| 163 |
+
</div>
|
| 164 |
+
</main>
|
| 165 |
+
<footer class="w">© 2026 CyberMax · <a href="https://cybermax.cybermax-tools.workers.dev/store/clipwhit/">Clipwhit in the CyberMax Store</a> · <a href="https://cybermax.cybermax-tools.workers.dev/terms">Terms</a> · <a href="https://cybermax.cybermax-tools.workers.dev/privacy">Privacy</a> · <a href="https://clipwhit.cybermax-tools.workers.dev/manage">Manage subscription</a> · <a href="mailto:cybermax.tools@gmail.com">cybermax.tools@gmail.com</a><br>Speech: OpenAI Whisper (Apache-2.0) via transformers.js · Faces: MediaPipe · Video: ffmpeg.wasm · Fonts: Anton, Inter (OFL).</footer>
|
| 166 |
+
<script type="module" src="app.js"></script>
|
| 167 |
+
</body>
|
| 168 |
</html>
|
lib/captions.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit captions: animated ASS subtitles (burned in by ffmpeg.wasm/libass) and SRT export, per clip.
|
| 2 |
+
// Times are relative to the clip start. © 2026 CyberMax. All rights reserved.
|
| 3 |
+
|
| 4 |
+
export const STYLES = {
|
| 5 |
+
pop: { label: 'Bold Pop', free: true, font: 'Anton', size: 118, base: '#FFFFFF', hi: '#FFD23F', outline: 7, shadow: 2, box: false, upper: true, pop: true, words: 3 },
|
| 6 |
+
karaoke: { label: 'Karaoke sweep', free: false, font: 'Anton', size: 112, base: '#FFFFFF', hi: '#3DDC97', outline: 6, shadow: 0, box: false, upper: true, sweep: true, words: 4 },
|
| 7 |
+
clean: { label: 'Clean box', free: false, font: 'Inter', size: 80, base: '#111111', hi: '#6C4CF1', outline: 0, shadow: 0, box: '#FFFFFF', upper: false, pop: false, words: 5 },
|
| 8 |
+
neon: { label: 'Neon', free: false, font: 'Anton', size: 124, base: '#FFFFFF', hi: '#FF3DA5', outline: 3, shadow: 6, box: false, upper: true, pop: true, words: 2 },
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
// ASS colours are &HAABBGGRR
|
| 12 |
+
export function assColor(hex, alpha = 0) {
|
| 13 |
+
const h = String(hex || '#FFFFFF').replace('#', '').padEnd(6, 'F');
|
| 14 |
+
return `&H${alpha.toString(16).padStart(2, '0').toUpperCase()}${h.slice(4, 6)}${h.slice(2, 4)}${h.slice(0, 2)}`.toUpperCase();
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function assTime(t) {
|
| 18 |
+
t = Math.max(0, t);
|
| 19 |
+
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = Math.floor(t % 60), cs = Math.floor((t * 100) % 100);
|
| 20 |
+
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// libass matches fontsource's static Inter build by its full name, not the family.
|
| 24 |
+
export const fontName = (f) => (f === 'Inter' ? 'Inter ExtraBold' : f || 'Anton');
|
| 25 |
+
|
| 26 |
+
const escAss = (s) => String(s).replace(/[{}\\]/g, '').replace(/\n/g, ' ');
|
| 27 |
+
|
| 28 |
+
/** Words (absolute times) inside [start, end] -> caption groups relative to the clip start. */
|
| 29 |
+
export function groupWords(words, start, end, perGroup = 3, maxChars = 22) {
|
| 30 |
+
const inClip = words.filter((w) => w.end > start + 0.05 && w.start < end - 0.05)
|
| 31 |
+
.map((w) => ({ text: w.text, start: Math.max(0, w.start - start), end: Math.min(end, w.end) - start }));
|
| 32 |
+
const groups = [];
|
| 33 |
+
let cur = [];
|
| 34 |
+
const flush = () => { if (cur.length) groups.push({ words: cur, start: cur[0].start, end: cur[cur.length - 1].end }); cur = []; };
|
| 35 |
+
for (const w of inClip) {
|
| 36 |
+
const len = cur.reduce((a, x) => a + x.text.length + 1, 0) + w.text.length;
|
| 37 |
+
if (cur.length >= perGroup || (cur.length && len > maxChars)) flush();
|
| 38 |
+
cur.push(w);
|
| 39 |
+
if (/[.!?,;:]$/.test(w.text)) flush();
|
| 40 |
+
}
|
| 41 |
+
flush();
|
| 42 |
+
// no gaps shorter than 0.25 s between groups (keeps captions from flickering)
|
| 43 |
+
for (let i = 0; i < groups.length - 1; i++) if (groups[i + 1].start - groups[i].end < 0.25) groups[i].end = groups[i + 1].start;
|
| 44 |
+
return groups;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* ASS document for one clip. opts: { style, width, height, brand: {base, hi, font}, mark: 'text' | null, title }
|
| 49 |
+
*/
|
| 50 |
+
export function buildAss(words, start, end, opts = {}) {
|
| 51 |
+
const st = { ...STYLES[opts.style] || STYLES.pop, ...(opts.brand || {}) };
|
| 52 |
+
const W = 1080, H = 1920; // PlayRes; libass scales to the real output size
|
| 53 |
+
const size = st.size;
|
| 54 |
+
const y = Math.round(H * 0.70);
|
| 55 |
+
const back = st.box ? assColor(st.box, 0) : assColor('#000000', 0x60);
|
| 56 |
+
const border = st.box ? 3 : 1; // BorderStyle 3 = opaque box
|
| 57 |
+
const head = `[Script Info]
|
| 58 |
+
ScriptType: v4.00+
|
| 59 |
+
PlayResX: ${W}
|
| 60 |
+
PlayResY: ${H}
|
| 61 |
+
WrapStyle: 2
|
| 62 |
+
ScaledBorderAndShadow: yes
|
| 63 |
+
|
| 64 |
+
[V4+ Styles]
|
| 65 |
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
| 66 |
+
Style: Cap,${fontName(st.font)},${size},${assColor(st.base)},${assColor(st.hi)},${assColor('#000000')},${back},-1,0,0,0,100,100,1,0,${border},${st.box ? 18 : st.outline},${st.shadow},5,60,60,0,1
|
| 67 |
+
Style: Mark,Inter ExtraBold,36,${assColor('#FFFFFF', 0x30)},${assColor('#FFFFFF')},${assColor('#000000', 0x80)},${assColor('#000000', 0x90)},-1,0,0,0,100,100,0,0,1,2,0,9,40,40,48,1
|
| 68 |
+
|
| 69 |
+
[Events]
|
| 70 |
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
| 71 |
+
`;
|
| 72 |
+
const lines = [];
|
| 73 |
+
const groups = groupWords(words, start, end, st.words, st.words <= 2 ? 16 : 24);
|
| 74 |
+
const up = (s) => (st.upper ? s.toUpperCase() : s);
|
| 75 |
+
const hi = assColor(st.hi), base = assColor(st.base);
|
| 76 |
+
for (const g of groups) {
|
| 77 |
+
const pos = `{\\an5\\pos(${W / 2},${y})}`;
|
| 78 |
+
if (st.sweep) {
|
| 79 |
+
const k = g.words.map((w) => `{\\kf${Math.max(1, Math.round((w.end - w.start) * 100))}}${escAss(up(w.text))}`).join(' ');
|
| 80 |
+
lines.push(`Dialogue: 0,${assTime(g.start)},${assTime(g.end)},Cap,,0,0,0,,${pos}{\\fad(60,40)}${k}`);
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
// one event per word: the spoken word is highlighted (and pops), the rest of the group stays in the base colour
|
| 84 |
+
g.words.forEach((w, i) => {
|
| 85 |
+
const s = i === 0 ? g.start : w.start;
|
| 86 |
+
const e = i === g.words.length - 1 ? g.end : g.words[i + 1].start;
|
| 87 |
+
const txt = g.words.map((x, j) => (j === i
|
| 88 |
+
? `{\\c${hi}${st.pop ? '\\fscx112\\fscy112\\t(0,90,\\fscx100\\fscy100)' : ''}}${escAss(up(x.text))}{\\c${base}\\fscx100\\fscy100}`
|
| 89 |
+
: escAss(up(x.text)))).join(' ');
|
| 90 |
+
const intro = i === 0 && st.pop ? '{\\fscx80\\fscy80\\t(0,80,\\fscx100\\fscy100)}' : '';
|
| 91 |
+
lines.push(`Dialogue: 0,${assTime(s)},${assTime(e)},Cap,,0,0,0,,${pos}${intro}${txt}`);
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
if (opts.mark) lines.push(`Dialogue: 1,${assTime(0)},${assTime(end - start + 1)},Mark,,0,0,0,,${escAss(opts.mark)}`);
|
| 95 |
+
return head + lines.join('\n') + '\n';
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const srtTime = (t) => assTime(t).replace(/^(\d+):(\d\d):(\d\d)\.(\d\d)$/, (m, h, mi, s, cs) => `${String(h).padStart(2, '0')}:${mi}:${s},${cs}0`);
|
| 99 |
+
|
| 100 |
+
/** SRT for one clip (relative times), same groups as the burned-in captions. */
|
| 101 |
+
export function buildSrt(words, start, end, perGroup = 5) {
|
| 102 |
+
return groupWords(words, start, end, perGroup, 42).map((g, i) => `${i + 1}\n${srtTime(g.start)} --> ${srtTime(g.end)}\n${g.words.map((w) => w.text).join(' ')}\n`).join('\n');
|
| 103 |
+
}
|
lib/crop.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit 9:16 auto-crop: face samples -> a smooth crop path -> an ffmpeg crop-x expression over clip time t.
|
| 2 |
+
// © 2026 CyberMax. All rights reserved.
|
| 3 |
+
|
| 4 |
+
/** Crop box size for a 9:16 window inside a srcW x srcH frame (even numbers, as x264 needs). */
|
| 5 |
+
export function cropBox(srcW, srcH, aspect = 9 / 16) {
|
| 6 |
+
let h = srcH, w = Math.round(h * aspect);
|
| 7 |
+
if (w > srcW) { w = srcW; h = Math.round(w / aspect); }
|
| 8 |
+
return { w: w - (w % 2), h: h - (h % 2) };
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* samples: [{t, cx}] with cx = face centre x in source pixels, or null when no face was found.
|
| 13 |
+
* Returns keyframes [{t, x}] (left edge of the crop), smoothed with a dead zone so the frame does not jitter.
|
| 14 |
+
*/
|
| 15 |
+
export function cropPath(samples, srcW, srcH, { aspect = 9 / 16, deadZone = 0.12, maxSpeed = 0.9 } = {}) {
|
| 16 |
+
const { w } = cropBox(srcW, srcH, aspect);
|
| 17 |
+
const maxX = Math.max(0, srcW - w);
|
| 18 |
+
const clampX = (x) => Math.max(0, Math.min(maxX, Math.round(x)));
|
| 19 |
+
if (!samples.length) return [{ t: 0, x: clampX((srcW - w) / 2) }];
|
| 20 |
+
// fill gaps: carry the last seen face forward (and the first one back); no faces at all -> centre
|
| 21 |
+
const seen = samples.filter((s) => s.cx != null);
|
| 22 |
+
if (!seen.length) return [{ t: 0, x: clampX((srcW - w) / 2) }];
|
| 23 |
+
let last = seen[0].cx;
|
| 24 |
+
const filled = samples.map((s) => ({ t: s.t, cx: s.cx != null ? (last = s.cx) : last }));
|
| 25 |
+
// median of 3 removes single-frame false detections
|
| 26 |
+
const med = filled.map((s, i) => {
|
| 27 |
+
const win = filled.slice(Math.max(0, i - 1), i + 2).map((x) => x.cx).sort((a, b) => a - b);
|
| 28 |
+
return { t: s.t, cx: win[Math.floor(win.length / 2)] };
|
| 29 |
+
});
|
| 30 |
+
const keys = [];
|
| 31 |
+
let x = clampX(med[0].cx - w / 2);
|
| 32 |
+
keys.push({ t: med[0].t, x });
|
| 33 |
+
for (let i = 1; i < med.length; i++) {
|
| 34 |
+
const want = clampX(med[i].cx - w / 2);
|
| 35 |
+
const dt = Math.max(0.01, med[i].t - med[i - 1].t);
|
| 36 |
+
if (Math.abs(want - x) <= deadZone * w) continue; // subject still inside the frame: hold the shot
|
| 37 |
+
const step = Math.min(Math.abs(want - x), maxSpeed * w * dt); // pan at most maxSpeed crop-widths per second
|
| 38 |
+
x = clampX(x + Math.sign(want - x) * step);
|
| 39 |
+
keys.push({ t: med[i].t, x });
|
| 40 |
+
}
|
| 41 |
+
return keys;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** Piecewise-linear ffmpeg expression for the crop x over t (clip-relative seconds). */
|
| 45 |
+
export function cropExpr(keys) {
|
| 46 |
+
if (!keys.length) return '0';
|
| 47 |
+
if (keys.length === 1) return String(keys[0].x);
|
| 48 |
+
// Hold each position, ramp over up to 0.6 s into the next one, hold after the last. Built from the end, so the
|
| 49 |
+
// outermost test is the earliest time and every else-branch only sees later times.
|
| 50 |
+
let expr = String(keys[keys.length - 1].x);
|
| 51 |
+
for (let i = keys.length - 1; i >= 1; i--) {
|
| 52 |
+
const a = keys[i - 1], b = keys[i];
|
| 53 |
+
const t1 = b.t, t0 = Math.max(a.t, b.t - 0.6);
|
| 54 |
+
const ramp = `${a.x}+(${b.x - a.x})*(t-${t0.toFixed(2)})/${Math.max(0.01, t1 - t0).toFixed(2)}`;
|
| 55 |
+
expr = `if(lt(t,${t0.toFixed(2)}),${a.x},if(lt(t,${t1.toFixed(2)}),${ramp},${expr}))`;
|
| 56 |
+
}
|
| 57 |
+
return expr;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** At most `max` keyframes (drops the smallest moves first), so the ffmpeg expression stays short. */
|
| 61 |
+
export function thinKeys(keys, max = 24) {
|
| 62 |
+
const k = keys.slice();
|
| 63 |
+
while (k.length > max) {
|
| 64 |
+
let bi = 1, bd = Infinity;
|
| 65 |
+
for (let i = 1; i < k.length; i++) { const d = Math.abs(k[i].x - k[i - 1].x); if (d < bd) { bd = d; bi = i; } }
|
| 66 |
+
k.splice(bi, 1);
|
| 67 |
+
}
|
| 68 |
+
return k;
|
| 69 |
+
}
|
lib/licence.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit licence: a Pro/Agency key sold through Stripe and signed by the CyberMax Worker. The app asks the Worker's
|
| 2 |
+
// /verify endpoint and remembers a good answer for 24 h, so it keeps working offline. © 2026 CyberMax. All rights reserved.
|
| 3 |
+
const K = 'clipwhit.licence';
|
| 4 |
+
export const KEY_RE = /^CLIPWHIT-(?:sub|cs|test)_[A-Za-z0-9_]{1,200}-[0-9a-f]{32}$/;
|
| 5 |
+
|
| 6 |
+
const read = () => { try { return JSON.parse(localStorage.getItem(K) || 'null'); } catch { return null; } };
|
| 7 |
+
const write = (v) => { try { v ? localStorage.setItem(K, JSON.stringify(v)) : localStorage.removeItem(K); } catch {} };
|
| 8 |
+
|
| 9 |
+
export function current() {
|
| 10 |
+
const v = read();
|
| 11 |
+
if (!v || !KEY_RE.test(v.key || '')) return { tier: 'free' };
|
| 12 |
+
const fresh = Date.now() - (v.checked || 0) < 7 * 864e5; // offline grace: 7 days since the last good check
|
| 13 |
+
return fresh && v.valid ? { tier: v.tier || 'pro', key: v.key, plan: v.plan, checked: v.checked } : { tier: 'free', key: v.key, stale: true };
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/** Ask the Worker. Returns { valid, tier, plan, reason }. Network failure keeps the last good answer. */
|
| 17 |
+
export async function verify(api, key, { force = false } = {}) {
|
| 18 |
+
key = String(key || '').trim();
|
| 19 |
+
if (!KEY_RE.test(key)) return { valid: false, reason: 'format' };
|
| 20 |
+
const v = read();
|
| 21 |
+
if (!force && v && v.key === key && v.valid && Date.now() - v.checked < 864e5) return { valid: true, tier: v.tier, plan: v.plan, cached: true };
|
| 22 |
+
try {
|
| 23 |
+
const r = await fetch(`${api}/verify?key=${encodeURIComponent(key)}`, { cache: 'no-store' });
|
| 24 |
+
const j = await r.json();
|
| 25 |
+
if (j.valid) write({ key, valid: true, tier: j.tier, plan: j.plan, checked: Date.now() });
|
| 26 |
+
else if (r.status === 401 || r.status === 404 || j.valid === false) write(null);
|
| 27 |
+
return j;
|
| 28 |
+
} catch (e) {
|
| 29 |
+
if (v && v.key === key && v.valid) return { valid: true, tier: v.tier, plan: v.plan, offline: true };
|
| 30 |
+
return { valid: false, reason: 'offline' };
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export const forget = () => write(null);
|
lib/scoring.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Clipwhit moment finder: pure functions (browser + Node tests). Turns a timestamped transcript and an audio
|
| 2 |
+
// loudness envelope into ranked, non-overlapping short-clip candidates with the reasons each one scored.
|
| 3 |
+
// © 2026 CyberMax. All rights reserved.
|
| 4 |
+
|
| 5 |
+
export const STOP = new Set(('a an and are as at be but by for from has have he her his i if in into is it its just like me my ' +
|
| 6 |
+
'not of on or our out so that the their them then there they this to up us was we were what when which who will with you your ' +
|
| 7 |
+
'yeah okay ok um uh really very actually going gonna get got do does did dont don\'t im i\'m it\'s thats that\'s youre you\'re ' +
|
| 8 |
+
'about all also can could would should one two some more most much many been being because how why where here now know think ' +
|
| 9 |
+
'thing things people said say says want make made time right well let lets let\'s kind sort mean guess stuff lot little ' +
|
| 10 |
+
'first second third version episode listening welcome today back show talking talk thanks next every single least before after ' +
|
| 11 |
+
'would could should never always over under still even only same other another those these into onto than tell tells told give gave').split(/\s+/));
|
| 12 |
+
|
| 13 |
+
// Phrases that make a viewer stop scrolling (weights are relative; tuned on podcast transcripts).
|
| 14 |
+
const HOOKS = [
|
| 15 |
+
[/\b(secret|nobody (tells|talks)|no one (tells|talks)|the truth|biggest mistake|mistakes?|the trick|here'?s (why|how|the thing|what)|what (nobody|most people))\b/i, 3],
|
| 16 |
+
[/\b(never|always|stop|don'?t|worst|best|insane|crazy|shocking|surpris\w*|wrong|myth|lie|lied|honestly|truth)\b/i, 1.5],
|
| 17 |
+
[/\b(how to|how i|how we|why you|why most|the reason|the one thing|the (first|second|third|only))\b/i, 2],
|
| 18 |
+
[/\b(\d[\d,.]*\s?(%|percent|k|thousand|million|billion|x|times|hours?|days?|weeks?|years?|dollars?|views?)|\$\s?\d)/i, 2],
|
| 19 |
+
[/\?/, 1],
|
| 20 |
+
];
|
| 21 |
+
const PAYOFF = /\b(that'?s (the|why|how|it)|the whole trick|so the lesson|the point is|bottom line|in short|which means|that'?s what)\b/i;
|
| 22 |
+
const LAUGH = /\[(laugh\w*|applause|chuckl\w*)\]|\((laugh\w*|applause)\)|\b(ha(ha)+|lol)\b/i;
|
| 23 |
+
const WEAK_START = /^(and|but|so|or|because|um|uh|like|yeah|okay|right)\b/i;
|
| 24 |
+
const FILLER = /\b(um+|uh+|you know|i mean|sort of|kind of)\b/gi;
|
| 25 |
+
|
| 26 |
+
export const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
| 27 |
+
const wordsOf = (s) => clean(s).split(' ').filter(Boolean);
|
| 28 |
+
|
| 29 |
+
/** Whisper chunks [{timestamp:[s,e], text}] -> [{start, end, text}] (drops empties, fixes null ends). */
|
| 30 |
+
export function toSegments(chunks, duration = Infinity) {
|
| 31 |
+
const out = [];
|
| 32 |
+
for (const c of chunks || []) {
|
| 33 |
+
const text = clean(c.text);
|
| 34 |
+
if (!text || /^\[(blank_audio|music|silence)\]$/i.test(text)) continue;
|
| 35 |
+
const [s, e] = c.timestamp || [c.start, c.end];
|
| 36 |
+
const start = Math.max(0, Number(s) || 0);
|
| 37 |
+
let end = Number(e);
|
| 38 |
+
if (!Number.isFinite(end) || end <= start) end = Math.min(duration, start + Math.max(1.5, wordsOf(text).length * 0.35));
|
| 39 |
+
if (out.length && text === out[out.length - 1].text) continue; // Whisper repeats a line at window seams
|
| 40 |
+
out.push({ start, end, text });
|
| 41 |
+
}
|
| 42 |
+
return out;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Word timings spread over each segment by character length (Whisper gives segment-level stamps). */
|
| 46 |
+
export function toWords(segments) {
|
| 47 |
+
const words = [];
|
| 48 |
+
for (const s of segments) {
|
| 49 |
+
const w = wordsOf(s.text);
|
| 50 |
+
if (!w.length) continue;
|
| 51 |
+
const weights = w.map((x) => Math.max(2, x.replace(/[^\p{L}\p{N}]/gu, '').length + 1));
|
| 52 |
+
const total = weights.reduce((a, b) => a + b, 0);
|
| 53 |
+
let t = s.start;
|
| 54 |
+
const span = Math.max(0.2, s.end - s.start);
|
| 55 |
+
w.forEach((text, i) => {
|
| 56 |
+
const d = (span * weights[i]) / total;
|
| 57 |
+
words.push({ text, start: t, end: t + d });
|
| 58 |
+
t += d;
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
return words;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/** RMS loudness per `step` seconds from 16 kHz mono samples. */
|
| 65 |
+
export function envelope(audio, rate = 16000, step = 0.5) {
|
| 66 |
+
const n = Math.max(1, Math.floor(rate * step));
|
| 67 |
+
const out = new Float32Array(Math.ceil(audio.length / n));
|
| 68 |
+
for (let i = 0; i < out.length; i++) {
|
| 69 |
+
let sum = 0;
|
| 70 |
+
const a = i * n, b = Math.min(audio.length, a + n);
|
| 71 |
+
for (let j = a; j < b; j++) sum += audio[j] * audio[j];
|
| 72 |
+
out[i] = Math.sqrt(sum / Math.max(1, b - a));
|
| 73 |
+
}
|
| 74 |
+
return { step, values: out };
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function energyOf(env, start, end) {
|
| 78 |
+
if (!env || !env.values?.length) return null;
|
| 79 |
+
const a = Math.floor(start / env.step), b = Math.max(a + 1, Math.ceil(end / env.step));
|
| 80 |
+
let s = 0, n = 0;
|
| 81 |
+
for (let i = a; i < b && i < env.values.length; i++) { s += env.values[i]; n++; }
|
| 82 |
+
return n ? s / n : null;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function mean(env) {
|
| 86 |
+
if (!env || !env.values?.length) return null;
|
| 87 |
+
let s = 0, n = 0;
|
| 88 |
+
for (const v of env.values) if (v > 0.003) { s += v; n++; }
|
| 89 |
+
return n ? s / n : null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/** Candidate windows that start on a segment boundary and end on one, lasting minLen..maxLen seconds. */
|
| 93 |
+
export function candidates(segments, { minLen = 15, maxLen = 60 } = {}) {
|
| 94 |
+
const out = [];
|
| 95 |
+
for (let i = 0; i < segments.length; i++) {
|
| 96 |
+
let text = '';
|
| 97 |
+
for (let j = i; j < segments.length; j++) {
|
| 98 |
+
text += (text ? ' ' : '') + segments[j].text;
|
| 99 |
+
const dur = segments[j].end - segments[i].start;
|
| 100 |
+
if (dur > maxLen) break;
|
| 101 |
+
if (dur >= minLen) out.push({ i, j, start: segments[i].start, end: segments[j].end, text });
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
return out;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** Score one window. Returns { score, reasons[] } with every contribution named. */
|
| 108 |
+
export function scoreWindow(w, segments, env, avgEnergy, { target = 35 } = {}) {
|
| 109 |
+
const reasons = [];
|
| 110 |
+
let score = 0;
|
| 111 |
+
const add = (v, why) => { if (v) { score += v; reasons.push(why); } };
|
| 112 |
+
const first = segments[w.i].text;
|
| 113 |
+
const opener = clean(segments.slice(w.i, w.i + 2).map((s) => s.text).join(' ')).slice(0, 160);
|
| 114 |
+
let hook = 0;
|
| 115 |
+
for (const [re, wt] of HOOKS) if (re.test(opener)) hook += wt;
|
| 116 |
+
add(Math.min(hook, 6) * 1.2, hook ? 'strong hook in the first seconds' : '');
|
| 117 |
+
let body = 0;
|
| 118 |
+
for (const [re, wt] of HOOKS) if (re.test(w.text)) body += wt * 0.35;
|
| 119 |
+
add(Math.min(body, 3), '');
|
| 120 |
+
add(PAYOFF.test(w.text) ? 2 : 0, 'has a payoff line');
|
| 121 |
+
add(/[.!?]["')\]]?$/.test(w.text.trim()) ? 1.5 : -1.5, /[.!?]["')\]]?$/.test(w.text.trim()) ? 'ends on a complete sentence' : 'ends mid-sentence');
|
| 122 |
+
add(/^[A-Z0-9"'$]/.test(first) && !WEAK_START.test(first) ? 1 : -1, WEAK_START.test(first) ? 'starts on a filler word' : 'clean start');
|
| 123 |
+
add(LAUGH.test(w.text) ? 2 : 0, 'laughter or reaction');
|
| 124 |
+
const dur = w.end - w.start;
|
| 125 |
+
const n = wordsOf(w.text).length;
|
| 126 |
+
const wps = n / Math.max(1, dur);
|
| 127 |
+
add(wps >= 2.2 && wps <= 4.2 ? 1 : wps < 1.4 ? -2 : 0, wps >= 2.2 && wps <= 4.2 ? 'lively pace' : wps < 1.4 ? 'slow or silent stretch' : '');
|
| 128 |
+
const fillers = (w.text.match(FILLER) || []).length;
|
| 129 |
+
add(fillers > 3 ? -Math.min(3, fillers * 0.3) : 0, fillers > 3 ? 'many filler words' : '');
|
| 130 |
+
const gap = w.i > 0 ? segments[w.i].start - segments[w.i - 1].end : 1;
|
| 131 |
+
add(gap >= 0.6 ? 0.8 : 0, gap >= 0.6 ? 'begins after a pause' : '');
|
| 132 |
+
const e = energyOf(env, w.start, w.end);
|
| 133 |
+
if (e != null && avgEnergy) {
|
| 134 |
+
const r = e / avgEnergy;
|
| 135 |
+
add(r > 1.15 ? Math.min(2, (r - 1) * 4) : r < 0.75 ? -1 : 0, r > 1.15 ? 'louder, more energetic audio' : r < 0.75 ? 'quiet audio' : '');
|
| 136 |
+
}
|
| 137 |
+
add(-Math.abs(dur - target) / 12, '');
|
| 138 |
+
return { score: Math.round(score * 100) / 100, reasons: reasons.filter(Boolean) };
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/** Ranked, non-overlapping moments. */
|
| 142 |
+
export function pickMoments(segments, env, { count = 10, minLen = 15, maxLen = 60, target = 35 } = {}) {
|
| 143 |
+
const avg = mean(env);
|
| 144 |
+
const scored = candidates(segments, { minLen, maxLen }).map((w) => ({ ...w, ...scoreWindow(w, segments, env, avg, { target }) }));
|
| 145 |
+
scored.sort((a, b) => b.score - a.score || a.start - b.start);
|
| 146 |
+
const picked = [];
|
| 147 |
+
const bag = (t) => new Set(wordsOf(t.toLowerCase()).map((w) => w.replace(/[^\p{L}\p{N}]/gu, '')).filter((w) => w.length > 2));
|
| 148 |
+
const similar = (a, b) => { let n = 0; for (const w of a) if (b.has(w)) n++; return n / Math.max(1, Math.min(a.size, b.size)); };
|
| 149 |
+
for (const c of scored) {
|
| 150 |
+
if (picked.length >= count) break;
|
| 151 |
+
if (picked.some((p) => c.start < p.end - 1 && c.end > p.start + 1)) continue;
|
| 152 |
+
c.bag = bag(c.text);
|
| 153 |
+
if (picked.some((p) => similar(c.bag, p.bag) > 0.7)) continue; // the same story told twice (re-runs, intros, ad reads)
|
| 154 |
+
picked.push(c);
|
| 155 |
+
}
|
| 156 |
+
return picked.map((c, k) => ({ id: k + 1, start: c.start, end: c.end, text: c.text, score: c.score, reasons: c.reasons, ...clipMeta(c.text) }));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/** Also exported for the transcript-only Hook Finder: plain text / SRT / VTT -> segments. */
|
| 160 |
+
export function parseTranscript(src) {
|
| 161 |
+
const txt = String(src || '').replace(/\r/g, '');
|
| 162 |
+
const ts = (h, m, s, ms) => (+h || 0) * 3600 + (+m) * 60 + (+s) + (+(ms || 0)) / 1000;
|
| 163 |
+
const re = /(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?[^\n]*\n([\s\S]*?)(?=\n\s*\n|\n?$)/g;
|
| 164 |
+
const out = [];
|
| 165 |
+
let m;
|
| 166 |
+
while ((m = re.exec(txt))) {
|
| 167 |
+
const text = clean(m[9].replace(/<[^>]+>/g, '').replace(/^\d+$/gm, ''));
|
| 168 |
+
if (text) out.push({ start: ts(m[1], m[2], m[3], (m[4] || '0').padEnd(3, '0')), end: ts(m[5], m[6], m[7], (m[8] || '0').padEnd(3, '0')), text });
|
| 169 |
+
}
|
| 170 |
+
if (out.length) return out;
|
| 171 |
+
// Plain text: optional [hh:mm:ss] or (mm:ss) stamps; otherwise sentences at ~2.8 words per second.
|
| 172 |
+
const sentences = txt.split(/\n+|(?<=[.!?])\s+/).map(clean).filter(Boolean);
|
| 173 |
+
let t = 0;
|
| 174 |
+
for (const s of sentences) {
|
| 175 |
+
const st = s.match(/^[[(]?(?:(\d+):)?(\d{1,2}):(\d{2})[\])]?\s*/);
|
| 176 |
+
if (st) t = ts(st[1], st[2], st[3]);
|
| 177 |
+
const text = st ? s.slice(st[0].length) : s;
|
| 178 |
+
if (!text) continue;
|
| 179 |
+
const d = Math.max(1.2, wordsOf(text).length / 2.8);
|
| 180 |
+
out.push({ start: t, end: t + d, text });
|
| 181 |
+
t += d;
|
| 182 |
+
}
|
| 183 |
+
return out;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
| 187 |
+
|
| 188 |
+
/** Title, description and hashtags from the clip's own words (local, no AI). */
|
| 189 |
+
export function clipMeta(text) {
|
| 190 |
+
const sentences = clean(text).split(/(?<=[.!?])\s+/).filter(Boolean);
|
| 191 |
+
let best = sentences[0] || clean(text);
|
| 192 |
+
let bestScore = -1;
|
| 193 |
+
for (const s of sentences.slice(0, 6)) {
|
| 194 |
+
let sc = 0;
|
| 195 |
+
for (const [re, wt] of HOOKS) if (re.test(s)) sc += wt;
|
| 196 |
+
sc -= Math.abs(wordsOf(s).length - 11) * 0.08;
|
| 197 |
+
if (sc > bestScore) { bestScore = sc; best = s; }
|
| 198 |
+
}
|
| 199 |
+
let title = best.replace(WEAK_START, '').replace(/^[,\s]+/, '').replace(/[.,;:]+$/, '');
|
| 200 |
+
if (title.length > 70) title = title.slice(0, 67).replace(/\s+\S*$/, '') + '…';
|
| 201 |
+
title = cap(title);
|
| 202 |
+
const counts = new Map();
|
| 203 |
+
for (const w of wordsOf(text.toLowerCase())) {
|
| 204 |
+
const k = w.replace(/[^\p{L}\p{N}]/gu, '');
|
| 205 |
+
if (k.length < 4 || STOP.has(k) || /^\d+$/.test(k)) continue;
|
| 206 |
+
counts.set(k, (counts.get(k) || 0) + 1);
|
| 207 |
+
}
|
| 208 |
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).slice(0, 3).map(([k]) => `#${k}`);
|
| 209 |
+
const hashtags = [...new Set([...top, '#podcast', '#shorts'])].slice(0, 5);
|
| 210 |
+
const description = `${sentences.slice(0, 2).join(' ').slice(0, 220)}${sentences.length > 2 ? '…' : ''}`;
|
| 211 |
+
return { title, description, hashtags };
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
export const clock = (s) => {
|
| 215 |
+
s = Math.max(0, Math.round(s));
|
| 216 |
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60;
|
| 217 |
+
return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`;
|
| 218 |
+
};
|
lib/zip.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Minimal ZIP writer (stored, no compression: MP4s don't compress). Returns a Blob. © 2026 CyberMax. All rights reserved.
|
| 2 |
+
const TABLE = (() => {
|
| 3 |
+
const t = new Uint32Array(256);
|
| 4 |
+
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
|
| 5 |
+
return t;
|
| 6 |
+
})();
|
| 7 |
+
export function crc32(bytes) {
|
| 8 |
+
let c = 0xFFFFFFFF;
|
| 9 |
+
for (let i = 0; i < bytes.length; i++) c = TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
|
| 10 |
+
return (c ^ 0xFFFFFFFF) >>> 0;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/** files: [{name, data: Uint8Array | string}] -> Uint8Array parts of a .zip (Blob-ready). */
|
| 14 |
+
export function zipParts(files, date = new Date()) {
|
| 15 |
+
const enc = new TextEncoder();
|
| 16 |
+
const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
|
| 17 |
+
const dosDate = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
| 18 |
+
const parts = [], central = [];
|
| 19 |
+
let offset = 0;
|
| 20 |
+
for (const f of files) {
|
| 21 |
+
const name = enc.encode(f.name);
|
| 22 |
+
const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data;
|
| 23 |
+
const crc = crc32(data);
|
| 24 |
+
const h = new DataView(new ArrayBuffer(30));
|
| 25 |
+
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true); h.setUint16(8, 0, true);
|
| 26 |
+
h.setUint16(10, dosTime, true); h.setUint16(12, dosDate, true); h.setUint32(14, crc, true);
|
| 27 |
+
h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true); h.setUint16(28, 0, true);
|
| 28 |
+
parts.push(new Uint8Array(h.buffer), name, data);
|
| 29 |
+
const c = new DataView(new ArrayBuffer(46));
|
| 30 |
+
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true); c.setUint16(10, 0, true);
|
| 31 |
+
c.setUint16(12, dosTime, true); c.setUint16(14, dosDate, true); c.setUint32(16, crc, true); c.setUint32(20, data.length, true);
|
| 32 |
+
c.setUint32(24, data.length, true); c.setUint16(28, name.length, true); c.setUint32(42, offset, true);
|
| 33 |
+
central.push(new Uint8Array(c.buffer), name);
|
| 34 |
+
offset += 30 + name.length + data.length;
|
| 35 |
+
}
|
| 36 |
+
const size = central.reduce((a, b) => a + b.length, 0);
|
| 37 |
+
const end = new DataView(new ArrayBuffer(22));
|
| 38 |
+
end.setUint32(0, 0x06054b50, true); end.setUint16(8, files.length, true); end.setUint16(10, files.length, true);
|
| 39 |
+
end.setUint32(12, size, true); end.setUint32(16, offset, true);
|
| 40 |
+
return [...parts, ...central, new Uint8Array(end.buffer)];
|
| 41 |
+
}
|
sample/growth-show-sample.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:54305400196a207ec7454726e23c2653085ae344406aa55b1c4dbea22face38c
|
| 3 |
+
size 1394746
|
styles.css
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Clipwhit · © 2026 CyberMax. All rights reserved. */
|
| 2 |
+
:root{--bg:#F7F4EE;--bg2:#EFEAE0;--card:#fff;--ink:#14121C;--muted:#5B5868;--line:#E4DED2;--brand:#6C3CF0;--brand-ink:#fff;--acc:#FFD23F;--ok:#0E7C55;--warn:#A4460E;--chip:#EEE9F9;--shadow:0 1px 2px rgba(20,18,28,.06),0 10px 28px rgba(20,18,28,.07);color-scheme:light}
|
| 3 |
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F0E15;--bg2:#15131D;--card:#1A1824;--ink:#EEEAF6;--muted:#A7A2B8;--line:#2B2838;--brand:#A58BFF;--brand-ink:#14121C;--acc:#FFD23F;--ok:#3DDC97;--warn:#FFB27A;--chip:#262236;--shadow:0 1px 2px rgba(0,0,0,.35),0 10px 28px rgba(0,0,0,.3);color-scheme:dark}}
|
| 4 |
+
:root[data-theme=dark]{--bg:#0F0E15;--bg2:#15131D;--card:#1A1824;--ink:#EEEAF6;--muted:#A7A2B8;--line:#2B2838;--brand:#A58BFF;--brand-ink:#14121C;--acc:#FFD23F;--ok:#3DDC97;--warn:#FFB27A;--chip:#262236;color-scheme:dark}
|
| 5 |
+
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
|
| 6 |
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
|
| 7 |
+
a{color:var(--brand)}img,video{max-width:100%}
|
| 8 |
+
.w{max-width:1120px;margin:0 auto;padding:0 16px}
|
| 9 |
+
.top{display:flex;align-items:center;gap:10px;padding:14px 0}.me{display:flex;align-items:center;gap:10px;font-weight:800;font-size:19px;color:var(--ink);text-decoration:none;margin-right:auto}.me img{width:34px;height:34px;border-radius:9px}
|
| 10 |
+
.top nav a{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:8px;font-size:15px}.top nav a:hover{background:var(--chip);color:var(--ink)}
|
| 11 |
+
@media (max-width:560px){.top nav a.h{display:none}}
|
| 12 |
+
.hero{display:grid;grid-template-columns:1.15fr .85fr;gap:28px;align-items:center;padding:18px 0 8px}.hero>*{min-width:0}
|
| 13 |
+
@media (max-width:860px){.hero{grid-template-columns:1fr}}
|
| 14 |
+
h1{font-size:clamp(32px,5.4vw,54px);line-height:1.05;letter-spacing:-.03em;margin:8px 0 14px;text-wrap:balance}
|
| 15 |
+
.lede{font-size:19px;color:var(--muted);margin:0 0 16px;max-width:640px}
|
| 16 |
+
h2{font-size:clamp(24px,3.3vw,32px);letter-spacing:-.02em;line-height:1.15;margin:56px 0 10px}.sub{color:var(--muted);margin:0 0 18px;max-width:760px}
|
| 17 |
+
.badges{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 18px}.badge{font-size:13px;font-weight:700;padding:5px 12px;border-radius:999px;background:var(--card);border:1px solid var(--line)}
|
| 18 |
+
.cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
| 19 |
+
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 20px;border-radius:12px;background:var(--brand);color:var(--brand-ink);font-weight:700;text-decoration:none;border:0;cursor:pointer;font-size:16px}
|
| 20 |
+
.btn:hover{filter:brightness(1.07)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.sm{min-height:38px;padding:6px 12px;font-size:14px;border-radius:10px}
|
| 21 |
+
.phone{justify-self:center;width:min(300px,80vw);aspect-ratio:9/16;border-radius:34px;background:#0c0b12;border:8px solid #22202c;box-shadow:var(--shadow);overflow:hidden;position:relative}
|
| 22 |
+
.phone img,.phone video{width:100%;height:100%;object-fit:cover;display:block}
|
| 23 |
+
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--shadow)}
|
| 24 |
+
.grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}.grid3 b{display:block;margin-bottom:4px}.grid3 p{margin:0;color:var(--muted)}
|
| 25 |
+
.n{display:inline-grid;place-items:center;width:30px;height:30px;border-radius:50%;background:var(--chip);color:var(--brand);font-weight:800;margin-bottom:8px}
|
| 26 |
+
/* app */
|
| 27 |
+
#app{scroll-margin-top:12px}
|
| 28 |
+
.app{background:var(--card);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:var(--shadow);margin-top:18px}
|
| 29 |
+
.apphead{display:flex;flex-wrap:wrap;align-items:center;gap:10px;justify-content:space-between;margin-bottom:12px}.apphead h2{margin:0;font-size:24px}
|
| 30 |
+
#tier{font-size:13px;font-weight:800;padding:4px 12px;border-radius:999px;background:var(--chip);color:var(--brand)}#tier[data-tier=pro],#tier[data-tier=agency]{background:var(--brand);color:var(--brand-ink)}
|
| 31 |
+
.drop{border:2px dashed var(--line);border-radius:16px;padding:28px 16px;text-align:center;cursor:pointer;background:var(--bg)}.drop.over,.drop:hover{border-color:var(--brand);background:var(--chip)}.drop b{font-size:19px;display:block}.drop span{color:var(--muted);font-size:14px}
|
| 32 |
+
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
| 33 |
+
.opts{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin:14px 0}
|
| 34 |
+
.opts label{display:flex;flex-direction:column;font-size:13px;font-weight:700;color:var(--muted);gap:4px}
|
| 35 |
+
input,select{font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:9px 10px;min-height:42px;width:100%}
|
| 36 |
+
input[type=checkbox]{width:20px;height:20px;min-height:0;accent-color:var(--brand)}input[type=color]{padding:2px;height:42px}
|
| 37 |
+
.check{display:flex;gap:8px;align-items:flex-start;font-size:14px;color:var(--muted);margin:6px 0}.check input{flex:none;margin-top:2px}
|
| 38 |
+
.work{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:18px;margin-top:16px}@media (max-width:860px){.work{grid-template-columns:minmax(0,1fr)}}
|
| 39 |
+
#preview{width:100%;border-radius:12px;background:#000;aspect-ratio:16/9}
|
| 40 |
+
.status{margin:12px 0 4px;font-weight:600;min-height:1.6em}.track{height:8px;background:var(--chip);border-radius:999px;overflow:hidden}#bar{height:100%;width:0;background:var(--brand);transition:width .2s}
|
| 41 |
+
#partial{color:var(--muted);font-size:14px;min-height:1.4em;margin:6px 0 0;overflow-wrap:anywhere}
|
| 42 |
+
.moments,.results{list-style:none;padding:0;margin:10px 0 0;display:grid;gap:10px}
|
| 43 |
+
.moment{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;border:1px solid var(--line);border-radius:14px;padding:12px;background:var(--bg)}
|
| 44 |
+
.moment.locked{opacity:.62}.pick{display:flex;flex-direction:column;align-items:center;gap:6px}
|
| 45 |
+
.mhead{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:14px}.time{font-weight:700}.score{color:var(--ok);font-weight:700}
|
| 46 |
+
.title{font-weight:700;margin:6px 0}.excerpt{margin:0;color:var(--muted);font-size:14px}
|
| 47 |
+
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.chip{font-size:12px;padding:2px 8px;border-radius:999px;background:var(--chip)}.chip.tags{background:transparent;border:1px solid var(--line)}
|
| 48 |
+
.nudge{font-size:13px;color:var(--muted);margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;align-items:center}
|
| 49 |
+
.mini{font:inherit;font-size:13px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:4px 8px;cursor:pointer}
|
| 50 |
+
.lockmsg{font-size:13px;margin:6px 0 0}
|
| 51 |
+
.clip{display:grid;grid-template-columns:150px minmax(0,1fr);gap:12px;border:1px solid var(--line);border-radius:14px;padding:12px;background:var(--bg)}.clip video{width:150px;height:auto;aspect-ratio:9/16;background:#000;border-radius:10px}
|
| 52 |
+
@media (max-width:420px){.clip{grid-template-columns:1fr}.clip video{width:100%}}
|
| 53 |
+
.cbody p{margin:4px 0;font-size:14px}.muted{color:var(--muted)}.tags{color:var(--brand)}
|
| 54 |
+
.panel{border:1px solid var(--line);border-radius:14px;padding:14px;margin-top:14px;background:var(--bg)}.panel h3{margin:0 0 8px;font-size:16px}
|
| 55 |
+
.note{font-size:14px;color:var(--muted)}.warn{color:var(--warn)}
|
| 56 |
+
.q{font:inherit;border:0;background:none;color:var(--brand);cursor:pointer;padding:0;text-decoration:underline}.q.on{font-weight:800}
|
| 57 |
+
/* sections */
|
| 58 |
+
.tbl{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;font-size:15px}.tbl th,.tbl td{padding:11px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.tbl th{background:var(--chip);font-size:13px;text-transform:uppercase;letter-spacing:.04em}.tbl tr:last-child td{border-bottom:0}.tbl .us{background:color-mix(in srgb,var(--brand) 9%,var(--card))}
|
| 59 |
+
.scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;border-radius:14px}
|
| 60 |
+
.plans{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}.pl{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:20px;display:flex;flex-direction:column;gap:8px;box-shadow:var(--shadow)}.pl.best{border:2px solid var(--brand)}
|
| 61 |
+
.pl p,.pl ul{margin:0;color:var(--muted);font-size:15px}.pl ul{padding-left:18px}.amt{font-size:38px;font-weight:800;letter-spacing:-.02em}.amt small{font-size:16px;color:var(--muted);font-weight:600}.pl .btn{margin-top:auto}.pl .alt{font-size:14px;text-align:center}
|
| 62 |
+
.tagline{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--brand)}
|
| 63 |
+
details{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 16px;margin:0 0 10px}summary{font-weight:700;cursor:pointer}details p{margin:8px 0 0;color:var(--muted)}
|
| 64 |
+
.band{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;background:var(--ink);color:var(--bg);border-radius:18px;padding:22px;margin:40px 0 0}.band h2{margin:0 0 4px;color:var(--bg);font-size:24px}.band p{margin:0;opacity:.8}.band .btn{background:var(--acc);color:#14121C}
|
| 65 |
+
.shots{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.shots figure{margin:0}.shots img{border-radius:14px;border:1px solid var(--line);box-shadow:var(--shadow);display:block;width:100%;height:auto}.shots figcaption{font-size:14px;color:var(--muted);margin-top:6px}
|
| 66 |
+
footer{color:var(--muted);font-size:14px;border-top:1px solid var(--line);margin-top:56px;padding:22px 0 40px}footer a{color:var(--muted)}
|
| 67 |
+
:focus-visible{outline:3px solid var(--acc);outline-offset:2px}[hidden]{display:none!important}
|
vendor/ffmpeg/NOTICE.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@ffmpeg/ffmpeg 0.12.15 (ESM build, unmodified), MIT License, Copyright (c) 2019 Jerome Wu.
|
| 2 |
+
https://github.com/ffmpegwasm/ffmpeg.wasm/blob/main/LICENSE
|
| 3 |
+
The ffmpeg core (@ffmpeg/core 0.12.10) is loaded at run time from the jsDelivr CDN and is not redistributed here.
|
vendor/ffmpeg/classes.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FFMessageType } from "./const.js";
|
| 2 |
+
import { getMessageID } from "./utils.js";
|
| 3 |
+
import { ERROR_TERMINATED, ERROR_NOT_LOADED } from "./errors.js";
|
| 4 |
+
/**
|
| 5 |
+
* Provides APIs to interact with ffmpeg web worker.
|
| 6 |
+
*
|
| 7 |
+
* @example
|
| 8 |
+
* ```ts
|
| 9 |
+
* const ffmpeg = new FFmpeg();
|
| 10 |
+
* ```
|
| 11 |
+
*/
|
| 12 |
+
export class FFmpeg {
|
| 13 |
+
#worker = null;
|
| 14 |
+
/**
|
| 15 |
+
* #resolves and #rejects tracks Promise resolves and rejects to
|
| 16 |
+
* be called when we receive message from web worker.
|
| 17 |
+
*/
|
| 18 |
+
#resolves = {};
|
| 19 |
+
#rejects = {};
|
| 20 |
+
#logEventCallbacks = [];
|
| 21 |
+
#progressEventCallbacks = [];
|
| 22 |
+
loaded = false;
|
| 23 |
+
/**
|
| 24 |
+
* register worker message event handlers.
|
| 25 |
+
*/
|
| 26 |
+
#registerHandlers = () => {
|
| 27 |
+
if (this.#worker) {
|
| 28 |
+
this.#worker.onmessage = ({ data: { id, type, data }, }) => {
|
| 29 |
+
switch (type) {
|
| 30 |
+
case FFMessageType.LOAD:
|
| 31 |
+
this.loaded = true;
|
| 32 |
+
this.#resolves[id](data);
|
| 33 |
+
break;
|
| 34 |
+
case FFMessageType.MOUNT:
|
| 35 |
+
case FFMessageType.UNMOUNT:
|
| 36 |
+
case FFMessageType.EXEC:
|
| 37 |
+
case FFMessageType.FFPROBE:
|
| 38 |
+
case FFMessageType.WRITE_FILE:
|
| 39 |
+
case FFMessageType.READ_FILE:
|
| 40 |
+
case FFMessageType.DELETE_FILE:
|
| 41 |
+
case FFMessageType.RENAME:
|
| 42 |
+
case FFMessageType.CREATE_DIR:
|
| 43 |
+
case FFMessageType.LIST_DIR:
|
| 44 |
+
case FFMessageType.DELETE_DIR:
|
| 45 |
+
this.#resolves[id](data);
|
| 46 |
+
break;
|
| 47 |
+
case FFMessageType.LOG:
|
| 48 |
+
this.#logEventCallbacks.forEach((f) => f(data));
|
| 49 |
+
break;
|
| 50 |
+
case FFMessageType.PROGRESS:
|
| 51 |
+
this.#progressEventCallbacks.forEach((f) => f(data));
|
| 52 |
+
break;
|
| 53 |
+
case FFMessageType.ERROR:
|
| 54 |
+
this.#rejects[id](data);
|
| 55 |
+
break;
|
| 56 |
+
}
|
| 57 |
+
delete this.#resolves[id];
|
| 58 |
+
delete this.#rejects[id];
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
/**
|
| 63 |
+
* Generic function to send messages to web worker.
|
| 64 |
+
*/
|
| 65 |
+
#send = ({ type, data }, trans = [], signal) => {
|
| 66 |
+
if (!this.#worker) {
|
| 67 |
+
return Promise.reject(ERROR_NOT_LOADED);
|
| 68 |
+
}
|
| 69 |
+
return new Promise((resolve, reject) => {
|
| 70 |
+
const id = getMessageID();
|
| 71 |
+
this.#worker && this.#worker.postMessage({ id, type, data }, trans);
|
| 72 |
+
this.#resolves[id] = resolve;
|
| 73 |
+
this.#rejects[id] = reject;
|
| 74 |
+
signal?.addEventListener("abort", () => {
|
| 75 |
+
reject(new DOMException(`Message # ${id} was aborted`, "AbortError"));
|
| 76 |
+
}, { once: true });
|
| 77 |
+
});
|
| 78 |
+
};
|
| 79 |
+
on(event, callback) {
|
| 80 |
+
if (event === "log") {
|
| 81 |
+
this.#logEventCallbacks.push(callback);
|
| 82 |
+
}
|
| 83 |
+
else if (event === "progress") {
|
| 84 |
+
this.#progressEventCallbacks.push(callback);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
off(event, callback) {
|
| 88 |
+
if (event === "log") {
|
| 89 |
+
this.#logEventCallbacks = this.#logEventCallbacks.filter((f) => f !== callback);
|
| 90 |
+
}
|
| 91 |
+
else if (event === "progress") {
|
| 92 |
+
this.#progressEventCallbacks = this.#progressEventCallbacks.filter((f) => f !== callback);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
/**
|
| 96 |
+
* Loads ffmpeg-core inside web worker. It is required to call this method first
|
| 97 |
+
* as it initializes WebAssembly and other essential variables.
|
| 98 |
+
*
|
| 99 |
+
* @category FFmpeg
|
| 100 |
+
* @returns `true` if ffmpeg core is loaded for the first time.
|
| 101 |
+
*/
|
| 102 |
+
load = ({ classWorkerURL, ...config } = {}, { signal } = {}) => {
|
| 103 |
+
if (!this.#worker) {
|
| 104 |
+
this.#worker = classWorkerURL ?
|
| 105 |
+
new Worker(new URL(classWorkerURL, import.meta.url), {
|
| 106 |
+
type: "module",
|
| 107 |
+
}) :
|
| 108 |
+
// We need to duplicated the code here to enable webpack
|
| 109 |
+
// to bundle worekr.js here.
|
| 110 |
+
new Worker(new URL("./worker.js", import.meta.url), {
|
| 111 |
+
type: "module",
|
| 112 |
+
});
|
| 113 |
+
this.#registerHandlers();
|
| 114 |
+
}
|
| 115 |
+
return this.#send({
|
| 116 |
+
type: FFMessageType.LOAD,
|
| 117 |
+
data: config,
|
| 118 |
+
}, undefined, signal);
|
| 119 |
+
};
|
| 120 |
+
/**
|
| 121 |
+
* Execute ffmpeg command.
|
| 122 |
+
*
|
| 123 |
+
* @remarks
|
| 124 |
+
* To avoid common I/O issues, ["-nostdin", "-y"] are prepended to the args
|
| 125 |
+
* by default.
|
| 126 |
+
*
|
| 127 |
+
* @example
|
| 128 |
+
* ```ts
|
| 129 |
+
* const ffmpeg = new FFmpeg();
|
| 130 |
+
* await ffmpeg.load();
|
| 131 |
+
* await ffmpeg.writeFile("video.avi", ...);
|
| 132 |
+
* // ffmpeg -i video.avi video.mp4
|
| 133 |
+
* await ffmpeg.exec(["-i", "video.avi", "video.mp4"]);
|
| 134 |
+
* const data = ffmpeg.readFile("video.mp4");
|
| 135 |
+
* ```
|
| 136 |
+
*
|
| 137 |
+
* @returns `0` if no error, `!= 0` if timeout (1) or error.
|
| 138 |
+
* @category FFmpeg
|
| 139 |
+
*/
|
| 140 |
+
exec = (
|
| 141 |
+
/** ffmpeg command line args */
|
| 142 |
+
args,
|
| 143 |
+
/**
|
| 144 |
+
* milliseconds to wait before stopping the command execution.
|
| 145 |
+
*
|
| 146 |
+
* @defaultValue -1
|
| 147 |
+
*/
|
| 148 |
+
timeout = -1, { signal } = {}) => this.#send({
|
| 149 |
+
type: FFMessageType.EXEC,
|
| 150 |
+
data: { args, timeout },
|
| 151 |
+
}, undefined, signal);
|
| 152 |
+
/**
|
| 153 |
+
* Execute ffprobe command.
|
| 154 |
+
*
|
| 155 |
+
* @example
|
| 156 |
+
* ```ts
|
| 157 |
+
* const ffmpeg = new FFmpeg();
|
| 158 |
+
* await ffmpeg.load();
|
| 159 |
+
* await ffmpeg.writeFile("video.avi", ...);
|
| 160 |
+
* // Getting duration of a video in seconds: ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 video.avi -o output.txt
|
| 161 |
+
* await ffmpeg.ffprobe(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", "video.avi", "-o", "output.txt"]);
|
| 162 |
+
* const data = ffmpeg.readFile("output.txt");
|
| 163 |
+
* ```
|
| 164 |
+
*
|
| 165 |
+
* @returns `0` if no error, `!= 0` if timeout (1) or error.
|
| 166 |
+
* @category FFmpeg
|
| 167 |
+
*/
|
| 168 |
+
ffprobe = (
|
| 169 |
+
/** ffprobe command line args */
|
| 170 |
+
args,
|
| 171 |
+
/**
|
| 172 |
+
* milliseconds to wait before stopping the command execution.
|
| 173 |
+
*
|
| 174 |
+
* @defaultValue -1
|
| 175 |
+
*/
|
| 176 |
+
timeout = -1, { signal } = {}) => this.#send({
|
| 177 |
+
type: FFMessageType.FFPROBE,
|
| 178 |
+
data: { args, timeout },
|
| 179 |
+
}, undefined, signal);
|
| 180 |
+
/**
|
| 181 |
+
* Terminate all ongoing API calls and terminate web worker.
|
| 182 |
+
* `FFmpeg.load()` must be called again before calling any other APIs.
|
| 183 |
+
*
|
| 184 |
+
* @category FFmpeg
|
| 185 |
+
*/
|
| 186 |
+
terminate = () => {
|
| 187 |
+
const ids = Object.keys(this.#rejects);
|
| 188 |
+
// rejects all incomplete Promises.
|
| 189 |
+
for (const id of ids) {
|
| 190 |
+
this.#rejects[id](ERROR_TERMINATED);
|
| 191 |
+
delete this.#rejects[id];
|
| 192 |
+
delete this.#resolves[id];
|
| 193 |
+
}
|
| 194 |
+
if (this.#worker) {
|
| 195 |
+
this.#worker.terminate();
|
| 196 |
+
this.#worker = null;
|
| 197 |
+
this.loaded = false;
|
| 198 |
+
}
|
| 199 |
+
};
|
| 200 |
+
/**
|
| 201 |
+
* Write data to ffmpeg.wasm.
|
| 202 |
+
*
|
| 203 |
+
* @example
|
| 204 |
+
* ```ts
|
| 205 |
+
* const ffmpeg = new FFmpeg();
|
| 206 |
+
* await ffmpeg.load();
|
| 207 |
+
* await ffmpeg.writeFile("video.avi", await fetchFile("../video.avi"));
|
| 208 |
+
* await ffmpeg.writeFile("text.txt", "hello world");
|
| 209 |
+
* ```
|
| 210 |
+
*
|
| 211 |
+
* @category File System
|
| 212 |
+
*/
|
| 213 |
+
writeFile = (path, data, { signal } = {}) => {
|
| 214 |
+
const trans = [];
|
| 215 |
+
if (data instanceof Uint8Array) {
|
| 216 |
+
trans.push(data.buffer);
|
| 217 |
+
}
|
| 218 |
+
return this.#send({
|
| 219 |
+
type: FFMessageType.WRITE_FILE,
|
| 220 |
+
data: { path, data },
|
| 221 |
+
}, trans, signal);
|
| 222 |
+
};
|
| 223 |
+
mount = (fsType, options, mountPoint) => {
|
| 224 |
+
const trans = [];
|
| 225 |
+
return this.#send({
|
| 226 |
+
type: FFMessageType.MOUNT,
|
| 227 |
+
data: { fsType, options, mountPoint },
|
| 228 |
+
}, trans);
|
| 229 |
+
};
|
| 230 |
+
unmount = (mountPoint) => {
|
| 231 |
+
const trans = [];
|
| 232 |
+
return this.#send({
|
| 233 |
+
type: FFMessageType.UNMOUNT,
|
| 234 |
+
data: { mountPoint },
|
| 235 |
+
}, trans);
|
| 236 |
+
};
|
| 237 |
+
/**
|
| 238 |
+
* Read data from ffmpeg.wasm.
|
| 239 |
+
*
|
| 240 |
+
* @example
|
| 241 |
+
* ```ts
|
| 242 |
+
* const ffmpeg = new FFmpeg();
|
| 243 |
+
* await ffmpeg.load();
|
| 244 |
+
* const data = await ffmpeg.readFile("video.mp4");
|
| 245 |
+
* ```
|
| 246 |
+
*
|
| 247 |
+
* @category File System
|
| 248 |
+
*/
|
| 249 |
+
readFile = (path,
|
| 250 |
+
/**
|
| 251 |
+
* File content encoding, supports two encodings:
|
| 252 |
+
* - utf8: read file as text file, return data in string type.
|
| 253 |
+
* - binary: read file as binary file, return data in Uint8Array type.
|
| 254 |
+
*
|
| 255 |
+
* @defaultValue binary
|
| 256 |
+
*/
|
| 257 |
+
encoding = "binary", { signal } = {}) => this.#send({
|
| 258 |
+
type: FFMessageType.READ_FILE,
|
| 259 |
+
data: { path, encoding },
|
| 260 |
+
}, undefined, signal);
|
| 261 |
+
/**
|
| 262 |
+
* Delete a file.
|
| 263 |
+
*
|
| 264 |
+
* @category File System
|
| 265 |
+
*/
|
| 266 |
+
deleteFile = (path, { signal } = {}) => this.#send({
|
| 267 |
+
type: FFMessageType.DELETE_FILE,
|
| 268 |
+
data: { path },
|
| 269 |
+
}, undefined, signal);
|
| 270 |
+
/**
|
| 271 |
+
* Rename a file or directory.
|
| 272 |
+
*
|
| 273 |
+
* @category File System
|
| 274 |
+
*/
|
| 275 |
+
rename = (oldPath, newPath, { signal } = {}) => this.#send({
|
| 276 |
+
type: FFMessageType.RENAME,
|
| 277 |
+
data: { oldPath, newPath },
|
| 278 |
+
}, undefined, signal);
|
| 279 |
+
/**
|
| 280 |
+
* Create a directory.
|
| 281 |
+
*
|
| 282 |
+
* @category File System
|
| 283 |
+
*/
|
| 284 |
+
createDir = (path, { signal } = {}) => this.#send({
|
| 285 |
+
type: FFMessageType.CREATE_DIR,
|
| 286 |
+
data: { path },
|
| 287 |
+
}, undefined, signal);
|
| 288 |
+
/**
|
| 289 |
+
* List directory contents.
|
| 290 |
+
*
|
| 291 |
+
* @category File System
|
| 292 |
+
*/
|
| 293 |
+
listDir = (path, { signal } = {}) => this.#send({
|
| 294 |
+
type: FFMessageType.LIST_DIR,
|
| 295 |
+
data: { path },
|
| 296 |
+
}, undefined, signal);
|
| 297 |
+
/**
|
| 298 |
+
* Delete an empty directory.
|
| 299 |
+
*
|
| 300 |
+
* @category File System
|
| 301 |
+
*/
|
| 302 |
+
deleteDir = (path, { signal } = {}) => this.#send({
|
| 303 |
+
type: FFMessageType.DELETE_DIR,
|
| 304 |
+
data: { path },
|
| 305 |
+
}, undefined, signal);
|
| 306 |
+
}
|
vendor/ffmpeg/const.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const MIME_TYPE_JAVASCRIPT = "text/javascript";
|
| 2 |
+
export const MIME_TYPE_WASM = "application/wasm";
|
| 3 |
+
export const CORE_VERSION = "0.12.9";
|
| 4 |
+
export const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;
|
| 5 |
+
export var FFMessageType;
|
| 6 |
+
(function (FFMessageType) {
|
| 7 |
+
FFMessageType["LOAD"] = "LOAD";
|
| 8 |
+
FFMessageType["EXEC"] = "EXEC";
|
| 9 |
+
FFMessageType["FFPROBE"] = "FFPROBE";
|
| 10 |
+
FFMessageType["WRITE_FILE"] = "WRITE_FILE";
|
| 11 |
+
FFMessageType["READ_FILE"] = "READ_FILE";
|
| 12 |
+
FFMessageType["DELETE_FILE"] = "DELETE_FILE";
|
| 13 |
+
FFMessageType["RENAME"] = "RENAME";
|
| 14 |
+
FFMessageType["CREATE_DIR"] = "CREATE_DIR";
|
| 15 |
+
FFMessageType["LIST_DIR"] = "LIST_DIR";
|
| 16 |
+
FFMessageType["DELETE_DIR"] = "DELETE_DIR";
|
| 17 |
+
FFMessageType["ERROR"] = "ERROR";
|
| 18 |
+
FFMessageType["DOWNLOAD"] = "DOWNLOAD";
|
| 19 |
+
FFMessageType["PROGRESS"] = "PROGRESS";
|
| 20 |
+
FFMessageType["LOG"] = "LOG";
|
| 21 |
+
FFMessageType["MOUNT"] = "MOUNT";
|
| 22 |
+
FFMessageType["UNMOUNT"] = "UNMOUNT";
|
| 23 |
+
})(FFMessageType || (FFMessageType = {}));
|
vendor/ffmpeg/errors.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const ERROR_UNKNOWN_MESSAGE_TYPE = new Error("unknown message type");
|
| 2 |
+
export const ERROR_NOT_LOADED = new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first");
|
| 3 |
+
export const ERROR_TERMINATED = new Error("called FFmpeg.terminate()");
|
| 4 |
+
export const ERROR_IMPORT_FAILURE = new Error("failed to import ffmpeg-core.js");
|
vendor/ffmpeg/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export * from "./classes.js";
|
| 2 |
+
export * from "./types.js";
|
vendor/ffmpeg/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export var FFFSType;
|
| 2 |
+
(function (FFFSType) {
|
| 3 |
+
FFFSType["MEMFS"] = "MEMFS";
|
| 4 |
+
FFFSType["NODEFS"] = "NODEFS";
|
| 5 |
+
FFFSType["NODERAWFS"] = "NODERAWFS";
|
| 6 |
+
FFFSType["IDBFS"] = "IDBFS";
|
| 7 |
+
FFFSType["WORKERFS"] = "WORKERFS";
|
| 8 |
+
FFFSType["PROXYFS"] = "PROXYFS";
|
| 9 |
+
})(FFFSType || (FFFSType = {}));
|
vendor/ffmpeg/utils.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Generate an unique message ID.
|
| 3 |
+
*/
|
| 4 |
+
export const getMessageID = (() => {
|
| 5 |
+
let messageID = 0;
|
| 6 |
+
return () => messageID++;
|
| 7 |
+
})();
|
vendor/ffmpeg/worker.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference no-default-lib="true" />
|
| 2 |
+
/// <reference lib="esnext" />
|
| 3 |
+
/// <reference lib="webworker" />
|
| 4 |
+
import { CORE_URL, FFMessageType } from "./const.js";
|
| 5 |
+
import { ERROR_UNKNOWN_MESSAGE_TYPE, ERROR_NOT_LOADED, ERROR_IMPORT_FAILURE, } from "./errors.js";
|
| 6 |
+
let ffmpeg;
|
| 7 |
+
const load = async ({ coreURL: _coreURL, wasmURL: _wasmURL, workerURL: _workerURL, }) => {
|
| 8 |
+
const first = !ffmpeg;
|
| 9 |
+
try {
|
| 10 |
+
if (!_coreURL)
|
| 11 |
+
_coreURL = CORE_URL;
|
| 12 |
+
// when web worker type is `classic`.
|
| 13 |
+
importScripts(_coreURL);
|
| 14 |
+
}
|
| 15 |
+
catch {
|
| 16 |
+
if (!_coreURL || _coreURL === CORE_URL)
|
| 17 |
+
_coreURL = CORE_URL.replace('/umd/', '/esm/');
|
| 18 |
+
// when web worker type is `module`.
|
| 19 |
+
self.createFFmpegCore = (await import(
|
| 20 |
+
/* @vite-ignore */ _coreURL)).default;
|
| 21 |
+
if (!self.createFFmpegCore) {
|
| 22 |
+
throw ERROR_IMPORT_FAILURE;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
const coreURL = _coreURL;
|
| 26 |
+
const wasmURL = _wasmURL ? _wasmURL : _coreURL.replace(/.js$/g, ".wasm");
|
| 27 |
+
const workerURL = _workerURL
|
| 28 |
+
? _workerURL
|
| 29 |
+
: _coreURL.replace(/.js$/g, ".worker.js");
|
| 30 |
+
ffmpeg = await self.createFFmpegCore({
|
| 31 |
+
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
|
| 32 |
+
// Encoded wasmURL and workerURL in the URL as a hack to fix locateFile issue.
|
| 33 |
+
mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL, workerURL }))}`,
|
| 34 |
+
});
|
| 35 |
+
ffmpeg.setLogger((data) => self.postMessage({ type: FFMessageType.LOG, data }));
|
| 36 |
+
ffmpeg.setProgress((data) => self.postMessage({
|
| 37 |
+
type: FFMessageType.PROGRESS,
|
| 38 |
+
data,
|
| 39 |
+
}));
|
| 40 |
+
return first;
|
| 41 |
+
};
|
| 42 |
+
const exec = ({ args, timeout = -1 }) => {
|
| 43 |
+
ffmpeg.setTimeout(timeout);
|
| 44 |
+
ffmpeg.exec(...args);
|
| 45 |
+
const ret = ffmpeg.ret;
|
| 46 |
+
ffmpeg.reset();
|
| 47 |
+
return ret;
|
| 48 |
+
};
|
| 49 |
+
const ffprobe = ({ args, timeout = -1 }) => {
|
| 50 |
+
ffmpeg.setTimeout(timeout);
|
| 51 |
+
ffmpeg.ffprobe(...args);
|
| 52 |
+
const ret = ffmpeg.ret;
|
| 53 |
+
ffmpeg.reset();
|
| 54 |
+
return ret;
|
| 55 |
+
};
|
| 56 |
+
const writeFile = ({ path, data }) => {
|
| 57 |
+
ffmpeg.FS.writeFile(path, data);
|
| 58 |
+
return true;
|
| 59 |
+
};
|
| 60 |
+
const readFile = ({ path, encoding }) => ffmpeg.FS.readFile(path, { encoding });
|
| 61 |
+
// TODO: check if deletion works.
|
| 62 |
+
const deleteFile = ({ path }) => {
|
| 63 |
+
ffmpeg.FS.unlink(path);
|
| 64 |
+
return true;
|
| 65 |
+
};
|
| 66 |
+
const rename = ({ oldPath, newPath }) => {
|
| 67 |
+
ffmpeg.FS.rename(oldPath, newPath);
|
| 68 |
+
return true;
|
| 69 |
+
};
|
| 70 |
+
// TODO: check if creation works.
|
| 71 |
+
const createDir = ({ path }) => {
|
| 72 |
+
ffmpeg.FS.mkdir(path);
|
| 73 |
+
return true;
|
| 74 |
+
};
|
| 75 |
+
const listDir = ({ path }) => {
|
| 76 |
+
const names = ffmpeg.FS.readdir(path);
|
| 77 |
+
const nodes = [];
|
| 78 |
+
for (const name of names) {
|
| 79 |
+
const stat = ffmpeg.FS.stat(`${path}/${name}`);
|
| 80 |
+
const isDir = ffmpeg.FS.isDir(stat.mode);
|
| 81 |
+
nodes.push({ name, isDir });
|
| 82 |
+
}
|
| 83 |
+
return nodes;
|
| 84 |
+
};
|
| 85 |
+
// TODO: check if deletion works.
|
| 86 |
+
const deleteDir = ({ path }) => {
|
| 87 |
+
ffmpeg.FS.rmdir(path);
|
| 88 |
+
return true;
|
| 89 |
+
};
|
| 90 |
+
const mount = ({ fsType, options, mountPoint }) => {
|
| 91 |
+
const str = fsType;
|
| 92 |
+
const fs = ffmpeg.FS.filesystems[str];
|
| 93 |
+
if (!fs)
|
| 94 |
+
return false;
|
| 95 |
+
ffmpeg.FS.mount(fs, options, mountPoint);
|
| 96 |
+
return true;
|
| 97 |
+
};
|
| 98 |
+
const unmount = ({ mountPoint }) => {
|
| 99 |
+
ffmpeg.FS.unmount(mountPoint);
|
| 100 |
+
return true;
|
| 101 |
+
};
|
| 102 |
+
self.onmessage = async ({ data: { id, type, data: _data }, }) => {
|
| 103 |
+
const trans = [];
|
| 104 |
+
let data;
|
| 105 |
+
try {
|
| 106 |
+
if (type !== FFMessageType.LOAD && !ffmpeg)
|
| 107 |
+
throw ERROR_NOT_LOADED; // eslint-disable-line
|
| 108 |
+
switch (type) {
|
| 109 |
+
case FFMessageType.LOAD:
|
| 110 |
+
data = await load(_data);
|
| 111 |
+
break;
|
| 112 |
+
case FFMessageType.EXEC:
|
| 113 |
+
data = exec(_data);
|
| 114 |
+
break;
|
| 115 |
+
case FFMessageType.FFPROBE:
|
| 116 |
+
data = ffprobe(_data);
|
| 117 |
+
break;
|
| 118 |
+
case FFMessageType.WRITE_FILE:
|
| 119 |
+
data = writeFile(_data);
|
| 120 |
+
break;
|
| 121 |
+
case FFMessageType.READ_FILE:
|
| 122 |
+
data = readFile(_data);
|
| 123 |
+
break;
|
| 124 |
+
case FFMessageType.DELETE_FILE:
|
| 125 |
+
data = deleteFile(_data);
|
| 126 |
+
break;
|
| 127 |
+
case FFMessageType.RENAME:
|
| 128 |
+
data = rename(_data);
|
| 129 |
+
break;
|
| 130 |
+
case FFMessageType.CREATE_DIR:
|
| 131 |
+
data = createDir(_data);
|
| 132 |
+
break;
|
| 133 |
+
case FFMessageType.LIST_DIR:
|
| 134 |
+
data = listDir(_data);
|
| 135 |
+
break;
|
| 136 |
+
case FFMessageType.DELETE_DIR:
|
| 137 |
+
data = deleteDir(_data);
|
| 138 |
+
break;
|
| 139 |
+
case FFMessageType.MOUNT:
|
| 140 |
+
data = mount(_data);
|
| 141 |
+
break;
|
| 142 |
+
case FFMessageType.UNMOUNT:
|
| 143 |
+
data = unmount(_data);
|
| 144 |
+
break;
|
| 145 |
+
default:
|
| 146 |
+
throw ERROR_UNKNOWN_MESSAGE_TYPE;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
catch (e) {
|
| 150 |
+
self.postMessage({
|
| 151 |
+
id,
|
| 152 |
+
type: FFMessageType.ERROR,
|
| 153 |
+
data: e.toString(),
|
| 154 |
+
});
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
if (data instanceof Uint8Array) {
|
| 158 |
+
trans.push(data.buffer);
|
| 159 |
+
}
|
| 160 |
+
self.postMessage({ id, type, data }, trans);
|
| 161 |
+
};
|