RTSP 카메라를 무인으로 녹화하고, 브라우저에서 시각 기반으로 되돌려 보는 셀프호스트 스택입니다.
녹화는 재인코딩 없이(-c:v copy) 하고, 조회는 TS 패킷의 PCR을 직접 읽어 HLS를 동적으로 만듭니다.
- 녹화 — go2rtc(RTSP 허브) + ffmpeg. 10분 조각, 벽시계 정렬로 저장합니다.
- 조회 — Rust 서버가
#EXT-X-BYTERANGEHLS를 즉석에서 생성합니다. 600초 조각을 6초 프래그먼트로 쪼개 보여줍니다. - 뷰어 — 단일 HTML. 타임라인(녹화 공백 표시)·시각 점프·배속, 두 카메라 동시 보기, 디지털 확대/이동, 키보드·더블탭 탐색을 제공합니다. 자세한 조작은 아래 뷰어 조작.
- 이관 — 완결된 날짜를 아카이브 디렉터리로 옮기고 오래된 것은 지웁니다. 파일시스템이 유일한 상태입니다.
- 인증 — 리버스 프록시(Caddy) + argon2 로그인. 모든 요청이 인증을 거칩니다.
카메라 두 대 기준 하루 약 40–60GB이며, 전부 컨테이너로 docker compose 하나로 뜹니다.
이 프로젝트의 모든 실측과 튜닝은 TP-Link Tapo C520WS를 기준으로 했습니다. 다른 기종이 없어 일반화하지 못했습니다. 아래 값들은 C520WS의 특성에서 나온 것이므로, 다른 카메라를 붙이실 때는 직접 재보고 조정하셔야 합니다.
| 항목 | C520WS 실측 | 어디에 영향 |
|---|---|---|
| RTSP 경로 | 메인 /stream1(2560×1440·25fps), 서브 /stream2(640×360) |
go2rtc.yaml 의 stream URL |
| 코덱 | 영상 H.264, 오디오 PCM A-law | recorder가 오디오만 AAC로 재인코딩(-c:a aac) |
| GOP(키프레임 간격) | 2초 (50프레임) | 프래그먼트 경계·공백 판정 (CCTV_GOP_SECONDS) |
| 레이트컨트롤 | 메인 CBR ~2.7Mbps (야간에도 안 떨어짐) | 하루 용량 산정 |
RTSP 경로는 기종마다 다릅니다. /stream1·/stream2 는 Tapo 규약입니다. 다른 카메라는
제조사 문서나 ffprobe rtsp://… 로 실제 경로를 확인해 go2rtc.yaml 에 넣으시면 됩니다.
GOP가 2초가 아니면 CCTV_GOP_SECONDS 를 그 값으로 바꾸셔야 합니다(안 그러면 공백을 잘못 판정합니다).
브라우저 ─► caddy :8081 ─┬─ /login ──► auth (argon2)
└─ 그 외 전부 ─► forward_auth → auth /verify
통과하면 ─► archive-server
├─ record/{cam}/*.ts (오늘)
└─ archive/{cam}/{날짜}/ (이관됨)
go2rtc ◄─ RTSP ─ 카메라 recorder(ffmpeg) ─► record/{cam}/*.ts
archive-indexer 5분마다 키프레임 인덱스(.idx)
archive-flusher 매시간 이관 + 보존기간 순환삭제
git clone <this-repo> cctv-archive && cd cctv-archive
# 1. 템플릿 복사 후 값 채우기
cp docker-compose.yml.example docker-compose.yml # ★ 표시된 곳(UID, TZ, 보존일 등)
cp .env.example .env # 카메라 계정·IP. chmod 600 .env
cp go2rtc/go2rtc.yaml.example go2rtc/go2rtc.yaml # 카메라 RTSP 경로 (기종별로 다름 — "검증한 카메라" 절)
# 2. 뷰어 비밀번호 (argon2 해시를 secrets/auth_hash 에 만든다. 화면에 안 찍힘)
docker compose build archive-server # 먼저 이미지 빌드(바이너리 필요)
./scripts/set-password.sh
# 3. 기동
docker compose up -d
docker compose logs -f archive-server # 기동 시 설정·카메라 목록이 찍힌다설치가 끝나면 http://<호스트>:8081/ 로 접속해 로그인 후 재생하시면 됩니다.
record/cam3/ 는 자동으로 인식됩니다. 두 곳만 손대시면 됩니다.
go2rtc.yaml에 스트림cam3를 추가합니다.docker-compose.yml에recorder-cam3서비스를 추가합니다(4줄, 기존 것을 복사한 뒤CAM: cam3).
브라우저 하나로 되돌려 봅니다. 마우스·터치·키보드를 모두 지원합니다.
- 타임라인 — 클릭/드래그로 그 시각으로 이동, 휠·핀치로 확대. 드래그 중 커서 위에
시:분:초가 뜹니다. - 탐색 ±5초 — 플레이어나 타임라인에 포커스를 두고 방향키
←/→. 모바일은 화면 좌/우 끝(각 20%)을 더블탭. - 재생/정지 — 화면 탭(또는 스페이스). 타임라인 프리셋 줄 오른쪽의
▶ 재생 / ⏸ 일시정지버튼으로도 됩니다(터치·마우스가 없는 TV 리모컨에서 방향키로 포커스 이동 후 OK). 가운데를 더블탭/더블클릭하면 전체화면. - 배속 —
1·2·4·16x버튼, 또는 화면을 0.5초 이상 꾹 누르는 동안만 임시 배속(배속 값 선택 가능). - 확대/이동 — 마우스 휠 또는 핀치로 확대(최대 4x), 확대 상태에서 끌어 화면 이동. 확대 중엔 재생/정지는 탭, 탐색은 타임라인으로.
- 영상 높이 — 영상과 타임라인 사이 구분선을 세로로 끌어 조절(넓은 화면). 못 커지는 만큼은 위아래 여백으로 남고 가운데 정렬됩니다.
- 사이드바 접기 — 넓은 화면에서 상단
⇥버튼으로 사이드바를 접어 영상을 넓게 봅니다. 접힘 상태는 기억됩니다. - 멀티뷰 — 오른쪽에 둘째 카메라를 골라 나란히. 한쪽을 더블클릭하면 그 화면만 크게(집중),
⊞ 2분할로 복귀. 두 화면의 벽시각은 자동 동기화됩니다(넓은 화면 전용). 멀티뷰를 켜고 끌 때 보던 시각과 타임라인 확대는 그대로 유지됩니다(같은 카메라·날짜면 다시 로딩하지 않음). - 테마·로그아웃 — 상단바에서.
일부 조작(구분선·사이드바·달력·멀티뷰)은 넓은 화면(≥1024px) 전용입니다. 좁은 화면은 세로로 쌓여 스크럽·배속·꾹눌러배속·더블탭 탐색에 집중합니다.
.example 파일들에 전부 주석과 함께 있습니다. 핵심만 옮기면 다음과 같습니다.
| 변수 | 뜻 |
|---|---|
TZ |
필수. 파일명(-strftime)과 PDT가 이 시간대를 따릅니다. 틀리면 모든 시각이 밀립니다. 녹화·조회가 같아야 합니다. |
CCTV_ARCHIVE_DIR |
이관된 과거 날짜가 쌓이는 곳입니다. 기본은 ./archive 입니다. 네트워크 스토리지를 쓰시려면 그 마운트를 여기에 두시면 됩니다(코드는 그냥 디렉터리로 봅니다). |
CCTV_RETENTION_DAYS |
아카이브 보존일입니다. 0이면 순환삭제를 하지 않습니다. |
CCTV_SESSION_HOURS |
로그인 유지 시간입니다(기본 720=30일). 절대 만료이며, restart auth 가 전체 로그아웃입니다. |
CCTV_COOKIE_SECURE |
앞단이 HTTPS면 1, 평문 HTTP로만 접근하면 0 입니다. |
GOP·스트림 경로처럼 카메라 특성에 의존하는 값은 위 "검증한 카메라" 절을 참조하세요. Tapo C520WS 기준입니다.
이 스택은 평문 HTTP로 :8081 을 냅니다. TLS는 각자 앞단에서 처리하시면 되고, 방식은 무엇이든 좋습니다.
- 리버스 프록시(Caddy/nginx/Traefik)로 인증서를 종료한 뒤
:8081로 프록시하기 - Cloudflare Tunnel / Tailscale 등으로 감싸기
- 신뢰된 LAN 안에서만 쓰기
앞단이 HTTPS가 되면 CCTV_COOKIE_SECURE=1 로 바꾸셔야 합니다(안 그러면 쿠키에 Secure가 안 붙습니다).
반대로 평문 HTTP로 접근하면서 1로 두면 브라우저가 쿠키를 안 실어 로그인이 되지 않으니 주의하세요.
전부 실측에서 나온 결정입니다.
- 저장 단위 ≠ 재생 단위. 디스크는 600초 조각이지만 플레이어에는
#EXT-X-BYTERANGE로 6초 프래그먼트를 줍니다. 600초를 통째로 주면 브라우저가 조각 하나(수백 MB)를 다 받아야 첫 프레임이 나옵니다. - 길이는 PCR로 잽니다.
EXTINF = 다음 키프레임 PCR − 현재 키프레임 PCR입니다. 조각 안의last−first를 쓰면 마지막 PCR 뒤 패킷을 놓쳐 하루 수 초가 누적됩니다. ffprobe는 조각당 수백 ms라 느리고 스트림별로 요동칩니다. - 프래그먼트는 키프레임이 아니라 그 앞의 PAT에서 자릅니다. 안 그러면 트랜스먹서가 앞부분을 버립니다.
- 불연속은 PCR 리셋으로 판정합니다. ffmpeg가 재시작하면 PCR이 0으로 돌아가므로
#EXT-X-DISCONTINUITY를 넣습니다. - 이관에 DB를 쓰지 않습니다. "아카이브에 같은 바이트로 있는가"는 보면 압니다. 복사 → 다시 읽어 검증 → 그 다음 삭제 순서입니다.
- SIGTERM을 보존합니다. recorder를
sh -c로 띄울 때exec가 없으면 PID 1이 sh가 되어 SIGTERM이 ffmpeg에 닿지 않고 조각이 찢어집니다.
# 호스트에서 직접 (빠른 반복; HTML은 include_str! 로 바이너리에 박힌다)
cargo build --release --manifest-path server/Cargo.toml
CCTV_BIND=0.0.0.0:8082 ./server/target/release/archive-server
cargo test --manifest-path server/Cargo.toml # 실제 조각에서 뜯은 픽스처로 검증Dockerfile은 의존성 캐시 층에서 더미 fn main(){} 을 만듭니다. COPY 가 mtime을 보존하는 탓에
소스가 다시 빌드되지 않을 수 있어, 빌드 끝에 --version 출력을 확인해 진짜 바이너리인지 검사합니다.
MIT (LICENSE). 단, 함께 배포되는 hls.js(server/assets/hls.min.js)는 Apache-2.0 입니다.