diff --git a/.github/workflows/api-smoke.yml b/.github/workflows/api-smoke.yml index a23a6f0..8b6fbcb 100644 --- a/.github/workflows/api-smoke.yml +++ b/.github/workflows/api-smoke.yml @@ -57,9 +57,11 @@ jobs: -H "Content-Type: application/json" \ -d "$payload" | jq . - - name: API Hardening (non-destructive) - run: bash scripts/api_hardening_test.sh - env: - ALLOW_MUTATION_REMOTE: '0' - + - name: Validation and method guard + run: | + set -euo pipefail + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API_BASE_URL/submit" -H "Content-Type: application/json" -d '{}') + test "$code" = "400" + code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$API_BASE_URL/submit") + test "$code" = "405" diff --git a/.gitignore b/.gitignore index 86b4d6e..efea13c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ /.venv .venv/ .myenv/ -client/.venv +client/.venv/ +.python-version client/build/ client/dist/ # FFmpeg binaries (too large for GitHub) @@ -35,25 +36,44 @@ frontend/.next/ npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* .pnpm-store/ .turbo/ +.npm/ +.eslintcache # Env .env +.env.* +!.env.example server/.env frontend/.env client/.env +server/.env.* +frontend/.env.* +client/.env.* +!server/env.example +!frontend/.env.example +!client/.env.example +.envrc +.direnv/ # Build artifacts +.build/ dist/ server/dist/ frontend/.firebase/ frontend/.cache/ +*.tsbuildinfo +/encodingdb-client-macos +/encodingdb-client-windows.exe # Prisma server/prisma/dev.db* server/prisma/*.db-journal server/prisma/migrations/*/steps.json +server/src/generated/prisma/ # Python / client __pycache__/ @@ -69,21 +89,39 @@ dist/ # Logs *.log +*.out +*.err logs/ client/dist/windows/build.log # Coverage / tests coverage/ .coverage* +.test-reports/ +scripts/.test-reports/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.tox/ +.nox/ +.hypothesis/ # Docker *.pid +*.pid.lock +docker-compose.override.yml # IDE / editor .idea/ .vscode/ +.fleet/ *.swp # Nginx certs (place externally) nginx/certs/ -IMPROVEMENTS.md \ No newline at end of file +IMPROVEMENTS.md +OPTIMIZATIONS.md +BUG_FIXES.md +NEW_FEATURES.md +roadmap.md diff --git a/README.md b/README.md index 7ae02f3..39abd10 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,302 @@ -Encoding Database – Open Benchmark Suite for Video Encoding -=========================================================== - -Overview --------- -Encoding Database is an open-source project that crowdsources real-world, reproducible performance and quality data for video encoding stacks across CPUs and GPUs. The goal is to help the community compare encoders, presets, and hardware on consistent inputs while capturing realistic throughput, quality (VMAF), and file size outcomes. - -Repo layout ------------ -- `server/`: Node/Express + Prisma API that accepts benchmark submissions and aggregates results. -- `frontend/`: Next.js app for exploring benchmarks and visualizations. -- `client/`: Cross-platform Python benchmark client (packaged with PyInstaller for end users) that runs standardized FFmpeg pipelines and submits results. -- `nginx/`: Reverse proxy examples for production deployments. -- `scripts/`: Helper scripts for development, building, and operations. - -What the benchmark measures ---------------------------- -For a fixed, canonical input clip, the client runs a matrix of encoder/preset combinations and reports: -- FPS (throughput) -- File size -- VMAF (if the FFmpeg build has libvmaf) -- Basic hardware info (CPU, GPU/integrated, RAM, OS) -- Encoder used (software/hardware), preset, CRF - -The API validates, scores, and aggregates data to present robust medians and highlight outliers. - -Quick start – Using the prebuilt client (Windows/macOS) ------------------------------------------------------- -1) Download the latest release for your OS from the Releases page. -2) Close other heavy apps to improve measurement quality. -3) Run the client: - - Windows: double-click `encodingdb-client-windows.exe` (the terminal will pause at the end so you can read results) - - macOS: run the unix executable or `./encodingdb-client-macos` from Terminal (Gatekeeper may require you to allow execution) -4) Follow the prompts to select a codec/encoder, CRF, and preset (or run a pre-defined small/medium/full benchmark from the menu). -5) If submissions are enabled, results will be uploaded automatically. Otherwise, they will be queued locally for retry. - -Client command-line options ---------------------------- -The client accepts flags to customize behavior. Common examples: +Encoding Database +================= -``` ---codec libx264 # force a specific encoder (e.g., libx264, libx265, h264_nvenc) ---presets fast,medium # presets list (comma-separated) ---crf 24 # CRF for software encoders (mapped for HW encoders where possible) ---no-submit # run locally but do not submit to the server ---batch-size 0 # 0=auto: number of physical CPU cores (not threads) ---use-token # opt-in to short-lived token auth if the server requires it ---base-url https://... # override API base (defaults to production) ---pause-on-exit # keep the console window open at the end (Windows) +Encoding Database is an open benchmarking platform for video encoding performance, quality, and efficiency. It combines: + +- A cross-platform Python client that runs reproducible FFmpeg benchmarks. +- A Node/Express + Prisma API that validates, scores, and aggregates submissions. +- A Next.js frontend with comparison tools and leaderboards. + +With the changes brought by version *v1.1.0*, the project has moved well beyond a simple benchmark script into a multi-component data platform with quality controls, ingest hardening, and hardware telemetry. + +## Changelog (v1.1.0) + +This release documents work completed since `v1.0.2` and reflects a major platform overhaul. + +### Client (Python benchmark runner) + +- Reworked benchmark execution to avoid double-encoding and measure speed/size/quality from one artifact. +- Added SSIM and PSNR computation (alongside VMAF), including parallelized quality analysis. +- Fixed hardware encoder CRF handling (VideoToolbox, QSV, AMF, VAAPI) where CRF could previously be ignored. +- Improved benchmark throughput with cached encoder discovery, FFmpeg progress parsing, and SHA256 caching. +- Fixed progress accounting and baseline cache behavior (including TTL support). +- Added hardware telemetry capture for GPU utilization/power, CPU utilization, memory peaks, and thermal throttling. + +### Server and data pipeline (Node/Express + Prisma) + +- Hardened ingest consistency with transactional aggregation, in-transaction audit inserts, and race-condition fixes. +- Replaced fragile running averages with sum/count-based aggregates for safer recomputation and correction. +- Expanded schema and validation for SSIM/PSNR and hardware telemetry metrics. +- Added query-path optimizations: response caching, composite query indexing, and PostgreSQL-native stats helpers. +- Improved ingest edge-case behavior (CORS for non-browser clients, proxy-aware rate-limit keying, bounded token store). + +### Frontend (Next.js analytics platform) + +- Overhauled large-dataset handling with virtualized benchmark tables, server-side filtering, and pagination. +- Expanded analysis views with SSIM/PSNR histograms, SSIM vs VMAF scatter, and rate-distortion visualization. +- Added/expanded comparison tooling, leaderboards, and encoder dashboard workflows. +- Added hardware intelligence views: efficiency metrics, GPU utilization, power comparison, CPU heatmaps, and recommendations. +- Fixed PL score behavior and control UX issues (median-size scoring bug, zero-weight guardrails, real-time normalization). + +### Database and integrity model + +- Tightened schema integrity with non-null `crf` defaults and normalized `gpuModel` handling. +- Standardized canonical input hash enforcement for reproducible benchmark comparisons. +- Extended benchmark rows with telemetry and quality sample-count fields for higher confidence analysis. +- Enforced CRF single-pass policy (`passes=1`) across the pipeline for consistency. + +## Why this project exists + +Encoder performance claims are often hard to compare because workloads, settings, and hardware conditions differ. Encoding Database standardizes those dimensions (as best we can) so results are more comparable and useful in real-world decision making: + +- Which encoder and preset is fastest on my class of hardware? +- What quality tradeoff am I buying for speed and output size? +- How much power and thermal headroom does a given encode path consume? + +## System architecture + +1. The client runs benchmark tasks (single run or benchmark batches) against a canonical input clip. +2. The client computes quality and performance metrics and captures optional system telemetry during encode. +3. The client submits an allowlisted payload to `/submit`. +4. The server validates payloads, deduplicates with a hash, scores quality confidence, stores an immutable audit row, and updates aggregate benchmark rows transactionally. +5. The frontend queries `/query` for accepted aggregates and renders analytics/leaderboards. + +## Repository layout + +- `client/`: Python benchmark runner, hardware detection, FFmpeg orchestration, telemetry sampler. +- `server/`: Express API, Zod validation, Prisma models/migrations, ingest + query pipeline. +- `frontend/`: Next.js 15 app with benchmark table, analytics, leaderboards, and hardware pages. +- `nginx/`: reverse-proxy configuration for production. +- `scripts/`: consolidated operational scripts (`local_test.sh`, `client_test.sh`, `build_macos_client.sh`, `build_windows_client.sh`). +- `sample.mp4`: canonical baseline clip used by the benchmark flow. + +## Current platform capabilities + +- Benchmark dimensions: codec/encoder, preset, CRF, content class, resolution (single-pass CRF mode). +- Core quality/performance: FPS, file size, VMAF, SSIM, PSNR. +- Hardware telemetry: utilization, power, memory, temperatures, CPU frequency, process I/O and CPU time, battery state. +- Data integrity controls: canonical input hash checks, idempotent payload hash, accepted/suspect/rejected submission status. +- Aggregation model: rolling sums/sample counts for stable recomputation and drift-resistant averages. +- Query API: filtering, sorting, ranges, pagination, derived efficiency metrics. +- Frontend analytics: scatter plots, histograms, rate-distortion, content/resolution comparisons, PL Score v6 leaderboards. + +## Telemetry and privacy + +### Data collection policy + +No user-identifiable data is collected in benchmark telemetry payloads. +Only system and benchmark run information is collected for data accuracy, reproducibility, and fairness across hardware. + +The client submits an explicit allowlist of fields. This prevents accidental inclusion of unrelated machine or user data. + +### Telemetry fields collected and why they matter + +| Category | Fields | Why this is collected | +| --- | --- | --- | +| System profile | `cpuModel`, `gpuModel`, `ramGB`, `os` | Normalizes comparisons across hardware and OS environments. | +| Workload configuration | `codec`, `preset`, `crf`, `contentClass`, `resolution`, `passes` (fixed to `1`), `inputHash` | Ensures benchmark rows are compared only when workload settings are equivalent. | +| Core benchmark outcome | `fps`, `fileSizeBytes`, `vmaf`, `ssim`, `psnr`, `runMs` | Captures speed, size, and perceptual quality outcomes of each encode. | +| Runtime telemetry (efficiency) | `gpuUtilAvg`, `gpuPowerAvgW`, `gpuMemPeakMB`, `cpuUtilAvg`, `cpuUtilMax`, `peakMemoryMB`, `thermalThrottle` | Enables efficiency and stability analysis beyond raw FPS. | +| Extended telemetry | `gpuTempMaxC`, `cpuFreqAvgMHz`, `cpuTempMaxC`, `ffmpegCpuUtilAvg`, `ffmpegCpuUtilMax`, `ffmpegReadMB`, `ffmpegWriteMB`, `ffmpegCpuTimeS`, `batteryPercentStart`, `batteryPercentEnd`, `batteryPercentDrop`, `powerSource`, `sampleCount`, `monitorDurationMs` | Improves confidence scoring, thermal context, and power/runtime interpretation. | +| Tooling metadata | `ffmpegVersion`, `encoderName`, `clientVersion`, `notes` | Aids reproducibility and diagnostics of edge-case runs. | + +### What is not collected + +- No names, emails, accounts, or profile identifiers. +- No location data. +- No browser cookies or advertising identifiers. +- No filesystem snapshots, personal files, or media uploads beyond benchmark metrics. +- No device serial numbers or MAC addresses in benchmark rows. + +### Why telemetry is important + +- It prevents misleading comparisons by preserving workload and hardware context. +- It enables efficiency metrics such as FPS/Watt and quality-per-watt. +- It improves outlier detection and submission confidence. +- It supports hardware recommendation and reliability analysis. + +## Quick start: benchmark client (prebuilt) + +1. Download the latest client release from: + - [GitHub Releases](https://github.com/oliverdougherC/Encoding_Database/releases) +2. Close heavy background apps for cleaner measurements. +3. Run the binary: + - Windows: `encodingdb-client-windows.exe` + - macOS: `./encodingdb-client-macos` +4. Follow the menu prompts to run single, small, medium, or full benchmark modes. +5. Results are submitted automatically unless `--no-submit` is enabled. + +## Client CLI options + +The client is menu-driven by default and also supports CLI flags: + +```bash +python client/main.py \ + --base-url https://encodingdb.platinumlabs.dev \ + --codec libx264 \ + --presets fast,medium \ + --crf 24 \ + --batch-size 0 \ + --content-class mixed \ + --resolution 1080p ``` -Hardware encoder detection --------------------------- -The client enumerates software encoders and probes hardware encoders using a fast one-frame test. This prevents showing unusable NVENC/QSV/AMF encoders on systems without those capabilities (e.g., integrated-only systems). On Windows, GPU model detection falls back to CIM/WMI when needed, improving support for integrated GPUs like AMD 780M. +Common flags: -Batch sizing ------------- -By default the client uses the number of physical CPU cores for parallel VMAF computation. This avoids over-subscription on hyperthreaded CPUs. You can override with `--batch-size N`. +- `--no-submit`: run benchmark but do not upload. +- `--use-token`: use short-lived ingest token flow when server supports it. +- `--queue-dir`: directory for offline retry queue. +- `--pause-on-exit`: keep console open after run (useful on Windows). -Development – Local environment -------------------------------- -Prereqs: -- Node 18+ +## Local development + +### Prerequisites + +- Node.js 18+ - Docker (for Postgres) -- Python 3.10+ (for the client) - -Steps: -1) Copy `env.example` to `.env` at repo root and set values as needed. Do the same in `server/env.example`. -2) Start API + DB: - ``` - docker-compose up --build - ``` -3) Install server deps and generate Prisma client: - ``` - cd server - npm ci - npm run build - npm run prisma:generate - npm run dev - ``` -4) Frontend: - ``` - cd frontend - npm ci - npm run dev - ``` -5) Client (Python): - ``` - cd client - python -m venv ../.myenv && ../.myenv/Scripts/activate # Windows - pip install -r requirements.txt - python main.py --no-submit --menu - ``` - -Building the Windows client ---------------------------- -Requirements: -- Windows Python with PyInstaller installed in your venv (`.myenv`), and -- `client/bin/win/ffmpeg.exe` and `ffprobe.exe` present (bundled with the exe) - -Build: +- Python 3.10+ + +### Option A: one-command local stack + +```bash +./scripts/local_test.sh +``` + +This script can stand up DB + API (+ frontend by default), apply migrations, seed test data, and run readiness checks. + +To launch the client in its default interactive mode: + +```bash +./scripts/client_test.sh +``` + +### Option B: manual setup + +1. Configure env files from `env.example` and `server/env.example`. +2. Start Postgres: + +```bash +docker compose up -d db +``` + +3. Start API: + +```bash +cd server +npm ci +npm run build +npx prisma generate +npx prisma migrate deploy +npm run dev +``` + +4. Start frontend: + +```bash +cd frontend +npm ci +echo "NEXT_PUBLIC_API_BASE_URL=http://localhost:3001" > .env.local +npm run dev +``` + +5. Run client locally: + +```bash +cd client +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py --no-submit +``` + +## API overview + +- `POST /submit`: submit one benchmark payload. +- `GET /query`: fetch accepted aggregate benchmarks with filter/sort/range params. +- `GET /test-videos`: list known benchmark clips. +- `GET /submit-token`, `GET /submit/token`, `GET /health/token`: optional short-lived token issuance. +- `GET /health`, `GET /health/live`, `GET /health/ready`: health checks. + +## Ingest security modes + +Configured via environment: + +- `public`: unsigned submissions accepted; token optional. +- `signed`: HMAC signature required. +- `hybrid`: signed preferred; token fallback; unsigned compatibility fallback. + +Additional controls: + +- global and `/submit` rate limits, +- body size limits, +- optional proof-of-work challenge for token mode, +- replay protection for signatures. + +## Frontend pages + +- `/`: benchmark table with filters, compare panel, PL Score sorting. +- `/analytics`: visual analytics (histograms, scatter, rate-distortion, content/resolution charts). +- `/compare-encoders`: focused encoder comparison dashboard. +- `/leaderboards`: top encoders by speed/quality/compression/PL Score. +- `/hardware`: efficiency and hardware intelligence charts. +- `/plove`: PL Score v6 documentation and formula overview. + +## Build packaged clients + +macOS: + +```bash +./scripts/build_macos_client.sh ``` -scripts\build_windows_client.ps1 + +Windows (Git Bash/MSYS/WSL with Windows Python available): + +```bash +./scripts/build_windows_client.sh ``` -Result: `client/dist/windows/encodingdb-client-windows.exe` -Building the macOS client -------------------------- -Use `scripts/build_macos_client.sh` (requires a native macOS Python and PyInstaller). Codesigning/notarization are not covered here. +Both packaging scripts expect platform FFmpeg/ffprobe binaries under `client/bin//`. + +## Testing and validation scripts + +- `server/test/routes.smoke.test.js`: server smoke tests. +- `scripts/local_test.sh`: local DB/API/frontend bring-up with readiness checks. +- `scripts/client_test.sh`: launches the client in default interactive mode. + +## Production deployment + +1. Configure env files from `env.example` and `server/env.example`. +2. One-command deploy (pull `main`, build, migrate, and start all services): + +```bash +./deploy.sh +``` + +3. Manual compose alternative: + +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` + +Security note: for hardened public deployment, set `INGEST_MODE=signed` and a strong `INGEST_HMAC_SECRET` in `.env`. + +Frontend-only deployment notes are in `frontend/DEPLOYMENT.md`. -Security and submission modes ------------------------------ -The API supports several ingest modes controlled by environment: -- public: accepts unsigned submissions (optionally short-lived tokens) -- signed: requires HMAC signature headers -- hybrid: accepts signed, else token if present, else unsigned (best-effort) +## Notes on benchmark scope -The client can fetch and attach a short-lived token (`--use-token`) when the server is configured for that. +- Canonical clip integrity is enforced by SHA256 (`sample.mp4`). +- Multi-content/resolution fields are supported in schema and UI; the canonical sample remains the default guaranteed clip path. +- Encoding mode is intentionally fixed to CRF single-pass (`passes=1`) across client, ingest, and frontend. +- Some telemetry fields are platform-dependent and may be unavailable on certain systems (for example, GPU power on non-NVIDIA hardware). -Privacy notes -------------- -Submitted payloads include hardware model strings, OS version, encoder name, and performance metrics. No personal data beyond the above is collected. Do not run the client on machines where this disclosure is unacceptable. +## Contributing -Contributing ------------- -Issues and PRs welcome. Please keep code clear and well-typed, and add small targeted tests where sensible. +Issues and PRs are welcome. When contributing: -License -------- -Apache 2.0. +- keep changes focused and well-scoped, +- include tests for behavior changes where practical, +- avoid breaking payload/schema compatibility without migration updates. +## License +Apache 2.0 diff --git a/client/config.py b/client/config.py index 30245a7..53b40f6 100644 --- a/client/config.py +++ b/client/config.py @@ -4,6 +4,7 @@ import sys import tempfile import threading +import math from dataclasses import dataclass, field from typing import Optional, Dict, Any, List, Tuple @@ -45,8 +46,17 @@ _ALLOWED_PAYLOAD_KEYS: Tuple[str, ...] = ( 'cpuModel', 'gpuModel', 'ramGB', 'os', - 'codec', 'preset', 'crf', 'fps', 'vmaf', 'fileSizeBytes', 'notes', - 'ffmpegVersion', 'encoderName', 'clientVersion', 'inputHash', 'runMs' + 'codec', 'preset', 'crf', 'passes', + 'fps', 'vmaf', 'ssim', 'psnr', 'fileSizeBytes', 'notes', + 'ffmpegVersion', 'encoderName', 'clientVersion', 'inputHash', 'runMs', + 'gpuUtilAvg', 'gpuPowerAvgW', 'gpuMemPeakMB', + 'cpuUtilAvg', 'cpuUtilMax', 'peakMemoryMB', 'thermalThrottle', + # Extended telemetry (Sprint 7) + 'gpuTempMaxC', 'cpuFreqAvgMHz', 'cpuTempMaxC', + 'ffmpegCpuUtilAvg', 'ffmpegCpuUtilMax', + 'ffmpegReadMB', 'ffmpegWriteMB', 'ffmpegCpuTimeS', + 'batteryPercentStart', 'batteryPercentEnd', 'batteryPercentDrop', + 'powerSource', 'sampleCount', 'monitorDurationMs', ) # Batch aggregation for Small/Full multi-run flows @@ -56,8 +66,10 @@ # Baseline cache for client-side outlier checks (populated lazily per session) _BASELINE_ROWS_CACHE: Optional[List[Dict[str, Any]]] = None +_BASELINE_ROWS_CACHE_TS: float = 0.0 +_BASELINE_ROWS_CACHE_TTL: float = 1800.0 # 30 minutes -# --- Cross-platform binary resolution helpers --- +# --- Cross-platform binary lookup helpers --- _FFMPEG_EXE: Optional[str] = None _FFPROBE_EXE: Optional[str] = None @@ -189,6 +201,53 @@ def ffprobe_exe() -> str: return "ffprobe" +CPU_FREQ_MIN_MHZ = 100.0 +CPU_FREQ_MAX_MHZ = 10_000.0 + + +def normalize_cpu_freq_mhz(raw_value: Any, *, reference_mhz: Optional[float] = None) -> Optional[float]: + """Normalize raw CPU frequency readings to MHz. + + Some platforms/drivers report frequency in GHz, KHz, or Hz. This function + maps those variants to MHz and drops implausible values. + """ + try: + raw = float(raw_value) + except Exception: + return None + if not math.isfinite(raw) or raw <= 0: + return None + + candidates = ( + raw, # already MHz + raw * 1000.0, # GHz -> MHz + raw / 1000.0, # KHz -> MHz + raw / 1_000_000.0, # Hz -> MHz + ) + plausible = [] + for c in candidates: + if math.isfinite(c) and CPU_FREQ_MIN_MHZ <= c <= CPU_FREQ_MAX_MHZ: + plausible.append(c) + + if not plausible: + return None + + # Keep the original value when it's already plausible in MHz. + if CPU_FREQ_MIN_MHZ <= raw <= CPU_FREQ_MAX_MHZ: + return raw + + if reference_mhz is not None: + try: + ref = float(reference_mhz) + if math.isfinite(ref) and CPU_FREQ_MIN_MHZ <= ref <= CPU_FREQ_MAX_MHZ: + return min(plausible, key=lambda c: abs(c - ref)) + except Exception: + pass + + # Fallback toward common desktop/laptop operating frequencies. + return min(plausible, key=lambda c: abs(c - 3000.0)) + + def sanitize_payload_for_server(payload: Dict[str, Any]) -> Dict[str, Any]: """Return a copy of payload containing only fields accepted by the server schema.""" try: @@ -196,6 +255,14 @@ def sanitize_payload_for_server(payload: Dict[str, Any]) -> Dict[str, Any]: for k in _ALLOWED_PAYLOAD_KEYS: if k in payload: clean[k] = payload[k] + # Deployment policy: CRF-only single-pass benchmarking. + clean['passes'] = 1 + if 'cpuFreqAvgMHz' in clean: + normalized = normalize_cpu_freq_mhz(clean.get('cpuFreqAvgMHz')) + if normalized is None: + clean.pop('cpuFreqAvgMHz', None) + else: + clean['cpuFreqAvgMHz'] = round(normalized, 2) return clean except Exception: return dict(payload) diff --git a/client/encoders.py b/client/encoders.py index 7a44a04..b4fa670 100644 --- a/client/encoders.py +++ b/client/encoders.py @@ -62,6 +62,17 @@ "vp9": ["libvpx-vp9"], } +HARDWARE_ENCODER_SUFFIXES: Tuple[str, ...] = ( + "_nvenc", "_qsv", "_amf", "_videotoolbox", "_vaapi", "_v4l2m2m", "_omx", +) + + +def is_hardware_encoder_name(encoder: str) -> bool: + try: + return encoder.strip().lower().endswith(HARDWARE_ENCODER_SUFFIXES) + except Exception: + return False + def exec_ok(cmd: List[str]) -> bool: try: @@ -82,12 +93,33 @@ def ensure_ffmpeg_and_ffprobe() -> Tuple[bool, Optional[str]]: return True, version_line -def has_encoder(encoder: str) -> bool: +_ENCODER_LIST_CACHE: Optional[set] = None + + +def _get_encoder_set() -> set: + """Fetch and cache the full encoder list from a single ffmpeg -encoders call.""" + global _ENCODER_LIST_CACHE + if _ENCODER_LIST_CACHE is not None: + return _ENCODER_LIST_CACHE try: - out = subprocess.run([config.ffmpeg_exe(), "-hide_banner", "-encoders"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) - return encoder in (out.stdout or "") + out = subprocess.run( + [config.ffmpeg_exe(), "-hide_banner", "-encoders"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, + ) + names: set = set() + for line in (out.stdout or "").splitlines(): + # Encoder lines start with a flags field (e.g. " V..... libx264") + parts = line.strip().split() + if len(parts) >= 2: + names.add(parts[1]) + _ENCODER_LIST_CACHE = names + return names except Exception: - return False + return set() + + +def has_encoder(encoder: str) -> bool: + return encoder in _get_encoder_set() def is_hardware_encoder_usable(encoder: str) -> bool: @@ -98,7 +130,7 @@ def is_hardware_encoder_usable(encoder: str) -> bool: if enc in config._ENCODER_USABLE_CACHE: return config._ENCODER_USABLE_CACHE[enc] - if not enc.endswith(("_nvenc", "_qsv", "_amf", "_videotoolbox", "_vaapi", "_v4l2m2m", "_omx")): + if not is_hardware_encoder_name(enc): ok = has_encoder(encoder) with config._GLOBAL_STATE_LOCK: config._ENCODER_USABLE_CACHE[enc] = ok @@ -109,12 +141,19 @@ def is_hardware_encoder_usable(encoder: str) -> bool: out_path = os.path.join(td, "probe.mp4") cmd = [ config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "error", - "-f", "lavfi", "-i", "testsrc=size=16x16:rate=1", - "-frames:v", "1", "-pix_fmt", "yuv420p", + # Use a realistic tiny sample so we don't reject encoders due to + # unusual minimum-size constraints. + "-f", "lavfi", "-i", "testsrc=size=128x128:rate=30", + "-frames:v", "8", "-pix_fmt", "yuv420p", "-c:v", encoder, ] if enc.endswith("_videotoolbox"): - cmd += ["-allow_sw", "1"] + # Mirror production VT options to reduce false positives in probe. + cmd += ["-b:v", "3000k"] + if enc == "h264_videotoolbox": + cmd += ["-profile:v", "high", "-g", "120"] + elif enc == "hevc_videotoolbox": + cmd += ["-tag:v", "hvc1"] cmd += ["-an", out_path] proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=8) ok = (proc.returncode == 0) and os.path.exists(out_path) and os.path.getsize(out_path) > 0 @@ -293,6 +332,14 @@ def get_encoder_friendly_label(encoder: str) -> str: return e +def _numeric_speed_key(n: str) -> int: + """Sort key for numeric presets (higher number = faster).""" + try: + return -int(n) + except Exception: + return 0 + + def sort_presets_by_speed_desc(encoder: str, presets: List[str]) -> List[str]: """Return presets ordered from fastest to slowest for given encoder.""" e = encoder.strip().lower() @@ -303,27 +350,8 @@ def sort_presets_by_speed_desc(encoder: str, presets: List[str]) -> List[str]: ] order_index = {name: i for i, name in enumerate(ordering)} return sorted(presets, key=lambda n: order_index.get(n, len(ordering))) - if e == "libsvtav1": - def speed_key(n: str) -> int: - try: - return -int(n) - except Exception: - return 0 - return sorted(presets, key=speed_key) - if e == "libaom-av1": - def speed_key(n: str) -> int: - try: - return -int(n) - except Exception: - return 0 - return sorted(presets, key=speed_key) - if e == "libvpx-vp9": - def speed_key(n: str) -> int: - try: - return -int(n) - except Exception: - return 0 - return sorted(presets, key=speed_key) + if e in ("libsvtav1", "libaom-av1", "libvpx-vp9"): + return sorted(presets, key=_numeric_speed_key) if e.endswith("_nvenc"): ordering = ["p7", "p6", "p5", "p4", "p3", "p2", "p1"] order_index = {name: i for i, name in enumerate(ordering)} diff --git a/client/ffmpeg.py b/client/ffmpeg.py index 31987d1..c90c5af 100644 --- a/client/ffmpeg.py +++ b/client/ffmpeg.py @@ -14,6 +14,41 @@ map_preset_for_encoder, pick_software_encoder_for_family, has_encoder, ) +from .hardware_monitor import HardwareMonitor + +EXTENDED_TELEMETRY_KEYS: tuple = ( + 'gpuTempMaxC', 'cpuFreqAvgMHz', 'cpuTempMaxC', + 'ffmpegCpuUtilAvg', 'ffmpegCpuUtilMax', + 'ffmpegReadMB', 'ffmpegWriteMB', 'ffmpegCpuTimeS', + 'batteryPercentStart', 'batteryPercentEnd', 'batteryPercentDrop', + 'powerSource', 'sampleCount', 'monitorDurationMs', +) + + +def _videotoolbox_target_bitrate(encoder: str, crf: Optional[int]) -> str: + """Translate CRF-like intent into a stable VideoToolbox bitrate target.""" + e = (encoder or "").strip().lower() + if crf is None: + if e == "hevc_videotoolbox": + return "3500k" + if e == "av1_videotoolbox": + return "3000k" + return "5000k" + c = max(10, min(40, int(crf))) + if e == "hevc_videotoolbox": + base = 3500 + elif e == "av1_videotoolbox": + base = 3000 + else: + base = 5000 + # Every +2 CRF lowers bitrate by ~20%; every -2 raises by ~25%. + delta = (24 - c) / 2.0 + if delta >= 0: + kbps = int(round(base * (1.25 ** delta))) + else: + kbps = int(round(base * (0.8 ** (-delta)))) + kbps = max(1200, min(22000, kbps)) + return f"{kbps}k" def build_ffmpeg_encode_cmd(*, input_path: str, output_path: str, encoder: str, preset_name: str, crf: Optional[int] = None) -> List[str]: @@ -29,39 +64,59 @@ def build_ffmpeg_encode_cmd(*, input_path: str, output_path: str, encoder: str, cmd += ["-crf", str(crf)] elif e.endswith("_nvenc"): cmd += ["-cq", str(max(0, min(51, crf)))] + elif e.endswith("_qsv"): + cmd += ["-global_quality", str(crf)] + elif e.endswith("_amf"): + cmd += ["-qp", str(crf)] + elif e.endswith("_vaapi"): + cmd += ["-qp", str(crf)] + elif e.endswith("_videotoolbox"): + # VideoToolbox reliability is significantly better with explicit bitrate. + cmd += ["-b:v", _videotoolbox_target_bitrate(e, crf)] if encoder.endswith(("_nvenc", "_qsv", "_amf", "_videotoolbox", "_vaapi")): cmd += ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"] e = encoder.strip().lower() if e.endswith("_videotoolbox"): cmd += ["-allow_sw", "1"] if e == "h264_videotoolbox": - cmd += ["-b:v", "5000k", "-profile:v", "high", "-g", "120"] + if crf is None: + cmd += ["-b:v", _videotoolbox_target_bitrate(e, None)] + cmd += ["-profile:v", "high", "-g", "120"] elif e == "hevc_videotoolbox": - cmd += ["-b:v", "5000k", "-tag:v", "hvc1"] + if crf is None: + cmd += ["-b:v", _videotoolbox_target_bitrate(e, None)] + cmd += ["-tag:v", "hvc1"] elif e == "av1_videotoolbox": - cmd += ["-b:v", "5000k"] + if crf is None: + cmd += ["-b:v", _videotoolbox_target_bitrate(e, None)] cmd += ["-an", output_path] return cmd +def _parse_frame_count_from_stderr(stderr: str) -> int: + """Extract the last frame= value from FFmpeg's stderr progress output.""" + matches = re.findall(r'frame=\s*(\d+)', stderr or '') + if matches: + try: + return int(matches[-1]) + except (ValueError, IndexError): + pass + return 0 + + def run_ffmpeg_test(input_path: str, preset: str, codec: str = "libx264", crf: Optional[int] = None) -> Dict[str, Any]: with tempfile.TemporaryDirectory() as td: out_path = os.path.join(td, "out.mp4") cmd = build_ffmpeg_encode_cmd(input_path=input_path, output_path=out_path, encoder=codec, preset_name=preset, crf=crf) + # Use -loglevel info to get frame= progress lines in stderr + if "-loglevel" in cmd: + idx = cmd.index("-loglevel") + cmd[idx + 1] = "info" start = time.time() proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) end = time.time() elapsed = max(0.0001, end - start) - try: - probe = subprocess.run([ - config.ffprobe_exe(), "-v", "error", "-count_frames", "-select_streams", "v:0", - "-show_entries", "stream=nb_read_frames", - "-of", "default=nokey=1:noprint_wrappers=1", out_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) - nb_frames_str = (probe.stdout or "").strip() - total_frames = int(nb_frames_str) if nb_frames_str.isdigit() else 0 - except Exception: - total_frames = 0 + total_frames = _parse_frame_count_from_stderr(proc.stderr) fps = (total_frames / elapsed) if total_frames > 0 else 0.0 size = os.path.getsize(out_path) if os.path.exists(out_path) else 0 result: Dict[str, Any] = {"fps": fps, "fileSizeBytes": size, "_encode_rc": proc.returncode, "elapsedMs": int(round(elapsed * 1000))} @@ -112,6 +167,47 @@ def compute_vmaf(input_path: str, encoded_path: str) -> Optional[float]: return None +def compute_ssim(input_path: str, encoded_path: str) -> Optional[float]: + cmd = [ + config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "info", + "-i", input_path, + "-i", encoded_path, + "-lavfi", "ssim", + "-f", "null", "-", + ] + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + out = proc.stdout + m = re.search(r'All:\s*([0-9]+(?:\.[0-9]+)?)', out) + if m: + return float(m.group(1)) + except Exception: + pass + return None + + +def compute_psnr(input_path: str, encoded_path: str) -> Optional[float]: + cmd = [ + config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "info", + "-i", input_path, + "-i", encoded_path, + "-lavfi", "psnr", + "-f", "null", "-", + ] + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + out = proc.stdout + m = re.search(r'average:\s*([0-9]+(?:\.[0-9]+)?|inf)', out) + if m: + val = m.group(1) + if val == 'inf': + return 100.0 + return min(float(val), 100.0) + except Exception: + pass + return None + + def _encoder_family_for(encoder: str) -> Optional[str]: e = (encoder or '').lower() if 'h264' in e: @@ -125,16 +221,45 @@ def _encoder_family_for(encoder: str) -> Optional[str]: return None +def _build_telemetry_note(telemetry: Dict[str, Any], max_len: int = 3200) -> Optional[str]: + if not telemetry: + return None + try: + blob = json.dumps(telemetry, separators=(",", ":"), sort_keys=True) + except Exception: + return None + if len(blob) > max_len: + blob = blob[:max_len] + return f"telemetry={blob}" + + +def _run_monitored(cmd: List[str]) -> tuple: + """Run an FFmpeg command with hardware monitoring via Popen. + + Returns (stdout, stderr, returncode, elapsed, hw_metrics). + """ + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + monitor = HardwareMonitor(ffmpeg_pid=proc.pid, interval=0.5) + monitor.start() + start = time.time() + stdout, stderr = proc.communicate() + end = time.time() + hw_metrics = monitor.stop() + elapsed = max(0.0001, end - start) + return stdout, stderr, proc.returncode, elapsed, hw_metrics + + def encode_to_artifact(*, input_path: str, encoder: str, preset: str, crf: Optional[int], out_dir: str, artifact_name: str) -> Dict[str, Any]: os.makedirs(out_dir, exist_ok=True) artifact_path = os.path.join(out_dir, artifact_name) cmd = build_ffmpeg_encode_cmd(input_path=input_path, output_path=artifact_path, encoder=encoder, preset_name=preset, crf=crf) - start = time.time() - proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - end = time.time() - elapsed = max(0.0001, end - start) + # Use -loglevel info to get frame= progress lines in stderr + if "-loglevel" in cmd: + idx = cmd.index("-loglevel") + cmd[idx + 1] = "info" + stdout, stderr, returncode, elapsed, hw_metrics = _run_monitored(cmd) original_encoder = encoder - if (proc.returncode != 0 or not os.path.exists(artifact_path) or os.path.getsize(artifact_path) <= 0): + if (returncode != 0 or not os.path.exists(artifact_path) or os.path.getsize(artifact_path) <= 0): family = _encoder_family_for(encoder) if family: sw = pick_software_encoder_for_family(family) @@ -142,34 +267,25 @@ def encode_to_artifact(*, input_path: str, encoder: str, preset: str, crf: Optio try: print(f" Hardware encoder '{encoder}' failed, falling back to software encoder '{sw}'...") cmd_sw = build_ffmpeg_encode_cmd(input_path=input_path, output_path=artifact_path, encoder=sw, preset_name=preset, crf=crf) - start = time.time() - proc = subprocess.run(cmd_sw, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - end = time.time() - elapsed = max(0.0001, end - start) + if "-loglevel" in cmd_sw: + idx = cmd_sw.index("-loglevel") + cmd_sw[idx + 1] = "info" + stdout, stderr, returncode, elapsed, hw_metrics = _run_monitored(cmd_sw) encoder = sw - if proc.returncode == 0 and os.path.exists(artifact_path) and os.path.getsize(artifact_path) > 0: + if returncode == 0 and os.path.exists(artifact_path) and os.path.getsize(artifact_path) > 0: print(f" Software encoder '{sw}' succeeded.") else: print(f" Software encoder '{sw}' also failed.", file=sys.stderr) except Exception as e: print(f" Fallback to software encoder failed: {e}", file=sys.stderr) - try: - probe = subprocess.run([ - config.ffprobe_exe(), '-v', 'error', '-count_frames', '-select_streams', 'v:0', - '-show_entries', 'stream=nb_read_frames', - '-of', 'default=nokey=1:noprint_wrappers=1', artifact_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) - nb_frames_str = (probe.stdout or '').strip() - total_frames = int(nb_frames_str) if nb_frames_str.isdigit() else 0 - except Exception: - total_frames = 0 + total_frames = _parse_frame_count_from_stderr(stderr) fps_val = (total_frames / elapsed) if total_frames > 0 else 0.0 size_val = os.path.getsize(artifact_path) if os.path.exists(artifact_path) else 0 err_msg: Optional[str] = None - if proc.returncode != 0 or size_val <= 0 or fps_val <= 0.0: - stderr_lines = (proc.stderr or '').splitlines() + if returncode != 0 or size_val <= 0 or fps_val <= 0.0: + stderr_lines = (stderr or '').splitlines() err_msg = '; '.join([ln.strip() for ln in stderr_lines[-5:]]) if stderr_lines else 'ffmpeg failed' - return { + result: Dict[str, Any] = { 'artifactPath': artifact_path, 'encoderUsed': encoder, 'elapsedMs': int(round(elapsed * 1000)), @@ -177,6 +293,65 @@ def encode_to_artifact(*, input_path: str, encoder: str, preset: str, crf: Optio 'fileSizeBytes': int(size_val), 'error': err_msg, } + if hw_metrics.gpu_util_avg is not None: + result['gpuUtilAvg'] = round(hw_metrics.gpu_util_avg, 2) + if hw_metrics.gpu_power_avg_w is not None: + result['gpuPowerAvgW'] = round(hw_metrics.gpu_power_avg_w, 2) + if hw_metrics.gpu_mem_peak_mb is not None: + result['gpuMemPeakMB'] = round(hw_metrics.gpu_mem_peak_mb, 2) + if hw_metrics.cpu_util_avg is not None: + result['cpuUtilAvg'] = round(hw_metrics.cpu_util_avg, 2) + if hw_metrics.cpu_util_max is not None: + result['cpuUtilMax'] = round(hw_metrics.cpu_util_max, 2) + if hw_metrics.peak_memory_mb is not None: + result['peakMemoryMB'] = round(hw_metrics.peak_memory_mb, 2) + if hw_metrics.thermal_throttle is not None: + result['thermalThrottle'] = hw_metrics.thermal_throttle + + # Extended telemetry fields (queryable columns on newer servers) + if hw_metrics.gpu_temp_max_c is not None: + result['gpuTempMaxC'] = round(hw_metrics.gpu_temp_max_c, 2) + if hw_metrics.cpu_freq_avg_mhz is not None: + result['cpuFreqAvgMHz'] = round(hw_metrics.cpu_freq_avg_mhz, 2) + if hw_metrics.cpu_temp_max_c is not None: + result['cpuTempMaxC'] = round(hw_metrics.cpu_temp_max_c, 2) + if hw_metrics.ffmpeg_cpu_util_avg is not None: + result['ffmpegCpuUtilAvg'] = round(hw_metrics.ffmpeg_cpu_util_avg, 2) + if hw_metrics.ffmpeg_cpu_util_max is not None: + result['ffmpegCpuUtilMax'] = round(hw_metrics.ffmpeg_cpu_util_max, 2) + if hw_metrics.ffmpeg_read_mb is not None: + result['ffmpegReadMB'] = round(hw_metrics.ffmpeg_read_mb, 2) + if hw_metrics.ffmpeg_write_mb is not None: + result['ffmpegWriteMB'] = round(hw_metrics.ffmpeg_write_mb, 2) + if hw_metrics.ffmpeg_cpu_time_s is not None: + result['ffmpegCpuTimeS'] = round(hw_metrics.ffmpeg_cpu_time_s, 3) + if hw_metrics.battery_percent_start is not None: + result['batteryPercentStart'] = round(hw_metrics.battery_percent_start, 2) + if hw_metrics.battery_percent_end is not None: + result['batteryPercentEnd'] = round(hw_metrics.battery_percent_end, 2) + if hw_metrics.battery_percent_drop is not None: + result['batteryPercentDrop'] = round(hw_metrics.battery_percent_drop, 2) + if hw_metrics.power_source is not None: + result['powerSource'] = hw_metrics.power_source + if hw_metrics.sample_count is not None: + result['sampleCount'] = int(hw_metrics.sample_count) + if hw_metrics.monitor_duration_ms is not None: + result['monitorDurationMs'] = int(hw_metrics.monitor_duration_ms) + + telemetry: Dict[str, Any] = {} + for key in ('gpuUtilAvg', 'gpuPowerAvgW', 'gpuMemPeakMB', + 'cpuUtilAvg', 'cpuUtilMax', 'peakMemoryMB', 'thermalThrottle'): + if key in result: + telemetry[key] = result[key] + for key in EXTENDED_TELEMETRY_KEYS: + if key in result: + telemetry[key] = result[key] + note = _build_telemetry_note(telemetry) + if telemetry: + result['telemetry'] = telemetry + if note: + result['telemetryNote'] = note + return result def compute_vmaf_parallel(input_path: str, artifacts: List[str], workers: int) -> Dict[str, Optional[float]]: @@ -204,55 +379,109 @@ def compute_vmaf_parallel(input_path: str, artifacts: List[str], workers: int) - return results +def compute_metrics_parallel(input_path: str, artifacts: List[str], workers: int, quiet: bool = False) -> Dict[str, Dict[str, Optional[float]]]: + """Compute VMAF, SSIM, and PSNR for each artifact in parallel. + + Returns {artifact_path: {'vmaf': X, 'ssim': Y, 'psnr': Z}}. + """ + results: Dict[str, Dict[str, Optional[float]]] = {ap: {'vmaf': None, 'ssim': None, 'psnr': None} for ap in artifacts} + if not artifacts: + return results + metric_fns: List[tuple] = [] + for ap in artifacts: + metric_fns.append((ap, 'vmaf', compute_vmaf)) + metric_fns.append((ap, 'ssim', compute_ssim)) + metric_fns.append((ap, 'psnr', compute_psnr)) + total = len(metric_fns) + done = 0 + if not quiet: + print(f"Calculating quality metrics (VMAF, SSIM, PSNR) for {len(artifacts)} artifact(s) with {max(1, workers)} worker(s)...") + with ThreadPoolExecutor(max_workers=max(1, workers)) as ex: + futs = {ex.submit(fn, input_path, ap): (ap, metric_name) for ap, metric_name, fn in metric_fns} + for fut in as_completed(futs): + ap, metric_name = futs[fut] + try: + results[ap][metric_name] = fut.result() + except Exception: + results[ap][metric_name] = None + done += 1 + try: + pct = (done / total) * 100.0 + except Exception: + pct = 100.0 + if not quiet: + print(f"Metrics progress: {done}/{total} ({pct:.0f}%)") + if not quiet: + print("Quality metrics batch complete.") + return results + + def run_single_benchmark(hardware: config.HardwareInfo, input_path: str, preset: str, codec: str = "libx264", crf: Optional[int] = None) -> Dict[str, Any]: - result = run_ffmpeg_test(input_path, preset=preset, codec=codec, crf=crf) - if (result.get("_encode_rc", 1) != 0 or float(result.get("fps", 0.0)) <= 0 or int(result.get("fileSizeBytes", 0)) <= 0): - family = None - if codec.endswith("_videotoolbox"): - family = "h264" if "h264" in codec else ("hevc" if "hevc" in codec else ("av1" if "av1" in codec else None)) - elif codec.endswith(('_nvenc', '_qsv', '_amf', '_vaapi')): - if 'h264' in codec: - family = 'h264' - elif 'hevc' in codec: - family = 'hevc' - elif 'av1' in codec: - family = 'av1' - elif 'vp9' in codec: - family = 'vp9' - if family: - sw = pick_software_encoder_for_family(family) - if sw and sw != codec: - print(f"Retrying with software encoder {sw} for preset={preset}...") - result = run_ffmpeg_test(input_path, preset=preset, codec=sw, crf=crf) - codec = sw + # Single encode via encode_to_artifact (which handles HW→SW fallback), + # then compute VMAF on the same artifact. No double-encode. (B-C01) with tempfile.TemporaryDirectory() as td: - encoded_path = os.path.join(td, "out.mp4") - cmd = build_ffmpeg_encode_cmd(input_path=input_path, output_path=encoded_path, encoder=codec, preset_name=preset, crf=crf) + info = encode_to_artifact( + input_path=input_path, + encoder=codec, + preset=preset, + crf=crf, + out_dir=td, + artifact_name="out.mp4", + ) + actual_encoder = info.get('encoderUsed', codec) vmaf: Optional[float] = None - if result.get("_encode_rc", 1) == 0 and float(result.get("fps", 0.0)) > 0 and int(result.get("fileSizeBytes", 0)) > 0: - print("Calculating VMAF...") - subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - vmaf = compute_vmaf(input_path, encoded_path) + ssim: Optional[float] = None + psnr: Optional[float] = None + artifact_path = info.get('artifactPath', os.path.join(td, "out.mp4")) + if info.get('error') is None and float(info.get('fps', 0.0)) > 0 and int(info.get('fileSizeBytes', 0)) > 0: + print("Calculating quality metrics (VMAF, SSIM, PSNR)...") + vmaf = compute_vmaf(input_path, artifact_path) + ssim = compute_ssim(input_path, artifact_path) + psnr = compute_psnr(input_path, artifact_path) + payload = { "cpuModel": hardware.cpuModel, "gpuModel": hardware.gpuModel or "", "ramGB": hardware.ramGB, "os": hardware.os, - "codec": codec, + "codec": actual_encoder, "preset": preset, "crf": crf, - "fps": float(result["fps"]), - "fileSizeBytes": int(result["fileSizeBytes"]), - "runMs": int(result.get("elapsedMs") or 0), + "fps": float(info.get('fps', 0.0)), + "fileSizeBytes": int(info.get('fileSizeBytes', 0)), + "runMs": int(info.get('elapsedMs') or 0), } if vmaf is not None: payload["vmaf"] = float(vmaf) - if result.get("_error"): - payload["notes"] = str(result["_error"])[:500] + if ssim is not None: + payload["ssim"] = float(ssim) + if psnr is not None: + payload["psnr"] = float(psnr) + # Hardware metrics from the monitor + for hw_key in ('gpuUtilAvg', 'gpuPowerAvgW', 'gpuMemPeakMB', + 'cpuUtilAvg', 'cpuUtilMax', 'peakMemoryMB', 'thermalThrottle'): + if info.get(hw_key) is not None: + payload[hw_key] = info[hw_key] + for hw_key in EXTENDED_TELEMETRY_KEYS: + if info.get(hw_key) is not None: + payload[hw_key] = info[hw_key] + note_parts: List[str] = [] + if info.get('error'): + note_parts.append(str(info['error']).strip()) + if info.get('telemetryNote'): + note_parts.append(str(info['telemetryNote']).strip()) + if note_parts: + payload["notes"] = "; ".join(note_parts)[:3500] return payload +_SHA256_CACHE: Dict[str, str] = {} + + def sha256_of_file(path: str) -> str: + resolved = os.path.realpath(path) + if resolved in _SHA256_CACHE: + return _SHA256_CACHE[resolved] hasher = hashlib.sha256() with open(path, 'rb') as f: while True: @@ -260,7 +489,9 @@ def sha256_of_file(path: str) -> str: if not chunk: break hasher.update(chunk) - return hasher.hexdigest() + digest = hasher.hexdigest() + _SHA256_CACHE[resolved] = digest + return digest def verify_sample_video(path: str) -> tuple: diff --git a/client/hardware_monitor.py b/client/hardware_monitor.py new file mode 100644 index 0000000..f1db421 --- /dev/null +++ b/client/hardware_monitor.py @@ -0,0 +1,526 @@ +"""Background hardware metrics sampling during FFmpeg encodes. + +Collects GPU utilization/power (NVIDIA via pynvml), CPU utilization (psutil), +process memory (psutil), and thermal throttling detection. +""" + +import threading +import time +import platform +import subprocess +from dataclasses import dataclass +from typing import Optional, List, Tuple + +import psutil +from .config import normalize_cpu_freq_mhz + +# Optional NVIDIA GPU monitoring +try: + import pynvml # type: ignore + _PYNVML_AVAILABLE = True +except Exception: + pynvml = None # type: ignore + _PYNVML_AVAILABLE = False + +# Optional GPU telemetry fallback (best-effort) +try: + import GPUtil # type: ignore + _GPUTIL_AVAILABLE = True +except Exception: + GPUtil = None # type: ignore + _GPUTIL_AVAILABLE = False + +_NVML_INITIALIZED = False +_NVML_INIT_LOCK = threading.Lock() + + +def _ensure_nvml() -> bool: + """Lazily initialize NVML once per process. Returns True if usable.""" + global _NVML_INITIALIZED + if not _PYNVML_AVAILABLE: + return False + if _NVML_INITIALIZED: + return True + with _NVML_INIT_LOCK: + if _NVML_INITIALIZED: + return True + try: + pynvml.nvmlInit() + _NVML_INITIALIZED = True + return True + except Exception: + return False + + +@dataclass +class HardwareMetrics: + gpu_util_avg: Optional[float] = None + gpu_power_avg_w: Optional[float] = None + gpu_mem_peak_mb: Optional[float] = None + gpu_temp_max_c: Optional[float] = None + cpu_util_avg: Optional[float] = None + cpu_util_max: Optional[float] = None + cpu_freq_avg_mhz: Optional[float] = None + cpu_temp_max_c: Optional[float] = None + peak_memory_mb: Optional[float] = None + ffmpeg_cpu_util_avg: Optional[float] = None + ffmpeg_cpu_util_max: Optional[float] = None + ffmpeg_read_mb: Optional[float] = None + ffmpeg_write_mb: Optional[float] = None + ffmpeg_cpu_time_s: Optional[float] = None + battery_percent_start: Optional[float] = None + battery_percent_end: Optional[float] = None + battery_percent_drop: Optional[float] = None + power_source: Optional[str] = None + sample_count: Optional[int] = None + monitor_duration_ms: Optional[int] = None + thermal_throttle: Optional[bool] = None + + +@dataclass +class _GpuSample: + util_pct: Optional[float] = None + power_w: Optional[float] = None + mem_used_mb: Optional[float] = None + temp_c: Optional[float] = None + + +@dataclass +class _CpuSample: + overall_pct: float + freq_mhz: Optional[float] = None + temp_c: Optional[float] = None + + +@dataclass +class _ProcSample: + cpu_pct: float + + +class HardwareMonitor: + """Samples hardware metrics in a background thread while an encode runs.""" + + def __init__(self, ffmpeg_pid: Optional[int] = None, interval: float = 0.5): + self._ffmpeg_pid = ffmpeg_pid + self._interval = max(0.1, interval) + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + self._gpu_samples: List[_GpuSample] = [] + self._cpu_samples: List[_CpuSample] = [] + self._proc_samples: List[_ProcSample] = [] + self._memory_peak_bytes: float = 0.0 + self._lock = threading.Lock() + self._gpu_handle = None + self._start_ts: float = 0.0 + self._end_ts: float = 0.0 + self._battery_start_pct: Optional[float] = None + self._battery_end_pct: Optional[float] = None + self._power_source: Optional[str] = None + self._ffmpeg_io_start: Optional[Tuple[float, float]] = None + self._ffmpeg_io_end: Optional[Tuple[float, float]] = None + self._ffmpeg_cpu_time_start: Optional[float] = None + self._ffmpeg_cpu_time_end: Optional[float] = None + self._cpu_freq_reference_mhz: Optional[float] = self._detect_cpu_freq_reference_mhz() + + def start(self) -> None: + self._stop_event.clear() + self._gpu_samples.clear() + self._cpu_samples.clear() + self._proc_samples.clear() + self._memory_peak_bytes = 0.0 + self._start_ts = time.time() + self._end_ts = 0.0 + self._battery_start_pct, source = self._read_battery_state() + self._battery_end_pct = None + self._power_source = source + self._ffmpeg_io_start = self._read_ffmpeg_io_totals() + self._ffmpeg_io_end = None + self._ffmpeg_cpu_time_start = self._read_ffmpeg_cpu_time() + self._ffmpeg_cpu_time_end = None + + if _ensure_nvml(): + try: + self._gpu_handle = pynvml.nvmlDeviceGetHandleByIndex(0) + except Exception: + self._gpu_handle = None + else: + self._gpu_handle = None + + # Prime psutil so the first real sample isn't always 0 + try: + psutil.cpu_percent(interval=None) + except Exception: + pass + for proc in self._collect_process_tree(): + try: + proc.cpu_percent(interval=None) + except Exception: + continue + + self._thread = threading.Thread(target=self._sample_loop, daemon=True) + self._thread.start() + + def stop(self) -> HardwareMetrics: + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + self._end_ts = time.time() + self._ffmpeg_io_end = self._read_ffmpeg_io_totals() + self._ffmpeg_cpu_time_end = self._read_ffmpeg_cpu_time() + self._battery_end_pct, source = self._read_battery_state() + if source: + self._power_source = source + return self._aggregate() + + # -- internal ----------------------------------------------------------- + + def _sample_loop(self) -> None: + while not self._stop_event.is_set(): + self._take_sample() + self._stop_event.wait(self._interval) + + def _take_sample(self) -> None: + self._sample_gpu() + self._sample_cpu() + self._sample_ffmpeg_process() + self._sample_memory() + + def _sample_gpu(self) -> None: + if self._gpu_handle is not None: + try: + util = pynvml.nvmlDeviceGetUtilizationRates(self._gpu_handle) + power_w: Optional[float] = None + try: + power_w = float(pynvml.nvmlDeviceGetPowerUsage(self._gpu_handle)) / 1000.0 + except Exception: + power_w = None + mem_used_mb: Optional[float] = None + try: + mem_info = pynvml.nvmlDeviceGetMemoryInfo(self._gpu_handle) + mem_used_mb = float(mem_info.used) / (1024 * 1024) + except Exception: + mem_used_mb = None + temp_c: Optional[float] = None + try: + temp_c = float( + pynvml.nvmlDeviceGetTemperature( + self._gpu_handle, pynvml.NVML_TEMPERATURE_GPU + ) + ) + except Exception: + temp_c = None + with self._lock: + self._gpu_samples.append( + _GpuSample( + util_pct=float(util.gpu), + power_w=power_w, + mem_used_mb=mem_used_mb, + temp_c=temp_c, + ) + ) + return + except Exception: + pass + + # Fallback path for non-NVML systems (best-effort only) + if not _GPUTIL_AVAILABLE: + return + try: + gpus = GPUtil.getGPUs() # type: ignore[attr-defined] + if not gpus: + return + g = gpus[0] + util_raw = getattr(g, 'load', None) + util_pct = float(util_raw) * 100.0 if isinstance(util_raw, (int, float)) else None + mem_raw = getattr(g, 'memoryUsed', None) + mem_used_mb = float(mem_raw) if isinstance(mem_raw, (int, float)) else None + power_raw = getattr(g, 'powerDraw', None) + power_w = float(power_raw) if isinstance(power_raw, (int, float)) and power_raw >= 0 else None + temp_raw = getattr(g, 'temperature', None) + temp_c = float(temp_raw) if isinstance(temp_raw, (int, float)) and temp_raw >= 0 else None + with self._lock: + self._gpu_samples.append( + _GpuSample( + util_pct=util_pct, + power_w=power_w, + mem_used_mb=mem_used_mb, + temp_c=temp_c, + ) + ) + except Exception: + pass + + def _sample_cpu(self) -> None: + try: + overall = psutil.cpu_percent(interval=None) + freq: Optional[float] = None + try: + f = psutil.cpu_freq() + if f and f.current and f.current > 0: + freq = normalize_cpu_freq_mhz( + f.current, + reference_mhz=self._cpu_freq_reference_mhz, + ) + if freq is not None and self._cpu_freq_reference_mhz is None: + self._cpu_freq_reference_mhz = freq + except Exception: + pass + temp: Optional[float] = None + try: + temps = psutil.sensors_temperatures() + if temps: + for name in ('coretemp', 'k10temp', 'cpu_thermal', + 'cpu-thermal', 'acpitz'): + if name in temps and temps[name]: + readings = [e.current for e in temps[name] + if e.current and e.current > 0] + if readings: + temp = max(readings) + break + except Exception: + pass + sample = _CpuSample(overall_pct=float(overall), freq_mhz=freq, + temp_c=temp) + with self._lock: + self._cpu_samples.append(sample) + except Exception: + pass + + def _sample_ffmpeg_process(self) -> None: + total_pct = 0.0 + any_sample = False + for proc in self._collect_process_tree(): + try: + pct = proc.cpu_percent(interval=None) + if pct >= 0: + total_pct += float(pct) + any_sample = True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception: + continue + if any_sample: + with self._lock: + self._proc_samples.append(_ProcSample(cpu_pct=total_pct)) + + def _sample_memory(self) -> None: + rss = 0.0 + any_sample = False + for proc in self._collect_process_tree(): + try: + rss += float(proc.memory_info().rss) + any_sample = True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception: + continue + if any_sample: + with self._lock: + if rss > self._memory_peak_bytes: + self._memory_peak_bytes = rss + + def _aggregate(self) -> HardwareMetrics: + m = HardwareMetrics() + + with self._lock: + gpu = list(self._gpu_samples) + cpu = list(self._cpu_samples) + proc = list(self._proc_samples) + peak_mem = self._memory_peak_bytes + + # GPU metrics + if gpu: + util_vals = [float(s.util_pct) for s in gpu if s.util_pct is not None] + if util_vals: + m.gpu_util_avg = sum(util_vals) / len(util_vals) + power_vals = [float(s.power_w) for s in gpu if s.power_w is not None and s.power_w >= 0] + if power_vals: + m.gpu_power_avg_w = sum(power_vals) / len(power_vals) + mem_vals = [float(s.mem_used_mb) for s in gpu if s.mem_used_mb is not None and s.mem_used_mb >= 0] + if mem_vals: + m.gpu_mem_peak_mb = max(mem_vals) + gpu_temps = [float(s.temp_c) for s in gpu if s.temp_c is not None and s.temp_c > 0] + if gpu_temps: + m.gpu_temp_max_c = max(gpu_temps) + + # CPU metrics + if cpu: + vals = [s.overall_pct for s in cpu] + m.cpu_util_avg = sum(vals) / len(vals) + m.cpu_util_max = max(vals) + freq_vals = [float(s.freq_mhz) for s in cpu if s.freq_mhz and s.freq_mhz > 0] + if freq_vals: + m.cpu_freq_avg_mhz = sum(freq_vals) / len(freq_vals) + cpu_temps = [float(s.temp_c) for s in cpu if s.temp_c and s.temp_c > 0] + if cpu_temps: + m.cpu_temp_max_c = max(cpu_temps) + + # FFmpeg process metrics + if proc: + vals = [s.cpu_pct for s in proc] + m.ffmpeg_cpu_util_avg = sum(vals) / len(vals) + m.ffmpeg_cpu_util_max = max(vals) + + # Memory + if peak_mem > 0: + m.peak_memory_mb = peak_mem / (1024 * 1024) + + # Process I/O deltas + if self._ffmpeg_io_start and self._ffmpeg_io_end: + read_delta = max(0.0, self._ffmpeg_io_end[0] - self._ffmpeg_io_start[0]) + write_delta = max(0.0, self._ffmpeg_io_end[1] - self._ffmpeg_io_start[1]) + m.ffmpeg_read_mb = read_delta / (1024 * 1024) + m.ffmpeg_write_mb = write_delta / (1024 * 1024) + + # Process CPU-time delta + if self._ffmpeg_cpu_time_start is not None and self._ffmpeg_cpu_time_end is not None: + m.ffmpeg_cpu_time_s = max(0.0, self._ffmpeg_cpu_time_end - self._ffmpeg_cpu_time_start) + + # Power source / battery metrics + m.battery_percent_start = self._battery_start_pct + m.battery_percent_end = self._battery_end_pct + if self._battery_start_pct is not None and self._battery_end_pct is not None: + m.battery_percent_drop = max(0.0, self._battery_start_pct - self._battery_end_pct) + m.power_source = self._power_source + + # Sampling stats + m.sample_count = max(len(cpu), len(gpu), len(proc)) + if self._start_ts > 0 and self._end_ts >= self._start_ts: + m.monitor_duration_ms = int(round((self._end_ts - self._start_ts) * 1000.0)) + + # Thermal throttling detection + m.thermal_throttle = self._detect_throttle(gpu, cpu) + + return m + + def _detect_throttle(self, gpu: List[_GpuSample], + cpu: List[_CpuSample]) -> Optional[bool]: + """Heuristic throttle detection based on frequency drop and temperature.""" + throttled = False + + # CPU frequency-based detection: compare first 25% avg vs last 25% + freq_samples = [s.freq_mhz for s in cpu if s.freq_mhz and s.freq_mhz > 0] + if len(freq_samples) >= 8: + q = max(1, len(freq_samples) // 4) + early = sum(freq_samples[:q]) / q + late = sum(freq_samples[-q:]) / q + if early > 0 and (early - late) / early > 0.15: + throttled = True + + # CPU temperature threshold + cpu_temps = [s.temp_c for s in cpu if s.temp_c and s.temp_c > 0] + if cpu_temps and max(cpu_temps) >= 95.0: + throttled = True + + # GPU temperature threshold + gpu_temps = [s.temp_c for s in gpu if s.temp_c and s.temp_c > 0] + if gpu_temps and max(gpu_temps) >= 90.0: + throttled = True + + if not freq_samples and not cpu_temps and not gpu_temps: + return None + + return throttled + + def _collect_process_tree(self) -> List[psutil.Process]: + pid = self._ffmpeg_pid + if pid is None: + return [] + try: + root = psutil.Process(pid) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return [] + except Exception: + return [] + procs = [root] + try: + procs.extend(root.children(recursive=True)) + except Exception: + pass + return procs + + def _read_ffmpeg_io_totals(self) -> Optional[Tuple[float, float]]: + read_total = 0.0 + write_total = 0.0 + any_sample = False + for proc in self._collect_process_tree(): + try: + io = proc.io_counters() + read_total += float(getattr(io, 'read_bytes', 0.0) or 0.0) + write_total += float(getattr(io, 'write_bytes', 0.0) or 0.0) + any_sample = True + except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError): + continue + except Exception: + continue + if not any_sample: + return None + return (read_total, write_total) + + def _read_ffmpeg_cpu_time(self) -> Optional[float]: + cpu_time_s = 0.0 + any_sample = False + for proc in self._collect_process_tree(): + try: + t = proc.cpu_times() + cpu_time_s += float(getattr(t, 'user', 0.0) or 0.0) + cpu_time_s += float(getattr(t, 'system', 0.0) or 0.0) + any_sample = True + except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError): + continue + except Exception: + continue + if not any_sample: + return None + return cpu_time_s + + def _read_battery_state(self) -> Tuple[Optional[float], Optional[str]]: + try: + b = psutil.sensors_battery() + if b is None: + return (None, None) + pct = float(b.percent) if b.percent is not None else None + source = "ac" if bool(b.power_plugged) else "battery" + return (pct, source) + except Exception: + return (None, None) + + def _detect_cpu_freq_reference_mhz(self) -> Optional[float]: + refs: List[float] = [] + try: + f = psutil.cpu_freq() + if f and f.max and f.max > 0: + n = normalize_cpu_freq_mhz(f.max) + if n is not None: + refs.append(n) + except Exception: + pass + try: + f = psutil.cpu_freq() + if f and f.current and f.current > 0: + n = normalize_cpu_freq_mhz(f.current) + if n is not None: + refs.append(n) + except Exception: + pass + if platform.system() == "Darwin": + try: + proc = subprocess.run( + ["sysctl", "-n", "hw.cpufrequency_max"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=1, + ) + hz = float((proc.stdout or "").strip() or "0") + if hz > 0: + mhz = hz / 1_000_000.0 + n = normalize_cpu_freq_mhz(mhz) + if n is not None: + refs.append(n) + except Exception: + pass + if not refs: + return None + return sum(refs) / len(refs) diff --git a/client/main.py b/client/main.py index cbd4092..99ad86b 100644 --- a/client/main.py +++ b/client/main.py @@ -31,11 +31,13 @@ normalize_codec_family, pick_software_encoder_for_family, discover_hardware_encoders_for_family, list_all_available_encoders, enumerate_supported_presets_for_encoder, sort_presets_by_speed_desc, - get_encoder_friendly_label, + get_encoder_friendly_label, is_hardware_encoder_name, is_hardware_encoder_usable, SOFTWARE_ENCODERS_ORDER, HARDWARE_ENCODERS, ) from .ffmpeg import ( run_ffmpeg_test, encode_to_artifact, compute_vmaf_parallel, + compute_metrics_parallel, + EXTENDED_TELEMETRY_KEYS, run_single_benchmark, sha256_of_file, verify_sample_video, load_presets_config, get_default_sample_path, ) @@ -44,10 +46,33 @@ from .ui import ( prompt_yes_no, prompt_choice, prompt_text, _clear_screen, confirm_benchmark_readiness, - print_end_screen, + print_end_screen, print_benchmark_result, + BenchmarkProgress, BatchRunDashboard, + print_info, print_success, print_warning, print_batch_summary, ) +def _resolve_input_for_task( + default_input: str, + default_input_hash: str, +) -> Tuple[str, str]: + """Return (effective_input_path, input_hash) for a task.""" + return default_input, default_input_hash + + +def _infer_encoder_family(encoder: str) -> Optional[str]: + e = (encoder or "").strip().lower() + if "h264" in e: + return "h264" + if "hevc" in e or "h265" in e: + return "hevc" + if "av1" in e: + return "av1" + if "vp9" in e: + return "vp9" + return None + + def run_benchmark_batch(*, hardware: HardwareInfo, base_url: str, args: argparse.Namespace, tasks: List[Dict[str, Any]]) -> int: ok, ffmpeg_version = ensure_ffmpeg_and_ffprobe() if not ok: @@ -68,106 +93,264 @@ def run_benchmark_batch(*, hardware: HardwareInfo, base_url: str, args: argparse file=sys.stderr, ) return 6 - input_hash = sha256_of_file(input_path) + default_input_hash = sha256_of_file(input_path) client_version = "client/0.1.0" workers = resolve_batch_size(getattr(args, 'batch_size', 0)) + chunk_size = max(1, workers) + total_tasks = len(tasks) + total_batches = (total_tasks + chunk_size - 1) // chunk_size if total_tasks > 0 else 0 + run_started_at = time.time() + baseline_rows: List[Dict[str, Any]] = [] if not getattr(args, 'no_submit', False): - _ = fetch_baseline_rows(base_url) + baseline_rows = fetch_baseline_rows(base_url) completed_count_local = 0 - total_tasks = len(tasks) processed_total = 0 - for i in range(0, len(tasks), max(1, workers)): - chunk = tasks[i:i + max(1, workers)] - with tempfile.TemporaryDirectory() as batch_dir: - artifacts_info: List[Dict[str, Any]] = [] - print(f"Encoding batch {i//max(1,workers)+1}: {len(chunk)} task(s) → {batch_dir}") - for idx, t in enumerate(chunk, start=1): - enc = t['encoder'] - preset = t['preset'] - crf = t.get('crf') - bg_load = measure_background_cpu_load(3.0, 0.5) - name = f"{enc.replace('/', '_')}-{preset}-{str(crf) if crf is not None else 'none'}-{idx}.mp4" - global_index = processed_total + idx - try: - overall_pct = ((global_index - 1) / max(1, total_tasks)) * 100.0 - except Exception: - overall_pct = 0.0 - print(f" - Encoding {idx}/{len(chunk)} in batch | Overall {global_index-1}/{total_tasks} ({overall_pct:.0f}%) → {enc} {preset} crf={crf}") - info = encode_to_artifact(input_path=input_path, encoder=enc, preset=preset, crf=crf, out_dir=batch_dir, artifact_name=name) - info['backgroundCpuPct'] = float(bg_load) - info['task'] = t - artifacts_info.append(info) - apaths = [x['artifactPath'] for x in artifacts_info] - vmaf_map = compute_vmaf_parallel(input_path, apaths, workers) - baseline_rows = fetch_baseline_rows(base_url) - for info in artifacts_info: - t = info['task'] - payload: Dict[str, Any] = { - 'cpuModel': hardware.cpuModel, - 'gpuModel': hardware.gpuModel or "", - 'ramGB': hardware.ramGB, - 'os': hardware.os, - 'codec': info.get('encoderUsed') or t['encoder'], - 'preset': t['preset'], - 'crf': t.get('crf'), - 'fps': float(info.get('fps') or 0.0), - 'fileSizeBytes': int(info.get('fileSizeBytes') or 0), - 'runMs': int(info.get('elapsedMs') or 0), - 'ffmpegVersion': ffmpeg_version, - 'encoderName': info.get('encoderUsed') or t['encoder'], - 'clientVersion': client_version, - 'inputHash': input_hash, - } - vmaf_score = vmaf_map.get(info['artifactPath']) - if vmaf_score is not None: - payload['vmaf'] = float(vmaf_score) - if info.get('error'): - payload['notes'] = str(info['error'])[:500] - skip, reason = should_skip_submission(hardware=hardware, payload=payload, background_cpu_pct=float(info.get('backgroundCpuPct') or 0.0), baseline_rows=baseline_rows) - if skip: - print(f"Skipped submission for {payload['codec']} {payload['preset']} (reason: {reason})") - if not args.no_submit: - try: - fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-skipped-{payload['preset']}.json") - payload_to_save = dict(payload) - if reason: + pre_batch_bg_load = measure_background_cpu_load(3.0, 0.5) + submitted_count = 0 + skipped_count = 0 + queued_count = 0 + failed_count = 0 + + def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> str: + label = f"{codec} {preset}".strip() + stats = f"ok={submitted_count} skip={skipped_count} queue={queued_count} fail={failed_count}" + total = max(1, total_tasks) + if label: + return f"{stage} {index}/{total}: {label} | {stats}" + return f"{stage} {index}/{total} | {stats}" + + try: + with BatchRunDashboard(total_tasks=total_tasks, total_batches=total_batches, hardware=hardware) as progress: + for i in range(0, len(tasks), chunk_size): + chunk = tasks[i:i + chunk_size] + batch_no = (i // chunk_size) + 1 + with tempfile.TemporaryDirectory() as batch_dir: + artifacts_info: List[Dict[str, Any]] = [] + print_info(f"Batch {batch_no}/{total_batches}: {len(chunk)} task(s)") + progress.start_batch(batch_no=batch_no, batch_size=len(chunk)) + progress.set_description(_batch_status(f"Batch {batch_no}/{total_batches} preparing", processed_total + 1)) + + for idx, t in enumerate(chunk, start=1): + enc = t['encoder'] + preset = t['preset'] + crf = t.get('crf') + bg_load = pre_batch_bg_load + name = f"{enc.replace('/', '_')}-{preset}-{str(crf) if crf is not None else 'none'}-{idx}.mp4" + global_index = processed_total + idx + progress.set_description(_batch_status("Encoding", global_index, enc, preset) + f" [{enc}, {preset}, crf={crf}]") + progress.set_current_test( + stage="Encoding", + encoder=enc, + preset=preset, + crf=crf, + passes=1, + isHardware=is_hardware_encoder_name(enc), + ) + effective_input, input_hash = _resolve_input_for_task(input_path, default_input_hash) + + info = encode_to_artifact( + input_path=effective_input, + encoder=enc, + preset=preset, + crf=crf, + out_dir=batch_dir, + artifact_name=name, + ) + + info['backgroundCpuPct'] = float(bg_load) + info['task'] = t + info['_input_hash'] = input_hash + info['_effective_input'] = effective_input + final_encoder = str(info.get('encoderUsed') or enc) + progress.set_current_test( + stage="Encoded", + encoder=final_encoder, + preset=preset, + crf=crf, + passes=1, + isHardware=is_hardware_encoder_name(final_encoder), + ) + progress.update_machine_metrics(info) + artifacts_info.append(info) + progress.advance_phase( + description=_batch_status("Encoded", processed_total + idx, final_encoder, preset), + ) + + for metric_idx, info in enumerate(artifacts_info, start=1): + effective_input = info.get('_effective_input', input_path) + ap = info['artifactPath'] + metric_index = processed_total + metric_idx + progress.set_current_test( + stage="Metrics", + encoder=str(info.get('encoderUsed') or info['task']['encoder']), + preset=str(info['task']['preset']), + crf=info['task'].get('crf'), + passes=1, + isHardware=is_hardware_encoder_name(str(info.get('encoderUsed') or info['task']['encoder'])), + ) + if info.get('error') is None and float(info.get('fps', 0.0)) > 0: + progress.set_description( + _batch_status( + "Metrics", metric_index, + str(info.get('encoderUsed') or info['task']['encoder']), + str(info['task']['preset']), + ) + ) + metrics = compute_metrics_parallel(effective_input, [ap], workers, quiet=True) + info['_metrics'] = metrics.get(ap, {}) + else: + info['_metrics'] = {} + progress.advance_phase( + description=_batch_status( + "Metrics done", + metric_index, + str(info.get('encoderUsed') or info['task']['encoder']), + str(info['task']['preset']), + ), + ) + + for info in artifacts_info: + t = info['task'] + input_hash = info.get('_input_hash', default_input_hash) + payload: Dict[str, Any] = { + 'cpuModel': hardware.cpuModel, + 'gpuModel': hardware.gpuModel or "", + 'ramGB': hardware.ramGB, + 'os': hardware.os, + 'codec': info.get('encoderUsed') or t['encoder'], + 'preset': t['preset'], + 'crf': t.get('crf'), + 'passes': 1, + 'fps': float(info.get('fps') or 0.0), + 'fileSizeBytes': int(info.get('fileSizeBytes') or 0), + 'runMs': int(info.get('elapsedMs') or 0), + 'ffmpegVersion': ffmpeg_version, + 'encoderName': info.get('encoderUsed') or t['encoder'], + 'clientVersion': client_version, + 'inputHash': input_hash, + } + artifact_metrics = info.get('_metrics', {}) + vmaf_score = artifact_metrics.get('vmaf') + if vmaf_score is not None: + payload['vmaf'] = float(vmaf_score) + ssim_score = artifact_metrics.get('ssim') + if ssim_score is not None: + payload['ssim'] = float(ssim_score) + psnr_score = artifact_metrics.get('psnr') + if psnr_score is not None: + payload['psnr'] = float(psnr_score) + + for hw_key in ('gpuUtilAvg', 'gpuPowerAvgW', 'gpuMemPeakMB', + 'cpuUtilAvg', 'cpuUtilMax', 'peakMemoryMB', 'thermalThrottle'): + if info.get(hw_key) is not None: + payload[hw_key] = info[hw_key] + for hw_key in EXTENDED_TELEMETRY_KEYS: + if info.get(hw_key) is not None: + payload[hw_key] = info[hw_key] + + note_parts: List[str] = [] + if info.get('error'): + note_parts.append(str(info['error']).strip()) + if info.get('telemetryNote'): + note_parts.append(str(info['telemetryNote']).strip()) + if note_parts: + payload['notes'] = "; ".join(note_parts)[:3500] + + skip, reason = should_skip_submission( + hardware=hardware, + payload=payload, + background_cpu_pct=float(info.get('backgroundCpuPct') or 0.0), + baseline_rows=baseline_rows, + ) + next_index = processed_total + 1 + progress.set_description(_batch_status("Submitting", next_index, str(payload['codec']), str(payload['preset']))) + progress.set_current_test( + stage="Submitting", + encoder=str(payload['codec']), + preset=str(payload['preset']), + crf=payload.get('crf'), + passes=payload.get('passes', 1), + isHardware=is_hardware_encoder_name(str(payload['codec'])), + ) + if skip: + print_warning(f"Skipped submission for {payload['codec']} {payload['preset']} (reason: {reason})") + skipped_count += 1 + if not args.no_submit: try: - payload_to_save['notes'] = ((payload_to_save.get('notes') or '') + f"; {reason}")[:500] - except Exception: - pass - with open(fname, 'w', encoding='utf-8') as fh: - json.dump(sanitize_payload_for_server(payload_to_save), fh, separators=(',', ':')) - print(f"Queued skipped payload for review: {fname}") - except Exception as qe: - print(f"Failed to queue skipped payload: {qe}", file=sys.stderr) - continue - if args.no_submit: - print(f"Dry-run: not submitting {payload['codec']} {payload['preset']}") - else: - try: - submit(base_url, sanitize_payload_for_server(payload), api_key=args.api_key, retries=max(1, args.retries), use_token=config._env_flag('INGEST_USE_TOKENS', False) or bool(getattr(args, 'use_token', False))) - print("Submitted Results") - except Exception as e: - print(f"Failed to submit {payload['preset']}: {e}", file=sys.stderr) - try: - fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-{payload['preset']}.json") - with open(fname, 'w', encoding='utf-8') as fh: - json.dump(sanitize_payload_for_server(payload), fh, separators=(',', ':')) - print(f"Queued for retry: {fname}") - except Exception as qe: - print(f"Failed to queue payload: {qe}", file=sys.stderr) - if float(payload.get('fps', 0.0)) > 0.0 and int(payload.get('fileSizeBytes', 0)) > 0: - completed_count_local += 1 - if config._BATCH_ACTIVE: - with config._GLOBAL_STATE_LOCK: - config._BATCH_COMPLETED_COUNT += 1 - processed_total += 1 - try: - overall_pct = (processed_total / max(1, total_tasks)) * 100.0 - except Exception: - overall_pct = 100.0 - print(f"Progress: {processed_total}/{total_tasks} ({overall_pct:.0f}%) complete\n") + fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-skipped-{payload['preset']}.json") + payload_to_save = dict(payload) + if reason: + try: + payload_to_save['notes'] = ((payload_to_save.get('notes') or '') + f"; {reason}")[:3500] + except Exception: + pass + with open(fname, 'w', encoding='utf-8') as fh: + json.dump(sanitize_payload_for_server(payload_to_save), fh, separators=(',', ':')) + queued_count += 1 + except Exception as qe: + print(f"Failed to queue skipped payload: {qe}", file=sys.stderr) + failed_count += 1 + progress.update_counters( + submitted=submitted_count, skipped=skipped_count, + queued=queued_count, failed=failed_count, + ) + elif args.no_submit: + progress.set_description(_batch_status("Dry-run", next_index, str(payload['codec']), str(payload['preset']))) + progress.update_counters( + submitted=submitted_count, skipped=skipped_count, + queued=queued_count, failed=failed_count, + ) + else: + try: + submit( + base_url, + sanitize_payload_for_server(payload), + api_key=args.api_key, + retries=max(1, args.retries), + use_token=config._env_flag('INGEST_USE_TOKENS', False) or bool(getattr(args, 'use_token', False)), + ) + submitted_count += 1 + except Exception as e: + print(f"Failed to submit {payload['preset']}: {e}", file=sys.stderr) + try: + fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-{payload['preset']}.json") + with open(fname, 'w', encoding='utf-8') as fh: + json.dump(sanitize_payload_for_server(payload), fh, separators=(',', ':')) + queued_count += 1 + except Exception as qe: + print(f"Failed to queue payload: {qe}", file=sys.stderr) + failed_count += 1 + progress.update_counters( + submitted=submitted_count, skipped=skipped_count, + queued=queued_count, failed=failed_count, + ) + + if float(payload.get('fps', 0.0)) > 0.0 and int(payload.get('fileSizeBytes', 0)) > 0: + completed_count_local += 1 + if config._BATCH_ACTIVE: + with config._GLOBAL_STATE_LOCK: + config._BATCH_COMPLETED_COUNT += 1 + + processed_total += 1 + progress.advance(description=_batch_status("Completed", processed_total, str(payload['codec']), str(payload['preset']))) + except KeyboardInterrupt: + print_warning("Batch run interrupted by user.") + return 130 + + elapsed_seconds = max(0.0, time.time() - run_started_at) + throughput_per_hour = (completed_count_local / elapsed_seconds * 3600.0) if elapsed_seconds > 0 else 0.0 + print_batch_summary({ + "totalTasks": total_tasks, + "totalBatches": total_batches, + "completed": completed_count_local, + "submitted": submitted_count, + "skipped": skipped_count, + "queued": queued_count, + "failed": failed_count, + "elapsedSeconds": elapsed_seconds, + "throughputPerHour": throughput_per_hour, + }) return 0 @@ -207,9 +390,11 @@ def run_with_args(args: argparse.Namespace) -> int: return 6 resolved_encoder: Optional[str] = None + explicit_encoder_selection = False user_codec = (args.codec or "").strip() if user_codec and has_encoder(user_codec): resolved_encoder = user_codec + explicit_encoder_selection = True else: family = normalize_codec_family(user_codec) if user_codec else None if not family: @@ -237,6 +422,28 @@ def run_with_args(args: argparse.Namespace) -> int: if not resolved_encoder or not has_encoder(resolved_encoder): print("Requested codec/encoder not available in this ffmpeg build.", file=sys.stderr) return 4 + if is_hardware_encoder_name(resolved_encoder) and not is_hardware_encoder_usable(resolved_encoder): + if explicit_encoder_selection: + print( + f"Selected hardware encoder '{resolved_encoder}' may not be usable on this machine. " + "Attempting it anyway; software fallback will be used if needed." + ) + else: + fam = _infer_encoder_family(resolved_encoder) + sw = pick_software_encoder_for_family(fam) if fam else None + if sw and has_encoder(sw): + print( + f"Selected hardware encoder '{resolved_encoder}' is not usable on this machine. " + f"Using software encoder '{sw}' instead." + ) + resolved_encoder = sw + else: + print( + f"Selected hardware encoder '{resolved_encoder}' is not usable on this machine, " + "and no software fallback was found.", + file=sys.stderr, + ) + return 4 hardware = detect_hardware() input_hash = sha256_of_file(input_path) @@ -247,7 +454,6 @@ def run_with_args(args: argparse.Namespace) -> int: except Exception: preset_list = ["fast", "medium", "slow"] - all_payloads: List[Dict[str, Any]] = [] base_url = args.base_url user_crf: Optional[int] = args.crf if preset_list: @@ -258,67 +464,56 @@ def run_with_args(args: argparse.Namespace) -> int: except Exception: original_size_bytes = 0 completed_count = 0 - for preset, crf_val in combos: - print(f"Running Test: {resolved_encoder}, crf={crf_val}, {preset}...") - payload = run_single_benchmark(hardware, input_path, preset=preset, codec=resolved_encoder, crf=crf_val) - payload["ffmpegVersion"] = ffmpeg_version - payload["encoderName"] = payload.get("codec", resolved_encoder) - payload["clientVersion"] = client_version - payload["inputHash"] = input_hash - all_payloads.append(payload) - fps_val = payload.get("fps") - vmaf_val = payload.get("vmaf") - size_val = payload.get("fileSizeBytes") - try: - rel_size = (float(size_val) / float(original_size_bytes) * 100.0) if original_size_bytes > 0 else None - except Exception: - rel_size = None - print("\n|---------------------------") - try: - print(f"| FPS: {float(fps_val):.2f}") - except Exception: - print("| FPS: N/A") - print("|---------------------------") - if vmaf_val is not None: + with BenchmarkProgress(len(combos), title="Single Benchmark Progress") as progress: + for preset, crf_val in combos: + progress.set_description(f"Running {resolved_encoder} {preset} crf={crf_val}") + print_info(f"Running Test: {resolved_encoder}, crf={crf_val}, {preset}...") + payload = run_single_benchmark(hardware, input_path, preset=preset, codec=resolved_encoder, crf=crf_val) + payload["ffmpegVersion"] = ffmpeg_version + payload["encoderName"] = payload.get("codec", resolved_encoder) + payload["clientVersion"] = client_version + payload["inputHash"] = input_hash + payload["passes"] = 1 + + size_val = payload.get("fileSizeBytes") try: - print(f"| VMAF: {float(vmaf_val):.2f}") + rel_size = (float(size_val) / float(original_size_bytes) * 100.0) if original_size_bytes > 0 else None except Exception: - print("| VMAF: N/A") - else: - print("| VMAF: N/A") - print("|---------------------------") - if rel_size is not None: - try: - print(f"| Relative File Size: {rel_size:.1f}%") - except Exception: - print("| Relative File Size: N/A") - else: - print("| Relative File Size: N/A") - print("|---------------------------\n") - if float(payload.get("fps", 0.0)) > 0.0 and int(payload.get("fileSizeBytes", 0)) > 0: - completed_count += 1 - if config._BATCH_ACTIVE: - with config._GLOBAL_STATE_LOCK: - config._BATCH_COMPLETED_COUNT += 1 - if args.no_submit: - print(f"Dry-run: not submitting preset={preset}") - continue - try: - if payload.get("fps", 0.0) <= 0 or payload.get("fileSizeBytes", 0) <= 0: - print(f"Skipped submission for preset={preset} due to encode failure (fps={payload.get('fps')}, size={payload.get('fileSizeBytes')})") - all_payloads.append({**payload, "localError": True}) + rel_size = None + print_benchmark_result(payload, rel_size) + + if float(payload.get("fps", 0.0)) > 0.0 and int(payload.get("fileSizeBytes", 0)) > 0: + completed_count += 1 + if config._BATCH_ACTIVE: + with config._GLOBAL_STATE_LOCK: + config._BATCH_COMPLETED_COUNT += 1 + + if args.no_submit: + print_info(f"Dry-run: not submitting preset={preset}") + progress.advance(description=f"{preset} (dry-run)") continue - submit(base_url, payload, api_key=args.api_key, retries=max(1, args.retries)) - print("Submitted Results") - except Exception as e: - print(f"Failed to submit {preset}: {e}", file=sys.stderr) try: - fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-{preset}.json") - with open(fname, "w", encoding="utf-8") as fh: - json.dump(payload, fh, separators=(",", ":")) - print(f"Queued for retry: {fname}") - except Exception as qe: - print(f"Failed to queue payload: {qe}", file=sys.stderr) + if payload.get("fps", 0.0) <= 0 or payload.get("fileSizeBytes", 0) <= 0: + print_warning( + f"Skipped submission for preset={preset} due to encode failure " + f"(fps={payload.get('fps')}, size={payload.get('fileSizeBytes')})" + ) + progress.advance(description=f"{preset} (failed)") + continue + clean_payload = sanitize_payload_for_server(payload) + submit(base_url, clean_payload, api_key=args.api_key, retries=max(1, args.retries)) + print_success("Submitted Results") + progress.advance(description=f"{preset} (submitted)") + except Exception as e: + print(f"Failed to submit {preset}: {e}", file=sys.stderr) + try: + fname = os.path.join(args.queue_dir, f"{int(time.time()*1000)}-{preset}.json") + with open(fname, "w", encoding="utf-8") as fh: + json.dump(sanitize_payload_for_server(payload), fh, separators=(",", ":")) + print_info(f"Queued for retry: {fname}") + except Exception as qe: + print(f"Failed to queue payload: {qe}", file=sys.stderr) + progress.advance(description=f"{preset} (queued)") try: files = sorted([f for f in os.listdir(args.queue_dir) if f.endswith('.json')]) @@ -350,12 +545,11 @@ def run_with_args(args: argparse.Namespace) -> int: def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.Namespace) -> int: try: import subprocess - if sys.stdin and sys.stdin.isatty(): + import shutil + if os.name != 'nt' and sys.stdin and sys.stdin.isatty() and shutil.which("stty"): subprocess.run(["stty", "sane"], check=False) except Exception: pass - GREEN = "\033[32;1m" - RESET = "\033[0m" sample_path = get_default_sample_path() if not sample_path: print("Required test video not found (expected sample.mp4 in project root).", file=sys.stderr) @@ -368,8 +562,7 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N file=sys.stderr, ) return 6 - print(f"Test Video Checksum {GREEN}Verified{RESET}") - print("") + print_success("Test Video Checksum Verified") presets_cfg = load_presets_config(PRESETS_CONFIG_PATH) s_minutes = int(presets_cfg.get("smallBenchmark", {}).get("approxMinutes", 5)) m_hours = int(presets_cfg.get("mediumBenchmark", presets_cfg.get("smallBenchmark", {})).get("approxHours", 3)) @@ -378,7 +571,7 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N f_hours = int(f_hours) if isinstance(f_hours, int) else float(f_hours) except Exception: f_hours = 3 - print("Select an option:") + print_info("Select an option:") menu = [ "Run Single Benchmark", f"Run Small Benchmark [~{s_minutes} minutes]", @@ -401,21 +594,17 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N sw_encs = [e for e in all_encs if e in sw_set] hw_encs = [e for e in all_encs if e in hw_set] - print("Select an encoder:") + print_info("Select an encoder:") idx_map: List[str] = [] - counter = 1 + option_labels: List[str] = [] if sw_encs: - print("------Software------") for e in sw_encs: - print(f" {counter}) {get_encoder_friendly_label(e)}") idx_map.append(e) - counter += 1 + option_labels.append(f"Software | {get_encoder_friendly_label(e)}") if hw_encs: - print("------Hardware------") for e in hw_encs: - print(f" {counter}) {get_encoder_friendly_label(e)}") idx_map.append(e) - counter += 1 + option_labels.append(f"Hardware | {get_encoder_friendly_label(e)}") default_idx = 0 try: @@ -423,12 +612,7 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N default_idx = idx_map.index("libx264") except Exception: default_idx = 0 - raw = input(f"Choose encoder (1-{len(idx_map)}) [default {default_idx+1}]: ").strip() - try: - enc_idx = (int(raw) - 1) if raw else default_idx - except Exception: - enc_idx = default_idx - enc_idx = min(max(0, enc_idx), len(idx_map)-1) + enc_idx = prompt_choice("Choose encoder", option_labels, default_index=default_idx) chosen_encoder = idx_map[enc_idx] try: @@ -501,7 +685,6 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N crf_values = [int(v) for v in presets_cfg.get("fullBenchmark", {}).get("crfValues", []) if isinstance(v, int)] if not crf_values: crf_values = [24] - tasks: List[Dict[str, Any]] = [] for crf_val in crf_values: for enc in encoders: diff --git a/client/network.py b/client/network.py index 4ca1457..eadc79b 100644 --- a/client/network.py +++ b/client/network.py @@ -1,18 +1,82 @@ import hashlib import json -import os import re import sys import time -from typing import Optional, Dict, Any, List +import threading +from typing import Optional, Dict, Any, List, Set from . import config +_REJECTED_KEYS_LOCK = threading.Lock() +_SERVER_REJECTED_KEYS: Dict[str, Set[str]] = {} + + +def _base_key(base_url: str) -> str: + try: + return base_url.rstrip('/').lower() + except Exception: + return base_url + + +def _get_rejected_keys(base_url: str) -> Set[str]: + key = _base_key(base_url) + with _REJECTED_KEYS_LOCK: + existing = _SERVER_REJECTED_KEYS.get(key) + return set(existing) if existing else set() + + +def _remember_rejected_keys(base_url: str, keys: List[str]) -> Set[str]: + key = _base_key(base_url) + cleaned = [str(k).strip() for k in keys if isinstance(k, str) and str(k).strip()] + if not cleaned: + return _get_rejected_keys(base_url) + with _REJECTED_KEYS_LOCK: + bucket = _SERVER_REJECTED_KEYS.setdefault(key, set()) + bucket.update(cleaned) + return set(bucket) + + +def _extract_unrecognized_keys(error_text: str) -> List[str]: + if not error_text: + return [] + messages: List[str] = [] + try: + data = json.loads(error_text) + details = data.get('details') if isinstance(data, dict) else None + form_errors = details.get('formErrors') if isinstance(details, dict) else None + if isinstance(form_errors, list): + for entry in form_errors: + if isinstance(entry, str): + messages.append(entry) + except Exception: + pass + if not messages: + messages = [error_text] + + out: List[str] = [] + for msg in messages: + match = re.search(r"Unrecognized keys?:\s*(.*)", msg) + if not match: + continue + key_blob = match.group(1) + for key in re.findall(r'"([^"]+)"', key_blob): + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key): + continue + if key not in out: + out.append(key) + return out + + def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: int = 3, backoff_seconds: float = 1.0, use_token: Optional[bool] = None) -> None: import requests # lazy import url = f"{base_url.rstrip('/')}/submit" - headers: Dict[str, str] = {"Content-Type": "application/json"} + payload_to_send: Dict[str, Any] = dict(payload) + for dropped in _get_rejected_keys(base_url): + payload_to_send.pop(dropped, None) + + base_headers: Dict[str, str] = {"Content-Type": "application/json"} if use_token is None: use_token = config._env_flag('INGEST_USE_TOKENS', False) if use_token: @@ -37,7 +101,7 @@ def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: i token = str(tokenData.get('token') or '') powInfo = tokenData.get('pow') or {} if token and re.fullmatch(r"[0-9a-f]{32}", token): - headers['x-ingest-token'] = token + base_headers['x-ingest-token'] = token try: difficulty = int(powInfo.get('difficulty') or 0) except Exception: @@ -60,7 +124,7 @@ def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: i print(f" {nonce//1000}k", end='', flush=True) test = hashlib.sha256(f"{token}.{nonce}".encode('utf-8')).hexdigest() if test.startswith(prefix): - headers['x-ingest-nonce'] = str(nonce) + base_headers['x-ingest-nonce'] = str(nonce) pow_found = True print(f" solved (nonce={nonce})") break @@ -73,28 +137,58 @@ def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: i except Exception: pass # HMAC signing if secret available - ts = int(time.time()) - body = json.dumps(payload, separators=(",", ":")) secret = config.ENV_INGEST_HMAC_SECRET - if secret: - import hmac - sig = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + body.encode("utf-8"), hashlib.sha256).hexdigest() - headers["x-signature"] = sig - headers["x-timestamp"] = str(ts) - for attempt in range(1, retries + 1): + + attempt = 1 + max_compat_retries = max(1, len(payload_to_send)) + compat_retries = 0 + while attempt <= retries: + body = json.dumps(payload_to_send, separators=(",", ":")) + headers = dict(base_headers) + if secret: + import hmac + ts = int(time.time()) + sig = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + body.encode("utf-8"), hashlib.sha256).hexdigest() + headers["x-signature"] = sig + headers["x-timestamp"] = str(ts) try: r = requests.post(url, data=body, timeout=30, headers=headers, verify=config.REQUESTS_VERIFY, allow_redirects=False) if 300 <= r.status_code < 400: loc = r.headers.get('Location') or r.headers.get('location') if loc: r = requests.post(loc, data=body, timeout=30, headers=headers, verify=config.REQUESTS_VERIFY, allow_redirects=False) + if r.status_code == 400: + try: + err_text = r.text + except Exception: + err_text = "" + unknown_keys = _extract_unrecognized_keys(err_text) + if unknown_keys: + removed_now: List[str] = [] + for key in unknown_keys: + if key in payload_to_send: + payload_to_send.pop(key, None) + removed_now.append(key) + if removed_now: + all_rejected = _remember_rejected_keys(base_url, removed_now) + print( + "submit compatibility: server rejected fields; retrying without: " + + ", ".join(sorted(all_rejected)), + file=sys.stderr, + ) + compat_retries += 1 + if compat_retries <= max_compat_retries: + continue if r.status_code == 429: try: ra = r.headers.get('Retry-After') delay = float(ra) if ra and str(ra).replace('.', '', 1).isdigit() else (backoff_seconds * attempt * 2) except Exception: delay = backoff_seconds * attempt * 2 + if attempt >= retries: + r.raise_for_status() time.sleep(max(0.5, delay)) + attempt += 1 continue if r.status_code >= 500: raise RuntimeError(f"server_error {r.status_code}") @@ -117,12 +211,17 @@ def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: i pass raise time.sleep(backoff_seconds * attempt) + attempt += 1 def fetch_baseline_rows(base_url: str) -> List[Dict[str, Any]]: with config._GLOBAL_STATE_LOCK: if config._BASELINE_ROWS_CACHE is not None: - return config._BASELINE_ROWS_CACHE + elapsed = time.time() - config._BASELINE_ROWS_CACHE_TS + if elapsed < config._BASELINE_ROWS_CACHE_TTL: + return config._BASELINE_ROWS_CACHE + # TTL expired — clear cache and re-fetch + config._BASELINE_ROWS_CACHE = None try: import requests # lazy import @@ -133,10 +232,12 @@ def fetch_baseline_rows(base_url: str) -> List[Dict[str, Any]]: if isinstance(data, list): with config._GLOBAL_STATE_LOCK: config._BASELINE_ROWS_CACHE = data + config._BASELINE_ROWS_CACHE_TS = time.time() return data except Exception: pass with config._GLOBAL_STATE_LOCK: config._BASELINE_ROWS_CACHE = [] + config._BASELINE_ROWS_CACHE_TS = time.time() return [] diff --git a/client/presets.json b/client/presets.json index d0a0589..ff1aee3 100644 --- a/client/presets.json +++ b/client/presets.json @@ -12,4 +12,3 @@ "approxHours": 240 } } - diff --git a/client/requirements.txt b/client/requirements.txt index 99df9ba..1f76e10 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -2,5 +2,7 @@ requests>=2.31.0 psutil>=5.9.8 py-cpuinfo>=9.0.0 GPUtil>=1.4.0 +nvidia-ml-py>=12.0.0 +rich>=13.7.1 pyinstaller>=6.0.0 # tqdm removed (unused) diff --git a/client/stats.py b/client/stats.py index 8d6aad4..3d8ca5d 100644 --- a/client/stats.py +++ b/client/stats.py @@ -50,17 +50,27 @@ def baseline_is_suspect(current: Dict[str, Any], rows: List[Dict[str, Any]]) -> fps_arr = [float(r.get('fps') or 0) for r in same if float(r.get('fps') or 0) > 0] size_arr = [float(r.get('fileSizeBytes') or 0) for r in same if float(r.get('fileSizeBytes') or 0) > 0] vmaf_arr = [float(r.get('vmaf') or 0) for r in same if r.get('vmaf') is not None] + ssim_arr = [float(r.get('ssim') or 0) for r in same if r.get('ssim') is not None] + psnr_arr = [float(r.get('psnr') or 0) for r in same if r.get('psnr') is not None] fps_med = _median(fps_arr) if fps_arr else float(current.get('fps') or 0) size_med = _median(size_arr) if size_arr else float(current.get('fileSizeBytes') or 0) vmaf_med = _median(vmaf_arr) if vmaf_arr else float(current.get('vmaf') or 0) + ssim_med = _median(ssim_arr) if ssim_arr else float(current.get('ssim') or 0) + psnr_med = _median(psnr_arr) if psnr_arr else float(current.get('psnr') or 0) fps_mad = _mad(fps_arr, fps_med) if fps_arr else 0.0 size_mad = _mad(size_arr, size_med) if size_arr else 0.0 vmaf_mad = _mad(vmaf_arr, vmaf_med) if vmaf_arr else 0.0 + ssim_mad = _mad(ssim_arr, ssim_med) if ssim_arr else 0.0 + psnr_mad = _mad(psnr_arr, psnr_med) if psnr_arr else 0.0 fps_z = _robust_z(float(current.get('fps') or 0), fps_med, fps_mad) size_z = _robust_z(float(current.get('fileSizeBytes') or 0), size_med, size_mad) vmaf_val = current.get('vmaf') vmaf_z = _robust_z(float(vmaf_val), vmaf_med, vmaf_mad) if vmaf_val is not None else 0.0 - max_abs = max(abs(fps_z), abs(size_z), abs(vmaf_z)) + ssim_val = current.get('ssim') + ssim_z = _robust_z(float(ssim_val), ssim_med, ssim_mad) if ssim_val is not None else 0.0 + psnr_val = current.get('psnr') + psnr_z = _robust_z(float(psnr_val), psnr_med, psnr_mad) if psnr_val is not None else 0.0 + max_abs = max(abs(fps_z), abs(size_z), abs(vmaf_z), abs(ssim_z), abs(psnr_z)) if max_abs > 3.0: return (True, f'baseline_outlier|z={max_abs:.2f}') return (False, '') diff --git a/client/ui.py b/client/ui.py index ad3cffa..2d2f820 100644 --- a/client/ui.py +++ b/client/ui.py @@ -1,19 +1,160 @@ import os import re import shutil +import signal import subprocess import sys -from typing import List +from typing import Any, Dict, List, Optional -from . import config +# Palette: +# Evergreen #173B34 +# Cornflower Blue #6C8FD5 +# Lavender Grey #9693CC +# Ash Grey #CDDBCD +# Vanilla Custard #EBE4B3 +_RICH_AVAILABLE = False +_console = None +_Panel = None +_Table = None +_Prompt = None +_Confirm = None +_Progress = None +_SpinnerColumn = None +_TextColumn = None +_BarColumn = None +_TimeElapsedColumn = None +_TimeRemainingColumn = None +_Live = None +_Group = None +_Columns = None +_Column = None -def prompt_yes_no(prompt: str, default_no: bool = True) -> bool: + +def _env_true(name: str) -> bool: + try: + return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on") + except Exception: + return False + + +_FORCE_TUI = _env_true("ENCODINGDB_FORCE_TUI") or _env_true("CLIENT_FORCE_TUI") +_FORCE_TERMINAL: Optional[bool] = True if _FORCE_TUI else None + +try: + from rich.console import Console + from rich.console import Group + try: + from rich.columns import Columns # type: ignore + except Exception: + Columns = None # type: ignore + from rich.live import Live + from rich.panel import Panel + from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, + ) + from rich.prompt import Confirm, Prompt + from rich.table import Column, Table + from rich.theme import Theme + + _theme = Theme({ + "evergreen": "#173B34", + "cornflower": "#6C8FD5", + "lavender": "#9693CC", + "ash": "#CDDBCD", + "vanilla": "#EBE4B3", + "title": "bold #EBE4B3 on #173B34", + "accent": "bold #6C8FD5", + "accent2": "#9693CC", + "muted": "#CDDBCD", + "ok": "bold #173B34 on #CDDBCD", + "warn": "bold #173B34 on #EBE4B3", + "bad": "bold #EBE4B3 on #173B34", + }) + try: + _console = Console( + theme=_theme, + highlight=False, + soft_wrap=True, + force_terminal=_FORCE_TERMINAL, + force_interactive=True, + ) + except TypeError: + _console = Console(theme=_theme, highlight=False, soft_wrap=True, force_terminal=_FORCE_TERMINAL) + _Panel = Panel + _Table = Table + _Prompt = Prompt + _Confirm = Confirm + _Progress = Progress + _SpinnerColumn = SpinnerColumn + _TextColumn = TextColumn + _BarColumn = BarColumn + _TimeElapsedColumn = TimeElapsedColumn + _TimeRemainingColumn = TimeRemainingColumn + _Live = Live + _Group = Group + _Columns = Columns + _Column = Column + _RICH_AVAILABLE = True +except Exception: + _RICH_AVAILABLE = False + + +def _rich_tty() -> bool: + if not (_RICH_AVAILABLE and _console is not None): + return False + if _FORCE_TUI: + return True + stdout_tty = bool(sys.stdout and sys.stdout.isatty()) + stdin_tty = bool(sys.stdin and sys.stdin.isatty()) + return stdout_tty or stdin_tty + + +def _stty_sane() -> None: try: if sys.stdin and sys.stdin.isatty(): subprocess.run(["stty", "sane"], check=False) except Exception: pass + + +def print_info(message: str) -> None: + if _rich_tty(): + _console.print(f"[accent]•[/accent] [muted]{message}[/muted]") + else: + print(message) + + +def print_success(message: str) -> None: + if _rich_tty(): + _console.print(f"[ok] {message} [/ok]") + else: + print(message) + + +def print_warning(message: str) -> None: + if _rich_tty(): + _console.print(f"[warn] {message} [/warn]") + else: + print(message) + + +def print_error(message: str) -> None: + if _rich_tty(): + _console.print(f"[bad] {message} [/bad]") + else: + print(message) + + +def prompt_yes_no(prompt: str, default_no: bool = True) -> bool: + _stty_sane() + if _rich_tty(): + return bool(_Confirm.ask(f"[accent]{prompt}[/accent]", default=not default_no)) suffix = " [y/N]: " if default_no else " [Y/n]: " ans = input(prompt + suffix).strip().lower() if not ans: @@ -22,44 +163,56 @@ def prompt_yes_no(prompt: str, default_no: bool = True) -> bool: def prompt_choice(prompt: str, options: List[str], default_index: int = 0) -> int: - try: - if sys.stdin and sys.stdin.isatty(): - subprocess.run(["stty", "sane"], check=False) - except Exception: - pass - for i, opt in enumerate(options, start=1): - print(f" {i}) {opt}") - raw = input(f"{prompt} (1-{len(options)}) [default {default_index+1}]: ").strip() - if not raw: - return default_index + _stty_sane() + if not options: + return 0 + if _rich_tty(): + table = _Table(show_header=False, box=None, pad_edge=False) + table.add_column(style="accent", width=4) + table.add_column(style="muted") + for i, opt in enumerate(options, start=1): + table.add_row(f"{i}.", opt) + _console.print(_Panel(table, title=f"[title] {prompt} [/title]", border_style="accent2")) + raw = _Prompt.ask( + f"[cornflower]{prompt}[/cornflower] [muted](1-{len(options)}, default {default_index + 1})[/muted]", + default=str(default_index + 1), + ).strip() + else: + for i, opt in enumerate(options, start=1): + print(f" {i}) {opt}") + raw = input(f"{prompt} (1-{len(options)}) [default {default_index+1}]: ").strip() try: idx = int(raw) if 1 <= idx <= len(options): return idx - 1 except Exception: pass - return default_index + return max(0, min(default_index, len(options) - 1)) def prompt_text(prompt: str, default_value: str = "") -> str: - try: - if sys.stdin and sys.stdin.isatty(): - subprocess.run(["stty", "sane"], check=False) - except Exception: - pass + _stty_sane() + if _rich_tty(): + raw = _Prompt.ask( + f"[cornflower]{prompt}[/cornflower]", + default=default_value if default_value is not None else "", + ).strip() + return raw or default_value raw = input(f"{prompt} [{default_value}]: ").strip() return raw or default_value def _clear_screen() -> None: try: - os.system("cls" if os.name == "nt" else "clear") + if _rich_tty(): + _console.clear() + else: + os.system("cls" if os.name == "nt" else "clear") except Exception: pass def ensure_min_terminal_size(min_cols: int = 100, min_rows: int = 30) -> None: - """Best-effort to resize terminal to avoid misaligned boxes.""" try: cols, rows = shutil.get_terminal_size((80, 24)) except Exception: @@ -76,52 +229,20 @@ def ensure_min_terminal_size(min_cols: int = 100, min_rows: int = 30) -> None: def confirm_benchmark_readiness() -> bool: _clear_screen() - try: - width = max(60, min(shutil.get_terminal_size((100, 20)).columns, 100)) - except Exception: - width = 80 - border = "\u2550" * (width - 2) - top = f"\u2554{border}\u2557" - bottom = f"\u255a{border}\u255d" - RED = "\033[31;1m" - RED_BG = "\033[41;97;1m" - RESET = "\033[0m" - ansi_re = re.compile(r"\x1b\[[0-9;]*m") - - def _display_len(s: str) -> int: - try: - return len(ansi_re.sub("", s)) - except Exception: - return len(s) - - def center_line(text: str) -> str: - t = text.strip() - pad = max(0, width - 2 - _display_len(t)) - left = pad // 2 - right = pad - left - return f"\u2551{' ' * left}{t}{' ' * right}\u2551" - - print(top) - print(center_line(f"{RED_BG} Warning! {RESET}")) - print(center_line("")) - lines = [ - f"{RED}Please close all programs that may be stealing CPU resources or using your media engine{RESET}", - f"{RED}(ie. Video Games, Studio Software, Video Playback, Browser, etc.){RESET}", - "", - f"{RED}Accurate data is very important! Have you closed all other programs?{RESET}", - ] - for ln in lines: - print(center_line(ln)) - print(center_line("")) - print(center_line("Type \"yes\" to proceed")) - print(bottom) - - try: - if sys.stdin and sys.stdin.isatty(): - subprocess.run(["stty", "sane"], check=False) - except Exception: - pass - ans = input("Type \"yes\" to proceed: ").strip().lower() + if _rich_tty(): + text = ( + "[bad] Warning! [/bad]\n\n" + "[muted]Please close all programs that may be stealing CPU resources or using your media engine\n" + "(video games, NLEs, video playback, browser tabs, etc.).[/muted]\n\n" + "[accent2]Accurate data is important.[/accent2]\n" + "[accent]Type \"yes\" to proceed.[/accent]" + ) + _console.print(_Panel(text, title="[title] Benchmark Readiness Check [/title]", border_style="accent2")) + else: + print("Please close all heavy background applications for accurate results.") + print('Type "yes" to proceed') + _stty_sane() + ans = input('Type "yes" to proceed: ').strip().lower() return ans == "yes" @@ -130,7 +251,7 @@ def _format_duration(seconds: float) -> str: h = total // 3600 m = (total % 3600) // 60 s = total % 60 - parts = [] + parts: List[str] = [] if h > 0: parts.append(f"{h}h") if m > 0 or h > 0: @@ -140,36 +261,550 @@ def _format_duration(seconds: float) -> str: def print_end_screen(completed_count: int, elapsed_seconds: float) -> None: - try: - width = max(60, min(shutil.get_terminal_size((100, 20)).columns, 100)) - except Exception: - width = 80 - border = "\u2550" * (width - 2) - top = f"\u2554{border}\u2557" - bottom = f"\u255a{border}\u255d" - GREEN = "\033[32;1m" - MAGENTA = "\033[35;1m" - GREEN_BG = "\033[42;97;1m" - RESET = "\033[0m" - ansi_re = re.compile(r"\x1b\[[0-9;]*m") - - def _display_len(s: str) -> int: + time_str = _format_duration(elapsed_seconds) + if _rich_tty(): + body = ( + "[ok] Benchmark run complete [/ok]\n\n" + f"[muted]Submitted data points:[/muted] [accent]{completed_count}[/accent]\n" + f"[muted]Time donated:[/muted] [accent2]{time_str}[/accent2]" + ) + _console.print(_Panel(body, title="[title] Thank You [/title]", border_style="accent2")) + else: + print(f"Benchmark complete. Submitted {completed_count} data points in {time_str}.") + + +def print_benchmark_result(payload: Dict[str, Any], relative_file_size_pct: Optional[float]) -> None: + def _fmt_float(v: Any, digits: int = 2) -> str: try: - return len(ansi_re.sub("", s)) + return f"{float(v):.{digits}f}" except Exception: - return len(s) - - def center_line(text: str) -> str: - t = text.strip() - pad = max(0, width - 2 - _display_len(t)) - left = pad // 2 - right = pad - left - return f"\u2551{' ' * left}{t}{' ' * right}\u2551" - - print(top) - print(center_line(f"{GREEN_BG} Thank you for completing the benchmark! {RESET}")) - print(center_line("")) - time_str = _format_duration(elapsed_seconds) - print(center_line(f"{GREEN}You supported an open-source database by submitting {completed_count} data points{RESET}")) - print(center_line(f"{GREEN}and donating {time_str} of your computer's time! {MAGENTA}<3{RESET}")) - print(bottom) + return "N/A" + + if _rich_tty(): + table = _Table(show_header=False, box=None, pad_edge=False) + table.add_column(style="muted", width=30) + table.add_column(style="accent", justify="right") + table.add_row("FPS", _fmt_float(payload.get("fps"), 2)) + table.add_row("VMAF", _fmt_float(payload.get("vmaf"), 2) if payload.get("vmaf") is not None else "N/A") + table.add_row("SSIM", _fmt_float(payload.get("ssim"), 4) if payload.get("ssim") is not None else "N/A") + table.add_row("PSNR", (_fmt_float(payload.get("psnr"), 2) + " dB") if payload.get("psnr") is not None else "N/A") + if relative_file_size_pct is not None: + table.add_row("Relative File Size", f"{_fmt_float(relative_file_size_pct, 1)}%") + else: + table.add_row("Relative File Size", "N/A") + if payload.get("gpuUtilAvg") is not None: + table.add_row("GPU Util", f"{_fmt_float(payload.get('gpuUtilAvg'), 1)}%") + if payload.get("gpuPowerAvgW") is not None: + gpu_power = float(payload.get("gpuPowerAvgW") or 0.0) + fps = float(payload.get("fps") or 0.0) + fps_per_watt = (fps / gpu_power) if gpu_power > 0 else None + if fps_per_watt is None: + table.add_row("GPU Power", f"{_fmt_float(gpu_power, 1)} W") + else: + table.add_row("GPU Power", f"{_fmt_float(gpu_power, 1)} W ({_fmt_float(fps_per_watt, 2)} FPS/W)") + if payload.get("cpuUtilAvg") is not None: + table.add_row("CPU Util", f"{_fmt_float(payload.get('cpuUtilAvg'), 1)}%") + if payload.get("peakMemoryMB") is not None: + table.add_row("Peak Memory", f"{_fmt_float(payload.get('peakMemoryMB'), 0)} MB") + if payload.get("thermalThrottle") is True: + table.add_row("Thermal Status", "[warn]THROTTLING DETECTED[/warn]") + _console.print(_Panel(table, title="[title] Benchmark Result [/title]", border_style="accent2")) + return + + print("\n|---------------------------") + print(f"| FPS: {_fmt_float(payload.get('fps'), 2)}") + print("|---------------------------") + print(f"| VMAF: {_fmt_float(payload.get('vmaf'), 2) if payload.get('vmaf') is not None else 'N/A'}") + print("|---------------------------") + print(f"| SSIM: {_fmt_float(payload.get('ssim'), 4) if payload.get('ssim') is not None else 'N/A'}") + print("|---------------------------") + print(f"| PSNR: {(_fmt_float(payload.get('psnr'), 2) + ' dB') if payload.get('psnr') is not None else 'N/A'}") + print("|---------------------------") + if relative_file_size_pct is not None: + print(f"| Relative File Size: {_fmt_float(relative_file_size_pct, 1)}%") + else: + print("| Relative File Size: N/A") + print("|---------------------------") + + +def print_batch_summary(summary: Dict[str, Any]) -> None: + total = int(summary.get("totalTasks") or 0) + total_batches = int(summary.get("totalBatches") or 0) + completed = int(summary.get("completed") or 0) + submitted = int(summary.get("submitted") or 0) + skipped = int(summary.get("skipped") or 0) + queued = int(summary.get("queued") or 0) + failed = int(summary.get("failed") or 0) + elapsed_seconds = float(summary.get("elapsedSeconds") or 0.0) + throughput_per_hour = float(summary.get("throughputPerHour") or 0.0) + if _rich_tty(): + table = _Table(show_header=False, box=None, pad_edge=False) + table.add_column(style="muted", width=22) + table.add_column(style="accent", justify="right") + table.add_row("Planned Tasks", str(total)) + if total_batches > 0: + table.add_row("Batches", str(total_batches)) + table.add_row("Completed Encodes", str(completed)) + table.add_row("Submitted", str(submitted)) + table.add_row("Skipped", str(skipped)) + table.add_row("Queued", str(queued)) + table.add_row("Failures", str(failed)) + if elapsed_seconds > 0: + table.add_row("Elapsed", _format_duration(elapsed_seconds)) + table.add_row("Throughput", f"{throughput_per_hour:.1f} encodes/hour") + _console.print(_Panel(table, title="[title] Batch Summary [/title]", border_style="accent2")) + return + print("Batch Summary") + print(f" Planned Tasks: {total}") + if total_batches > 0: + print(f" Batches: {total_batches}") + print(f" Completed Encodes: {completed}") + print(f" Submitted: {submitted}") + print(f" Skipped: {skipped}") + print(f" Queued: {queued}") + print(f" Failures: {failed}") + if elapsed_seconds > 0: + print(f" Elapsed: {_format_duration(elapsed_seconds)}") + print(f" Throughput: {throughput_per_hour:.1f} encodes/hour") + + +class BenchmarkProgress: + def __init__(self, total: int, title: str = "Benchmark Progress"): + self.total = max(1, int(total)) + self.title = title + self._progress = None + self._task_id = None + self._count = 0 + + def __enter__(self) -> "BenchmarkProgress": + if _rich_tty(): + self._progress = _Progress( + _SpinnerColumn(style="cornflower"), + _TextColumn("[cornflower]{task.description}[/cornflower]"), + _BarColumn(bar_width=40, complete_style="lavender", finished_style="lavender", style="evergreen"), + _TextColumn("[muted]{task.completed}/{task.total}[/muted]"), + _TimeElapsedColumn(), + _TimeRemainingColumn(), + console=_console, + transient=False, + ) + self._progress.start() + self._task_id = self._progress.add_task(self.title, total=self.total) + return self + + def advance(self, description: Optional[str] = None, step: int = 1) -> None: + self._count += step + if self._progress is not None and self._task_id is not None: + kwargs: Dict[str, Any] = {"advance": step} + if description: + kwargs["description"] = description + self._progress.update(self._task_id, **kwargs) + return + if description: + print(f"Progress: {self._count}/{self.total} - {description}") + else: + print(f"Progress: {self._count}/{self.total}") + + def set_description(self, description: str) -> None: + if self._progress is not None and self._task_id is not None: + self._progress.update(self._task_id, description=description) + + def __exit__(self, exc_type, exc, tb) -> None: + if self._progress is not None: + self._progress.stop() + self._progress = None + self._task_id = None + + +class BatchRunDashboard: + def __init__(self, total_tasks: int, total_batches: int, hardware: Optional[Any] = None): + self.total_tasks = max(1, int(total_tasks)) + self.total_batches = max(1, int(total_batches)) + self.hardware = hardware + self._phase_steps_per_task = 3 # encode + metrics + submit + + self._live = None + self._overall_progress = None + self._batch_progress = None + self._overall_task_id = None + self._batch_task_id = None + + self._overall_phase_steps = 0 + self._batch_phase_steps = 0 + self._overall_count = 0 + self._batch_count = 0 + self._batch_no = 1 + self._batch_size = 1 + self._description = "Preparing batch..." + self._task_info: Dict[str, Any] = {} + self._metrics: Dict[str, Any] = {} + self._counters: Dict[str, int] = {"submitted": 0, "skipped": 0, "queued": 0, "failed": 0} + self._prev_sigwinch: Any = None + self._sigwinch_installed = False + + def __enter__(self) -> "BatchRunDashboard": + if _rich_tty(): + left_col = _Column(width=8, no_wrap=True) if _Column is not None else None + self._overall_progress = _Progress( + _TextColumn("[accent]{task.fields[label]}[/accent]", table_column=left_col), + _BarColumn(bar_width=None, complete_style="lavender", finished_style="lavender", style="evergreen"), + _TextColumn("[muted]{task.fields[display_done]}/{task.fields[display_total]}[/muted]"), + _TimeElapsedColumn(), + _TimeRemainingColumn(), + console=_console, + expand=True, + transient=False, + ) + self._batch_progress = _Progress( + _TextColumn("[accent2]{task.fields[label]}[/accent2]", table_column=left_col), + _BarColumn(bar_width=None, complete_style="cornflower", finished_style="cornflower", style="evergreen"), + _TextColumn("[muted]{task.fields[display_done]}/{task.fields[display_total]}[/muted]"), + _TimeElapsedColumn(), + _TimeRemainingColumn(), + console=_console, + expand=True, + transient=False, + ) + # Important: do not call Progress.start() here. + # Each Progress would create its own Live display and conflict with + # this dashboard's parent Live, which can delay rendering until + # teardown/interrupt on some terminals. + self._overall_task_id = self._overall_progress.add_task( + "Overall progress", + total=self.total_tasks * self._phase_steps_per_task, + display_done=0, + display_total=self.total_tasks, + label="Overall", + ) + self._batch_task_id = self._batch_progress.add_task( + "Batch progress", + total=max(1, self._batch_size * self._phase_steps_per_task), + display_done=0, + display_total=self._batch_size, + label="Batch", + ) + self._live = _Live( + self._render(), + console=_console, + refresh_per_second=8, + auto_refresh=True, + ) + try: + self._live.start(refresh=True) + except TypeError: + self._live.start() + try: + self._live.refresh() + except Exception: + pass + self._install_resize_handler() + self._refresh() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self._restore_resize_handler() + if self._live is not None: + self._live.stop() + self._live = None + if self._overall_progress is not None: + self._overall_progress.stop() + self._overall_progress = None + if self._batch_progress is not None: + self._batch_progress.stop() + self._batch_progress = None + self._overall_task_id = None + self._batch_task_id = None + + def start_batch(self, batch_no: int, batch_size: int) -> None: + self._batch_no = max(1, int(batch_no)) + self._batch_size = max(1, int(batch_size)) + self._batch_count = 0 + self._batch_phase_steps = 0 + if self._batch_progress is not None and self._batch_task_id is not None: + self._batch_progress.reset( + self._batch_task_id, + total=max(1, self._batch_size * self._phase_steps_per_task), + completed=0, + description=f"Batch {self._batch_no}/{self.total_batches}", + display_done=0, + display_total=self._batch_size, + label="Batch", + ) + self._refresh() + + def set_description(self, description: str) -> None: + self._description = description + if self._overall_progress is not None and self._overall_task_id is not None: + self._overall_progress.update(self._overall_task_id, description=description) + if self._batch_progress is not None and self._batch_task_id is not None: + self._batch_progress.update( + self._batch_task_id, + description=f"Batch {self._batch_no}/{self.total_batches}", + ) + self._refresh() + + def set_current_test(self, **kwargs: Any) -> None: + self._task_info = dict(kwargs) + self._refresh() + + def update_machine_metrics(self, metrics: Dict[str, Any]) -> None: + self._metrics = dict(metrics or {}) + self._refresh() + + def update_counters(self, *, submitted: int, skipped: int, queued: int, failed: int) -> None: + self._counters = { + "submitted": max(0, int(submitted)), + "skipped": max(0, int(skipped)), + "queued": max(0, int(queued)), + "failed": max(0, int(failed)), + } + self._refresh() + + def advance_phase(self, description: Optional[str] = None, step: int = 1) -> None: + step_n = max(1, int(step)) + self._overall_phase_steps += step_n + self._batch_phase_steps += step_n + if description: + self._description = description + display_overall = min(self.total_tasks, max(self._overall_count, self._overall_phase_steps)) + display_batch = min(self._batch_size, max(self._batch_count, self._batch_phase_steps)) + + if self._overall_progress is not None and self._overall_task_id is not None: + kwargs: Dict[str, Any] = { + "advance": step_n, + "display_done": display_overall, + "display_total": self.total_tasks, + } + if description: + kwargs["description"] = description + self._overall_progress.update(self._overall_task_id, **kwargs) + + if self._batch_progress is not None and self._batch_task_id is not None: + self._batch_progress.update( + self._batch_task_id, + advance=step_n, + display_done=display_batch, + display_total=self._batch_size, + ) + self._refresh() + + def advance(self, description: Optional[str] = None, step: int = 1) -> None: + step_n = max(1, int(step)) + self._overall_count += step_n + self._batch_count += step_n + self._overall_phase_steps += step_n + self._batch_phase_steps += step_n + if description: + self._description = description + display_overall = min(self.total_tasks, max(self._overall_count, self._overall_phase_steps)) + display_batch = min(self._batch_size, max(self._batch_count, self._batch_phase_steps)) + + if self._overall_progress is not None and self._overall_task_id is not None: + kwargs: Dict[str, Any] = { + "advance": step_n, + "display_done": display_overall, + "display_total": self.total_tasks, + } + if description: + kwargs["description"] = description + self._overall_progress.update(self._overall_task_id, **kwargs) + if self._batch_progress is not None and self._batch_task_id is not None: + self._batch_progress.update( + self._batch_task_id, + advance=step_n, + display_done=display_batch, + display_total=self._batch_size, + ) + + if not _rich_tty(): + if description: + print(f"Progress: {self._overall_count}/{self.total_tasks} - {description}") + else: + print(f"Progress: {self._overall_count}/{self.total_tasks}") + self._refresh() + + def _install_resize_handler(self) -> None: + try: + sig = signal.SIGWINCH + except Exception: + return + + try: + self._prev_sigwinch = signal.getsignal(sig) + except Exception: + self._prev_sigwinch = None + + def _on_resize(signum: int, frame: Any) -> None: + self._refresh() + prev = self._prev_sigwinch + if callable(prev) and prev is not _on_resize: + try: + prev(signum, frame) + except Exception: + pass + + try: + signal.signal(sig, _on_resize) + self._sigwinch_installed = True + except Exception: + self._sigwinch_installed = False + + def _restore_resize_handler(self) -> None: + if not self._sigwinch_installed: + return + try: + signal.signal(signal.SIGWINCH, self._prev_sigwinch) + except Exception: + pass + self._sigwinch_installed = False + + def _fmt(self, key: str, suffix: str = "", digits: int = 1) -> str: + try: + v = self._metrics.get(key) + if v is None: + return "N/A" + return f"{float(v):.{digits}f}{suffix}" + except Exception: + return "N/A" + + def _machine_info_lines(self, compact: bool = False) -> List[str]: + hw = self.hardware + cpu_model = getattr(hw, "cpuModel", None) if hw is not None else None + gpu_model = getattr(hw, "gpuModel", None) if hw is not None else None + ram_gb = getattr(hw, "ramGB", None) if hw is not None else None + os_name = getattr(hw, "os", None) if hw is not None else None + + is_hardware = bool(self._task_info.get("isHardware", False)) + mode_label = "Hardware Encoder" if is_hardware else "Software Encoder" + power_source = str(self._metrics.get("powerSource") or "unknown").upper() + + lines = [ + f"[muted]Mode:[/muted] [accent]{mode_label}[/accent]", + f"[muted]OS:[/muted] {os_name or 'Unknown'}", + f"[muted]RAM:[/muted] {ram_gb if ram_gb is not None else 'N/A'} GB", + f"[muted]Power Source:[/muted] {power_source}", + f"[muted]Power Draw:[/muted] {self._fmt('gpuPowerAvgW', ' W', 1)}", + f"[muted]Battery Drop:[/muted] {self._fmt('batteryPercentDrop', '%', 2)}" + ] + + if compact: + if is_hardware: + lines.extend([ + f"[muted]GPU:[/muted] {gpu_model or 'Unknown'}", + f"[muted]GPU Util:[/muted] {self._fmt('gpuUtilAvg', '%', 1)}", + ]) + else: + lines.extend([ + f"[muted]CPU:[/muted] {cpu_model or 'Unknown'}", + f"[muted]CPU Util:[/muted] {self._fmt('cpuUtilAvg', '%', 1)}", + ]) + return lines + + if is_hardware: + lines.extend([ + f"[muted]GPU:[/muted] {gpu_model or 'Unknown'}", + f"[muted]GPU Util:[/muted] {self._fmt('gpuUtilAvg', '%', 1)}", + f"[muted]GPU Temp:[/muted] {self._fmt('gpuTempMaxC', ' C', 1)}", + f"[muted]Video Engine CPU:[/muted] {self._fmt('ffmpegCpuUtilAvg', '%', 1)}", + ]) + else: + lines.extend([ + f"[muted]CPU:[/muted] {cpu_model or 'Unknown'}", + f"[muted]CPU Util:[/muted] {self._fmt('cpuUtilAvg', '%', 1)}", + f"[muted]CPU Temp:[/muted] {self._fmt('cpuTempMaxC', ' C', 1)}", + f"[muted]CPU Freq:[/muted] {self._fmt('cpuFreqAvgMHz', ' MHz', 0)}", + ]) + return lines + + def _test_info_lines(self, compact: bool = False) -> List[str]: + stage = str(self._task_info.get("stage") or "Preparing") + enc = str(self._task_info.get("encoder") or "-") + preset = str(self._task_info.get("preset") or "-") + crf = self._task_info.get("crf") + + lines = [ + f"[muted]Stage:[/muted] [accent2]{stage}[/accent2]", + f"[muted]Encoder:[/muted] [vanilla]{enc}[/vanilla]", + f"[muted]Preset:[/muted] {preset} [muted]CRF:[/muted] {crf if crf is not None else '-'}", + f"[muted]Mode:[/muted] CRF (1-pass)", + f"[muted]Queue:[/muted] ok={self._counters['submitted']} skip={self._counters['skipped']} queue={self._counters['queued']} fail={self._counters['failed']}", + f"[muted]Now:[/muted] {self._description}", + ] + if compact: + return lines[:5] + return lines + + def _render(self) -> Any: + if not (_rich_tty() and self._overall_progress is not None and self._batch_progress is not None): + return "" + + width = 0 + try: + width = int(getattr(getattr(_console, "size", None), "width", 0) or 0) + except Exception: + width = 0 + compact = width > 0 and width < 120 + side_by_side = width >= 130 + + test_lines = self._test_info_lines(compact=compact) + machine_lines = self._machine_info_lines(compact=compact) + if side_by_side: + max_lines = max(len(test_lines), len(machine_lines)) + if len(test_lines) < max_lines: + test_lines.extend([""] * (max_lines - len(test_lines))) + if len(machine_lines) < max_lines: + machine_lines.extend([""] * (max_lines - len(machine_lines))) + + current_panel = _Panel( + "\n".join(test_lines), + title="[title] Current Benchmark [/title]", + border_style="accent2", + expand=True, + ) + telemetry_panel = _Panel( + "\n".join(machine_lines), + title="[title] Client Telemetry [/title]", + border_style="accent2", + expand=True, + ) + if side_by_side: + top_grid = _Table.grid(expand=True) + top_grid.add_column(ratio=3) + top_grid.add_column(ratio=2) + top_grid.add_row(current_panel, telemetry_panel) + top = top_grid + else: + top_stack = _Table.grid(expand=True) + top_stack.add_row(current_panel) + top_stack.add_row(telemetry_panel) + top = top_stack + + batch_panel = _Panel( + self._batch_progress, + title=f"[title] Batch {self._batch_no}/{self.total_batches} [/title]", + border_style="accent2", + expand=True, + ) + overall_panel = _Panel( + self._overall_progress, + title="[title] Total Run Progress [/title]", + border_style="accent2", + expand=True, + ) + return _Group(top, batch_panel, overall_panel) + + def _refresh(self) -> None: + if self._live is not None: + self._live.update(self._render(), refresh=False) + try: + self._live.refresh() + except Exception: + try: + self._live.update(self._render(), refresh=True) + except Exception: + pass + try: + if _console is not None and getattr(_console, "file", None) is not None: + _console.file.flush() + except Exception: + pass diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..66d339f --- /dev/null +++ b/deploy.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$SCRIPT_DIR" +if [[ ! -f "$ROOT_DIR/docker-compose.prod.yml" ]]; then + ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +fi + +REMOTE="${DEPLOY_REMOTE:-origin}" +BRANCH="${DEPLOY_BRANCH:-main}" +COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-docker-compose.prod.yml}" +SERVICE_TIMEOUT_SECONDS="${DEPLOY_SERVICE_TIMEOUT_SECONDS:-240}" +API_TIMEOUT_SECONDS="${DEPLOY_API_TIMEOUT_SECONDS:-240}" +SKIP_PULL=0 + +usage() { + cat <<'EOF' +Usage: ./deploy.sh [--skip-pull] [--help] + +Options: + --skip-pull Skip git fetch/pull and deploy current local checkout. + --help Show this help text. + +Environment overrides: + DEPLOY_REMOTE=origin + DEPLOY_BRANCH=main + DEPLOY_COMPOSE_FILE=docker-compose.prod.yml + DEPLOY_SERVICE_TIMEOUT_SECONDS=240 + DEPLOY_API_TIMEOUT_SECONDS=240 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-pull) + SKIP_PULL=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "[deploy] Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +log() { + echo "[deploy] $*" +} + +die() { + echo "[deploy] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +read_env_value() { + local key="$1" + local value + value="$( + awk -F= -v key="$key" ' + $0 !~ /^[[:space:]]*#/ && $1 == key { + print substr($0, index($0, "=") + 1) + } + ' .env | tail -n 1 + )" + value="$(printf '%s' "$value" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + printf '%s' "$value" +} + +ensure_clean_worktree() { + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + die "Working tree is not clean. Commit/stash changes, or run with --skip-pull." + fi +} + +wait_for_service() { + local service="$1" + local timeout="$2" + local elapsed=0 + while (( elapsed < timeout )); do + local cid state health summary + cid="$(docker compose -f "$COMPOSE_FILE" ps -q "$service" 2>/dev/null || true)" + if [[ -n "$cid" ]]; then + summary="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || true)" + state="${summary%% *}" + health="${summary##* }" + if [[ "$state" == "running" && ( "$health" == "healthy" || "$health" == "none" ) ]]; then + log "Service '$service' is running (health=$health)." + return 0 + fi + if [[ "$state" == "exited" || "$state" == "dead" ]]; then + log "Service '$service' entered state=$state (health=$health)." + docker compose -f "$COMPOSE_FILE" logs --tail=120 "$service" || true + return 1 + fi + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + log "Timed out waiting for service '$service' after ${timeout}s." + docker compose -f "$COMPOSE_FILE" logs --tail=120 "$service" || true + return 1 +} + +wait_for_api_ready() { + local timeout="$1" + local elapsed=0 + while (( elapsed < timeout )); do + if docker compose -f "$COMPOSE_FILE" exec -T server \ + node -e "require('http').get('http://127.0.0.1:3001/health/ready',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1));" \ + >/dev/null 2>&1; then + log "API readiness check passed (/health/ready=200)." + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + log "API readiness check failed after ${timeout}s." + docker compose -f "$COMPOSE_FILE" logs --tail=120 server || true + return 1 +} + +cd "$ROOT_DIR" + +require_cmd git +require_cmd docker + +[[ -d ".git" ]] || die "Not a git repository: $ROOT_DIR" +[[ -f "$COMPOSE_FILE" ]] || die "Compose file not found: $COMPOSE_FILE" +[[ -f ".env" ]] || die "Missing .env in $ROOT_DIR. Create it from env.example before deployment." + +ingest_mode="$(read_env_value "INGEST_MODE")" +normalized_ingest_mode="$(printf '%s' "$ingest_mode" | tr '[:upper:]' '[:lower:]')" +if [[ -z "$normalized_ingest_mode" ]]; then + log "WARNING: INGEST_MODE is unset. Runtime default is public (unsigned submissions accepted)." +elif [[ "$normalized_ingest_mode" == "public" ]]; then + log "WARNING: INGEST_MODE=public allows unsigned submissions." +fi +if [[ "$normalized_ingest_mode" == "signed" ]]; then + ingest_secret="$(read_env_value "INGEST_HMAC_SECRET")" + [[ -n "$ingest_secret" ]] || die "INGEST_MODE=signed requires INGEST_HMAC_SECRET in .env." +fi + +if [[ "$SKIP_PULL" -eq 0 ]]; then + ensure_clean_worktree + log "Fetching latest '$BRANCH' from '$REMOTE'..." + git fetch --prune "$REMOTE" "$BRANCH" + + current_branch="$(git rev-parse --abbrev-ref HEAD)" + if [[ "$current_branch" != "$BRANCH" ]]; then + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + log "Checking out local branch '$BRANCH'..." + git checkout "$BRANCH" + else + log "Creating local branch '$BRANCH' tracking '$REMOTE/$BRANCH'..." + git checkout -b "$BRANCH" --track "$REMOTE/$BRANCH" + fi + fi + + log "Pulling latest changes (fast-forward only)..." + git pull --ff-only "$REMOTE" "$BRANCH" +fi + +log "Validating compose configuration..." +docker compose -f "$COMPOSE_FILE" config -q + +log "Building and starting production stack..." +docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans + +log "Waiting for services to become healthy..." +services="$(docker compose -f "$COMPOSE_FILE" config --services)" +for service in $services; do + wait_for_service "$service" "$SERVICE_TIMEOUT_SECONDS" || die "Service readiness failed: $service" +done + +if echo "$services" | grep -qx "server"; then + wait_for_api_ready "$API_TIMEOUT_SECONDS" || die "API readiness failed." +fi + +commit="$(git rev-parse --short HEAD)" +log "Deployment complete at commit $commit." +docker compose -f "$COMPOSE_FILE" ps diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e41fc7b..33f9380 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -93,7 +93,7 @@ services: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/certs:/etc/nginx/certs:ro healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:80/health" , "||", "exit", "0"] + test: ["CMD-SHELL", "wget -qO- http://localhost:80/health >/dev/null || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 2abff0a..5106ec3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.9' services: db: image: postgres:16-alpine @@ -7,7 +6,7 @@ services: POSTGRES_PASSWORD: app POSTGRES_DB: benchmarks ports: - - '5432:5432' + - '${POSTGRES_PORT:-5432}:5432' volumes: - db_data:/var/lib/postgresql/data healthcheck: diff --git a/env.example b/env.example index 6eb029b..426f9bb 100644 --- a/env.example +++ b/env.example @@ -6,13 +6,22 @@ DATABASE_URL= # Server PORT=3001 +# Leave blank to use safe defaults (production: 1 proxy hop, non-prod: disabled). +# Set to true/false/number/CIDR list if you need custom behavior. +TRUST_PROXY= CORS_ORIGIN=https://mydomain.com BODY_LIMIT=1mb RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX=300 SUBMIT_RATE_WINDOW_MS=60000 SUBMIT_RATE_MAX=30 +# public | hybrid | signed (public accepts unsigned submissions) +INGEST_MODE=public +# Required when INGEST_MODE=signed INGEST_HMAC_SECRET= +POW_ENABLED=0 +POW_DIFFICULTY=0 +SUBMIT_TOKEN_TTL_SECONDS=60 # Frontend (public) NEXT_PUBLIC_API_BASE_URL=https://mydomain.com diff --git a/frontend/README.md b/frontend/README.md index 535f3ea..1f7ac89 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,50 +1,19 @@ +Frontend (Next.js) +================== + Environment: -Create a `.env.local` file with: +Create `frontend/.env.local`: -``` +```bash NEXT_PUBLIC_API_BASE_URL=https://encodingdb.platinumlabs.dev ``` Run locally: -``` -npm install -npm run dev -``` -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - ```bash +npm ci npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Then open [http://localhost:3000](http://localhost:3000). diff --git a/frontend/app/analytics/page.tsx b/frontend/app/analytics/page.tsx index 88dc77c..3f9c0d8 100644 --- a/frontend/app/analytics/page.tsx +++ b/frontend/app/analytics/page.tsx @@ -1,41 +1,17 @@ -import { headers } from "next/headers"; import type { Benchmark } from "../components/BenchmarksTable"; import FpsByCodecChart from "../components/FpsByCodecChart"; import VmafHistogram from "../components/VmafHistogram"; import ScatterFpsSize from "../components/ScatterFpsSize"; import GroupedSizeByPreset from "../components/GroupedSizeByPreset"; +import SsimHistogram from "../components/SsimHistogram"; +import PsnrHistogram from "../components/PsnrHistogram"; +import ScatterSsimVmaf from "../components/ScatterSsimVmaf"; +import RateDistortionChart from "../components/RateDistortionChart"; +import LazyChart from "../components/LazyChart"; +import { fetchBenchmarks } from "../lib/fetchBenchmarks"; import styles from "./page.module.css"; -export const dynamic = "force-dynamic"; - -async function fetchBenchmarks(): Promise { - const internal = process.env.INTERNAL_API_BASE_URL; - - let host = "localhost:3000"; - let proto = "http"; - try { - const h = await headers(); - host = h.get("x-forwarded-host") || h.get("host") || "localhost:3000"; - proto = h.get("x-forwarded-proto") || "http"; - } catch { - // Headers unavailable, use defaults - } - - const origin = `${proto}://${host}`; - const primaryUrl = internal ? `${internal}/query` : `${origin}/api/query`; - try { - const res = await fetch(primaryUrl, { signal: AbortSignal.timeout(10000) }); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - return res.json(); - } catch (err) { - if (internal) { - const res = await fetch(`${origin}/api/query`, { signal: AbortSignal.timeout(10000) }); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - return res.json(); - } - throw err; - } -} +export const revalidate = 60; export default async function AnalyticsPage() { let data: Benchmark[] = []; @@ -61,10 +37,14 @@ export default async function AnalyticsPage() {

