Compare commits

..

45 Commits

Author SHA1 Message Date
git_admin aa290a51b7 v160 2026-06-03 16:09:49 +05:00
git_admin 847c87fd7e v158 2026-06-02 14:02:09 +05:00
git_admin f263d84a8d player: extract branded .pjs player into shared static/player.{js,css}
One source of truth for the branded player on desktop (archive) and
mobile. The ArchivePlayer class moves out of app.js into player.js
(name kept; app.js still does `new ArchivePlayer`); the .pjs styles
move out of style.css into player.css. Both archive.html and mobile.html
load the shared module before their own script.

The shared player gains two additive, backward-compatible options:
- live: timeline hidden, only pause/play + fullscreen (mobile Live).
- autohide:false: control bar always visible (touch); desktop keeps
  autohide (default true), so desktop archive behaves exactly as before.
Fullscreen is now self-contained (no global toggleFullscreen dep) so it
works on mobile too, with an iOS <video> fallback.

mobile.js drops its duplicated Player class and uses ArchivePlayer.

Verified on .79: /archive 200 loads player.{js,css}; app.js has no
class def; mobile (Host :8443) loads the shared module.

VERSION 0.0.154 -> 0.0.155

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:28:04 +05:00
git_admin 6f9142a795 mobile: both modes use the branded .pjs player
Replace the split mobile UI (live = round pause button, archive = native
<video controls>) with one branded player for both modes, ported from the
desktop ArchivePlayer (.pjs) and kept self-contained in the mobile bundle
so the live desktop is untouched.

- Live: branded player with pause/play + fullscreen only (no seekbar).
- Archive: branded player with full seek (transcode virtual timeline,
  seek = restart transcode with -ss) + playback speed (0.5/1/2/5x).
- H.264 server transcode path unchanged; touch-friendly always-on bar.

Verified end-to-end on .79: live.mp4 200 video/mp4, play.mp4 200,
duration_s present for the seek timeline.

VERSION 0.0.153 -> 0.0.154

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:08:55 +05:00
git_admin cfb125784d mobile: fix both modes showing at once (hidden view not hidden)
[hidden] sets display:none, but .m-view{display:flex} has equal
specificity and comes later, so it overrode the hidden attribute and
both #view-live and #view-arch rendered together. Add
.m-view[hidden]{display:none} so the inactive mode is actually hidden.

VERSION 0.0.152 -> 0.0.153

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:59:07 +05:00
git_admin c408fcbe92 mobile: hamburger drawer for mode select; live=pause/play, archive=seek
Move mode selection (Live / Архив) into a top hamburger side-drawer
instead of the bottom bar. Header now shows the ☰ button + current mode
title. Live view has a single pause/play button (no seek bar); Archive
keeps native controls for seeking (перемотка).

VERSION 0.0.151 -> 0.0.152

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:55:20 +05:00
git_admin 126b588dc7 mobile: one mode-toggle button instead of two bottom tabs
Replace the two-tab bottom bar (Live / Архив) with a single centered
toggle button. It switches the mode either/or: shows the current mode
with a swap glyph (⇆), tap flips Live ⇄ Архив.

