VerseCut technical documentation
Version 3.0 · September 2026 · Stateam LLC
An engineering reference for administrators and developers, alongside the user guide and the install guide. Every statement is drawn from the 3.0 source tree; identifiers are given exactly as they appear so you can navigate from this document to the code.
1. Overview
VerseCut is a video editor for teaching and sermon production: a local HTTP server written in Python that serves a single-page browser interface and drives FFmpeg and faster-whisper as ordinary external programs.
Design constraints
| Constraint | How it is met |
|---|---|
| Local only | The server binds 127.0.0.1 only (ThreadingHTTPServer(("127.0.0.1", port), Handler) in main()). There is no outbound request anywhere in app/ after setup |
| No account | Licensing is a signed file verified on the machine (engine/licensing.py). There is no activation server and no sign-in |
| No telemetry | Nothing is reported anywhere. The server sets HF_HUB_DISABLE_TELEMETRY, HF_HUB_DISABLE_SYMLINKS_WARNING and HF_HUB_DISABLE_PROGRESS_BARS at import so the Hugging Face hub library stays quiet too |
| No background service | The process starts when the user opens VerseCut and exits on its own. watchdog() calls os._exit(0) once the window has not pinged for 120 seconds and no job is running |
| No auto-update | Nothing in the application checks a version or downloads code. Updating means running the installer again |
| Inspectable source | The whole product ships as readable Python and JavaScript. Even Ed25519 signature verification is pure Python (engine/ed25519.py) so that licence checking cannot be disabled by deleting a package |
Technology choices that follow
The interface is plain ES modules and DOM — no framework, no build step, no bundler; app/web/js/*.js is served verbatim. The server is http.server.ThreadingHTTPServer from the standard library. The third-party runtime packages are faster-whisper (transcription, installed without PyAV) and Pillow (drawing burned-in captions), with their pinned dependencies. Rendering is one FFmpeg invocation per export.
Platforms and Python
| Supported | |
|---|---|
| Windows | 10 or 11, 64-bit. The installer fetches Python 3.12.10 per-user if py -3.12 and the usual install locations do not provide it |
| macOS | 12 Monterey or later, Intel and Apple silicon. The installer brings its own Python 3.12.14 inside VerseCut.app; the Mac's own Python is never used |
| Linux | Not a supported product, but the engine runs for development with VERSECUT_DEV=1 |
Both platforms run Python 3.12 with the same 33 pinned packages.
2. Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Browser window (Edge / Chrome --app=, or the default browser) │
│ │
│ index.html ← token + version substituted at serve time │
│ main.js store.js timeline.js preview.js panels.js │
│ dialogs.js titles.js licence.js util.js │
└───────────────┬──────────────────────────────────────────────────┘
│ fetch() POST /api/… · GET /media · GET /api/jobs/<id>
│ X-VerseCut-Token: <per-launch token>
┌───────────────▼──────────────────────────────────────────────────┐
│ app/server.py ThreadingHTTPServer on 127.0.0.1:<8765…8814> │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Handler │ │ JOBS dict │ │ tidy_project() lic() │ │
│ │ api_* routes │ │ start_job() │ │ reveal() open_file() │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────────────────┘ │
└─────────┼────────────────┼───────────────────────────────────────┘
│ │
┌─────────▼────────────────▼───────────────────────────────────────┐
│ app/engine/ │
│ paths.py folders, tool discovery, subprocess helpers │
│ media.py probe, media_id, registry, thumbs, peaks, proxies │
│ render.py build(), apply_transitions(), run_ffmpeg() │
│ captions.py transcribe(), SRT/VTT/ITT, caption frames │
│ cards.py import_cards() — FCPXML / CSV / file-name timing │
│ licensing.py evaluate(), gate*(), install() ed25519.py │
└─────────┬──────────────────────────┬─────────────────────────────┘
│ subprocess │ import
┌─────────▼──────────────┐ ┌─────────▼─────────────────────────────┐
│ runtime/ffmpeg/ffmpeg │ │ faster-whisper (CTranslate2, CPU int8)│
│ runtime/ffmpeg/ffprobe │ │ runtime/models/models--Systran--… │
└────────────────────────┘ └───────────────────────────────────────┘
│
┌─────────▼────────────────────────────────────────────────────────┐
│ Documents/VerseCut/Projects *.wcproj, *_assets/ │
│ Documents/VerseCut/Scripture Cards │
│ Videos (or ~/Movies)/VerseCut Exports │
│ <cache root>/VerseCut/cache/<media id>/ meta.json thumb.jpg … │
│ <cache root>/VerseCut/licence/ versecut.licence state.json │
└──────────────────────────────────────────────────────────────────┘
The browser window. open_window() looks for Edge or Chrome at known paths and launches it with --app=<url>, a dedicated --user-data-dir under the cache root, --window-size=1600,960, --no-first-run, --no-default-browser-check and --disable-sync, giving a chromeless window rather than a tab. If none is found it falls back to webbrowser.open(url).
The local HTTP server. One Handler class. do_GET serves the page, static files, media bytes and job status; do_POST dispatches /api/<route> to api_<route with / replaced by _> by getattr. Both check the Host header first.
The engine modules are plain Python packages with no web knowledge, importable and drivable from a script — which is what tests/test_licensing.py and tests/test_3_0.py do.
FFmpeg and faster-whisper are external. FFmpeg is found by paths._find_tool(), which prefers runtime/ffmpeg/ffmpeg[.exe] and falls back to shutil.which. faster-whisper is imported lazily inside captions.transcribe(); captions.whisper_available() is a guarded import used to report capability.
Request path for an export
- The user clicks Export.
main.jsACTIONS.exportstops playback and callsD.exportDialog(). dialogs.jsofferscheckLeadingBlack(), builds the settings object from the chosen preset (uhd4k,yt1080,std1080,hd720,mp3→ width, height, CRF/quality), encoder, folder and file name, and checksPOST /api/exists.ensureTitlePNGs(W, H)renders every title clip at the export resolution throughtitles.renderTitle()and posts each toPOST /api/asset/png, storingc.pngandc.pngKeyon the clip.POST /api/exportwith{project, settings}. The handler runstidy_project(), thenlicensing.gate_height(), then the two inline gates for transitions and theV3/V4logo tracks. A refusal raisesLicenceErrorand the response is 402.start_job("export", job_export, project, settings)creates a job dict, spawns a daemon thread and returns the job immediately.job_export()first refuses outright ifrender.available_encoders()is empty. Otherwise it makes a temporary directory, callsrender.build()for(cmd, total), thenrender.run_ffmpeg()inside a retry loop that walks down the list of working encoders if one fails part-way.render.build()writes the filtergraph tograph.txtin the work directory and returns an argument list containing the sentinel__GRAPH__.run_ffmpeg()replaces it with-/filter_complexor-filter_complex_scriptdepending on what_graph_option()probed, runs FFmpeg withcwdset to the work directory, and parsesout_time_us=/out_time_ms=lines from-progress pipe:1intojob["progress"].- Meanwhile the page polls
GET /api/jobs/<id>every 400 ms throughutil.waitJob(), updating the bar and the estimate. - On a zero exit,
job_export()optionally writes a sibling.srtfrom the caption clips and returns{"files": [...]};statusbecomesdone. The dialog then offers Copy path / Play video / Show in folder, which callPOST /api/openfileandPOST /api/reveal.
3. Repository layout
VerseCut/ (the download; Windows and macOS packages share app/, docs/, tests/)
├─ START HERE.txt first-run instructions, log file locations
├─ README.md overview, plans, what changed in 3.0
├─ LICENCE.txt end-user licence agreement
├─ Install-VerseCut.bat Windows: runs setup/install.ps1 via PowerShell
├─ VerseCut.bat Windows launcher (runs setup first if runtime is absent)
├─ Verify-VerseCut.bat Windows: runs tests/test_licensing.py and tests/test_3_0.py
├─ Install VerseCut.command macOS: runs setup/install-macos.sh, which builds VerseCut.app
├─ app/
│ ├─ server.py HTTP server, routes, jobs, tidy_project, launch, watchdog
│ ├─ versecut.ico / .icns Windows shortcut icon / macOS bundle icon
│ ├─ fonts/ Source Sans 3 Semibold (SIL OFL) for burned-in captions
│ ├─ engine/
│ │ ├─ __init__.py empty package marker
│ │ ├─ paths.py platform folders, FFmpeg discovery, run/popen helpers
│ │ ├─ codecs.py encoder policy: OS H.264/AAC encoders only, LAME for MP3
│ │ ├─ media.py probing, media_id, registry, thumbs, peaks, proxies
│ │ ├─ render.py filtergraph construction, transitions
│ │ ├─ captions.py faster-whisper (without PyAV), SRT/VTT/ITT/TTML, caption frames
│ │ ├─ cards.py scripture card extraction and timing
│ │ ├─ licensing.py licence format, evaluate(), feature gates
│ │ └─ ed25519.py RFC 8032 signature verification, pure Python
│ └─ web/
│ ├─ index.html the whole document; __TOKEN__ / __VERSION__ substituted
│ ├─ style.css stylesheet
│ ├─ icon.png favicon and brand mark
│ └─ js/ nine ES modules, see section 4
├─ setup/
│ ├─ install.ps1 Windows setup, six steps
│ ├─ install-macos.sh macOS setup, seven steps
│ ├─ launch-macos.sh becomes VerseCut.app/Contents/MacOS/VerseCut
│ ├─ requirements.txt 33 pinned packages (no PyAV)
│ ├─ requirements-nodeps.txt faster-whisper==1.2.1, installed with --no-deps
│ ├─ ffmpeg/ Windows package: LGPL FFmpeg 9.0 + SHA256SUMS
│ └─ ffmpeg-macos/ macOS package: LGPL FFmpeg 9.0 universal + SHA256SUMS
├─ docs/ install.html, guide.html, technical.html, notices.html
├─ tests/
│ ├─ test_licensing.py offline licence and project-hygiene suite
│ ├─ test_3_0.py encoder policy, captions, export, installer checks
│ └─ test_ui.py end-to-end suite against a running engine
└─ runtime/ Windows, created by setup: venv/, ffmpeg/, models/, install.log
4. Front-end modules
| File | Owns | Main exports |
|---|---|---|
util.js |
HTTP helpers, formatting, toast, modal, localStorage wrapper |
api, getJob, waitJob, mediaURL, tc, shortTime, parseTime, toast, openModal, closeModal, confirmBox, store, $, $$, esc, uid, clamp |
store.js |
The project object, selection, undo/redo, every timeline edit primitive | S, on, emit, newProject, setProject, tidyProject, checkpoint, commit, changed, undo, redo, expanded, makeClip, addMediaClip, clearRange, splitAt, removeClips, removeRange, rippleDelete, closeGaps, freeStart, addTrack, accepts, defaultFit |
timeline.js |
Track lanes, clip drawing, ruler, drag/trim/drop, scrub, zoom | initTimeline, render, placePlayhead, follow, seek, setZoom, zoomFit, ppsFromZoom, zoomFromPps, defaultTrackFor, addTitle |
preview.js |
The stage: layers, playback sync, on-canvas drag/resize, drop onto the picture | buildLayers, fit, tick, pauseAll, initStageEditing, baseSize, boxOf, placeAt |
panels.js |
Media bin, title presets, caption list, audio mix, Inspector | initPanels, renderBin, renderCaptions, renderMix, renderInspector, captionClips, highlightCue, placeLogo, showTab |
dialogs.js |
File browser and every modal workflow | pickFiles, importMedia, openProject, saveProject, importCards, transcribe, importCaptions, exportCaptions, exportDialog, newProjectDialog, defaultName, help |
titles.js |
Canvas title renderer and the five presets | PRESETS, renderTitle, defaultTitle, titleKey |
licence.js |
Licence dialog, plan badge, upgrade prompt | licence, can, maxHeight, refresh, paint, upgradePrompt, licenceDialog |
main.js |
Entry point: wiring, ACTIONS, keyboard, render loop, polling, autosave |
none (side effects; calls boot()) |
The state store
S is a single mutable object exported from store.js:
export const S = {
project: null, // the .wcproj object currently open
sel: new Set(), // selected clip ids
time: 0, // playhead, seconds
playing: false,
pps: 40, // pixels per second on the timeline
dirty: false, // unsaved edits exist
env: {}, // the /api/state reply: ffmpeg, whisper, models, encoders, licence…
};
Two further fields are added at run time: S.gap, set by timeline.js when the user clicks empty space on a track, and S.rev, the revision counter below.
The event bus
listeners is a plain object of arrays. on(ev, fn) appends; emit(ev, data) calls each synchronously. The events are project (a different project is loaded — rebuild layers), change (redraw everything), select (redraw the Inspector), light (redraw the timeline only), seek, licence and focusInspector.
Undo/redo
snap() serialises only the mutable parts — clips, tracks, media and mix — with JSON.stringify. checkpoint() pushes a snapshot onto undoStack, trims it to UNDO_DEPTH = 60 entries and clears redoStack. commit(fn) is the wrapper every user edit should use: checkpoint, mutate, changed().
restore(s) parses the snapshot and Object.assigns it onto S.project, but re-attaches the live media objects by id so background status (thumb, peaks, proxy, status) survives an undo, and drops any selected id that no longer exists. The 60-entry cap replaced a 200-entry history that held too much memory on a long programme.
Interactive gestures call checkpoint() once, on the first movement past the drag threshold (onStageDown() in preview.js, onDragMove() in timeline.js), so a whole drag is one undo step. The Inspector sliders do the same via box._drag.
S.rev and the memoised expanded()
changed() increments S.rev and emits change. expanded() is the front-end mirror of render.apply_transitions(): it takes the clip list and returns concrete overlaps, fades and dip markers so the preview shows exactly what the export will produce. Because it runs on every animation frame it is memoised:
let memo = { rev: -1 };
export function expanded() {
if (memo.rev === S.rev && memo.project === S.project) return memo;
…
memo = { rev: S.rev, project: S.project, clips, dips };
return memo;
}
Each returned clip is a shallow copy carrying _src, a reference to the real clip, so playback can read live values (volume, muted, x, y, scale) from the original while using the expanded timing. changedLight() in timeline.js bumps S.rev during a drag without a full redraw, keeping the drag smooth while still invalidating the memo.
Stage-space geometry
The stage is a fixed 1920 × 1080 element scaled with a CSS transform:
scaleK = Math.min(wrap.clientWidth / W, wrap.clientHeight / H) * 0.98;
stage().style.transform = `scale(${scaleK})`;
stage().style.setProperty('--inv', String(1 / scaleK));
All geometry is computed in that 1920 × 1080 space, never in screen pixels. --inv lets the stylesheet keep selection handles a constant on-screen size. stagePoint(e) converts a pointer event with (e.clientX - rect.left) / scaleK.
baseSize(c)— the unscaled size: the full frame for a title, the media's own pixels whenfit === 'native', otherwise the frame-fitted size preserving aspect ratio.boxOf(c)— appliesc.scaleand the centre offsetsc.x,c.y, returning{x, y, w, h, cx, cy}.placeAt(c, pos)— setsx/yfor one of nine positions with a safe margin ofSAFE_X = 96,SAFE_Y = 54.snapBox()— snaps the centre to the frame centre and the edges to the safe margins and frame edges, with a tolerance of10 / scaleKso the snap feels the same at every zoom. Holding Alt passesmove = falseand disables it.- Corner resize keeps the opposite corner fixed and the aspect ratio, clamping scale to
0.03 … 4.
5. Data model
A project is one JSON file, <safe name>.wcproj, written with json.dumps(proj, indent=1) through a .tmp file and os.replace().
{
"version": 2, // 2 after any server-side tidy_project()
"name": "gowordtoday 2026-09-20",
"fps": 30,
"width": 1920, // reference canvas; x/y/scale are relative to it
"height": 1080,
"media": [
{
"id": "3f2a9c11be4d07a5", // content fingerprint, 16 hex chars
"path": "D:/Teaching/week29.mp4",
"name": "week29.mp4",
"kind": "video", // video | audio | image
"duration": 2451.36, // 0 for images
"width": 1920, "height": 1080,
"hasVideo": true, "hasAudio": true,
"vcodec": "h264", "acodec": "aac", "pixfmt": "yuv420p", "fps": 29.97,
"thumb": "…/cache/3f2a…/thumb.jpg",
"peaks": "…/cache/3f2a…/peaks.json",
"proxy": null, // path to a browser-playable copy, when one was needed
"status": "ready" // processing | proxy | ready | missing
}
],
"tracks": [
{ "id": "T1", "type": "text", "name": "Titles" },
{ "id": "V4", "type": "video", "name": "Logo (top)" },
{ "id": "V3", "type": "video", "name": "Logo (bottom)" },
{ "id": "V2", "type": "video", "name": "Scripture cards" },
{ "id": "V1", "type": "video", "name": "Main video" },
{ "id": "C1", "type": "caption", "name": "Captions" },
{ "id": "A1", "type": "audio", "name": "Voice / SFX" },
{ "id": "A2", "type": "audio", "name": "Music", "duck": true }
],
"clips": [
{ "id": "k3n8p1qa", "kind": "video", "track": "V1", "mediaId": "3f2a9c11be4d07a5",
"start": 0, "in": 12.5, "out": 640.0,
"volume": 1, "fadeIn": 0, "fadeOut": 0.5,
"opacity": 1, "scale": 1, "x": 0, "y": 0, "label": "week29.mp4" },
{ "id": "b7x2mm09", "kind": "image", "track": "V2", "mediaId": "9d10aa77c2b31e04",
"start": 135.0, "in": 0, "out": 8, "fadeIn": 0.3, "fadeOut": 0.3,
"fit": "frame", "label": "03_Genesis_28-1",
"trans": { "type": "dissolve", "dur": 1 } },
{ "id": "t0qq4ze1", "kind": "text", "track": "T1",
"start": 4, "in": 0, "out": 9, "fadeIn": 0.5, "fadeOut": 0.5,
"scale": 1, "x": 0, "y": 0, "opacity": 1,
"title": { "preset": "apm-lower", "line1": "Dr. Benjamin John",
"line2": "Ancient Path Mandate", "size": 1 },
"png": "…/Projects/Week29_assets/title_t0qq4ze1_1920.png",
"pngKey": "[\"apm-lower\",\"Dr. Benjamin John\",\"Ancient Path Mandate\",1,null]@1920" },
{ "id": "c9vv1ka2", "kind": "caption", "track": "C1",
"start": 21.44, "in": 0, "out": 3.1, "text": "In the beginning God\ncreated the heaven" }
],
"mix": { "normalize": true, "cleanup": false, "duck": true, "musicLevel": 0.35 },
"exportSettings": { "preset": "yt1080", "encoder": "h264_qsv",
"burnCaptions": false, "writeSrt": true, "dir": "D:/Exports" }
}
Project object
| Field | Type | Meaning |
|---|---|---|
version |
int | 2. newProject() writes it and tidy_project() sets it on every server load and save |
name |
string | Also the file stem after safe_name() |
fps |
number | Project frame rate, the default for export |
width, height |
int | Reference canvas. x, y and fit: "native" sizes are expressed against it and scaled to the export size at render time |
media |
array | Media entries, see below |
tracks |
array | Ordered top to bottom as drawn |
clips |
array | Flat list; a clip's track names its lane |
mix |
object | normalize, cleanup, duck, musicLevel — the Audio tab |
exportSettings |
object | Last export choices, remembered per project |
Track
| Field | Type | Meaning |
|---|---|---|
id |
string | V*, A*, T*, C*. nextTrackId() allocates the lowest free number for the type |
type |
string | video, audio, text, caption |
name |
string | Shown in the track head |
duck |
bool | Audio only: this is the music bus, ducked under the voice |
hidden |
bool | Excluded from preview and render |
muted |
bool | Audio excluded from the mix |
locked |
bool | Not editable by drag, split or ripple |
accepts(track, kind) is the compatibility rule: video tracks take video and image, audio tracks take audio, text tracks take text, caption tracks take caption.
Clip
| Field | Applies to | Meaning |
|---|---|---|
id |
all | 8-character random string from uid() |
kind |
all | video, audio, image, text, caption |
track |
all | Track id |
start |
all | Timeline position in seconds |
in, out |
all | See below. Duration is always out − in |
mediaId |
media clips | Key into media[] |
label |
media clips | Display name on the clip |
volume |
video, audio | Linear gain, default 1; the Inspector allows 0–2 |
muted |
video, audio | Excluded from the mix |
fadeIn, fadeOut |
all but caption | Seconds. Alpha fade for pictures, afade for sound |
opacity |
visual | 0–1, applied with colorchannelmixer=aa= |
scale |
visual | Multiplier on baseSize, default 1 |
x, y |
visual | Pixel offsets from the frame centre, in reference-canvas pixels |
fit |
image, video | frame (fit inside the frame, preserving aspect) or native (the media's own pixels) |
trans |
video, text | { "type": "none"\|"dissolve"\|"black"\|"white", "dur": seconds } — the transition into this clip, from the clip that ends exactly where it starts |
title |
text | { preset, line1, line2, size, color? } |
png, pngKey |
text | The rendered title PNG and the cache key that produced it |
text |
caption | The cue text; \n is a line break |
The x/y/scale coordinate system
x and y are offsets in reference-canvas pixels from the centre of the frame, positive right and down. A clip with x: 0, y: 0 is centred whatever its size. scale multiplies the base size. render.build() converts to the export resolution:
dx = float(c.get("x", 0) or 0) * W / ref_w
dy = float(c.get("y", 0) or 0) * H / ref_h
xs = f"(W-w)/2{'+' if dx >= 0 else '-'}{abs(dx):.1f}"
so a 1920-wide project exported at 3840 places and sizes everything proportionally.
in and out
For time-based media (video, audio), in and out are offsets into the source file in seconds; in is what FFmpeg receives as -ss. Trimming the left edge moves both start and in; trimming the right edge changes out only.
For still media (image, text, caption) there is no source timeline: in stays 0 and out is the on-screen duration. Every edit path branches on this — clearRange(), splitAt() and removeRange() in store.js each test kind === 'text' || kind === 'caption' || kind === 'image' and adjust out rather than in.
The default eight tracks
newProject() creates them in this order, which is also top-to-bottom on screen: T1 text (Titles), V4 video (Logo (top)), V3 video (Logo (bottom)), V2 video (Scripture cards), V1 video (Main video), C1 caption (Captions), A1 audio (Voice / SFX), A2 audio (Music, duck: true).
Compositing order is the reverse: render.VIDEO_ORDER = ["V1", "V2", "V3", "V4"] is bottom to top, then any other video tracks, then text tracks on top.
setProject() also migrates 1.0 projects: a V3 named Logo & seal with no V4 present is renamed Logo (bottom) and a new V4 Logo (top) is inserted above it.
tidy_project()
Called on every projects/load, projects/save and export. It does three things:
- Collapses duplicate media. Entries are keyed by
os.path.normpath(path).replace("\\", "/").casefold(). The first entry for a path wins; later ones go intoremapand every clip'smediaIdis re-pointed at the survivor. Case folding matters because Windows and macOS both treat file names case-insensitively, so picking the same file twice must not produce two entries. - Drops unused media. Only entries whose id appears on some clip are kept, truncated to the last
MAX_PROJECT_MEDIA = 600if that is somehow exceeded. - Drops orphan clips. Any clip whose
mediaIdno longer resolves is removed; clips with nomediaId(titles, captions) are always kept.
Finally proj["version"] = 2.
It exists because of a defect in 1.2. Scripture-card ZIPs were unpacked afresh on every import, changing every file's modification time, and media identity was derived from that timestamp. Re-importing the same ZIP minted a new id for every card, so a template re-used a few times accumulated hundreds of dead media entries and the clips referencing them. The project file grew without limit and the editor stalled on open with the media bin blank. 2.0 fixes the cause in media.media_id() (section 8) and adds tidy_project() as a repair pass, so projects already damaged by 1.2 heal the first time they are opened. store.js carries a matching tidyProject() so the browser does the same on load.
6. HTTP API reference
Authentication
Two checks, both cheap and both mandatory.
Host check. _host_ok() reads the Host header, strips the port and requires 127.0.0.1 or localhost. Anything else gets 403 {"error": "forbidden"} before any parsing. This defeats DNS rebinding: a hostile page that resolves its own domain to 127.0.0.1 still sends its own name in Host.
Token check. TOKEN = secrets.token_urlsafe(24) is generated once per launch and never stored. _token_ok(q) accepts it in either the X-VerseCut-Token header or the t query parameter. The token is substituted into index.html at serve time:
html = html.replace("__TOKEN__", TOKEN).replace("__VERSION__", VERSION)
and read by the page as window.VERSECUT.token. util.api() sends the header; mediaURL() and getJob() use ?t= because <video> and <img> elements cannot set headers. Failing the check gives 403 {"error": "bad token"}.
There is one deliberate exception: / and /static/… need the host check only, since the page has to be fetchable before it can know the token.
GET routes
| Route | Response |
|---|---|
/, /index.html |
200 text/html — index.html with __TOKEN__ and __VERSION__ substituted. No token required |
/static/<path> |
200 with the type from mimetypes.guess_type. The resolved path must be under WEB_DIR and be a file, else 404 {"error": "not found"}. No token required |
/media?path=<abs>&t=<token> |
The file's bytes. 404 {"error": "missing file"} if it is not a file |
/api/jobs/<id>?t=<token> |
200 with the public job object, or 404 {"error": "no job"} |
do_HEAD = do_GET; the body write is skipped by the self.command != "HEAD" guard in _send() and the early return in _serve_file().
/media and Range. _serve_file() always advertises Accept-Ranges: bytes and Cache-Control: private, max-age=3600. A Range: bytes=<start>-<end> header is parsed with bytes=(\d*)-(\d*); a suffix range (bytes=-N) yields the last N bytes. A matched range returns 206 with Content-Range: bytes start-end/size, otherwise 200 and the whole file. The body streams in 1 MiB chunks, and ConnectionResetError, BrokenPipeError and ConnectionAbortedError are swallowed because a seeking <video> element aborts requests constantly. .mov is relabelled video/mp4 so Chromium plays H.264 QuickTime files.
POST routes
All take a JSON body and return JSON. A handler returning None yields {"ok": true}. An unknown route is 404 {"error": "no route"}; a malformed body is 400 {"error": "bad json"}; any other exception is 400 {"error": "<str(e)>"} with a traceback on the server's stdout.
| Route | Request | Response |
|---|---|---|
/api/state |
{} |
{version, licence, ffmpeg, whisper, models, encoders, exportsDir, projectsDir, platform} |
/api/ping |
{} |
{ok: true}. Resets the watchdog clock |
/api/fs |
{path?, kinds?} |
{path, parent, entries: [{name, path, dir, kind?, size?}], places} |
/api/media/import |
{paths: [...]} |
{media: [entry…], errors: ["name: message"…]} |
/api/media/status |
{items: [{id, path}], probe?} |
{media: [entry-with-project-id…]} |
/api/projects/list |
{} |
{projects: [{name, path, modified}]} newest first |
/api/projects/load |
{path} or {name} |
The tidied project object |
/api/projects/save |
{project} |
{path}, or 402 when the Free project limit is reached |
/api/projects/delete |
{name} |
{ok: true} |
/api/asset/png |
{dataUrl, project?, name?} |
{path}. 400 if the data URL is not image/png |
/api/cards/import |
{path} |
A job object, or 402 without cardImport |
/api/captions/import |
{path} |
{cues: [{start, end, text}]} |
/api/captions/export |
{cues, path, format?} |
{path}. format is srt, vtt or itt; anything else is a KeyError → 400 |
/api/transcribe |
{project, model?} |
A job object, or 402 without transcribe |
/api/export |
{project, settings} |
A job object, or 402 on height, transitions or logo tracks |
/api/licence |
{} |
The full evaluation, forced fresh |
/api/licence/install |
{text} or {path} |
The new evaluation. 400 if empty or not genuine |
/api/licence/remove |
{} |
The evaluation after removal |
/api/jobs/cancel |
{id} |
{ok: true}. Sets cancel and kills the child process |
/api/reveal |
{path} |
{ok: true} |
/api/openfile |
{path} |
{ok: true}. 400 if the file does not exist |
/api/exists |
{path} |
{exists: bool} |
/api/shutdown |
{} |
{ok: true}, then os._exit(0) after 0.5 s |
/api/fs classifies by extension: media.kind_for() first, then zip, then captions (.srt, .vtt, .itt, .ttml, .xml), then licence (.licence, .license, .lic, .txt, .key). Dot files and $-prefixed names are skipped, and PermissionError/OSError on an entry is ignored rather than failing the listing. places comes from paths.quick_places().
/api/media/status is deliberately cheap: it answers from the registry and the on-disk cache and passes allow_probe=bool(d.get("probe")), so routine polling never starts an ffprobe. It handles at most MAX_STATUS_ITEMS = 120 items per call and returns the project's own id for each entry even if the file's fingerprint has changed.
The job object and its lifecycle
{ "id": "a3f19c2b77e1", "kind": "export", "status": "running",
"progress": 0.42, "message": "Rendering…", "result": null,
"error": null, "started": 1789000000.0 }
| Field | Meaning |
|---|---|
id |
uuid4().hex[:12] |
kind |
export, transcribe or cards |
status |
running → done, error or cancelled |
progress |
0.0–1.0; the FFmpeg parser caps at 0.999 until completion |
message |
Human-readable stage |
result |
The handler's return value once done |
error |
str(exception) on failure |
start_job() calls prune_jobs(), allocates the id, stores the dict in the module-level JOBS and starts a daemon thread. public_job() strips keys beginning with _, which keeps the subprocess.Popen handle (_proc) out of the HTTP response.
prune_jobs(keep=12, max_age=1800) removes finished jobs oldest-first while there are more than 12 or the oldest is over 30 minutes old. Running jobs are never pruned. busy() reports whether any job is running, and is what stops the watchdog killing the process mid-export.
Cancellation sets job["cancel"] = True and kills job["_proc"]. run_ffmpeg() notices the flag while reading progress, kills the process and raises RuntimeError("Cancelled"); captions.transcribe() checks it between segments. start_job's runner turns an exception into status = "cancelled" when the flag is set, otherwise "error".
The 402 response
Any licensing.LicenceError raised inside a handler is caught by do_POST and returned as:
{ "error": "4K export is part of VerseCut Creator…",
"licence": "free", "status": "free", "upgrade": true }
with HTTP 402 Payment Required. util.api() recognises r.status === 402 || j.upgrade, tags the thrown Error with .upgrade and .plan, and main.js run() responds by refreshing the licence and showing upgradePrompt().
7. Render pipeline
render.build(project, settings, out_path, workdir, mode) returns (argv, total_seconds). mode is video (MP4), mp3 (audio only) or voice (16 kHz mono WAV of the voice tracks, used for transcription).
Preparation. timeline_duration() is the largest start + (out − in) over all non-caption clips; an empty timeline raises ValueError("The timeline is empty."). Clips on unknown tracks and clips under a millisecond are dropped, then apply_transitions() rewrites the list.
Input selection. Every clip that contributes picture gets its own input:
# still image
add_input(["-loop", "1", "-framerate", f"{fps:g}", "-t", _f(dur), "-i", src])
# time-based media
add_input(["-ss", _f(c["in"]), "-t", _f(dur), "-i", src])
-ss before -i is an input seek, so FFmpeg decodes only what is needed. idx_of[c["id"]] records the input index so a clip with both picture and sound is opened once and mapped twice.
Placement. A time-based input is normalised with setpts=PTS-STARTPTS, scaled, then shifted to its timeline position with setpts=PTS+<start>/TB. Scaling takes one of two forms:
# fit: "native" — the media's own pixels, scaled with the output
fps=30,scale=<w*k>:<h*k>,setsar=1,format=rgba where k = scale * W / ref_w
# otherwise — fit inside a scaled frame box
fps=30,scale=<W*s>:<H*s>:force_original_aspect_ratio=decrease,setsar=1,format=rgba
format=rgba is what makes alpha fades possible on an overlay.
Alpha fades and opacity. opacity below 0.999 adds colorchannelmixer=aa=<op>; fadeIn/fadeOut add fade=t=in:st=0:d=…:alpha=1 and fade=t=out:st=<dur−fo>:d=…:alpha=1, both clamped to the clip length.
The overlay chain. A black base is created once, and each clip composited onto the running result:
color=c=black:s=1920x1080:r=30:d=<total>,format=yuv420p[base]
[b<n-1>][v<n>]overlay=x=(W-w)/2+120.0:y=(H-h)/2-40.0:eof_action=pass:
enable='between(t,12.000,20.000)'[b<n>]
eof_action=pass keeps the chain alive after the overlay input ends instead of terminating the graph; enable='between(t,…)' limits the overlay to the clip's window so a short input cannot bleed outside it. Composition order is VIDEO_ORDER (V1, V2, V3, V4), then any other video tracks, then text tracks. Hidden tracks are skipped.
Audio. Each audible clip produces:
[<i>:a]asetpts=PTS-STARTPTS,aresample=48000,
aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,
volume=1.000[,afade…],adelay=<start ms>:all=1[a<k>]
adelay with all=1 places the clip on the timeline and applies to every channel. Clips are split into two buses by the track's duck flag: music if set, voice otherwise. A silent bed is always present so the mix has a defined length:
anullsrc=r=48000:cl=stereo,atrim=0:<total>[asil]
The voice bus is [asil] plus every voice clip through amix=inputs=N:normalize=0:duration=first, optionally followed by highpass=f=70,afftdn=nf=-25 when voiceCleanup is set.
Sidechain ducking. When there is music, voice and duckMusic (default true):
[voice]asplit=2[vmix][vsc]
[music]volume=<musicLevel>[mv]
[mv][vsc]sidechaincompress=threshold=0.015:ratio=12:attack=20:release=450:makeup=1[mduck]
[vmix][mduck]amix=inputs=2:normalize=0:duration=first[mixed]
The voice is split so one copy drives the compressor's sidechain and the other is mixed. With ducking off, the music is simply levelled and mixed.
Loudness. When normalize is set and the mode is not voice:
[mixed]loudnorm=I=-14:TP=-1.5:LRA=11,aresample=48000,atrim=0:<total>[aout]
This is single-pass loudnorm. lufs defaults to −14, the YouTube target.
Burnt captions. With burnCaptions set and visible caption clips, captions.build_caption_track() draws every cue as a full-frame transparent PNG with Pillow (caption_image()), writes an ffconcat list that alternates blank.png and the cue images with exact duration lines covering 0 … total, and adds it as one input (-f concat -safe 0 -i captions/captions.ffconcat). The graph gains [<n>:v]fps=<fps>,format=rgba,setpts=PTS-STARTPTS[capv] and [<last>][capv]overlay=0:0:eof_action=pass:format=auto[subs]. The style matches 2.0: white Source Sans 3 Semibold (bundled in app/fonts, SIL OFL) at 5 % of the frame height on a navy #073059 box at about 70 % opacity, bottom-centred with a 6 % margin, wrapped to the frame width. Drawing captions in Python means the video engine needs no libass, FreeType or HarfBuzz, which keeps the macOS FFmpeg build small and LGPL-only.
Encoder policy (engine/codecs.py). VerseCut 3.0 encodes H.264 and AAC only with the operating system's own encoders, whose patent licences Microsoft, Apple and the GPU vendors hold. It never uses x264 or FFmpeg's built-in AAC encoder in a customer build; render.available_encoders(), render.video_args() and render.audio_codec() are thin wrappers over codecs.
| Platform | H.264, best first | AAC | MP3 |
|---|---|---|---|
win32 |
h264_qsv, h264_nvenc, h264_amf, h264_mf |
aac_mf (Media Foundation) |
libmp3lame |
darwin |
h264_videotoolbox |
aac_at (AudioToolbox) |
libmp3lame |
linux (development only) |
h264_nvenc, h264_qsv |
— | libmp3lame |
VERSECUT_DEV=1 adds libx264 and FFmpeg's aac to the lists so the code can be developed and tested on Linux. No shipped package or launcher sets it, and video_args() raises CodecUnavailable for any encoder outside the current list.
settings.encoder |
Arguments |
|---|---|
h264_qsv |
-c:v h264_qsv -global_quality <quality> -preset medium |
h264_nvenc |
-c:v h264_nvenc -cq <quality> -preset p5 -rc vbr |
h264_amf |
-c:v h264_amf -rc cqp -qp_i <quality> -qp_p <quality> -quality balanced |
h264_mf |
-c:v h264_mf -b:v <bitrate> |
h264_videotoolbox |
-c:v h264_videotoolbox -b:v <bitrate> -profile:v high -allow_sw 1 |
With no encoder in settings, video_args() uses the first entry of video_encoders() — the best allowed encoder this machine actually has — and raises CodecUnavailable with platform help text if there is none.
_bitrate(settings) feeds the two rate-driven encoders (h264_mf, h264_videotoolbox). It is width × height × fps × bpp, where bpp is 0.20 for quality <= 18, 0.15 for quality <= 20 and 0.11 otherwise, with a floor of 1 Mbit/s.
Video mode then appends -pix_fmt yuv420p -r <fps>, the AAC arguments and -movflags +faststart; voice mode is -ac 1 -ar 16000 -c:a pcm_s16le. Every mode ends with -t <total> -progress pipe:1 -nostats <output>.
Audio codec. codecs.aac_args() returns -c:a aac_mf -b:a 192k -ar 48000 -ac 2 on Windows and -c:a aac_at -b:a 192k on macOS. MP3 export uses libmp3lame; if the engine has no LAME the export stops with a clear message rather than writing AAC into an .mp3 file.
Probing. codecs.compiled(name) reads ffmpeg -encoders once and looks for " <name> ". _probe() then runs a real trial encode — a 0.2-second black clip for video, a 0.3-second tone for audio — to null with the exact arguments VerseCut would use, and caches the result. video_encoders() returns only encoders that are allowed, compiled in and working, in preference order, and can return an empty list. api_state passes that list to the browser; dialogs.js populates the encoder dropdown from it.
Fallback. job_export() raises before rendering anything if mode == "video" and no encoder works, with the platform help text (on Windows "N" editions: install the Media Feature Pack). Otherwise run_ffmpeg() runs inside a loop. On a RuntimeError the current encoder is added to tried and the next allowed encoder not yet tried is used, with the job message "<label> failed — retrying with <label>…". The loop re-raises on a cancellation, when no untried encoder remains, or once len(tried) > 2. It never falls back to a software encoder that VerseCut does not license.
The graph file. The filter list is joined with ";\n" and written to graph.txt. _graph_option() decides once per process between -/filter_complex (FFmpeg 7.1 and later) and -filter_complex_script, by trying the former on a trivial graph in CACHE_DIR/graph_test.txt.
Progress parsing. run_ffmpeg() reads FFmpeg's stdout line by line with stderr redirected to ffmpeg.log in the work directory:
m = re.match(r"out_time_(?:us|ms)=(\d+)", line.strip())
if m and total:
job["progress"] = min(0.999, int(m.group(1)) / 1e6 / total)
Both spellings are accepted and both divided by 10⁶, which is correct because FFmpeg reports microseconds under either name. On a non-zero exit the last 1500 characters of ffmpeg.log are raised as RuntimeError("FFmpeg failed:\n…") — what the export dialog shows in its <pre> block.
apply_transitions()
clip.trans describes the transition into a clip. The function expands it into concrete overlaps and fades and returns (clips, dips), where dips is a list of (track_id, cut_time, duration, colour). It works on deep copies, so the project is not mutated.
Each adjacent pair on a video or text track must be a butt cut — abs(prev.start + prev_dur − cut) <= 0.05 — or the transition is ignored.
Dissolve. The incoming clip needs material before the cut, or the outgoing clip material after it. pre is the handle on the incoming clip (min(d, c["in"]), or the full duration for a still, which can simply be stretched); post is the handle on the outgoing clip (min(d, media_duration − p["out"])). If pre >= d/2 the incoming clip is pulled earlier and given fadeIn = pre; otherwise if post >= d/2 the outgoing clip is extended and the incoming clip gets fadeIn = post. If neither has a usable handle the transition degrades to black rather than failing.
Dip to black or white. Each side gets a half-length audio fade (_afadeOut, _afadeIn). Then the restriction: a real dip is produced only on V1.
if tid == "V1":
dips.append((tid, cut, d, "white" if kind == "white" else "black"))
else: # overlays simply fade out and back in
p["fadeOut"] = max(float(p.get("fadeOut") or 0), half)
c["fadeIn"] = max(float(c.get("fadeIn") or 0), half)
A colour card on an overlay track would cover every layer beneath it, including the main video, which is never what is wanted — so on V2, V3, V4 or a title track the dip becomes a symmetrical fade out and in. expanded() in store.js applies the same rule, so preview and export agree.
The dip itself is a separate overlay inserted immediately above its own track's clips:
color=c=black:s=1920x1080:r=30:d=<d>,format=rgba,
fade=t=in:st=0:d=<d/2>:alpha=1,fade=t=out:st=<d/2>:d=<d/2>:alpha=1,
setpts=PTS+<cut-d/2>/TB[v<n>]
8. Media handling
media_id() — a content fingerprint
h = hashlib.sha1()
h.update(f"{p.name.lower()}|{st.st_size}|".encode("utf-8"))
with open(p, "rb") as f:
h.update(f.read(262144))
if st.st_size > 524288:
f.seek(-262144, os.SEEK_END)
h.update(f.read(262144))
return h.hexdigest()[:16]
Lower-cased file name, size, the first 256 KiB and — for files over 512 KiB — the last 256 KiB. The modification time is deliberately excluded. Scripture-card ZIPs are unpacked on every import, so keying on mtime minted a new identity for every card each time a template was re-used: the root cause of the 1.2 project growth described in section 5. A content fingerprint separates genuinely different files reliably and costs well under a millisecond, and cards.extract() reinforces it by skipping any file already on disk at the same size rather than rewriting it. If the file cannot be read, the resolved path is hashed instead so an id is still produced.
The registry and its bounds
_registry is a module-level dict of id → media dict guarded by _lock. _trim_registry() keeps it to REGISTRY_LIMIT = 400 entries, discarding the oldest by insertion order. get(mid) is a plain lookup, used by the HTTP status sweep before anything more expensive.
Probe semaphore, failure cooldown and allow_probe
_probe_gate = threading.Semaphore(2) is held around every ffprobe and around the whole of _process(), so at most two media operations touch disk and CPU at once however many files are imported. _recent_fail records the last failed probe per path, and FAIL_COOLDOWN = 60.0 stops an unreadable file being retried in a tight loop.
register(path, allow_probe=True) is the single entry point. With allow_probe=False it answers only from the registry or meta.json; if neither has the file it raises RuntimeError("Not read yet"), and within the cooldown after a failure RuntimeError("Still reading this file"). api_media_status passes allow_probe=bool(d.get("probe")), so the routine 1.2-to-6-second poll from main.js pollMedia() never starts a probe, while verifyMedia() — once per project open — passes probe: true in batches of 40. The front end backs off too: POLL_GIVE_UP = 40 attempts per item, at most 60 items per sweep, and wait = min(6000, 1200 + pending.length * 40).
Probing, thumbnails and peaks
probe() runs ffprobe -v error -show_format -show_streams -of json and reduces the result to duration, width, height, hasVideo, hasAudio, vcodec, acodec, pixfmt and fps. A video stream with disposition.attached_pic is skipped, so cover art in an MP3 does not make it look like a video. fps comes from avg_frame_rate, defaulting to 30. A file classified video by extension but found to have no video stream is reclassified audio.
For video and image, one frame at min(1.0, duration * 0.1) seconds is written to <cache>/<id>/thumb.jpg at scale=320:-2; existing thumbnails are reused and the browser fetches the path through /media.
_make_peaks() decodes the file to mono signed 16-bit at 2000 Hz and reduces it to 100 peaks per second, each 0–255 (per = rate // 100, so 20 samples per peak). With NumPy available it reshapes and takes a per-block maximum; without it an array('h') loop does the same. The result is {"rate": 100, "peaks": [...]} in peaks.json. timeline.js drawWaves() fetches it once per media id into peaksCache and draws it on a <canvas> per clip, scaled by the clip's volume and limited to 4000 columns.
Preview proxies
needs_proxy(path, info, kind) returns true when the browser cannot be trusted to play the file directly:
- images never need one;
- an extension outside
BROWSER_CONTAINERSalways does; - a video codec outside
{h264, vp8, vp9, av1}or a pixel format other thanyuv420p/yuvj420pdoes; - an audio codec outside
{aac, mp3, opus, vorbis, flac, pcm_s16le, pcm_s24le, pcm_f32le, pcm_u8}does.
The proxy is 540p H.264 from the same allowed encoders as export (codecs.video_encoders()[0], quality 26, -g 30), with AAC 128 kbit/s from the operating system's encoder and +faststart, written to proxy_tmp.mp4 and moved into place with os.replace() so a half-written file is never served.
If a helper step throws, _process_locked() still sets status = "ready" and records the message in m["error"], so a file with no thumbnail remains usable.
Cache layout
<cache root>/VerseCut/cache/
├─ port.txt the port this launch chose
├─ graph_test.txt the probe file for _graph_option()
└─ <16-hex media id>/
├─ meta.json the media dict as last processed
├─ thumb.jpg 320-wide thumbnail
├─ peaks.json {"rate": 100, "peaks": [...]}
└─ proxy.mp4 | proxy.m4a when one was needed
The cache is keyed by content, so it survives a file being moved and is shared between projects. Deleting it costs only regeneration time.
9. Captions and scripture cards
The transcription path
POST /api/transcribegates ontranscribeand startsjob_transcribe.render.build(project, {"normalize": False}, wav, wd, mode="voice")produces the audio. Invoicemode, clips on ducked (music) tracks are excluded andloudnormis skipped; FFmpeg writes 16 kHz monopcm_s16le.captions.transcribe()setsHF_HUB_OFFLINE=1when the requested model is already inMODELS_DIR(runtime/modelson Windows,~/Library/Application Support/VerseCut/modelson macOS), so a machine that has the model never attempts a network call.installed_models()looks formodels--Systran--faster-whisper-<name>forbase.en,small.en,medium.enandlarge-v3._import_faster_whisper()imports faster-whisper without PyAV: faster-whisper imports PyAV at module level only fordecode_audio(), which VerseCut never calls, and PyAV's binary wheels bundle a GPL FFmpeg. The installers therefore install faster-whisper with--no-depsand this function registers an inertavplaceholder.load_wav_16k()reads the 16 kHz WAV from step 2 into a float32 NumPy array, which is whatmodel.transcribe()receives.- The model loads as
WhisperModel(model_name, device="cpu", compute_type="int8", download_root=MODELS_DIR). If loading fails and the model is not installed, the error explains that it needs one internet connection. - Transcription runs with
word_timestamps=True,vad_filter=True,beam_size=5andinitial_prompt=SCRIPTURE_PROMPT— a sentence naming the books of the Bible, common proper nouns and King James forms (thee, thou, hath, saith, unto) so scripture vocabulary is spelled correctly. Progress isseg.end / duration, andjob["cancel"]is checked between segments. words_to_cues()groups words into cues. A cue is flushed when the text would exceedMAX_CHARS = 84, when it would span more thanMAX_SECS = 6.0, or when the gap to the next word exceeds 1 second. It is also flushed after a word ending in.,?or!once the cue passes 20 characters, or after,,;or:once it passes 55. Each cue's text goes throughwrap(), which splits at the space nearest the middle beyond 42 characters. Finally, any overlap between consecutive cues is removed by pulling the earlier cue'sendback.
Caption formats
| Format | Read | Written | Notes |
|---|---|---|---|
| SRT | parse_srt_vtt() |
to_srt() |
hh:mm:ss,mmm |
| WebVTT | parse_srt_vtt() |
to_vtt() |
hh:mm:ss.mmm, WEBVTT header |
| iTT / TTML / DFXP / XML | parse_ttml() |
to_itt() |
SMPTE timebase, frame-accurate hh:mm:ss:ff |
| Burned-in picture | — | build_caption_track() |
PNG frames drawn with Pillow, overlaid in one pass (see Render pipeline) |
parse_file() dispatches on the extension: .itt, .ttml, .xml and .dfxp to the TTML parser, everything else to the SRT/VTT parser, which is tolerant — it scans each blank-line-separated block for the first --> line and strips markup from the body. _parse_ts() accepts 12.5s, 300f, hh:mm:ss:ff, hh:mm:ss, mm:ss and a bare number, and treats , as a decimal point. parse_ttml() reads frameRate from the root element, walks every <p>, turns <br/> into a newline, and falls back to a dur attribute (default 3s) when end is absent.
Scripture cards: the three timing methods
cards.import_cards(src) calls extract() — which returns the folder directly, or unpacks a ZIP into Documents/VerseCut/Scripture Cards/<stem> — indexes every .png, .jpg, .jpeg or .webp by lower-cased file name, then tries three methods in order. The first that yields any placements wins, and the method name is reported to the user.
| Order | Method | What it expects |
|---|---|---|
| 1 | FCPXML (from_fcpxml) |
Any *.fcpxml or *.xml in the folder. <asset> elements are matched to image files by the base name of their src (or a <media-rep> src), with a fallback to resolving file:// paths relative to the XML. The tree is walked through gap, video, asset-clip, clip, ref-clip, title and spine, accumulating offset and start as rational times like 1001/30000s. Nested fadeIn/fadeOut durations are honoured, defaulting to 0.3 s |
| 2 | CSV insert sheet (from_csv) |
Any *.csv. Column names are matched case-insensitively: the file column contains file, png, card or image; the in-point is in, start, timecode, tc in, time in or begin; the optional out-point is out, end, tc out or time out. A reference column supplies the label. Missing out-points give DEFAULT_DUR = 8.0 seconds |
| 3 | Timecodes in file names (from_names) |
A timecode at the end of the stem, e.g. 03_Genesis_28-1_00-02-15 or …_00-02-15-12. TC_RE accepts -, _, ., : and h/m/s separators and prefers a match that ends the stem; TC_COMPACT accepts a trailing hhmmss or hhmmssff. A three-digit fractional part is milliseconds, otherwise frames at 30. Minutes or seconds above 59 reject the match. Durations are derived from the gap to the next card, clamped to 1–8 seconds, with 0.3 s fades |
If images are found but no method yields timing, import_cards() raises ValueError telling the user to include the FCPXML or CSV sheet, or timecodes in the file names. Each placement is {path, start, duration, fadeIn, fadeOut, label}; dialogs.js importCards() imports the images as media and creates one image clip per card on the chosen track, with an optional offset and an option to replace what is already there.
extract() also refuses unsafe archive members: entries under __MACOSX, directory entries, and any path resolving outside the destination folder.
10. Licensing subsystem
Enforcement is server-side. licence.js says so in its own header comment: the interface reflects entitlement, the engine decides it. Every gate is a Python call inside a route handler, so editing the JavaScript — or calling the API directly — unlocks nothing.
Licence file format
-----BEGIN VERSECUT LICENCE-----
eyJlbWFpbCI6Im5hbWVAZXhhbXBsZS5jb20iLCJleHBpcmVzIjoiMjAyNy0w
… 64-character lines …
-----END VERSECUT LICENCE-----
The body is base64url(canonical_payload) + "." + base64url(signature), wrapped at 64 characters. decode() accepts the text with or without the BEGIN/END lines, strips all whitespace and requires exactly one .. Padding is restored before decoding, so the unpadded form is fine.
The payload is a JSON object with the keys name, email, id, plan (free, creator, studio), term (annual or perpetual), expires (ISO date, annual only), lockVersion (release line, perpetual only), seats and the optional machines (a list of machine ids).
The canonical JSON that is signed
def canonical(payload):
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
Compact separators and sorted keys, UTF-8 encoded. Signing and verification use exactly these bytes, so re-ordering or re-spacing the payload invalidates the signature.
Ed25519 verification
PUBLIC_KEY is a 32-byte constant compiled into licensing.py. verify_text() decodes, recomputes canonical(payload) and calls ed25519.verify(msg, sig, pk), raising ValueError("This licence file is not genuine, or it has been altered.") on failure. ed25519.py is a complete RFC 8032 implementation in pure Python — _edwards_add, _scalarmult, _decodepoint with an on-curve check, and a rejection of S >= l. It is dependency-free on purpose, so removing a package cannot disable licence checking, and test_crypto() verifies it against RFC 8032 test vector 1. The private key never leaves Stateam LLC.
evaluate() and its statuses
evaluate(app_version, text=None) never raises. It reads LICENCE_FILE unless text is supplied and returns plan, term, status, machine, name, email, licenceId, seats, expires, lockVersion, daysLeft, message, trial, appVersion, line, features and licensed.
| Status | Cause | Effective plan |
|---|---|---|
free |
No licence file and no trial left | free |
trial |
No usable licence, trial days remain | creator |
active |
Signature valid, in date, right machine, right line | the licence's plan |
grace |
Annual licence expired within GRACE_DAYS = 14 |
the licence's plan |
expired |
Annual licence expired more than 14 days ago | free |
version-locked |
Perpetual licence whose lockVersion line is older than the running line |
free |
wrong-machine |
machines is non-empty and this machine's id is not in it |
free |
invalid |
Signature check failed or the text is unparseable | free, or creator if trial days remain |
licensed is true for active, grace and trial. Note the fallback rule: the trial rescues only free and invalid. A licence that is expired, version-locked or bound to another machine drops to Free even if trial days remain — an expired customer is not silently put back on a trial.
line_of("3.0.3") is "3.0", and perpetual comparison is on the two-part tuple, so a 3.0 perpetual licence works on 3.0.9 and is locked out of 3.1 and 4.0.
The feature table
FEATURES = {
"free": {"maxProjects": 3, "maxHeight": 1080, "transcribe": False, "cardImport": False,
"transitions": False, "logoTracks": False, "commercial": False, "seats": 1},
"creator": {"maxProjects": 0, "maxHeight": 2160, "transcribe": True, "cardImport": True,
"transitions": True, "logoTracks": True, "commercial": True, "seats": 2},
"studio": {"maxProjects": 0, "maxHeight": 2160, "transcribe": True, "cardImport": True,
"transitions": True, "logoTracks": True, "commercial": True, "seats": 5},
}
maxProjects: 0 means unlimited. For Studio, seats is replaced by the licence's own seat count (max(result["seats"], 1)), which is how a six-seat Studio licence reports six.
Where each gate is enforced
| Feature | Gate | Route |
|---|---|---|
transcribe |
licensing.gate(lic(), "transcribe") |
api_transcribe |
cardImport |
licensing.gate(lic(), "cardImport") |
api_cards_import |
maxHeight |
licensing.gate_height(st, settings["height"]) |
api_export |
transitions |
inline check for any clip with trans.type not None/"none" |
api_export |
logoTracks |
inline check for any clip on V3 or V4 |
api_export |
maxProjects |
licensing.gate_projects(lic(), existing, safe_name(name)) |
api_projects_save |
gate_projects() always allows re-saving a project that already exists, so hitting the Free limit never locks a user out of work in progress. commercial and seats are entitlements stated in the licence agreement, not code gates.
lic() caches the evaluation for five seconds because it is consulted on export, save and every licence route; lic(fresh=True) bypasses the cache and is used by api_licence, api_licence_install and api_licence_remove.
The trial clock and tamper resistance
State lives in licence/state.json:
{ "data": { "start": "2026-09-06", "high": "2026-09-20" },
"mac": "9f3c…" }
start is the date of first launch; high is the latest date this installation has ever seen. Days used is max(today, high) − start, so winding the system clock backwards cannot extend a trial — the high-water mark is already recorded. TRIAL_DAYS = 14.
The file is authenticated with _state_mac(), an HMAC-SHA256 over canonical(data) keyed by sha256("versecut-state|" + _raw_machine_key()). _read_state() compares with hmac.compare_digest and returns {} on any mismatch, missing file or parse error. Editing the dates by hand therefore invalidates the file and the trial restarts from today — a deliberate trade, since the trial is a courtesy and the licence is what matters. Writes go through a .tmp file and os.replace().
The machine fingerprint
_raw_machine_key() joins platform.system(), platform.node(), uuid.getnode() (the MAC address as 12 hex digits) and a platform-specific stable identifier:
| Platform | Identifier |
|---|---|
| Windows | HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, read with KEY_WOW64_64KEY |
| macOS | IOPlatformUUID from ioreg -rd1 -c IOPlatformExpertDevice, with a 5-second timeout |
| Linux | /etc/machine-id |
Any failure is swallowed, so the fingerprint degrades rather than breaking. machine_id() hashes the result with SHA-256 and encodes the first eight bytes in a 32-character alphabet omitting I and O to avoid transcription mistakes, giving VC-XXXX-XXXX-XXXX — 17 characters, checked by the test suite. It is a one-way hash containing no name, serial number or address, and it leaves the machine only if the customer copies it to support to have a licence locked.
Licence folder layout
<cache root>/VerseCut/licence/
├─ versecut.licence the installed licence text, BEGIN/END wrapped
└─ state.json { "data": { "start", "high" }, "mac" }
install(text) verifies before writing, so a bad paste never replaces a good licence — test_ui.py checks exactly that. remove() unlinks the licence file and returns to trial or Free; state.json is untouched, so removing a licence does not grant a new trial.
11. Security model
What is exposed
One TCP listener on 127.0.0.1, on a port between 8765 and 8814. It serves the interface, media bytes for any absolute path given to /media, and the API. That API can read directories, read and write project files, write PNGs into a project's assets folder, write caption and export files to any path, run FFmpeg, open Explorer or Finder, open a file in the default application, and exit the process.
What is not exposed
Nothing listens on an external interface. There is no outbound network traffic from app/ after setup — no update check, no analytics, no licence server — and HF_HUB_OFFLINE=1 is set before the speech model loads when the model is already on disk. No credentials are stored, because there are none. The licence private key is not on the machine; only the public key is.
Threat cases considered
| Case | Position |
|---|---|
| Another local program | The per-launch token is the barrier: a process that cannot read it cannot use the API. A process that can read another user's memory or files has already defeated the operating system's own boundary, and VerseCut does not try to defend against that |
| A hostile web page | The Host check rejects any request not arriving as 127.0.0.1 or localhost, defeating DNS rebinding. Without the token an attacker gets 403 on everything but / and /static/…. The token is 24 random bytes from secrets, regenerated every launch and never written to disk, so it cannot be guessed or replayed across sessions |
| A shared machine | Projects, cache, licence and trial state are per-user and protected by normal file permissions. The machine fingerprint, however, is per-machine, so a machine-locked licence covers the computer, not the account |
| A tampered licence | Ed25519 verification over the canonical payload. Altering the payload or the signature, or signing with another key, is rejected (test_licensing.py and test_ui.py check all three). There is no fallback path that trusts an unverified licence |
| A modified installation | Not defended, and honestly so. Anyone who can edit licensing.py can remove the gates. Pure-Python Ed25519 stops the easy attack — deleting a dependency — and nothing more |
Honest limits
/mediaserves any absolute path the caller names, limited by the token and file permissions, not by a path allow-list./static/…is path-checked (WEB_DIR.resolve() not in f.parents);/mediais not, because serving arbitrary user media is its purpose./api/fslists any directory the user can read;/api/captions/exportand/api/exportwrite wherever the user can write.api_revealandapi_openfilehand a path toexplorer.exe,openoros.startfile, so a caller with the token can open an arbitrary file in its default application.- The token appears in URLs (
?t=) for<video>,<img>and job polling, so it reaches the browser's history and any HTTP log — not a concern for a loopback server with no proxy, but worth knowing. - There is no authentication between users on a multi-user machine beyond file permissions and the fact that each user's VerseCut has its own token and port.
12. Configuration, paths and ports
| Purpose | Windows | macOS | Linux |
|---|---|---|---|
| Installation root | wherever unzipped, e.g. C:\VerseCut |
/Applications/VerseCut.app/Contents/Resources/VerseCut (or ~/Applications/… when /Applications is not writable); the downloaded folder is not used after setup |
as unzipped |
| Runtime | <root>\runtime\ (venv, ffmpeg, models) |
<root>/runtime/ (python, ffmpeg) inside the app |
same |
| Projects | %USERPROFILE%\Documents\VerseCut\Projects |
~/Documents/VerseCut/Projects |
~/Documents/VerseCut/Projects |
| Project assets | …\Projects\<name>_assets\ |
…/Projects/<name>_assets/ |
same |
| Scripture cards | …\Documents\VerseCut\Scripture Cards |
~/Documents/VerseCut/Scripture Cards |
same |
| Exports | %USERPROFILE%\Videos\VerseCut Exports |
~/Movies/VerseCut Exports |
~/Videos/VerseCut Exports |
| Cache | %LOCALAPPDATA%\VerseCut\cache |
~/Library/Application Support/VerseCut/cache |
~/.cache/versecut |
| Port file | %LOCALAPPDATA%\VerseCut\cache\port.txt |
…/VerseCut/cache/port.txt |
~/.cache/versecut/port.txt |
| Licence | %LOCALAPPDATA%\VerseCut\licence\ |
~/Library/Application Support/VerseCut/licence/ |
~/.cache/licence |
| Browser window profile | %LOCALAPPDATA%\VerseCut\window-profile |
~/Library/Application Support/VerseCut/window-profile |
~/.cache/window-profile |
| Application log | %LOCALAPPDATA%\VerseCut\versecut.log |
~/Library/Logs/VerseCut/versecut.log (written by the app launcher) |
~/.cache/versecut.log |
| Setup log | <root>\runtime\install.log |
~/Library/Logs/VerseCut/install.log |
same |
| Speech models | <root>\runtime\models\ |
~/Library/Application Support/VerseCut/models/ (outside the app, so the signed bundle is never modified and upgrades keep the model) |
<root>/runtime/models/ |
On Windows, DOCUMENTS, VIDEOS, MUSIC, PICTURES, DESKTOP and DOWNLOADS are read from HKCU\…\Explorer\User Shell Folders so OneDrive-redirected folders are honoured; _win_shell_folder() falls back to the plain ~/… path if the value is missing or does not exist. PROJECTS_DIR, CARDS_DIR, CACHE_DIR and MODELS_DIR are created at import time.
The licence, window-profile and log paths are derived as CACHE_DIR.parent, which reads naturally on Windows and macOS. On Linux, where CACHE_DIR is ~/.cache/versecut, the parent is ~/.cache, so they land directly there rather than in a versecut subfolder.
Ports
find_port(preferred=8765) tries to bind 127.0.0.1:port for 50 consecutive ports, 8765 to 8814, and returns the first that binds; if all 50 are taken it raises RuntimeError("No free port"). The chosen port is written to port.txt.
On a non-headless launch, main() reads port.txt first and fetches http://127.0.0.1:<port>/ with a 1.5-second timeout and an explicitly empty ProxyHandler (so a system proxy cannot intercept a loopback request). If the reply contains VerseCut it opens another window on the running copy and returns — which is what makes clicking the desktop icon twice safe.
Environment variables
| Variable | Read or set by | Effect |
|---|---|---|
LOCALAPPDATA |
paths.py |
Cache root on Windows; falls back to ~/AppData/Local |
HF_HUB_DISABLE_SYMLINKS_WARNING, HF_HUB_DISABLE_TELEMETRY, HF_HUB_DISABLE_PROGRESS_BARS |
set by server.py at import with setdefault |
Silences the Hugging Face hub library and disables its telemetry |
HF_HUB_OFFLINE |
set by captions.transcribe() |
Forced to 1 when the requested model is already installed, so no network call is attempted |
VERSECUT_TEST_LICENCES |
tests/test_licensing.py, tests/test_ui.py |
Path to the signed test-licence kit |
VERSECUT_MODELS_DIR |
paths.py; set by the macOS launcher |
Where speech models live |
VERSECUT_DEV |
paths.py, codecs.py |
1 on a development machine only: allows an FFmpeg found on PATH and the development encoders (libx264, FFmpeg's aac). The macOS launcher explicitly unsets it |
PYTHONDONTWRITEBYTECODE, PYTHONNOUSERSITE |
set by the macOS launcher | Nothing is written into the app bundle at run time, and no user site-packages are loaded |
PIP_*, PYTHONWARNINGS, TEMP |
setup/install.ps1 |
Quiet installer output and scratch space |
There is no configuration file and no way to change the port, the folders or any limit without editing the source. The constants worth knowing: MAX_STATUS_ITEMS = 120 and MAX_PROJECT_MEDIA = 600 in server.py; REGISTRY_LIMIT = 400 and FAIL_COOLDOWN = 60.0 in media.py; UNDO_DEPTH = 60 in store.js; TRIAL_DAYS = 14 and GRACE_DAYS = 14 in licensing.py.
Log files
| File | Contents |
|---|---|
runtime/install.log (Windows), ~/Library/Logs/VerseCut/install.log (macOS) |
Everything the installer ran, with full stdout and stderr. The console shows only clean progress lines |
<cache parent>/versecut.log (Windows), ~/Library/Logs/VerseCut/versecut.log (macOS) |
The server's stdout and stderr. On Windows it is written when running under pythonw.exe; on macOS the app launcher always appends to it and rotates it at 5 MB |
<temp>/versecut_*/ffmpeg.log |
Per-render FFmpeg stderr. Deleted with the temporary directory; its last 1500 characters are surfaced in the error message on failure |
setup/ffmpeg/SHA256SUMS, setup/ffmpeg-macos/SHA256SUMS |
The SHA-256 of every video-engine file shipped; the installers refuse to continue if a file does not match |
13. Setup and packaging
Both installers are pinned: every package version and every downloaded file is fixed and checked, and the video engine ships inside the VerseCut download rather than being fetched from a third-party site.
| Component | Windows | macOS |
|---|---|---|
| Python | 3.12.10 from python.org (per-user, only if missing; Authenticode signature checked) | 3.12.14 from python-build-standalone release 20260901, private to VerseCut (SHA-256 checked). The Mac's own Python, Xcode tools and Homebrew are not used |
| Python packages | setup/requirements.txt (32 exact pins, wheels only) then setup/requirements-nodeps.txt (faster-whisper==1.2.1 --no-deps). PyAV is never installed |
same |
| Video engine | setup/ffmpeg/: BtbN FFmpeg n9.0.2-3-ga5923073bf, LGPL v3 shared build (no x264/x265), checked against SHA256SUMS |
setup/ffmpeg-macos/: FFmpeg 9.0.2 + LAME 3.100, LGPL v2.1+, universal (arm64 + x86_64), built with release-tools/build-ffmpeg-macos.sh, checked against SHA256SUMS |
| Speech model | small.en from Hugging Face into runtime\models |
small.en into ~/Library/Application Support/VerseCut/models |
Windows — setup/install.ps1
Install-VerseCut.bat runs it with -NoProfile -ExecutionPolicy Bypass. The script sets $ErrorActionPreference = 'Stop' and TLS 1.2, creates runtime/, starts runtime/install.log, warns if the root is inside OneDrive, and runs Unblock-File on its own files. It reuses a WordCut 1.0 speech model if one is found (never an older FFmpeg) and deletes stale WordCut.lnk shortcuts.
| Step | What it does |
|---|---|
| 1 · Python | Find-Python tries py -3.12 and the usual per-user and all-users locations. If none is found it downloads python-3.12.10-amd64.exe from python.org, verifies the Authenticode signature and that the signer is the Python Software Foundation, then installs per-user |
| 2 · Environment | A venv at runtime\venv. A venv from an earlier version (no versecut-3.0 marker) is deleted and rebuilt, which also removes PyAV. Then the two requirements files, and an import check |
| 3 · FFmpeg | Verifies every file in setup\ffmpeg against SHA256SUMS, replaces runtime\ffmpeg with them, runs ffmpeg -version, and trial-encodes with h264_mf and aac_mf; if Windows' encoders do not respond it explains the Media Feature Pack for "N" editions |
| 4 · Speech model | Skipped if models--Systran--faster-whisper-small.en is present, otherwise about 480 MB. A failure is a warning; VerseCut retries on first use |
| 5 · Shortcuts | VerseCut.lnk on the Desktop and in the Programs folder, targeting runtime\venv\Scripts\pythonw.exe app\server.py |
| 6 · Licence folder | Creates %LOCALAPPDATA%\VerseCut\licence |
macOS — setup/install-macos.sh
Install VerseCut.command runs it. It builds one self-contained app, so where the buyer unzipped the download does not matter and the folder can be deleted afterwards. That fixes the 2.0 problems on a Mac: no Python prerequisite, no dependence on a folder in Downloads, Desktop or Documents (which macOS privacy protection blocked the 2.0 icon from reading), and an app that appears in Launchpad and Spotlight.
| Step | What it does |
|---|---|
| 1 · This Mac | macOS 12 or later, Apple silicon or Intel, 3 GB free. Chooses /Applications (or ~/Applications if not writable) and builds into a hidden .VerseCut-installing.app beside it |
| 2 · VerseCut | Copies app/, docs/, tests/, the licence and README into Contents/Resources/VerseCut with ditto --noextattr --noqtn, so no download attributes are carried into the app |
| 3 · Python | Downloads the pinned python-build-standalone archive for this Mac's architecture (cached in ~/Library/Application Support/VerseCut/downloads), checks its SHA-256 and unpacks it to runtime/python |
| 4 · Speech engine | Installs the pinned wheels, then faster-whisper with --no-deps, then an import check |
| 5 · Video engine | Checks setup/ffmpeg-macos/SHA256SUMS, copies ffmpeg/ffprobe to runtime/ffmpeg, and confirms h264_videotoolbox and aac_at are present |
| 6 · Speech model | As on Windows, into ~/Library/Application Support/VerseCut/models |
| 7 · App | Writes Info.plist (version 3.0.0, folder-access usage descriptions), the launcher and the icon; precompiles Python bytecode; signs the bundle ad hoc (codesign --sign -); moves any older VerseCut.app from either Applications folder to the Bin; moves the new app into place; registers it with Launch Services; opens it |
Any failure stops with a red Setup stopped: line and a macOS alert, and the full detail is in ~/Library/Logs/VerseCut/install.log.
The macOS .app bundle
VerseCut.app/Contents/
├─ Info.plist com.stateam.versecut 3.0.0, LSMinimumSystemVersion 12.0,
│ NSDocuments/Downloads/Desktop/RemovableVolumes…UsageDescription
├─ MacOS/VerseCut launcher (setup/launch-macos.sh)
└─ Resources/
├─ VerseCut.icns
└─ VerseCut/
├─ app/ docs/ tests/ LICENCE.txt README.md
└─ runtime/
├─ python/ private CPython 3.12 + pinned packages
└─ ffmpeg/ ffmpeg, ffprobe (LGPL, universal)
The launcher sets PYTHONDONTWRITEBYTECODE, PYTHONNOUSERSITE and VERSECUT_MODELS_DIR, unsets PYTHONPATH, PYTHONHOME and VERSECUT_DEV, appends to ~/Library/Logs/VerseCut/versecut.log, and runs runtime/python/bin/python3 app/server.py. If the engine is missing or exits with an error within a minute, it shows a macOS alert with a Show log button instead of failing silently.
Signed and notarised builds (next step)
VerseCut 3.0 is not yet signed by Stateam: the Windows installer is a .bat/PowerShell pair and the macOS installer is a .command script, so Windows SmartScreen and macOS Gatekeeper show a warning the first time (see the install guide). Removing that warning needs:
Windows. A code-signing identity (for example Azure Artifact Signing) and a single installer .exe/.msi built with Inno Setup or WiX that contains Python, the venv and FFmpeg, signed with signtool.
macOS. Apple Developer Program membership with a Developer ID Installer certificate. The simplest route from 3.0 is a signed, notarised .pkg whose post-install step runs install-macos.sh for the logged-in user; xcrun notarytool submit then xcrun stapler staple. The .app itself is built on the customer's Mac and is not quarantined, so it does not need notarising.
14. Local development
Running the engine from source
No installation is needed. Python 3.12 with the packages in setup/requirements.txt (plus faster-whisper --no-deps for transcription) and ffmpeg/ffprobe on PATH. On a development machine set VERSECUT_DEV=1 so VerseCut accepts an FFmpeg from PATH and the development encoders:
VERSECUT_DEV=1 python app/server.py --no-window
--no-window does three things: skips the "already running?" check on port.txt, does not launch a browser, and does not start the watchdog — so the process will not exit after two minutes of silence. It prints the URL and a token prefix:
VerseCut 3.0.0 running at http://127.0.0.1:8765/ (token Xk4q…)
Licence: creator / trial machine VC-4J7Q-2M8T-95XD
Reaching it with curl
Only the prefix is printed, so take the whole token from the served page:
PORT=8765
TOKEN=$(curl -s "http://127.0.0.1:$PORT/" | sed -n 's/.*token: "\([^"]*\)".*/\1/p')
curl -s -X POST "http://127.0.0.1:$PORT/api/state" \
-H 'Content-Type: application/json' \
-H "X-VerseCut-Token: $TOKEN" -d '{}'
curl -s "http://127.0.0.1:$PORT/api/jobs/<id>?t=$TOKEN"
curl -s -o out.mp4 "http://127.0.0.1:$PORT/media?t=$TOKEN&path=/abs/path.mp4"
Omitting the token gives 403 {"error":"bad token"}; sending a Host header other than 127.0.0.1 or localhost gives 403 {"error":"forbidden"}.
How the front end is served
There is no build step. GET / reads app/web/index.html from disk on every request and substitutes __TOKEN__ and __VERSION__; GET /static/<path> reads from app/web/. Both send Cache-Control: no-store. Editing a .js file and reloading the window is the whole development loop. window.WC = { S } is exported at the end of boot(), so the live project can be inspected from DevTools.
Running the test suites
tests/test_licensing.py is offline and needs no running server. It covers Ed25519 against RFC 8032, the licence matrix (including a 2.0 one-time licence being locked out of 3.0), card id stability across three re-imports, template re-use not growing a project, duplicate-path collapsing and job-table bounding.
tests/test_3_0.py checks the 3.0 licensing-clean build against the installed video engine: that customer builds never list x264 or FFmpeg's AAC encoder, that the requirements pin every package and exclude PyAV, that faster-whisper imports without PyAV, a real export with burned-in captions (H.264 + AAC, caption visible only during its cue), MP3 export, a preview proxy, and the macOS installer's safety properties.
python tests/test_licensing.py
python tests/test_3_0.py # Windows: Verify-VerseCut.bat runs both
VERSECUT_DEV=1 python tests/test_3_0.py # on a Linux development machine
tests/test_ui.py drives a running engine over HTTP. Start the server first, then:
python app/server.py --no-window &
python tests/test_ui.py [port] # default 8765
It reads the token out of the served HTML, checks that a wrong token is rejected, installs an expired licence and confirms the engine refuses a 4K export, transcription, card import, a project containing a transition and a project with a logo-track clip — all with 402 — then activates a valid licence and confirms that 4K is allowed, that tampered and foreign-key licences are refused without disturbing the good licence, that a Studio licence reports its seat count, and that projects/save drops unused media.
VERSECUT_TEST_LICENCES. Both suites need a set of signed test licences, which are not in this repository because generating them requires the private key. Set the variable to the folder:
export VERSECUT_TEST_LICENCES=/path/to/test-licences
Without it, both suites look for test-licences next to the repository root, then ../VerseCut-Licence-Tools/test-licences, then ../VC2-tools/test-licences. test_licensing.py prints SKIP licence matrix and continues; test_ui.py prints instructions (python versecut-licence.py testkit) and exits with status 2. The expected files are 01-creator-annual-valid.licence through 11-foreign-key.licence.
15. Extending VerseCut
Add a new export preset
app/web/js/dialogs.js,exportDialog(): add an<option>to#exPreset.- In the same function, add the key to the
sizemap —{ key: [width, height, quality] }.qualityis a single number thatvideo_args()maps to whatever the selected encoder wants:-global_qualityfor Quick Sync,-cqfor NVENC,-qp_i/-qp_pfor AMF, and — through_bitrate()— a-b:vforh264_mfand VideoToolbox. Lower is better quality in every case, and the_bitrate()thresholds step at 18 and 20, so keep new presets on that scale. - The
presetvalue (slow/medium) is passed through but only affects development builds that use x264. - Above 1080 lines, give the option
data-locked="1"whenmaxHeight()is below it, asuhd4kdoes;licensing.gate_height()enforces it server-side regardless. render.build()andvideo_args()need no change — they readwidth,height,quality,fps,encoderandpresetfromsettings. Adding a new encoder, by contrast, means an entry incodecs.H264(only for an encoder the operating system or GPU vendor provides and licenses), acodecs.LABELentry, a branch incodecs.video_args()and a label indialogs.js encName.
Add a title style
app/web/js/titles.js: add an entry toPRESETSwithname,line1andline2defaults.- Add a drawing function of the same key to
DRAW, signature(ctx, W, H, p, k), wherepis the clip'stitleobject andkis(size || 1) * (H / 1080). Use the module'sSERIF,SANS,GOLD,NAVYandCREAMconstants and thefit(),wrap(),shadow()andsmallCaps()helpers. - Nothing else:
renderTitle()dispatches throughDRAW[t.preset] || DRAW.plain,panels.js renderTitlePresets()and the Inspector dropdown are both built fromPRESETS, and export rasterises through the same function at the export resolution. - If the style needs a new field, add it to
titleKey()so the cache andpngKeyinvalidate correctly.
Add a track type
app/web/js/store.js: extendaccepts()with the clip kinds the type takes, add a prefix letter to the map innextTrackId(), handle it inaddTrack(), and add it tonewProject()if it should exist by default. Migrations for older projects belong insetProject().app/web/js/timeline.js: add a row height toHEIGHTand decide thecanHide/canMuteflags inrender().app/web/js/preview.js:buildLayers()creates slot layers forvideoandtextand an<audio>element foraudio; add a branch, and handle the type intick()and, if it is visible,visualClipsAt().app/engine/render.py:build()compositesvideoandtextand mixes audio fromvideoandaudio;apply_transitions()acts onvideoandtext;timeline_duration()ignorescaption. Extend each as needed.
Add a new API route
app/server.py: add a methodapi_<name>(self, d)toHandler. Route/api/foo/barmaps toapi_foo_bar—do_POSTdoesroute.replace("/", "_"). Return a JSON-serialisable value, orNonefor{"ok": true}.- Raise
ValueError(or any exception) for a 400, orlicensing.LicenceErrorfor a 402. - For anything slow, return
public_job(start_job("<kind>", <fn>, *args))and write the worker asdef worker(job, …), updatingjob["progress"]andjob["message"]and checkingjob.get("cancel"). Store any child process asjob["_proc"]soapi_jobs_cancelcan kill it andpublic_job()strips it. app/web/js/util.jsneeds nothing —api('foo/bar', {...})works immediately; wrap a job withwaitJob(job, onTick).
Add a caption format
app/engine/captions.py: writeto_<fmt>(cues)returning text, and/or a parser returning[{start, end, text}].- Register the writer in
server.api_captions_export's dispatch dict{"srt": …, "vtt": …, "itt": …}, and the reader incaptions.parse_file(), which dispatches on the extension toparse_ttml()orparse_srt_vtt(). app/server.pylist_dir(): add the extension to thecaptionstuple so the file browser shows it.app/web/js/dialogs.jsexportCaptions(): add an<option>to#ceFmt; the value serves as both theformatfield and the file extension.- If the format needs a frame rate or other parameter, note that
api_captions_exportpasses onlycues— extend the call site and the handler together.
16. Troubleshooting for administrators
| Symptom | Cause | Fix |
|---|---|---|
| The window does not appear, but the process is running | No Edge or Chrome at the paths open_window() checks, and the default browser did not open |
Open http://127.0.0.1:<port>/ manually — the port is in port.txt. Install Edge or Chrome for a proper app window. Check versecut.log |
| Nothing happens at all when the icon is clicked (Windows) | The shortcut points at a runtime\venv that no longer exists, usually because the folder was moved after setup |
Run the installer again in the current location |
| VerseCut shows "VerseCut could not start" (macOS) | The launcher could not find the engine inside the app, or the engine exited with an error | Click Show log and read the end of ~/Library/Logs/VerseCut/versecut.log; run Install VerseCut.command again to rebuild the app. The macOS app is self-contained and does not depend on where the download was unzipped |
RuntimeError: No free port, or "Cannot reach VerseCut engine" from a stale bookmark |
All 50 ports 8765–8814 are occupied, or the page was opened on a port that now belongs to a different launch, so its embedded token is wrong | Close the window and start VerseCut again — tokens are per-launch and never reused. For an occupied range, find the holder with netstat -ano / lsof -i :8765-8814; the range is not configurable without editing find_port() |
| "FFmpeg is missing — run Install-VerseCut first" | paths.FFMPEG is None: nothing at runtime/ffmpeg/ffmpeg[.exe] (an FFmpeg on PATH is ignored outside development builds) |
Run the installer again. Check the video-engine step in the install log |
| Export fails with "FFmpeg failed:" and a log tail | A real FFmpeg error — usually a missing source file, an unreadable codec or no disk space | The last 1500 characters of ffmpeg.log are in the dialog. Confirm every media file is present (the bin shows MISSING), and check free space on the export volume |
| The message says "<encoder> failed — retrying with <encoder>…" | The selected encoder could not be initialised part-way through — a driver update pending, another application holding the GPU encoder, or a stream the encoder will not accept | The retry is automatic: job_export() walks down the allowed encoders, up to two fallbacks, and produces a correct file. To avoid paying for the failed attempt each time, pick the encoder that succeeded from the export dialog's dropdown, which lists only encoders that passed a real trial encode |
| "Windows' own video and audio encoders were not found…" | codecs.video_encoders() or codecs.aac_encoder() came back empty. On Windows 10/11 "N" editions Media Foundation's encoders are absent |
Install the Media Feature Pack (Settings › Apps › Optional features), restart, open VerseCut again. On a Mac, restart the Mac. MP3 export still works. POST /api/state reports the working list under encoders |
| "Speech-to-text engine is not installed" | import faster_whisper failed — the venv is broken or the package did not install |
Run the installer again; step 2 is the relevant one. Check install.log for the pip output |
| "The ' |
models--Systran--faster-whisper-<name> is absent from MODELS_DIR and there is no internet |
Connect once and click Transcribe again, or choose a model the dialog does not mark "needs one-time download". /api/state reports models |
| Media shows as MISSING in the bin | The file was moved, renamed or is on a disconnected drive. verifyMedia() marked it on project open |
Put the file back at its original path, or re-import and re-place it. Note that tidy_project() will remove the media entry and its clips on the next save once nothing references it |
| Media stays at "reading…" or "optimizing…" | A slow proxy transcode, or a file that fails to probe | Proxies are 540p H.264 and take real time on a long recording. A failing file is retried no more than once a minute (FAIL_COOLDOWN) and abandoned by the page after 40 attempts (POLL_GIVE_UP). Check versecut.log |
| A project is slow to open or the bin appears blank | The 1.2 project-growth defect: hundreds of dead media entries from repeated card imports | Open and save the project once. tidy_project() runs on both load and save — it collapses duplicate paths, drops media no clip uses and removes orphan clips. 2.0's content-based media_id() stops it recurring |
| A licence will not activate: "This licence file is not genuine, or it has been altered" | The pasted text was truncated or re-wrapped by an email client, or it is for a different product | Paste the whole block including the BEGIN and END lines, or use Choose file… with the .licence attachment. A failed install never replaces the licence already in place |
| Licence says "This licence is locked to another computer" | The payload has a machines list that does not contain this machine's id |
Copy the Machine ID from the Licence dialog and contact support to move the licence. The id is a one-way hash and contains nothing identifying |
| Licence says "Your one-time licence covers VerseCut 2.0" on 3.0 | A perpetual licence is locked to a release line | The older version keeps working for good; the newer line needs an upgrade licence |
| Trial restarted unexpectedly | licence/state.json failed its HMAC check — it was edited, or the machine fingerprint changed (new network hardware, a rebuilt machine) |
Expected behaviour. Install a licence; the trial clock is not used once one is active |
Where to find each log
| Log | Windows | macOS |
|---|---|---|
| Setup | <root>\runtime\install.log |
~/Library/Logs/VerseCut/install.log |
Application (when launched by the shortcut or .app) |
%LOCALAPPDATA%\VerseCut\versecut.log |
~/Library/Logs/VerseCut/versecut.log |
| FFmpeg, per render | a temporary versecut_* folder, deleted on completion; the tail appears in the error dialog |
the same |
| FFmpeg build identity | setup\ffmpeg\SHA256SUMS in the download; ffmpeg -version |
setup/ffmpeg-macos/SHA256SUMS and BUILDINFO.txt; ffmpeg -version |
Launching from a terminal (python app/server.py --no-window) sends everything to the terminal instead of versecut.log — the quickest way to see a traceback, since do_POST prints one for every failed request.
17. Appendix: dependencies and their licences
VerseCut's own code is proprietary (© Stateam LLC). The components below are third-party. docs/notices.html carries the full list, including every pinned Python package.
| Component | Licence | How VerseCut uses it |
|---|---|---|
| FFmpeg 9.0 | LGPL (Windows build: LGPL v3; macOS build: LGPL v2.1 or later). No GPL or non-free parts: no x264, x265 or fdk-aac | Shipped inside the VerseCut download and run as a separate program, never linked into VerseCut. Windows: BtbN FFmpeg-Builds ffmpeg-n9.0-latest-win64-lgpl-shared-9.0 (version n9.0.2-3-ga5923073bf). macOS: FFmpeg 9.0.2 built by Stateam with release-tools/build-ffmpeg-macos.sh (network protocols disabled). Both unmodified. Corresponding source is published on the VerseCut website and available on request for three years. You may replace the binaries with your own build of the same libraries |
| LAME 3.100 (macOS build) / the LAME in the BtbN build (Windows) | LGPL v2 or later | MP3 encoding |
| faster-whisper 1.2.1 (SYSTRAN) | MIT | Offline speech-to-text. Installed with --no-deps, so PyAV is not installed |
| CTranslate2, ONNX Runtime, tokenizers, Hugging Face Hub | MIT, MIT, Apache 2.0, Apache 2.0 | Run and fetch the speech model. Hub telemetry is disabled and HF_HUB_OFFLINE=1 is set once a model is installed |
| Whisper models (OpenAI, converted by SYSTRAN) | MIT | small.en is fetched at setup |
| Pillow | MIT-CMU (HPND) | Draws burned-in captions |
| NumPy and the other pinned packages | BSD, MIT, Apache 2.0, ISC, MPL 2.0 (certifi, tqdm; unmodified) | See docs/notices.html |
| Python 3.12 | PSF License | Windows: python.org, per-user, signature checked. macOS: python-build-standalone, inside VerseCut.app |
| Source Sans 3 (bundled), Lora, Poppins, JetBrains Mono (website) | SIL Open Font License 1.1 | Caption and interface typography |
H.264 and AAC. VerseCut encodes H.264 and AAC only with encoders that the operating system provides and its vendor licenses — Intel Quick Sync, NVIDIA NVENC, AMD AMF and Media Foundation on Windows (h264_*, aac_mf), Apple VideoToolbox and AudioToolbox on macOS (h264_videotoolbox, aac_at). engine/codecs.py enforces this: x264 and FFmpeg's own AAC encoder are not selectable in a customer build. The patent status of decoding in FFmpeg and of the operating-system encoders is a matter for Stateam's counsel; this document describes what the software does, not a legal conclusion.
© 2026 Stateam LLC · VerseCut 3.0