Imported from feralcreative/feralcreative.tv (
AGENTS.md). Install upstream withnpx skills add feralcreative/feralcreative.tv. Copyright stays with the author.
feralcreative.tv
Feral Creative's TV network: a signed-in channel dashboard at feralcreative.tv (index.html), the cat camera grid at cats.feralcreative.tv (cats.html), and the full-window Montclair skyline at montclair.tv (skyline.html). The hub also links to 3d.feralcreative.tv, the printer cam, which is a separate repo (~/www/feralcreative/3d.feralcreative.co) and container. nginx serves the three pages here from the same cats-web container. RTSP→HLS conversion runs in MediaMTX; PTZ and feed administration run in the Express/ONVIF backend. Production uses three Docker containers on a Synology NAS behind Cloudflare. The new hub and cats.tv hostnames are prepared in local code; DNS, Tunnel ingress, OAuth origins, and production deployment still require explicit authorization. nginx 301s the old cats.feralcreative.co hostname to cats.feralcreative.tv, so do not deploy this until cats.feralcreative.tv resolves and is in the OAuth origins.
When a change makes anything in this file inaccurate—a command, a path, a convention, a prohibition—update this file in the same change.
Commands
| Task | Command |
|---|---|
| Install (root, sass only) | npm install |
| Install (ONVIF backend) | cd backend && npm install |
| Compile SCSS once | npx sass styles/scss:styles/css --style compressed |
| Watch SCSS | npm run watch:css |
| Start everything, open browser | npm start |
| Start everything, no browser | npm run start:no-open |
| Start containers only | npm run dev |
| Stop local stack | npm run dev:stop |
| Force-kill local stack | npm run dev:kill |
| Rebuild images locally | docker compose up -d --build |
Regenerate mediamtx.yml from template |
bash utils/deploy/hooks/pre-build.sh |
| Run backend outside Docker | cd backend && npm run dev |
| Deploy to production | ./utils/deploy/prod.sh |
| Production container status | ./utils/deploy/deploy-utils.sh status |
| Production logs | ./utils/deploy/deploy-utils.sh logs |
There is no test suite, no linter, and no typechecker. Do not invent one without being asked.
npm start (utils/start.sh) compiles SCSS, starts a sass --watch, opens http://localhost:54999/ once nginx answers, and runs docker compose up --build in the foreground. Ctrl+C stops the stack and the watcher. It refuses to start if .env, mediamtx.yml, or node_modules is missing, and names the fix. OPEN_BROWSER=0 or npm run start:no-open skips the browser.
npm run dev is the bare docker-compose up with no watcher and no browser. There is no BrowserSync and no dev:all script, whatever older docs claim.
./utils/deploy/deploy-utils.sh also accepts restart, stop, start, shell, backup, restore, update-data, and help. Every subcommand SSHes to the NAS.
Definition of done
npm start. It compiles SCSS, rebuilds changed images, and opens the page for you.- Check the directory at
http://localhost:54999/, the grid athttp://cats.localhost:54999/, and Montclair athttp://montclair.localhost:54999/. Confirm all three containers are up andcats-webreports healthy:docker ps --filter name=cats-. - Check the browser console. Off the camera LAN, expect RTSP failures for the three Tapo tiles and an off-air SKYLINE tile when OBS is stopped; that is not your change breaking.
- Tear down when finished: Ctrl+C, then
npm run dev:stopto remove the containers.
Not required: any test run, any lint run, or a production deploy. Never deploy as part of "finishing" a change.
Prohibitions
- Never commit
mediamtx.yml. It is generated frommediamtx.yml.templatebyutils/deploy/hooks/pre-build.shand contains live RTSP credentials. It is currently tracked in git despite being listed in.gitignore; leave the removal to a human (see Gotchas). - Never put a credential in
docker-compose.prod.yml. It currently hardcodesCAMERA_USERandCAMERA_PASS. Both belong in.envand must reach the container throughenvironment:interpolation, the waydocker-compose.ymldoes it. - Never hand-edit
mediamtx.yml,styles/css/*, or eitherpackage-lock.json. Editmediamtx.yml.template,styles/scss/*, andpackage.jsonrespectively. - Never edit
mediamtx.yml.example. It is a stale leftover, not the template.mediamtx.yml.templateis the live one. - Never run
./utils/deploy/prod.shor./utils/deploy/stage.sh, and never run anydeploy-utils.shsubcommand, without explicit per-run permission. All of them reach the production NAS over SSH andprod.shpurges the Cloudflare cache. - Never add a secret to
config.js,auth.js,app.js, oronvif-controls.js. These scripts andobs-stream.jsare served verbatim to the browser; never put publishing credentials in them. - Never treat the
config.jsallowlists as a security boundary.ALLOWED_EMAILSandADVANCED_FEATURESare client-side only; anyone can read and bypass them. - Adding a dependency, changing the Google OAuth client, changing a published port, or touching
deploy.configneeds human approval first.
Credentials
.env is the only source of local secrets. .env.example is the canonical key list—read it rather than duplicating keys here.
| Variable | Source | Notes |
|---|---|---|
CLOUDFLARE_API_TOKEN |
.env |
Needs Zone.Cache Purge scope; used only by deploy.sh |
CLOUDFLARE_ZONE_ID |
.env |
Zone for cats.feralcreative.co |
CAMERA_USER, CAMERA_PASS |
.env |
Tapo camera account (Tapo app → Settings → Advanced Settings → Camera Account), not the Tapo login |
CAMERA_*_IP |
.env |
Static LAN IPs; also hardcoded in mediamtx.yml.template and backend/server.js |
TEST_MODE |
.env |
true makes the backend mock all PTZ calls |
GOOGLE_CLIENT_ID |
config.js |
Public by design, not a secret |
.env, *.env, mediamtx.yml, styles/css/*, and CONSOLE.txt are gitignored. Never read a file matching those into a commit, a log, or a chat response.
Architecture
Every channel, the dashboard included, requires Google sign-in. The dashboard uses the same auth.js gate as the viewers but loads no streams. channels.js maps channel links to cats.localhost and montclair.localhost during local development; production links use their canonical domains. Both viewers link back to the hub. /admin on the hub and cats hosts is the existing four-feed panel with server-verified Google tokens; Montclair exposes no feed-control API. The 3d channel has no local host, so it always links to production. The hub's on-air lights poll GET /api/channels every 15 seconds; see docs/api.md.
Request path for a camera tile:
- The cats viewer loads
cats.html, which pullsconfig.js,auth.js,obs-stream.js,app.js, andonvif-controls.jsas plain scripts—no bundler, no modules, all globals. auth.jsgates everything. Cameras start only fromGoogleAuth.onAuthStateChanged, which calls the globalinitializeCameras. On localhost withCONFIG.DEV_MODEtrue it fabricates adev@feralcreative.couser and skips Google entirely.app.jstryHLSFallbackrequests/cam_<name>_low/video1_stream.m3u8for each grid tile;loadModalStreamrequests/cam_<name>/video1_stream.m3u8for the expanded modal.- nginx (
nginx.conf) proxies those paths tomediamtx:8888, and/api/onvif/tobackend:3001. - mediamtx pulls RTSP from each camera per its
paths:block inmediamtx.yml—stream1is high quality,stream2is low. - Feed on/off:
/adminPUTs to/api/feeds/<key>;backend/feeds.jsverifies the Google ID token server-side and deletes or restores that feed's MediaMTX paths through the MediaMTX API. The viewer pollsGET /api/feedsand shows the off-air slate for any feed that is off. - PTZ:
onvif-controls.jsPOSTs to/api/onvif/<camera>/ptz/moveand/ptz/stop;backend/server.jstranslates those to ONVIFcontinuousMoveandstopon camera port 2020.
Camera keys are left, right, top, and other in the frontend and stream paths. Only left, right, and top belong to the ONVIF CAMERAS map. Physical assignments are LEFT .201, RIGHT .203, and TOP .202; keep stream sources and ONVIF targets aligned. other is the optional OBS feed shown as SKYLINE; the old bookshelf camera at .204 is retired. OBS uses cam_other/index.m3u8 for both views, has no ONVIF controls, and uses obs-stream.js to poll /api/streams/other/status every four seconds. Adding or renaming a camera means checking all frontend, nginx, MediaMTX, and backend wiring.
Dependencies flow one way: frontend → nginx → (mediamtx | backend). Nothing reads back. The backend holds no state beyond its in-memory devices map, and there is no database.
Deeper detail: docs/architecture.md.
Conventions
- Plain ES2017+ browser JS, double quotes, semicolons, two-space indent, no build step. Each concern is one class or one flat set of functions on
window. - Log with a bracketed tag:
[AUTH],[ONVIF], or[`${camera}`]inapp.js. Match the surrounding tag rather than inventing one. - Frontend errors are caught, logged to console, and surfaced by toggling the matching
#error-<camera>,#loading-<camera>, and#status-<camera>elements. Tapo status text is one ofConnecting...,Connected, orError, with the class set tostatus connecting|connected|error. The optional OBS feed additionally usesOff air/status offline; its slate is expected when OBS is stopped and automatically clears when playback starts. - Backend errors return
{ "error": "<message>" }with 404 for an unknown or unconnected camera and 500 for an ONVIF failure. - SCSS lives in
styles/scss/on the 7-1 pattern. Every partial declares its own@use "../abstracts/variables" as *. Only use color variables thatstyles/scss/abstracts/_variables.scssactually defines. - Label every media query with
//@ Labelon the line above, asmain.scssalready does. - Most of
styles/scss/(thepages/,vendors/, and unusedcomponents/partials) is unreferenced boilerplate fromtemplate.zip. Do not extend it; onlymain.scssand the partials it imports reach the output. The dashboard uses the explicitly importedcomponents/_tv-dashboard.scss; do not use the oldpages/_dashboard.scss. - Bump the
?v=query string on a changed script or stylesheet in every HTML page that loads it—nginx caches CSS and JS for an hour.
Gotchas
-
Local OBS publishing uses
127.0.0.1:1935. This port is bound only to the Mac loopback interface. Set OBS Server tortmp://127.0.0.1:1935and its masked Stream Key tocam_other?user=<encoded username>&pass=<encoded password>using the camera account. Leave OBS's separate Use authentication checkbox disabled; MediaMTX authenticates RTMP through those query parameters. Seedocs/OBS_SETUP.md. Production is configured atrtmp://192.168.1.3:1936, mapped to MediaMTX port 1935 on the NAS LAN; OBS currently publishes there. Port 1935 on the NAS is occupied by another service. Future remote changes require explicit approval. -
54999 is the host port, 80 is the container port. nginx listens on 80;
docker-compose.ymlanddocker-compose.prod.ymlboth publish it as54999. Do not "fix" one side to match the other. Until 2026-09-11 the local compose file published54999:54999and theDockerfileprobed 54999, so the host port served nothing andcats-webnever went healthy; both are corrected. -
docker compose updoes not compile SCSS and does not regeneratemediamtx.yml. The build copiesstyles/from the working tree, andstyles/css/is gitignored, so a fresh clone builds an unstyled site.npm starthandles the SCSS half;mediamtx.ymlstill needsutils/deploy/hooks/pre-build.sh, which onlyprod.shruns for you. -
MediaMTX auth is
authInternalUsersinmediamtx.yml.template. Anyone may read; the camera account (CAMERA_USER/CAMERA_PASS) may publishcam_otherand use the API on 9997, which the backend needs for feed on/off. Changing the template means regeneratingmediamtx.yml(bash utils/deploy/hooks/pre-build.sh) and restartingcats-mediamtx. MediaMTX still logsskipping track 2 (G711)per Tapo path because HLS cannot carry the cameras' G.711 audio; the site plays muted, so nothing is lost. -
The frontend is HLS-only.
cats.htmlstill loads the WHEP client from a CDN andnginx.confstill proxies/cam_*/whep, but no code path calls WHEP. ThewhepClientsmap inapp.jsholdsHlsinstances. README and older docs describing "WebRTC primary with HLS fallback" are stale. -
The
visibilitychangehandler reconnects every camera every time the tab is focused. It testspc.connectionStateon what is actually anHlsinstance, so the value is alwaysundefined. Do not read it as evidence that WebRTC is live. -
config.jsshipsDEV_MODE: true. It only takes effect onlocalhost/127.0.0.1, so production still requires Google sign-in, but any local session silently runs as an advanced user with PTZ access. -
Feed admin is the only server-verified endpoint.
PUT /api/feeds/<key>checks a Google ID token againstADMIN_EMAILSinbackend/feeds.js;/adminalways requires a real Google sign-in, even on localhost withDEV_MODE. Feed state lives indata/backend-state/feeds.json(gitignored; it holds path configs with camera credentials). See docs/api.md. -
The ONVIF API is unauthenticated.
requireAdvancedFeaturesinbackend/server.jsis a pass-through with a TODO. Anything that can reach/api/onvif/can move the cameras. -
ONVIF controls use the same-origin
/apiproxy in every environment. Localhost requests reach the local backend through nginx; no Tailscale address or separately published backend port is required. -
Cameras are LAN-only (
192.168.1.201–203). Off the home network, streams cannot load at all. SetTEST_MODE=truein.envto mock PTZ; see docs/REMOTE_TESTING.md. There is no equivalent mock for video. -
ONVIF is on port 2020, not 80.
backend/server.jshardcodes it; Tapo firmware does not use the ONVIF default. -
mediamtx.ymlis both gitignored and tracked, so git keeps versioning it and every regeneration shows up as a diff full of credentials. Do notgit add -Ablindly in this repo—checkgit statusfirst. -
utils/deploy/hooks/pre-build.shandstart-docker.shload.envviaexport $(cat .env | xargs), which breaks on any value containing whitespace or shell metacharacters. If a credential stops working after a rotation, suspect the quoting before the credential. -
/site.webmanifestand/images/web-app-manifest-*.pngare referenced bycats.htmlbut do not exist, so every page load logs three 404s. Ignore them, or add the files; do not chase them as a regression. -
npx sassemits two@importdeprecation warnings from the end ofstyles/scss/main.scss. Converting them to@userequires moving them above all rules in the file, which reorders the emitted cascade—not a drop-in fix. The build otherwise succeeds. -
montclair.tv is a second nginx
serverblock, not a second site. It servesskyline.htmlfor every page path, 404s/index.html,/cats.html, and/admin.html, and proxies only/cam_other/and/api/streams/other/status—no ONVIF, no Tapo streams. The shared hub/cats block isdefault_server; its$site_indexmap servescats.htmloncats.feralcreative.tvandcats.localhost, andindex.htmlon the hub or any unmatched host. Locally, test it athttp://montclair.localhost:54999/;auth.jstreats any*.localhosthost as localhost forDEV_MODE.www.montclair.tv301s to the apex (a Cloudflare redirect rule on the montclair.tv zone, with the nginxserverblock as a fallback), so onlyhttps://montclair.tvneeds to be in the Google OAuth client's authorized JavaScript origins;www.feralcreative.tvlikewise 301s toferalcreative.tvin nginx (project IDd-feralcreative-co). DNS for both hostnames is a proxied CNAME to the shared Cloudflare Tunnel, whose ingress routes them tolocalhost:54999alongsidecats.feralcreative.co; there is no DSM reverse-proxy rule. Sign-in is per-origin, so viewers sign in separately on each domain. A new static file must be added to theDockerfileCOPYlist and the local compose volume list. -
docs/SYNOLOGY_DEPLOYMENT.mdis template boilerplate, referencingnpm run docker:buildand atemplate-feralcreativeimage that do not exist here. Use docs/DEPLOYMENT.md.
Commit and PR conventions
-
Conventional Commits, imperative mood:
type(scope): subject. Types in use:feat,fix,chore,refactor,docs,style. -
The agent commits on the user's behalf in logical blocks (one feature, fix, or refactor step per commit). Stage paths explicitly rather than
git add -A, becausemediamtx.ymlis tracked (see Gotchas). Pushing and deploying still need explicit permission. -
Never add AI attribution to a commit message or PR body—no
Co-Authored-By, no generated-with footer. -
Never commit
.env,mediamtx.yml,styles/css/*,CONSOLE.txt, or anything under_TASKS/or_archive/. -
mainis the only branch. Branch before committing if asked to work on a branch.
Deep-dive index
-
docs/OBS_SETUP.md—optional USB camera, OBS publishing, offline behavior, local and production RTMP publishing.
-
docs/architecture.md—container topology, ports, stream paths, camera key wiring. Read before any cross-cutting or camera-count change.
-
docs/api.md—ONVIF backend endpoints, feed on/off API, and mediamtx stream URLs. Read before touching
backend/server.js,onvif-controls.js, ornginx.conf. -
docs/decisions.md—why HLS over WebRTC, why the NAS, why client-side auth. Read before "fixing" something that looks wrong.
-
docs/debugging.md—log locations, known failure modes, health checks. Read first when a stream or PTZ call fails.
-
docs/DEPLOYMENT.md—release process to the NAS, environments, rollback.
-
docs/REMOTE_TESTING.md—working away from the camera LAN.
-
docs/ONVIF_IMPLEMENTATION.md—PTZ implementation detail and advanced-user gating.
-
utils/deploy/QUICK_REFERENCE.md—deploy script command reference.