VERSION 0.0.150 -> 0.0.151

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:49:02 +05:00
git_admin c91f82bf28 mobile: add a phone dashboard on a second port (8443) — Live + Archive
A new mobile dashboard is served on a second host port (8443) mapped to the
same backend, so the recorder/supervisor is not duplicated. main.py detects
the mobile port via the Host header and serves mobile.html instead of the
desktop index. The mobile SPA has two modes: Live (one camera with a selector)
and Archive (camera + date + segment list). Video is delivered as server-side
H.264 (live.mp4 / play.mp4 transcode) so a plain <video> plays on any phone.
Bottom tab bar, dark theme, safe-area aware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:36:50 +05:00
git_admin 157893b516 fix: pin archive layout to row; fit sidebar version into the 72px rail
Layout review found archive-main relied on the default flex-direction while
the base `main` rule forces column, so the sidebar/content split was fragile —
now set explicitly to row. The sidebar app-version pill is shrunk and centred
so it fits the 72px icon rail instead of being clipped. HTML tag balance, CSS
brace balance and all table column counts (cameras 9, storages 9, users 4)
checked out clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:19:55 +05:00
git_admin 4a8eddf1d8 settings: group transcode params into 3 boxes (codec / bitrate / preset)
The flat transcode parameter row is reorganised into three bordered group
cards: «Кодек» (codec, H.264 profile, preset, keyframe interval), «Битрейт»
(rate control, bitrate, QP) and «Готовый профиль» (the SOFT/MEDIUM/HARD
preset). All data-f controls and the .qpreset select keep their attributes,
so the existing load/save and sync logic is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:59:56 +05:00
git_admin 74236fdbb0 ui: drop version from footer; add live autostart toggle (default: wait for Play)
The footer no longer shows the app version (sidebar version stays). A new
per-browser preference liveAutostart controls live view startup: a toggle in
Settings → Плеер. Default off — live opens paused and waits for Play; on —
cameras start immediately on entering live view. Live.init seeds this.playing
from the pref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:53:17 +05:00
git_admin 0216d2c1f1 live: fix double-click fullscreen — return to grid in mode 1, solo-only in mode 2
Track whether fullscreen was entered by the double-click itself (_fsBySolo).
Mode 1 (from the grid): a double-click fullscreens the camera and a second
double-click now exits fullscreen back to live view. Mode 2 (already in
fullscreen via F): double-click toggles solo/grid without leaving fullscreen;
Esc or F exits. Exiting fullscreen by any means resets the flag and clears solo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:48:02 +05:00
git_admin c2b698e1f4 ui: hide control footer in fullscreen; toggle fullscreen with the F key
The grid/archive control bar is hidden while its frame is fullscreen, so the
video fills the screen cleanly. A global F-key handler toggles fullscreen of
the active frame (live-main or arch-content) — entered and exited with F, and
exited with Esc as well. Uses e.code (KeyF) so it works on non-Latin layouts
and ignores typing in inputs and Ctrl/Cmd/Alt combos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:35:37 +05:00
git_admin ffddaaa50f archive: add 32px content footer; date section as a bottom card; hide cam scrollbar
The archive content frame gets a 32px footer (reusing the live grid-bar look)
with a fullscreen button that expands the player + timeline. The sidebar date
section is now a bottom-anchored card (panel-2, bordered, rounded) holding the
date input above the inline calendar; the camera list fills the space above it
and its scrollbar is hidden (still scrollable). Calendar colours tweaked to sit
on the card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:56 +05:00
git_admin dcc4cc046f archive: replace toolbar with a 220px split sidebar (camera list + calendar)
The player-frame toolbar (camera <select> + date input) is removed. Archive
main is now a row: a 220px sidebar split in half by height — the top half is
a scrollable clickable camera list, the bottom half holds the date input plus
a new inline month calendar (prev/next month, today + selected highlight,
Monday-first). Picking a camera or a day loads that day's recordings. The
player and DVR timeline move into an .arch-content column on the right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:14:59 +05:00
git_admin ab817e0d31 ui: restore a 50px top header with centered brand; rename to CoRE.Vizion+
Re-add the top header as a 50px bar injected by ensureHeader() on every
layout page (skipped on login), with the brand centered. The layout height
now subtracts both header and footer. Rebrand the project CoRE.Vision →
CoRE.Vizion+ everywhere: header + footer brand, all page <title>s, the login
card, and the FastAPI OpenAPI title (applies on next backend restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:03:04 +05:00
git_admin 02efe2ab4b ui: drop the Live view submenu; turn nav into a 64x64 icon rail
All grid/play/fullscreen submenu items are removed from every sidebar
(index, archive, settings, layout) — those controls now live in the live
grid footer. The sidebar becomes a narrow 72px rail and Live/Archive/Settings
render as 64x64 icon tiles (icon + small caption, active = inset red ring).
The layout.html gear (only link to /layout) is dropped with its submenu, so
the layout editor is now reachable by URL only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:57:08 +05:00
git_admin 5b8f11dbb6 live: group paging next to play/stop; add grid-split menu before fullscreen
The grid control footer is regrouped: ⏮ play/stop ⏭ now sit together in the
centre (left section empty). On the right, before fullscreen, a new ▦ split
button opens an upward popup menu with the layouts 4/9/16/25/36/49/64 that
calls setDim; the menu respects maxDim (16+ mode), highlights the active
layout, and closes on outside click.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:49:56 +05:00
git_admin c6cdcf55b2 live: add a control footer under the grid (paging + play/stop + fullscreen)
The live-view <main> now hosts its own 32px footer bar beneath the grid:
prev page on the left, play/stop in the centre, next page + fullscreen on the
right — all icon buttons. Pagination is new: cameras are split into pages of
dim² cells, prev/next walk the pages (page 0 still honours a saved manual
layout), and the arrows disable at the bounds. Fullscreen now targets <main>
so the control bar stays visible in fullscreen (the global stats footer is
outside <main> and hides as before). The sidebar play/fullscreen items now
target <main> too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:36:52 +05:00
git_admin b29b808c6d ui: restore ⟳/✕ actions in edit mode only; shrink toggles; uniform 22px rows
The camera table actions column is back as the 9th column but stays empty in
the normal view — restart (⟳) and delete (✕) buttons render only on existing
cameras while bulk-editing. Restart in bulk mode no longer reloads the table
(keeps pending edits); delete reloads as before.

Row height is now truly uniform: cams-fixed cells use line-height:1 with
vertically-centred controls, the Вкл/Запись toggles shrink to 30×16, and
selects/inputs/action buttons cap at 20px so filled rows match empty slots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:25:33 +05:00
git_admin a519b78eb2 ui: remove per-row Лог/⟳/✕ buttons from camera table, fix row height to 22px
The camera table actions column (log/restart/delete buttons) is removed:
the table drops to 8 columns across thead, filled rows, empty slots and the
bulk-edit rows. The now-orphaned log subrow and openLogs tracking are gone.
Camera rows get a hard fixed height of 22px via a new table.cams.cams-fixed
rule (zero vertical padding, 22px line-height) so rows never inflate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:15:39 +05:00
git_admin e2e03054ac ui: remove top header, move CPU/RAM/disk + clock into the footer
Header bar dropped from all pages; brand, system stats (#sys-stats) and clock
(#clock) now live in the 22px footer next to the version. .layout fills the
full height minus the footer. ensureFooter runs before startClock/refreshStats
so the moved elements exist when populated.

VERSION 0.0.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:56:59 +05:00
git_admin b07bf2d874 ui: add a 22px bottom footer (brand + version)
Footer is a third layout block; .layout height now subtracts --footer-h (22px)
so it never overlaps the full-height video grid. Injected via JS only on pages
with the main layout (skips login); shows CoRE.Vision brand + version.

VERSION 0.0.135.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:50:17 +05:00
git_admin ec41267444 ui: edit all 64 slots inline in bulk mode; drop per-row "+ Добавить"
"Изменить" now renders all 64 slots as editable rows — filled ones for editing,
empty ones with blank fields (user prefilled "admin", channel 1) for adding.
Saving PUTs changed cameras and POSTs filled empty slots (auto id camN, lowest
free). Removed the per-row "+ Добавить" buttons; empty slots in normal view just
show "пусто". Bulk edit can now be entered with zero cameras.

VERSION 0.0.134.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:47:24 +05:00
git_admin 1e6974acb8 ui: remove "+ Добавить камеру" and "Очистить все" header buttons
Adding a camera stays available via the per-slot "+ Добавить" row. JS handlers
are already guarded with if (el), so no logic changes are needed.

VERSION 0.0.133.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:32:06 +05:00
git_admin 20dfed0bae ui: pick Hikvision channel (1-32) instead of typing full stream paths
Cameras (esp. an NVR at one IP with many channels) are configured by a channel
number now: channel N → main /Streaming/Channels/{N*100+1}, sub {N*100+2}
(101/102 = ch1, 1101/1102 = ch11, 3201/3202 = ch32). The add/edit form and the
bulk-edit row use a 1-32 dropdown; the camera list shows "канал N". Frontend
derives main_path/sub_path from the channel, so the backend is unchanged.

VERSION 0.0.132.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:29:40 +05:00
git_admin abc726b5a7 ui: bulk-edit edits cells in place instead of a stacked per-row form
"Изменить" previously replaced each camera row with a vertical .cam-edit-grid
form inside one colspan cell — effectively a different table. Now bulk edit
keeps the same columns and turns the cells into compact inline inputs:
Камера→name, IP→ip, Потоки→main/sub, Вкл/Запись toggles, Хранилище select,
and the Статус column becomes Логин/Пароль (header swaps via data-bulk-label).
Live per-row onchange is suppressed while bulk is active (changes collected
until Сохранить). Inputs are compact (22px) so row height stays dense.

VERSION 0.0.131.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:01:50 +05:00
git_admin 421f873160 ui: keep camera table dense — compact inline storage select
The per-row "Хранилище" <select> inherited the base select padding (6px 8px,
~32px tall) and inflated every camera row. Scope a compact rule to .stor-sel
(22px height, matching the toggles) so row height stays dense. Edit/bulk-form
selects are unaffected.

VERSION 0.0.130.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:53:12 +05:00
git_admin 8e4844111b storage: per-camera target + date subfolders; move active-storage widget
- Camera.storage_id: each camera chooses its storage ("" = active/default).
  supervisor resolves per-camera (storage_by_id → active fallback); cameras
  API (body/payload) + /toggle accept storage_id and repoint the recorder.
- Recordings now go to <root>/<cam>/<YYYY-MM-DD>/<file>.mp4. This ffmpeg build
  lacks -strftime_mkdir, so recorder_task pre-creates today+tomorrow dirs at
  start and the indexer re-ensures them each tick (survives midnight).
- indexer: scan date subdirs + legacy flat files; sort by basename so the
  in-progress segment is detected correctly when both layouts coexist.
- frontend: per-camera "Хранилище" dropdown in the camera table (inline) and
  in the add/edit + bulk forms. Move the active-storage summary widget from
  the Система tab into the Хранилища tab.

VERSION 0.0.129.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:47:44 +05:00
git_admin a64749ccc8 storage: multi-storage management with per-storage quota
Replace the single fixed /records path with a list of storages and an
active selection (where new recordings go). Quota is now per-storage.

- config: StorageItem(id,name,type,path,quota_gb,nas-meta) + Storage keeps
  global retention_days/segment_seconds; active + items. Migration from the
  old single {path,quota_gb} schema → one local item.
- recorder: write to the active storage; restart_all() repoints on switch;
  NAS-not-mounted guard (never write into the container overlay).
- indexer: scan ALL storage roots so recordings survive an active switch.
- retention: enforce quota PER storage via db.total_size_under /
  oldest_recordings_under (path-prefix LIKE).
- api/storages: GET/POST/PUT/DELETE + activate + policy. NAS uses host-mount
  (backend stores SMB params, generates the mount/fstab command, reports
  ismount status). Retire old POST /api/storage.
- frontend: new "Хранилища" settings tab — table with inline per-row quota,
  add form (local folder / NAS SMB), global policy (segment + retention).
- compose: add shared ./storages:/storages root for extra local folders and
  host-mounted NAS shares.

VERSION 0.0.127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:31:15 +05:00
git_admin 24155a8492 cameras: split camera enable (Вкл) from REC (record-to-disk)
Add a separate `record` field so a camera can be registered but fully
off, or on but not recording:
- enabled (Вкл): camera on/off. Disabled = no go2rtc stream (no live),
  no recording; entry is just stored. build_streams/sync_all/reconcile
  skip disabled cameras; live grid shows "камера выключена".
- record (REC): write segments to disk. Recorder runs only when
  enabled AND record.

Unified POST /api/cameras/{id}/toggle {enabled?, record?} (replaces the
/record endpoint); go2rtc is touched only when `enabled` changes, so
flipping REC doesn't blip live. Camera table gets Вкл + Запись toggle
columns; add/edit forms get both switches.

load_config migrates the old schema (no `record` key): old `enabled`
meant "record" with live always on -> enabled=True, record=old enabled.

VERSION -> 0.0.126.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:35:26 +05:00
git_admin 7060f26e86 cameras: surface per-camera REC (record-to-disk) toggle
`enabled` already gated recording only (go2rtc/live work regardless), but
it was a buried "Вкл" checkbox. Make it an explicit REC control:
- New "Запись" column with an inline REC switch in each camera row.
- Relabel the add/edit form toggle "Вкл" -> "REC" + tooltip.
- New POST /api/cameras/{id}/record {enabled} that flips recording through
  the supervisor only (no go2rtc re-sync, so live view doesn't blip).

Off = view-only (live via go2rtc); on = record segments to disk.
VERSION -> 0.0.124.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:21:49 +05:00
git_admin 81a5b58eb9 settings: lay transcode params out in an even grid
The params row was a ragged flex-wrap and "Готовый профиль" was floated
with margin-left:auto, so controls didn't line up and the preset menu
dangled at an odd height. Split the row into a left grid (auto-fit
columns, controls bottom-aligned, full-width) and a separate right-side
"Готовый профиль" zone. Reorder fields logically: codec, H.264 profile,
preset, rate control, bitrate, QP, keyframe interval. Drop the now-dead
SOFT/MEDIUM/HARD button CSS.

VERSION -> 0.0.123.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:44:49 +05:00
git_admin 90e216e30b settings: restore color-coding for quality presets, pin them right
The SOFT/MEDIUM/HARD selector lost its color coding when it became a
dropdown. Re-apply colors (SOFT red, MEDIUM yellow, HARD white) to the
options and to the select's shown value, and right-align the "Готовый
профиль" label (margin-left:auto) so it clearly sits on the right.

VERSION -> 0.0.122.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:36:21 +05:00
git_admin 650a531f21 live: double-click solos a cell to full screen (fullscreen mode)
Double-clicking a grid cell now maximizes that camera ("solo") via a CSS
class on the grid instead of putting the tile into the browser Fullscreen
API. In fullscreen the soloed camera fills the screen; double-clicking
again returns to the grid (staying fullscreen). From windowed mode the
gesture also enters fullscreen so the camera fills the screen. ESC exits
fullscreen natively; on exit the solo state is cleared.

VERSION -> 0.0.121.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:32:36 +05:00
git_admin 44f5aa920f settings: unify transcode param styling and controls
- Style QP <input type=number> like the <select>s (it rendered with the
  default white background -> stood out); add a disabled (greyed) style.
- QP vs bitrate are now mutually DISABLED instead of hidden: QP mode
  greys the bitrate field, VBR/CBR greys the QP field. Hiding never
  worked because .tr-row label{display:flex} overrode [hidden].
- Replace SOFT/MEDIUM/HARD preset buttons with a "Готовый профиль"
  dropdown, placed last (rightmost) in the params row.

VERSION -> 0.0.120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:21:28 +05:00
git_admin c876a46e61 transcode: add CBR/VBR/QP rate control + more x264 presets
Player/transcoding settings gain encoder controls:
- Rate-control mode VBR / CBR / QP (constant quantizer). x264: VBR uses
  -b:v/-maxrate/-bufsize, CBR adds -x264-params nal-hrd=cbr (min=max=b:v),
  QP uses -qp N (no bitrate). VP9 maps QP -> -crf N -b:v 0, CBR -> min=max=b:v.
- Preset list expanded to ultrafast/superfast/veryfast/faster/fast/medium
  (was only ultrafast/superfast); for VP9 the preset maps to cpu-used.
- New QP field in the UI, shown when rate control = QP, otherwise the
  bitrate field is shown.

Both /play.mp4 (archive) and /live.mp4 accept rc + qp query params.
All three modes verified producing valid output on the live restream.
VERSION -> 0.0.119.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:38:04 +05:00
git_admin 5828873910 settings: fold Account widget into Users tab, drop Account tab
Per request, move the account widget (current user line + logout button)
to the top of the "Пользователи" tab and remove the standalone Account
tab. Note: the Users tab is superadmin-only, so logout is now reachable
only by superadmin (it is the only logout in the app) -- confirmed
acceptable by the user, who operates as superadmin.

VERSION -> 0.0.118.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:20:14 +05:00
git_admin fcddc0c470 settings: move Player/transcode and Account into their own tabs
The "Система" tab was a catch-all (Recording, Player & transcoding,
Storage, System info, Account). Split out two dedicated tabs:

- "Плеер": per-browser playback/transcode profiles. Kept at operator+
  (the transcode option spawns server-side ffmpeg, i.e. CPU load).
- "Учётная запись": current user + logout. Ungated, so any signed-in
  user (incl. viewers) can log out -- previously it was buried inside
  the operator-only "Система" tab.

.tabs gets flex-wrap so the now-7 tabs wrap instead of overflowing.
VERSION -> 0.0.117.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:14:59 +05:00
git_admin d1ef54af27 player: fix HEVC color (BT.709 limited range)
The built-in WASM player (frontend/static/vendor/hevcjs/hevc-core.js)
rendered washed-out, green/red-shifted video. Two causes, both fixed:

- VideoFrame path (Chrome): the I420 VideoFrame was created without a
  colorSpace, so the browser defaulted to BT.601 limited -> hue shift,
  and guessed the range -> washout. Set explicit BT.709, fullRange:false.
- WebGL fallback shader: used BT.709 FULL-range coefficients on
  limited-range camera output -> washout/desaturation. Switched to
  BT.709 limited-range conversion ((Y-16)*255/219, chroma centered 128).

VERSION -> 0.0.116.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:03:30 +05:00
git_admin 00cfca1a57 go2rtc: auto-reconcile streams so live/recording self-heal
Live and recording both read from the go2rtc restream. If go2rtc's
runtime stream table gets emptied (camera clear via API, go2rtc restart
with a stale/empty config, or a silently-failed startup sync_all), every
restream returns 404 and both live and recording go dark until a manual
`docker restart nvr-go2rtc`.

Add a background Reconciler that every 30s diffs the desired streams
against go2rtc /api/streams and PUTs only the missing ones (existing
streams are left untouched so active producers/consumers are not
disrupted). VERSION -> 0.0.115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:53:02 +05:00
git_admin 346d3b5b96 deploy 2026-05-30 00:57:29 +05:00
git_admin 3dc2bdd631 v114 2026-05-30 00:48:16 +05:00
git_admin 1da4f2e1b6 fix cert 2026-05-30 00:42:20 +05:00
git_admin 32f1c82e2b add .gitattributes: force *.sh to LF
entrypoint.sh has a /bin/sh shebang; CRLF would break exec in the Linux
container. Keep shell scripts LF-only regardless of Windows autocrlf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:27:47 +05:00
38 changed files with 2527 additions and 820 deletions
+2
View File
@@ -0,0 +1,2 @@
# shell-скрипты всегда с LF — CRLF ломает shebang/exec в Linux-контейнере
*.sh text eol=lf
-1
View File
@@ -1,3 +1,2 @@
DESIGN.md
README.md
cameras.yaml
+1 -1
View File
@@ -1 +1 @@
0.0.113
0.0.160
+60 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import re
from dataclasses import replace
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
@@ -12,7 +13,7 @@ from ..config import Camera, save_config
from ..runtime import runtime
from ..services import go2rtc
from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset,
norm_keyint, norm_codec)
norm_keyint, norm_codec, norm_rc, norm_qp)
router = APIRouter(prefix="/api/cameras", tags=["cameras"])
@@ -32,11 +33,13 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
id=cam_id,
name=(body.get("name") or cam_id).strip(),
enabled=bool(body.get("enabled", True)),
record=bool(body.get("record", True)),
ip=ip,
user=user,
password=password,
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
sub_path=sub,
storage_id=(body.get("storage_id") or "").strip(),
)
@@ -49,11 +52,13 @@ def _camera_payload(cam_id: str) -> dict:
"id": cam.id,
"name": cam.name,
"enabled": cam.enabled,
"record": cam.record,
"ip": cam.ip,
"user": cam.user,
"password": cam.password,
"main_path": cam.main_path,
"sub_path": cam.sub_path,
"storage_id": cam.storage_id,
"has_sub": has_sub,
"status": status.to_dict() if status else None,
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
@@ -86,6 +91,8 @@ async def camera_live_transcoded(
preset: str = Query(None),
keyint: str = Query(None),
codec: str = Query(None),
rc: str = Query(None),
qp: str = Query(None),
stream: str = Query("sub"),
):
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
@@ -103,7 +110,8 @@ async def camera_live_transcoded(
"-fflags", "nobuffer", "-flags", "low_delay",
"-rtsp_transport", "tcp", "-i", src,
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
rc=norm_rc(rc), qp=norm_qp(qp)),
"-an",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1",
@@ -251,7 +259,8 @@ async def create_camera(request: Request) -> dict:
runtime.config.cameras.append(cam)
save_config(runtime.config)
runtime.supervisor.add_camera(cam)
await go2rtc.add_camera_streams(runtime.config, cam)
if cam.enabled:
await go2rtc.add_camera_streams(runtime.config, cam)
return _camera_payload(cam_id)
@@ -266,19 +275,29 @@ async def update_camera(cam_id: str, request: Request) -> dict:
runtime.config.cameras[idx] = cam
save_config(runtime.config)
await runtime.supervisor.replace_camera(cam)
await go2rtc.add_camera_streams(runtime.config, cam)
if cam.enabled:
await go2rtc.add_camera_streams(runtime.config, cam)
else:
await go2rtc.remove_camera_streams(runtime.config, cam_id)
return _camera_payload(cam_id)
@router.delete("/{cam_id}")
async def delete_camera(cam_id: str, request: Request) -> dict:
require_role(request, "admin")
if not runtime.config.camera(cam_id):
cams = runtime.config.cameras
idx = next((i for i, c in enumerate(cams) if c.id == cam_id), None)
if idx is None:
raise HTTPException(404, "camera not found")
runtime.config.cameras = [c for c in runtime.config.cameras if c.id != cam_id]
# удаление из середины сдвигает слоты последующих камер → их папки записи
# (номер слота) меняются. Перезапускаем запись, чтобы ffmpeg писал в новую папку.
shifts = idx < len(cams) - 1
runtime.config.cameras = [c for c in cams if c.id != cam_id]
save_config(runtime.config)
await runtime.supervisor.remove_camera(cam_id)
await go2rtc.remove_camera_streams(runtime.config, cam_id)
if shifts:
await runtime.supervisor.restart_all()
return {"ok": True}
@@ -295,3 +314,38 @@ async def clear_cameras(request: Request) -> dict:
await runtime.supervisor.remove_camera(cam_id)
await go2rtc.remove_camera_streams(runtime.config, cam_id)
return {"ok": True, "removed": len(ids)}
@router.post("/{cam_id}/toggle")
async def toggle_camera(cam_id: str, request: Request) -> dict:
"""Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск).
enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи
(камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем
лишь при смене enabled, поэтому переключение REC не мигает live."""
require_role(request, "admin")
cam = runtime.config.camera(cam_id)
if not cam:
raise HTTPException(404, "camera not found")
body = await request.json()
kw = {}
if "enabled" in body:
kw["enabled"] = bool(body["enabled"])
if "record" in body:
kw["record"] = bool(body["record"])
if "storage_id" in body: # смена хранилища записи камеры
kw["storage_id"] = (body["storage_id"] or "").strip()
if not kw:
raise HTTPException(400, "ожидается enabled, record и/или storage_id")
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
new = replace(cam, **kw)
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
runtime.config.cameras[idx] = new
save_config(runtime.config)
if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры
if new.enabled:
await go2rtc.add_camera_streams(runtime.config, new)
else:
await go2rtc.remove_camera_streams(runtime.config, cam_id)
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
+6 -2
View File
@@ -9,7 +9,8 @@ from fastapi.responses import FileResponse, StreamingResponse
from ..runtime import runtime
from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile,
norm_bitrate, norm_preset, norm_keyint, norm_codec)
norm_bitrate, norm_preset, norm_keyint, norm_codec,
norm_rc, norm_qp)
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
@@ -53,6 +54,8 @@ async def recording_transcoded(
preset: str = Query(None),
keyint: str = Query(None),
codec: str = Query(None),
rc: str = Query(None),
qp: str = Query(None),
start: float = Query(0.0),
):
"""Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC.
@@ -71,7 +74,8 @@ async def recording_transcoded(
*(["-ss", f"{start:.3f}"] if start and start > 0 else []),
"-i", rec.path,
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
rc=norm_rc(rc), qp=norm_qp(qp)),
"-an",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1",
+235
View File
@@ -0,0 +1,235 @@
"""REST: управление хранилищами записи — список, CRUD, выбор активного, политика.
Несколько хранилищ (local-папка или NAS/SMB) + одно активное (куда пишем новые записи).
Квота — на каждом хранилище. NAS монтируется на ХОСТЕ (backend не монтирует сам), здесь
лишь хранятся параметры и генерируется готовая команда mount/fstab."""
from __future__ import annotations
import os
import re
from dataclasses import replace
from fastapi import APIRouter, HTTPException, Request
from ..auth import require_role
from ..config import StorageItem, save_config
from ..runtime import runtime
from ..services.system import is_mounted, storage_disk
router = APIRouter(prefix="/api/storages", tags=["storages"])
ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
STORAGES_ROOT = "/storages" # общий bind-mount корень для доп. хранилищ
HOST_STORAGES_ROOT = "/opt/nvr/storages" # он же на хосте (для команды монтирования NAS)
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
# ── helpers ─────────────────────────────────────────────────────
async def _item_payload(item: StorageItem, active: str) -> dict:
nas = item.type == "nas"
return {
"id": item.id,
"name": item.name,
"type": item.type,
"path": item.path,
"quota_gb": item.quota_gb,
"server": item.server,
"share": item.share,
"username": item.username,
"password": item.password,
"subpath": item.subpath,
"enabled": item.enabled,
"active": item.id == active,
"mounted": is_mounted(item.path) if nas else True,
"used_bytes": await runtime.db.total_size_under(item.path),
"disk": storage_disk(item.path),
}
def _nas_mount_help(item: StorageItem) -> dict:
"""Готовая команда монтирования SMB на хосте + строка fstab (монтаж — на хосте)."""
host_path = f"{HOST_STORAGES_ROOT}/{item.id}"
src = f"//{item.server}/{item.share}"
if item.subpath:
src += "/" + item.subpath.strip("/")
cred = f"username={item.username},password={item.password}"
opts = f"{cred},uid=0,gid=0,vers=3.0,iocharset=utf8"
return {
"host_path": host_path,
"mount_cmd": f"sudo mkdir -p {host_path} && sudo mount -t cifs {src} {host_path} -o {opts}",
"fstab": f"{src} {host_path} cifs {opts},_netdev,nofail 0 0",
"note": "После монтирования на хосте выполните `docker compose up -d` (или включите "
"rshared-проброс), чтобы том стал виден в контейнере.",
}
# ── список ──────────────────────────────────────────────────────
@router.get("")
async def list_storages() -> dict:
cfg = runtime.config
active = cfg.storage.active
items = [await _item_payload(s, active) for s in cfg.storage.items]
return {
"storages": items,
"active": active,
"retention_days": cfg.storage.retention_days,
"segment_seconds": cfg.storage.segment_seconds,
}
# ── политика (глобально): срок хранения и длина сегмента ─────────
@router.post("/policy")
async def update_policy(request: Request) -> dict:
require_role(request, "admin")
body = await request.json()
st = runtime.config.storage
kw: dict = {}
seg_changed = False
if "segment_seconds" in body:
try:
seg = int(body["segment_seconds"])
except (TypeError, ValueError):
raise HTTPException(400, "segment_seconds должен быть числом")
if seg not in ALLOWED_SEGMENTS:
raise HTTPException(400, "допустимо 60 / 120 / 300 / 600 / 900 секунд")
kw["segment_seconds"] = seg
seg_changed = seg != st.segment_seconds
if "retention_days" in body:
try:
rd = int(body["retention_days"])
except (TypeError, ValueError):
raise HTTPException(400, "retention_days должен быть числом")
if rd < 0 or rd > 3650:
raise HTTPException(400, "retention_days в диапазоне 0..3650")
kw["retention_days"] = rd
if not kw:
raise HTTPException(400, "ожидается segment_seconds и/или retention_days")
runtime.config.storage = replace(st, **kw)
save_config(runtime.config)
if seg_changed:
await runtime.supervisor.restart_all()
return {
"ok": True,
"segment_seconds": runtime.config.storage.segment_seconds,
"retention_days": runtime.config.storage.retention_days,
}
# ── CRUD ────────────────────────────────────────────────────────
@router.post("")
async def create_storage(request: Request) -> dict:
require_role(request, "admin")
body = await request.json()
sid = (body.get("id") or "").strip()
if not ID_RE.match(sid):
raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)")
if runtime.config.storage_by_id(sid):
raise HTTPException(409, "хранилище с таким id уже существует")
stype = body.get("type", "local")
if stype not in ("local", "nas"):
raise HTTPException(400, "type: local | nas")
name = (body.get("name") or sid).strip()
quota = max(0, int(body.get("quota_gb") or 0))
helper = None
if stype == "local":
# путь = подпапка общего корня /storages/<id>; допускаем явный path (напр. /records)
path = (body.get("path") or "").strip() or f"{STORAGES_ROOT}/{sid}"
item = StorageItem(id=sid, name=name, type="local", path=path, quota_gb=quota)
try:
os.makedirs(path, exist_ok=True)
except OSError as exc:
raise HTTPException(400, f"не удалось создать папку {path}: {exc}")
else:
server = (body.get("server") or "").strip()
share = (body.get("share") or "").strip()
if not server or not share:
raise HTTPException(400, "для NAS обязательны server и share")
path = f"{STORAGES_ROOT}/{sid}"
item = StorageItem(
id=sid, name=name, type="nas", path=path, quota_gb=quota,
server=server, share=share,
username=(body.get("username") or "").strip(),
password=body.get("password") or "",
subpath=(body.get("subpath") or "").strip(),
)
# создаём точку монтирования на хосте (через bind-mount /storages) — писать туда
# рекордер начнёт только когда SMB реально смонтируют (guard по os.path.ismount)
try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
helper = _nas_mount_help(item)
new_items = runtime.config.storage.items + (item,)
runtime.config.storage = replace(runtime.config.storage, items=new_items)
save_config(runtime.config)
out = await _item_payload(item, runtime.config.storage.active)
if helper:
out["mount_help"] = helper
return out
@router.put("/{sid}")
async def update_storage(sid: str, request: Request) -> dict:
require_role(request, "admin")
item = runtime.config.storage_by_id(sid)
if not item:
raise HTTPException(404, "storage not found")
body = await request.json()
kw: dict = {}
if "name" in body:
kw["name"] = (body.get("name") or item.name).strip()
if "quota_gb" in body:
kw["quota_gb"] = max(0, int(body.get("quota_gb") or 0))
if "enabled" in body:
kw["enabled"] = bool(body["enabled"])
for f in ("server", "share", "username", "subpath"): # метаданные NAS
if f in body:
kw[f] = (body.get(f) or "").strip()
if "password" in body:
kw["password"] = body.get("password") or ""
if not kw:
raise HTTPException(400, "нет полей для изменения")
# id/type/path неизменны (привязаны к записям на диске → не ломаем архив)
new = replace(item, **kw)
items = list(runtime.config.storage.items)
idx = next(i for i, s in enumerate(items) if s.id == sid)
items[idx] = new
runtime.config.storage = replace(runtime.config.storage, items=tuple(items))
save_config(runtime.config)
# quota применится при следующем проходе retention; path не менялся → рекордеры не трогаем
out = await _item_payload(new, runtime.config.storage.active)
if new.type == "nas":
out["mount_help"] = _nas_mount_help(new)
return out
@router.delete("/{sid}")
async def delete_storage(sid: str, request: Request) -> dict:
"""Убирает хранилище из списка. Файлы и записи в БД НЕ трогаются (архив сохраняется)."""
require_role(request, "admin")
if not runtime.config.storage_by_id(sid):
raise HTTPException(404, "storage not found")
if sid == runtime.config.storage.active:
raise HTTPException(409, "нельзя удалить активное хранилище — сначала выберите другое")
if len(runtime.config.storage.items) <= 1:
raise HTTPException(409, "нельзя удалить последнее хранилище")
items = tuple(s for s in runtime.config.storage.items if s.id != sid)
runtime.config.storage = replace(runtime.config.storage, items=items)
save_config(runtime.config)
return {"ok": True}
@router.post("/{sid}/activate")
async def activate_storage(sid: str, request: Request) -> dict:
"""Делает хранилище активным (новые записи идут туда). Перезапускает рекордеры."""
require_role(request, "admin")
item = runtime.config.storage_by_id(sid)
if not item:
raise HTTPException(404, "storage not found")
runtime.config.storage = replace(runtime.config.storage, active=sid)
save_config(runtime.config)
await runtime.supervisor.restart_all() # репойнт рекордеров на новый путь
mounted = is_mounted(item.path) if item.type == "nas" else True
return {"ok": True, "active": sid, "mounted": mounted}
+1 -26
View File
@@ -3,16 +3,13 @@ from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter
from ..auth import require_role
from ..config import Storage, save_config
from ..runtime import runtime
from ..services.system import system_stats
router = APIRouter(prefix="/api", tags=["system"])
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
@@ -36,25 +33,3 @@ async def get_system() -> dict:
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.post("/storage")
async def update_storage(request: Request) -> dict:
"""Смена размера сегмента записи (1/5/10/15 мин). Перезапускает рекордеры."""
require_role(request, "admin")
body = await request.json()
try:
seg = int(body.get("segment_seconds"))
except (TypeError, ValueError):
raise HTTPException(400, "segment_seconds должен быть числом")
if seg not in ALLOWED_SEGMENTS:
raise HTTPException(400, "допустимо 60 / 300 / 600 / 900 секунд")
st = runtime.config.storage
runtime.config.storage = Storage(
path=st.path, quota_gb=st.quota_gb,
retention_days=st.retention_days, segment_seconds=seg,
)
save_config(runtime.config)
await runtime.supervisor.restart_all()
return {"segment_seconds": seg}
+171 -20
View File
@@ -18,6 +18,8 @@ class Camera:
password: str
main_path: str
sub_path: str | None = None
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
def rtsp_url(self, path: str) -> str:
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
@@ -28,12 +30,45 @@ class Camera:
return self.rtsp_url(self.main_path)
@dataclass(frozen=True)
class StorageItem:
"""Одно хранилище записи: локальная папка или сетевой диск (SMB/NAS).
`path` — путь ВНУТРИ контейнера, куда пишутся сегменты. `quota_gb` — лимит
размера ЭТОГО хранилища (0 = без лимита). Для NAS поля server/share/username/
password/subpath — метаданные (монтаж делается на хосте, backend ими не
монтирует, только показывает статус и генерирует команду mount).
"""
id: str
name: str
type: str # "local" | "nas"
path: str
quota_gb: int = 0
server: str = ""
share: str = ""
username: str = ""
password: str = ""
subpath: str = ""
enabled: bool = True
@dataclass(frozen=True)
class Storage:
path: str = "/records"
quota_gb: int = 50
"""Глобальная политика записи + список хранилищ и id активного.
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
"""
active: str = "local"
retention_days: int = 7
segment_seconds: int = 600
items: tuple[StorageItem, ...] = ()
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
_DEFAULT_STORAGE = StorageItem(
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
)
@dataclass(frozen=True)
@@ -54,52 +89,168 @@ class Config:
def enabled_cameras(self) -> list[Camera]:
return [c for c in self.cameras if c.enabled]
# ── слоты записи ────────────────────────────────────────────
# Папка камеры в хранилище = номер её слота (позиция в списке, 1-based) с
# ведущим нулём: слот 5 → «05», слот 12 → «12». Так совпадает со столбцом ID в UI.
def slot_of(self, cam_id: str) -> int | None:
for i, c in enumerate(self.cameras, start=1):
if c.id == cam_id:
return i
return None
def archive_dir(self, cam_id: str) -> str:
"""Имя подпапки записи камеры в хранилище. Откат на id, если камеры нет в списке."""
slot = self.slot_of(cam_id)
return f"{slot:02d}" if slot is not None else cam_id
def camera_by_archive_dir(self, name: str) -> Camera | None:
"""Обратное: имя папки-слота («05») → камера в этом слоте. None для нечисловых
(легаси-папки по старому id индексируются под своим именем как раньше)."""
if name.isdigit():
idx = int(name)
if 1 <= idx <= len(self.cameras):
return self.cameras[idx - 1]
return None
# ── хранилища ───────────────────────────────────────────────
def storage_by_id(self, sid: str) -> StorageItem | None:
return next((s for s in self.storage.items if s.id == sid), None)
def active_storage(self) -> StorageItem:
"""Хранилище, куда пишутся новые записи (fallback — первый/дефолтный)."""
return (
self.storage_by_id(self.storage.active)
or (self.storage.items[0] if self.storage.items else _DEFAULT_STORAGE)
)
def storage_paths(self) -> list[str]:
"""Пути всех хранилищ — индексатор сканирует их все (старые записи не теряются)."""
return [s.path for s in self.storage.items] or [_DEFAULT_STORAGE.path]
def load_config(path: str | None = None) -> Config:
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
# Толерантно к отсутствию/повреждению: если файла нет, на его месте каталог
# (битый bind-mount) или YAML невалиден — стартуем без камер (добавляются из UI),
# а не падаем с ошибкой на старте.
raw: dict = {}
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError):
raw = {}
storage = Storage(**(raw.get("storage") or {}))
storage = _storage_from_raw(raw.get("storage") or {})
live = Live(**(raw.get("live") or {}))
cameras = [
Camera(
id=c["id"],
name=c.get("name", c["id"]),
enabled=c.get("enabled", True),
ip=c["ip"],
user=c["user"],
password=c["password"],
main_path=c["main_path"],
sub_path=c.get("sub_path"),
)
for c in (raw.get("cameras") or [])
]
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
return Config(storage=storage, live=live, cameras=cameras)
def _camera_from_raw(c: dict) -> Camera:
# Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда),
# отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем
# так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть.
if "record" in c:
enabled, record = bool(c.get("enabled", True)), bool(c["record"])
else:
enabled, record = True, bool(c.get("enabled", True))
return Camera(
id=c["id"],
name=c.get("name", c["id"]),
enabled=enabled,
record=record,
ip=c["ip"],
user=c["user"],
password=c["password"],
main_path=c["main_path"],
sub_path=c.get("sub_path"),
storage_id=c.get("storage_id", "") or "",
)
def _storage_item_from_raw(s: dict) -> StorageItem:
return StorageItem(
id=s["id"],
name=s.get("name", s["id"]),
type=s.get("type", "local"),
path=s["path"],
quota_gb=int(s.get("quota_gb", 0) or 0),
server=s.get("server", "") or "",
share=s.get("share", "") or "",
username=s.get("username", "") or "",
password=s.get("password", "") or "",
subpath=s.get("subpath", "") or "",
enabled=bool(s.get("enabled", True)),
)
def _storage_from_raw(raw: dict) -> Storage:
# Новая схема: есть список items.
if raw.get("items"):
items = tuple(_storage_item_from_raw(s) for s in raw["items"])
active = raw.get("active") or (items[0].id if items else "local")
return Storage(
active=active,
retention_days=int(raw.get("retention_days", 7)),
segment_seconds=int(raw.get("segment_seconds", 600)),
items=items,
)
# Миграция со старой одиночной схемы {path, quota_gb, retention_days, segment_seconds}:
# один локальный item, старый глобальный quota → квота этого хранилища.
legacy_path = raw.get("path") or "/records"
legacy_quota = int(raw.get("quota_gb", 30) or 30)
local = StorageItem(
id="local", name="Локальное хранилище", type="local",
path=legacy_path, quota_gb=legacy_quota,
)
return Storage(
active="local",
retention_days=int(raw.get("retention_days", 7)),
segment_seconds=int(raw.get("segment_seconds", 600)),
items=(local,),
)
def camera_to_yaml(c: Camera) -> dict:
return {
"id": c.id,
"name": c.name,
"enabled": c.enabled,
"record": c.record,
"ip": c.ip,
"user": c.user,
"password": c.password,
"main_path": c.main_path,
"sub_path": c.sub_path,
"storage_id": c.storage_id,
}
def storage_item_to_yaml(s: StorageItem) -> dict:
d = {
"id": s.id, "name": s.name, "type": s.type,
"path": s.path, "quota_gb": s.quota_gb,
}
if s.type == "nas":
d.update({
"server": s.server, "share": s.share, "username": s.username,
"password": s.password, "subpath": s.subpath,
})
if not s.enabled:
d["enabled"] = False
return d
def save_config(config: Config, path: str | None = None) -> None:
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
data = {
"storage": {
"path": config.storage.path,
"quota_gb": config.storage.quota_gb,
"active": config.storage.active,
"retention_days": config.storage.retention_days,
"segment_seconds": config.storage.segment_seconds,
"items": [storage_item_to_yaml(s) for s in config.storage.items],
},
"live": {"go2rtc_url": config.live.go2rtc_url},
"cameras": [camera_to_yaml(c) for c in config.cameras],
+26
View File
@@ -8,6 +8,14 @@ import aiosqlite
from .models import Recording
def _like_prefix(prefix: str) -> str:
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
p = prefix.rstrip("/") + "/"
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return p + "%"
SCHEMA = """
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -105,6 +113,24 @@ class Database:
rows = await cur.fetchall()
return [Recording(**dict(r)) for r in rows]
# ── per-storage (по префиксу пути) — для квоты на каждое хранилище ──
async def total_size_under(self, prefix: str) -> int:
cur = await self.conn.execute(
"SELECT COALESCE(SUM(size_bytes), 0) FROM recordings WHERE path LIKE ? ESCAPE '\\'",
(_like_prefix(prefix),),
)
row = await cur.fetchone()
return int(row[0]) if row else 0
async def oldest_recordings_under(self, prefix: str, limit: int = 50) -> list[Recording]:
cur = await self.conn.execute(
"SELECT * FROM recordings WHERE path LIKE ? ESCAPE '\\' "
"ORDER BY started_at ASC LIMIT ?",
(_like_prefix(prefix), limit),
)
rows = await cur.fetchall()
return [Recording(**dict(r)) for r in rows]
async def recordings_before(self, ts: int) -> list[Recording]:
cur = await self.conn.execute(
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
+23 -7
View File
@@ -17,7 +17,7 @@ from .recorder.supervisor import Supervisor
from .services.retention import Retention
from .runtime import runtime
from . import auth, prefs
from .api import cameras, recordings, system, ws
from .api import cameras, recordings, storages, system, ws
from .api.ws import ConnectionManager
from .services import go2rtc
@@ -28,6 +28,13 @@ logging.basicConfig(
log = logging.getLogger("nvr")
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
# Мобильный dashboard: тот же бэкенд слушает один порт, но в docker проброшен второй
# хост-порт (8443→8090). Запрос на него приходит с Host ".. :8443" → отдаём mobile.html.
MOBILE_PORT = os.environ.get("NVR_MOBILE_PORT", "8443")
def _is_mobile(request: Request) -> bool:
return request.headers.get("host", "").endswith(":" + MOBILE_PORT)
@asynccontextmanager
@@ -46,25 +53,31 @@ async def lifespan(app: FastAPI):
runtime.indexer = Indexer(runtime.config, runtime.db)
runtime.retention = Retention(runtime.config, runtime.db)
os.makedirs(runtime.config.storage.path, exist_ok=True)
# каталоги локальных хранилищ; NAS не создаём — ждём монтаж на хосте (guard в recorder_task)
for st in runtime.config.storage.items:
if st.type == "local":
os.makedirs(st.path, exist_ok=True)
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc
runtime.supervisor.start_all()
runtime.indexer.start()
runtime.retention.start()
log.info("NVR запущен: камер=%d, хранилище=%s",
len(runtime.config.cameras), runtime.config.storage.path)
runtime.reconciler.start()
log.info("NVR запущен: камер=%d, активное хранилище=%s",
len(runtime.config.cameras), runtime.config.active_storage().path)
try:
yield
finally:
log.info("остановка NVR…")
await runtime.reconciler.stop()
await runtime.indexer.stop()
await runtime.retention.stop()
await runtime.supervisor.stop_all()
await runtime.db.close()
app = FastAPI(title="CoRE.Vision", version="1.0", lifespan=lifespan)
app = FastAPI(title="CoRE.Vizion+", version="1.0", lifespan=lifespan)
# публичные пути (без авторизации)
PUBLIC_PATHS = {"/login", "/api/login", "/api/health"}
@@ -96,14 +109,17 @@ app.include_router(auth.router)
app.include_router(prefs.router)
app.include_router(cameras.router)
app.include_router(recordings.router)
app.include_router(storages.router)
app.include_router(system.router)
app.include_router(ws.router)
# ── фронтенд ────────────────────────────────────────────────────
@app.get("/")
async def index() -> FileResponse:
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
async def index(request: Request) -> FileResponse:
# на мобильном порту — мобильный dashboard (Live одной камеры + Архив)
page = "mobile.html" if _is_mobile(request) else "index.html"
return FileResponse(os.path.join(FRONTEND_DIR, page))
@app.get("/archive")
+10 -7
View File
@@ -5,19 +5,22 @@ import asyncio
import json
import os
from ..config import Camera, Storage
from ..config import Camera
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
def record_args(cam: Camera, out_root: str, segment_seconds: int,
input_url: str | None = None, sub_dir: str | None = None) -> list[str]:
"""ffmpeg для записи основного потока сегментами mp4 копированием.
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
"""
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
# Подпапка даты: <root>/<sub_dir>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
out_template = os.path.join(out_root, sub_dir or cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
return [
"ffmpeg",
"-nostdin",
@@ -29,7 +32,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
"-an", # звук не пишем (камеры обычно без него)
"-c:v", "copy",
"-f", "segment",
"-segment_time", str(storage.segment_seconds),
"-segment_time", str(segment_seconds),
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
"-segment_clocktime_offset", "0",
"-segment_format", "mp4",
+71 -25
View File
@@ -10,7 +10,7 @@ import asyncio
import logging
import os
import time
from datetime import datetime
from datetime import datetime, timedelta
from ..config import Config
from ..db import Database
@@ -32,22 +32,34 @@ def _parse_started_at(path: str) -> int:
def _scan_segments(root: str) -> dict[str, list[str]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
"""Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
легаси-id камеры — сопоставление в камеру делает вызывающий).
Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
<folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
result: dict[str, list[str]] = {}
if not os.path.isdir(root):
return result
for cam_id in os.listdir(root):
cam_dir = os.path.join(root, cam_id)
for folder in os.listdir(root):
cam_dir = os.path.join(root, folder)
if not os.path.isdir(cam_dir):
continue
files = [
os.path.join(cam_dir, fn)
for fn in os.listdir(cam_dir)
if fn.endswith(".mp4")
]
files.sort()
files: list[str] = []
for entry in os.listdir(cam_dir):
p = os.path.join(cam_dir, entry)
if entry.endswith(".mp4") and os.path.isfile(p):
files.append(p) # старый плоский формат
elif os.path.isdir(p): # подпапка даты
for fn in os.listdir(p):
if fn.endswith(".mp4"):
files.append(os.path.join(p, fn))
# сортируем по ИМЕНИ файла (YYYY-MM-DD_HH-MM-SS) — хронологически независимо от того,
# лежит файл плоско или в подпапке даты (полный путь сортировал бы '/' < '_' неверно,
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
files.sort(key=os.path.basename)
if files:
result[cam_id] = files
result[folder] = files
return result
@@ -74,30 +86,64 @@ class Indexer:
async def _loop(self) -> None:
while not self._stopping:
try:
self._ensure_date_dirs()
await self.scan_once()
except Exception: # noqa: BLE001
log.exception("indexer scan failed")
await asyncio.sleep(SCAN_INTERVAL)
def _ensure_date_dirs(self) -> None:
"""Создаёт каталоги даты (сегодня+завтра) для пишущих камер заранее: ffmpeg
не умеет -strftime_mkdir, без готового каталога запись встала бы в полночь."""
today = datetime.now()
days = [today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")]
for cam in self.config.cameras:
if not (cam.enabled and cam.record):
continue
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
if store.type == "nas" and not os.path.ismount(store.path):
continue
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
for d in days:
try:
os.makedirs(os.path.join(store.path, sub, d), exist_ok=True)
except OSError:
pass
async def scan_once(self) -> int:
root = self.config.storage.path
by_cam = _scan_segments(root)
active_path = self.config.active_storage().path
known = await self.db.known_paths()
added = 0
for cam_id, files in by_cam.items():
# последний файл может писаться прямо сейчас — не индексируем
complete = files[:-1] if len(files) > 1 else []
for path in complete:
if path in known:
continue
duration, size = await probe_duration(path)
if size is None or size == 0:
continue
await self.db.add_recording(
cam_id, path, _parse_started_at(path), duration, size
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
for root in self.config.storage_paths():
by_dir = _scan_segments(root)
is_active = (root == active_path)
for folder, files in by_dir.items():
# Сопоставление папки с камерой по приоритету:
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
# иначе папка камеры с id «5» спуталась бы со слотом 5;
# 2) папка-слот «NN» → камера в этом слоте (новая схема);
# 3) иначе оставляем имя папки (осиротевший архив индексируется как раньше).
cam = self.config.camera(folder) or self.config.camera_by_archive_dir(folder)
cam_id = cam.id if cam else folder
# последний файл ещё пишется только в активной папке-слоте ТЕКУЩЕЙ камеры;
# легаси-папки и неактивное хранилище дописаны — индексируем целиком.
being_written = (
is_active and cam is not None
and self.config.archive_dir(cam.id) == folder
)
added += 1
complete = files[:-1] if being_written else files
for path in complete:
if path in known:
continue
duration, size = await probe_duration(path)
if size is None or size == 0:
continue
await self.db.add_recording(
cam_id, path, _parse_started_at(path), duration, size
)
added += 1
# уборка пропавших файлов
for path in known:
+31 -5
View File
@@ -6,8 +6,9 @@ import logging
import os
import time
from collections import deque
from datetime import datetime, timedelta
from ..config import Camera, Storage
from ..config import Camera, StorageItem
from ..models import CameraState, CameraStatus
from .ffmpeg import record_args
@@ -21,9 +22,12 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
class RecorderTask:
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
on_status=None, input_url: str | None = None, sub_dir: str | None = None):
self.cam = cam
self.storage = storage
self.store = store # активное хранилище (куда пишем)
self.segment_seconds = segment_seconds
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
self.input_url = input_url # источник записи (рестрим go2rtc)
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
self.status = CameraStatus(
@@ -69,11 +73,33 @@ class RecorderTask:
# ── основной цикл ───────────────────────────────────────────
async def _run(self) -> None:
backoff = BACKOFF_START
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
while not self._stopping:
# NAS: пишем только если том реально смонтирован на хосте. Иначе запись
# ушла бы в overlay контейнера (потеря при рестарте) — ждём монтаж, каталог
# тоже не создаём.
if self.store.type == "nas" and not os.path.ismount(self.store.path):
self._set_state(CameraState.RETRYING, "NAS не смонтирован")
backoff = min(backoff * 2, BACKOFF_MAX)
await asyncio.sleep(backoff)
continue
try:
# подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
base = os.path.join(self.store.path, self.sub_dir)
now = datetime.now()
for d in (now, now + timedelta(days=1)):
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
except OSError as exc:
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
backoff = min(backoff * 2, BACKOFF_MAX)
await asyncio.sleep(backoff)
continue
self._set_state(CameraState.STARTING)
args = record_args(self.cam, self.storage, self.input_url)
args = record_args(self.cam, self.store.path, self.segment_seconds,
self.input_url, sub_dir=self.sub_dir)
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
try:
self._proc = await asyncio.create_subprocess_exec(
+13 -7
View File
@@ -21,16 +21,23 @@ class Supervisor:
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
return go2rtc.restream_main_url(self.config, cam.id)
def _new_task(self, cam: Camera) -> RecorderTask:
# хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
# id (удалённое хранилище) → тоже откат на активное.
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
return RecorderTask(cam, store, self.config.storage.segment_seconds,
on_status=self.on_status, input_url=self._input_url(cam),
sub_dir=self.config.archive_dir(cam.id))
def start_all(self) -> None:
for cam in self.config.cameras:
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
input_url=self._input_url(cam))
task = self._new_task(cam)
self.tasks[cam.id] = task
if cam.enabled:
if cam.enabled and cam.record:
task.start()
log.info("recorder started: %s", cam.id)
else:
log.info("recorder skipped (disabled): %s", cam.id)
log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id)
async def stop_all(self) -> None:
for task in self.tasks.values():
@@ -57,10 +64,9 @@ class Supervisor:
def add_camera(self, cam: Camera) -> None:
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
input_url=self._input_url(cam))
task = self._new_task(cam)
self.tasks[cam.id] = task
if cam.enabled:
if cam.enabled and cam.record:
task.start()
log.info("recorder added: %s", cam.id)
+2
View File
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
from .recorder.indexer import Indexer
from .recorder.supervisor import Supervisor
from .services.retention import Retention
from .services.go2rtc import Reconciler
from .api.ws import ConnectionManager
@@ -18,6 +19,7 @@ class Runtime:
supervisor: "Supervisor"
indexer: "Indexer"
retention: "Retention"
reconciler: "Reconciler"
ws: "ConnectionManager"
+79
View File
@@ -14,6 +14,7 @@ go2rtc-потоки на камеру:
from __future__ import annotations
import asyncio
import json
import logging
import os
import urllib.parse
@@ -27,6 +28,7 @@ from ..config import Camera, Config
log = logging.getLogger("nvr.go2rtc")
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
RECONCILE_INTERVAL = 30 # сек — период сверки рантайма go2rtc с конфигом
def _rtsp_host(config: Config) -> str:
@@ -58,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]:
def build_streams(config: Config) -> dict:
streams: dict = {}
for c in config.cameras:
if not c.enabled: # выключенная камера — без потоков (ни live, ни записи)
continue
for name, src in _streams_for(c).items():
streams[name] = [src]
return streams
@@ -118,5 +122,80 @@ async def sync_all(config: Config) -> None:
write_go2rtc_yaml(config)
base = config.live.go2rtc_url.rstrip("/")
for cam in config.cameras:
if not cam.enabled:
continue
for name, src in _streams_for(cam).items():
await _call("PUT", _put(base, name, src))
def _get_stream_names(base: str) -> set[str]:
"""Имена потоков, реально загруженных в рантайм go2rtc (/api/streams)."""
req = urllib.request.Request(f"{base}/api/streams", method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8") or "{}")
return set(data.keys())
async def reconcile(config: Config) -> int:
"""Дотягивает в go2rtc недостающие потоки (самовосстановление).
go2rtc читает go2rtc.yaml только при старте, а рантайм-таблицу держит через API.
Если её опустошить (DELETE при чистке камер, перезапуск go2rtc со старым/пустым
файлом, молча провалившийся стартовый sync_all) — рестрим начинает отдавать 404,
и live, и запись «слепнут». Здесь сверяем желаемый набор с /api/streams и PUT-им
ТОЛЬКО отсутствующие — существующие не трогаем, чтобы не рвать активных
producer'ов/consumer'ов. Возвращает число восстановленных потоков."""
if not config.cameras:
return 0
base = config.live.go2rtc_url.rstrip("/")
try:
existing = await asyncio.to_thread(_get_stream_names, base)
except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/перезапускаться
log.debug("go2rtc reconcile: /api/streams недоступен: %s", exc)
return 0
added = 0
for cam in config.cameras:
if not cam.enabled: # выключенные камеры в go2rtc не держим
continue
for name, src in _streams_for(cam).items():
if name not in existing:
await _call("PUT", _put(base, name, src))
added += 1
if added:
log.info("go2rtc reconcile: восстановлено потоков %d", added)
write_go2rtc_yaml(config) # и файл приводим в соответствие — для будущего рестарта
return added
class Reconciler:
"""Фоновая сверка потоков go2rtc с конфигом каждые RECONCILE_INTERVAL секунд.
Держит ссылку на тот же объект Config, что и остальной рантайм (его .cameras
мутируется in-place при add/clear), поэтому всегда видит актуальный список."""
def __init__(self, config: Config, interval: int = RECONCILE_INTERVAL):
self.config = config
self.interval = interval
self._task: asyncio.Task | None = None
self._stopping = False
def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="go2rtc-reconcile")
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _loop(self) -> None:
while not self._stopping:
await asyncio.sleep(self.interval)
try:
await reconcile(self.config)
except Exception: # noqa: BLE001
log.exception("go2rtc reconcile failed")
+14 -12
View File
@@ -61,20 +61,22 @@ class Retention:
return len(old)
async def _enforce_quota(self) -> int:
quota = self.config.storage.quota_gb * 1024 ** 3
if not quota:
return 0
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
removed = 0
# удаляем самые старые, пока суммарный размер не уложится в квоту
while await self.db.total_size() > quota:
batch = await self.db.oldest_recordings(limit=20)
if not batch:
break
for rec in batch:
await self._remove(rec.id, rec.path)
removed += 1
if await self.db.total_size() <= quota:
for item in self.config.storage.items:
if not item.quota_gb:
continue
quota = item.quota_gb * 1024 ** 3
while await self.db.total_size_under(item.path) > quota:
batch = await self.db.oldest_recordings_under(item.path, limit=20)
if not batch:
break
for rec in batch:
await self._remove(rec.id, rec.path)
removed += 1
if await self.db.total_size_under(item.path) <= quota:
break
return removed
async def _remove(self, rec_id: int, path: str) -> None:
+19 -6
View File
@@ -1,6 +1,7 @@
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
from __future__ import annotations
import os
import shutil
import time
@@ -11,19 +12,31 @@ from ..config import Config
_START = time.time()
def system_stats(config: Config) -> dict:
path = config.storage.path
def storage_disk(path: str) -> dict:
"""Использование диска, на котором лежит path (для одного хранилища)."""
try:
usage = shutil.disk_usage(path)
disk = {
return {
"total_gb": round(usage.total / 1024 ** 3, 1),
"used_gb": round(usage.used / 1024 ** 3, 1),
"free_gb": round(usage.free / 1024 ** 3, 1),
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
}
except OSError:
disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
return {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
def is_mounted(path: str) -> bool:
"""Точка монтирования? (для NAS — признак, что том реально подключён)."""
try:
return os.path.ismount(path)
except OSError:
return False
def system_stats(config: Config) -> dict:
active = config.active_storage()
path = active.path
vm = psutil.virtual_memory()
return {
"uptime_s": int(time.time() - _START),
@@ -32,8 +45,8 @@ def system_stats(config: Config) -> dict:
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
"storage_path": path,
"disk": disk,
"quota_gb": config.storage.quota_gb,
"disk": storage_disk(path),
"quota_gb": active.quota_gb,
"retention_days": config.storage.retention_days,
"segment_seconds": config.storage.segment_seconds,
}
+63 -22
View File
@@ -1,17 +1,25 @@
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP)."""
"""Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC."""
from __future__ import annotations
H264_PROFILES = ("baseline", "main", "high")
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
PRESETS = ("ultrafast", "superfast")
PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium")
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
CODECS = ("h264", "vp9")
RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор
DEFAULT_PROFILE = "main"
DEFAULT_BITRATE = "2000k"
DEFAULT_PRESET = "superfast"
DEFAULT_KEYINT = 2
DEFAULT_CODEC = "h264"
DEFAULT_RC = "vbr"
DEFAULT_QP = 23
QP_MIN, QP_MAX = 0, 51
# пресет x264 → cpu-used VP9 (больше число = быстрее кодирование, ниже качество)
_VP9_CPU = {"ultrafast": "8", "superfast": "8", "veryfast": "7",
"faster": "6", "fast": "5", "medium": "4"}
def norm_profile(p: str | None) -> str:
@@ -30,6 +38,10 @@ def norm_preset(p: str | None) -> str:
return p if p in PRESETS else DEFAULT_PRESET
def norm_rc(m: str | None) -> str:
return m if m in RC_MODES else DEFAULT_RC
def norm_keyint(k) -> int:
try:
k = int(k)
@@ -38,51 +50,80 @@ def norm_keyint(k) -> int:
return k if k in KEYINTS else DEFAULT_KEYINT
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
preset: str | None = None, keyint_sec=None) -> list[str]:
"""Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров.
def norm_qp(q) -> int:
try:
q = int(q)
except (TypeError, ValueError):
return DEFAULT_QP
return max(QP_MIN, min(QP_MAX, q))
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
интервал в секундах соблюдается независимо от fps камеры."""
bufsize = f"{int(bitrate[:-1]) * 2}k"
def _kbps(bitrate: str) -> int:
return int(bitrate[:-1])
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
preset: str | None = None, keyint_sec=None,
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
"""Аргументы libx264: профиль, пресет, GOP и режим контроля битрейта.
rc: vbr — целевой битрейт с потолком (-b:v/-maxrate); cbr — постоянный
битрейт (nal-hrd=cbr, min=max=b:v); qp — постоянный квантизатор (-qp, без
битрейта). Ключевые кадры расставляются по времени (-force_key_frames)."""
preset = norm_preset(preset)
sec = norm_keyint(keyint_sec)
rc = norm_rc(rc)
args = [
"-c:v", "libx264",
"-preset", preset,
"-profile:v", profile,
"-b:v", bitrate,
"-maxrate", bitrate,
"-bufsize", bufsize,
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
"-pix_fmt", "yuv420p",
]
if rc == "qp":
args += ["-qp", str(norm_qp(qp))]
elif rc == "cbr":
kb = _kbps(bitrate)
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate,
"-bufsize", f"{kb}k", "-x264-params", "nal-hrd=cbr"]
else: # vbr
kb = _kbps(bitrate)
args += ["-b:v", bitrate, "-maxrate", bitrate, "-bufsize", f"{kb * 2}k"]
if low_latency:
args += ["-tune", "zerolatency"]
return args
def vp9_args(bitrate: str, low_latency: bool = False,
preset: str | None = None, keyint_sec=None) -> list[str]:
"""Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим)."""
preset: str | None = None, keyint_sec=None,
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
"""Аргументы libvpx-VP9 (профиль H.264 неприменим).
rc: vbr/cbr — по битрейту; qp — постоянное качество (-crf N -b:v 0, у VP9
нет прямого -qp). cpu-used маппится из пресета. realtime для live."""
sec = norm_keyint(keyint_sec)
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live
cpu = "8" if norm_preset(preset) == "ultrafast" else "6"
return [
rc = norm_rc(rc)
args = [
"-c:v", "libvpx-vp9",
"-b:v", bitrate,
"-maxrate", bitrate,
"-deadline", "realtime",
"-cpu-used", cpu,
"-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"),
"-row-mt", "1",
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
"-pix_fmt", "yuv420p",
]
if rc == "qp":
args += ["-crf", str(norm_qp(qp)), "-b:v", "0"]
elif rc == "cbr":
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate]
else: # vbr
args += ["-b:v", bitrate, "-maxrate", bitrate]
return args
def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False,
preset: str | None = None, keyint_sec=None) -> list[str]:
preset: str | None = None, keyint_sec=None,
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
"""Аргументы видеокодировщика под выбранный кодек (h264 | vp9)."""
if norm_codec(codec) == "vp9":
return vp9_args(bitrate, low_latency, preset, keyint_sec)
return x264_args(profile, bitrate, low_latency, preset, keyint_sec)
return vp9_args(bitrate, low_latency, preset, keyint_sec, rc, qp)
return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp)
View File
+20
View File
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDNzCCAh+gAwIBAgIUNxd5IcLXh/oNSAiUMUwDm/zwLD0wDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPQ29SRS5WaXNpb24gTlZSMB4XDTI2MDUyOTExNTQzMloX
DTM2MDUyNjExNTQzMlowGjEYMBYGA1UEAwwPQ29SRS5WaXNpb24gTlZSMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtL5c7I4d0aAardYj4BhHPfN6UxbP
wJegCMLXP5SCTzx1S9XvMCVmZuGdtc2/lLkHR80Z4fco1fOL+sUVxyvEcnLziaNq
lYlzLRNCMcvgBQHaKm3YxlR68g3ELD9GqdcwhL3hsVTl6EJhanWSUPhgqmkUL51p
J1EUZAbr3eR1vI3l/sN3xXpmUnxYkqzlTcyOpJxAiFSAqr77x4D8YMMnonukyFRh
vEAzJAWSsG9naB8Qg7IbMkTKu1omM7xvAnPi6Bfq+YU2TcTtDEcOOXInOudR/9Fl
ZVVe2uUh51mI87d8d7MCTDHzpk5p/DR/ImDd2X/+rf2eTLkifnAjuowlZQIDAQAB
o3UwczAdBgNVHQ4EFgQU8KrHNWn07q6EFNuB2pi7Q7hEQTswHwYDVR0jBBgwFoAU
8KrHNWn07q6EFNuB2pi7Q7hEQTswDwYDVR0TAQH/BAUwAwEB/zAgBgNVHREEGTAX
hwTAqIJPhwTAqIJTgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBAJ+GWCMw
kp+W+KaFgIuWiN/t/bqSTheqdw0Qvwg9tm6+WSwEClKn9Nj9TKOaDqIxYVQ/BQbE
r6GbPeFyx33ThMeGlHC5DznMFptVF4LTam89wchw+Kz3ZASryAZurHhbGUeTtWZb
fHeNgY9cRKpi+/Z5U4x2jHrg8o3eZjPeWlgby6aDvIpAAdvmYXy1aQrfUYJThkeG
4lwjinL6zwB+/13n3gqBR6SXfDbUu4W21BYqR+KuuqnCaUvs44WLST79xsP4JuJu
F7y2JpQEV/l2WmKO/wVWQxV+2cfQb1e5QQ81Q2ZVoQyxl4l8HekfTDGdXDbj1y2u
O0FJGZanhcCNmIg=
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0vlzsjh3RoBqt
1iPgGEc983pTFs/Al6AIwtc/lIJPPHVL1e8wJWZm4Z21zb+UuQdHzRnh9yjV84v6
xRXHK8RycvOJo2qViXMtE0Ixy+AFAdoqbdjGVHryDcQsP0ap1zCEveGxVOXoQmFq
dZJQ+GCqaRQvnWknURRkBuvd5HW8jeX+w3fFemZSfFiSrOVNzI6knECIVICqvvvH
gPxgwyeie6TIVGG8QDMkBZKwb2doHxCDshsyRMq7WiYzvG8Cc+LoF+r5hTZNxO0M
Rw45cic651H/0WVlVV7a5SHnWYjzt3x3swJMMfOmTmn8NH8iYN3Zf/6t/Z5MuSJ+
cCO6jCVlAgMBAAECggEAEZPt6kehWMYPB4Gq+DFZmbR4h2TRjEkdaZy2swVDbbyo
f66Xq4Fc+yZ4CxAD0M1J4E7EdKMal+wusehsR3Qsj8uO1H+jGatGG0fN3pVAUocC
llvGgvYBBadsa7ffrHGJfhoQErSkrg4+oSGRZZ8yRNXL/nF694roG8OcjsuYM7UY
e0Gi8In+n6Y56ZTT/UibdeGyezrBtr9D5fhNsBXVXxgYBvQhNSSRWhJ684jKBz/4
K5e+EOXdrBjYcaGWFiRFN0cJ1hgneXbtZY3j86TgvFtwyrgig58bXiH3O+LmNM1B
/0ZqkDrqQJ47locyX/K0UVHvxTpyxRN7prO8wzbQxQKBgQDrTrodQ+ZEVlFMdBVO
1kptIRDF+hxwccvMMDHyakRPi/9lBfExH2ldd//fLUI1KZZWfVo8C0J5+dXx5d5A
5Ct0raM+jrRd6vBf3ec9tpC4VU+tPSnOrn1AiW7AmECjvnbuiQ3hW7N8vYGRHeyj
AUJbd9xXRXDOBO4ypiJHo0aESwKBgQDEo0k4yX2RFUObHZXFUmKSQGHW7NooxR05
9SdJ/1nfqyiRCdyoFHlIhNdSohnQYwEVx6Ko4+HaN2Fz+UdUrKr35//olFjZSPYK
n+xfYmg516y5cGYtSkrwHlhtyv0EVa/jIMt6xsvv8jFYgnwlQ7YwfzDRXHAplkCJ
0VcGiL4PDwKBgCgXS8J8tRjjlApwpMi/3gJl5dO1X28RFGX/uCLTVDwxYBw4PPXf
ojYYofHGZkdkhIbL6LneOT9K/9atEYcA2R6SDwQzkCuIQdgzmJh9KH8fmemsSBk8
xX5fbA1IY4sCgoT1uPWyiAwyxYaSEKVdK48mBtafsC6JzIO4ppKKEROtAoGBAIuM
jjRc2l4SRy5YKqgktYuxYT4UTSbN7NXq430iWPfhEiMMaqpmDUSn8d/U5pj7ChQY
35kYUHp6/xA9AqBXAeZ5oSW/0eYyX1pe8HMo9WrHYu8fk4Pky5XpEzyn6DQhU3fP
GFkDqEubB+YWhGA106BLQ6vw/DCnTxn/lNEwTiGFAoGAHejoj3dx/6LEYa2J28Lr
4Eh8s0ag0Lg6OpzDft1teZE1v4SPMWymqducECkTLh3JwkBm9adOXckRdK3D4HSX
Mm06s1+hF6NzLGv5OEySGxpKJOXYRvzMUQAyu6ZgafNDfvGGZ658OH+dvJYB1zLH
ecnSb37SiQb0slju08Bo+g8=
-----END PRIVATE KEY-----
+8 -4
View File
@@ -10,7 +10,8 @@ services:
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
ports:
- "443:8090" # дашборд на https://<сервер>
- "443:8090" # десктоп-дашборд на https://<сервер>
- "8443:8090" # мобильный dashboard https://<сервер>:8443 (тот же бэкенд, mobile.html по Host)
volumes:
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
@@ -20,10 +21,13 @@ services:
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
- ./data:/data # SQLite-индекс архива
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
# - /mnt/nas/cctv:/records
# Запись. ./records — дефолтное локальное хранилище (id=local).
- ./records:/records
# Общий корень доп. хранилищ (вкладка «Хранилища»): локальные подпапки
# /storages/<id> и точки монтирования NAS. NAS монтируется на ХОСТЕ в
# ./storages/<id>; после `mount` на хосте выполните `docker compose up -d`,
# чтобы том стал виден здесь (или добавьте :rshared для live-подхвата).
- ./storages:/storages
environment:
TZ: Europe/Moscow
NVR_CONFIG: /app/cameras.yaml
+22 -26
View File
@@ -3,48 +3,44 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Архив</title>
<title>CoRE.Vizion+ — Архив</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/player.css">
</head>
<body data-page="archive">
<header>
<div class="brand">CoRE<span>.Vision</span></div>
<div class="spacer"></div>
<div class="stats" id="sys-stats"></div>
<div class="clock" id="clock"></div>
</header>
<div class="layout">
<aside>
<a class="nav-item" href="/" data-nav="live"><span class="ico"></span>Live view</a>
<div class="subnav">
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
</div>
<a class="nav-item active" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
<div class="aside-spacer"></div>
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a>
</aside>
<main class="archive-main">
<div class="toolbar">
<label class="muted">Камера:</label>
<select id="cam-select"></select>
<label class="muted">Дата:</label>
<input type="date" id="date">
<aside class="arch-side">
<div class="arch-cams" id="arch-cams"></div>
<div class="arch-date">
<input type="date" id="date" class="arch-dateinput">
<div class="arch-cal" id="calendar"></div>
</div>
</aside>
<div class="arch-content" id="arch-content">
<div class="player-wrap" id="player">
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
</div>
<div class="timeline" id="timeline"></div>
<footer class="grid-bar" id="arch-bar">
<div class="gb-left"></div>
<div class="gb-center"></div>
<div class="gb-right">
<button class="gb-btn" id="arch-fs" title="Полный экран" aria-label="Полный экран"></button>
</div>
</footer>
</div>
<div class="player-wrap" id="player">
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
</div>
<div class="timeline" id="timeline"></div>
</main>
</div>
<script src="/static/player.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+1 -8
View File
@@ -3,17 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Камеры</title>
<title>CoRE.Vizion+ — Камеры</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="cams">
<header>
<div class="brand">CoRE<span>.Vision</span></div>
<div class="spacer"></div>
<div class="stats" id="sys-stats"></div>
<div class="clock" id="clock"></div>
</header>
<div class="layout">
<aside>
<a class="nav-item" href="/" data-nav="live"><span class="ico"></span>Live view</a>
+26 -20
View File
@@ -3,38 +3,44 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Live view</title>
<title>CoRE.Vizion+ — Live view</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="live">
<header>
<div class="brand">CoRE<span>.Vision</span></div>
<div class="spacer"></div>
<div class="stats" id="sys-stats"></div>
<div class="clock" id="clock"></div>
</header>
<div class="layout">
<aside>
<a class="nav-item active" href="/" data-nav="live"><span class="ico"></span>Live view</a>
<div class="subnav" id="live-subnav">
<a class="subitem subplay" id="play-toggle" href="#" title="Начать/остановить просмотр">⏹ Стоп</a>
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
<a class="subitem subfs" id="fs-grid" href="#" title="Развернуть сетку на весь экран">⛶ Полный экран</a>
</div>
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
<div class="aside-spacer"></div>
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a>
</aside>
<main>
<main id="live-main">
<div class="grid g4" id="grid"></div>
<footer class="grid-bar" id="grid-bar">
<div class="gb-left"></div>
<div class="gb-center">
<button class="gb-btn" id="gb-prev" title="Предыдущая страница" aria-label="Предыдущая страница"></button>
<button class="gb-btn gb-play" id="gb-play" title="Просмотр / стоп" aria-label="Просмотр / стоп"></button>
<button class="gb-btn" id="gb-next" title="Следующая страница" aria-label="Следующая страница"></button>
</div>
<div class="gb-right">
<div class="gb-split">
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки"></button>
<div class="gb-split-menu" id="gb-split-menu">
<button data-grid="1">1</button>
<button data-grid="2">4</button>
<button data-grid="3">9</button>
<button data-grid="4">16</button>
<button data-grid="5">25</button>
<button data-grid="6">36</button>
<button data-grid="7">49</button>
<button data-grid="8">64</button>
</div>
</div>
<button class="gb-btn" id="gb-fs" title="Полный экран" aria-label="Полный экран"></button>
</div>
</footer>
</main>
</div>
+2 -21
View File
@@ -3,32 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Раскладка</title>
<title>CoRE.Vizion+ — Раскладка</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="layout">
<header>
<div class="brand">CoRE<span>.Vision</span></div>
<div class="spacer"></div>
<div class="stats" id="sys-stats"></div>
<div class="clock" id="clock"></div>
</header>
<div class="layout">
<aside>
<div class="navline">
<a class="nav-item" href="/" data-nav="live" style="flex:1"><span class="ico"></span>Live view</a>
<a class="nav-gear active" href="/layout" title="Настройка раскладки"></a>
</div>
<div class="subnav">
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
</div>
<a class="nav-item" href="/" data-nav="live"><span class="ico"></span>Live view</a>
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
<div class="aside-spacer"></div>
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a>
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Вход</title>
<title>CoRE.Vizion+ — Вход</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<form class="login-card" id="login-form">
<div class="brand login-brand">CoRE<span>.Vision</span></div>
<div class="brand login-brand">CoRE<span>.Vizion+</span></div>
<input type="text" id="username" placeholder="Логин" autocomplete="username" autofocus>
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
<button type="submit">Войти</button>
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#161a22">
<title>CoRE.Vizion+ — Mobile</title>
<link rel="stylesheet" href="/static/player.css">
<link rel="stylesheet" href="/static/mobile.css">
</head>
<body>
<div class="m-app">
<header class="m-head">
<button class="m-burger" id="m-burger" aria-label="Меню"></button>
<span class="m-title" id="m-title">Live</span>
</header>
<!-- бутерброд-меню: выбор режима (Live / Архив) -->
<div class="m-scrim" id="m-scrim" hidden></div>
<aside class="m-drawer" id="m-drawer">
<div class="m-drawer-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></div>
<button class="m-nav active" data-view="live"><span class="i"></span>Live</button>
<button class="m-nav" data-view="arch"><span class="i">🗄</span>Архив</button>
</aside>
<!-- LIVE: одна камера + выбор камеры (плеер только пауза/плей) -->
<section class="m-view" id="view-live">
<div class="m-bar">
<select id="m-live-cam" class="m-sel" title="Камера"></select>
</div>
<div class="m-stage"><div class="player-wrap" id="m-live-box"></div></div>
</section>
<!-- АРХИВ: камера + дата + список записей (плеер с перемоткой) -->
<section class="m-view" id="view-arch" hidden>
<div class="m-bar">
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
<input type="date" id="m-arch-date" class="m-date" title="Дата">
</div>
<div class="m-stage"><div class="player-wrap" id="m-arch-box"></div></div>
<div class="m-seglist" id="m-seglist"></div>
</section>
</div>
<script src="/static/player.js"></script>
<script src="/static/mobile.js"></script>
</body>
</html>
+116 -63
View File
@@ -3,29 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoRE.Vision — Настройки</title>
<title>CoRE.Vizion+ — Настройки</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="settings">
<header>
<div class="brand">CoRE<span>.Vision</span></div>
<div class="spacer"></div>
<div class="stats" id="sys-stats"></div>
<div class="clock" id="clock"></div>
</header>
<div class="layout">
<aside>
<a class="nav-item" href="/" data-nav="live"><span class="ico"></span>Live view</a>
<div class="subnav">
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
</div>
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
<div class="aside-spacer"></div>
<a class="nav-item active" href="/settings" data-nav="settings"><span class="ico"></span>Настройки</a>
@@ -34,7 +18,9 @@
<main class="settings-main">
<div class="tabs">
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
<button class="tab" data-tab="storage" data-min-role="admin">Хранилища</button>
<button class="tab" data-tab="layout">Раскладка</button>
<button class="tab" data-tab="player" data-min-role="operator">Плеер</button>
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
<button class="tab" data-tab="general" data-min-role="operator">Система</button>
<button class="tab" data-tab="optim" data-min-role="superadmin">Доп настройки</button>
@@ -48,56 +34,137 @@
<div style="display:flex;gap:8px">
<button id="bulk-edit">Изменить</button>
<button id="bulk-cancel" hidden>Отмена</button>
<button id="add-cam">+ Добавить камеру</button>
<button id="clear-cams" class="danger" title="Удалить все камеры (архив не трогается)">Очистить все</button>
</div>
</div>
<table class="cams">
<table class="cams cams-fixed">
<thead>
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th style="width:150px">Хранилище</th><th data-bulk-label="Логин / пароль">Статус</th><th style="width:62px"></th></tr>
</thead>
<tbody id="cam-rows"></tbody>
</table>
</div>
</div>
<!-- ВКЛАДКА: Хранилища (управление записью) -->
<div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
<div class="card">
<div class="card-head">
<h2>Хранилища записи</h2>
<button id="stor-add">+ Добавить хранилище</button>
</div>
<div class="muted" style="margin-bottom:12px">
Активное хранилище — куда пишутся новые записи. Квота задаётся на каждое хранилище
(0 = без лимита) и правится прямо в таблице. После смены активного старые записи
остаются доступны в Архиве. Удаление хранилища убирает его из списка — файлы записей
НЕ удаляются.
</div>
<table class="cams">
<thead>
<tr>
<th style="width:74px">Активно</th>
<th style="width:90px">ID</th>
<th>Название</th>
<th style="width:56px">Тип</th>
<th>Путь</th>
<th style="width:130px">Квота, ГБ</th>
<th style="width:150px">Занято / диск</th>
<th style="width:130px">Статус</th>
<th style="width:48px"></th>
</tr>
</thead>
<tbody id="stor-rows"></tbody>
</table>
<div class="muted" id="stor-msg" style="margin-top:10px;font-size:12px"></div>
</div>
<!-- форма добавления (скрыта по умолчанию) -->
<div class="card" id="stor-form-card" hidden>
<h2>Новое хранилище</h2>
<div class="cam-edit-grid">
<label>ID<input type="text" id="sf-id" placeholder="arch2"></label>
<label>Название<input type="text" id="sf-name" placeholder="Архив 2"></label>
<label>Тип
<select id="sf-type">
<option value="local">Локальное (папка)</option>
<option value="nas">NAS (SMB)</option>
</select>
</label>
<label>Квота, ГБ (0 = без лимита)<input type="number" id="sf-quota" min="0" value="0"></label>
<label class="sf-local">Папка<input type="text" id="sf-path" placeholder="/storages/arch2 (по умолчанию)"></label>
<label class="sf-nas" hidden>SMB сервер (IP)<input type="text" id="sf-server" placeholder="192.168.1.10"></label>
<label class="sf-nas" hidden>Шара (share)<input type="text" id="sf-share" placeholder="cctv"></label>
<label class="sf-nas" hidden>Логин<input type="text" id="sf-user"></label>
<label class="sf-nas" hidden>Пароль<input type="password" id="sf-pass"></label>
<label class="sf-nas" hidden>Подпапка (необяз.)<input type="text" id="sf-subpath" placeholder="cam-archive"></label>
</div>
<div class="cam-edit-foot">
<span class="err" id="sf-err"></span>
<button id="sf-save" class="primary">Создать</button>
<button id="sf-cancel">Отмена</button>
</div>
<div class="muted" id="sf-mount" style="margin-top:10px;font-size:12px;white-space:pre-wrap"></div>
</div>
<div class="card">
<h2>Политика записи <span class="muted" style="font-weight:400;font-size:12px">— общая для всех хранилищ</span></h2>
<div class="cam-edit-grid">
<label>Длина сегмента
<select id="pol-segment">
<option value="60">1 минута</option>
<option value="120">2 минуты</option>
<option value="300">5 минут</option>
<option value="600">10 минут</option>
<option value="900">15 минут</option>
</select>
</label>
<label>Срок хранения, дней (0 = не удалять по возрасту)<input type="number" id="pol-retention" min="0" max="3650"></label>
</div>
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
<div class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
</div>
<div class="card">
<h2>Активное хранилище <span class="muted" style="font-weight:400;font-size:12px">— сводка</span></h2>
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
</div>
</div>
<!-- ВКЛАДКА 2: Система -->
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
<div class="wrap">
<div class="card" data-min-role="admin">
<h2>Запись</h2>
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
<select id="segment-size" style="margin-top:6px;display:block">
<option value="60">1 минута</option>
<option value="120">2 минуты</option>
<option value="300">5 минут</option>
<option value="600">10 минут</option>
<option value="900">15 минут</option>
</select>
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
<div class="card">
<h2>Система</h2>
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
</div>
</div>
</div>
<!-- ВКЛАДКА: Плеер и перекодирование (этот браузер) -->
<div class="tab-panel" data-panel="player" data-min-role="operator" hidden>
<div class="wrap">
<div class="card">
<h2>Live view <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" id="pref-autostart">
<span>Показывать камеры сразу при входе в Live view
<span class="muted">— иначе просмотр стартует после нажатия Play (по умолчанию)</span></span>
</label>
</div>
<div class="card">
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
Для каждого потока выберите способ воспроизведения: <b>как есть</b>
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
VP9 (нагрузка на CPU сервера, для слабых клиентов).
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b>
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
(декод нагружает CPU клиента); <b>перекодировать</b> — сервер транскодирует
в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).
</div>
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
<div class="tr-block" data-tr="1">
<h3>1 поток (основной)</h3>
<div class="seg-toggle">
<label><input type="radio" name="mode-1" value="native"><span>Как есть</span></label>
<label><input type="radio" name="mode-1" value="player"><span>Встроенный плеер</span></label>
<label><input type="radio" name="mode-1" value="webcodecs"><span>WebCodecs (железо)</span></label>
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
</div>
<div class="tr-row"></div>
@@ -106,9 +173,7 @@
<div class="tr-block" data-tr="2">
<h3>2 поток (суб)</h3>
<div class="seg-toggle">
<label><input type="radio" name="mode-2" value="native"><span>Как есть</span></label>
<label><input type="radio" name="mode-2" value="player"><span>Встроенный плеер</span></label>
<label><input type="radio" name="mode-2" value="webcodecs"><span>WebCodecs (железо)</span></label>
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
</div>
<div class="tr-row"></div>
@@ -117,7 +182,6 @@
<div class="tr-block" data-tr="a">
<h3>Архив</h3>
<div class="seg-toggle">
<label><input type="radio" name="mode-a" value="native"><span>Как есть</span></label>
<label><input type="radio" name="mode-a" value="player"><span>Встроенный плеер</span></label>
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
</div>
@@ -127,22 +191,6 @@
<div style="margin-top:16px"><button id="prefs-save">Сохранить</button></div>
<div class="muted" id="prefs-saved" style="margin-top:8px;font-size:12px">Сохраняется в этом браузере</div>
</div>
<div class="card">
<h2>Хранилище</h2>
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
</div>
<div class="card">
<h2>Система</h2>
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
</div>
<div class="card">
<h2>Учётная запись</h2>
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
<button id="logout">Выйти из системы</button>
</div>
</div>
</div>
@@ -204,6 +252,11 @@
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
<div class="tab-panel" data-panel="users" data-min-role="superadmin" hidden>
<div class="card">
<h2>Учётная запись</h2>
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
<button id="logout">Выйти из системы</button>
</div>
<div class="card">
<div class="card-head">
<h2>Пользователи и роли</h2>
+815 -445
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, бутерброд-меню, фирменный плеер. */
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
font: 15px/1.4 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
overflow: hidden;
}
.m-app { display: flex; flex-direction: column; height: 100%; }
/* ── шапка с бутербродом ── */
.m-head {
height: 48px; flex: none; display: flex; align-items: center; gap: 10px;
padding: 0 8px; background: #161a22; border-bottom: 1px solid #272d3a;
position: relative; z-index: 30;
}
.m-burger { background: none; border: 0; color: #e6e9ef; font-size: 22px; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
.m-burger:active { background: #1d2230; }
.m-title { font-weight: 600; font-size: 16px; letter-spacing: .3px; }
.m-brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; }
.m-brand span { color: #c0392b; }
/* ── бутерброд-меню: боковая шторка + затемнение ── */
.m-scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; }
.m-scrim[hidden] { display: none; }
.m-drawer {
position: fixed; top: 0; left: 0; bottom: 0; width: 240px; max-width: 80vw; z-index: 50;
background: #161a22; border-right: 1px solid #272d3a;
display: flex; flex-direction: column;
transform: translateX(-100%); transition: transform .22s ease;
}
.m-drawer.open { transform: translateX(0); }
.m-drawer-head { height: 56px; flex: none; display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #272d3a; }
.m-nav {
display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
background: none; border: 0; border-left: 3px solid transparent; color: #cdd3e0;
padding: 16px 18px; font: 600 16px/1 inherit; cursor: pointer;
}
.m-nav .i { font-size: 20px; width: 24px; text-align: center; }
.m-nav:active { background: #1d2230; }
.m-nav.active { color: #fff; background: #1d2230; border-left-color: #c0392b; }
/* ── режимы ── */
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.m-view[hidden] { display: none; } /* hidden должен реально скрывать (иначе display:flex перебивает) */
.m-bar { flex: none; display: flex; gap: 8px; padding: 8px; background: #161a22; border-bottom: 1px solid #272d3a; }
.m-sel, .m-date {
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
}
.m-date { flex: 0 0 auto; }
/* сцена плеера: live — на всё доступное место, архив — 16:9 (ниже список записей) */
.m-stage { background: #000; position: relative; min-height: 0; }
#view-live .m-stage { flex: 1; }
#view-arch .m-stage { flex: none; aspect-ratio: 16 / 9; }
.player-wrap { width: 100%; height: 100%; }
/* список записей архива */
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
.m-seg {
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
background: #161a22; color: #e6e9ef; border: 1px solid #272d3a; border-radius: 8px;
padding: 13px 14px; margin-bottom: 6px; font-size: 15px; font-variant-numeric: tabular-nums; cursor: pointer;
}
.m-seg:active { background: #1d2230; }
.m-seg.active { background: #c0392b; border-color: #c0392b; color: #fff; }
.m-dur { color: #8b93a7; font-size: 13px; }
.m-seg.active .m-dur { color: #fff; }
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
/* фирменный плеер (.pjs) — общий static/player.css; ниже только тач-подгонка размеров */
.pjs-btn { font-size: 20px; padding: 6px 8px; }
.pjs-seek { height: 6px; }
.pjs-seek:hover { height: 6px; }
.pjs-knob { width: 16px; height: 16px; margin-left: -8px; }
.pjs-bar { gap: 12px; padding-bottom: calc(11px + env(safe-area-inset-bottom, 0)); }
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
+120
View File
@@ -0,0 +1,120 @@
// CoRE.Vizion+ — мобильный dashboard. Оба режима через фирменный плеер (.pjs):
// Live — только пауза/плей + во весь экран; Архив — перемотка + скорость.
// Видео отдаётся сервером уже перекодированным в H.264 (играет на любом телефоне).
"use strict";
// фиксированный профиль H.264 для телефона (макс. совместимость)
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
function pad(n) { return String(n).padStart(2, "0"); }
async function api(url) {
const r = await fetch(url, { credentials: "same-origin" });
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
const M = {
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
async init() {
let data;
try { data = await api("/api/cameras"); } catch (e) { return; }
this.cams = data.cameras || [];
const enabled = this.cams.filter((c) => c.enabled);
const lc = document.getElementById("m-live-cam");
const ac = document.getElementById("m-arch-cam");
lc.innerHTML = enabled.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
ac.innerHTML = this.cams.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
this.liveId = enabled[0] ? enabled[0].id : null;
this.archId = this.cams[0] ? this.cams[0].id : null;
lc.onchange = () => { this.liveId = lc.value; this.startLive(); };
ac.onchange = () => { this.archId = ac.value; this.loadArch(); };
const dt = document.getElementById("m-arch-date");
const t = new Date();
this.date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
dt.value = this.date;
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
// бутерброд-меню сверху: выбор режима (Live / Архив)
const drawer = document.getElementById("m-drawer");
const scrim = document.getElementById("m-scrim");
const setMenu = (open) => { drawer.classList.toggle("open", open); scrim.hidden = !open; };
document.getElementById("m-burger").onclick = () => setMenu(!drawer.classList.contains("open"));
scrim.onclick = () => setMenu(false);
document.querySelectorAll(".m-nav").forEach((b) => {
b.onclick = () => { this.showView(b.dataset.view); setMenu(false); };
});
this.showView("live");
},
_kill() { if (this.player) { this.player.destroy(); this.player = null; } },
showView(v) {
this.view = v;
document.getElementById("m-title").textContent = v === "live" ? "Live" : "Архив";
document.querySelectorAll(".m-nav").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
document.getElementById("view-live").hidden = v !== "live";
document.getElementById("view-arch").hidden = v !== "arch";
this._kill();
if (v === "live") this.startLive();
else this.loadArch();
},
// ── Live ──
startLive() {
this._kill();
const box = document.getElementById("m-live-box");
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
this.player = new ArchivePlayer(box, { live: true, autohide: false, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
},
// ── Архив ──
async loadArch() {
this._kill();
const box = document.getElementById("m-arch-box");
const list = document.getElementById("m-seglist");
box.innerHTML = '<div class="m-empty">Выберите запись ниже</div>';
if (!this.archId || !this.date) { list.innerHTML = ""; return; }
const [y, m, d] = this.date.split("-").map(Number);
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
const to = from + 86400;
list.innerHTML = '<div class="m-empty">Загрузка…</div>';
let recs = [];
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
catch (e) { list.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
if (!recs.length) { list.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
this.segs = recs;
list.innerHTML = recs.map((r) => {
const tt = new Date(r.started_at * 1000);
const mins = Math.round((r.duration_s || 0) / 60);
return `<button class="m-seg" data-id="${r.id}">` +
`${pad(tt.getHours())}:${pad(tt.getMinutes())}:${pad(tt.getSeconds())}` +
`<span class="m-dur">${mins} мин</span></button>`;
}).join("");
list.onclick = (e) => {
const b = e.target.closest(".m-seg");
if (!b) return;
list.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
this.playSeg(+b.dataset.id);
};
},
playSeg(id) {
this._kill();
const seg = this.segs.find((r) => r.id === id);
const box = document.getElementById("m-arch-box");
this.player = new ArchivePlayer(box, {
mode: "transcode", autohide: false,
duration: seg ? (seg.duration_s || 0) : 0,
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
});
},
};
document.addEventListener("DOMContentLoaded", () => M.init());
+48
View File
@@ -0,0 +1,48 @@
/* CoRE.Vizion+ — фирменный плеер (.pjs). Общие стили: десктоп (архив) и мобила. */
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
.pjs-bigplay {
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
transform: translate(-50%, -50%) scale(.9);
}
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
.pjs-spinner {
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
}
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
@keyframes pjs-spin { to { transform: rotate(360deg); } }
/* нижняя панель управления */
.pjs-bar {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
background: linear-gradient(transparent, rgba(0,0,0,.8));
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
}
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
.pjs-btn {
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
}
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
/* шкала перемотки */
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
.pjs-seek:hover { height: 7px; }
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
.pjs-knob {
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
}
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
.pjs.fs:not(.show) { cursor: none; }
/* live-режим: только пауза/плей + во весь экран (без шкалы/времени/скорости) */
.pjs.live .pjs-time, .pjs.live .pjs-dur, .pjs.live .pjs-seek, .pjs.live .pjs-speed { display: none; }
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
+185
View File
@@ -0,0 +1,185 @@
// CoRE.Vizion+ — фирменный плеер (.pjs). Общий модуль: десктоп (архив) и мобила.
// Класс называется ArchivePlayer (имя сохранено: app.js делает new ArchivePlayer(...)).
// Режимы:
// transcode — серверный H.264-поток без Range: виртуальный таймлайн на duration,
// перемотка = перезапуск транскода с -ss (srcFor(startSec)).
// file — нативно перематываемый файл (HW-декод H.265): v.currentTime/duration.
// live — без таймлайна: только пауза/плей + fullscreen (src без длительности).
// opts: { mode, live, duration, srcFor(startSec)->url, src, offset, onEnded, onTime, spIdx, autohide }
// autohide — авто-скрытие панели (десктоп: true по умолчанию; тач/мобила: передаём false).
"use strict";
class ArchivePlayer {
constructor(wrap, opts = {}) {
this.live = opts.live === true || opts.mode === "live";
this.mode = this.live ? "live" : (opts.mode === "transcode" ? "transcode" : "file");
this.total = opts.duration || 0; // полная длина сегмента, сек
this.srcFor = opts.srcFor || null; // (startSec) => url (для transcode)
this.startOffset = opts.offset || 0; // смещение начала текущего потока (transcode)
this.onEnded = opts.onEnded || null;
this.onTime = opts.onTime || null; // (posSec) — следование таймлайна
this.speeds = [0.5, 1, 2, 5];
this.spIdx = opts.spIdx != null ? opts.spIdx : 1; // скорость сохраняется между фрагментами
this.autohide = opts.autohide !== false; // false → панель всегда видна (тач)
this.hideTimer = 0; this.dragging = false; this.dragP = 0;
wrap.innerHTML = "";
const root = document.createElement("div");
root.className = "pjs show" + (this.live ? " live" : "");
root.tabIndex = 0;
root.innerHTML =
'<video class="pjs-video" ' + (this.live ? "muted " : "") + 'autoplay playsinline></video>' +
'<div class="pjs-center"><div class="pjs-spinner"></div>' +
'<button class="pjs-bigplay" aria-label="Play">▶</button></div>' +
'<div class="pjs-bar">' +
'<button class="pjs-btn pjs-play" title="Воспроизведение/пауза (Пробел)">⏸</button>' +
'<span class="pjs-time">0:00</span>' +
'<div class="pjs-seek"><div class="pjs-buf"></div><div class="pjs-prog"></div><div class="pjs-knob"></div></div>' +
'<span class="pjs-dur">0:00</span>' +
'<button class="pjs-btn pjs-speed" title="Скорость воспроизведения">1×</button>' +
'<button class="pjs-btn pjs-fs" title="Во весь экран (F)">⛶</button>' +
'</div>';
wrap.appendChild(root);
this.root = root;
this.v = root.querySelector(".pjs-video");
this.seek = root.querySelector(".pjs-seek");
this.prog = root.querySelector(".pjs-prog");
this.buf = root.querySelector(".pjs-buf");
this.knob = root.querySelector(".pjs-knob");
this.timeEl = root.querySelector(".pjs-time");
this.durEl = root.querySelector(".pjs-dur");
this.playBtn = root.querySelector(".pjs-play");
this.speedBtn = root.querySelector(".pjs-speed");
this.speedBtn.textContent = this.speeds[this.spIdx] + "×";
this.v.src = this.live ? opts.src : (this.mode === "transcode" ? this.srcFor(this.startOffset) : opts.src);
this._bind();
if (this.live) this.v.play().catch(() => {});
}
_fmt(s) {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return m + ":" + String(sec).padStart(2, "0");
}
_total() {
if (this.live) return 0;
if (this.mode === "transcode") return this.total;
return isFinite(this.v.duration) && this.v.duration ? this.v.duration : (this.total || 0);
}
_curTime() {
if (this.live) return 0;
if (this.mode === "transcode") return this.startOffset + (this.v.currentTime || 0);
return this.v.currentTime || 0;
}
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; }
// перемотка к доле p∈[0..1] всего сегмента
_seekToFrac(p) {
if (this.live) return;
const total = this._total(); if (!total) return;
const t = Math.min(total, Math.max(0, p * total));
if (this.mode === "transcode") {
this.startOffset = t; // перезапуск транскода с нужной секунды
this.v.src = this.srcFor(t);
this.v.play().catch(() => {});
} else {
try { this.v.currentTime = t; } catch (e) { /**/ }
}
this._sync();
}
_seekRel(d) { const total = this._total(); if (total) this._seekToFrac((this._curTime() + d) / total); }
_bind() {
const v = this.v, root = this.root;
v.addEventListener("loadedmetadata", () => {
this.durEl.textContent = this._fmt(this._total());
if (this.mode === "file" && this.startOffset > 0) { try { v.currentTime = this.startOffset; } catch (e) { /**/ } }
this._applyRate(); // вернуть скорость после загрузки нового потока (src сбрасывает playbackRate)
});
v.addEventListener("timeupdate", () => this._sync());
v.addEventListener("progress", () => this._syncBuf());
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); this._autohide(); });
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); this._show(); });
v.addEventListener("waiting", () => root.classList.add("buffering"));
v.addEventListener("playing", () => root.classList.remove("buffering"));
v.addEventListener("ended", () => { root.classList.add("paused"); if (this.onEnded) this.onEnded(); });
const toggle = () => { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } };
this.playBtn.onclick = toggle;
root.querySelector(".pjs-bigplay").onclick = toggle;
v.onclick = toggle;
v.ondblclick = () => this._fs();
root.querySelector(".pjs-fs").onclick = () => this._fs();
this.speedBtn.onclick = () => {
this.spIdx = (this.spIdx + 1) % this.speeds.length;
const r = this.speeds[this.spIdx]; v.playbackRate = r; this.speedBtn.textContent = r + "×";
};
if (!this.live) {
// перемотка по шкале: во время drag — только превью, seek/перезапуск один раз на отпускании
const fracAt = (clientX) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (clientX - r.left) / r.width)); };
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = this._fmt(p * this._total()); };
this.seek.addEventListener("pointerdown", (e) => { this.dragging = true; this.dragP = fracAt(e.clientX); try { this.seek.setPointerCapture(e.pointerId); } catch (er) {} preview(this.dragP); });
this.seek.addEventListener("pointermove", (e) => { if (this.dragging) { this.dragP = fracAt(e.clientX); preview(this.dragP); } });
this.seek.addEventListener("pointerup", () => { if (this.dragging) { this.dragging = false; this._seekToFrac(this.dragP); } });
}
// показ/автоскрытие панели
root.addEventListener("mousemove", () => this._autohide());
root.addEventListener("mouseleave", () => { if (!v.paused && this.autohide) this._hide(); });
// горячие клавиши (когда плеер в фокусе)
root.addEventListener("keydown", (e) => {
if (e.code === "Space") { e.preventDefault(); toggle(); }
else if (e.code === "ArrowRight") { e.preventDefault(); this._seekRel(5); }
else if (e.code === "ArrowLeft") { e.preventDefault(); this._seekRel(-5); }
else if (e.key === "f" || e.key === "F" || e.key === "а" || e.key === "А") { e.preventDefault(); this._fs(); }
});
this._fsSync = () => root.classList.toggle("fs", (document.fullscreenElement || document.webkitFullscreenElement) === root);
document.addEventListener("fullscreenchange", this._fsSync);
document.addEventListener("webkitfullscreenchange", this._fsSync);
setTimeout(() => { try { root.focus(); } catch (e) {} }, 0);
}
_sync() {
if (this.dragging || this.live) return; // во время перетаскивания шкалой управляет preview
const cur = this._curTime(), total = this._total();
const p = total ? Math.min(1, cur / total) : 0;
this.prog.style.width = (p * 100) + "%";
this.knob.style.left = (p * 100) + "%";
this.timeEl.textContent = this._fmt(cur);
this.durEl.textContent = this._fmt(total);
if (this.onTime) this.onTime(cur);
}
_syncBuf() {
const v = this.v; if (this.live || !v.buffered.length) return;
const total = this._total(); if (!total) return;
const base = this.mode === "transcode" ? this.startOffset : 0;
const end = base + v.buffered.end(v.buffered.length - 1);
this.buf.style.width = Math.min(100, end / total * 100) + "%";
}
// self-contained fullscreen (без глобального toggleFullscreen): работает и на мобиле
_fs() {
const el = this.root;
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
if (fsEl) {
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
} else if (el.requestFullscreen || el.webkitRequestFullscreen) {
(el.requestFullscreen || el.webkitRequestFullscreen).call(el);
} else if (this.v.webkitEnterFullscreen) {
this.v.webkitEnterFullscreen(); // iOS: на весь экран умеет только видеоэлемент
}
}
_show() { this.root.classList.add("show"); }
_hide() { this.root.classList.remove("show"); }
_autohide() {
this._show();
if (!this.autohide) return; // тач-режим: панель всегда видна
clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(() => { if (!this.v.paused && !this.dragging) this._hide(); }, 2500);
}
destroy() {
this.onTime = null; this.onEnded = null; // хвостовые timeupdate/ended не должны дёргать ленту
clearTimeout(this.hideTimer);
if (this._fsSync) {
document.removeEventListener("fullscreenchange", this._fsSync);
document.removeEventListener("webkitfullscreenchange", this._fsSync);
}
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
}
}
+167 -77
View File
@@ -13,8 +13,9 @@
--ok: #22c55e;
--warn: #f59e0b;
--err: #ef4444;
--header-h: 48px;
--sidebar-w: 240px;
--header-h: 50px;
--footer-h: 22px;
--sidebar-w: 72px;
}
* { box-sizing: border-box; }
@@ -26,12 +27,12 @@ html, body {
overflow: hidden;
}
/* ── Шапка ── */
header {
height: var(--header-h); display: flex; align-items: center;
/* ── Шапка (50px, бренд по центру) ── */
header.app-header {
height: var(--header-h); flex: none; display: flex; align-items: center; justify-content: center;
padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
gap: 18px;
}
header.app-header .brand { font-size: 18px; }
.brand { font-weight: 700; letter-spacing: .5px; color: #fff; }
.brand span { color: #c0392b; }
nav { display: flex; gap: 4px; }
@@ -46,11 +47,22 @@ nav a.active { background: var(--accent); color: #fff; }
.stats b { color: var(--text); font-weight: 600; }
.clock { font-variant-numeric: tabular-nums; color: var(--text); }
/* ── Раскладка ── */
.layout { display: flex; height: calc(100% - var(--header-h)); }
/* ── Раскладка (между шапкой и футером) ── */
.layout { display: flex; height: calc(100% - var(--header-h) - var(--footer-h)); }
/* ── Футер (бренд + CPU/RAM/диск + часы + версия) ── */
footer.app-footer {
height: var(--footer-h); display: flex; align-items: center; gap: 14px;
padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-dim);
}
footer.app-footer .brand { font-size: 12px; }
footer.app-footer .stats { display: flex; gap: 14px; font-size: 11px; }
footer.app-footer .clock { font-size: 11px; color: var(--text); }
footer.app-footer #footer-version { font-variant-numeric: tabular-nums; }
aside {
width: var(--sidebar-w); background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 0; overflow: hidden;
}
.aside-title {
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
@@ -58,14 +70,14 @@ aside {
}
/* ── Навигация в сайдбаре ── */
.nav-item {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 14px;
border-left: 3px solid transparent; background: none; border-top: 0; border-right: 0;
border-bottom: 0; cursor: pointer; width: 100%; text-align: left; font-family: inherit;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
width: 64px; height: 64px; padding: 0; border-radius: 12px;
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 9px; line-height: 1.15;
border: 0; background: none; cursor: pointer; text-align: center; font-family: inherit;
}
.nav-item:hover { background: var(--panel-2); color: var(--text); }
.nav-item.active { background: var(--panel-2); color: #fff; border-left-color: #c0392b; }
.nav-item .ico { font-size: 17px; line-height: 1; width: 20px; text-align: center; }
.nav-item.active { background: var(--panel-2); color: #fff; box-shadow: inset 0 0 0 1px #c0392b; }
.nav-item .ico { font-size: 26px; line-height: 1; width: auto; text-align: center; }
.aside-spacer { flex: 1; }
/* Иконка настройки раскладки рядом с Live view */
@@ -84,7 +96,7 @@ aside {
.subitem.active { color: #fff; border-left-color: #c0392b; background: var(--panel-2); }
.subitem.subfs { padding-left: 16px; margin-top: 4px; border-top: 1px solid var(--border); padding-top: 8px; }
.subitem.subplay { padding-left: 16px; font-weight: 600; color: var(--text); margin-bottom: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.app-version { padding: 8px 16px; font-size: 11px; color: var(--text-dim); }
.app-version { padding: 6px 2px; font-size: 10px; color: var(--text-dim); text-align: center; width: 100%; }
.nav-item.logout { color: var(--text-dim); border-top: 1px solid var(--border); }
.nav-item.logout:hover { color: #e57373; }
@@ -147,7 +159,7 @@ input[type="checkbox"]:checked {
}
input[type="checkbox"]:checked::before { transform: translateX(18px); background: var(--ok); }
input[type="checkbox"]:focus-visible { outline: 2px solid var(--ok); outline-offset: 2px; }
input[type="date"], select {
input[type="date"], input[type="number"], select {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 6px 8px; border-radius: 6px;
}
@@ -158,6 +170,42 @@ input[type="date"], select {
background: #000; overflow: hidden;
grid-auto-rows: 1fr; grid-auto-columns: 1fr; /* все дорожки строго равны */
}
/* «Соло»: одна камера на весь экран (двойной клик по ячейке в полноэкранном режиме) */
.grid.solo { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; }
.grid.solo > .tile:not(.solo) { display: none; }
/* ── Панель управления под сеткой (футер фрейма live): листание + play/stop + фулскрин ── */
footer.grid-bar {
height: 32px; flex: none; display: flex; align-items: center; gap: 8px;
padding: 0 8px; background: var(--panel); border-top: 1px solid var(--border);
}
.grid-bar .gb-left { flex: 1; display: flex; justify-content: flex-start; }
.grid-bar .gb-center { flex: 0 0 auto; display: flex; justify-content: center; gap: 8px; }
.grid-bar .gb-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; }
.grid-bar .gb-btn {
height: 24px; min-width: 30px; padding: 0 8px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--text); border-radius: 6px; cursor: pointer;
font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
.grid-bar .gb-btn:hover:not(:disabled) { border-color: #c0392b; color: #fff; }
.grid-bar .gb-btn:disabled { opacity: .3; cursor: default; }
.grid-bar .gb-play { min-width: 42px; font-size: 15px; }
/* кнопка «Раскладка» (window split) + всплывающее меню (раскрывается вверх) */
.grid-bar .gb-split { position: relative; display: inline-flex; }
.grid-bar .gb-split-menu {
display: none; position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 50;
flex-direction: column; gap: 2px; padding: 4px; min-width: 60px;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
}
.grid-bar .gb-split-menu.open { display: flex; }
.grid-bar .gb-split-menu button {
height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: 5px;
background: none; color: var(--text); cursor: pointer; font-size: 13px;
font-variant-numeric: tabular-nums; text-align: center;
}
.grid-bar .gb-split-menu button:hover { background: var(--panel-2); }
.grid-bar .gb-split-menu button.active { background: var(--accent); border-color: #c0392b; color: #fff; }
.tile {
position: relative; background: #05070a; border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; overflow: hidden;
@@ -192,6 +240,9 @@ input[type="date"], select {
.tile:fullscreen { border: none; background: #000; }
.tile:fullscreen video, .tile:fullscreen video-stream { object-fit: contain; }
#grid:fullscreen { width: 100vw; height: 100vh; background: var(--bg); padding: 0; }
/* в полноэкранном режиме панель-футер скрыта (вход/выход — клавиша F, выход также Esc) */
#live-main:fullscreen .grid-bar,
#arch-content:fullscreen .grid-bar { display: none; }
.hevc-hint {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
background: rgba(63, 3, 3, .88); color: #fff; font-size: 11px;
@@ -199,55 +250,57 @@ input[type="date"], select {
}
/* ── Архив ── */
.archive-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* в ряд: сайдбар 220px слева + контент справа (явно, т.к. базовое main = column) */
.archive-main { flex: 1; display: flex; flex-direction: row; overflow: hidden; }
/* левый сайдбар архива: 220px, поделён по высоте пополам */
.arch-side {
width: 220px; flex: none; display: flex; flex-direction: column;
background: var(--panel); border-right: 1px solid var(--border); overflow: hidden;
}
/* верхний фрейм — список камер: заполняет всё над карточкой даты, без полосы прокрутки */
.arch-cams { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; }
.arch-cams::-webkit-scrollbar { width: 0; height: 0; display: none; }
.arch-cam {
padding: 6px 12px; font-size: 13px; color: var(--text); cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.arch-cam:hover { background: var(--panel-2); }
.arch-cam.active { background: #c0392b; color: #fff; font-weight: 600; }
/* нижний фрейм даты — карточка (дата сверху + календарь), прижата вниз */
.arch-date {
flex: none; margin: 10px; padding: 10px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
display: flex; flex-direction: column; gap: 8px;
}
.arch-dateinput {
width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 8px; font-size: 13px;
}
.arch-cal { user-select: none; }
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
.cal-nav {
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
width: 24px; height: 24px; border-radius: 6px; cursor: pointer; padding: 0; line-height: 1; font-size: 14px;
}
.cal-nav:hover { border-color: #c0392b; color: #fff; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-wd { font-size: 10px; color: var(--text-dim); text-align: center; padding: 2px 0; }
.cal-day {
text-align: center; font-size: 12px; padding: 4px 0; border-radius: 5px; cursor: pointer;
color: var(--text); font-variant-numeric: tabular-nums;
}
.cal-day.empty { visibility: hidden; cursor: default; }
.cal-day:hover:not(.empty) { background: rgba(255, 255, 255, .07); }
.cal-day.today { box-shadow: inset 0 0 0 1px var(--text-dim); }
.cal-day.sel { background: #c0392b; color: #fff; font-weight: 600; }
/* контент архива (плеер + таймлайн) */
.arch-content { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.player-wrap { flex: 1; background: #000; display: flex; align-items: center; justify-content: center; min-height: 0; }
.player-wrap video { max-width: 100%; max-height: 100%; }
.player-wrap .hint { color: var(--text-dim); }
/* ── плеер архива в стиле Playerjs ── */
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
.pjs-bigplay {
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
transform: translate(-50%, -50%) scale(.9);
}
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
.pjs-spinner {
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
}
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
@keyframes pjs-spin { to { transform: rotate(360deg); } }
/* нижняя панель управления */
.pjs-bar {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
background: linear-gradient(transparent, rgba(0,0,0,.8));
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
}
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
.pjs-btn {
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
}
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
/* шкала перемотки */
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
.pjs-seek:hover { height: 7px; }
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
.pjs-knob {
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
}
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
.pjs.fs:not(.show) { cursor: none; }
/* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
.timeline {
@@ -328,7 +381,7 @@ input[type="date"], select {
.settings-main .wrap { max-width: 920px; margin: 0 auto; }
/* Вкладки (как в браузере) */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tab {
background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
color: var(--text-dim); padding: 10px 18px; cursor: pointer; font-size: 14px; font-weight: 500;
@@ -336,20 +389,18 @@ input[type="date"], select {
.tab:hover { color: var(--text); }
.tab.active { color: #fff; border-bottom-color: #c0392b; }
.tab-panel[hidden] { display: none; }
.tr-row { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; padding-left: 28px; }
/* ряд настроек профиля: слева — ровная сетка параметров, справа — «Готовый профиль» */
.tr-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; padding-left: 28px; align-items: flex-start; }
.tr-row[hidden] { display: none; }
/* 3 группы параметров перекодирования: Кодек / Битрейт / Готовый профиль */
.tr-group { border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px 12px; background: var(--panel-2); }
.tr-gtitle { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 8px; }
.tr-gbody { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: flex-end; }
.tr-row label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-dim); }
.tr-row select { display: block; }
/* общие пресеты перекодирования (SOFT / MEDIUM / HARD) */
.tr-presets { flex-basis: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; }
.tr-preset {
padding: 4px 12px; font-size: 12px; font-weight: 600; border-radius: 6px;
background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); cursor: pointer;
}
.tr-preset[data-preset="SOFT"] { color: #e03131; }
.tr-preset[data-preset="MEDIUM"] { color: #e9c46a; }
.tr-preset[data-preset="HARD"] { color: #ffffff; }
.tr-preset.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tr-gbody select, .tr-gbody input[type="number"] { display: block; }
.tr-gbody input[type="number"] { width: 80px; }
.tr-group-preset select { min-width: 120px; }
.tr-row select:disabled, .tr-row input:disabled { opacity: .4; cursor: not-allowed; }
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
.tr-block:first-of-type { border-top: none; padding-top: 0; }
@@ -415,8 +466,26 @@ table.cams th, table.cams td {
table.cams th { color: var(--text-dim); font-weight: 500; font-size: 11px; padding: 4px 10px; }
table.cams td.actions { white-space: nowrap; text-align: right; }
table.cams tr.slot-empty td { opacity: .5; }
/* строка-контейнер лога не должна занимать высоту, пока лог скрыт */
table.cams tr.logrow > td { padding: 0; border: none; }
/* камеры: жёстко фиксированная высота строки 22px независимо от контролов.
line-height:1 + контролы по центру и ниже 22px → строки не распирает. */
table.cams.cams-fixed td, table.cams.cams-fixed th {
height: 22px; padding-top: 0; padding-bottom: 0; line-height: 1;
}
table.cams.cams-fixed td > * { vertical-align: middle; }
/* тумблеры «Вкл»/«Запись» в строке — компактные (30×16) */
table.cams.cams-fixed td input[type="checkbox"] {
width: 30px; height: 16px; border-radius: 8px;
}
table.cams.cams-fixed td input[type="checkbox"]::before { width: 12px; height: 12px; top: 1px; left: 1px; }
table.cams.cams-fixed td input[type="checkbox"]:checked::before { transform: translateX(14px); }
/* селекты и инпуты в строке — на 2px ниже строки, по центру */
table.cams.cams-fixed td select.stor-sel,
table.cams.cams-fixed td select.cell-sel,
table.cams.cams-fixed td input.cell-in { height: 20px; }
/* кнопки действий ⟳/✕ (режим редактирования) — компактные */
table.cams.cams-fixed td.actions button {
height: 20px; padding: 0 6px; font-size: 13px; line-height: 1;
}
/* ── Инлайн-редактор строки (вместо всплывающего окна) ── */
.cam-edit { background: var(--panel-2); padding: 12px 12px 14px; }
@@ -439,10 +508,31 @@ button.primary { background: var(--accent); border-color: #c0392b; color: #fff;
button.danger { border-color: #c0392b; color: #e74c3c; }
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
/* инлайн-селекты в строке (хранилище, канал) — компактные, не раздувают высоту строки */
table.cams td select.stor-sel, table.cams td select.cell-sel {
height: 22px; padding: 1px 4px; font-size: 12px; line-height: 1;
border-radius: 5px; width: 100%; max-width: 150px; box-sizing: border-box;
vertical-align: middle;
}
/* массовое редактирование: инпуты прямо в ячейках, компактные (высота строки не растёт) */
table.cams td input.cell-in {
height: 22px; padding: 1px 6px; font-size: 12px; line-height: 1;
border-radius: 5px; width: 100%; box-sizing: border-box; vertical-align: middle;
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
}
table.cams td input.cell-in:focus { outline: none; border-color: #c0392b; }
table.cams td.cell-dual { white-space: nowrap; }
table.cams td.cell-dual input.cell-in { display: inline-block; width: calc(50% - 3px); }
table.cams td.cell-dual input.cell-in + input.cell-in { margin-left: 4px; }
table.cams tr.bulk-row td { background: rgba(192, 57, 43, .05); }
.badge {
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
border-radius: 11px; font-size: 12px; background: var(--panel-2);
}
.badge-active { background: rgba(46, 204, 113, .18); color: var(--ok); font-weight: 600; }
.q-cell { display: inline-flex; align-items: center; gap: 6px; }
.q-cell .q-input { width: 78px; }
.q-cell button { padding: 2px 8px; font-size: 12px; }
.logbox {
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
+12 -7
View File
@@ -286,7 +286,11 @@ var FrameRenderer = class _FrameRenderer {
format: "I420",
codedWidth: w,
codedHeight: h,
timestamp
timestamp,
// Hikvision H.265 — BT.709, TV/limited range. Без явного colorSpace браузер для
// I420 по умолчанию берёт BT.601 (зелёно-красный сдвиг) и неверный диапазон
// (белёсость). Задаём явно, чтобы YUV→RGB шёл по правильной матрице и диапазону.
colorSpace: { primaries: "bt709", transfer: "bt709", matrix: "bt709", fullRange: false }
});
await this._writer.write(videoFrame);
videoFrame.close();
@@ -394,12 +398,13 @@ var FRAGMENT_SRC = `
uniform sampler2D u_texCb;
uniform sampler2D u_texCr;
void main() {
float y = texture2D(u_texY, v_tex).r;
float cb = texture2D(u_texCb, v_tex).r - 0.5;
float cr = texture2D(u_texCr, v_tex).r - 0.5;
float r = y + 1.5748 * cr;
float g = y - 0.1873 * cb - 0.4681 * cr;
float b = y + 1.8556 * cb;
// BT.709 limited range (TV): Y∈[16,235]/255 → (Y-16)*255/219; Cb/Cr∈[16,240]/255 центр 128/255.
float y = (texture2D(u_texY, v_tex).r - 0.0627451) * 1.1643836;
float cb = texture2D(u_texCb, v_tex).r - 0.5019608;
float cr = texture2D(u_texCr, v_tex).r - 0.5019608;
float r = y + 1.7927411 * cr;
float g = y - 0.2132486 * cb - 0.5329093 * cr;
float b = y + 2.1124018 * cb;
gl_FragColor = vec4(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0), 1.0);
}
`;
+3
View File
@@ -0,0 +1,3 @@
sudo apt install docker-compose-v2 git -y
git clone https://git.core.uz/git_admin/CoRE.Vision
sudo docker compose up -d --build