Compare commits

...
49 Commits
Author SHA1 Message Date
proitlab fccc285ab3 Add OUTPUT_DIR browser + copy-on-load with progress to upload dashboard
LOAD MP4 on an OUTPUT_DIR file copies it into UPLOAD_DIR (chunked,
progress via /api/copy-progress) before spawning RECOUNT_CMD; collision
in UPLOAD_DIR fails with 400. New read-only folder-grouped browser
mirrors recounting_dashboard.py. Docs updated.
2026-08-13 13:33:26 +07:00
proitlab 634d649069 Fix STREAM toggle: auto-on preview only once per session, manual OFF persists 2026-08-10 15:41:06 +07:00
proitlab d9493ece5a STOP: host-wide /proc sweep guarantees zero RECOUNT_CMD processes remain 2026-08-10 12:19:06 +07:00
proitlab 2606de9e78 Document LOAD MP4 / .continue recount flow in SPEC, AGENTS, env example 2026-08-10 10:59:26 +07:00
proitlab 3624993189 Add LOAD MP4 button flow before START RECOUNT in upload templates 2026-08-10 10:56:50 +07:00
proitlab 5f39d47df8 Split recount flow: /api/load-mp4 spawns, /api/start-recount signals .continue 2026-08-10 10:50:07 +07:00
proitlab 6be017c4f0 Auto-on live preview when recount starts (all recounting templates) 2026-08-10 09:53:55 +07:00
proitlab 6043197d72 Fix recount stop: kill whole process group, not just shell 2026-08-05 10:29:41 +07:00
proitlab d909bc85bd Add bytetrack-recounting-upload-dashboard.service for port 5003 2026-08-05 10:27:43 +07:00
proitlab 763bcab3a6 Open cross-dashboard header links in same tab 2026-08-05 08:21:46 +07:00
proitlab 3ac73f6fd9 Add Lamborghini-themed upload recounting dashboard as default template 2026-08-05 08:10:20 +07:00
proitlab 89673db0bf Add cross-dashboard header links (UPLOAD_DASHBOARD_URL, LIVE_DASHBOARD_URL) 2026-08-05 07:58:00 +07:00
proitlab 99e126de1b Keep local file selection in applyState across all recounting dashboards 2026-08-05 01:10:00 +07:00
proitlab 5df224116d Keep file selection when /api/state reports no active recount 2026-08-05 01:00:00 +07:00
proitlab f01601d1d7 Parse batch number and date from upload filename for live API lookup 2026-08-05 00:49:39 +07:00
proitlab 14ebfb7779 Keep original filename on upload, remove timestamp prefix 2026-08-05 00:41:44 +07:00
proitlab 79656cd661 Add upload progress bar to recounting upload dashboard 2026-08-05 00:39:47 +07:00
proitlab bcceaae626 Add recounting upload dashboard with MP4 upload before recount 2026-08-05 00:26:31 +07:00
proitlab f6a388cdb9 Commit for today 2026-08-03 16:59:11 +07:00
proitlab aa3e3d6c35 Some Fixes. Reset problem 2026-08-03 16:00:26 +07:00
proitlab 75587ddae2 Reset Error 2026-08-03 15:51:43 +07:00
proitlab 2437566712 Still Error 2026-08-03 15:33:21 +07:00
proitlab 2e25089aae Fix Bug stuck in certain Batch 2026-08-03 15:21:20 +07:00
proitlab d08cedb604 Recounting Template 2026-08-01 20:22:24 +07:00
proitlab 26ef30ff5b Fix Accent Tesla 2026-08-01 20:10:41 +07:00
proitlab a03098d30c Add Tesla 2026-08-01 20:01:57 +07:00
proitlab 6cfde8b225 Some Fix 2026-08-01 19:25:54 +07:00
proitlab 1d9fd55fe3 Lamborgini Template 2026-08-01 19:21:02 +07:00
proitlab 636f987c5e Daily Trend and Quick Stats 2:1 2026-08-01 19:04:49 +07:00
proitlab 8fcc9305e3 Live Camera with Recent Activity 2026-08-01 18:58:48 +07:00
proitlab e6d66db84b Update session 2026-08-01 18:32:35 +07:00
proitlab 7394aedd5b Preview Windows 2026-07-30 22:06:50 +07:00
proitlab c2d5ca0d94 Proxy live-stream 2026-07-30 21:57:22 +07:00
proitlab 18036cd135 640x480 preview mp4 2026-07-30 21:48:53 +07:00
proitlab 2866257afe Sort mp4 files 2026-07-30 21:45:11 +07:00
proitlab f47441155b Fix Bug load file 2026-07-30 21:39:05 +07:00
proitlab 61f7b082e4 Fix long loading 2026-07-30 21:34:34 +07:00
proitlab 4f37419701 Highligh filename when clicked 2026-07-30 21:20:29 +07:00
proitlab 7778e72628 Keep counter when recounting finish 2026-07-30 21:07:52 +07:00
proitlab ac0678d9c2 Batch number from file 2026-07-30 21:01:56 +07:00
proitlab 2792ccd5bc Folder for date 2026-07-30 20:58:10 +07:00
proitlab 9496824637 Folder View MP4 2026-07-30 20:53:56 +07:00
proitlab 5593d5e392 No Path 2026-07-29 17:42:21 +07:00
proitlab ded4d7b831 Log API Reset 2026-07-29 17:38:07 +07:00
proitlab d3ce1274cf Logging add 2026-07-29 16:16:36 +07:00
proitlab b63e2fa2fa Reset Mechanism 2026-07-29 16:15:02 +07:00
proitlab e8893a3aa4 Reset All 2026-07-29 16:02:26 +07:00
proitlab 3941d75d95 No Auto Stream 2026-07-29 15:53:37 +07:00
proitlab 5d85fcbb17 Delete State File and Live Stream File 2026-07-29 15:51:22 +07:00
19 changed files with 45926 additions and 884 deletions

No files matched your search