Analytics

- - - - + + + + + + + +
); diff --git a/frontend/app/api/hwlink/route.ts b/frontend/app/api/hwlink/route.ts index 4aaea68..fd27b4e 100644 --- a/frontend/app/api/hwlink/route.ts +++ b/frontend/app/api/hwlink/route.ts @@ -13,8 +13,7 @@ export async function GET(req: NextRequest) { return NextResponse.redirect("https://www.techpowerup.com/"); } - const decoded = decodeURIComponent(q); - const encoded = encodeURIComponent(decoded); + const encoded = encodeURIComponent(q); const base = "https://www.techpowerup.com"; const searchUrl = kind === "cpu" @@ -25,6 +24,7 @@ export async function GET(req: NextRequest) { const res = await fetch(searchUrl, { headers: { "user-agent": "Mozilla/5.0" }, cache: "no-store", + signal: AbortSignal.timeout(8000), }); if (!res.ok) { @@ -48,7 +48,7 @@ export async function GET(req: NextRequest) { .replace(/[^a-z0-9+.\- ]+/g, " ") .replace(/\s+/g, " ") .trim(); - const target = norm(decoded); + const target = norm(q); let idx = 0; for (const m of matches) { diff --git a/frontend/app/api/query/route.ts b/frontend/app/api/query/route.ts index 58ffa90..ccbd0df 100644 --- a/frontend/app/api/query/route.ts +++ b/frontend/app/api/query/route.ts @@ -1,9 +1,41 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; -// Simple mock API for frontend-only development. -// If INTERNAL_API_BASE_URL is set, the frontend pages bypass this and hit the server directly. +export async function GET(request: NextRequest) { + const internal = process.env.INTERNAL_API_BASE_URL; + const isProd = process.env.NODE_ENV === "production"; -export async function GET() { + // If we have a real backend, proxy the request with all query params + if (internal) { + try { + const qs = request.nextUrl.search; + const url = `${internal}/query${qs}`; + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) { + return NextResponse.json({ error: "Backend error" }, { status: res.status }); + } + const data = await res.json(); + const response = NextResponse.json(data); + // Forward X-Total-Count header if present + const totalCount = res.headers.get("X-Total-Count"); + if (totalCount) { + response.headers.set("X-Total-Count", totalCount); + response.headers.set("Access-Control-Expose-Headers", "X-Total-Count"); + } + return response; + } catch (error) { + console.error("Query proxy failed:", error); + return NextResponse.json({ error: "Upstream query failed" }, { status: 502 }); + } + } + + if (isProd) { + return NextResponse.json( + { error: "INTERNAL_API_BASE_URL is not configured" }, + { status: 503 }, + ); + } + + // Mock API for frontend-only development const sample = [ { id: "mock-1", @@ -52,7 +84,7 @@ export async function GET() { vmafSamples: 2, }, ]; - return NextResponse.json(sample); + const response = NextResponse.json(sample); + response.headers.set("X-Total-Count", String(sample.length)); + return response; } - - diff --git a/frontend/app/compare-encoders/EncoderDashboardClient.tsx b/frontend/app/compare-encoders/EncoderDashboardClient.tsx new file mode 100644 index 0000000..f22f575 --- /dev/null +++ b/frontend/app/compare-encoders/EncoderDashboardClient.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Benchmark } from "../components/BenchmarksTable"; +import { CODEC_COLORS, codecColorKey } from "../lib/chartColors"; +import { useChartTheme } from "../lib/useChartTheme"; +import { escapeHtml } from "../lib/escapeHtml"; +import EChart from "../components/EChart"; +import styles from "./page.module.css"; + +const COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; + +const AXES = ["Speed", "Quality", "Compression", "SSIM", "PSNR"] as const; + +type EncoderStats = { + codec: string; + avgFps: number; + avgVmaf: number; + avgSsim: number; + avgPsnr: number; + avgSizeMB: number; + count: number; + color: string; +}; + +function computeEncoderStats(data: Benchmark[]): Map { + const map = new Map(); + for (const d of data) { + const agg = map.get(d.codec) || { fps: 0, vmaf: 0, ssim: 0, psnr: 0, size: 0, fpsN: 0, vmafN: 0, ssimN: 0, psnrN: 0, sizeN: 0 }; + if (d.fps > 0) { agg.fps += d.fps; agg.fpsN++; } + if (typeof d.vmaf === "number") { agg.vmaf += d.vmaf; agg.vmafN++; } + if (typeof d.ssim === "number") { agg.ssim += d.ssim; agg.ssimN++; } + if (typeof d.psnr === "number") { agg.psnr += d.psnr; agg.psnrN++; } + if (d.fileSizeBytes > 0) { agg.size += d.fileSizeBytes / (1024 * 1024); agg.sizeN++; } + map.set(d.codec, agg); + } + const result = new Map(); + for (const [codec, agg] of map.entries()) { + result.set(codec, { + codec, + avgFps: agg.fpsN > 0 ? agg.fps / agg.fpsN : 0, + avgVmaf: agg.vmafN > 0 ? agg.vmaf / agg.vmafN : 0, + avgSsim: agg.ssimN > 0 ? agg.ssim / agg.ssimN : 0, + avgPsnr: agg.psnrN > 0 ? agg.psnr / agg.psnrN : 0, + avgSizeMB: agg.sizeN > 0 ? agg.size / agg.sizeN : 0, + count: agg.fpsN, + color: CODEC_COLORS[codecColorKey(codec)] || CODEC_COLORS.other, + }); + } + return result; +} + +export default function EncoderDashboardClient({ data }: { data: Benchmark[] }) { + const t = useChartTheme(); + const allStats = useMemo(() => computeEncoderStats(data), [data]); + const codecs = useMemo(() => Array.from(allStats.keys()).sort(), [allStats]); + const [selected, setSelected] = useState([]); + + const toggleCodec = (codec: string) => { + setSelected((prev) => { + if (prev.includes(codec)) return prev.filter((c) => c !== codec); + if (prev.length >= 4) return prev; + return [...prev, codec]; + }); + }; + + const selectedStats = selected.map((c) => allStats.get(c)).filter(Boolean) as EncoderStats[]; + + const radarOption = useMemo(() => { + if (selectedStats.length < 2) return null; + let maxFps = 1, maxSize = 1; + const maxVmaf = 100, maxSsim = 1, maxPsnr = 50; + for (const s of selectedStats) { + if (s.avgFps > maxFps) maxFps = s.avgFps; + if (s.avgSizeMB > maxSize) maxSize = s.avgSizeMB; + } + const normalize = (s: EncoderStats): number[] => [ + Math.min(100, (s.avgFps / maxFps) * 100), + Math.min(100, (s.avgVmaf / maxVmaf) * 100), + s.avgSizeMB > 0 ? Math.min(100, ((maxSize - s.avgSizeMB) / maxSize) * 100 + 10) : 0, + Math.min(100, (s.avgSsim / maxSsim) * 100), + s.avgPsnr >= 20 ? Math.min(100, ((s.avgPsnr - 20) / (maxPsnr - 20)) * 100) : 0, + ]; + return { + backgroundColor: "transparent", + tooltip: { + trigger: "item", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg, fontSize: 12 }, + formatter: (params: { name: string; value: number[] }) => { + const lines = AXES.map((ax, i) => `${ax}: ${(params.value[i] || 0).toFixed(1)}`).join("
"); + return `${escapeHtml(params.name)}
${lines}`; + }, + }, + legend: { + data: selectedStats.map((s) => s.codec), + textStyle: { color: t.fg, fontSize: 12 }, + top: 4, + type: "scroll" as const, + }, + radar: { + indicator: AXES.map((name) => ({ name, max: 100 })), + splitLine: { lineStyle: { color: t.border } }, + axisLine: { lineStyle: { color: t.border } }, + splitArea: { show: false }, + axisName: { color: t.fg, fontSize: 12 }, + center: ["50%", "54%"], + radius: "60%", + }, + series: [ + { + type: "radar", + data: selectedStats.map((s, i) => ({ + name: s.codec, + value: normalize(s), + lineStyle: { color: COLORS[i % COLORS.length], width: 2 }, + areaStyle: { color: COLORS[i % COLORS.length], opacity: 0.12 + i * 0.04 }, + itemStyle: { color: COLORS[i % COLORS.length] }, + symbol: "circle", + symbolSize: 5, + })), + }, + ], + }; + }, [selectedStats, t]); + + return ( +
+

Encoder Comparison

+

Select up to 4 encoders to compare across performance axes.

+ +
+ {codecs.map((codec) => { + const isSelected = selected.includes(codec); + const stats = allStats.get(codec)!; + return ( + + ); + })} +
+ + {radarOption && ( + <> +
+ +
+ +
+ + + + + {selectedStats.map((s) => ( + + ))} + + + + {selectedStats.map((s) => )} + {selectedStats.map((s) => )} + {selectedStats.map((s) => )} + {selectedStats.map((s) => )} + {selectedStats.map((s) => )} + {selectedStats.map((s) => )} + +
Metric{s.codec}
Avg FPS{s.avgFps.toFixed(1)}
Avg VMAF{s.avgVmaf.toFixed(1)}
Avg SSIM{s.avgSsim.toFixed(4)}
Avg PSNR (dB){s.avgPsnr.toFixed(2)}
Avg Size (MB){s.avgSizeMB.toFixed(2)}
Samples{s.count}
+
+ + )} + + {selected.length === 1 && ( +
+ Select at least 2 encoders to see the comparison. +
+ )} + + {selected.length === 0 && ( +
+ Select encoders above to begin comparing. +
+ )} +
+ ); +} diff --git a/frontend/app/compare-encoders/page.module.css b/frontend/app/compare-encoders/page.module.css new file mode 100644 index 0000000..2566e67 --- /dev/null +++ b/frontend/app/compare-encoders/page.module.css @@ -0,0 +1,73 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px; +} + +.heading { + margin-bottom: 8px; +} + +.codecGrid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 24px; +} + +.codecBtn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; + transition: background 0.15s, border-color 0.15s; +} + +.codecBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.codecBtnActive { + font-weight: 600; +} + +.codecDot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.radarWrapper { + margin-bottom: 24px; +} + +.summaryTableWrapper { + overflow-x: auto; +} + +.summaryTable { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.summaryTable th, +.summaryTable td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.summaryTable th { + font-weight: 600; + background: var(--surface); +} + +.summaryTable td:first-child { + font-weight: 500; +} diff --git a/frontend/app/compare-encoders/page.tsx b/frontend/app/compare-encoders/page.tsx new file mode 100644 index 0000000..e8b7dfe --- /dev/null +++ b/frontend/app/compare-encoders/page.tsx @@ -0,0 +1,28 @@ +import type { Benchmark } from "../components/BenchmarksTable"; +import { fetchBenchmarks } from "../lib/fetchBenchmarks"; +import EncoderDashboardClient from "./EncoderDashboardClient"; + +export const revalidate = 60; + +export default async function EncoderComparisonPage() { + let data: Benchmark[] = []; + let error: string | null = null; + try { + data = await fetchBenchmarks(); + } catch (e: unknown) { + error = e instanceof Error ? e.message : "Unknown error"; + } + + if (error) { + return ( +
+

Encoder Comparison

+
+ Failed to load data: {error} +
+
+ ); + } + + return ; +} diff --git a/frontend/app/components/BenchmarksTable.module.css b/frontend/app/components/BenchmarksTable.module.css index b627a95..28a8963 100644 --- a/frontend/app/components/BenchmarksTable.module.css +++ b/frontend/app/components/BenchmarksTable.module.css @@ -1,12 +1,13 @@ .filterGrid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; - margin-bottom: 8px; + margin-bottom: 10px; } .encoderFilters { display: flex; + flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; @@ -26,7 +27,7 @@ .weightsGrid { display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 8px; align-items: center; @@ -74,10 +75,26 @@ } .sortable { - cursor: pointer; user-select: none; } +.sortButton { + width: 100%; + background: none; + border: 0; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; + padding: 0; +} + +.sortButton:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent); + outline-offset: 2px; + border-radius: 4px; +} + .sortIndicator { margin-left: 4px; } @@ -90,9 +107,193 @@ padding: 6px 10px; } +.detailsModal { + width: min(960px, calc(100vw - 28px)); + max-width: min(960px, calc(100vw - 28px)) !important; + max-height: min(88vh, 920px); + display: flex; + flex-direction: column; +} + +.detailsBody { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.detailsOverviewGrid { + display: grid; + grid-template-columns: minmax(260px, 0.95fr) minmax(360px, 1.35fr); + gap: 12px; + align-items: stretch; +} + +.aggregateCard { + border: 1px solid color-mix(in srgb, var(--border) 64%, var(--accent) 36%); + border-radius: 10px; + background: linear-gradient( + 152deg, + color-mix(in srgb, var(--surface-2) 74%, var(--highlight) 26%), + color-mix(in srgb, var(--surface) 84%, var(--surface-2) 16%) + ); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.aggregateHeader { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.aggregateTitle { + font-weight: 600; +} + +.aggregateSubtitle { + font-size: 12px; + line-height: 1.45; + margin-top: 4px; +} + +.aggregateBadge { + white-space: nowrap; + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; +} + +.aggregateBadgeMany { + background: color-mix(in srgb, var(--accent) 18%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); +} + +.aggregateBadgeSingle { + background: color-mix(in srgb, var(--surface-2) 88%, var(--surface)); +} + +.samplePills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.samplePill { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--border) 70%, var(--accent-secondary) 30%); + border-radius: 999px; + padding: 3px 10px; + background: color-mix(in srgb, var(--surface) 68%, var(--surface-2) 32%); +} + +.samplePillValue { + font-size: 12px; + font-weight: 600; +} + +.sectionTitle { + font-size: 12px; + margin-bottom: 6px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keyCard { + border: 1px solid color-mix(in srgb, var(--border) 70%, var(--accent) 30%); + border-radius: 10px; + background: color-mix(in srgb, var(--surface-2) 74%, var(--surface) 26%); + padding: 12px; +} + +.keyStatsGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.keyStat { + border: 1px solid color-mix(in srgb, var(--border) 84%, var(--accent) 16%); + border-radius: 8px; + background: color-mix(in srgb, var(--surface) 84%, var(--surface-2) 16%); + padding: 8px 10px; + min-height: 68px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.keyStatLabel { + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: color-mix(in srgb, var(--muted) 80%, var(--foreground) 20%); + margin-bottom: 4px; +} + +.keyStatValue { + font-size: 24px; + line-height: 1.05; + font-weight: 700; +} + +.configCard { + border: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent-secondary) 28%); + border-radius: 10px; + background: color-mix(in srgb, var(--surface) 82%, var(--surface-2) 18%); + padding: 12px; +} + +.configGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 14px; +} + +.configRow { + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + padding-bottom: 6px; +} + +.configLabel { + font-size: 11px; + margin-bottom: 2px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.configValue { + font-weight: 600; + line-height: 1.3; +} + +.additionalToggleRow { + display: flex; + justify-content: flex-start; +} + +.additionalToggleBtn { + padding: 7px 10px; +} + +.additionalPanel { + border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); + border-radius: 10px; + padding: 12px; + background: color-mix(in srgb, var(--surface) 88%, var(--surface-2) 12%); +} + .detailsGrid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } @@ -100,6 +301,10 @@ display: flex; flex-direction: column; gap: 4px; + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-2) 84%, var(--surface) 16%); + padding: 8px 10px; } .labelText { @@ -165,6 +370,11 @@ font-size: 12px; } +.weightWarning { + color: var(--warning-fg, #ca8a04); + font-size: 12px; +} + .hoverBtn { padding: 6px 12px; } @@ -172,3 +382,83 @@ .hoverBtn:hover { background: color-mix(in srgb, var(--accent) 12%, var(--surface-2)); } + +.virtualScrollContainer { + height: 640px; + overflow-y: auto; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in srgb, var(--surface) 92%, var(--surface-2) 8%); +} + +.virtualHeader { + position: sticky; + top: 0; + z-index: 2; + background: color-mix(in srgb, var(--surface) 90%, var(--highlight) 10%); + border-bottom: 1px solid color-mix(in srgb, var(--border) 78%, var(--accent) 22%); + font-weight: 600; + font-size: 13px; +} + +.virtualHeader > div { + padding: 8px 6px; +} + +.virtualRow { + align-items: center; + border-bottom: 1px solid color-mix(in srgb, var(--border) 86%, transparent); + font-size: 13px; +} + +.virtualRow > div { + padding: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.virtualRow:hover { + background: color-mix(in srgb, var(--accent) 7%, var(--surface)); +} + +.paginationBar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + font-size: 13px; +} + +@media (max-width: 760px) { + .detailsModal { + width: calc(100vw - 20px); + max-width: calc(100vw - 20px) !important; + } + + .detailsOverviewGrid { + grid-template-columns: 1fr; + } + + .keyStatsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .keyStatValue { + font-size: 20px; + } + + .configGrid { + grid-template-columns: 1fr; + } + + .detailsGrid { + grid-template-columns: 1fr; + } + + .aggregateHeader { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/app/components/BenchmarksTable.tsx b/frontend/app/components/BenchmarksTable.tsx index eb20e3c..725510b 100644 --- a/frontend/app/components/BenchmarksTable.tsx +++ b/frontend/app/components/BenchmarksTable.tsx @@ -1,52 +1,45 @@ "use client"; import { useMemo, useState, useEffect, useCallback, useRef } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useVirtualizer } from "@tanstack/react-virtual"; import styles from "./BenchmarksTable.module.css"; import ComparePanel, { CompareStickyBar } from "./ComparePanel"; import { formatCodecLabel } from "./codecLabel"; +import type { Benchmark } from "../lib/types"; +import { createPlScoreContext, scorePlBenchmarkV6 } from "../lib/plScore"; -export type Benchmark = { - id: string; - createdAt: string; - cpuModel: string; - gpuModel: string | null; - ramGB: number; - os: string; - codec: string; - // CRF is optional depending on encoder; when absent show "-" - crf?: number | null; - preset: string; - fps: number; - vmaf: number | null; - fileSizeBytes: number; - notes: string | null; - ffmpegVersion?: string | null; - encoderName?: string | null; - clientVersion?: string | null; - inputHash?: string | null; - runMs?: number | null; - status?: string | null; - // Aggregation counts (available from server) - samples?: number; - vmafSamples?: number; -}; +export type { Benchmark } from "../lib/types"; -// Extended type for benchmarks with computed scores -type EnrichedBenchmark = Benchmark & { - _plove: number; +const PAGE_SIZE = 50; +const COL_WIDTHS = "4% 9% 17% 17% 13% 7% 11% 12% 7% 7%"; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +// Intermediate type with per-row metrics (expensive computation, cached separately) +type PerRowMetrics = Benchmark & { + _q: number; // quality component + _s: number; // size component + _sp: number; // speed component + _eff: number; // efficiency component + _rel: number; // reliability component + _confidence: number; _relSize: number; _codecLabel: string; _isHardware: boolean; }; -type SortKey = "cpuModel" | "gpuModel" | "codec" | "crf" | "preset" | "_plove"; +// Extended type for benchmarks with computed scores +type EnrichedBenchmark = PerRowMetrics & { + _plScore: number; +}; + +type SortKey = "cpuModel" | "gpuModel" | "codec" | "crf" | "preset" | "_plScore"; export default function BenchmarksTable({ initialData }: { initialData: Benchmark[] }) { const searchParams = useSearchParams(); - const router = useRouter(); - const routerRef = useRef(router); - useEffect(() => { routerRef.current = router; }, [router]); const isInitRef = useRef(false); const [cpuFilter, setCpuFilter] = useState(() => searchParams.get("cpu") || ""); @@ -55,8 +48,9 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar const [presetFilter, setPresetFilter] = useState(() => searchParams.get("preset") || ""); const [sortKey, setSortKey] = useState(() => { const s = searchParams.get("sort"); - if (s && ["cpuModel", "gpuModel", "codec", "crf", "preset", "_plove"].includes(s)) return s as SortKey; - return "_plove"; + if (s === "_plove") return "_plScore"; // backward compatibility with older links + if (s && ["cpuModel", "gpuModel", "codec", "crf", "preset", "_plScore"].includes(s)) return s as SortKey; + return "_plScore"; }); const [sortDir, setSortDir] = useState<"asc" | "desc">(() => { const d = searchParams.get("dir"); @@ -66,30 +60,30 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar const [softwareOnly, setSoftwareOnly] = useState(() => searchParams.get("sw") === "1"); const [hardwareOnly, setHardwareOnly] = useState(() => searchParams.get("hw") === "1"); - // Sync filter state to URL search params (debounced to avoid excessive updates) + // Sync filter state to URL search params using native replaceState (F-07) const urlDebounceRef = useRef | null>(null); useEffect(() => { if (!isInitRef.current) { isInitRef.current = true; return; } if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); urlDebounceRef.current = setTimeout(() => { + if (typeof window === "undefined") return; const params = new URLSearchParams(); if (cpuFilter) params.set("cpu", cpuFilter); if (gpuFilter) params.set("gpu", gpuFilter); if (codecFilter) params.set("codec", codecFilter); if (presetFilter) params.set("preset", presetFilter); - if (sortKey !== "_plove") params.set("sort", sortKey); + if (sortKey !== "_plScore") params.set("sort", sortKey); if (sortDir !== "desc") params.set("dir", sortDir); if (softwareOnly) params.set("sw", "1"); if (hardwareOnly) params.set("hw", "1"); const qs = params.toString(); - const base = typeof window !== "undefined" ? window.location.pathname : "/"; - routerRef.current.replace(qs ? `${base}?${qs}` : base, { scroll: false }); + const base = window.location.pathname; + window.history.replaceState(null, "", qs ? `${base}?${qs}` : base); }, 300); return () => { if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [cpuFilter, gpuFilter, codecFilter, presetFilter, sortKey, sortDir, softwareOnly, hardwareOnly]); - // Weights for PLOVE score (sum must equal 1.0) + // Core PL Score v6 weights (sum normalized to 1.0) const [wQuality, setWQuality] = useState(1 / 3); const [wSize, setWSize] = useState(1 / 3); const [wSpeed, setWSpeed] = useState(1 / 3); @@ -121,10 +115,21 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar }); }, []); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setShowCompare(false); }, []); + const [page, setPage] = useState(0); + + // Reset page when any filter changes + useEffect(() => { + setPage(0); + }, [cpuFilter, gpuFilter, codecFilter, presetFilter, softwareOnly, hardwareOnly]); const codecs = useMemo(() => Array.from(new Set(initialData.map(d => d.codec))).sort(), [initialData]); const presets = useMemo(() => Array.from(new Set(initialData.map(d => d.preset))).sort(), [initialData]); - const filteredPresets = useMemo(() => codecFilter ? presetsForCodec(initialData, codecFilter) : presets, [initialData, codecFilter, presets]); + const filteredPresets = useMemo(() => { + if (!codecFilter) return presets; + const lower = codecFilter.toLowerCase(); + const matching = initialData.filter(r => r.codec.toLowerCase().includes(lower)); + return Array.from(new Set(matching.map(r => r.preset))).sort(); + }, [initialData, codecFilter, presets]); // Pre-compute hardware encoder classification once per row to avoid repeated regex tests const dataWithHwClass = useMemo(() => { @@ -140,75 +145,54 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar return dataWithHwClass.filter(row => { if (cpu && !row.cpuModel.toLowerCase().includes(cpu)) return false; if (gpu && !(row.gpuModel ?? "").toLowerCase().includes(gpu)) return false; - if (codecFilter && row.codec !== codecFilter) return false; - if (codecFilter && presetFilter && row.preset !== presetFilter) return false; + if (codecFilter && !row.codec.toLowerCase().includes(codecFilter.toLowerCase())) return false; + if (presetFilter && row.preset !== presetFilter) return false; if (softwareOnly && !hardwareOnly) return !row._isHardware; if (hardwareOnly && !softwareOnly) return row._isHardware; return true; }); }, [dataWithHwClass, cpuFilter, gpuFilter, codecFilter, presetFilter, softwareOnly, hardwareOnly]); - // Compute relative size baseline (median size across filtered rows) - const sizeBaseline = useMemo(() => { - const sizes = filtered.map(r => r.fileSizeBytes).filter(s => s > 0).sort((a,b)=>a-b); - if (sizes.length === 0) return 1; - const mid = Math.floor(sizes.length / 2); - return sizes.length % 2 === 0 ? Math.max(1, Math.floor((sizes[mid-1] + sizes[mid]) / 2)) : Math.max(1, sizes[mid]); - }, [filtered]); - - // Dataset min/max for normalization - const ranges = useMemo(() => { - const vmafVals = filtered.filter(r => typeof r.vmaf === "number").map(r => Number(r.vmaf)); - const fpsVals = filtered.map(r => Math.max(0, r.fps || 0)); - const relSizes = filtered.map(r => (r.fileSizeBytes > 0 ? r.fileSizeBytes / sizeBaseline : 1)); - let vmafMin = 0, vmafMax = 0, fpsMin = 0, fpsMax = 0, rsMin = 0, rsMax = 0; - if (vmafVals.length) { vmafMin = vmafVals[0]; vmafMax = vmafVals[0]; for (const v of vmafVals) { if (v < vmafMin) vmafMin = v; if (v > vmafMax) vmafMax = v; } } - if (fpsVals.length) { fpsMin = fpsVals[0]; fpsMax = fpsVals[0]; for (const v of fpsVals) { if (v < fpsMin) fpsMin = v; if (v > fpsMax) fpsMax = v; } } - if (relSizes.length) { rsMin = relSizes[0]; rsMax = relSizes[0]; for (const v of relSizes) { if (v < rsMin) rsMin = v; if (v > rsMax) rsMax = v; } } - return { vmafMin, vmafMax, fpsMin, fpsMax, rsMin, rsMax }; - }, [filtered, sizeBaseline]); + const plContext = useMemo(() => createPlScoreContext(filtered), [filtered]); - const withScores = useMemo((): EnrichedBenchmark[] => { - function qualityScore(vmaf: number | null | undefined): number { - if (typeof vmaf !== "number") return 100; - const v = Math.max(0, Math.min(100, vmaf)); - if (v >= 90) { - return 50 + 50 * Math.sqrt((v - 90) / 10); - } - return 50 * Math.pow(v / 90, 4); - } - function sizeScore(rel: number): number { - if (!(ranges.rsMax > ranges.rsMin)) return 100; - return 100 * (ranges.rsMax - rel) / (ranges.rsMax - ranges.rsMin); - } - function speedScore(fps: number): number { - const f = Math.max(0, fps || 0); - if (!(ranges.fpsMax > 0 && ranges.fpsMin > 0)) return 0; - if (ranges.fpsMax === ranges.fpsMin) return 100; - const logF = Math.log(f > 0 ? f : ranges.fpsMin); - const logMin = Math.log(ranges.fpsMin); - const logMax = Math.log(ranges.fpsMax); - return 100 * (logF - logMin) / (logMax - logMin); - } - - return filtered.map((row): EnrichedBenchmark => { - const relSize = row.fileSizeBytes > 0 ? row.fileSizeBytes / sizeBaseline : 1; + // Stage 1: compute PL Score v6 components that are independent from user weights + const perRowMetrics = useMemo((): PerRowMetrics[] => { + return filtered.map((row): PerRowMetrics => { + const relSize = row.fileSizeBytes > 0 ? row.fileSizeBytes / plContext.sizeBaseline : 1; const encoder = (row.encoderName ?? row.codec ?? "").toLowerCase(); const codecLabel = formatCodecLabel(encoder); + const scored = scorePlBenchmarkV6(row, plContext, { quality: 1 / 3, size: 1 / 3, speed: 1 / 3 }); + return { + ...row, + _q: scored.quality, + _s: scored.size, + _sp: scored.speed, + _eff: scored.efficiency, + _rel: scored.reliability, + _confidence: scored.measurementConfidence, + _relSize: relSize, + _codecLabel: codecLabel, + }; + }); + }, [filtered, plContext]); - if (relSize >= 1) { - return { ...row, _plove: 0, _relSize: relSize, _codecLabel: codecLabel }; - } - - const q = qualityScore(row.vmaf); - const s = sizeScore(relSize); - const sp = speedScore(row.fps); - const prelim = wQuality * q + wSize * s + wSpeed * sp; - const plove = Math.max(0, Math.min(100, prelim)); - - return { ...row, _plove: plove, _relSize: relSize, _codecLabel: codecLabel }; + // Stage 2: recompute final PL score when weights change + const withScores = useMemo((): EnrichedBenchmark[] => { + const weightSum = Math.max(0.0001, wQuality + wSize + wSpeed); + const normalizedQuality = wQuality / weightSum; + const normalizedSize = wSize / weightSum; + const normalizedSpeed = wSpeed / weightSum; + return perRowMetrics.map((row): EnrichedBenchmark => { + const core = clamp( + normalizedQuality * row._q + normalizedSize * row._s + normalizedSpeed * row._sp, + 0, + 100, + ); + const confidenceAdj = (row._confidence - 0.7) * 6; + const total = clamp(core * 0.78 + row._eff * 0.14 + row._rel * 0.08 + confidenceAdj, 0, 100); + return { ...row, _plScore: total }; }); - }, [filtered, ranges, wQuality, wSize, wSpeed, sizeBaseline]); + }, [perRowMetrics, wQuality, wSize, wSpeed]); const sorted = useMemo((): EnrichedBenchmark[] => { const data = [...withScores]; @@ -216,7 +200,7 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar const mul = sortDir === "asc" ? 1 : -1; const getValue = (row: EnrichedBenchmark): string | number | null => { if (sortKey === "codec") return row._codecLabel; - if (sortKey === "_plove") return row._plove; + if (sortKey === "_plScore") return row._plScore; return row[sortKey] ?? null; }; const av = getValue(a); @@ -231,6 +215,36 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar return data; }, [withScores, sortKey, sortDir]); + // Keep selections in-sync with currently visible filtered dataset. + useEffect(() => { + const allowed = new Set(sorted.map(row => row.id)); + setSelectedIds(prev => { + let changed = false; + const next = new Set(); + for (const id of prev) { + if (allowed.has(id)) { + next.add(id); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [sorted]); + + const totalRows = sorted.length; + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + useEffect(() => { + if (page >= totalPages) { + setPage(Math.max(0, totalPages - 1)); + } + }, [page, totalPages]); + + const pagedRows = useMemo(() => { + const start = page * PAGE_SIZE; + return sorted.slice(start, start + PAGE_SIZE); + }, [sorted, page]); + const compareRows = useMemo(() => sorted.filter(r => selectedIds.has(r.id)), [sorted, selectedIds]); const setSort = (key: SortKey) => { @@ -240,12 +254,27 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar function applyWeightsFromUI() { const sum = uiQuality + uiSize + uiSpeed; - const safe = sum > 0 ? sum : 1; - setWQuality(uiQuality / safe); - setWSize(uiSize / safe); - setWSpeed(uiSpeed / safe); + if (sum <= 0) { + // All sliders at zero: reset to equal weights (B-F02) + const d = 1 / 3; + setWQuality(d); + setWSize(d); + setWSpeed(d); + setUiQuality(d); + setUiSize(d); + setUiSpeed(d); + return; + } + setWQuality(uiQuality / sum); + setWSize(uiSize / sum); + setWSpeed(uiSpeed / sum); } + const weightsNeedNormalization = useMemo(() => { + const sum = uiQuality + uiSize + uiSpeed; + return sum > 0 && Math.abs(sum - 1.0) > 0.05; + }, [uiQuality, uiSize, uiSpeed]); + return (
@@ -261,18 +290,26 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar onChange={e => setGpuFilter(e.target.value)} className="input" /> - +
+ { setCodecFilter(e.target.value); setPresetFilter(""); }} + className="input" + /> + + {codecs.map(c => ( +
{ const v = e.target.checked; setSoftwareOnly(v); if (v) setHardwareOnly(false); }} /> - Software Encoders Only + Software Only
-
Scoring Weights
-
Sum is constrained to 1.00
+
PL Score v6 Core Weights
+
Quality, Size, and Speed are normalized to sum to 1.00
- +
Applied: Q {wQuality.toFixed(2)} • S {wSize.toFixed(2)} • V {wSpeed.toFixed(2)}
+ {weightsNeedNormalization && ( +
Sliders will be normalized to sum to 1.0 on Apply
+ )}
-
- - {(() => { - const cols = [ - , - , - , - , - , - , - , - , - , - , - ]; - return {cols}; - })()} - - - - - - - - - - {sorted.map(row => ( - - - - - - - - - - - - - ))} - {sorted.length === 0 && ( - - - - )} - -
Details setSort("cpuModel")} label="CPU" active={sortKey === "cpuModel"} dir={sortDir} /> - setSort("gpuModel")} label="GPU" active={sortKey === "gpuModel"} dir={sortDir} /> - setSort("codec")} label="Codec" active={sortKey === "codec"} dir={sortDir} /> - setSort("crf")} label="CRF" active={sortKey === "crf"} dir={sortDir} align="right" /> - setSort("preset")} label="Preset" active={sortKey === "preset"} dir={sortDir} /> - setSort("_plove")} label="PLOVE Score" active={sortKey === "_plove"} dir={sortDir} align="right" /> - FFmpegSubs
- toggleSelect(row.id)} - disabled={!selectedIds.has(row.id) && selectedIds.size >= 6} - aria-label="Select for comparison" - style={{ accentColor: "var(--accent)" }} - /> - - - {renderHardwareLink(row.cpuModel, "cpu")}{renderGpuCell(row)}{row._codecLabel}{row.crf == null ? "-" : row.crf}{row.preset}{row._plove > 0 ? row._plove.toFixed(2) : "-"} - - {typeof row.samples === "number" ? row.samples : "-"}
- No results for current filters. -
+ + +
+ + Showing {totalRows === 0 ? 0 : page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalRows)} of {totalRows} + +
+ + Page {page + 1} of {totalPages} + +
{showDetailId && (() => { @@ -411,50 +399,201 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar ); } -function Th({ label, onClick, active, dir, align }: { label: string; onClick: () => void; active: boolean; dir: "asc" | "desc"; align?: "left" | "right" }) { +function VirtualTable({ rows, selectedIds, toggleSelect, setShowDetailId, setShowFfmpegId, sortKey, sortDir, setSort }: { rows: EnrichedBenchmark[]; selectedIds: Set; toggleSelect: (id: string) => void; setShowDetailId: (id: string | null) => void; setShowFfmpegId: (id: string | null) => void; sortKey: SortKey; sortDir: "asc" | "desc"; setSort: (key: SortKey) => void }) { + const parentRef = useRef(null); + const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => 48, overscan: 10 }); return ( - - {label} - {active && ( - - )} - +
+
+
+
Details
+ setSort("cpuModel")} label="CPU" active={sortKey === "cpuModel"} dir={sortDir} /> + setSort("gpuModel")} label="GPU" active={sortKey === "gpuModel"} dir={sortDir} /> + setSort("codec")} label="Codec" active={sortKey === "codec"} dir={sortDir} /> + setSort("crf")} label="CRF" active={sortKey === "crf"} dir={sortDir} align="right" /> + setSort("preset")} label="Preset" active={sortKey === "preset"} dir={sortDir} /> + setSort("_plScore")} label="PL Score v6" active={sortKey === "_plScore"} dir={sortDir} align="right" /> +
FFmpeg
+
Subs
+
+
+
+ {virtualizer.getVirtualItems().map(vr => { + const row = rows[vr.index]; + return ( +
+
toggleSelect(row.id)} disabled={!selectedIds.has(row.id) && selectedIds.size >= 6} aria-label="Select for comparison" style={{ accentColor: "var(--accent)" }} />
+
+
{renderHardwareLink(row.cpuModel, "cpu")}
+
{renderGpuCell(row)}
+
{row._codecLabel}
+
{row.crf == null ? "-" : row.crf}
+
{row.preset}
+
{row._plScore > 0 ? row._plScore.toFixed(2) : "-"}
+
+
{typeof row.samples === "number" ? row.samples : "-"}
+
+ ); + })} + {rows.length === 0 &&
No results for current filters.
} +
+
+
+ ); +} + +function ThDiv({ label, onClick, active, dir, align }: { label: string; onClick: () => void; active: boolean; dir: "asc" | "desc"; align?: "left" | "right" }) { + return ( +
+ +
); } function DetailsModal({ row, onClose, relSize }: { row: EnrichedBenchmark; onClose: () => void; relSize: number }) { + const [showAdditional, setShowAdditional] = useState(false); + + const acceptedSamplesRaw = typeof row.samples === "number" ? row.samples : 1; + const acceptedSamples = acceptedSamplesRaw > 0 ? acceptedSamplesRaw : 1; + const isAggregate = acceptedSamples > 1; + const aggregateSuffix = isAggregate ? " (avg)" : ""; + const vmafSamples = typeof row.vmafSamples === "number" ? row.vmafSamples : row.vmaf != null ? acceptedSamples : 0; + const ssimSamples = typeof row.ssimSamples === "number" ? row.ssimSamples : row.ssim != null ? acceptedSamples : 0; + const psnrSamples = typeof row.psnrSamples === "number" ? row.psnrSamples : row.psnr != null ? acceptedSamples : 0; + const encodeModeLabel = "CRF (single-pass)"; + return (
-
+
Encode Details
-
- - - - - - - - - +
+
+
+
+
+
{isAggregate ? "Aggregate settings row" : "Single-submission row"}
+
+ {isAggregate + ? `Averages across ${acceptedSamples} accepted submissions with identical CPU/GPU, codec, preset, and CRF.` + : "One accepted submission currently exists for this exact settings profile."} +
+
+
+ {acceptedSamples} {acceptedSamples === 1 ? "submission" : "submissions"} +
+
+
+ + + +
+
+ +
+
Key performance snapshot
+
+ + + + + + +
+
+
+ +
+
Configuration
+
+ + + + + +
+
+ +
+ +
+ + {showAdditional && ( +
+
Additional data
+
+ + + + + + + + + + + + + + + + + + + +
+
+ )}
); } +function SamplePill({ label, count }: { label: string; count: number }) { + return ( +
+ {label} + {count} +
+ ); +} + +function KeyStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ConfigRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + function LabelValue({ label, value }: { label: string; value: string }) { return (
@@ -469,6 +608,15 @@ function hasShellMetachars(s: string): boolean { return /[;&|`$(){}[\]<>\\!"'*?#~]/.test(s); } +function shellQuotePosix(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function isSafeCliToken(value: string): boolean { + return /^[a-z0-9_:+.-]+$/i.test(value); +} + function FfmpegModal({ row, onClose }: { row: EnrichedBenchmark; onClose: () => void }) { const [inputPath, setInputPath] = useState("input.mp4"); const [outputPath, setOutputPath] = useState("output.mp4"); @@ -481,31 +629,35 @@ function FfmpegModal({ row, onClose }: { row: EnrichedBenchmark; onClose: () => }, [copied]); // Check for potentially dangerous characters in paths + const encoderRaw = (row.encoderName ?? row.codec ?? "").trim(); + const presetRaw = (row.preset ?? "").trim(); + const safeEncoder = isSafeCliToken(encoderRaw) ? encoderRaw : ""; + const safePreset = isSafeCliToken(presetRaw) ? presetRaw : ""; const pathWarning = hasShellMetachars(inputPath) || hasShellMetachars(outputPath); + const profileWarning = (!safeEncoder && encoderRaw.length > 0) || (!safePreset && presetRaw.length > 0); const command = useMemo(() => { - const encoder = (row.encoderName ?? row.codec ?? "").trim(); const safeInput = inputPath || "input.mp4"; const safeOutput = outputPath || "output.mp4"; const parts: string[] = [ "ffmpeg", "-i", - safeInput, + shellQuotePosix(safeInput), ]; - if (encoder) { - parts.push("-c:v", encoder); + if (safeEncoder) { + parts.push("-c:v", shellQuotePosix(safeEncoder)); } if (row.crf != null) { parts.push("-crf", String(row.crf)); } - if (row.preset) { - parts.push("-preset", row.preset); + if (safePreset) { + parts.push("-preset", shellQuotePosix(safePreset)); } parts.push("-c:a", "copy"); - parts.push(safeOutput); + parts.push(shellQuotePosix(safeOutput)); return parts.join(" "); - }, [row, inputPath, outputPath]); + }, [row.crf, inputPath, outputPath, safeEncoder, safePreset]); const copy = async () => { try { @@ -549,6 +701,11 @@ function FfmpegModal({ row, onClose }: { row: EnrichedBenchmark; onClose: () => Warning: Path contains special characters. Review the command carefully before running.
)} + {profileWarning && ( +
+ Warning: Encoder or preset contained unsafe characters and was omitted from the generated command. +
+ )}
{command}
+
+ )} + + )} +
+ ); +} diff --git a/frontend/app/components/ScatterFpsSize.module.css b/frontend/app/components/ScatterFpsSize.module.css index 61034e5..e010402 100644 --- a/frontend/app/components/ScatterFpsSize.module.css +++ b/frontend/app/components/ScatterFpsSize.module.css @@ -1,5 +1,10 @@ .chartCard { - padding: 12px; + padding: 14px; + border-width: 2px; + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; } .headerRow { @@ -8,25 +13,20 @@ align-items: center; margin-bottom: 8px; gap: 12px; + flex: 0 0 auto; } .chartTitle { - font-weight: 600; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; } .codecInput { - max-width: 280px; -} - -.rangeControls { - margin-top: 8px; -} - -.xRangeWrapper { - width: 100%; + max-width: 320px; } -.xRangeLabel { - font-size: 12px; - text-align: center; +.chartBody { + flex: 1; + min-height: 0; } diff --git a/frontend/app/components/ScatterFpsSize.tsx b/frontend/app/components/ScatterFpsSize.tsx index e36e8be..169cfd4 100644 --- a/frontend/app/components/ScatterFpsSize.tsx +++ b/frontend/app/components/ScatterFpsSize.tsx @@ -1,111 +1,82 @@ "use client"; -import { useMemo, useState, useRef, useEffect, useCallback } from "react"; +import { useMemo, useState } from "react"; import type { Benchmark } from "./BenchmarksTable"; +import { CODEC_COLORS, codecColorKey } from "../lib/chartColors"; +import { useChartTheme } from "../lib/useChartTheme"; +import { escapeHtml } from "../lib/escapeHtml"; +import EChart from "./EChart"; import styles from "./ScatterFpsSize.module.css"; -type Point = { - x: number; // file size (MB) - y: number; // fps - label: string; - color: string; -}; - -const COLORS: Record = { - av1: "#173B34", // Evergreen - h264: "#6C8FD5", // Cornflower Blue - hevc: "#9693CC", // Lavender Grey - vp9: "#d4a843", // Darker gold (accessible contrast) - other: "#CDDBCD", // Ash Grey -}; - -function codecKey(codec: string): keyof typeof COLORS { - const c = codec.toLowerCase(); - if (c.includes("av1")) return "av1"; - if (c.includes("265") || c.includes("hevc") || c.includes("x265")) return "hevc"; - if (c.includes("264") || c.includes("avc") || c.includes("x264")) return "h264"; - if (c.includes("vp9") || c.includes("libvpx")) return "vp9"; - return "other"; -} - export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { - const [codecFilter, setCodecFilter] = useState(""); - const [hover, setHover] = useState<{ domX: number; domY: number; text: string; svgX: number; svgY: number } | null>(null); - const [view, setView] = useState<{ xMax: number; yMax: number }>({ xMax: 1, yMax: 1 }); - const svgRef = useRef(null); - - const points = useMemo(() => { - return data - .filter((d) => !codecFilter || d.codec.toLowerCase().includes(codecFilter.toLowerCase())) - .map((d) => ({ - x: Math.max(0.001, d.fileSizeBytes / (1024 * 1024)), - y: Math.max(0, d.fps), - label: `${d.codec} \u2022 ${d.preset}${d.crf != null ? ` \u2022 CRF ${d.crf}` : ""}`, - color: COLORS[codecKey(d.codec)], - })); + const t = useChartTheme(); + const [codecFilter, setCodecFilter] = useState(""); + + const series = useMemo(() => { + const filtered = data.filter( + (d) => !codecFilter || d.codec.toLowerCase().includes(codecFilter.toLowerCase()), + ); + const map = new Map(); + for (const d of filtered) { + const key = codecColorKey(d.codec); + const arr = map.get(key) || []; + arr.push({ + value: [Math.max(0.001, d.fileSizeBytes / (1024 * 1024)), Math.max(0, d.fps)], + label: `${d.codec} • ${d.preset}${d.crf != null ? ` • CRF ${d.crf}` : ""}`, + }); + map.set(key, arr); + } + return Array.from(map.entries()).map(([key, points]) => ({ + name: key, + type: "scatter" as const, + data: points, + symbolSize: 6, + itemStyle: { color: CODEC_COLORS[key] || CODEC_COLORS.other, opacity: 0.8 }, + emphasis: { itemStyle: { opacity: 1 }, scale: 1.4 }, + })); }, [data, codecFilter]); - const width = 720; - const height = 380; - const margin = { top: 24, right: 24, bottom: 48, left: 56 }; - const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; - - let maxXRaw = 1, maxYRaw = 1; - for (const p of points) { if (p.x > maxXRaw) maxXRaw = p.x; if (p.y > maxYRaw) maxYRaw = p.y; } - const maxX = Math.max(1, view.xMax); - const maxY = Math.max(1, view.yMax); - - const xFor = (v: number) => margin.left + (v / maxX) * chartWidth; - const yFor = (v: number) => margin.top + chartHeight - (v / maxY) * chartHeight; - - // Initialize view to fit data; update when data changes - useEffect(() => { - setView({ xMax: Math.ceil(maxXRaw), yMax: Math.ceil(maxYRaw) }); - }, [maxXRaw, maxYRaw]); - - // Throttle mouse move to one update per animation frame - const rafRef = useRef(null); - useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []); - - const onMouseMove = useCallback((e: React.MouseEvent) => { - const clientX = e.clientX; - const clientY = e.clientY; - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const scaleX = width / rect.width; - const scaleY = height / rect.height; - const svgMx = (clientX - rect.left) * scaleX; - const svgMy = (clientY - rect.top) * scaleY; - - let best: { d2: number; p: Point } | null = null; - for (const p of points) { - const dx = xFor(p.x) - svgMx; - const dy = yFor(p.y) - svgMy; - const d2 = dx * dx + dy * dy; - if (!best || d2 < best.d2) best = { d2, p }; - } - if (best && best.d2 < 16 * 16) { - setHover({ - domX: clientX - rect.left, - domY: clientY - rect.top, - svgX: xFor(best.p.x), - svgY: yFor(best.p.y), - text: `${best.p.label} \u2014 ${best.p.y.toFixed(1)} FPS, ${best.p.x.toFixed(2)} MB`, - }); - } else { - setHover(null); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [points, maxX, maxY]); + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "item", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg, fontSize: 12 }, + formatter: (params: { seriesName: string; value: [number, number]; data: { label: string } }) => + `${escapeHtml(params.data.label)}
Size: ${params.value[0].toFixed(2)} MB
FPS: ${params.value[1].toFixed(1)}`, + }, + legend: { + data: series.map((s) => s.name), + textStyle: { color: t.fg, fontSize: 11 }, + top: 4, + type: "scroll" as const, + }, + dataZoom: [ + { type: "inside" }, + { type: "slider", xAxisIndex: 0, height: 16, bottom: 4, borderColor: t.border, fillerColor: `${t.accent}33`, handleStyle: { color: t.accent }, showDetail: false }, + ], + grid: { left: 52, right: 12, top: 32, bottom: 40, containLabel: false }, + xAxis: { + type: "value", + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, formatter: (v: number) => `${v.toFixed(0)} MB` }, + splitLine: { lineStyle: { color: t.border } }, + }, + yAxis: { + type: "value", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series, + }), [series, t]); return ( -
+
FPS vs File Size
setCodecFilter(e.target.value)} />
- setHover(null)}> - {/* Grid */} - {Array.from({ length: 5 }).map((_, i) => { - const y = margin.top + (i * chartHeight) / 4; - return ; - })} - {Array.from({ length: 5 }).map((_, i) => { - const x = margin.left + (i * chartWidth) / 4; - return ; - })} - - {/* Points */} - {points.map((p, idx) => { - const cx = xFor(p.x); - const cy = yFor(p.y); - const isHovered = hover && Math.hypot(hover.svgX - cx, hover.svgY - cy) < 16; - return ( - - ); - })} - - {/* X axis */} - - {Array.from({ length: 5 }).map((_, i) => { - const x = margin.left + (i * chartWidth) / 4; - const value = (maxX * i) / 4; - return ( - - {value.toFixed(1)} MB - - ); - })} - File Size (MB) - - {/* Y axis */} - - {Array.from({ length: 5 }).map((_, i) => { - const value = (maxY * (4 - i)) / 4; - const y = margin.top + (i * chartHeight) / 4; - return ( - - {value.toFixed(0)} FPS - - ); - })} - - {hover && ( -
- {hover.text} -
- )} - - {/* Axis range controls */} -
-
- setView(v=>({ ...v, xMax: Number(e.target.value) }))} style={{ width: "100%", accentColor: "var(--accent)" }} /> -
Max File Size (MB)
-
-
+
); } diff --git a/frontend/app/components/ScatterSsimVmaf.tsx b/frontend/app/components/ScatterSsimVmaf.tsx new file mode 100644 index 0000000..cc5a534 --- /dev/null +++ b/frontend/app/components/ScatterSsimVmaf.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { CODEC_COLORS, codecColorKey } from "../lib/chartColors"; +import { useChartTheme } from "../lib/useChartTheme"; +import { escapeHtml } from "../lib/escapeHtml"; +import EChart from "./EChart"; +import styles from "./ScatterFpsSize.module.css"; + +export default function ScatterSsimVmaf({ data }: { data: Benchmark[] }) { + const t = useChartTheme(); + const [codecFilter, setCodecFilter] = useState(""); + + const series = useMemo(() => { + const filtered = data + .filter((d) => typeof d.ssim === "number" && typeof d.vmaf === "number") + .filter((d) => !codecFilter || d.codec.toLowerCase().includes(codecFilter.toLowerCase())); + const map = new Map(); + for (const d of filtered) { + const key = codecColorKey(d.codec); + const arr = map.get(key) || []; + arr.push({ + value: [Math.max(0, Math.min(1, d.ssim as number)), Math.max(0, Math.min(100, d.vmaf as number))], + label: `${d.codec} • ${d.preset}${d.crf != null ? ` • CRF ${d.crf}` : ""}`, + }); + map.set(key, arr); + } + return Array.from(map.entries()).map(([key, points]) => ({ + name: key, + type: "scatter" as const, + data: points, + symbolSize: 6, + itemStyle: { color: CODEC_COLORS[key] || CODEC_COLORS.other, opacity: 0.8 }, + emphasis: { itemStyle: { opacity: 1 }, scale: 1.4 }, + })); + }, [data, codecFilter]); + + const hasData = series.some((s) => s.data.length > 0); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "item", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg, fontSize: 12 }, + formatter: (params: { value: [number, number]; data: { label: string } }) => + `${escapeHtml(params.data.label)}
SSIM: ${params.value[0].toFixed(4)}
VMAF: ${params.value[1].toFixed(1)}`, + }, + legend: { + data: series.map((s) => s.name), + textStyle: { color: t.fg, fontSize: 11 }, + top: 4, + type: "scroll" as const, + }, + dataZoom: [{ type: "inside" }], + grid: { left: 52, right: 12, top: 32, bottom: 32, containLabel: false }, + xAxis: { + type: "value", + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 10, formatter: (v: number) => v.toFixed(3) }, + splitLine: { lineStyle: { color: t.border } }, + }, + yAxis: { + type: "value", + name: "VMAF", + min: 0, + max: 100, + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series, + }), [series, t]); + + if (!hasData) return null; + + return ( +
+
+
SSIM vs VMAF
+ setCodecFilter(e.target.value)} + /> +
+
+
+ ); +} diff --git a/frontend/app/components/SsimHistogram.tsx b/frontend/app/components/SsimHistogram.tsx new file mode 100644 index 0000000..38aced0 --- /dev/null +++ b/frontend/app/components/SsimHistogram.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +export default function SsimHistogram({ data, bins = 20 }: { data: Benchmark[]; bins?: number }) { + const t = useChartTheme(); + + const values = useMemo(() => + data + .map((d) => (typeof d.ssim === "number" ? Math.max(0, Math.min(1, d.ssim)) : null)) + .filter((v): v is number => v != null), + [data]); + + const { binData, lo, hi } = useMemo(() => { + if (values.length === 0) return { binData: [], lo: 0, hi: 1 }; + let lo = values[0], hi = values[0]; + for (const v of values) { if (v < lo) lo = v; if (v > hi) hi = v; } + lo = Math.max(0, lo - 0.005); + hi = Math.min(1, hi + 0.005); + const step = (hi - lo) / bins; + const counts = new Array(bins).fill(0) as number[]; + for (const v of values) { + const idx = Math.min(bins - 1, Math.floor((v - lo) / step)); + counts[idx] += 1; + } + return { + binData: counts.map((count, i) => [lo + (i + 0.5) * step, count] as [number, number]), + lo, + hi, + }; + }, [values, bins]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { value: [number, number] }[]) => { + const [mid, count] = params[0].value; + const step = (hi - lo) / bins; + return `SSIM ${(mid - step / 2).toFixed(4)}–${(mid + step / 2).toFixed(4)}
${count} run${count === 1 ? "" : "s"}`; + }, + }, + dataZoom: [ + { type: "inside", xAxisIndex: 0 }, + { type: "slider", xAxisIndex: 0, height: 18, bottom: 4, borderColor: t.border, fillerColor: `${t.accent}33`, handleStyle: { color: t.accent }, showDetail: false }, + ], + grid: { left: 40, right: 12, top: 8, bottom: 40, containLabel: false }, + xAxis: { + type: "value", + min: lo, + max: hi, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 10, formatter: (v: number) => v.toFixed(3) }, + splitLine: { show: false }, + }, + yAxis: { + type: "value", + name: "Count", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + minInterval: 1, + }, + series: [ + { + type: "bar", + data: binData, + barWidth: "95%", + itemStyle: { color: "#6C8FD5", borderRadius: [3, 3, 0, 0] }, + emphasis: { itemStyle: { color: "#8aabea" } }, + }, + ], + }), [binData, lo, hi, bins, t]); + + if (values.length === 0) return null; + + return ( +
+
SSIM Distribution
+
Scroll to zoom · drag to pan
+
+
+ ); +} diff --git a/frontend/app/components/StatsCards.module.css b/frontend/app/components/StatsCards.module.css index 5755dd5..cd93267 100644 --- a/frontend/app/components/StatsCards.module.css +++ b/frontend/app/components/StatsCards.module.css @@ -1,8 +1,8 @@ .grid { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; - margin-bottom: 16px; + margin-bottom: 18px; } @media (max-width: 768px) { @@ -12,17 +12,21 @@ } .card { - padding: 12px 16px; + padding: 14px 16px; + border-width: 2px; } .label { font-size: 12px; color: var(--muted); margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; } .value { - font-size: 20px; - font-weight: 600; + font-size: 22px; + font-weight: 700; color: var(--accent); + font-family: "Consolas", "Menlo", "Monaco", "Courier New", monospace; } diff --git a/frontend/app/components/VmafHistogram.tsx b/frontend/app/components/VmafHistogram.tsx index cb09117..bf52120 100644 --- a/frontend/app/components/VmafHistogram.tsx +++ b/frontend/app/components/VmafHistogram.tsx @@ -1,122 +1,94 @@ "use client"; +import { useMemo } from "react"; import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; -import { useMemo, useRef, useState } from "react"; +export default function VmafHistogram({ data, bins = 20 }: { data: Benchmark[]; bins?: number }) { + const t = useChartTheme(); -export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[]; bins?: number }) { - const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null); - const svgRef = useRef(null); + const values = useMemo(() => + data + .map((d) => (typeof d.vmaf === "number" ? Math.max(0, Math.min(100, d.vmaf)) : null)) + .filter((v): v is number => v != null), + [data]); - const valuesAll = useMemo(() => { - return data.map((d) => typeof d.vmaf === "number" ? Math.max(0, Math.min(100, d.vmaf)) : null).filter((v): v is number => v != null); - }, [data]); - - const { autoMin, autoMax } = useMemo(() => { - if (valuesAll.length === 0) return { autoMin: 0, autoMax: 100 }; - let lo = valuesAll[0], hi = valuesAll[0]; - for (const v of valuesAll) { if (v < lo) lo = v; if (v > hi) hi = v; } - return { autoMin: Math.max(0, Math.min(lo, 80)), autoMax: Math.min(100, Math.max(hi, 95)) }; - }, [valuesAll]); - - const [range, setRange] = useState<{ min: number; max: number }>({ min: autoMin, max: autoMax }); - - if (valuesAll.length === 0) return null; - - const { counts, maxCount, step } = useMemo(() => { - const mn = range.min; - const mx = range.max; - const s = (mx - mn) / bins; - const visible = valuesAll.filter(v => v >= mn && v <= mx); - const c = new Array(bins).fill(0) as number[]; - for (const v of visible) { - const idx = Math.min(bins - 1, Math.floor((v - mn) / s)); - c[idx] += 1; + const { binData, lo, hi } = useMemo(() => { + if (values.length === 0) return { binData: [], lo: 0, hi: 100 }; + let lo = values[0], hi = values[0]; + for (const v of values) { if (v < lo) lo = v; if (v > hi) hi = v; } + // Widen slightly so all bars are visible + lo = Math.max(0, lo - 1); + hi = Math.min(100, hi + 1); + const step = (hi - lo) / bins; + const counts = new Array(bins).fill(0) as number[]; + for (const v of values) { + const idx = Math.min(bins - 1, Math.floor((v - lo) / step)); + counts[idx] += 1; } - let mc = 1; - for (const count of c) if (count > mc) mc = count; - return { counts: c, maxCount: mc, step: s }; - }, [valuesAll, range.min, range.max, bins]); - - const min = range.min; - const max = range.max; - - const width = 720; - const height = 280; - const margin = { top: 24, right: 16, bottom: 40, left: 40 }; - const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; - - const barGap = 2; - const barWidth = (chartWidth - barGap * (bins - 1)) / bins; - const xFor = (i: number) => margin.left + i * (barWidth + barGap); - const yFor = (c: number) => margin.top + chartHeight - (c / maxCount) * chartHeight; - - // Convert SVG coordinates to DOM pixel coordinates for tooltip positioning - function svgToDom(svgX: number, svgY: number): { x: number; y: number } { - const rect = svgRef.current?.getBoundingClientRect(); - if (!rect) return { x: svgX, y: svgY }; return { - x: (svgX / width) * rect.width, - y: (svgY / height) * rect.height, + binData: counts.map((count, i) => [lo + (i + 0.5) * step, count] as [number, number]), + lo, + hi, }; - } + }, [values, bins]); - return ( -
-
VMAF Distribution
- setHover(null)}> - {/* Grid */} - {Array.from({ length: 4 }).map((_, i) => { - const y = margin.top + (i * chartHeight) / 3; - return ; - })} + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { value: [number, number] }[]) => { + const [mid, count] = params[0].value; + const step = (hi - lo) / bins; + return `VMAF ${(mid - step / 2).toFixed(1)}–${(mid + step / 2).toFixed(1)}
${count} run${count === 1 ? "" : "s"}`; + }, + }, + dataZoom: [ + { type: "inside", xAxisIndex: 0 }, + { type: "slider", xAxisIndex: 0, height: 18, bottom: 4, borderColor: t.border, fillerColor: `${t.accent}33`, handleStyle: { color: t.accent }, showDetail: false }, + ], + grid: { left: 40, right: 12, top: 8, bottom: 40, containLabel: false }, + xAxis: { + type: "value", + min: lo, + max: hi, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11 }, + splitLine: { show: false }, + }, + yAxis: { + type: "value", + name: "Count", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + minInterval: 1, + }, + series: [ + { + type: "bar", + data: binData, + barWidth: "95%", + itemStyle: { color: "#9693CC", borderRadius: [3, 3, 0, 0] }, + emphasis: { itemStyle: { color: "#b3b0e0" } }, + }, + ], + }), [binData, lo, hi, bins, t]); - {counts.map((c, i) => { - const x = xFor(i); - const y = yFor(c); - const h = margin.top + chartHeight - y; - const labelFrom = Math.round(min + i * step); - const labelTo = Math.round(min + (i + 1) * step); - return ( - { - const dom = svgToDom(x + barWidth / 2 + 8, y); - setHover({ x: dom.x, y: dom.y, text: `${labelFrom}\u2013${labelTo}: ${c}` }); - }} onMouseLeave={() => setHover(null)}> - - - ); - })} + if (values.length === 0) return null; - {/* Axis */} - - {Array.from({ length: 5 }).map((_, i) => { - const x = margin.left + (i * chartWidth) / 4; - const value = min + ((max - min) * i) / 4; - return ( - - {value.toFixed(0)} - - ); - })} - VMAF - - {/* Range sliders under chart */} -
- - -
- {hover && ( -
- {hover.text} -
- )} + return ( +
+
VMAF Distribution
+
Scroll to zoom · drag to pan
+
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ed05c03..bc9e51b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -75,10 +75,14 @@ body { body { color: var(--foreground); - background: var(--background); - font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + background: + radial-gradient(circle at 12% 18%, color-mix(in srgb, var(--accent) 20%, transparent) 0, transparent 38%), + radial-gradient(circle at 86% 8%, color-mix(in srgb, var(--accent-secondary) 16%, transparent) 0, transparent 34%), + linear-gradient(180deg, color-mix(in srgb, var(--background) 93%, #ffffff) 0%, var(--background) 100%); + font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + line-height: 1.4; } * { @@ -105,8 +109,12 @@ a { /* Utility classes used inline to avoid hard-coded colors */ .card { background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--border) 75%, var(--accent) 25%); + border-radius: 12px; + box-shadow: + 0 1px 0 color-mix(in srgb, var(--highlight) 48%, transparent), + 0 10px 24px color-mix(in srgb, #000000 12%, transparent); + backdrop-filter: blur(1px); } .subtle { @@ -114,16 +122,25 @@ a { } .btn { - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent-secondary) 28%); border-radius: 10px; - background: var(--surface-2); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-2) 86%, var(--highlight) 14%), + var(--surface-2) + ); color: var(--foreground); - transition: all 150ms ease; + transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease, background 140ms ease; } .btn:hover { - background: color-mix(in srgb, var(--accent) 12%, var(--surface-2)); - box-shadow: 0 2px 6px rgba(0,0,0,0.12); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 12%, var(--surface-2)), + color-mix(in srgb, var(--accent) 6%, var(--surface-2)) + ); + border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); + box-shadow: 0 6px 14px color-mix(in srgb, #000 18%, transparent); transform: translateY(-1px); } @@ -184,14 +201,22 @@ a { .input { width: 100%; padding: 10px 12px; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 70%, var(--accent-secondary) 30%); border-radius: 8px; - background: var(--surface-2); + background: color-mix(in srgb, var(--surface-2) 88%, var(--highlight) 12%); color: var(--foreground); + font-family: "Consolas", "Menlo", "Monaco", "Courier New", monospace; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.input:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 64%, var(--border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); } .kbd { - font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: "Consolas", "Menlo", "Monaco", "Courier New", monospace; font-size: 13px; line-height: 1.5; background: var(--surface-2); diff --git a/frontend/app/hardware/page.module.css b/frontend/app/hardware/page.module.css new file mode 100644 index 0000000..6b2465d --- /dev/null +++ b/frontend/app/hardware/page.module.css @@ -0,0 +1,37 @@ +.container { + padding: 28px 24px 40px; + max-width: 1360px; + margin: 0 auto; +} + +.heading { + font-size: clamp(1.4rem, 2vw, 2rem); + font-weight: 700; + margin-bottom: 8px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.subheading { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 12px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + grid-auto-rows: 300px; + gap: 16px; +} + +@media (max-width: 768px) { + .container { + padding: 20px 12px 28px; + } + + .grid { + grid-template-columns: 1fr; + gap: 12px; + } +} diff --git a/frontend/app/hardware/page.tsx b/frontend/app/hardware/page.tsx new file mode 100644 index 0000000..7b25d25 --- /dev/null +++ b/frontend/app/hardware/page.tsx @@ -0,0 +1,65 @@ +import type { Benchmark } from "../components/BenchmarksTable"; +import ErrorBoundary from "../components/ErrorBoundary"; +import HardwareRecommendation from "../components/HardwareRecommendation"; +import EfficiencyChart from "../components/EfficiencyChart"; +import GpuUtilChart from "../components/GpuUtilChart"; +import PowerConsumptionChart from "../components/PowerConsumptionChart"; +import CpuUtilHeatmap from "../components/CpuUtilHeatmap"; +import LazyChart from "../components/LazyChart"; +import { fetchBenchmarks } from "../lib/fetchBenchmarks"; +import styles from "./page.module.css"; + +export const revalidate = 60; + +export default async function HardwarePage() { + let data: Benchmark[] = []; + let error: string | null = null; + try { + data = await fetchBenchmarks(); + } catch (e: unknown) { + error = e instanceof Error ? e.message : "Unknown error"; + } + + if (error) { + return ( +
+

Hardware Intelligence

+
+ Failed to load data: {error} +
+
+ ); + } + + return ( +
+

Hardware Intelligence

+

+ Hardware recommendations and efficiency analysis based on real benchmark data. + Power and GPU metrics require submissions from systems with NVIDIA GPUs. +

+ +
+

Hardware Recommendation Engine

+

+ Select a codec and priority to see which hardware performs best. Rankings are based on + aggregated benchmark data from all submissions. +

+ + + +
+ +

Efficiency Metrics

+
+ + + +
+ +
+ +
+
+ ); +} diff --git a/frontend/app/layout.module.css b/frontend/app/layout.module.css index 7e5d71d..2ccd601 100644 --- a/frontend/app/layout.module.css +++ b/frontend/app/layout.module.css @@ -1,44 +1,73 @@ .headerBar { - background: #173B34; - border-bottom: 1px solid #0f2a24; - padding: 12px 24px; + background: + linear-gradient(180deg, color-mix(in srgb, #173B34 88%, #0d241f) 0%, #173B34 100%); + border-bottom: 1px solid color-mix(in srgb, #0f2a24 70%, #6C8FD5 30%); + box-shadow: 0 6px 18px rgba(9, 20, 18, 0.34); + padding: 10px 20px; + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(6px); } .nav { display: flex; align-items: center; justify-content: space-between; - max-width: 1200px; + max-width: 1360px; margin: 0 auto; + gap: 16px; } .brandLink { - font-weight: 600; + font-weight: 700; text-decoration: none; - color: #6C8FD5; - font-size: 16px; + color: #EBE4B3; + font-size: 1.05rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 0 10px rgba(108, 143, 213, 0.3); } .navLinks { display: flex; - gap: 12px; + gap: 8px; align-items: center; + flex-wrap: wrap; + justify-content: flex-end; } .navBtn { text-decoration: none; - padding: 6px 10px; + padding: 6px 9px; color: #CDDBCD; - background: transparent; + background: color-mix(in srgb, #173B34 78%, #0f2a24); border: 1px solid #1e3a33; - border-radius: 10px; + border-radius: 8px; transition: all 150ms ease; - font-size: inherit; + font-size: 0.88rem; + letter-spacing: 0.02em; } .navBtn:hover { - background: #1e3a33; - color: #ffffff; - box-shadow: 0 2px 6px rgba(0,0,0,0.12); - transform: translateY(-1px); + background: color-mix(in srgb, #1e3a33 72%, #6C8FD5 28%); + color: #EBE4B3; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.24); + transform: translateY(-1px) scale(1.01); +} + +@media (max-width: 980px) { + .headerBar { + padding: 10px 12px; + } + + .nav { + align-items: flex-start; + flex-direction: column; + } + + .navLinks { + width: 100%; + justify-content: flex-start; + } } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 87fab84..900fb24 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,20 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; import "./globals.css"; import styles from "./layout.module.css"; import ThemeToggle from "./components/ThemeToggle"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { title: "Encoding Benchmarks", description: "Community-Submitted Encoding Benchmarks", @@ -34,14 +23,16 @@ export default function RootLayout({ }} /> - +