# CLAUDE.md Operating rules for coding agents in this repository. It lists what you must not break and where to look; it does not explain the product. `AGENTS.md` points here. ## Context ClippyMe is a self-hosted app that turns long videos into 9:16 shorts: FastAPI backend, one pipeline subprocess per job, React 18 + Vite 6 + Tailwind v4 dashboard, files on disk instead of a database. Start with [docs/architecture/overview.md](docs/architecture/overview.md). ## Where to look, in order 1. `docs/architecture/` — current design of the subsystem you are changing. 2. The production code and its tests. 3. [docs/reference/configuration.md](docs/reference/configuration.md) and `.github/workflows/ci.yml`. 4. Only then `docs/research/`, for rationale. **Research and history are not implementation authority**: they describe other projects and past states. If they disagree with code, tests or architecture docs, those win. The source-of-truth table is in [docs/README.md](docs/README.md). ## Repo map - `src/clippyme/api/` — FastAPI routes (`app.py`: jobs, clips, monitor; `config_routes.py`: settings), `schemas.py`, `security.py`. - Service packages (endpoint logic; never import FastAPI): `src/clippyme/jobs/` job lifecycle (submission, queue, runner with the retry loop, control, journal, runtime state, results, history and restore, uploads); `clips/` per-clip operations (resolution, locks, smart cut and reframe requests); `editing/` compose layers (grade, subtitles, smart cut, hook, logo, banner, AI trim); `monitoring/` live monitor; `publishing/` manual publish. - `src/clippyme/core/` — errors, SSRF-safe DNS (`netutil`), Gemini clip schema (`clip_schema`). `media/` — ffmpeg/ffprobe primitives shared by the pipeline and the compose layers: `encode`, `media_probe`, `ffmpeg_exec`. - `src/clippyme/pipeline/` — the per-job subprocess. Entrypoint is `orchestrator.py` (preflight, checkpoints, retries, output QA) wrapping `main.py` (download, transcription, Gemini, cut, reframe); both module paths are fixed (`python -m`, persisted argv). Stages live in `transcription/` (Deepgram, ElevenLabs, cache, diarization; local Whisper stays in `main.py`, device/model choice in `hardware.py`), `analysis/` (Gemini request/parser/service, TextTiling), `reframe/` (engine, detectors, tracking, decision math, scene detection) and `quality/` (clip QA, media QA, quality suite). - `src/clippyme/integrations/` — Zernio, Kick, Twitch, YouTube RSS, auto-editor updater. `storage/` — `data/config.json`. - `dashboard/src/` — the UI: `app/App.jsx` (state wiring only), `features/` (one folder per screen: create, processing, results, clip-editor, publishing, live-monitor, history, settings), `components/` (cross-feature UI; `controls/` = shared subtitle/logo/grade/banner/hook controls), `api/` (backend client), `hooks/` side effects, `lib/` pure logic, `styles/`. - `tests/` mirrors `src/clippyme/`; frontend tests sit next to the code. ## Commands ```bash pytest -m "not integration" -q # host suite ruff check src/clippyme tests --select E9,F63,F7,F82 # CI lint rules cd dashboard && npm run lint && npm test && npm run build # frontend docker compose run --rm -u root backend sh -lc "pip install -q pytest && pytest -m integration -q" ``` Setup, skips and the full CI list: [docs/development/testing.md](docs/development/testing.md). Optional upstream engineering/UX/media skill routing: [docs/development/agent-skills.md](docs/development/agent-skills.md). Skill advice cannot override the invariants below. Dependency changes: [docs/development/dependencies.md](docs/development/dependencies.md) (`requirements.lock` is generated with uv, never hand-edited). Minimum before committing: host suite + Ruff for backend changes; lint, test and build for dashboard changes; the integration suite when you touch CV-dependent pipeline code, the Dockerfile or dependency files. ## Code rules - **Thin handlers**: validate → call a service helper → return JSON. More than ~25 lines of logic in a handler gets extracted. Service code raises `core.errors.ValidationError` (400), `NotFoundError` (404), `ConflictError` (409), mapped by one app-level handler. - **Per-clip endpoints resolve through `clip_resolve.resolve_clip()`**; do not re-implement the metadata/filename fallback chain. Clip files resolve via `clip_resolve.clip_filename_for`. - **Host-testable logic**: `pipeline/main.py`, `reframe.py`, `reframe_detect.py` import cv2/torch at module level and cannot be imported on the host. Put new logic in the pure modules (`reframe_ops.py`, `reframe_track.py`, `cut_ops.py`, `run_ops.py`, `gemini_request.py`, `smartcut_ops.py`, `media_probe.py`, …). - **Back-compat re-exports**: `reframe.py` re-exports the moved track/detect names, `main.py` the reframe API, `smartcut.py` the `smartcut_ops` names. Keep them when moving code. - **Atomic writes** for anything a crash could corrupt: temp file + `os.replace`, mode `0o600` (`job_artifacts.save_job_metadata` pattern); add fsync where the state must survive power loss (`runtime_state`, `job_journal`). - **One encode setting**: every libx264 pass uses `media/encode.x264_video_args()`; no raw `-crf` literals. - **Shared frontend controls**: subtitle/logo/grade controls are shared between Create and the edit modal via `subtitleControls.jsx` / `layerControls.jsx` (`value` + `onChange(partial)`); never clone them per surface. Edit-modal state lives in the `editClipModal.jsx` shell: tab bodies in `editTabs.jsx` are conditionally rendered and lose state on unmount. UI primitives are hand-rolled in `primitives.jsx` (no shadcn CLI). - **Defaults duplicated across stacks** (hook style in `dashboard/src/lib/uiOptions.js` and `editing/hook_overlay.py`, grade/logo presets) are pinned by `tests/editing/test_frontend_backend_parity.py`; change both sides and the test. - **Lint config**: `dashboard/eslint.config.js`. Accessibility is checked by the Axe test (`src/app/accessibility.test.jsx`), not by lint rules. - **New setting**: document it in `docs/reference/configuration.md` (and `.env.example` if operators will set it). `tests/repository/test_docs.py` fails when a setting read by the code is missing from the reference. ## Invariants Breaking one of these is a bug even if tests pass. - **Lock order**, never reversed: `clip_locks.clip_lock` (asyncio, per `(job_dir, clip_index)`, re-entrant per task) first; then either `smartcut._clip_lock` (threading, only inside a worker thread) or the live monitor's global `_publish_lock` (asyncio; serialises monitor publishes and guards `picked_slots`; manual publish never takes it); `job_artifacts._METADATA_LOCK` innermost, only in sync/worker-thread code. No threading lock is held across an `await`. - **Job metadata is one shared document**: API-side writers use `job_artifacts.update_job_metadata` (reload + mutate + save under the lock), never a copy loaded before a long render. Reframe, publish and history restore answer 409 while the job is queued/processing/paused. - **Monitor hand-off**: the monitor holds the clip lock over compose → copy at hand-off and over recompose → upload → accept, including the post-accept history record and artifact cleanup (run in a worker thread). - **Publish identities** stay separate: logical clip `(job_id, original_index)`; monitor `publication_id`; `request_id` = Zernio `Idempotency-Key`, reused after an ambiguous outcome, rotated (and persisted before the next call) only after a certain rejection. `409 idempotency_conflict` is retried with the same key after `Retry-After` and never counted as accepted; `409` with `existingPostId` is accepted. Manual publish derives its key from `(job_id, clip_index, intent_id)`; no `intent_id` = no key. Delivery is at least once. Details: [docs/architecture/publishing.md](docs/architecture/publishing.md). - **Manual publish vs monitor**: 409 while `LiveMonitorRegistry.publication_owner` says the monitor owns the clip. - **Published clips**: after a confirmed monitor publish, artifacts are deleted and the entry marked `deleted_after_publish`; `shorts` positions never shift, consumers pass `original_index`. - **Job journal** (`data/jobs_journal.json`) holds active jobs only and never secrets, `Popen` handles or logs. Recovery reuses status `failed` on purpose: the frontend stops polling only on `completed|stopped|cancelled|failed`. - **Exit code 2** from the orchestrator = deterministic rejection, never retried. Progressive metadata is never a completion marker. - **Compose order**: Grade → Subtitles → Smart Cut → Hook → Logo → Banner. Fusion may merge passes, never reorder them. An animated hook needs `-loop 1` + `-shortest`. - **Reframe `disabled`** = whole frame letterboxed, Ken Burns forced off. `REFRAME_GLOBAL_METHOD=kalman|l2` only runs with `REFRAME_STATIC_AUTO=0`. - **Gemini cost**: one price table (`gemini_request.MODEL_PRICING`); thinking tokens bill at the output rate; cost is priced per usage category, never from `total_token_count`; an unpriced model's cost is `None`, never 0; the preflight cost gate fails closed on unknown pricing. - **Gemini prompt**: titles are engagement-first by design; never reintroduce a mechanical CTA. `CLIPPYME_CREATOR_NAME` names the clip's subject but never overrides speaker attribution. Rationale: [docs/research/title-hook-copy.md](docs/research/title-hook-copy.md). - **Clip edges**: word → sentence → silence snapping in `cut_ops.py`; the 10–75 s clip-schema cushion is intentional. - **ffmpeg in the pipeline** goes through `ffmpeg_exec.py` (stderr to a temp file, no-progress watchdog, `.partial-` then validate then rename). - **DNS** for SSRF checks goes through `netutil.py`; never touch `socket.setdefaulttimeout`. ## Security rules - `job_id` is regex-validated everywhere; the per-job `model` name is regex-validated against argv injection; config/state endpoints require a trusted origin or private-network client (`security.py`). Subprocess argv is always a list, never a shell string. - `SafeStaticFiles` blocks `*_metadata.json`, `source_*` and runtime files on `/videos`. - With `TRUST_PROXY=1`, `client_ip` reads the **last** `X-Forwarded-For` hop, matching nginx's appending `$proxy_add_x_forwarded_for`; keep both in sync. - Never commit secrets, `data/`, `.env` or `tmp/`. The pre-commit hook (`git config core.hooksPath .githooks`) enforces it; never bypass it. - Tests build fake secrets at runtime; no token-shaped literals in the repo. ## Do not - Commit with `--no-verify`, force-push, or rewrite published history. - Hand-edit `requirements.lock` or anything in `docs/vendor/`. - Treat `docs/research/` as a specification. - Add a new document when an existing canonical page can be made clearer; link instead of copying facts between pages.