Imported from artiom-bw/video-compression (
AGENTS.md). Install upstream withnpx skills add artiom-bw/video-compression. Copyright stays with the author.
AGENTS.md — full project context for AI agents
Hand this file to any AI agent working on this repo. It is the source of truth for architecture, conventions, and non-obvious behaviors. Prefer reading this + the actual files over inventing new patterns.
Project: video-compression (pet project)
Purpose: Upload (or CLI-encode) a source video into multiple codecs/resolutions with ffmpeg, store each run as a pack, then visually compare results in a React viewer (including for a team Show & Tell). Also ships as a macOS Electron app (.dmg) wrapping the same UI + local API.
Owner intent: Clean architecture, no magic values, CSS Modules, arrow functions, barrel exports. Keep Compare playback sync rock-solid when changing sources or modes.
Quick commands
npm run dev # Vite + local compress API → http://localhost:5173
npm run electron:dev # Electron window; API owned by electron/dev.mjs; Vite proxies /api + /compressed
npm run electron:build # dist + unsigned arm64 DMG → release/
npm run compress # CLI fallback: original-video/<one file> → public/compressed/{packId}/
npm run build # tsc -b && vite build
npm run preview # static preview only — upload/compress API is NOT available
Requirements: Node 18+, ffmpeg with libsvtav1, libx264, libx265. Packaged Mac app packs: ~/Documents/Compressed videos/ (COMPRESSED_ROOT). Web/electron:dev use public/compressed/.
High-level pipeline
Browser (npm run dev)
Home: pick codecs + resolutions, upload one file OR open/remove pack
│
▼ POST /api/compress (raw body + X-Filename + X-Codecs + X-Resolutions, SSE)
server/encode.mjs → pack dir under public/compressed/{packId}/
│
▼
scripts/generate-list.mjs → {packId}/list.json + packs.json upsert
│
▼
Viewer (Grid / Compare) for that pack (auto-open after compress)
CLI path (optional):
original-video/<one file>
│
▼ npm run compress (= cd original-video && bash ../compress.sh)
server/cli-compress.mjs → server/encode.mjs → public/compressed/{packId}/
Pack disk layout
public/compressed/
packs.json
{packId}/
{source-name}.{ext} # uploaded / copied original
list.json
{base}-{codec}-{crf}-{res}p.mp4
packId= slug(basename) + timestamppacks.json:{ packs: [{ id, name, createdAt, original, videoCount }] }- Video URLs:
/compressed/{packId}/{file} - One active pack in the viewer at a time (PackSwitcher)
- Legacy: flat
public/compressed/*.mp4auto-migrated intolegacy-{base}on API start ifpacksis empty - Runtime store is
public/compressed/(notdist/). Pack binaries /list.json/packs.jsonare gitignored.
Encoding rules (server/encode.mjs)
- Codecs:
av1(libsvtav1),h264(libx264),h265(libx265) — subset viacodecsopt /X-Codecs - Resolutions: default
1080 720 480(height; width auto viascale=-2:HEIGHT) — subset viaresolutionsopt /X-Resolutions/ envRESOLUTIONS - Job matrix = selected codecs × selected resolutions (at least one of each required)
- Audio is kept — re-encoded AAC 128k
- Output name:
{base}-{codec}-{crf}-{res}p.mp4 - Env defaults:
FRAMERATE=24,CRF_AV1=52,CRF_H264=40,CRF_H265=40,JOBS=4 - Progress/log events:
{type:'log'|'progress'|'encodeProgress'|'done'|'aborted'|'error'}encodeProgress:{ codec, res, percent }(0–100; needs ffprobe duration)progress: completed encode count{ done, total }
- Only one compress job at a time (
409if busy) - Abort (
EncodeAbortedError/POST /api/compress/abort) deletes the pack dir + drops it frompacks.json
Catalog (scripts/generate-list.mjs)
Per-pack list.json shape (unchanged):
{
"original": { "file": "bw-video.mov", "size": "220.6 MB", "bytes": 231356637 },
"videos": [
{
"base": "bw-video",
"codec": "av1",
"res": "1080",
"file": "bw-video-av1-52-1080p.mp4",
"size": "958.2 KB",
"bytes": 981147,
"saved": "219.7 MB",
"savedPercent": 99.6
}
]
}
Local API (shared handlers)
Implemented in server/api-handlers.mjs:
- Vite:
server/vite-plugin-api.mjs(skipped whenELECTRON_API_PORTis set) - Standalone / Electron:
server/create-api-server.mjs - Electron shell:
electron/main.mjs, orchestratorelectron/dev.mjs
| Endpoint | Role |
|---|---|
GET /api/packs |
list packs (+ legacy migrate) |
DELETE /api/packs/:id |
delete pack dir + remove from packs.json |
POST /api/packs/:id/reveal |
open pack folder in Finder (open / explorer / xdg-open) |
GET /api/ffmpeg-status |
{ ok, missing, message } for Home banner / startup gate |
POST /api/compress |
raw video body; headers X-Filename, optional X-Codecs, X-Resolutions; upload fully, then SSE |
POST /api/compress/abort |
abort active job + delete pack dir |
Also serves GET /compressed/{packId}/{file} from Node fs. Video responses must support HTTP byte ranges (Accept-Ranges, 206, Content-Range) — without this, HTML video seeking snaps back to 0. Pack root: public/compressed/ or process.env.COMPRESSED_ROOT.
Gotcha: never listen to req "close" for abort — Node emits it after a successful body read too (false abort → instant return home). Abort via explicit endpoint + res "close" after SSE starts.
Electron PATH: Finder-launched apps get a minimal PATH — main/dev.mjs prepend /opt/homebrew/bin and /usr/local/bin so Homebrew ffmpeg resolves.
Gitignore notes
- Pack binaries (
*.mp4/ originals) underpublic/compressed/ignored packs.jsonand per-packlist.jsonignored (runtime)- CLI still accepts a source in
original-video/(not necessarily committed)
App architecture (src/)
src/
main.tsx
styles/global.css
app/
App.tsx # screen router: home | progress | viewer
screens/
HomeScreen/ # UploadPanel + PackList
CompressProgress/ # bar + logs + abort + elapsed/ETA
ViewerScreen/ # Grid / Compare (+ PackSwitcher)
components/
ModeToggle/
FadeSwitch/
VideoPlain/
VideoCompare/
UploadPanel/ # file + codec/resolution multi-select
PackList/ # Open + Remove
PackSwitcher/
index.ts
hooks/
usePacks.ts # list + remove
useCompressJob.ts # upload/SSE/abort + encodeProgress + startedAt
useVideoCatalog.ts # pack-scoped list.json; filters codecs/res from catalog
useComparePlayback.ts
index.ts
constants/video.ts
types/video.ts
utils/
server/
encode.mjs # resolveBin / checkFfmpeg; imports generatePackList
packs.mjs # COMPRESSED_ROOT override supported
generate-list.mjs # generatePackList() → list.json
api-handlers.mjs
create-api-server.mjs
cli-compress.mjs
vite-plugin-api.mjs
electron/
main.cjs # CJS entry (require('electron')); packaged extraMetadata type=commonjs
preload.cjs
dev.mjs
Import conventions
- Prefer barrels:
from "../constants",from "../hooks",from "../types",from "../utils",from "../components",from "../screens". - Use
import cn from "classnames"(aliascn, notclassNames). - Components are arrow functions (
export const Foo = () => {}). - Styles: CSS Modules (
*.module.css) exceptstyles/global.css. - Design tokens live on
:rootinglobal.css. Use them in modules. - No magic strings for codecs/modes/resolutions/screens/API — use
constants/video.ts.
Types
Codec,Resolution,ViewMode,Screenderived from const objects inconstants/video.ts.PackMeta,CompressEvent, catalog types intypes/video.ts.CompressSelection(codecs+resolutions) from UploadPanel / compress start options.
UI behavior (important)
Screens
| Screen | Behavior |
|---|---|
| Home | Left: drop one video, multi-select codecs + resolutions (default all; ≥1 each) → Compress. Right: Open / Show in Finder / Remove pack. While a job runs in the background: CompressJobBanner (same % bar/stats, Show → Progress, Abort). Upload disabled while running. |
| Progress | Full logs + bar + ETA. Back to home leaves the job running (does not abort). Abort cancels. On success only if still on Progress → auto-open Viewer; if user left for Home/Viewer, packs refresh quietly and job chrome clears. On abort → home. On error → Back / Home banner with Dismiss. |
| Viewer | Grid / Compare for one active pack. PackSwitcher + Back to home (safe during background compress). |
Progress logs
- Pending
Encoding {codec} {res}p…lines show spinner +%when available. - Once
✓ … {codec} {resp}arrives, hide the matchingEncoding …row (don’t leave both). - Overall
%= mean ofencodeProgressvalues overtotaljobs (floor atdone/totalif probe failed).
Viewer modes
| Mode | Behavior |
|---|---|
| Grid (default) | Videos grouped by resolution present in the pack. Codec cards + size/savings. |
| Compare | Independent codec + res per side from available pairs only (codecsForRes / resolutionsForCodec). Hover slider. Shared controls. Disabled when the pack has fewer than 2 encodes. |
Compare playback (useComparePlayback + VideoCompare)
Critical invariants — do not break:
- Two
<video>elements, always bound toleft.src/right.src. Swap only flips clip-path / labels / z-index — no remount/reload on Swap. - Hover moves the vertical split; no drag required.
- Click on stage (and Enter/Space) toggles play/pause.
- Videos are
mutedin the viewer (files still contain AAC). - Shared scrubber + rAF drift correction. Uncontrolled
<input type="range">(DOM value via ref) — controlledvalue={currentTime}raced pointerUp and snapped seek to 0. Scrub position tracked inscrubValueRef. Noloop. - On codec/res change: preserve timecode; after metadata, seek both and resume if was playing.
compareTimelives inuseVideoCatalog. On Compare unmount, flush viaonTimeChange. On remount,initialTimeseeks and starts paused.- Clip technique: front video uses
clipPath: inset(0 ${100-split}% 0 0). - Prefer distinct left/right pairs; with one codec, differ by resolution when possible. If only one encode exists, identical sides are allowed (don’t disable the sole
<option>). - Play/pause must use
playingRef+playBoth/pauseBoth(never leave one side playing). Guard rAF whileseekingRef.
Grid playback (VideoPlain)
react-intersection-observer→ play when in view, pause when leave.- Native controls, muted, loop when autoplay.
Defaults (from constants/video.ts)
- Screen:
home - Mode:
grid - Compare left: AV1 @ 1080p
- Compare right: H.264 @ 1080p
- Split: 50%
- View fade: 220ms
- Compress ETA shown after overall ≥
UI.COMPRESS_ETA_MIN_PERCENT(3%)
What NOT to do (unless explicitly asked)
- Don’t add more codecs without updating
server/encode.mjs,generate-list.mjsregex,CODECS/ orders / labels. - Don’t remount videos on Swap.
- Don’t strip audio in ffmpeg unless product decision changes.
- Don’t put hex colors in modules when a CSS variable exists.
- Don’t invent a second
cnhelper — useclassnamesascn. - Don’t commit large binaries under
public/compressed/. - Don’t expect upload/compress API under
npm run preview/ static hosting. - Don’t abort compress by listening to
req"close"(false positives after body read).
Verify after changes
npx tsc -b
npm run build
Manually smoke-test:
npm run dev→ home with two panels; codecs/resolutions checkboxes default all.- Deselect some codecs/res → Compress → progress shows fewer jobs, elapsed/ETA, Abort works (pack gone).
- Success → Viewer; Grid/Compare selectors match only encoded pairs.
- Back → pack listed → Open / Remove.
- PackSwitcher switches catalog; Compare sync intact.
- Optional: CLI
npm run compresswith one file inoriginal-video/.