A local-only desktop media encoder. It takes a file already on your machine, encodes it to HLS/ABR with FFmpeg (GPU-accelerated where the hardware allows), optionally encrypts it with AES-128, and uploads the result straight to your own S3-compatible storage. Nothing is uploaded to us, because there is no us: the encoder, the UI and the credentials all live on the one machine.
It also speaks to the Luminary CMS. A user clicking "upload media" on a post in their browser opens a session on the local encoder; the file is chosen in the desktop app, and the CMS is handed back a playback URL and (when encrypted) the key, over a live event stream.
Luminary CMS (browser, another origin) Desktop app (Electron)
│ ┌──────────────────────────┐
│ GET /api/cms/health │ main process │
│ POST /api/cms/sessions ──────────►│ • Encoding API (Nest) │
│ (gated by Origin, TOFU) │ • safeStorage cipher │
│ │ • origin dialogs │
│ SSE /api/sessions/:id/events ◄───│ • ffmpeg / ffprobe │
│ hlsUrl, progress, status │ │
│ GET /api/sessions/:id/key ──────►│ │
│ the AES-128 key, masked │ │
│ renderer = app/dist │
└───────────┬──────────────┘
│ local file, by reference
▼
S3-compatible storage
(the user's own bucket)
The API binds to 127.0.0.1 only. The renderer authenticates with a token minted per launch and passed over the preload bridge; a CMS is authorised by its browser Origin, approved once by the user in a native dialog and remembered thereafter.
| Workspace | Description | README |
|---|---|---|
api/ |
Encoding API — NestJS, embeddable via createServer() |
api/README.md |
app/ |
Vue 3 renderer UI | app/README.md |
app-electron/ |
Desktop shell, hosts the API in-process, packaging | app-electron/bin/README.md (ffmpeg binaries) |
cms-mock/ |
Dev-only stand-in for the Luminary CMS | cms-mock/README.md |
encode-config/ |
Shared encode-config form + types | encode-config/README.md |
hls-core/ |
Shared HLS parsing, key utilities, angle extraction | — |
player-core/ |
Player pipeline: fetch, decrypt, extract angles, state | — |
player-web/ |
Web player over the PlayerAdapter contract |
— |
player-web-legacy/ |
The same contract on Video.js 8 / VHS | player-web-legacy/README.md |
ffmpeg-build/ |
Builds the bundled LGPL FFmpeg | ffmpeg-build/README.md |
- Node.js ≥ 18
- FFmpeg + ffprobe on
PATHfor development, withaacand a hardware H.264 encoder for your machine —h264_videotoolbox+scale_vton Apple Silicon,h264_nvencon NVIDIA. Deliberately not alibx264build: there is no software H.264 fallback left anywhere, because packaged builds ship an LGPL FFmpeg without it and development is meant to fail the same way. Packaged builds ship their own — see app-electron/bin/README.md - An S3-compatible bucket to write output to (MinIO, R2, AWS S3, B2, Spaces…)
npm installnpm run devRuns the shared-library watch builds, the API on http://127.0.0.1:3000 (Swagger at /api/docs), and the web client on http://localhost:5173.
api/.env:
MASTER_API_KEY=dev-token
CMS_ALLOWED_ORIGINS=http://localhost:5199app/.env:
VITE_API_URL=http://127.0.0.1:3000
VITE_API_TOKEN=dev-tokenThere is no preload bridge in a plain browser, so a dropped file cannot be resolved to a real path — the local-file flow needs the Electron shell.
npm run dev:electronBuilds the libraries and the API, then runs Vite and Electron together.
If the app starts and no window ever appears, check for
ELECTRON_RUN_AS_NODE=1in your environment (VS Code terminals and some agent runners set it) andunsetit. With it set,electron .runs as plain Node.
npm -w cms-mock run dev # http://localhost:5199A dev-only app that drives the real handshake against the running encoder: health check, POST /api/cms/sessions, an SSE console that highlights the first event carrying hlsUrl + encryptionKeyHex, and a playback check that lists video angles, renders the extracted single-angle and audio-only playlists, and previews the luminary://key substitution a player performs. It runs on a different origin on purpose, so origin gating and CORS are genuinely exercised. Defaults assume a local MinIO with an anonymously readable media bucket.
Each command builds the encoder it ships first, then the workspaces, then the app — so a clean clone produces a complete artifact with no separate steps.
npm -w app-electron run dist:mac # dmg + zip, arm64 and x64
npm -w app-electron run dist:win # NSIS installer, x64 — needs a Windows machine
npm -w app-electron run dist:win-portable # portable zip, x64 — builds on macOS too
npm -w app-electron run pack # unpacked directory, for inspection
npm -w app-electron run verify-package # assert every packaged app can actually encodeArtifacts land in app-electron/release/, named <product>-<version>-<mac|win>-<arch>.<ext>.
Neither the artifacts nor the ffmpeg binaries are in the repository.
| Target | Host | Prerequisites |
|---|---|---|
| macOS dmg/zip | macOS | brew install nasm pkg-config gnupg (plus the Xcode CLT) |
| Windows portable zip | macOS or Linux | the above, plus brew install mingw-w64 cmake llvm |
| Windows NSIS installer | Windows | a Windows machine, or Wine — which is why the portable target exists |
LLVM is not optional for the Windows build. --enable-cuda-llvm gives scale_cuda
for the NVIDIA path and needs a clang with the NVPTX backend, which Apple's clang does
not have. Put it first on PATH:
PATH="/opt/homebrew/opt/llvm/bin:$PATH" npm -w app-electron run dist:win-portableThe ffmpeg build itself is ffmpeg-build/README.md — why we
build rather than download, what goes in, and the LGPL position. app-electron/bin/README.md
covers where the binaries land and their licences.
Builds are unsigned — there is no Developer ID and no Authenticode certificate, so
there is no auto-update either. macOS bundles are still ad-hoc signed by
app-electron/build/after-pack.cjs, without which a downloaded copy is refused outright as
"damaged". On macOS 15 and later, opening an unsigned app takes System Settings →
Privacy & Security → Open Anyway; right-click → Open no longer works, Apple removed
it. Windows shows a SmartScreen warning. The portable Windows build additionally carries
the stock Electron icon, because stamping it needs Wine.
Signing, notarization and auto-update are tracked in #206.
| Command | What it does |
|---|---|
npm run dev |
Libraries (watch) + API + web client |
npm run dev:electron |
Libraries (watch) + API build + web client + Electron |
npm -w api run dev |
API dev server with watch |
npm -w api run build / test / test:e2e |
Build and test the API |
npm -w app run dev / build / test |
Web client |
npm -w cms-mock run dev |
CMS mock on port 5199 |
npm -w app-electron run dev / dist:mac / dist:win / pack |
Desktop shell |
npm -w app-electron run dist:win-portable |
Portable Windows zip, buildable on macOS |
npm -w {hls-core,encode-config,player-core,player-web} run build / dev / test |
Shared libraries |
- CLAUDE.md — the deep reference: architecture, CMS contract, trust model, endpoint tables, conventions, packaging notes
- api/README.md — API reference, authentication, environment, encoding workflow, output layout
- app/README.md — renderer structure and environment
- Issues — known gaps and follow-up work
- PATENTS.md — the patent position, which no copyright licence covers
An app that embeds player-web or player-web-legacy needs the five library
workspaces built — they ship only dist/ — and needs none of the desktop app to
do it. npm run ci:libs installs exactly that subset, and npm run build:libs
builds it:
npm run ci:libs # ~180 MB, no electron
npm run build:libsA plain npm ci installs every workspace, which pulls in electron and
electron-builder and downloads the Electron binary — around 500 MB and a
lengthy download, to produce five small libraries. That is the right thing when
working on the encoder and the wrong thing in a consumer's image build.
The workspace list in ci:libs is the one in build:libs. Adding a library
means adding it to both, which is why they sit next to each other.
Licensed per directory rather than repository-wide, because the pieces are not all destined for the same audience: the shared libraries are meant to be consumed by other applications, while the build scripts are a separate concern.
| Licence | ||
|---|---|---|
every workspace — api/, app/, app-electron/, encode-config/, hls-core/, player-core/, player-web/, player-web-legacy/, cms-mock/ |
Apache-2.0 | each carries its own LICENSE |
ffmpeg-build/ |
GPL-3.0-or-later | carries its own LICENSE; the build scripts also carry an SPDX header |
everything else — docs/, test-media/, the root files |
Apache-2.0 | the root LICENSE |
| the bundled FFmpeg and FFprobe | LGPL-2.1 | built without --enable-gpl and without libx264; COPYING.LGPLv2.1 and LICENSE-ffmpeg.txt ship beside the binaries |
The LGPL build is enforced rather than assumed: verify-package.mjs fails the
package unless the shipped encoder reports LGPL and carries no libx264, and it
reads BUILDCONF.txt for targets the packaging machine cannot execute.
Substitution is what the LGPL asks in return for shipping this way. The encoder
resolves in order: FFMPEG_PATH, then a directory the user chooses in the app,
then the bundled binary.