+38938 -784
View File
File diff suppressed because it is too large. Load diff
+16 -3
View File
@@ -4,10 +4,12 @@
- **Edge AI counter**: RTSP camera → YOLO RKNN (NPU) → tracking → line-crossing → SQLite + JSON state → Flask dashboard. - **Edge AI counter**: RTSP camera → YOLO RKNN (NPU) → tracking → line-crossing → SQLite + JSON state → Flask dashboard.
- **2 counter scripts**, only 1 deployed: - **2 counter scripts**, only 1 deployed:
- `counter_live.py` — Jetson TensorRT variant (CUDA, NOT used on RK3588). - `counter_live.py` — Jetson TensorRT artifact (CUDA, NOT used on RK3588).
- **`counter_live_rknn_bytetrack.py`** — RK3588 with ByteTrack. **This is what systemd runs.** Reference for C++ port. - **`counter_live_rknn_bytetrack.py`** — RK3588 with ByteTrack. **This is what systemd runs.** Reference for C++ port.
- `batch_store.py` — shared SQLite persistence + batch state machine. - `batch_store.py` — shared SQLite persistence + batch state machine.
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB. - `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
- `recounting_dashboard.py` — Flask recounting dashboard on port 5002, consumes live + recount APIs.
- `recounting_dashboard_upload.py` — Flask upload+recount dashboard on port 5003; uploads MP4 first, then **LOAD MP4** spawns `RECOUNT_CMD` (process loads and pauses), then **START RECOUNT** touches `{SHM_DIR}/.continue` to begin counting.
## No build / test / lint ## No build / test / lint
@@ -29,6 +31,9 @@ PYTHONNOUSERSITE=1 venv/bin/python counter_live_rknn_bytetrack.py
# Run dashboard # Run dashboard
PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
# Run upload recounting dashboard
PYTHONNOUSERSITE=1 venv/bin/python recounting_dashboard_upload.py
``` ```
## Key environment & install quirks ## Key environment & install quirks
@@ -38,13 +43,21 @@ PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility. - **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility.
- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`. - **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`.
- Service user is **`root`**, not `jetson` (despite README saying otherwise). - Service user is **`root`**, not `jetson` (despite README saying otherwise).
- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`. - Three systemd units: `bytetrack-counter.service`, `bytetrack-counter-dashboard.service`, and `bytetrack-recounting-upload-dashboard.service` (upload recounting dashboard, port 5003).
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588. - `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
- **Recount continue flow** (upload dashboard, port 5003): `POST /api/load-mp4 {path}` kills the old process, spawns `RECOUNT_CMD` (loads + pauses), and POSTs `/api/reset` to the recount node. `POST /api/start-recount` (no body, requires a loaded file) touches `{SHM_DIR}/.continue`; the C++ process consumes that marker to begin counting. The dashboard never clears the marker.
- **OUTPUT_DIR browser + copy-on-load** (upload dashboard, port 5003): `GET /api/mp4-files` lists MP4s from `OUTPUT_DIR` folder-grouped (mirrors `recounting_dashboard.py`'s `/api/mp4-files` shape). Selecting one and pressing LOAD MP4 makes `POST /api/load-mp4` copy it into `UPLOAD_DIR` first (chunked, progress reported via `GET /api/copy-progress` → `{active,name,total,done,pct}`, frontend polls every 250 ms to drive the progress bar). Name collision in `UPLOAD_DIR` → 400 error (no overwrite). The OUTPUT_DIR browser is read-only (no delete).
- **Recount STOP sweep**: `POST /api/stop-recount` kills the tracked process group (`killpg` TERM→5s→KILL) then sweeps the host via `/proc` for any process whose argv matches the `RECOUNT_CMD` binary name (basename of the first token, so it also catches orphans from a dashboard restart); TERM→`_SWEEP_GRACE_SEC`→KILL with `_SWEEP_MAX_RETRIES` retries, all under `_recount_lock`. Returns `{success, killed, remaining}`; `remaining` is 0 in the normal path, and survivors (e.g. D-state) are logged rather than force-hanging.
- **Upload filename contract**: uploads are expected as `batch_XX_YYYYMMDD_HHmmSS.mp4` (XX = batch number, timestamp = date). `recounting_dashboard_upload.py` parses both to query the live API's `/api/day-detail/<YYYY-MM-DD>` for the recorded count.
- **`RECOUNT_CMD`** in the upload dashboard is a template with a `{path}` placeholder, e.g. `bytetrack-counter-cpp config.env --source {path}`; the uploaded file path is `shlex.quote()`d before substitution, and the command is spawned by **LOAD MP4** (loads + pauses until `.continue` is touched).
- **Cross-dashboard header links**: `UPLOAD_DASHBOARD_URL` (live → upload dashboard, `counter_dashboard.py`) and `LIVE_DASHBOARD_URL` (upload → live dashboard, `recounting_dashboard_upload.py`); empty value hides the header link.
## Code conventions ## Code conventions
- All config lives in `.env` (dotenv), read via `os.getenv()` at module top-level in each script. - All config lives in `.env` (dotenv), read via `os.getenv()` at module top-level in each script.
- The 3 counter scripts duplicate ~80% of each other (drawing helpers, batch loop, etc). Changes to logic may need replication across variants. - The 2 counter scripts share drawing/batch helpers. `counter_live_rknn_bytetrack.py` is the reference for C++ port.
- `batch_store.py` has its own threading (cutoff watcher, batch timeout timer) — thread safety is via a single `state_lock`. - `batch_store.py` has its own threading (cutoff watcher, batch timeout timer) — thread safety is via a single `state_lock`.
- The dashboard re-creates DB tables on startup (`_ensure_db()`) independently from `batch_store.py`. - The dashboard re-creates DB tables on startup (`_ensure_db()`) independently from `batch_store.py`.
- No formal version tracking exists anywhere in this codebase. - No formal version tracking exists anywhere in this codebase.
- Dashboard template selectable via `DASHBOARD_TEMPLATE` env var — supports `dashboard.html`, `dashboard_lamborghini.html`, `dashboard_tesla.html`.
+288
View File
@@ -0,0 +1,288 @@
# Design System Inspired by Lamborghini
## 1. Visual Theme & Atmosphere
Lamborghini's website is a cathedral of darkness — a digital stage where jet-black surfaces stretch infinitely and every element emerges from the void like a machine under a spotlight. The page is almost entirely black. Not dark gray, not near-black — true, uncompromising black (`#000000`) that saturates the viewport and refuses to yield. Into this abyss, white type and Lamborghini Gold (`#FFC000`) are deployed with surgical precision, creating a visual language that feels like walking through a nighttime motorsport event where every surface absorbs light except the things that matter.
The hero is a full-viewport video — dark, cinematic, immersive — showing event footage or vehicle reveals with the Lamborghini bull logo floating ethereally above. The navigation is minimal: a centered bull logo, a "MENU" hamburger on the left, and search/bookmark icons on the right, all rendered in white against the black canvas. There are no borders, no visible nav containers, no background color on the header — just white marks floating in darkness. The overall mood is nocturnal luxury: exclusive, theatrical, and deliberately intimidating. Each section transition is a scroll through darkness into the next revelation.
Typography is the voice of this darkness. LamboType — a custom Neo-Grotesk typeface created by Character Type and design agency Strichpunkt — is used for everything from 120px uppercase display headlines to 10px micro labels. Its distinctive 12° angled terminals are inspired by the aerodynamic lines of Lamborghini's super sports cars, and its proportions range from Normal to Ultracompressed width. Headlines SHOUT in uppercase at enormous scales with tight line-heights (0.92 at 120px), creating dense blocks of text that feel stamped from steel. The typeface carries hexagonal geometric DNA — constructed from hexagons, three-armed stars, and circles — that echoes throughout the interface in the hexagonal pause button and UI icons. Built on Bootstrap grid with 68 Element Plus/UI components, the technical infrastructure is substantial beneath the theatrical surface.
**Key Characteristics:**
- True black (`#000000`) dominant surfaces with white and gold as the only relief colors
- LamboType custom Neo-Grotesk font with 12° angled terminals inspired by aerodynamic car lines
- Lamborghini Gold (`#FFC000`) as the sole accent color — used exclusively for primary CTA buttons
- All-uppercase display typography at extreme scales (120px, 80px, 54px) with tight line-heights
- Full-viewport video heroes with cinematic event/vehicle content
- Zero border-radius on buttons — sharp, angular, uncompromising rectangles
- Hexagonal motifs in UI elements (pause button, icon system) echoing brand geometry
- Bootstrap grid system + Element Plus/UI 68 components underneath
- Transparent ghost buttons with white borders at 50% opacity as the secondary CTA pattern
## 2. Color Palette & Roles
### Primary
- **Lamborghini Gold** (`#FFC000`): The signature accent color — a warm, saturated amber-gold (rgb 255, 192, 0) used exclusively for primary action buttons ("Discover More", "Tickets", "Start Configuration"). The only chromatic color in the entire interface, it ignites against the black canvas like a headlight cutting through night
- **Pure White** (`#FFFFFF`): Primary text color on dark surfaces, logo rendering, nav elements, and light-mode button fills — the voice that speaks from the darkness
### Secondary & Accent
- **Dark Gold** (`#917300`): Hover/pressed state for gold buttons — a deep amber (rgb 145, 115, 0) that darkens the gold to signal interaction
- **Gold Text** (`#FFCE3E`): Slightly lighter gold variant (rgb 255, 206, 62) used for inline text accents and highlighted labels
- **Cyan Pulse** (`#29ABE2`): Electric blue-cyan (rgb 41, 171, 226) appearing as an informational accent and interactive element highlight
- **Link Blue** (`#3860BE`): Medium blue (rgb 56, 96, 190) used universally for link hover states across all text colors
### Surface & Background
- **Absolute Black** (`#000000`): The dominant surface color — used for page background, hero sections, header, footer, and most containers
- **Charcoal** (`#202020`): Elevated dark surface (rgb 32, 32, 32) — the primary "dark gray" for cards, panels, and text containers sitting above the black canvas
- **Dark Iron** (`#181818`): Subtle surface variant (rgb 24, 24, 24) — barely distinguishable from black, used for footer and deep sections
- **Overlay Black** (`rgba(0,0,0,0.7)`): Semi-transparent overlay for modals and video dimming
- **Near White** (`#F8F8F8`): Rare light surface (rgb 248, 248, 248) for content blocks in white-mode sections
- **Mist** (`#E6E6E6`): Light gray surface for secondary light-mode containers
### Neutrals & Text
- **Pure White** (`#FFFFFF`): Primary text on dark backgrounds — headlines, body, nav labels
- **Smoke** (`#F5F5F5`): Secondary text on dark surfaces — slightly softer than pure white
- **Graphite** (`#494949`): Dark gray text on light surfaces (rgb 73, 73, 73)
- **Ash** (`#7D7D7D`): Mid-range gray for muted text, timestamps, and metadata (rgb 125, 125, 125)
- **Steel** (`#969696`): Lighter gray for disabled text and subtle labels (rgb 150, 150, 150)
- **Slate** (`#666666`): Alternative mid-gray for secondary content
- **Iron** (`#555555`): Dark mid-gray for body text variants
- **Shadow** (`#313131`): Very dark gray for text on dark surfaces where white is too strong
### Semantic & Accent
- **Cyan Pulse** (`#29ABE2`): Used for informational highlights and interactive feedback
- **Link Blue** (`#3860BE`): Universal hover state for all hyperlinks
- **Teal Action** (`#1EAEDB`): Button hover background for transparent/ghost variants (rgb 30, 174, 219)
### Gradient System
- No explicit gradients in the color palette — the dark-to-light progression is achieved through surface layering: `#000000` → `#181818` → `#202020` → `#494949` → `#7D7D7D`
- Video heroes use natural atmospheric gradients from the content itself
- Top-of-page gradient: subtle dark-to-darker fade at the edges of full-bleed imagery
## 3. Typography Rules
### Font Family
- **Display & UI**: `LamboType`, Roboto, Helvetica Neue, Arial — custom Neo-Grotesk typeface by Character Type for Lamborghini's 2024 brand refresh. Available in widths from Normal to Ultracompressed and weights from Light (300) to Black. Features 12° angled terminals inspired by aerodynamic car geometry, hexagonal construction logic, and support for 200+ languages including Latin, Cyrillic, and Greek
- **Fallback/UI**: `Open Sans` — used for some button/form contexts as system fallback
- **No italic variants** observed on the marketing site — the brand voice is always upright
### Hierarchy
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Hero Display | 120px (7.50rem) | 400 | 0.92 | normal | LamboType, uppercase, maximum impact |
| Display 2 | 80px (5.00rem) | 400 | 1.13 | normal | LamboType, uppercase, major section titles |
| Section Title | 54px (3.38rem) | 400 | 1.19 | normal | LamboType, uppercase |
| Sub-section | 40px (2.50rem) | 400 | 1.15 | normal | LamboType, uppercase |
| Feature Heading | 27px (1.69rem) | 400 | 1.37 | normal | LamboType, uppercase |
| Card Title | 24px (1.50rem) | 400 | — | normal | LamboType |
| Body Large | 18px (1.13rem) | 400 | 1.56 | normal | LamboType, mixed case and uppercase variants |
| Body / UI | 16px (1.00rem) | 400/700 | 1.50 | normal/0.16px | LamboType, primary body text |
| Button Large | 16px (1.00rem) | 400 | 1.50 | normal | Gold CTA buttons |
| Button Standard | 14.4px (0.90rem) | 300/700 | 1.00 | 0.14–0.2px | LamboType, uppercase, ghost buttons |
| Button Small | 13px (0.81rem) | 300/500 | 1.20 | 0.13–0.2px | LamboType, compact button variant |
| Caption | 14px (0.88rem) | 600/700 | 1.14–1.50 | -0.42px | LamboType, uppercase, negative tracking |
| Label | 12px (0.75rem) | 400/500 | 1.83 | 0.96px | LamboType, uppercase badges and micro labels |
| Micro | 10px (0.63rem) | 400 | 1.00–2.00 | 0.225px | LamboType, uppercase, smallest text |
### Principles
- **ALL-CAPS is the default voice**: Display and feature headings are universally uppercase. This creates a shouting, commanding tone that matches the brand's aggression
- **Extreme scale range**: From 120px heroes to 10px micro labels — a 12:1 ratio that creates dramatic visual hierarchy
- **Tight line-heights at scale**: Display sizes use 0.92-1.19 line-height, creating dense, compressed blocks of type that feel stamped rather than typeset
- **Weight 400 dominates**: Unlike many design systems that use bold for emphasis, Lamborghini's regular weight carries the headlines — the typeface itself is so distinctive it doesn't need weight variation
- **Negative tracking on captions**: -0.42px letter-spacing on 14px captions creates a compressed, technical aesthetic
- **Positive tracking on micro text**: +0.225px at 10px ensures legibility at the smallest sizes
- **Single typeface discipline**: LamboType handles everything — the 12° angled terminals and hexagonal geometry provide visual coherence across all sizes
## 4. Component Stylings
### Buttons
All buttons use **zero border-radius** — sharp, angular rectangles that echo the aggressive lines of Lamborghini vehicles.
**Gold Accent CTA** — The primary action:
- Default: bg `#FFC000` (Lamborghini Gold), text `#000000`, padding 24px, fontSize 16px, fontWeight 400, borderRadius 0px, no border
- Hover: bg `#917300` (Dark Gold), darkens significantly
- Class: `btn-accent btn-large`
- Used for: "Discover More", "Tickets", "Start Configuration"
**Transparent Ghost** — The secondary action on dark backgrounds:
- Default: bg transparent, text `#FFFFFF`, border 1px solid `#FFFFFF`, padding 16px, opacity 0.5
- Hover: bg `#1EAEDB` (Teal Action), text white, opacity 0.7
- Focus: bg `#1EAEDB`, border 1px solid `#000000`, outline 2px solid `#000000`
- Used for: secondary CTAs on hero sections and dark panels
**White Filled** — Light-mode primary:
- Default: bg `#FFFFFF`, text `#202020`, no border
- Used for: CTAs on dark sections where gold isn't appropriate
**Black Filled** — Dark filled variant:
- Default: bg `#000000`, text `#202020`
- Used for: Inverted CTA on light sections
**Gray Neutral** — Subtle action:
- Default: bg `#969696`, text `#202020`
- Used for: secondary/tertiary actions, badge-like buttons
### Cards & Containers
- Background: `#202020` (Charcoal) on black canvas, or `#000000` on lighter sections
- Border: `0px 1px solid #202020` bottom borders for section dividers
- Border-radius: 0px (completely sharp corners)
- Shadow: minimal, uses overlay opacity for depth
- Content: full-bleed photography + overlaid text in white
### Inputs & Forms
- Minimal form presence on the marketing site
- Switch elements: border-radius 20px (the only rounded element), border 1px solid `#DDDDDD`
- Cookie banner input style: white text on black with `#7D7D7D` borders
### Navigation
- **Desktop**: Centered bull logo, "MENU" hamburger with icon on left, search icon + bookmarks icon on right
- **Background**: Transparent (inherits black page background)
- **Sticky**: Fixed to top, floats above content
- **No visible borders or shadows** — elements float in the darkness
- **"MENU" label**: White text at 14px weight 400, uppercase, accompanies hamburger icon
- **Hexagonal motifs**: Pause button on hero sections uses hexagonal outline shape
### Image Treatment
- **Hero**: Full-viewport video sections (100vh) with cinematic event/vehicle footage
- **Event photography**: Full-bleed aerial shots of Lamborghini Arena events
- **Vehicle imagery**: High-contrast studio shots on dark backgrounds, full-width
- **Aspect ratios**: Predominantly 16:9 and wider for cinematic feel
- **Dark gradient overlays**: Subtle darkening at top/bottom edges of video to ensure text legibility
### Distinctive Components
- **Hexagonal Pause Button**: Video control uses a hexagonal outline (matching the brand's geometric DNA from the typeface), positioned bottom-right of hero sections
- **Progress Bar**: Thin white line at bottom of hero sections indicating video/slide progress
- **Badge/Tag**: bg `#969696`, text white, padding 8px, fontSize 10px, borderRadius 2px — tiny metallic pills
## 5. Layout Principles
### Spacing System
- **Base unit**: 8px
- **Full scale**: 2px, 4px, 5px, 8px, 10px, 12px, 15px, 16px, 20px, 24px, 32px, 40px, 48px, 56px
- **Button padding**: 16px (ghost), 24px (gold accent)
- **Section padding**: 48–56px vertical, 40px horizontal
- **Small spacing**: 2–5px for fine adjustments (badge padding, border spacing)
### Grid & Container
- **Framework**: Bootstrap grid system (container + row + col)
- **Max width**: 1440px (largest breakpoint)
- **Columns**: Standard 12-column Bootstrap grid
- **Full-bleed**: Hero sections break out of grid to fill viewport edge-to-edge
- **Content areas**: Centered within 1200px max-width containers
### Whitespace Philosophy
Lamborghini uses darkness as whitespace. The generous black expanses between content blocks serve the same function as white space in a light design — creating breathing room that elevates each element to the status of exhibit. A model name floating in the middle of a black viewport has the same visual weight as a gallery piece on a white wall. The absence of color IS the design.
### Border Radius Scale
| Value | Context |
|-------|---------|
| 0px | Default for everything — buttons, cards, containers, images |
| 1px | Subtle span elements |
| 2px | Badges, close buttons, cookie elements — barely perceptible |
| 20px | Toggle switches only — the sole rounded element |
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Level 0 (Abyss) | `#000000` flat | Page background, deepest layer |
| Level 1 (Surface) | `#181818` or `#202020` | Cards, content panels, elevated sections |
| Level 2 (Overlay) | `rgba(0,0,0,0.7)` | Modal backdrops, video dimming |
| Level 3 (Fog) | `rgba(0,0,0,0.5)` | Lighter overlays, hover states |
| Level 4 (Mist) | `rgba(0,0,0,0.25)` | Subtle depth hints |
### Shadow Philosophy
Lamborghini achieves depth through surface color layering rather than shadows. On a black canvas, traditional drop shadows are invisible — instead, the system creates elevation by shifting from absolute black to progressively lighter dark grays: `#000000` → `#181818` → `#202020` → `#494949`. This "darkness gradient" approach means that elevated elements are literally lighter than their surroundings, inverting the traditional shadow model.
### Decorative Depth
- Full-bleed video provides atmospheric depth through cinematic lighting
- The hexagonal pause button floats with a thin white outline stroke
- Progress bars at hero section bottoms create a subtle horizon line
- No gradients, glows, or blur effects on UI elements — the photography provides all visual richness
## 7. Do's and Don'ts
### Do
- Use absolute black (`#000000`) as the primary background — never dark gray as a substitute
- Apply Lamborghini Gold (`#FFC000`) exclusively for primary CTA buttons — never for decorative purposes
- Set all display headings in uppercase with LamboType — the brand voice is always SHOUTING
- Use zero border-radius on buttons and cards — sharp angles are non-negotiable
- Maintain tight line-heights (0.92–1.19) on display type to create dense, architectural text blocks
- Use the transparent ghost button (white border, 50% opacity) as the secondary CTA on dark backgrounds
- Let full-viewport video/photography carry emotional weight — UI is infrastructure, not decoration
- Reserve hexagonal geometry for UI icons and the video control button
- Use weight 400 (regular) for headlines — the typeface is distinctive enough without bold emphasis
- Keep the gray palette achromatic — all neutrals are pure gray without color tinting
### Don't
- Introduce additional accent colors beyond gold — the monochrome-plus-gold system is sacred
- Apply border-radius to buttons or cards — curved edges contradict the angular vehicle aesthetic
- Use LamboType in italic or decorative styles — the brand is always upright and direct
- Add gradients to buttons or surfaces — depth comes from surface layering, not blending
- Use light backgrounds as the primary canvas — darkness is the default state, light is the exception
- Mix lowercase into display headings — the uppercase convention communicates authority and power
- Add hover animations with scale or translate — interactions should be color-only (background/opacity shifts)
- Use Open Sans for display text — LamboType must handle all visible typography
- Create busy layouts with many small elements — Lamborghini's design is about singular, bold statements
- Apply shadows to elements — on a black canvas, shadows are meaningless; use surface color shifts instead
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <425px | Single column, reduced type scale, stacked buttons |
| Mobile | 425-576px | Single column, hamburger nav, hero text ~40px |
| Tablet Small | 576-768px | 2-column grid begins, padding adjusts |
| Tablet | 768-1024px | 2-column layout, expanded hero, vehicle cards side-by-side |
| Desktop | 1024-1280px | Full navigation, 3+ column grids, display text at 80px |
| Desktop Large | 1280-1440px | Full layout, hero at 120px display, max-width containers |
| Wide | >1440px | Content centered, margins expand, hero fills viewport |
### Touch Targets
- Gold CTA buttons: 48px+ minimum height with 24px padding (exceeds WCAG 44×44px)
- Ghost buttons: 48px+ with 16px padding
- Hamburger menu: large touch target (~48px square)
- Hexagonal pause button: approximately 48px diameter
### Collapsing Strategy
- **Navigation**: Always hamburger-based ("MENU" + icon) — no horizontal nav expansion on any breakpoint
- **Hero video**: Maintains full-viewport height across all breakpoints, adjusting object-fit
- **Display type**: Scales from 120px (desktop) → 80px (tablet) → 54px/40px (mobile)
- **Button layout**: Side-by-side on desktop, stacks vertically on mobile
- **Grid columns**: 3-column → 2-column → 1-column progression
- **Section spacing**: Reduces from 56px → 40px → 24px vertical padding
### Image Behavior
- Hero videos use `object-fit: cover` to maintain cinematic framing at all sizes
- Vehicle images scale within their containers with maintained aspect ratios
- Event photography crops to viewport width on narrow screens
- Background images darken at edges to maintain text contrast on all viewports
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: "Lamborghini Gold (#FFC000)"
- Background: "Absolute Black (#000000)"
- Surface: "Charcoal (#202020)"
- Heading text: "Pure White (#FFFFFF)"
- Body text: "Ash (#7D7D7D)"
- Link hover: "Link Blue (#3860BE)"
- Accent: "Cyan Pulse (#29ABE2)"
- Border: "Pure White (#FFFFFF) at 50% opacity"
### Example Component Prompts
- "Create a hero section with a full-viewport black background, the model name 'TEMERARIO' in LamboType at 120px uppercase weight 400 white text with 0.92 line-height, centered vertically, with a Lamborghini Gold (#FFC000) 'Discover More' button below — sharp corners, 0px radius, 24px padding, black text"
- "Design a transparent ghost button with 1px solid white border at 50% opacity, white text at 14.4px uppercase with 0.2px letter-spacing, padding 16px, on a black background — hover state changes to Teal Action (#1EAEDB) background with 70% opacity"
- "Build a navigation bar with zero visible background on absolute black, a centered bull logo, 'MENU' text label with hamburger icon on the left, and search + bookmark icons on the right — all in white, sticky position"
- "Create a news card grid on charcoal (#202020) background with white headlines at 27px uppercase, body text in #7D7D7D at 16px, and a white underlined 'Read More' link that turns #3860BE on hover"
- "Design a section divider using a 1px solid bottom border in #202020 on a black canvas — the elevation difference is purely through surface color shift, not shadow"
### Iteration Guide
When refining existing screens generated with this design system:
1. Focus on ONE component at a time — Lamborghini's system is extreme and every element must feel aggressive
2. Reference specific color names and hex codes from this document — the palette has only about 5 active colors
3. Use natural language descriptions, not CSS values — "sharp-cut golden rectangle" not "border-radius: 0px; background: #FFC000"
4. Describe the desired "feel" alongside specific measurements — "floating in total darkness" communicates the black canvas better than "background: #000000"
5. Remember that UPPERCASE IS THE DEFAULT — if text isn't uppercase at display sizes, it probably should be
+59 -9
View File
@@ -13,10 +13,10 @@ Daily cutoff @ HH:MM resets batch numbering. RK3588 hardware.
- `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2) - `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2)
- `PYTHONNOUSERSITE=1` ! set or venv breaks - `PYTHONNOUSERSITE=1` ! set or venv breaks
- SQLite for persistence, JSON file for active-batch state - SQLite for persistence, JSON file for active-batch state
- Flask on port 5000 (dashboard) + 5002 (recounting), systemd supervision - Flask on port 5000 (dashboard) + 5002 (recounting) + 5003 (upload recounting), systemd supervision
- `counter_live.py` ⊥ run on RK3588 — Jetson TensorRT artifact, ! port target - `counter_live.py` ⊥ run on RK3588 — Jetson TensorRT artifact, ! port target
- `counter_live_rknn_bytetrack.py` — reference for C++ port, this is what systemd runs - `counter_live_rknn_bytetrack.py` — reference for C++ port, this is what systemd runs
- Single deployment: `counter_live_rknn_bytetrack.py` + `counter_dashboard.py` + `recounting_dashboard.py` - Single deployment: `counter_live_rknn_bytetrack.py` + `counter_dashboard.py` + `recounting_dashboard.py` + `recounting_dashboard_upload.py`
- `go2rtc` ! running for recounting MP4 streaming (port 1984) - `go2rtc` ! running for recounting MP4 streaming (port 1984)
## §I — Interfaces ## §I — Interfaces
@@ -54,6 +54,7 @@ env: MIN_OBJECT_PER_BATCH → min count to persist batch (default 60)
env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60) env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60)
env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames
env: EXPORT_CSV → write per-crossing CSV (default true) env: EXPORT_CSV → write per-crossing CSV (default true)
env: UPLOAD_DASHBOARD_URL → header link to upload recounting dashboard (default empty → hidden)
### Recounting dashboard config ### Recounting dashboard config
``` ```
@@ -63,6 +64,16 @@ env: RECOUNT_API_URL → base URL of recounting counter API (second node)
env: GO2RTC_API_URL → go2rtc REST API (default http://localhost:1984) env: GO2RTC_API_URL → go2rtc REST API (default http://localhost:1984)
env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount") env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount")
``` ```
### Upload recounting dashboard config
```
env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003)
env: UPLOAD_DIR → dir for uploaded MP4s (default <repo>/uploads)
env: OUTPUT_DIR → dir scanned for "From Output Directory" browser (default /opt/bytetrack-counter)
env: RECOUNT_CMD → replay command template with {path} placeholder (default "bytetrack-counter config.env --source {path}")
env: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload_lamborghini.html)
env: LIVE_DASHBOARD_URL → header link to live counter dashboard (default empty → hidden)
```
``` ```
### SQLite ### SQLite
@@ -91,17 +102,40 @@ api: GET /api/recent-batches?limit=N → [ {date, batch#, count, start/end, dura
api: GET /api/available-dates → [ {date, total_count, total_batches} ] api: GET /api/available-dates → [ {date, total_count, total_batches} ]
api: GET /api/export-daily-csv?days=N → .xlsx download (named csv, emits xlsx) api: GET /api/export-daily-csv?days=N → .xlsx download (named csv, emits xlsx)
api: GET /api/export-day-csv/<date> → .xlsx download (named csv, emits xlsx) api: GET /api/export-day-csv/<date> → .xlsx download (named csv, emits xlsx)
api: POST /api/reset → delete STATE_FILE, touch {SHM_DIR}/.reset; returns 500 if SHM marker fails
api: GET /api/live-video → MJPEG stream from shared-memory JPEG api: GET /api/live-video → MJPEG stream from shared-memory JPEG
### Recounting dashboard ### Recounting dashboard
``` ```
api: GET / → recounting HTML api: GET / → recounting HTML (template via DASHBOARD_TEMPLATE env var)
api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch
api: GET /api/recount-progress → proxy to RECOUNT_API_URL:/api/current-batch api: GET /api/recount-progress → proxy to RECOUNT_API_URL:/api/current-batch
api: GET /api/batch-result?file=<path> → parse `batch_NN_YYYYMMDD_HHmmSS.mp4`, query live API for count api: GET /api/batch-result?file=<path> → parse batch num from filename, date from folder, query live API
api: GET /api/mp4-files → [ {name, path, size, mtime} ... ] api: GET /api/mp4-files → folders grouped by date, files sorted by batch #
api: POST /api/start-recount {path} → configure go2rtc stream, return {stream_url} api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
api: POST /api/stop-recount → tear down go2rtc stream api: GET /api/proxy-stream → proxy MJPEG from RECOUNT_API_URL:/api/live-video (same-origin, avoids ORB)
api: GET /api/download-mp4?file=<path> → download selected MP4
api: POST /api/start-recount {path} → reset recount counter + launch ffmpeg RTSP, return {stream_url}
api: POST /api/stop-recount → kill ffmpeg + clear current file
```
### Upload recounting dashboard
```
api: GET / → upload recounting HTML (template via RECOUNTING_UPLOAD_TEMPLATE env var)
api: POST /api/upload → multipart .mp4 (≤512MB), saved as original filename in UPLOAD_DIR; returns {name, path, size}
api: GET /api/uploads → list uploaded MP4s sorted by mtime desc → [ {name, path, size, mtime} ]
api: GET /api/mp4-files → list OUTPUT_DIR MP4s grouped by folder (date), files sorted by batch # → [ {folder, files:[{name, path, size, mtime}]} ]
api: GET /api/copy-progress → {active:false} idle | {active, name, total, done, pct} during an in-progress copy
api: POST /api/delete-upload {path} → delete uploaded MP4 (path restricted to UPLOAD_DIR)
api: POST /api/load-mp4 {path} → if path outside UPLOAD_DIR: collision check (same-name in UPLOAD_DIR → 400), else copy (chunked, progress-reporting) into UPLOAD_DIR first; then kill old process, spawn RECOUNT_CMD (copied path shlex-quoted) paused, reset recount API; returns copied path
api: POST /api/start-recount → require loaded file, touch {SHM_DIR}/.continue to signal C++ process to begin counting
api: POST /api/stop-recount → killpg tracked group + host-wide /proc sweep of RECOUNT_CMD binary (TERM→grace→KILL, verify loop); returns {success, killed, remaining}
api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
api: GET /api/proxy-stream → proxy MJPEG from RECOUNT_API_URL:/api/live-video (same-origin)
api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch
api: GET /api/recount-progress → proxy to RECOUNT_API_URL:/api/current-batch
api: GET /api/batch-result?file=<path> → parse batch num + date (YYYY-MM-DD) from filename batch_XX_YYYYMMDD_HHmmSS.mp4, query live API /api/day-detail/<date>, fallback recent-batches
api: GET /api/download-mp4?file=<path> → download selected MP4
``` ```
``` ```
@@ -127,6 +161,13 @@ V16: NMS postprocessing → iou_thr=0.45, class-aware grouping, score > CONF
V17: tracks pruned after TRACKED_PRUNE_SEC (default 300s) without update V17: tracks pruned after TRACKED_PRUNE_SEC (default 300s) without update
V18: .env missing → scripts fail at import (os.getenv falls back to defaults, may mismatch) V18: .env missing → scripts fail at import (os.getenv falls back to defaults, may mismatch)
V19: stream reconnect → reset motion detection state (prev_gray = None) V19: stream reconnect → reset motion detection state (prev_gray = None)
V20: reset → delete STATE_FILE + touch {SHM_DIR}/.reset; batch_store._check_reset_signal() watches marker & clears in-memory state before next crossing
V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batch-result queries /api/day-detail/<YYYY-MM-DD> on live API
V22: preview auto-on once per streaming session — enablePreview() on START RECOUNT + applyState streaming branch guarded by previewAutoOn flag; manual toggle persists until next session (flag reset only on stop); img.onerror retries cache-busted src while streaming
V23: .continue marker flow — LOAD MP4 spawns RECOUNT_CMD paused; START RECOUNT touches {SHM_DIR}/.continue; C++ process consumes marker to begin counting; dashboard never clears it (LOAD alone does not count)
V24: STOP → killpg tracked group then host-wide /proc sweep of RECOUNT_CMD binary (basename of first token); sweep runs under _recount_lock, TERM→_SWEEP_GRACE_SEC→KILL, bounded retries; returns {success, killed, remaining} with remaining==0 in normal path; survivors (D-state) logged, never force-hang
V25: LOAD MP4 on an OUTPUT_DIR file → copy into UPLOAD_DIR (original untouched); copy runs server-side, chunked, reports {done,total} to _copy_progress under _copy_lock; name collision in UPLOAD_DIR → 400 error, no overwrite
V26: OUTPUT_DIR browser → read-only, folder-grouped via /api/mp4-files (mirrors 5002 shape); no delete button on OUTPUT_DIR rows
``` ```
## §T — Tasks ## §T — Tasks
@@ -137,14 +178,22 @@ T1|.|unify install paths — README/DEPLOY/setup-venv.sh/uninstall say `/opt/jet
T2|.|zombie var RESET_COUNTERS_AT_CUTOFF — defined in .env.example, unused in code (confirmed by review)| T2|.|zombie var RESET_COUNTERS_AT_CUTOFF — defined in .env.example, unused in code (confirmed by review)|
T3|.|add version flag — no `--version` or git-derived version exists| T3|.|add version flag — no `--version` or git-derived version exists|
T4|.|keep reference Python clean — counter_live.py is artifact; focus edits on counter_live_rknn_bytetrack.py as port source| T4|.|keep reference Python clean — counter_live.py is artifact; focus edits on counter_live_rknn_bytetrack.py as port source|
T5|.|rename export routes — routes named `export-daily-csv` but emit `.xlsx`|I.flask T5|x|rename export routes — routes named `export-daily-csv` but emit `.xlsx`|I.flask
T6|.|fix live-stream snapshot path — some scripts default `/dev/shm/jetson-counter/`, example says `bytetrack-counter`| T6|x|fix live-stream snapshot path — some scripts default `/dev/shm/jetson-counter/`, example says `bytetrack-counter`|
T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)| T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)|
T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)| T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)|
T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1 T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1
T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator| T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|
T11|x|reduce live-counter poll interval 2000→200ms for real-time feel| T11|x|reduce live-counter poll interval 2000→200ms for real-time feel|
T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc streaming| T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc streaming|
T13|x|add template system — DASHBOARD_TEMPLATE env var selects dashboard.html|lamborghini|tesla|
T14|x|harden reset API — separate SHM marker creation from state file cleanup; return 500 on SHM failure|V20
T15|x|add upload recounting dashboard — MP4 upload, progress bar, RECOUNT_CMD replay|V21
T16|x|add cross-dashboard header links — UPLOAD_DASHBOARD_URL (live→upload) & LIVE_DASHBOARD_URL (upload→live)|I.config
T17|x|auto-on live preview when recount running — enablePreview() on START + applyState; onerror retry while streaming|V22
T18|x|split recount flow — /api/load-mp4 spawns paused RECOUNT_CMD, /api/start-recount touches {SHM_DIR}/.continue; C++ consumes marker|V23
T19|x|hard-kill STOP — sweep all RECOUNT_CMD-binary processes via /proc, verify remaining==0, report killed/remaining|V24
T20|x|add OUTPUT_DIR browser to upload dashboard — /api/mp4-files listing, copy-on-load with progress polling into UPLOAD_DIR, collision guard|V25,V26
``` ```
## §B — Bugs ## §B — Bugs
@@ -152,5 +201,6 @@ T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc
``` ```
id|date|cause|fix id|date|cause|fix
B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19 B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19
B2|2026-08-10|applyState re-ran enablePreview() every 1s poll, overriding manual STREAM toggle (OFF flipped back to ON)|V22
``` ```
+13 -4
View File
@@ -3,12 +3,16 @@ Production batch persistence for edge Jetson counter.
Mirrors frigate-counter SQLite schema + current_batch.json contract. Mirrors frigate-counter SQLite schema + current_batch.json contract.
""" """
import json import json
import os
import sqlite3 import sqlite3
import threading import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
_SHMD = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter")
_RESET_FLAG = Path(f"{_SHMD}/.reset")
class BatchStore: class BatchStore:
def __init__( def __init__(
@@ -195,11 +199,16 @@ class BatchStore:
self.log('Ignore batch label cooldown finished') self.log('Ignore batch label cooldown finished')
def _check_reset_signal(self): def _check_reset_signal(self):
reset_file = Path(str(self.state_file) + ".reset") if _RESET_FLAG.exists():
if reset_file.exists(): _RESET_FLAG.unlink(missing_ok=True)
reset_file.unlink(missing_ok=True)
if self.current_state is not None: if self.current_state is not None:
self.log("External reset signal — clearing in-memory state") self.log("External reset signal (SHM marker) — clearing in-memory state")
self.current_state = None
if self.batch_timer:
self.batch_timer.cancel()
self.batch_timer = None
elif self.current_state is not None and not self.state_file.exists():
self.log("External reset signal (state file deleted) — clearing in-memory state")
self.current_state = None self.current_state = None
if self.batch_timer: if self.batch_timer:
self.batch_timer.cancel() self.batch_timer.cancel()
@@ -0,0 +1,34 @@
[Unit]
Description=Edge Counter Recounting Upload Dashboard (Flask, port 5003)
Documentation=file:///opt/bytetrack-counter/DEPLOY.md
After=network-online.target bytetrack-counter-dashboard.service
Wants=network-online.target bytetrack-counter-dashboard.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/bytetrack-counter
EnvironmentFile=/opt/bytetrack-counter/.env
Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
Environment=PYTHONNOUSERSITE=1
Environment=FLASK_DEBUG=false
ExecStart=/opt/bytetrack-counter/venv/bin/python recounting_dashboard_upload.py
TimeoutStopSec=15
KillSignal=SIGTERM
Restart=on-failure
RestartSec=5
StartLimitInterval=60s
StartLimitBurst=3
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=false
[Install]
WantedBy=multi-user.target
+22
View File
@@ -11,6 +11,8 @@ OUTPUT_DIR=/opt/bytetrack-counter
DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db
# JSON file persisting the current active batch state # JSON file persisting the current active batch state
STATE_FILE=/tmp/bytetrack_current_batch.json STATE_FILE=/tmp/bytetrack_current_batch.json
# Shared memory directory used for reset signal (touch .reset to clear counter state)
SHM_DIR=/dev/shm/bytetrack-counter
# --- Input source --- # --- Input source ---
# RTSP / HTTP live stream, or a local video file path # RTSP / HTTP live stream, or a local video file path
@@ -149,12 +151,16 @@ LIVE_STREAM_QUALITY=75
LIVE_STREAM_EVERY_N=2 LIVE_STREAM_EVERY_N=2
# --- Dashboard (counter_dashboard.py) --- # --- Dashboard (counter_dashboard.py) ---
# Template file: "dashboard.html" (default), "dashboard_lamborghini.html", or "dashboard_tesla.html"
DASHBOARD_TEMPLATE=dashboard.html
# Flask secret key for session/cookie signing — change in production! # Flask secret key for session/cookie signing — change in production!
SECRET_KEY=change-me-in-production SECRET_KEY=change-me-in-production
# Bind address for the Flask web server # Bind address for the Flask web server
DASHBOARD_HOST=0.0.0.0 DASHBOARD_HOST=0.0.0.0
# Listen port for the dashboard web UI # Listen port for the dashboard web UI
DASHBOARD_PORT=5000 DASHBOARD_PORT=5000
# URL of the upload recounting dashboard (recounting_dashboard_upload.py); empty hides the header link
UPLOAD_DASHBOARD_URL=http://localhost:5003
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production # Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
FLASK_DEBUG=false FLASK_DEBUG=false
# Fallback name for the active-batch JSON state file used by the dashboard # Fallback name for the active-batch JSON state file used by the dashboard
@@ -173,3 +179,19 @@ GO2RTC_URL=http://localhost:1984
RTSP_PORT=8554 RTSP_PORT=8554
# Stream name pushed to RTSP and consumed from go2rtc # Stream name pushed to RTSP and consumed from go2rtc
RECOUNT_STREAM_NAME=stream_1 RECOUNT_STREAM_NAME=stream_1
# --- Recounting Upload Dashboard (recounting_dashboard_upload.py) ---
# Bind port for the upload-first recount dashboard (distinct from 5002)
RECOUNTING_UPLOAD_PORT=5003
# Directory where uploaded MP4 files are stored
UPLOAD_DIR=/opt/bytetrack-counter/uploads
# Directory scanned for the "From Output Directory" file browser (folder-grouped MP4s).
# Selecting one of those files and pressing LOAD MP4 copies it into UPLOAD_DIR first.
OUTPUT_DIR=/opt/bytetrack-counter
# Command launched by LOAD MP4 (bytetrack-counter-cpp); {path} is the uploaded MP4.
# The process loads + pauses until START RECOUNT touches {SHM_DIR}/.continue.
RECOUNT_CMD=bytetrack-counter-cpp config.env --source {path}
# Template file for the upload dashboard: "recounting_upload_lamborghini.html" (default) or "recounting_upload.html"
RECOUNTING_UPLOAD_TEMPLATE=recounting_upload_lamborghini.html
# URL of the live counter dashboard (counter_dashboard.py); empty hides the header link
LIVE_DASHBOARD_URL=http://localhost:5000
+38 -6
View File
@@ -6,11 +6,13 @@ Default port 5000 (replaces frigate-counter dashboard role).
""" """
import json import json
import logging
import os import os
import sqlite3 import sqlite3
import time import time
from io import BytesIO from io import BytesIO
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
@@ -21,6 +23,9 @@ from werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
app = Flask(__name__, template_folder="templates") app = Flask(__name__, template_folder="templates")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production") app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
@@ -31,8 +36,11 @@ CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00")) CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg") LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter")
SITE_NAME = os.getenv("SITE_NAME", "LIVE") SITE_NAME = os.getenv("SITE_NAME", "LIVE")
TEMPLATE = os.getenv("DASHBOARD_TEMPLATE", "dashboard.html")
UPLOAD_DASHBOARD_URL = os.getenv("UPLOAD_DASHBOARD_URL", "")
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000")) DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
@@ -125,7 +133,7 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
@app.route("/") @app.route("/")
def index(): def index():
return render_template("dashboard.html", site_name=SITE_NAME) return render_template(TEMPLATE, site_name=SITE_NAME, upload_dashboard_url=UPLOAD_DASHBOARD_URL)
@app.route("/api/current-batch") @app.route("/api/current-batch")
@@ -550,12 +558,36 @@ def export_day_xlsx(date):
@app.route("/api/reset", methods=["POST"]) @app.route("/api/reset", methods=["POST"])
def api_reset(): def api_reset():
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset API called")
try: try:
Path(CURRENT_BATCH_PATH).unlink(missing_ok=True) paths = {CURRENT_BATCH_PATH}
Path(str(CURRENT_BATCH_PATH) + ".reset").touch() sf = os.getenv("STATE_FILE")
except Exception: if sf:
pass paths.add(sf)
return jsonify({"success": True, "message": "State file cleared"}) cb = os.getenv("CURRENT_BATCH_PATH")
if cb:
paths.add(cb)
for p in paths:
Path(p).unlink(missing_ok=True)
Path(LIVE_STREAM_FRAME_PATH).unlink(missing_ok=True)
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset state file cleanup error: {e}")
try:
Path(SHM_DIR).mkdir(parents=True, exist_ok=True)
Path(f"{SHM_DIR}/.reset").touch()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset done — cleared {len(paths)} state file(s) + live frame + SHM reset flag at {SHM_DIR}/.reset")
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset SHM error: {e}")
return jsonify({"success": False, "error": f"SHM reset failed: {e}"}), 500
reset_path = f"{SHM_DIR}/.reset"
return jsonify({
"success": True,
"message": "State + live frame cleared",
"shm_reset": reset_path,
"shm_exists": Path(reset_path).exists(),
})
if __name__ == "__main__": if __name__ == "__main__":
+288
View File
@@ -0,0 +1,288 @@
# Design System Inspired by Lamborghini
## 1. Visual Theme & Atmosphere
Lamborghini's website is a cathedral of darkness — a digital stage where jet-black surfaces stretch infinitely and every element emerges from the void like a machine under a spotlight. The page is almost entirely black. Not dark gray, not near-black — true, uncompromising black (`#000000`) that saturates the viewport and refuses to yield. Into this abyss, white type and Lamborghini Gold (`#FFC000`) are deployed with surgical precision, creating a visual language that feels like walking through a nighttime motorsport event where every surface absorbs light except the things that matter.
The hero is a full-viewport video — dark, cinematic, immersive — showing event footage or vehicle reveals with the Lamborghini bull logo floating ethereally above. The navigation is minimal: a centered bull logo, a "MENU" hamburger on the left, and search/bookmark icons on the right, all rendered in white against the black canvas. There are no borders, no visible nav containers, no background color on the header — just white marks floating in darkness. The overall mood is nocturnal luxury: exclusive, theatrical, and deliberately intimidating. Each section transition is a scroll through darkness into the next revelation.
Typography is the voice of this darkness. LamboType — a custom Neo-Grotesk typeface created by Character Type and design agency Strichpunkt — is used for everything from 120px uppercase display headlines to 10px micro labels. Its distinctive 12° angled terminals are inspired by the aerodynamic lines of Lamborghini's super sports cars, and its proportions range from Normal to Ultracompressed width. Headlines SHOUT in uppercase at enormous scales with tight line-heights (0.92 at 120px), creating dense blocks of text that feel stamped from steel. The typeface carries hexagonal geometric DNA — constructed from hexagons, three-armed stars, and circles — that echoes throughout the interface in the hexagonal pause button and UI icons. Built on Bootstrap grid with 68 Element Plus/UI components, the technical infrastructure is substantial beneath the theatrical surface.
**Key Characteristics:**
- True black (`#000000`) dominant surfaces with white and gold as the only relief colors
- LamboType custom Neo-Grotesk font with 12° angled terminals inspired by aerodynamic car lines
- Lamborghini Gold (`#FFC000`) as the sole accent color — used exclusively for primary CTA buttons
- All-uppercase display typography at extreme scales (120px, 80px, 54px) with tight line-heights
- Full-viewport video heroes with cinematic event/vehicle content
- Zero border-radius on buttons — sharp, angular, uncompromising rectangles
- Hexagonal motifs in UI elements (pause button, icon system) echoing brand geometry
- Bootstrap grid system + Element Plus/UI 68 components underneath
- Transparent ghost buttons with white borders at 50% opacity as the secondary CTA pattern
## 2. Color Palette & Roles
### Primary
- **Lamborghini Gold** (`#FFC000`): The signature accent color — a warm, saturated amber-gold (rgb 255, 192, 0) used exclusively for primary action buttons ("Discover More", "Tickets", "Start Configuration"). The only chromatic color in the entire interface, it ignites against the black canvas like a headlight cutting through night
- **Pure White** (`#FFFFFF`): Primary text color on dark surfaces, logo rendering, nav elements, and light-mode button fills — the voice that speaks from the darkness
### Secondary & Accent
- **Dark Gold** (`#917300`): Hover/pressed state for gold buttons — a deep amber (rgb 145, 115, 0) that darkens the gold to signal interaction
- **Gold Text** (`#FFCE3E`): Slightly lighter gold variant (rgb 255, 206, 62) used for inline text accents and highlighted labels
- **Cyan Pulse** (`#29ABE2`): Electric blue-cyan (rgb 41, 171, 226) appearing as an informational accent and interactive element highlight
- **Link Blue** (`#3860BE`): Medium blue (rgb 56, 96, 190) used universally for link hover states across all text colors
### Surface & Background
- **Absolute Black** (`#000000`): The dominant surface color — used for page background, hero sections, header, footer, and most containers
- **Charcoal** (`#202020`): Elevated dark surface (rgb 32, 32, 32) — the primary "dark gray" for cards, panels, and text containers sitting above the black canvas
- **Dark Iron** (`#181818`): Subtle surface variant (rgb 24, 24, 24) — barely distinguishable from black, used for footer and deep sections
- **Overlay Black** (`rgba(0,0,0,0.7)`): Semi-transparent overlay for modals and video dimming
- **Near White** (`#F8F8F8`): Rare light surface (rgb 248, 248, 248) for content blocks in white-mode sections
- **Mist** (`#E6E6E6`): Light gray surface for secondary light-mode containers
### Neutrals & Text
- **Pure White** (`#FFFFFF`): Primary text on dark backgrounds — headlines, body, nav labels
- **Smoke** (`#F5F5F5`): Secondary text on dark surfaces — slightly softer than pure white
- **Graphite** (`#494949`): Dark gray text on light surfaces (rgb 73, 73, 73)
- **Ash** (`#7D7D7D`): Mid-range gray for muted text, timestamps, and metadata (rgb 125, 125, 125)
- **Steel** (`#969696`): Lighter gray for disabled text and subtle labels (rgb 150, 150, 150)
- **Slate** (`#666666`): Alternative mid-gray for secondary content
- **Iron** (`#555555`): Dark mid-gray for body text variants
- **Shadow** (`#313131`): Very dark gray for text on dark surfaces where white is too strong
### Semantic & Accent
- **Cyan Pulse** (`#29ABE2`): Used for informational highlights and interactive feedback
- **Link Blue** (`#3860BE`): Universal hover state for all hyperlinks
- **Teal Action** (`#1EAEDB`): Button hover background for transparent/ghost variants (rgb 30, 174, 219)
### Gradient System
- No explicit gradients in the color palette — the dark-to-light progression is achieved through surface layering: `#000000` → `#181818` → `#202020` → `#494949` → `#7D7D7D`
- Video heroes use natural atmospheric gradients from the content itself
- Top-of-page gradient: subtle dark-to-darker fade at the edges of full-bleed imagery
## 3. Typography Rules
### Font Family
- **Display & UI**: `LamboType`, Roboto, Helvetica Neue, Arial — custom Neo-Grotesk typeface by Character Type for Lamborghini's 2024 brand refresh. Available in widths from Normal to Ultracompressed and weights from Light (300) to Black. Features 12° angled terminals inspired by aerodynamic car geometry, hexagonal construction logic, and support for 200+ languages including Latin, Cyrillic, and Greek
- **Fallback/UI**: `Open Sans` — used for some button/form contexts as system fallback
- **No italic variants** observed on the marketing site — the brand voice is always upright
### Hierarchy
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Hero Display | 120px (7.50rem) | 400 | 0.92 | normal | LamboType, uppercase, maximum impact |
| Display 2 | 80px (5.00rem) | 400 | 1.13 | normal | LamboType, uppercase, major section titles |
| Section Title | 54px (3.38rem) | 400 | 1.19 | normal | LamboType, uppercase |
| Sub-section | 40px (2.50rem) | 400 | 1.15 | normal | LamboType, uppercase |
| Feature Heading | 27px (1.69rem) | 400 | 1.37 | normal | LamboType, uppercase |
| Card Title | 24px (1.50rem) | 400 | — | normal | LamboType |
| Body Large | 18px (1.13rem) | 400 | 1.56 | normal | LamboType, mixed case and uppercase variants |
| Body / UI | 16px (1.00rem) | 400/700 | 1.50 | normal/0.16px | LamboType, primary body text |
| Button Large | 16px (1.00rem) | 400 | 1.50 | normal | Gold CTA buttons |
| Button Standard | 14.4px (0.90rem) | 300/700 | 1.00 | 0.14–0.2px | LamboType, uppercase, ghost buttons |
| Button Small | 13px (0.81rem) | 300/500 | 1.20 | 0.13–0.2px | LamboType, compact button variant |
| Caption | 14px (0.88rem) | 600/700 | 1.14–1.50 | -0.42px | LamboType, uppercase, negative tracking |
| Label | 12px (0.75rem) | 400/500 | 1.83 | 0.96px | LamboType, uppercase badges and micro labels |
| Micro | 10px (0.63rem) | 400 | 1.00–2.00 | 0.225px | LamboType, uppercase, smallest text |
### Principles
- **ALL-CAPS is the default voice**: Display and feature headings are universally uppercase. This creates a shouting, commanding tone that matches the brand's aggression
- **Extreme scale range**: From 120px heroes to 10px micro labels — a 12:1 ratio that creates dramatic visual hierarchy
- **Tight line-heights at scale**: Display sizes use 0.92-1.19 line-height, creating dense, compressed blocks of type that feel stamped rather than typeset
- **Weight 400 dominates**: Unlike many design systems that use bold for emphasis, Lamborghini's regular weight carries the headlines — the typeface itself is so distinctive it doesn't need weight variation
- **Negative tracking on captions**: -0.42px letter-spacing on 14px captions creates a compressed, technical aesthetic
- **Positive tracking on micro text**: +0.225px at 10px ensures legibility at the smallest sizes
- **Single typeface discipline**: LamboType handles everything — the 12° angled terminals and hexagonal geometry provide visual coherence across all sizes
## 4. Component Stylings
### Buttons
All buttons use **zero border-radius** — sharp, angular rectangles that echo the aggressive lines of Lamborghini vehicles.
**Gold Accent CTA** — The primary action:
- Default: bg `#FFC000` (Lamborghini Gold), text `#000000`, padding 24px, fontSize 16px, fontWeight 400, borderRadius 0px, no border
- Hover: bg `#917300` (Dark Gold), darkens significantly
- Class: `btn-accent btn-large`
- Used for: "Discover More", "Tickets", "Start Configuration"
**Transparent Ghost** — The secondary action on dark backgrounds:
- Default: bg transparent, text `#FFFFFF`, border 1px solid `#FFFFFF`, padding 16px, opacity 0.5
- Hover: bg `#1EAEDB` (Teal Action), text white, opacity 0.7
- Focus: bg `#1EAEDB`, border 1px solid `#000000`, outline 2px solid `#000000`
- Used for: secondary CTAs on hero sections and dark panels
**White Filled** — Light-mode primary:
- Default: bg `#FFFFFF`, text `#202020`, no border
- Used for: CTAs on dark sections where gold isn't appropriate
**Black Filled** — Dark filled variant:
- Default: bg `#000000`, text `#202020`
- Used for: Inverted CTA on light sections
**Gray Neutral** — Subtle action:
- Default: bg `#969696`, text `#202020`
- Used for: secondary/tertiary actions, badge-like buttons
### Cards & Containers
- Background: `#202020` (Charcoal) on black canvas, or `#000000` on lighter sections
- Border: `0px 1px solid #202020` bottom borders for section dividers
- Border-radius: 0px (completely sharp corners)
- Shadow: minimal, uses overlay opacity for depth
- Content: full-bleed photography + overlaid text in white
### Inputs & Forms
- Minimal form presence on the marketing site
- Switch elements: border-radius 20px (the only rounded element), border 1px solid `#DDDDDD`
- Cookie banner input style: white text on black with `#7D7D7D` borders
### Navigation
- **Desktop**: Centered bull logo, "MENU" hamburger with icon on left, search icon + bookmarks icon on right
- **Background**: Transparent (inherits black page background)
- **Sticky**: Fixed to top, floats above content
- **No visible borders or shadows** — elements float in the darkness
- **"MENU" label**: White text at 14px weight 400, uppercase, accompanies hamburger icon
- **Hexagonal motifs**: Pause button on hero sections uses hexagonal outline shape
### Image Treatment
- **Hero**: Full-viewport video sections (100vh) with cinematic event/vehicle footage
- **Event photography**: Full-bleed aerial shots of Lamborghini Arena events
- **Vehicle imagery**: High-contrast studio shots on dark backgrounds, full-width
- **Aspect ratios**: Predominantly 16:9 and wider for cinematic feel
- **Dark gradient overlays**: Subtle darkening at top/bottom edges of video to ensure text legibility
### Distinctive Components
- **Hexagonal Pause Button**: Video control uses a hexagonal outline (matching the brand's geometric DNA from the typeface), positioned bottom-right of hero sections
- **Progress Bar**: Thin white line at bottom of hero sections indicating video/slide progress
- **Badge/Tag**: bg `#969696`, text white, padding 8px, fontSize 10px, borderRadius 2px — tiny metallic pills
## 5. Layout Principles
### Spacing System
- **Base unit**: 8px
- **Full scale**: 2px, 4px, 5px, 8px, 10px, 12px, 15px, 16px, 20px, 24px, 32px, 40px, 48px, 56px
- **Button padding**: 16px (ghost), 24px (gold accent)
- **Section padding**: 48–56px vertical, 40px horizontal
- **Small spacing**: 2–5px for fine adjustments (badge padding, border spacing)
### Grid & Container
- **Framework**: Bootstrap grid system (container + row + col)
- **Max width**: 1440px (largest breakpoint)
- **Columns**: Standard 12-column Bootstrap grid
- **Full-bleed**: Hero sections break out of grid to fill viewport edge-to-edge
- **Content areas**: Centered within 1200px max-width containers
### Whitespace Philosophy
Lamborghini uses darkness as whitespace. The generous black expanses between content blocks serve the same function as white space in a light design — creating breathing room that elevates each element to the status of exhibit. A model name floating in the middle of a black viewport has the same visual weight as a gallery piece on a white wall. The absence of color IS the design.
### Border Radius Scale
| Value | Context |
|-------|---------|
| 0px | Default for everything — buttons, cards, containers, images |
| 1px | Subtle span elements |
| 2px | Badges, close buttons, cookie elements — barely perceptible |
| 20px | Toggle switches only — the sole rounded element |
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Level 0 (Abyss) | `#000000` flat | Page background, deepest layer |
| Level 1 (Surface) | `#181818` or `#202020` | Cards, content panels, elevated sections |
| Level 2 (Overlay) | `rgba(0,0,0,0.7)` | Modal backdrops, video dimming |
| Level 3 (Fog) | `rgba(0,0,0,0.5)` | Lighter overlays, hover states |
| Level 4 (Mist) | `rgba(0,0,0,0.25)` | Subtle depth hints |
### Shadow Philosophy
Lamborghini achieves depth through surface color layering rather than shadows. On a black canvas, traditional drop shadows are invisible — instead, the system creates elevation by shifting from absolute black to progressively lighter dark grays: `#000000` → `#181818` → `#202020` → `#494949`. This "darkness gradient" approach means that elevated elements are literally lighter than their surroundings, inverting the traditional shadow model.
### Decorative Depth
- Full-bleed video provides atmospheric depth through cinematic lighting
- The hexagonal pause button floats with a thin white outline stroke
- Progress bars at hero section bottoms create a subtle horizon line
- No gradients, glows, or blur effects on UI elements — the photography provides all visual richness
## 7. Do's and Don'ts
### Do
- Use absolute black (`#000000`) as the primary background — never dark gray as a substitute
- Apply Lamborghini Gold (`#FFC000`) exclusively for primary CTA buttons — never for decorative purposes
- Set all display headings in uppercase with LamboType — the brand voice is always SHOUTING
- Use zero border-radius on buttons and cards — sharp angles are non-negotiable
- Maintain tight line-heights (0.92–1.19) on display type to create dense, architectural text blocks
- Use the transparent ghost button (white border, 50% opacity) as the secondary CTA on dark backgrounds
- Let full-viewport video/photography carry emotional weight — UI is infrastructure, not decoration
- Reserve hexagonal geometry for UI icons and the video control button
- Use weight 400 (regular) for headlines — the typeface is distinctive enough without bold emphasis
- Keep the gray palette achromatic — all neutrals are pure gray without color tinting
### Don't
- Introduce additional accent colors beyond gold — the monochrome-plus-gold system is sacred
- Apply border-radius to buttons or cards — curved edges contradict the angular vehicle aesthetic
- Use LamboType in italic or decorative styles — the brand is always upright and direct
- Add gradients to buttons or surfaces — depth comes from surface layering, not blending
- Use light backgrounds as the primary canvas — darkness is the default state, light is the exception
- Mix lowercase into display headings — the uppercase convention communicates authority and power
- Add hover animations with scale or translate — interactions should be color-only (background/opacity shifts)
- Use Open Sans for display text — LamboType must handle all visible typography
- Create busy layouts with many small elements — Lamborghini's design is about singular, bold statements
- Apply shadows to elements — on a black canvas, shadows are meaningless; use surface color shifts instead
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <425px | Single column, reduced type scale, stacked buttons |
| Mobile | 425-576px | Single column, hamburger nav, hero text ~40px |
| Tablet Small | 576-768px | 2-column grid begins, padding adjusts |
| Tablet | 768-1024px | 2-column layout, expanded hero, vehicle cards side-by-side |
| Desktop | 1024-1280px | Full navigation, 3+ column grids, display text at 80px |
| Desktop Large | 1280-1440px | Full layout, hero at 120px display, max-width containers |
| Wide | >1440px | Content centered, margins expand, hero fills viewport |
### Touch Targets
- Gold CTA buttons: 48px+ minimum height with 24px padding (exceeds WCAG 44×44px)
- Ghost buttons: 48px+ with 16px padding
- Hamburger menu: large touch target (~48px square)
- Hexagonal pause button: approximately 48px diameter
### Collapsing Strategy
- **Navigation**: Always hamburger-based ("MENU" + icon) — no horizontal nav expansion on any breakpoint
- **Hero video**: Maintains full-viewport height across all breakpoints, adjusting object-fit
- **Display type**: Scales from 120px (desktop) → 80px (tablet) → 54px/40px (mobile)
- **Button layout**: Side-by-side on desktop, stacks vertically on mobile
- **Grid columns**: 3-column → 2-column → 1-column progression
- **Section spacing**: Reduces from 56px → 40px → 24px vertical padding
### Image Behavior
- Hero videos use `object-fit: cover` to maintain cinematic framing at all sizes
- Vehicle images scale within their containers with maintained aspect ratios
- Event photography crops to viewport width on narrow screens
- Background images darken at edges to maintain text contrast on all viewports
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: "Lamborghini Gold (#FFC000)"
- Background: "Absolute Black (#000000)"
- Surface: "Charcoal (#202020)"
- Heading text: "Pure White (#FFFFFF)"
- Body text: "Ash (#7D7D7D)"
- Link hover: "Link Blue (#3860BE)"
- Accent: "Cyan Pulse (#29ABE2)"
- Border: "Pure White (#FFFFFF) at 50% opacity"
### Example Component Prompts
- "Create a hero section with a full-viewport black background, the model name 'TEMERARIO' in LamboType at 120px uppercase weight 400 white text with 0.92 line-height, centered vertically, with a Lamborghini Gold (#FFC000) 'Discover More' button below — sharp corners, 0px radius, 24px padding, black text"
- "Design a transparent ghost button with 1px solid white border at 50% opacity, white text at 14.4px uppercase with 0.2px letter-spacing, padding 16px, on a black background — hover state changes to Teal Action (#1EAEDB) background with 70% opacity"
- "Build a navigation bar with zero visible background on absolute black, a centered bull logo, 'MENU' text label with hamburger icon on the left, and search + bookmark icons on the right — all in white, sticky position"
- "Create a news card grid on charcoal (#202020) background with white headlines at 27px uppercase, body text in #7D7D7D at 16px, and a white underlined 'Read More' link that turns #3860BE on hover"
- "Design a section divider using a 1px solid bottom border in #202020 on a black canvas — the elevation difference is purely through surface color shift, not shadow"
### Iteration Guide
When refining existing screens generated with this design system:
1. Focus on ONE component at a time — Lamborghini's system is extreme and every element must feel aggressive
2. Reference specific color names and hex codes from this document — the palette has only about 5 active colors
3. Use natural language descriptions, not CSS values — "sharp-cut golden rectangle" not "border-radius: 0px; background: #FFC000"
4. Describe the desired "feel" alongside specific measurements — "floating in total darkness" communicates the black canvas better than "background: #000000"
5. Remember that UPPERCASE IS THE DEFAULT — if text isn't uppercase at display sizes, it probably should be
+71 -17
View File
@@ -9,6 +9,7 @@ import os
import re import re
import subprocess import subprocess
import threading import threading
import time
import requests import requests
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -33,6 +34,7 @@ RTSP_PORT = os.getenv("RTSP_PORT", "8554")
STREAM_NAME = os.getenv("RECOUNT_STREAM_NAME", "stream_1") STREAM_NAME = os.getenv("RECOUNT_STREAM_NAME", "stream_1")
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT") SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
TEMPLATE = os.getenv("DASHBOARD_TEMPLATE", "recounting.html")
DASHBOARD_PORT = int(os.getenv("RECOUNTING_DASHBOARD_PORT", "5002")) DASHBOARD_PORT = int(os.getenv("RECOUNTING_DASHBOARD_PORT", "5002"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
@@ -41,7 +43,7 @@ FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
_http_session = requests.Session() _http_session = requests.Session()
_http_session.timeout = 3 _http_session.timeout = 3
_FILENAME_RE = re.compile(r"batch[_-](\d+)[_-](\d{8})[_-]\d{6}\.mp4", re.IGNORECASE) _FILENAME_RE = re.compile(r"batch[_-]?(\d+)", re.IGNORECASE)
_streaming_mp4 = None _streaming_mp4 = None
_streaming_lock = threading.Lock() _streaming_lock = threading.Lock()
@@ -50,12 +52,9 @@ _current_file = None
def _parse_filename(filename): def _parse_filename(filename):
m = _FILENAME_RE.match(filename) m = _FILENAME_RE.search(filename)
if m: if m:
batch_num = int(m.group(1)) return int(m.group(1)), None
date_str = m.group(2)
date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
return batch_num, date_formatted
return None, None return None, None
@@ -69,6 +68,15 @@ def _api_get(base_url, path, default=None):
return default return default
def _folder_date(filepath):
output = Path(OUTPUT_DIR)
rel = Path(filepath).relative_to(output)
parts = rel.parts
if len(parts) >= 2:
return parts[0]
return None
def _stop_ffmpeg_locked(): def _stop_ffmpeg_locked():
global _streaming_proc, _streaming_mp4 global _streaming_proc, _streaming_mp4
if _streaming_proc: if _streaming_proc:
@@ -83,7 +91,7 @@ def _stop_ffmpeg_locked():
@app.route("/") @app.route("/")
def index(): def index():
return render_template("recounting.html", site_name=SITE_NAME, output_dir=OUTPUT_DIR) return render_template(TEMPLATE, site_name=SITE_NAME, output_dir=OUTPUT_DIR)
@app.route("/api/live-progress") @app.route("/api/live-progress")
@@ -104,10 +112,20 @@ def api_recount_progress():
@app.route("/api/mp4-files") @app.route("/api/mp4-files")
def api_mp4_files(): def api_mp4_files():
files = [] folders = {}
output = Path(OUTPUT_DIR) output = Path(OUTPUT_DIR)
if output.exists(): if not output.exists():
for f in sorted(output.rglob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True): return jsonify([])
for d in sorted(output.iterdir(), reverse=True):
if not d.is_dir():
continue
folder_name = d.name
mp4s = sorted(d.glob("*.mp4"), key=lambda p: _parse_filename(p.name)[0] or 0)
if not mp4s:
continue
files = []
for f in mp4s:
st = f.stat() st = f.stat()
files.append({ files.append({
"name": f.name, "name": f.name,
@@ -115,7 +133,12 @@ def api_mp4_files():
"size": st.st_size, "size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
}) })
return jsonify(files) folders[folder_name] = files
result = []
for folder, files in sorted(folders.items(), reverse=True):
result.append({"folder": folder, "files": files})
return jsonify(result)
@app.route("/api/start-recount", methods=["POST"]) @app.route("/api/start-recount", methods=["POST"])
@@ -132,9 +155,15 @@ def start_recount():
_stop_ffmpeg_locked() _stop_ffmpeg_locked()
try: try:
_http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5) resp = _http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5)
except Exception: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → {resp.status_code}")
pass time.sleep(1)
except requests.exceptions.Timeout:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → timeout")
except requests.exceptions.ConnectionError as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → unreachable: {e}")
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → error: {e}")
with _streaming_lock: with _streaming_lock:
_current_file = mp4_path _current_file = mp4_path
@@ -150,12 +179,33 @@ def start_recount():
return jsonify({ return jsonify({
"success": True, "success": True,
"stream_url": f"{RECOUNT_API_URL}/api/live-video", "stream_url": "/api/proxy-stream",
"file": Path(mp4_path).name, "file": Path(mp4_path).name,
"path": mp4_path, "path": mp4_path,
}) })
@app.route("/api/proxy-stream")
def api_proxy_stream():
try:
resp = _http_session.get(f"{RECOUNT_API_URL}/api/live-video", stream=True, timeout=5)
if resp.status_code != 200:
return Response("stream unavailable", status=502)
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
resp.close()
return Response(
generate(),
mimetype=resp.headers.get("Content-Type", "multipart/x-mixed-replace; boundary=frame"),
)
except Exception:
return Response("stream unavailable", status=502)
@app.route("/api/stop-recount", methods=["POST"]) @app.route("/api/stop-recount", methods=["POST"])
def stop_recount(): def stop_recount():
global _current_file global _current_file
@@ -177,7 +227,7 @@ def api_state():
if current_file: if current_file:
result["file"] = current_file result["file"] = current_file
result["file_name"] = Path(current_file).name result["file_name"] = Path(current_file).name
result["stream_url"] = f"{RECOUNT_API_URL}/api/live-video" result["stream_url"] = "/api/proxy-stream"
batch_num, date_str = _parse_filename(Path(current_file).name) batch_num, date_str = _parse_filename(Path(current_file).name)
if batch_num is not None: if batch_num is not None:
result["batch_number"] = batch_num result["batch_number"] = batch_num
@@ -197,10 +247,14 @@ def api_download_mp4():
def api_batch_result(): def api_batch_result():
fpath = request.args.get("file", "") fpath = request.args.get("file", "")
filename = Path(fpath).name filename = Path(fpath).name
batch_num, date_str = _parse_filename(filename) batch_num, _ = _parse_filename(filename)
if batch_num is None: if batch_num is None:
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200 return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
date_str = _folder_date(fpath)
if not date_str:
return jsonify({"success": False, "error": "Cannot determine date from folder"}), 200
def _match_in_batches(batches): def _match_in_batches(batches):
for b in batches: for b in batches:
if b.get("batch_number") == batch_num: if b.get("batch_number") == batch_num:
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
"""
Recounting upload dashboard — upload MP4 then launch recount via commander.
Consumes live counter + recount APIs, proxies recount live-video for preview.
"""
import json
import logging
import os
import re
import shlex
import shutil
import signal
import subprocess
import threading
import time
import requests
from datetime import datetime
from pathlib import Path
from flask import Flask, render_template, jsonify, request, Response, send_file
from werkzeug.serving import WSGIRequestHandler
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
load_dotenv()
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
app = Flask(__name__, template_folder="templates")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB
REPO_ROOT = Path(__file__).resolve().parent
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(REPO_ROOT / "uploads")))
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter"))
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
TEMPLATE = os.getenv("RECOUNTING_UPLOAD_TEMPLATE", "recounting_upload_lamborghini.html")
LIVE_DASHBOARD_URL = os.getenv("LIVE_DASHBOARD_URL", "")
DASHBOARD_PORT = int(os.getenv("RECOUNTING_UPLOAD_PORT", "5003"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {path}")
SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter")
_CONTINUE_FLAG = Path(f"{SHM_DIR}/.continue")
_SWEEP_GRACE_SEC = 5
_SWEEP_MAX_RETRIES = 3
_http_session = requests.Session()
_http_session.timeout = 3
_FILENAME_RE = re.compile(r"batch[_-]?(\d+)[_-](\d{8})[_-]\d{6}", re.IGNORECASE)
_recount_proc = None
_recount_lock = threading.Lock()
_current_file = None
_copy_progress = {}
_copy_lock = threading.Lock()
def _parse_filename(filename):
m = _FILENAME_RE.search(filename)
if m:
raw = m.group(2)
return int(m.group(1)), f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
return None, None
def _api_get(base_url, path, default=None):
try:
resp = _http_session.get(f"{base_url}{path}")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return default
def _stop_recount_locked():
global _recount_proc
proc = _recount_proc
_recount_proc = None
if proc is None:
return
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, PermissionError):
return
try:
os.killpg(pgid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
try:
proc.wait(timeout=3)
except Exception:
pass
except Exception:
pass
def _recount_binary_name():
if not RECOUNT_CMD:
return "bytetrack-counter-cpp"
try:
first = shlex.split(RECOUNT_CMD)[0]
except (ValueError, IndexError):
return "bytetrack-counter-cpp"
return Path(first).name or "bytetrack-counter-cpp"
def _iter_matching_pids(binary):
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/cmdline", "rb") as f:
raw = f.read().split(b"\x00")
except (FileNotFoundError, PermissionError, ProcessLookupError):
continue
args = [t.decode(errors="ignore") for t in raw if t]
for token in args:
for piece in token.split():
if Path(piece).name == binary:
yield int(entry)
break
else:
continue
break
def _sweep_recount_processes(binary):
killed = 0
for _ in range(_SWEEP_MAX_RETRIES):
pids = list(_iter_matching_pids(binary))
if not pids:
break
for pid in pids:
if pid not in list(_iter_matching_pids(binary)):
continue
try:
os.kill(pid, signal.SIGTERM)
killed += 1
except (ProcessLookupError, PermissionError):
pass
time.sleep(_SWEEP_GRACE_SEC)
for pid in list(_iter_matching_pids(binary)):
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
time.sleep(1)
return killed, len(list(_iter_matching_pids(binary)))
def _folder_date(filepath, base):
rel = Path(filepath).resolve().relative_to(base.resolve())
parts = rel.parts
if len(parts) >= 2:
return parts[0]
return None
@app.route("/")
def index():
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR), output_dir=str(OUTPUT_DIR), live_dashboard_url=LIVE_DASHBOARD_URL)
@app.route("/api/live-progress")
def api_live_progress():
data = _api_get(LIVE_API_URL, "/api/current-batch")
if data and data.get("success"):
return jsonify(data)
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Live unreachable"}), 200
@app.route("/api/recount-progress")
def api_recount_progress():
data = _api_get(RECOUNT_API_URL, "/api/current-batch")
if data and data.get("success"):
return jsonify(data)
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Recount unreachable"}), 200
@app.route("/api/uploads")
def api_uploads():
files = []
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
for f in sorted(Path(UPLOAD_DIR).glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
st = f.stat()
files.append({
"name": f.name,
"path": str(f),
"size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
})
return jsonify(files)
@app.route("/api/mp4-files")
def api_mp4_files():
folders = {}
output = Path(OUTPUT_DIR)
if not output.exists():
return jsonify([])
for d in sorted(output.iterdir(), reverse=True):
if not d.is_dir():
continue
folder_name = d.name
mp4s = sorted(d.glob("*.mp4"), key=lambda p: _parse_filename(p.name)[0] or 0)
if not mp4s:
continue
files = []
for f in mp4s:
st = f.stat()
files.append({
"name": f.name,
"path": str(f),
"size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
})
folders[folder_name] = files
result = []
for folder, files in sorted(folders.items(), reverse=True):
result.append({"folder": folder, "files": files})
return jsonify(result)
@app.route("/api/copy-progress")
def api_copy_progress():
with _copy_lock:
p = dict(_copy_progress)
if not p or not p.get("active"):
return jsonify({"active": False})
total = p.get("total") or 0
done = p.get("done") or 0
pct = round(done * 100.0 / total) if total else 0
return jsonify({
"active": True,
"name": p.get("name"),
"total": total,
"done": done,
"pct": pct,
})
@app.route("/api/upload", methods=["POST"])
def api_upload():
if "file" not in request.files:
return jsonify({"success": False, "error": "No file field"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"success": False, "error": "No file selected"}), 400
if not f.filename.lower().endswith(".mp4"):
return jsonify({"success": False, "error": "Only .mp4 files allowed"}), 400
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
dest_name = secure_filename(f.filename)
dest_path = Path(UPLOAD_DIR) / dest_name
f.save(str(dest_path))
st = dest_path.stat()
return jsonify({
"success": True,
"name": dest_name,
"path": str(dest_path),
"size": st.st_size,
})
@app.route("/api/delete-upload", methods=["POST"])
def api_delete_upload():
data = request.get_json(force=True) or {}
fpath = data.get("path", "")
if not fpath or not os.path.isfile(fpath):
return jsonify({"success": False, "error": "File not found"}), 404
f = Path(fpath)
if f.parent.resolve() != Path(UPLOAD_DIR).resolve():
return jsonify({"success": False, "error": "Not in upload dir"}), 403
f.unlink()
return jsonify({"success": True})
def _reset_recount_api():
try:
resp = _http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → {resp.status_code}")
except requests.exceptions.Timeout:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → timeout")
except requests.exceptions.ConnectionError as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → unreachable: {e}")
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → error: {e}")
def _spawn_recount_locked(upload_path):
global _recount_proc
cmd_str = RECOUNT_CMD.replace("{path}", shlex.quote(str(upload_path)))
_recount_proc = subprocess.Popen(cmd_str, shell=True, start_new_session=True)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount loaded: {cmd_str}")
def _copy_file_with_progress(src, dest):
global _copy_progress
total = os.path.getsize(src)
done = 0
with _copy_lock:
_copy_progress = {
"active": True,
"name": Path(src).name,
"total": total,
"done": 0,
"error": None,
}
try:
with open(src, "rb") as fsrc, open(dest, "wb") as fdst:
while True:
buf = fsrc.read(1024 * 1024)
if not buf:
break
fdst.write(buf)
done += len(buf)
with _copy_lock:
_copy_progress["done"] = done
shutil.copystat(src, dest)
except Exception as e:
with _copy_lock:
_copy_progress["error"] = str(e)
raise
finally:
with _copy_lock:
_copy_progress["active"] = False
@app.route("/api/load-mp4", methods=["POST"])
def load_mp4():
global _current_file
data = request.get_json(force=True) or {}
src_path = data.get("path", "")
if not src_path:
return jsonify({"success": False, "error": "Missing 'path'"}), 400
if not os.path.isfile(src_path):
return jsonify({"success": False, "error": f"File not found: {src_path}"}), 404
src_path = str(Path(src_path).resolve())
upload_path = src_path
if Path(src_path).parent != Path(UPLOAD_DIR).resolve():
dest_path = str(Path(UPLOAD_DIR) / Path(src_path).name)
if os.path.exists(dest_path):
return jsonify({
"success": False,
"error": f"{Path(dest_path).name} already exists in uploads, delete it first",
}), 400
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
try:
_copy_file_with_progress(src_path, dest_path)
except Exception as e:
with _copy_lock:
err = _copy_progress.get("error") or str(e)
return jsonify({"success": False, "error": f"Copy failed: {err}"}), 500
upload_path = dest_path
with _recount_lock:
_stop_recount_locked()
_current_file = upload_path
try:
_spawn_recount_locked(upload_path)
except Exception as e:
_current_file = None
return jsonify({"success": False, "error": f"Failed to start: {e}"}), 500
_reset_recount_api()
return jsonify({
"success": True,
"stream_url": "/api/proxy-stream",
"file": Path(upload_path).name,
"path": upload_path,
})
@app.route("/api/start-recount", methods=["POST"])
def start_recount():
with _recount_lock:
current = _current_file
if not current:
return jsonify({"success": False, "error": "Load an MP4 first"}), 400
try:
Path(SHM_DIR).mkdir(parents=True, exist_ok=True)
_CONTINUE_FLAG.touch(exist_ok=True)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount continue: {_CONTINUE_FLAG}")
except Exception as e:
return jsonify({"success": False, "error": f"Failed to signal: {e}"}), 500
return jsonify({
"success": True,
"stream_url": "/api/proxy-stream",
"file": Path(current).name,
"path": current,
})
@app.route("/api/stop-recount", methods=["POST"])
def stop_recount():
global _current_file
with _recount_lock:
_stop_recount_locked()
_current_file = None
killed, remaining = _sweep_recount_processes(_recount_binary_name())
stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if remaining:
print(f"[{stamp}] Recount stop: killed {killed}, remaining {remaining} (SIGKILL could not reap)")
else:
print(f"[{stamp}] Recount stop: killed {killed}, remaining {remaining}")
return jsonify({"success": True, "killed": killed, "remaining": remaining})
@app.route("/api/state")
def api_state():
with _recount_lock:
running = _recount_proc is not None and _recount_proc.poll() is None
current = _current_file
result = {
"streaming": running,
}
if current:
result["file"] = current
result["file_name"] = Path(current).name
result["stream_url"] = "/api/proxy-stream"
batch_num, date_str = _parse_filename(Path(current).name)
if batch_num is not None:
result["batch_number"] = batch_num
result["batch_date"] = date_str
return jsonify(result)
@app.route("/api/proxy-stream")
def api_proxy_stream():
try:
resp = _http_session.get(f"{RECOUNT_API_URL}/api/live-video", stream=True, timeout=5)
if resp.status_code != 200:
return Response("stream unavailable", status=502)
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
resp.close()
return Response(
generate(),
mimetype=resp.headers.get("Content-Type", "multipart/x-mixed-replace; boundary=frame"),
)
except Exception:
return Response("stream unavailable", status=502)
@app.route("/api/batch-result")
def api_batch_result():
fpath = request.args.get("file", "")
filename = Path(fpath).name
batch_num, date_str = _parse_filename(filename)
if batch_num is None:
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
if not date_str:
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
if not date_str:
return jsonify({"success": False, "error": "Cannot determine date from filename"}), 200
def _match_in_batches(batches):
for b in batches:
if b.get("batch_number") == batch_num:
return b
return None
data = _api_get(LIVE_API_URL, f"/api/day-detail/{date_str}")
if data:
b = _match_in_batches(data.get("batches", []))
if b:
return jsonify({
"success": True,
"batch_number": batch_num,
"date": date_str,
"count": b["count"],
"start_time": b.get("start_time"),
"end_time": b.get("end_time"),
})
recent = _api_get(LIVE_API_URL, "/api/recent-batches?limit=100")
if recent:
for entry in recent if isinstance(recent, list) else recent.get("batches", []):
if isinstance(entry, dict) and entry.get("batch_number") == batch_num:
return jsonify({
"success": True,
"batch_number": batch_num,
"date": entry.get("date", ""),
"count": entry["count"],
"start_time": entry.get("start_time"),
"end_time": entry.get("end_time"),
})
return jsonify({"success": False, "error": f"Batch #{batch_num} not found"}), 200
@app.route("/api/download-mp4")
def api_download_mp4():
fpath = request.args.get("file", "")
if not fpath or not os.path.isfile(fpath):
return jsonify({"success": False, "error": "File not found"}), 404
return send_file(fpath, as_attachment=True, download_name=Path(fpath).name)
if __name__ == "__main__":
WSGIRequestHandler.protocol_version = "HTTP/1.1"
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
print(f"Recounting upload dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"Live API: {LIVE_API_URL}")
print(f"Recount API: {RECOUNT_API_URL}")
print(f"Upload dir: {UPLOAD_DIR}")
print(f"Output dir: {OUTPUT_DIR}")
print(f"Recount command: {RECOUNT_CMD}")
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
+19 -10
View File
@@ -287,6 +287,12 @@
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.live-recent-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.panel { .panel {
background: var(--glass); background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
@@ -520,7 +526,7 @@
/* Responsive */ /* Responsive */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.summary-cards { grid-template-columns: repeat(2, 1fr); } .summary-cards { grid-template-columns: repeat(2, 1fr); }
.charts-row { grid-template-columns: 1fr; } .charts-row, .live-recent-row { grid-template-columns: 1fr; }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.container { padding: 16px 12px; } .container { padding: 16px 12px; }
@@ -569,6 +575,7 @@
<span class="date-val" id="current-date">--</span> <span class="date-val" id="current-date">--</span>
Counting Day Counting Day
</div> </div>
{% if upload_dashboard_url %}<a class="live-chip" href="{{ upload_dashboard_url }}" style="text-decoration:none;">RECOUNT UPLOAD</a>{% endif %}
<div class="live-chip"> <div class="live-chip">
<span class="dot"></span> <span class="dot"></span>
<span>{{ site_name }}</span> <span>{{ site_name }}</span>
@@ -615,8 +622,9 @@
<span id="best-date">--</span> <span id="best-date">--</span>
</div> </div>
<!-- Live Video Feed --> <!-- Live Video + Recent Activity -->
<div class="video-panel" id="video-panel"> <div class="live-recent-row">
<div class="video-panel" id="video-panel" style="margin-bottom:0;">
<div class="video-header"> <div class="video-header">
<span class="section-title" style="margin-bottom:0;">&#9673; Live Camera Feed</span> <span class="section-title" style="margin-bottom:0;">&#9673; Live Camera Feed</span>
<div style="display:flex;align-items:center;gap:10px;"> <div style="display:flex;align-items:center;gap:10px;">
@@ -637,6 +645,13 @@
<div class="video-error-overlay" id="video-error-overlay">Connection lost — retrying...</div> <div class="video-error-overlay" id="video-error-overlay">Connection lost — retrying...</div>
</div> </div>
</div> </div>
<div class="panel">
<span class="section-title">&#9673; Recent Activity</span>
<div style="margin-top:16px;" id="recent-batches">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- Charts + Quick Stats --> <!-- Charts + Quick Stats -->
<div class="charts-row"> <div class="charts-row">
@@ -687,13 +702,6 @@
<span class="qs-val" id="avg-per-batch">--</span> <span class="qs-val" id="avg-per-batch">--</span>
</div> </div>
</div> </div>
<div class="recent-section">
<div class="rs-title">&#9673; Recent Activity</div>
<div id="recent-batches">
<!-- Populated by JS -->
</div>
</div>
</div> </div>
</div> </div>
@@ -1170,6 +1178,7 @@ function onVideoLoad() {
} }
function onVideoError() { function onVideoError() {
if (!videoActive) { hideVideoError(); return; }
const overlay = document.getElementById('video-error-overlay'); const overlay = document.getElementById('video-error-overlay');
if (overlay) overlay.classList.add('visible'); if (overlay) overlay.classList.add('visible');
if (videoErrorTimer) clearTimeout(videoErrorTimer); if (videoErrorTimer) clearTimeout(videoErrorTimer);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+155 -51
View File
@@ -68,7 +68,6 @@
max-width: 1600px; margin: 0 auto; padding: 24px 20px; max-width: 1600px; margin: 0 auto; padding: 24px 20px;
} }
/* Header */
.header { .header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; margin-bottom: 24px; padding: 18px 28px; margin-bottom: 24px;
@@ -125,7 +124,6 @@
display: inline-block; display: inline-block;
} }
/* Top row: two counter panels */
.counters-row { .counters-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -172,10 +170,9 @@
color: var(--accent3); background: rgba(255, 45, 120, 0.06); color: var(--accent3); background: rgba(255, 45, 120, 0.06);
} }
/* Bottom row: video + file browser */
.bottom-row { .bottom-row {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -197,10 +194,10 @@
flex: 1; display: flex; align-items: center; justify-content: center; flex: 1; display: flex; align-items: center; justify-content: center;
background: #000; border-radius: 10px; background: #000; border-radius: 10px;
overflow: hidden; position: relative; overflow: hidden; position: relative;
min-height: 280px; aspect-ratio: 4 / 3; max-height: 480px;
} }
.video-container img { .video-container img {
max-width: 100%; max-height: 100%; display: block; width: 100%; height: 100%; object-fit: contain; display: block;
} }
.video-placeholder { .video-placeholder {
text-align: center; color: var(--text-secondary); text-align: center; color: var(--text-secondary);
@@ -226,8 +223,17 @@
.video-status.stopped { .video-status.stopped {
color: var(--text-secondary); background: var(--cell-bg); color: var(--text-secondary); background: var(--cell-bg);
} }
.video-toggle {
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 6px 14px; cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-size: 10px; letter-spacing: 1px;
color: var(--text-secondary);
transition: border-color 0.3s, color 0.3s;
}
.video-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
.video-toggle.active { border-color: var(--accent4); color: var(--accent4); }
/* File browser panel */
.file-panel { .file-panel {
background: var(--glass); background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
@@ -278,7 +284,23 @@
} }
.file-item.streaming .fi-icon { color: var(--accent4); animation: blink 1.5s ease-in-out infinite; } .file-item.streaming .fi-icon { color: var(--accent4); animation: blink 1.5s ease-in-out infinite; }
/* Buttons */ .folder-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; margin-bottom: 2px;
background: var(--cell-bg);
border: 1px solid var(--cell-border); border-radius: 8px;
cursor: pointer; transition: border-color 0.2s;
font-size: 10px; letter-spacing: 1px;
color: var(--accent2);
}
.folder-header:hover { border-color: var(--border-glow); }
.folder-header .folder-arrow {
font-size: 10px; transition: transform 0.2s; width: 14px; text-align: center;
}
.folder-header.open .folder-arrow { transform: rotate(90deg); }
.folder-body { display: none; padding-left: 8px; }
.folder-body.open { display: block; }
.btn { .btn {
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px; font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600; padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
@@ -302,7 +324,6 @@
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; } .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; } .btn-row { display: flex; gap: 8px; margin-top: 12px; }
/* Footer */
.footer { .footer {
margin-top: 24px; text-align: center; margin-top: 24px; text-align: center;
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary); font-size: 9px; letter-spacing: 2px; color: var(--text-secondary);
@@ -328,7 +349,6 @@
<div class="orb orb-2"></div> <div class="orb orb-2"></div>
<div class="container"> <div class="container">
<!-- Header -->
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="logo-icon">&#10006;</div> <div class="logo-icon">&#10006;</div>
@@ -349,7 +369,6 @@
</div> </div>
</div> </div>
<!-- Counter panels -->
<div class="counters-row"> <div class="counters-row">
<div class="panel"> <div class="panel">
<span class="section-title" id="live-title">&#9673; Live Counter</span> <span class="section-title" id="live-title">&#9673; Live Counter</span>
@@ -375,7 +394,6 @@
</div> </div>
</div> </div>
<!-- Bottom: video + file list -->
<div class="bottom-row"> <div class="bottom-row">
<div class="video-panel"> <div class="video-panel">
<div class="video-header"> <div class="video-header">
@@ -385,6 +403,7 @@
<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> <span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span>
stopped stopped
</span> </span>
<button class="video-toggle" id="videoToggle" onclick="togglePreview()">STREAM OFF</button>
</div> </div>
</div> </div>
<div class="video-container" id="video-container"> <div class="video-container" id="video-container">
@@ -430,6 +449,7 @@
let selectedFile = null; let selectedFile = null;
let streaming = false; let streaming = false;
let streamUrl = ''; let streamUrl = '';
let previewAutoOn = false;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRecountProgress(); loadRecountProgress();
@@ -452,67 +472,75 @@ async function syncState() {
function applyState(data) { function applyState(data) {
if (!data) return; if (!data) return;
if (data.file) {
selectedFile = data.file;
document.getElementById('btn-start').disabled = data.streaming;
document.getElementById('btn-download').disabled = false;
}
if (data.streaming && data.file) { if (data.streaming && data.file) {
streaming = true; streaming = true;
streamUrl = data.stream_url; streamUrl = data.stream_url;
const img = document.getElementById('stream-img'); selectedFile = data.file;
const placeholder = document.getElementById('video-placeholder');
if (img.src.indexOf(streamUrl) === -1) {
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
}
document.getElementById('btn-stop').disabled = false; document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-start').disabled = true; document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-start').textContent = 'STREAMING'; document.getElementById('btn-start').textContent = 'STREAMING';
document.getElementById('btn-start').classList.add('btn-ghost'); document.getElementById('btn-start').classList.add('btn-ghost');
document.getElementById('video-status').innerHTML = document.getElementById('video-status').innerHTML =
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + (data.file_name || ''); '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + (data.file_name || '');
document.getElementById('video-status').className = 'video-status connected'; document.getElementById('video-status').className = 'video-status connected';
if (!previewAutoOn) enablePreview();
loadBatchResult(data.file); loadBatchResult(data.file);
} else if (!data.streaming && data.file) { } else if (data.file && selectedFile === data.file) {
previewAutoOn = false;
loadBatchResult(data.file); loadBatchResult(data.file);
} }
loadFiles();
} }
// --- File list ---
async function loadFiles() { async function loadFiles() {
const container = document.getElementById('file-list');
try { try {
const res = await fetch('/api/mp4-files'); const res = await fetch('/api/mp4-files');
const files = await res.json(); const folders = await res.json();
const container = document.getElementById('file-list'); if (!folders || folders.length === 0) {
if (files.length === 0) {
container.innerHTML = '<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">No MP4 files found</div>'; container.innerHTML = '<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">No MP4 files found</div>';
return; return;
} }
container.innerHTML = files.map(f => { container.innerHTML = folders.map((g, gi) => {
const label = g.folder || 'root';
const fid = 'folder-' + gi;
const wasOpen = document.getElementById(fid) && document.getElementById(fid).classList.contains('open');
return `
<div class="folder-header" id="${fid}-hdr" onclick="toggleFolder('${fid}')">
<span class="folder-arrow">&#9654;</span>
<i class="fas fa-folder" style="color:var(--accent2);"></i>
<span>${label}</span>
<span style="font-size:8px;color:var(--text-secondary);">(${g.files.length})</span>
</div>
<div class="folder-body${wasOpen ? ' open' : ''}" id="${fid}">
${g.files.map(f => {
const sel = selectedFile === f.path; const sel = selectedFile === f.path;
const str = streaming && selectedFile === f.path; const str = streaming && selectedFile === f.path;
let cls = 'file-item'; let cls = 'file-item';
if (str) cls += ' streaming'; if (str) cls += ' streaming';
else if (sel) cls += ' selected'; else if (sel) cls += ' selected';
return ` return `
<div class="${cls}" onclick="selectFile('${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')"> <div class="${cls}" onclick="event.stopPropagation(); selectFile('${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')" data-path="${f.path.replace(/"/g, '&quot;')}">
<span class="fi-icon">${str ? '&#9673;' : '&#9654;'}</span> <span class="fi-icon">${str ? '&#9673;' : '&#9654;'}</span>
<span class="fi-name">${f.name}</span> <span class="fi-name">${f.name}</span>
<span class="fi-size">${formatSize(f.size)}</span> <span class="fi-size">${formatSize(f.size)}</span>
</div>`; </div>`;
}).join('')}
</div>`;
}).join(''); }).join('');
} catch (err) { } catch (err) {
console.error('Failed to load files:', err); console.error('Failed to load files:', err);
} }
} }
function toggleFolder(fid) {
const hdr = document.getElementById(fid + '-hdr');
const body = document.getElementById(fid);
if (hdr) hdr.classList.toggle('open');
if (body) body.classList.toggle('open');
}
function selectFile(path, name) { function selectFile(path, name) {
if (selectedFile === path) { if (selectedFile === path) {
selectedFile = null; selectedFile = null;
@@ -526,16 +554,27 @@ function selectFile(path, name) {
document.getElementById('live-time').textContent = '--'; document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected'; document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected'; document.getElementById('live-status').className = 'counter-status disconnected';
loadFiles(); updateFileSelection();
return; return;
} }
selectedFile = path; selectedFile = path;
document.getElementById('btn-download').disabled = false; document.getElementById('btn-download').disabled = false;
if (!streaming) document.getElementById('btn-start').disabled = false; if (!streaming) document.getElementById('btn-start').disabled = false;
loadFiles(); updateFileSelection();
loadBatchResult(path); loadBatchResult(path);
} }
function updateFileSelection() {
document.querySelectorAll('.file-item').forEach(el => {
const path = el.getAttribute('data-path');
if (path === selectedFile) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
let batchResultLoaded = false; let batchResultLoaded = false;
async function loadBatchResult(filePath) { async function loadBatchResult(filePath) {
@@ -597,14 +636,19 @@ function downloadFile() {
document.body.removeChild(a); document.body.removeChild(a);
} }
// --- Streaming ---
async function startRecount() { async function startRecount() {
if (!selectedFile) return; if (!selectedFile) return;
const btnStart = document.getElementById('btn-start'); const btnStart = document.getElementById('btn-start');
btnStart.disabled = true; btnStart.disabled = true;
btnStart.textContent = 'CONNECTING...'; btnStart.textContent = 'CONNECTING...';
lastRecountCount = null;
lastRecountTime = null;
document.getElementById('recount-count').textContent = '0';
document.getElementById('recount-time').textContent = 'resetting...';
document.getElementById('recount-status').textContent = 'resetting';
document.getElementById('recount-status').className = 'counter-status disconnected';
try { try {
const res = await fetch('/api/start-recount', { const res = await fetch('/api/start-recount', {
method: 'POST', method: 'POST',
@@ -622,15 +666,13 @@ async function startRecount() {
streaming = true; streaming = true;
selectedFile = data.path; selectedFile = data.path;
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
document.getElementById('btn-stop').disabled = false; document.getElementById('btn-stop').disabled = false;
btnStart.textContent = 'STREAMING'; btnStart.textContent = 'STREAMING';
btnStart.classList.add('btn-ghost'); btnStart.classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + data.file;
document.getElementById('video-status').className = 'video-status connected';
enablePreview();
} catch (err) { } catch (err) {
alert('Error: ' + err.message); alert('Error: ' + err.message);
btnStart.disabled = false; btnStart.disabled = false;
@@ -641,10 +683,14 @@ async function startRecount() {
async function stopRecount() { async function stopRecount() {
try { try {
await fetch('/api/stop-recount', { method: 'POST' }); await fetch('/api/stop-recount', { method: 'POST' });
} catch (err) {} } catch (err) {
alert('Failed to stop recount');
return;
}
streaming = false; streaming = false;
streamUrl = ''; streamUrl = '';
selectedFile = null; selectedFile = null;
previewAutoOn = false;
batchResultLoaded = false; batchResultLoaded = false;
const img = document.getElementById('stream-img'); const img = document.getElementById('stream-img');
@@ -672,10 +718,60 @@ async function stopRecount() {
document.getElementById('live-status').textContent = 'no file selected'; document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected'; document.getElementById('live-status').className = 'counter-status disconnected';
resetVideoToggle();
loadFiles(); loadFiles();
} }
// --- Recount counter --- function togglePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
if (img.style.display === 'block') {
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
} else {
enablePreview();
}
}
function resetVideoToggle() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
}
function enablePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
previewAutoOn = true;
img.onerror = function() {
if (streaming && streamUrl) {
img.src = streamUrl + '?' + Date.now();
}
};
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON';
btn.classList.add('active');
}
let lastRecountCount = null;
let lastRecountTime = null;
async function loadRecountProgress() { async function loadRecountProgress() {
const countEl = document.getElementById('recount-count'); const countEl = document.getElementById('recount-count');
@@ -685,26 +781,34 @@ async function loadRecountProgress() {
const res = await fetch('/api/recount-progress'); const res = await fetch('/api/recount-progress');
const data = await res.json(); const data = await res.json();
if (!data.success) { if (!data.success) {
if (lastRecountCount === null) {
countEl.textContent = '--'; countEl.textContent = '--';
timeEl.textContent = data.error || 'unreachable'; timeEl.textContent = data.error || 'unreachable';
}
statusEl.className = 'counter-status disconnected'; statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'no connection'; statusEl.textContent = 'completed';
return; return;
} }
countEl.textContent = data.count.toLocaleString(); lastRecountCount = data.count.toLocaleString();
countEl.textContent = lastRecountCount;
if (data.last_detection_time) { if (data.last_detection_time) {
const d = new Date(data.last_detection_time); const d = new Date(data.last_detection_time);
timeEl.textContent = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); lastRecountTime = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
timeEl.textContent = lastRecountTime;
} else if (lastRecountTime) {
timeEl.textContent = lastRecountTime;
} else { } else {
timeEl.textContent = 'waiting...'; timeEl.textContent = 'waiting...';
} }
statusEl.className = 'counter-status connected'; statusEl.className = 'counter-status connected';
statusEl.textContent = 'connected'; statusEl.textContent = 'connected';
} catch (err) { } catch (err) {
if (lastRecountCount === null) {
countEl.textContent = '--'; countEl.textContent = '--';
timeEl.textContent = 'unreachable'; timeEl.textContent = 'unreachable';
}
statusEl.className = 'counter-status disconnected'; statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'no connection'; statusEl.textContent = 'completed';
} }
} }
</script> </script>
+742
View File
@@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} Recounting</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #000000;
--surface: #181818;
--surface2: #202020;
--gold: #FFC000;
--gold-text: #FFCE3E;
--white: #FFFFFF;
--ash: #7D7D7D;
--graphite: #494949;
--divider: #333333;
--success: #00ff88;
}
body {
font-family: 'Helvetica Neue', 'Arial', sans-serif;
background: var(--bg);
color: var(--white);
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 1600px; margin: 0 auto; padding: 32px 40px;
}
/* Header */
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 0; margin-bottom: 40px;
border-bottom: 1px solid var(--divider);
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo-icon {
width: 40px; height: 40px;
background: var(--gold); color: #000;
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700;
}
.header h1 {
font-size: 16px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--white);
}
.header-sub { font-size: 9px; color: var(--ash); letter-spacing: 3px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 24px; }
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 3px; padding: 6px 14px;
text-transform: uppercase; color: var(--white);
border: 1px solid rgba(255,255,255,0.2);
}
.live-chip.live-chip-green { border-color: var(--success); color: var(--success); }
.live-chip .dot {
width: 6px; height: 6px;
}
.live-chip .dot { background: var(--white); }
.live-chip.live-chip-green .dot { background: var(--success); }
.section-title {
font-size: 11px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--ash);
margin-bottom: 20px; display: inline-block;
}
/* Counter panels */
.counters-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.panel {
background: var(--surface);
padding: 32px;
}
.counter-card {
text-align: center;
padding: 20px 0;
}
.counter-card .counter-val {
font-size: 56px; font-weight: 300;
color: var(--white);
line-height: 1;
margin-bottom: 8px;
}
.counter-card.recount .counter-val { color: var(--white); }
.counter-card .counter-label {
font-size: 10px; color: var(--ash); text-transform: uppercase; letter-spacing: 3px;
}
.counter-card .counter-sub {
font-size: 9px; color: var(--graphite); margin-top: 8px;
}
.counter-card .counter-sub .batch-num {
font-size: 18px; font-weight: 600; color: var(--gold-text);
}
.counter-status {
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
margin-top: 12px; padding: 4px 12px; display: inline-block;
}
.counter-status.connected { color: var(--success); }
.counter-status.disconnected { color: var(--ash); }
/* Bottom row: video + file browser */
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.video-panel {
background: var(--surface);
padding: 24px;
display: flex; flex-direction: column;
min-height: 380px;
}
.video-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.video-container {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #000;
overflow: hidden; position: relative;
aspect-ratio: 4 / 3; max-height: 480px;
}
.video-container img {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.video-placeholder {
text-align: center; color: var(--graphite); padding: 40px;
}
.video-placeholder .vp-icon { font-size: 40px; opacity: 0.2; margin-bottom: 12px; }
.video-placeholder .vp-text { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; }
.video-placeholder .vp-hint { font-size: 8px; margin-top: 8px; color: var(--graphite); }
.video-status {
font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.video-status.connected { color: var(--success); }
.video-status.stopped { color: var(--ash); }
.video-toggle {
background: none; border: 1px solid rgba(255,255,255,0.3);
padding: 5px 14px; cursor: pointer;
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
color: var(--ash); transition: border-color 0.3s, color 0.3s;
}
.video-toggle:hover { border-color: var(--white); color: var(--white); }
.video-toggle.active { border-color: var(--success); color: var(--success); }
/* File browser panel */
.file-panel {
background: var(--surface);
padding: 24px;
display: flex; flex-direction: column;
}
.file-panel .fp-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.file-panel .fp-dir {
font-size: 8px; color: var(--ash); letter-spacing: 1px;
word-break: break-all; margin-bottom: 16px;
padding: 8px 12px; background: var(--surface2);
border: 1px solid var(--divider);
}
.file-list {
flex: 1; overflow-y: auto; max-height: 400px;
}
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; margin-bottom: 2px;
background: var(--surface2);
border: 1px solid var(--divider);
cursor: pointer; transition: border-color 0.2s, background 0.2s;
}
.file-item:hover { border-color: var(--white); }
.file-item.selected {
border-color: var(--gold);
background: rgba(255,192,0,0.06);
}
.file-item.streaming {
border-color: var(--success);
background: rgba(0,255,136,0.06);
}
.file-item .fi-name {
font-size: 10px; color: var(--white);
word-break: break-all; flex: 1;
}
.file-item .fi-size {
font-size: 8px; color: var(--graphite);
margin-left: 8px; white-space: nowrap;
}
.file-item .fi-icon {
font-size: 12px; margin-right: 8px;
color: var(--ash);
}
.file-item.streaming .fi-icon { color: var(--success); }
.folder-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; margin-bottom: 2px;
background: var(--surface2);
border: 1px solid var(--divider);
cursor: pointer; transition: border-color 0.2s;
font-size: 10px; letter-spacing: 2px;
color: var(--gold-text); text-transform: uppercase;
}
.folder-header:hover { border-color: var(--white); }
.folder-header .folder-arrow {
font-size: 10px; transition: transform 0.2s; width: 14px; text-align: center;
}
.folder-header.open .folder-arrow { transform: rotate(90deg); }
.folder-body { display: none; padding-left: 8px; }
.folder-body.open { display: block; }
/* Buttons */
.btn {
font-size: 9px; letter-spacing: 3px; text-transform: uppercase;
padding: 10px 20px; cursor: pointer; font-weight: 600; border: none;
transition: all 0.3s;
}
.btn-primary {
background: var(--gold); color: #000;
}
.btn-primary:hover { opacity: 0.85; }
.btn-danger {
background: var(--white); color: #000;
}
.btn-danger:hover { opacity: 0.85; }
.btn-ghost {
background: none; color: var(--ash);
border: 1px solid rgba(255,255,255,0.2);
}
.btn-ghost:hover { border-color: var(--white); color: var(--white); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 16px; }
/* Footer */
.footer {
margin-top: 40px; text-align: center;
font-size: 8px; letter-spacing: 4px; color: var(--graphite); text-transform: uppercase;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--graphite); }
@media (max-width: 900px) {
.counters-row, .bottom-row { grid-template-columns: 1fr; }
.container { padding: 20px 12px; }
.header { flex-direction: column; gap: 12px; }
.counter-card .counter-val { font-size: 40px; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-icon">&#10006;</div>
<div>
<h1>{{ site_name }} Recounting</h1>
<div class="header-sub">Edge counter batch replay</div>
</div>
</div>
<div class="header-right">
<div class="live-chip live-chip-green">
<span class="dot"></span>
<span>LIVE</span>
</div>
<div class="live-chip">
<span class="dot"></span>
<span>RECOUNT</span>
</div>
</div>
</div>
<!-- Counter panels -->
<div class="counters-row">
<div class="panel">
<span class="section-title" id="live-title">Live Counter</span>
<div class="counter-card">
<div class="counter-val" id="live-count">--</div>
<div class="counter-label" id="live-label">Objects Counted</div>
<div class="counter-sub">Batch <span class="batch-num" id="live-batch">--</span> &middot; <span id="live-time">--</span></div>
<div class="counter-status disconnected" id="live-status">
no file selected
</div>
</div>
</div>
<div class="panel">
<span class="section-title">Recount Progress</span>
<div class="counter-card recount">
<div class="counter-val" id="recount-count">--</div>
<div class="counter-label">Objects Recounted</div>
<div class="counter-sub"><span id="recount-time">--</span></div>
<div class="counter-status disconnected" id="recount-status">
waiting...
</div>
</div>
</div>
</div>
<!-- Bottom: video + file list -->
<div class="bottom-row">
<div class="video-panel">
<div class="video-header">
<span class="section-title" style="margin-bottom:0;">Preview</span>
<div style="display:flex;align-items:center;gap:10px;">
<span class="video-status stopped" id="video-status">
<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span>
stopped
</span>
<button class="video-toggle" id="videoToggle" onclick="togglePreview()">STREAM OFF</button>
</div>
</div>
<div class="video-container" id="video-container">
<div class="video-placeholder" id="video-placeholder">
<div class="vp-icon">&#9654;</div>
<div class="vp-text">Select an MP4 file to stream</div>
<div class="vp-hint">Requires go2rtc on this host</div>
</div>
<img id="stream-img" alt="Stream" style="display:none;" />
</div>
</div>
<div class="file-panel">
<div class="fp-header">
<span class="section-title" style="margin-bottom:0;font-size:11px;">Recordings</span>
<button class="btn btn-ghost" onclick="loadFiles()" style="font-size:8px;padding:4px 8px;">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="fp-dir" id="output-dir">{{ output_dir }}</div>
<div class="file-list" id="file-list">
<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;">
Loading files...
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
&#9654; START RECOUNT
</button>
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
&#9632; STOP
</button>
<button class="btn btn-ghost" id="btn-download" onclick="downloadFile()" disabled>
<i class="fas fa-download"></i>
</button>
</div>
</div>
</div>
<div class="footer">{{ site_name }} Recounting Dashboard</div>
</div>
<script>
let selectedFile = null;
let streaming = false;
let streamUrl = '';
let previewAutoOn = false;
document.addEventListener('DOMContentLoaded', () => {
loadRecountProgress();
loadFiles();
syncState();
setInterval(loadRecountProgress, 500);
setInterval(loadFiles, 30000);
setInterval(syncState, 1000);
});
async function syncState() {
try {
const res = await fetch('/api/state');
const data = await res.json();
applyState(data);
} catch (err) {}
}
function applyState(data) {
if (!data) return;
if (data.streaming && data.file) {
streaming = true;
streamUrl = data.stream_url;
selectedFile = data.file;
document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-start').textContent = 'STREAMING';
document.getElementById('btn-start').classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + (data.file_name || '');
document.getElementById('video-status').className = 'video-status connected';
if (!previewAutoOn) enablePreview();
loadBatchResult(data.file);
} else if (data.file && selectedFile === data.file) {
previewAutoOn = false;
loadBatchResult(data.file);
}
}
// --- File list ---
async function loadFiles() {
const container = document.getElementById('file-list');
try {
const res = await fetch('/api/mp4-files');
const folders = await res.json();
if (!folders || folders.length === 0) {
container.innerHTML = '<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">No MP4 files found</div>';
return;
}
container.innerHTML = folders.map((g, gi) => {
const label = g.folder || 'root';
const fid = 'folder-' + gi;
const wasOpen = document.getElementById(fid) && document.getElementById(fid).classList.contains('open');
return `
<div class="folder-header" id="${fid}-hdr" onclick="toggleFolder('${fid}')">
<span class="folder-arrow">&#9654;</span>
<i class="fas fa-folder" style="color:var(--gold-text);"></i>
<span>${label}</span>
<span style="font-size:8px;color:var(--graphite);">(${g.files.length})</span>
</div>
<div class="folder-body${wasOpen ? ' open' : ''}" id="${fid}">
${g.files.map(f => {
const sel = selectedFile === f.path;
const str = streaming && selectedFile === f.path;
let cls = 'file-item';
if (str) cls += ' streaming';
else if (sel) cls += ' selected';
return `
<div class="${cls}" onclick="event.stopPropagation(); selectFile('${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')" data-path="${f.path.replace(/"/g, '&quot;')}">
<span class="fi-icon">${str ? '&#9673;' : '&#9654;'}</span>
<span class="fi-name">${f.name}</span>
<span class="fi-size">${formatSize(f.size)}</span>
</div>`;
}).join('')}
</div>`;
}).join('');
} catch (err) {
console.error('Failed to load files:', err);
}
}
function toggleFolder(fid) {
const hdr = document.getElementById(fid + '-hdr');
const body = document.getElementById(fid);
if (hdr) hdr.classList.toggle('open');
if (body) body.classList.toggle('open');
}
function selectFile(path, name) {
if (selectedFile === path) {
selectedFile = null;
batchResultLoaded = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
updateFileSelection();
return;
}
selectedFile = path;
document.getElementById('btn-download').disabled = false;
if (!streaming) document.getElementById('btn-start').disabled = false;
updateFileSelection();
loadBatchResult(path);
}
function updateFileSelection() {
document.querySelectorAll('.file-item').forEach(el => {
const path = el.getAttribute('data-path');
if (path === selectedFile) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
let batchResultLoaded = false;
async function loadBatchResult(filePath) {
if (!filePath) {
batchResultLoaded = false;
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
return;
}
try {
const res = await fetch('/api/batch-result?file=' + encodeURIComponent(filePath));
const data = await res.json();
if (!data.success) {
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = data.error || 'not found';
document.getElementById('live-status').textContent = 'not found';
document.getElementById('live-status').className = 'counter-status disconnected';
batchResultLoaded = false;
return;
}
batchResultLoaded = true;
document.getElementById('live-title').innerHTML = 'Batch Result';
document.getElementById('live-count').textContent = data.count.toLocaleString();
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = data.batch_number;
document.getElementById('live-time').textContent = data.date;
var startStr = data.start_time ? new Date(data.start_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
var endStr = data.end_time ? new Date(data.end_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
document.getElementById('live-status').textContent = 'recorded \u00b7 ' + startStr + ' \u2013 ' + endStr;
document.getElementById('live-status').className = 'counter-status connected';
} catch (err) {
console.error('Failed to load batch result:', err);
batchResultLoaded = false;
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
function downloadFile() {
if (!selectedFile) return;
var a = document.createElement('a');
a.href = '/api/download-mp4?file=' + encodeURIComponent(selectedFile);
a.download = selectedFile.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// --- Streaming ---
async function startRecount() {
if (!selectedFile) return;
const btnStart = document.getElementById('btn-start');
btnStart.disabled = true;
btnStart.textContent = 'CONNECTING...';
lastRecountCount = null;
lastRecountTime = null;
document.getElementById('recount-count').textContent = '0';
document.getElementById('recount-time').textContent = 'resetting...';
document.getElementById('recount-status').textContent = 'resetting';
document.getElementById('recount-status').className = 'counter-status disconnected';
try {
const res = await fetch('/api/start-recount', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: selectedFile })
});
const data = await res.json();
if (!data.success) {
alert('Failed: ' + (data.error || 'unknown'));
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
return;
}
streamUrl = data.stream_url;
streaming = true;
selectedFile = data.path;
document.getElementById('btn-stop').disabled = false;
btnStart.textContent = 'STREAMING';
btnStart.classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + data.file;
document.getElementById('video-status').className = 'video-status connected';
enablePreview();
} catch (err) {
alert('Error: ' + err.message);
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
}
}
async function stopRecount() {
try {
await fetch('/api/stop-recount', { method: 'POST' });
} catch (err) {}
streaming = false;
streamUrl = '';
selectedFile = null;
previewAutoOn = false;
batchResultLoaded = false;
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
btnStart.disabled = true;
btnStart.textContent = '\u25b6 START RECOUNT';
btnStart.classList.remove('btn-ghost');
btnStop.disabled = true;
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span> stopped';
document.getElementById('video-status').className = 'video-status stopped';
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
resetVideoToggle();
loadFiles();
}
function togglePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
if (img.style.display === 'block') {
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
} else {
enablePreview();
}
}
function resetVideoToggle() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
}
function enablePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
previewAutoOn = true;
img.onerror = function() {
if (streaming && streamUrl) {
img.src = streamUrl + '?' + Date.now();
}
};
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON';
btn.classList.add('active');
}
// --- Recount counter ---
let lastRecountCount = null;
let lastRecountTime = null;
async function loadRecountProgress() {
const countEl = document.getElementById('recount-count');
const timeEl = document.getElementById('recount-time');
const statusEl = document.getElementById('recount-status');
try {
const res = await fetch('/api/recount-progress');
const data = await res.json();
if (!data.success) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = data.error || 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
return;
}
lastRecountCount = data.count.toLocaleString();
countEl.textContent = lastRecountCount;
if (data.last_detection_time) {
const d = new Date(data.last_detection_time);
lastRecountTime = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
timeEl.textContent = lastRecountTime;
} else if (lastRecountTime) {
timeEl.textContent = lastRecountTime;
} else {
timeEl.textContent = 'waiting...';
}
statusEl.className = 'counter-status connected';
statusEl.textContent = 'connected';
} catch (err) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
}
}
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+286
View File
@@ -0,0 +1,286 @@
# Design System Inspired by Tesla
## 1. Visual Theme & Atmosphere
Tesla's website is an exercise in radical subtraction — a digital showroom where the product is everything and the interface is almost nothing. The page opens with a full-viewport hero that fills the entire screen with cinematic car photography: three vehicles arranged on polished concrete against a hazy cityscape sky, with a single model name floating above in translucent white type. There are no decorative borders, no gradients, no patterns, no shadows. The UI exists only to provide just enough navigational structure to get out of the way. Every pixel that isn't product imagery is white space, and that restraint is the design system's most powerful statement.
The color philosophy is almost ascetic: a single blue (`#3E6AE1`) for primary calls to action, three shades of dark gray for text hierarchy, and white for everything else. The entire emotional weight is carried by photography — sprawling landscape shots, studio-lit vehicle profiles, and atmospheric environmental compositions that stretch edge-to-edge across each viewport-height section. The UI chrome dissolves into the imagery. The navigation bar floats above the hero with no visible background, border, or shadow — the TESLA wordmark and five navigation labels simply exist in the space, trusting the content beneath them to provide sufficient contrast.
Typography recently transitioned from Gotham to Universal Sans — a custom family split into "Display" for headlines and "Text" for body/UI elements — unifying the website, mobile app, and in-car software into a single typographic voice. The Display variant renders hero titles at 40px weight 500, while the Text variant handles everything from navigation (14px/500) to body copy (14px/400). The font carries a geometric precision with slightly humanist terminals that feels engineered rather than designed — exactly matching Tesla's brand identity of technology that doesn't need to announce itself. There are no text shadows, no text gradients, no decorative type treatments. Every letterform earns its place through clarity alone.
**Key Characteristics:**
- Full-viewport hero sections (100vh) dominated by cinematic car photography with minimal overlay UI
- Near-zero UI decoration: no shadows, no gradients, no borders, no patterns anywhere on the page
- Single accent color — Electric Blue (`#3E6AE1`) — used exclusively for primary CTA buttons
- Universal Sans font family (Display + Text) unifying web, app, and in-car interfaces
- Photography-first presentation where product imagery carries all emotional weight
- Frosted-glass navigation concept with transparent/white nav that floats over hero content
- 0.33s cubic-bezier transitions as the universal timing for all interactive state changes
- Carousel-driven hero with dot indicators and edge arrow navigation for multiple vehicle showcases
- "Ask a Question" persistent chatbot bar anchored to the viewport bottom
## 2. Color Palette & Roles
### Primary
- **Electric Blue** (`#3E6AE1`): Primary CTA button background — a confident, mid-saturation blue (rgb 62, 106, 225) that stands alone as the only chromatic color in the entire interface. Used exclusively for "Order Now" and other primary action buttons
- **Pure White** (`#FFFFFF`): Dominant background color for all surfaces, panels, navigation, and secondary button fills — the canvas that lets photography breathe
### Secondary & Accent
- **Promo Blue** (`#3E6AE1`): Blue also serves for promotional text ("0% APR Available") displayed over hero imagery in the same hue as the CTA — creating a visual link between incentive messaging and action
- No secondary accent colors exist. Tesla deliberately avoids color variety to maintain extreme visual discipline
### Surface & Background
- **White Canvas** (`#FFFFFF`): Page background, navigation panel, dropdown menus, and all surface containers
- **Light Ash** (`#F4F4F4`): Subtle alternate surface for section differentiation — barely perceptible shift from pure white (rgb 244, 244, 244)
- **Carbon Dark** (`#171A20`): Dark surface color for hero text overlays and potential dark-mode contexts (rgb 23, 26, 32) — a warm near-black with a blue undertone
- **Frosted Glass** (`rgba(255, 255, 255, 0.75)`): Semi-transparent white for navigation backdrop-filter effects on scroll
### Neutrals & Text
- **Carbon Dark** (`#171A20`): Primary heading and navigation text — the darkest text value (rgb 23, 26, 32), used for model names, nav labels, and hero titles on light backgrounds
- **Graphite** (`#393C41`): Body text and secondary content (rgb 57, 60, 65) — the default paragraph color, slightly warmer than pure gray
- **Pewter** (`#5C5E62`): Tertiary text for sub-links, secondary navigation links like "Learn" and "Order" (rgb 92, 94, 98)
- **Silver Fog** (`#8E8E8E`): Placeholder text in input fields and disabled states (rgb 142, 142, 142)
- **Cloud Gray** (`#EEEEEE`): Light borders and divider lines (rgb 238, 238, 238)
- **Pale Silver** (`#D0D1D2`): Subtle UI borders and delineation (rgb 208, 209, 210)
### Semantic & Accent
- Tesla's marketing site avoids semantic color coding (no green/red/yellow status indicators). Error, success, and warning states follow standard browser defaults in form contexts
- The blue CTA (`#3E6AE1`) serves as the sole interactive color signal
### Gradient System
- No gradients are used anywhere in the interface
- Depth is achieved entirely through photography, whitespace, and the binary contrast between full-bleed imagery and clean white surfaces
- The navigation achieves layering through opacity (frosted glass effect) rather than gradient or shadow
## 3. Typography Rules
### Font Family
- **Display**: `Universal Sans Display`, -apple-system, Arial, sans-serif — used for hero titles and large model names. A geometric sans-serif with precisely engineered proportions, recently replacing Gotham to unify Tesla's digital ecosystem (website, mobile app, vehicle interface)
- **Text/UI**: `Universal Sans Text`, -apple-system, Arial, sans-serif — used for navigation, body copy, buttons, and all UI text. Optimized for legibility at smaller sizes with slightly wider proportions than the Display variant
- **No OpenType features** detected — typography is completely unembellished
- **No italic variants** observed on the marketing site
### Hierarchy
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Hero Title | 40px (2.50rem) | 500 | 48px (1.20) | normal | Universal Sans Display, white on dark hero imagery |
| Product Name | 17px (1.06rem) | 500 | 20px (1.18) | normal | Universal Sans Text, model names in nav panel and cards |
| Nav Item | 14px (0.88rem) | 500 | 16.8px (1.20) | normal | Universal Sans Text, primary navigation labels |
| Body Text | 14px (0.88rem) | 400 | 20px (1.43) | normal | Universal Sans Text, paragraph and descriptive content |
| Button Label | 14px (0.88rem) | 500 | 16.8px (1.20) | normal | Universal Sans Text, CTA button text |
| Sub-link | 14px (0.88rem) | 400 | 20px (1.43) | normal | Tertiary links (Learn, Order, Experience) |
| Promo Text | 22px (1.38rem) | 400 | 20px (0.91) | normal | White promotional text on hero ("0% APR Available") |
| Category Label | 16px (est.) | 500 | — | normal | White text labels on category cards ("Sport Sedan") |
### Principles
- **"Normal" letter-spacing everywhere**: Unlike most modern tech brands that use negative tracking for headlines, Tesla uses default letter-spacing at every level. This reflects a philosophy that the typeface should speak for itself without manipulation
- **Weight restraint**: Only two weights appear — 500 (medium) for headings/UI and 400 (regular) for body. No bold (700), no light (300). The system avoids typographic drama
- **Unified font sizing**: Most UI text clusters at 14px with only hero titles (40px) and promo text (22px) breaking away. This extreme uniformity creates a sense of engineered consistency
- **Display vs Text split**: The two-variant system (Display for hero, Text for UI) creates subtle optical correction without visible stylistic difference — they appear as the same typeface at different sizes
- **No text transforms**: No uppercase text appears in the main navigation or CTAs — the lowercase approach reinforces Tesla's understated confidence
## 4. Component Stylings
### Buttons
All buttons use barely-rounded rectangles (4px border-radius) — creating a sharp, technical aesthetic that mirrors the precision of the vehicles.
**Primary CTA** — The main action button:
- Default: bg `#3E6AE1` (Electric Blue), text `#FFFFFF`, fontSize 14px, fontWeight 500, padding 4px with inner content centering, borderRadius 4px, minHeight 40px, width 200px
- Border: 3px solid transparent (reserves space for focus/active border animation)
- Box Shadow: `rgba(0,0,0,0) 0px 0px 0px 2px inset` (invisible at rest, animates to visible on focus)
- Transition: `border-color 0.33s, background-color 0.33s, color 0.33s, box-shadow 0.25s`
- Hover: subtle darkening of blue background
- Used for: "Order Now" calls to action
**Secondary CTA** — The alternative action button:
- Default: bg `#FFFFFF`, text `#393C41` (Graphite), same dimensions and border pattern as primary
- Transition: identical timing to primary (0.33s)
- Used for: "View Inventory" alongside primary CTA
**Nav Button** — Top navigation items:
- Default: bg transparent, text `#171A20` (Carbon Dark), fontSize 14px, fontWeight 500, borderRadius 4px, padding 4px 16px, minHeight 32px
- Transition: `color 0.33s, background-color 0.33s`
- Active/expanded: subtle background highlight
- Used for: "Vehicles", "Energy", "Charging", "Discover", "Shop"
**Text Link** — In-content actions:
- Default: text `#5C5E62` (Pewter), fontSize 14px, fontWeight 400, no background, no border
- Hover: underline decoration with box-shadow transition
- Transition: `box-shadow 0.33s cubic-bezier(0.5, 0, 0, 0.75), color 0.33s`
- Used for: "Learn", "Order", "Experience", "New", "Pre-Owned" links in dropdown panel
### Cards & Containers
**Vehicle Card** (Navigation panel):
- Background: transparent (inherits panel white)
- Border: none
- Shadow: none
- Content: vehicle image (transparent PNG) + model name centered below + two text links
- Layout: 3-column grid within the dropdown panel
- No hover animation on the card itself — interaction is via the text links beneath
**Category Card** (Homepage lower section):
- Background: full-bleed landscape photography
- Border radius: approximately 12px (subtly rounded)
- Overflow: hidden (clips image to rounded corners)
- Text: white label in top-left corner ("Sport Sedan", "Midsize SUV")
- Size: large format, approximately 2:1 aspect ratio
- No shadow, no border, no overlay gradient — text relies on image darkness for contrast
### Inputs & Forms
- Background: transparent
- Text color: `#171A20` (Carbon Dark)
- Placeholder color: `#8E8E8E` (Silver Fog)
- Border: minimal, inherits from browser defaults
- Font: Universal Sans Text, 14px
- The "Ask a Question" chatbot input bar sits at the viewport bottom with a clean white background and subtle border
### Navigation
- **Desktop**: Centered horizontal nav with TESLA wordmark (spaced uppercase letters) on the left, five category buttons center-aligned, and three icon buttons (help, globe/language, account) on the right
- **Background**: White (transitions from transparent over dark hero to opaque white on scroll via class toggle `tds-site-header--white-background`)
- **Dropdown panel**: Full-width white panel with 3-column vehicle grid + right sidebar text links, no shadow, no border — appears seamlessly below the nav
- **Sticky behavior**: `sticky-without-slide` class — stays at top without slide-in animation
- **Mobile**: Hamburger collapse pattern
- **No visible separator** between nav and content — the nav blends with the hero
### Image Treatment
- **Hero**: Full-viewport (100vh) sections with cinematic photography — edge-to-edge, no padding, no margin
- **Vehicle images**: Transparent PNG renders on white background in dropdown panel, studio-quality 3/4 angle shots
- **Category cards**: Landscape photography with approximately 2:1 ratio, rounded corners (12px)
- **Carousel**: Auto-advancing with dot indicators (3 dots) and left/right arrow navigation on edges
- **Lazy loading**: Below-fold sections use lazy loading, rendering as blank white until scrolled into view
### Persistent Chat Bar
- Anchored to viewport bottom, visible across all sections
- White background with subtle border
- Contains: chat icon + "Ask a Question" label + placeholder text ("What's Dog Mode?") + send icon + "Schedule a Drive Today" secondary CTA
- Schedule CTA has a teal/blue icon accent
## 5. Layout Principles
### Spacing System
- **Base unit**: 8px
- **Common values**: 8px (0.5rem), 16px (1rem), 21.44px (1.34rem)
- **Button padding**: 4px (minimal outer) with content centering via flexbox, 4px 16px for nav items
- **Section padding**: Full-viewport sections with content centered vertically
- **Card gap**: approximately 16px between category cards
### Grid & Container
- **Max width**: approximately 1383px (full viewport width used for most content)
- **Hero**: Full-bleed, edge-to-edge, 100vh sections
- **Navigation panel**: 3-column grid for vehicle cards with right-aligned text sidebar (~70/30 split)
- **Category cards**: 2-up horizontal layout (large left card + smaller right card)
### Whitespace Philosophy
Tesla uses whitespace as a luxury signal. The generous vertical spacing between sections (each section is a full viewport height) means you can only see one "message" at a time — one car, one model name, one CTA pair. This creates a gallery-like browsing experience where each scroll is a deliberate transition, not a continuous feed. White space is not empty — it's the frame that elevates each vehicle to the status of art piece.
### Border Radius Scale
| Value | Context |
|-------|---------|
| 0px | Most elements — sharp edges are the default |
| 4px | Buttons (primary, secondary, nav items) — barely perceptible rounding |
| ~12px | Category cards — noticeable but restrained rounding on larger surfaces |
| 50% | Carousel dot indicators — perfect circles |
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Level 0 (Flat) | No shadow, no border | Default state for all elements — cards, panels, buttons at rest |
| Level 1 (Frost) | `rgba(255,255,255,0.75)` backdrop | Navigation bar on scroll — frosted glass transparency |
| Level 2 (Overlay) | `rgba(128,128,128,0.65)` | Modal overlays and region/cookie popups |
| Level 3 (Subtle) | `rgba(0,0,0,0.05)` | Minimal shadow hints on rare hover states |
### Shadow Philosophy
Tesla's approach to elevation is essentially "none." The site avoids box-shadows entirely in its primary interface. Depth is communicated through three alternative strategies:
1. **Z-index layering**: The sticky navigation sits above hero content through positioning, not shadow
2. **Opacity-based transparency**: The frosted glass nav and overlay modals use background-color opacity rather than shadow to indicate layering
3. **Photography-as-depth**: The full-bleed images create their own visual depth through perspective, lighting, and composition — making UI shadows redundant
### Decorative Depth
- No gradients, glows, or atmospheric effects on UI elements
- The hero imagery itself provides all visual richness — sunset skies, reflected light on car surfaces, ground shadows from studio lighting
- The carousel arrow buttons use a semi-transparent white background to float above the hero imagery without disrupting it
## 7. Do's and Don'ts
### Do
- Let photography dominate every screen — the product IS the design
- Use Electric Blue (`#3E6AE1`) exclusively for primary CTAs — never for decorative purposes
- Maintain viewport-height sections for major content blocks — one message per screen
- Keep typography at weight 400-500 only — no bold, no light, no extremes
- Use 4px border-radius for all interactive elements — precision over playfulness
- Trust whitespace as a luxury signal — never fill available space just because it's empty
- Keep all transitions at 0.33s — consistency in motion is as important as consistency in color
- Use transparent PNG vehicle imagery on white backgrounds for product showcases
- Center CTAs horizontally below model names — the vertical rhythm is model → subtitle → buttons
- Maintain the Display/Text font split — Display for hero-scale text only, Text for everything else
### Don't
- Add shadows to any element — elevation through shadow contradicts the flat, gallery aesthetic
- Use more than one chromatic color besides the blue CTA — the palette is intentionally monochrome-plus-one
- Apply gradients, patterns, or decorative backgrounds to surfaces — white and photography are the only backgrounds
- Use text larger than 40px on the web — the typography is deliberately restrained even at hero scale
- Add borders to cards or containers — separation is achieved through spacing, not lines
- Use uppercase text transforms — Tesla's confidence is expressed through lowercase calm
- Introduce rounded-pill buttons or large border-radii — the 4px radius is deliberate and precise
- Override the Universal Sans family with other typefaces — cross-platform consistency is a core brand value
- Add hover animations with scale/translate transforms — Tesla's interactions are color-only (background and border transitions)
- Clutter the viewport with multiple CTAs — every screen should have at most two action buttons
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <768px | Single-column layout, hamburger nav replaces horizontal labels, hero text scales to ~28px, CTA buttons stack vertically, category cards become full-width |
| Tablet | 768-1024px | 2-column nav panel, hero maintains full-viewport height, CTAs remain side-by-side, reduced horizontal padding |
| Desktop | 1024-1440px | Full horizontal nav, 3-column vehicle grid in dropdown, hero at 40px, side-by-side CTAs at 200px/160px width |
| Large Desktop | >1440px | Content remains centered, hero photography scales to fill wider viewports, max-width container for nav panel content |
### Touch Targets
- Primary CTA buttons: 200px × 40px minimum (well above 44×44px WCAG requirement)
- Nav buttons: minimum 32px height with 4px 16px padding — adequate touch targets
- Carousel arrows: ~44px square white semi-transparent buttons at viewport edges
- Text links ("Learn", "Order"): 14px text with adequate line-height spacing for touch
### Collapsing Strategy
- **Navigation**: Horizontal category buttons (Vehicles, Energy, Charging, Discover, Shop) collapse to a hamburger/drawer menu on mobile
- **Hero CTA pair**: Side-by-side buttons on desktop stack vertically on mobile
- **Category cards**: 2-up horizontal layout collapses to single-column full-width on mobile
- **Vehicle grid**: 3-column grid in desktop nav panel becomes 2-column on tablet, single-column on mobile
- **Spacing**: Section vertical padding remains generous (viewport-height sections) but horizontal padding reduces
### Image Behavior
- Hero images are fully responsive and fill the entire viewport at every breakpoint
- Vehicle carousel images use `object-fit: cover` to maintain cinematic composition across widths
- Transparent PNG vehicle images in the nav panel scale proportionally within their grid cells
- Category card images maintain their landscape ratio and clip via `overflow: hidden` with border-radius
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: "Electric Blue (#3E6AE1)"
- Background: "Pure White (#FFFFFF)"
- Heading text: "Carbon Dark (#171A20)"
- Body text: "Graphite (#393C41)"
- Tertiary text: "Pewter (#5C5E62)"
- Placeholder: "Silver Fog (#8E8E8E)"
- Alternate surface: "Light Ash (#F4F4F4)"
- Dark surface: "Carbon Dark (#171A20)"
### Example Component Prompts
- "Create a hero section with a full-viewport background image, centered 'Model Y' title in Universal Sans Display at 40px weight 500 in white, a subtitle line below, and two buttons side by side: a primary Electric Blue (#3E6AE1) 'Order Now' button and a secondary white 'View Inventory' button, both with 4px border-radius and 40px height"
- "Design a navigation bar with a spaced-letter wordmark on the left, five text buttons (14px, weight 500, Carbon Dark #171A20) centered, and three icon buttons on the right, all on a white background with no shadow or border"
- "Build a vehicle card grid with 3 columns, each card showing a transparent-background car image above a model name (17px, weight 500, Carbon Dark) and two text links (14px, weight 400, Pewter #5C5E62) labeled 'Learn' and 'Order', on a pure white surface with no borders or shadows"
- "Create a category card with full-bleed landscape photography, 12px border-radius, overflow hidden, and a white text label ('Sport Sedan') positioned in the top-left corner with no overlay gradient"
- "Design a persistent bottom bar with a chat input ('Ask a Question' placeholder), a send icon, and a secondary CTA ('Schedule a Drive Today') with a teal icon, anchored to the viewport bottom on a white background"
### Iteration Guide
When refining existing screens generated with this design system:
1. Focus on ONE component at a time — Tesla's system is so minimal that each element must be pixel-perfect
2. Reference specific color names and hex codes from this document — there are only 6-7 colors in the entire system
3. Use natural language descriptions, not CSS values — "barely rounded corners" not "border-radius: 4px"
4. Describe the desired "feel" alongside specific measurements — "gallery-like silence between sections" communicates the whitespace philosophy better than "margin-bottom: 100vh"
5. Always verify that photography is doing the emotional heavy-lifting — if the UI itself feels "designed," it's too much