From 3d3690b0b96e1f5380b12a0b4c9858c028f17110 Mon Sep 17 00:00:00 2001 From: ofhd Date: Tue, 17 Feb 2026 13:56:12 -0800 Subject: [PATCH 1/7] frontend fixes --- frontend/app/components/FpsByCodecChart.tsx | 45 +++++++++++-------- .../app/components/GroupedSizeByPreset.tsx | 30 ++++++++----- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/frontend/app/components/FpsByCodecChart.tsx b/frontend/app/components/FpsByCodecChart.tsx index d48325f..7842874 100644 --- a/frontend/app/components/FpsByCodecChart.tsx +++ b/frontend/app/components/FpsByCodecChart.tsx @@ -30,15 +30,17 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } const bars = computeAverageFpsByCodec(data); if (bars.length === 0) return null; - const width = 640; - const height = 260; - const margin = { top: 32, right: 16, bottom: 60, left: 48 }; - const chartWidth = width - margin.left - margin.right; + const height = 280; + const margin = { top: 32, right: 16, bottom: 80, left: 48 }; const chartHeight = height - margin.top - margin.bottom; let maxValue = 1; for (const b of bars) if (b.value > maxValue) maxValue = b.value; const barGap = 8; - const barWidth = Math.max(4, (chartWidth - barGap * (bars.length - 1)) / bars.length); + const minBarWidth = 40; + const neededWidth = bars.length * (minBarWidth + barGap) - barGap; + const chartWidth = Math.max(576, neededWidth); + const width = chartWidth + margin.left + margin.right; + const barWidth = (chartWidth - barGap * (bars.length - 1)) / bars.length; const xForIndex = (i: number) => margin.left + i * (barWidth + barGap); const yForValue = (v: number) => margin.top + chartHeight - (v / maxValue) * chartHeight; @@ -46,7 +48,8 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } return (
{title}
- +
+ {/* Y axis grid lines */} {Array.from({ length: 5 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 4; @@ -64,18 +67,23 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } })} {/* X axis labels */} - {bars.map((b, i) => ( - - {b.label} - - ))} + {bars.map((b, i) => { + const cx = xForIndex(i) + barWidth / 2; + const cy = height - margin.bottom + 16; + return ( + + {b.label} + + ); + })} {/* Y axis ticks */} {Array.from({ length: 5 }).map((_, i) => { @@ -93,6 +101,7 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } FPS +
); } diff --git a/frontend/app/components/GroupedSizeByPreset.tsx b/frontend/app/components/GroupedSizeByPreset.tsx index 7ce5d07..f70d88a 100644 --- a/frontend/app/components/GroupedSizeByPreset.tsx +++ b/frontend/app/components/GroupedSizeByPreset.tsx @@ -43,15 +43,20 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { return m; }, [groups]); - const width = 720; - const height = 320; - const margin = { top: 24, right: 16, bottom: 64, left: 56 }; - const chartWidth = width - margin.left - margin.right; + const height = 340; + const margin = { top: 24, right: 16, bottom: 84, left: 56 }; const chartHeight = height - margin.top - margin.bottom; - const groupGap = 18; - const barGap = 6; - const barWidth = Math.max(4, (chartWidth - groupGap * (presets.length - 1)) / presets.length / Math.max(1, codecs.length) - barGap); - const xStartForGroup = (i: number) => margin.left + i * ((barWidth + barGap) * codecs.length + groupGap); + const groupGap = 24; + const barGap = 4; + const minBarWidth = 24; + const barsPerGroup = Math.max(1, codecs.length); + const neededGroupWidth = barsPerGroup * minBarWidth + (barsPerGroup - 1) * barGap; + const neededChartWidth = presets.length * neededGroupWidth + (presets.length - 1) * groupGap; + const chartWidth = Math.max(648, neededChartWidth); + const width = chartWidth + margin.left + margin.right; + const groupWidth = (chartWidth - (presets.length - 1) * groupGap) / Math.max(1, presets.length); + const barWidth = (groupWidth - (barsPerGroup - 1) * barGap) / barsPerGroup; + const xStartForGroup = (i: number) => margin.left + i * (groupWidth + groupGap); let maxValue = 1; for (const g of groups) if (g.avgMB > maxValue) maxValue = g.avgMB; @@ -70,7 +75,8 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { return (
Average File Size by Preset and Codec
- setHover(null)}> +
+ setHover(null)}> {/* Grid */} {Array.from({ length: 4 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 3; @@ -105,9 +111,10 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { {/* X axis labels */} {presets.map((p, pi) => { - const x = xStartForGroup(pi) + ((barWidth + barGap) * codecs.length - barGap) / 2; + const cx = xStartForGroup(pi) + groupWidth / 2; + const cy = height - margin.bottom + 16; return ( - + {p} ); @@ -125,6 +132,7 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { ); })} +
{hover && (
{hover.text} From 72bcf23d22b272b0cf88d2a571f2f964e1318b70 Mon Sep 17 00:00:00 2001 From: ofhd Date: Wed, 18 Feb 2026 21:17:24 -0800 Subject: [PATCH 2/7] Massive changes. Preparing for v1.1.0 --- .gitignore | 7 +- client/config.py | 15 +- client/encoders.py | 86 +- client/ffmpeg.py | 496 +- client/hardware_monitor.py | 478 ++ client/main.py | 683 ++- client/network.py | 8 +- client/presets.json | 12 +- client/requirements.txt | 2 + client/stats.py | 12 +- client/test_videos.py | 249 ++ client/ui.py | 836 +++- docker-compose.prod.yml | 2 +- docker-compose.yml | 1 - frontend/app/api/query/route.ts | 41 +- .../EncoderDashboardClient.tsx | 195 + frontend/app/compare-encoders/page.module.css | 73 + frontend/app/compare-encoders/page.tsx | 28 + .../app/components/BenchmarksTable.module.css | 282 +- frontend/app/components/BenchmarksTable.tsx | 633 ++- frontend/app/components/ComparePanel.tsx | 23 +- frontend/app/components/ContentRadarChart.tsx | 163 + frontend/app/components/CpuUtilHeatmap.tsx | 130 + frontend/app/components/EChart.tsx | 64 + frontend/app/components/EfficiencyChart.tsx | 88 + frontend/app/components/ErrorBoundary.tsx | 2 +- frontend/app/components/FpsByCodecChart.tsx | 170 +- frontend/app/components/GpuUtilChart.tsx | 90 + .../app/components/GroupedSizeByPreset.tsx | 204 +- .../app/components/HardwareRecommendation.tsx | 145 + frontend/app/components/LazyChart.tsx | 36 + .../components/LeaderboardTable.module.css | 81 + frontend/app/components/LeaderboardTable.tsx | 52 + .../app/components/PassSpeedComparison.tsx | 96 + .../app/components/PowerConsumptionChart.tsx | 88 + frontend/app/components/PsnrHistogram.tsx | 93 + .../app/components/RateDistortionChart.tsx | 140 + .../components/ResolutionComparisonChart.tsx | 152 + .../app/components/ScatterFpsSize.module.css | 28 +- frontend/app/components/ScatterFpsSize.tsx | 226 +- frontend/app/components/ScatterSsimVmaf.tsx | 95 + frontend/app/components/SsimHistogram.tsx | 93 + frontend/app/components/StatsCards.module.css | 14 +- frontend/app/components/VmafHistogram.tsx | 186 +- frontend/app/globals.css | 49 +- frontend/app/hardware/page.module.css | 37 + frontend/app/hardware/page.tsx | 64 + frontend/app/layout.module.css | 61 +- frontend/app/layout.tsx | 19 +- frontend/app/leaderboards/page.module.css | 21 + frontend/app/leaderboards/page.tsx | 224 + frontend/app/lib/chartColors.ts | 16 + frontend/app/lib/fetchBenchmarks.ts | 31 + frontend/app/lib/fetchBenchmarksClient.ts | 13 + frontend/app/lib/plScore.ts | 274 ++ frontend/app/lib/types.ts | 40 + frontend/app/lib/useChartTheme.ts | 53 + frontend/app/page.module.css | 51 +- frontend/app/page.tsx | 34 +- frontend/app/plove/page.tsx | 63 +- frontend/package-lock.json | 60 + frontend/package.json | 2 + nginx/conf.d/default.conf | 35 - scripts/e2e.sh | 124 +- scripts/local_test.sh | 253 ++ scripts/manage_keys.sh | 56 +- scripts/redploy.sh | 77 +- scripts/setup_env.sh | 37 +- server/package.json | 2 +- .../migration.sql | 2 +- .../migration.sql | 39 + .../migration.sql | 47 + .../migration.sql | 4 + .../migration.sql | 11 + .../migration.sql | 58 + .../migration.sql | 2 + .../migration.sql | 29 + .../migration.sql | 3 + .../migration.sql | 288 ++ server/prisma/schema.prisma | 135 +- server/src/generated/prisma/client.d.ts | 1 - server/src/generated/prisma/client.js | 4 - server/src/generated/prisma/default.d.ts | 1 - server/src/generated/prisma/default.js | 4 - server/src/generated/prisma/edge.d.ts | 1 - server/src/generated/prisma/edge.js | 200 - server/src/generated/prisma/index-browser.js | 187 - server/src/generated/prisma/index.d.ts | 2774 ------------ server/src/generated/prisma/index.js | 221 - .../libquery_engine-darwin-arm64.dylib.node | Bin 19321760 -> 0 bytes server/src/generated/prisma/package.json | 183 - .../src/generated/prisma/query_engine_bg.js | 2 - .../src/generated/prisma/query_engine_bg.wasm | Bin 2317122 -> 0 bytes .../src/generated/prisma/runtime/edge-esm.js | 34 - server/src/generated/prisma/runtime/edge.js | 34 - .../prisma/runtime/index-browser.d.ts | 370 -- .../generated/prisma/runtime/index-browser.js | 16 - .../src/generated/prisma/runtime/library.d.ts | 3976 ----------------- .../src/generated/prisma/runtime/library.js | 146 - .../generated/prisma/runtime/react-native.js | 83 - .../prisma/runtime/wasm-compiler-edge.js | 84 - .../prisma/runtime/wasm-engine-edge.js | 36 - server/src/generated/prisma/schema.prisma | 37 - .../prisma/wasm-edge-light-loader.mjs | 4 - .../generated/prisma/wasm-worker-loader.mjs | 4 - server/src/generated/prisma/wasm.d.ts | 1 - server/src/generated/prisma/wasm.js | 207 - server/src/index.ts | 32 +- server/src/routes.ts | 899 +++- server/src/seedDummyDatabase.ts | 345 ++ server/test/routes.smoke.test.js | 189 + 111 files changed, 8710 insertions(+), 10277 deletions(-) create mode 100644 client/hardware_monitor.py create mode 100644 client/test_videos.py create mode 100644 frontend/app/compare-encoders/EncoderDashboardClient.tsx create mode 100644 frontend/app/compare-encoders/page.module.css create mode 100644 frontend/app/compare-encoders/page.tsx create mode 100644 frontend/app/components/ContentRadarChart.tsx create mode 100644 frontend/app/components/CpuUtilHeatmap.tsx create mode 100644 frontend/app/components/EChart.tsx create mode 100644 frontend/app/components/EfficiencyChart.tsx create mode 100644 frontend/app/components/GpuUtilChart.tsx create mode 100644 frontend/app/components/HardwareRecommendation.tsx create mode 100644 frontend/app/components/LazyChart.tsx create mode 100644 frontend/app/components/LeaderboardTable.module.css create mode 100644 frontend/app/components/LeaderboardTable.tsx create mode 100644 frontend/app/components/PassSpeedComparison.tsx create mode 100644 frontend/app/components/PowerConsumptionChart.tsx create mode 100644 frontend/app/components/PsnrHistogram.tsx create mode 100644 frontend/app/components/RateDistortionChart.tsx create mode 100644 frontend/app/components/ResolutionComparisonChart.tsx create mode 100644 frontend/app/components/ScatterSsimVmaf.tsx create mode 100644 frontend/app/components/SsimHistogram.tsx create mode 100644 frontend/app/hardware/page.module.css create mode 100644 frontend/app/hardware/page.tsx create mode 100644 frontend/app/leaderboards/page.module.css create mode 100644 frontend/app/leaderboards/page.tsx create mode 100644 frontend/app/lib/chartColors.ts create mode 100644 frontend/app/lib/fetchBenchmarks.ts create mode 100644 frontend/app/lib/fetchBenchmarksClient.ts create mode 100644 frontend/app/lib/plScore.ts create mode 100644 frontend/app/lib/types.ts create mode 100644 frontend/app/lib/useChartTheme.ts create mode 100755 scripts/local_test.sh mode change 100644 => 100755 scripts/manage_keys.sh create mode 100644 server/prisma/migrations/20251009000000_create_submission_table/migration.sql create mode 100644 server/prisma/migrations/20251010120000_data_integrity_sprint1/migration.sql create mode 100644 server/prisma/migrations/20251012120000_sprint2_optimizations/migration.sql create mode 100644 server/prisma/migrations/20260218120000_sprint3_ssim_psnr/migration.sql create mode 100644 server/prisma/migrations/20260218130000_sprint5_multi_content/migration.sql create mode 100644 server/prisma/migrations/20260218130000_sprint5_multi_content_resolution/migration.sql create mode 100644 server/prisma/migrations/20260218130000_sprint6_hardware_metrics/migration.sql create mode 100644 server/prisma/migrations/20260218140000_sprint4_query_indexes/migration.sql create mode 100644 server/prisma/migrations/20260219170000_sprint7_extended_telemetry/migration.sql delete mode 100644 server/src/generated/prisma/client.d.ts delete mode 100644 server/src/generated/prisma/client.js delete mode 100644 server/src/generated/prisma/default.d.ts delete mode 100644 server/src/generated/prisma/default.js delete mode 100644 server/src/generated/prisma/edge.d.ts delete mode 100644 server/src/generated/prisma/edge.js delete mode 100644 server/src/generated/prisma/index-browser.js delete mode 100644 server/src/generated/prisma/index.d.ts delete mode 100644 server/src/generated/prisma/index.js delete mode 100755 server/src/generated/prisma/libquery_engine-darwin-arm64.dylib.node delete mode 100644 server/src/generated/prisma/package.json delete mode 100644 server/src/generated/prisma/query_engine_bg.js delete mode 100644 server/src/generated/prisma/query_engine_bg.wasm delete mode 100644 server/src/generated/prisma/runtime/edge-esm.js delete mode 100644 server/src/generated/prisma/runtime/edge.js delete mode 100644 server/src/generated/prisma/runtime/index-browser.d.ts delete mode 100644 server/src/generated/prisma/runtime/index-browser.js delete mode 100644 server/src/generated/prisma/runtime/library.d.ts delete mode 100644 server/src/generated/prisma/runtime/library.js delete mode 100644 server/src/generated/prisma/runtime/react-native.js delete mode 100644 server/src/generated/prisma/runtime/wasm-compiler-edge.js delete mode 100644 server/src/generated/prisma/runtime/wasm-engine-edge.js delete mode 100644 server/src/generated/prisma/schema.prisma delete mode 100644 server/src/generated/prisma/wasm-edge-light-loader.mjs delete mode 100644 server/src/generated/prisma/wasm-worker-loader.mjs delete mode 100644 server/src/generated/prisma/wasm.d.ts delete mode 100644 server/src/generated/prisma/wasm.js create mode 100644 server/src/seedDummyDatabase.ts create mode 100644 server/test/routes.smoke.test.js diff --git a/.gitignore b/.gitignore index 86b4d6e..a1a0435 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ frontend/.cache/ server/prisma/dev.db* server/prisma/*.db-journal server/prisma/migrations/*/steps.json +server/src/generated/prisma/ # Python / client __pycache__/ @@ -86,4 +87,8 @@ coverage/ # 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/client/config.py b/client/config.py index 30245a7..5a5e486 100644 --- a/client/config.py +++ b/client/config.py @@ -45,8 +45,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', 'contentClass', 'resolution', '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,6 +65,8 @@ # 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 --- 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..f83cd5d 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,236 @@ 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 + + +RESOLUTION_DIMENSIONS: Dict[str, tuple] = { + "480p": (854, 480), + "720p": (1280, 720), + "1080p": (1920, 1080), + "1440p": (2560, 1440), + "4k": (3840, 2160), +} + +TWOPASS_BITRATE_TARGETS: Dict[str, str] = { + "480p": "1500k", + "720p": "3000k", + "1080p": "6000k", + "1440p": "12000k", + "4k": "20000k", +} + + +def scale_video(input_path: str, target_resolution: str, output_path: str) -> bool: + """Scale a video to target_resolution using a near-lossless intermediate. + + Returns True on success. + """ + dims = RESOLUTION_DIMENSIONS.get(target_resolution) + if not dims: + return False + w, h = dims + cmd = [ + config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-i", input_path, + "-vf", f"scale={w}:{h}:flags=lanczos", + "-c:v", "libx264", "-crf", "0", "-preset", "ultrafast", + "-an", output_path, + ] + try: + proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=300) + return proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0 + except Exception: + return False + + +def build_ffmpeg_twopass_cmds( + *, input_path: str, output_path: str, encoder: str, preset_name: str, + bitrate: str, passlogfile: str, +) -> tuple: + """Return (pass1_cmd, pass2_cmd) for two-pass encoding.""" + from .encoders import map_preset_for_encoder + + null_out = "/dev/null" if os.name != "nt" else "NUL" + + base = [ + config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "info", "-nostdin", + "-i", input_path, + "-c:v", encoder, + ] + base += map_preset_for_encoder(encoder, preset_name) + base += ["-b:v", bitrate] + + if encoder.endswith(("_nvenc", "_qsv", "_amf", "_videotoolbox", "_vaapi")): + base += ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"] + + pass1 = base + ["-pass", "1", "-passlogfile", passlogfile, "-an", "-f", "null", null_out] + pass2 = base + ["-pass", "2", "-passlogfile", passlogfile, "-an", output_path] + + return (pass1, pass2) + + +def encode_to_artifact_twopass( + *, input_path: str, encoder: str, preset: str, bitrate: str, + out_dir: str, artifact_name: str, +) -> Dict[str, Any]: + """Two-pass encode, measuring combined time for both passes.""" + os.makedirs(out_dir, exist_ok=True) + artifact_path = os.path.join(out_dir, artifact_name) + passlogfile = os.path.join(out_dir, "ffmpeg2pass") + + pass1_cmd, pass2_cmd = build_ffmpeg_twopass_cmds( + input_path=input_path, output_path=artifact_path, encoder=encoder, + preset_name=preset, bitrate=bitrate, passlogfile=passlogfile, + ) + + start = time.time() + + proc1 = subprocess.run(pass1_cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if proc1.returncode != 0: + stderr_lines = (proc1.stderr or "").splitlines() + err_msg = "; ".join([ln.strip() for ln in stderr_lines[-5:]]) if stderr_lines else "pass 1 failed" + end = time.time() + return { + "artifactPath": artifact_path, + "encoderUsed": encoder, + "elapsedMs": int(round((end - start) * 1000)), + "fps": 0.0, + "fileSizeBytes": 0, + "error": err_msg, + } + + proc2 = subprocess.run(pass2_cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + end = time.time() + elapsed = max(0.0001, end - start) + + total_frames = _parse_frame_count_from_stderr(proc2.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_2: Optional[str] = None + if proc2.returncode != 0 or size_val <= 0 or fps_val <= 0.0: + stderr_lines = (proc2.stderr or "").splitlines() + err_msg_2 = "; ".join([ln.strip() for ln in stderr_lines[-5:]]) if stderr_lines else "pass 2 failed" + + for ext in ("-0.log", "-0.log.mbtree", ".log", ".log.mbtree"): + try: + p = passlogfile + ext + if os.path.exists(p): + os.remove(p) + except OSError: + pass + + return { + "artifactPath": artifact_path, + "encoderUsed": encoder, + "elapsedMs": int(round(elapsed * 1000)), + "fps": float(fps_val), + "fileSizeBytes": int(size_val), + "error": err_msg_2, + } + + 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 +616,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..9ceebb4 --- /dev/null +++ b/client/hardware_monitor.py @@ -0,0 +1,478 @@ +"""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 +from dataclasses import dataclass +from typing import Optional, List, Tuple + +import psutil + +# 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 + + 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 = float(f.current) + 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) diff --git a/client/main.py b/client/main.py index cbd4092..42211a4 100644 --- a/client/main.py +++ b/client/main.py @@ -31,23 +31,73 @@ 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, encode_to_artifact_twopass, + scale_video, RESOLUTION_DIMENSIONS, TWOPASS_BITRATE_TARGETS, + EXTENDED_TELEMETRY_KEYS, run_single_benchmark, sha256_of_file, verify_sample_video, load_presets_config, get_default_sample_path, ) +from .test_videos import ( + CONTENT_CLASSES, CONTENT_CLASS_LABELS, RESOLUTION_ORDER, + ensure_test_videos, get_video_path, available_content_classes, +) from .network import submit, fetch_baseline_rows from .stats import should_skip_submission 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( + t: Dict[str, Any], default_input: str, batch_dir: str, +) -> Tuple[str, str]: + """Return (effective_input_path, input_hash) for a task, scaling resolution if needed.""" + content_class = t.get('contentClass', 'mixed') + resolution = t.get('resolution', '1080p') + + video_path = get_video_path(str(content_class), str(resolution)) + if not video_path: + video_path = get_video_path(str(content_class)) + if not video_path: + video_path = default_input + + target_dims = RESOLUTION_DIMENSIONS.get(str(resolution)) + if target_dims and str(resolution) != '1080p' and video_path == default_input: + scaled_name = f"scaled_{resolution}.mp4" + scaled_path = os.path.join(batch_dir, scaled_name) + if not os.path.exists(scaled_path): + print(f" Scaling source to {resolution}...") + ok = scale_video(video_path, str(resolution), scaled_path) + if ok: + video_path = scaled_path + else: + print(f" Warning: scaling to {resolution} failed, using original", file=sys.stderr) + + return video_path, sha256_of_file(video_path) + + +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 +118,303 @@ 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) + + needed_cc = set() + needed_res = set() + for t in tasks: + cc = t.get('contentClass', 'mixed') + res = t.get('resolution', '1080p') + if cc != 'mixed': + needed_cc.add(cc) + if res != '1080p': + needed_res.add(res) + if needed_cc or needed_res: + ensure_test_videos(list(needed_cc) if needed_cc else None, list(needed_res) if needed_res else None) 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') + passes = t.get('passes', 1) + content_class = t.get('contentClass', 'mixed') + resolution = t.get('resolution', '1080p') + 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 + label_parts = [enc, preset, f"crf={crf}"] + if content_class != 'mixed': + label_parts.append(f"content={content_class}") + if resolution != '1080p': + label_parts.append(f"res={resolution}") + if passes == 2: + label_parts.append("2-pass") + progress.set_description(_batch_status("Encoding", global_index, enc, preset) + f" [{', '.join(label_parts)}]") + progress.set_current_test( + stage="Encoding", + encoder=enc, + preset=preset, + crf=crf, + passes=passes, + contentClass=content_class, + resolution=resolution, + isHardware=is_hardware_encoder_name(enc), + ) + effective_input, input_hash = _resolve_input_for_task(t, input_path, batch_dir) + + if passes == 2: + bitrate = TWOPASS_BITRATE_TARGETS.get(str(resolution), '6000k') + info = encode_to_artifact_twopass( + input_path=effective_input, encoder=enc, preset=preset, + bitrate=bitrate, out_dir=batch_dir, artifact_name=name, + ) + else: + 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=passes, + contentClass=content_class, + resolution=resolution, + 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=info['task'].get('passes', 1), + contentClass=info['task'].get('contentClass', 'mixed'), + resolution=info['task'].get('resolution', '1080p'), + 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'), + 'contentClass': t.get('contentClass', 'mixed'), + 'resolution': t.get('resolution', '1080p'), + 'passes': t.get('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), + contentClass=payload.get('contentClass', 'mixed'), + resolution=payload.get('resolution', '1080p'), + 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 +454,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 +486,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 +518,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 +528,58 @@ 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["contentClass"] = getattr(args, 'content_class', 'mixed') or 'mixed' + payload["resolution"] = getattr(args, 'resolution', '1080p') or '1080p' + payload["passes"] = getattr(args, 'passes', 1) or 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 +611,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 +628,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 +637,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 +660,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 +678,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: @@ -502,40 +752,58 @@ def interactive_menu_flow(parser: argparse.ArgumentParser, base_args: argparse.N if not crf_values: crf_values = [24] + bench_key = {1: "smallBenchmark", 2: "mediumBenchmark", 3: "fullBenchmark"}.get(choice, "smallBenchmark") + bench_cfg = presets_cfg.get(bench_key, {}) + content_classes_list: List[str] = bench_cfg.get("contentClasses", ["mixed"]) + resolutions_list: List[str] = bench_cfg.get("resolutions", ["1080p"]) + passes_list: List[int] = [int(p) for p in bench_cfg.get("passes", [1])] + if not content_classes_list: + content_classes_list = ["mixed"] + if not resolutions_list: + resolutions_list = ["1080p"] + if not passes_list: + passes_list = [1] + tasks: List[Dict[str, Any]] = [] - for crf_val in crf_values: - for enc in encoders: - presets_for_encoder = enumerate_supported_presets_for_encoder(enc) - ordered = sort_presets_by_speed_desc(enc, presets_for_encoder) - if choice == 1: - if not ordered: - continue - mid_index = max(0, (len(ordered) - 1) // 2) - picks: List[str] = [] - faster1 = mid_index - 1 - faster2 = mid_index - 2 - if faster2 >= 0: - picks.append(ordered[faster2]) - if faster1 >= 0: - picks.append(ordered[faster1]) - picks.append(ordered[mid_index]) - seen: Dict[str, bool] = {} - final = [p for p in picks if not seen.setdefault(p, False)] - for preset_label in final: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) - elif choice == 2: - if len(ordered) > 0: - drop_count = int(round(len(ordered) * 0.2)) - if drop_count >= len(ordered): - drop_count = len(ordered) - 1 - keep = ordered[:-drop_count] if drop_count > 0 else ordered - else: - keep = ordered - for preset_label in keep: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) - else: - for preset_label in ordered: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) + for content_class in content_classes_list: + for resolution in resolutions_list: + for num_passes in passes_list: + for crf_val in crf_values: + for enc in encoders: + presets_for_encoder = enumerate_supported_presets_for_encoder(enc) + ordered = sort_presets_by_speed_desc(enc, presets_for_encoder) + if choice == 1: + if not ordered: + continue + mid_index = max(0, (len(ordered) - 1) // 2) + picks: List[str] = [] + faster1 = mid_index - 1 + faster2 = mid_index - 2 + if faster2 >= 0: + picks.append(ordered[faster2]) + if faster1 >= 0: + picks.append(ordered[faster1]) + picks.append(ordered[mid_index]) + seen: Dict[str, bool] = {} + final = [p for p in picks if not seen.setdefault(p, False)] + for preset_label in final: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, + 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) + elif choice == 2: + if len(ordered) > 0: + drop_count = int(round(len(ordered) * 0.2)) + if drop_count >= len(ordered): + drop_count = len(ordered) - 1 + keep = ordered[:-drop_count] if drop_count > 0 else ordered + else: + keep = ordered + for preset_label in keep: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, + 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) + else: + for preset_label in ordered: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, + 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) rc = run_benchmark_batch( hardware=detect_hardware(), @@ -578,6 +846,9 @@ def build_arg_parser() -> argparse.ArgumentParser: p.add_argument("--batch-size", type=int, default=0, help="Batch size for parallel VMAF (0=auto: cpu_count or 4)") p.add_argument("--use-token", action="store_true", help="Use short-lived submit token (opt-in; or set INGEST_USE_TOKENS=1)") p.add_argument("--pause-on-exit", action="store_true", help="On Windows, wait for Enter key after completion to keep the window open") + p.add_argument("--content-class", default="mixed", choices=CONTENT_CLASSES, help="Content class for the test video (default: mixed)") + p.add_argument("--resolution", default="1080p", choices=RESOLUTION_ORDER, help="Target resolution (default: 1080p)") + p.add_argument("--passes", type=int, default=1, choices=[1, 2], help="Number of encoding passes (default: 1)") return p diff --git a/client/network.py b/client/network.py index 4ca1457..dd99ef9 100644 --- a/client/network.py +++ b/client/network.py @@ -122,7 +122,11 @@ def submit(base_url: str, payload: Dict[str, Any], api_key: str = "", retries: i 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 +137,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..69dd8d2 100644 --- a/client/presets.json +++ b/client/presets.json @@ -1,15 +1,25 @@ { + "contentClasses": ["mixed"], + "resolutions": ["1080p"], "smallBenchmark": { "crfValues": [24], + "contentClasses": ["mixed"], + "resolutions": ["1080p"], + "passes": [1], "approxMinutes": 15 }, "mediumBenchmark": { "crfValues": [22, 24, 26], + "contentClasses": ["mixed"], + "resolutions": ["720p", "1080p"], + "passes": [1], "approxHours": 3 }, "fullBenchmark": { "crfValues": [12, 14, 16, 18, 20, 22, 24, 26, 28, 30], + "contentClasses": ["mixed", "action", "animation"], + "resolutions": ["480p", "720p", "1080p", "1440p"], + "passes": [1, 2], "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/test_videos.py b/client/test_videos.py new file mode 100644 index 0000000..aafcbe5 --- /dev/null +++ b/client/test_videos.py @@ -0,0 +1,249 @@ +"""Test video catalog, download, and caching for multi-content benchmarks (Sprint 5).""" + +import hashlib +import os +import sys +from typing import Dict, List, Optional, Tuple + +from . import config + +CONTENT_CLASSES: List[str] = [ + "mixed", + "talkingHead", + "action", + "animation", + "screen", + "nature", + "gaming", +] + +CONTENT_CLASS_LABELS: Dict[str, str] = { + "mixed": "Mixed (Original)", + "talkingHead": "Talking Head", + "action": "Action / Sports", + "animation": "Animation / Cartoon", + "screen": "Screen Recording", + "nature": "Nature / Documentary", + "gaming": "Gaming", +} + +RESOLUTION_PRESETS: Dict[str, Tuple[int, int]] = { + "480p": (854, 480), + "720p": (1280, 720), + "1080p": (1920, 1080), + "1440p": (2560, 1440), + "4k": (3840, 2160), +} + +RESOLUTION_ORDER: List[str] = ["480p", "720p", "1080p", "1440p", "4k"] + +RELEASES_BASE_URL = ( + "https://github.com/oliverdougherC/Encoding_Database" + "/releases/download/test-clips-v1" +) + +TEST_VIDEO_CATALOG: List[Dict[str, object]] = [ + { + "name": "sample.mp4", + "contentClass": "mixed", + "resolution": "1080p", + "duration": 20.0, + "sha256": config.SAMPLE_VIDEO_SHA256, + "sizeBytes": config.SAMPLE_VIDEO_SIZE_BYTES, + }, + { + "name": "talking_head_1080p.mp4", + "contentClass": "talkingHead", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, + { + "name": "action_1080p.mp4", + "contentClass": "action", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, + { + "name": "animation_1080p.mp4", + "contentClass": "animation", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, + { + "name": "screen_1080p.mp4", + "contentClass": "screen", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, + { + "name": "nature_1080p.mp4", + "contentClass": "nature", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, + { + "name": "gaming_1080p.mp4", + "contentClass": "gaming", + "resolution": "1080p", + "duration": 15.0, + "sha256": "", + "sizeBytes": 0, + }, +] + + +def get_cache_dir() -> str: + """Return the directory used to cache downloaded test clips.""" + home = os.path.expanduser("~") + cache_dir = os.path.join(home, ".encodingdb", "test-clips") + os.makedirs(cache_dir, mode=0o755, exist_ok=True) + return cache_dir + + +def _sha256_file(path: str) -> str: + hasher = hashlib.sha256() + with open(path, "rb") as f: + while True: + chunk = f.read(1024 * 1024) + if not chunk: + break + hasher.update(chunk) + return hasher.hexdigest() + + +def download_test_video(video_meta: Dict[str, object], force: bool = False) -> Optional[str]: + """Download a test video from GitHub Releases and verify its SHA256. + + Returns the local file path on success, or None on failure. + """ + name = str(video_meta["name"]) + sha256 = str(video_meta.get("sha256") or "") + size_bytes = int(video_meta.get("sizeBytes") or 0) + + if name == "sample.mp4": + from .ffmpeg import get_default_sample_path + local = get_default_sample_path() + if local and os.path.exists(local): + return local + + cache_dir = get_cache_dir() + local_path = os.path.join(cache_dir, name) + + if not force and os.path.exists(local_path): + if sha256 and _sha256_file(local_path) == sha256.lower(): + return local_path + if not sha256 and os.path.getsize(local_path) > 0: + return local_path + + url = f"{RELEASES_BASE_URL}/{name}" + print(f"Downloading test clip: {name}...") + + try: + import requests # lazy import + resp = requests.get(url, stream=True, timeout=120, verify=config.REQUESTS_VERIFY) + resp.raise_for_status() + + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + tmp_path = local_path + ".tmp" + + with open(tmp_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=256 * 1024): + f.write(chunk) + downloaded += len(chunk) + if total > 0: + pct = (downloaded / total) * 100 + print(f"\r {downloaded / (1024*1024):.1f} / {total / (1024*1024):.1f} MB ({pct:.0f}%)", end="", flush=True) + + print() + + if sha256: + actual = _sha256_file(tmp_path) + if actual != sha256.lower(): + print(f" SHA256 mismatch for {name}: expected {sha256[:16]}..., got {actual[:16]}...", file=sys.stderr) + os.remove(tmp_path) + return None + + if size_bytes > 0 and os.path.getsize(tmp_path) != size_bytes: + print(f" Size mismatch for {name}: expected {size_bytes}, got {os.path.getsize(tmp_path)}", file=sys.stderr) + os.remove(tmp_path) + return None + + os.replace(tmp_path, local_path) + print(f" Cached: {local_path}") + return local_path + + except Exception as e: + print(f" Failed to download {name}: {e}", file=sys.stderr) + return None + + +def ensure_test_videos( + content_classes: Optional[List[str]] = None, + resolutions: Optional[List[str]] = None, +) -> Dict[str, str]: + """Download and cache all required test clips. + + Returns a dict mapping "contentClass:resolution" keys to local file paths. + """ + wanted_cc = set(content_classes) if content_classes else {"mixed"} + wanted_res = set(resolutions) if resolutions else {"1080p"} + result: Dict[str, str] = {} + + for meta in TEST_VIDEO_CATALOG: + cc = str(meta["contentClass"]) + res = str(meta["resolution"]) + if cc not in wanted_cc: + continue + if res not in wanted_res: + continue + + path = download_test_video(meta) + if path: + result[f"{cc}:{res}"] = path + + return result + + +def get_video_path(content_class: str, resolution: str = "1080p") -> Optional[str]: + """Return the cached path for a specific test video, or None.""" + cache_key = f"{content_class}:{resolution}" + + if content_class == "mixed" and resolution == "1080p": + from .ffmpeg import get_default_sample_path + local = get_default_sample_path() + if local and os.path.exists(local): + return local + + cache_dir = get_cache_dir() + for meta in TEST_VIDEO_CATALOG: + if str(meta["contentClass"]) == content_class and str(meta["resolution"]) == resolution: + name = str(meta["name"]) + local_path = os.path.join(cache_dir, name) + if os.path.exists(local_path): + return local_path + + return None + + +def available_content_classes() -> List[str]: + """Return content classes that have at least one cached test video locally.""" + avail: List[str] = [] + for cc in CONTENT_CLASSES: + for meta in TEST_VIDEO_CATALOG: + if str(meta["contentClass"]) == cc: + path = get_video_path(cc, str(meta["resolution"])) + if path: + avail.append(cc) + break + return avail diff --git a/client/ui.py b/client/ui.py index ad3cffa..1e93710 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,553 @@ 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") + passes = self._task_info.get("passes") + cc = str(self._task_info.get("contentClass") or "mixed") + res = str(self._task_info.get("resolution") or "1080p") + + 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]Content:[/muted] {cc} [muted]Resolution:[/muted] {res} [muted]Passes:[/muted] {passes if passes is not None else 1}", + 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/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..5c57abc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.9' services: db: image: postgres:16-alpine diff --git a/frontend/app/api/query/route.ts b/frontend/app/api/query/route.ts index 58ffa90..94bc4b2 100644 --- a/frontend/app/api/query/route.ts +++ b/frontend/app/api/query/route.ts @@ -1,9 +1,36 @@ -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) { + 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; + } + + 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 +79,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..8c40209 --- /dev/null +++ b/frontend/app/compare-encoders/EncoderDashboardClient.tsx @@ -0,0 +1,195 @@ +"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 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 `${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..0f64e31 --- /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 dynamic = "force-dynamic"; + +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..c6cf3ae 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; @@ -90,9 +91,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 +285,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 +354,11 @@ font-size: 12px; } +.weightWarning { + color: var(--warning-fg, #ca8a04); + font-size: 12px; +} + .hoverBtn { padding: 6px 12px; } @@ -172,3 +366,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..48f76d4 100644 --- a/frontend/app/components/BenchmarksTable.tsx +++ b/frontend/app/components/BenchmarksTable.tsx @@ -1,52 +1,52 @@ "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"; - -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; -}; - -// Extended type for benchmarks with computed scores -type EnrichedBenchmark = Benchmark & { - _plove: number; +import { fetchFilteredBenchmarks } from "../lib/fetchBenchmarksClient"; +import type { Benchmark } from "../lib/types"; +import { createPlScoreContext, scorePlBenchmarkV6 } from "../lib/plScore"; + +export type { Benchmark } from "../lib/types"; + +const PAGE_SIZE = 50; +const COL_WIDTHS = "4% 9% 17% 17% 13% 7% 11% 12% 7% 7%"; + +// 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; +}; + +const CONTENT_CLASS_LABELS: Record = { + mixed: "Mixed (Original)", + talkingHead: "Talking Head", + action: "Action / Sports", + animation: "Animation / Cartoon", + screen: "Screen Recording", + nature: "Nature / Documentary", + gaming: "Gaming", +}; + +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,41 +55,49 @@ 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"); return d === "asc" ? "asc" : "desc"; }); + // Multi-content filters (Sprint 5) + const [contentClassFilter, setContentClassFilter] = useState(() => searchParams.get("cc") || ""); + const [resolutionFilter, setResolutionFilter] = useState(() => searchParams.get("res") || ""); + const [passesFilter, setPassesFilter] = useState(() => searchParams.get("passes") || ""); // Encoder type filters 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 (contentClassFilter) params.set("cc", contentClassFilter); + if (resolutionFilter) params.set("res", resolutionFilter); + if (passesFilter) params.set("passes", passesFilter); 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]); + }, [cpuFilter, gpuFilter, codecFilter, presetFilter, sortKey, sortDir, contentClassFilter, resolutionFilter, passesFilter, 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); @@ -122,17 +130,71 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar }, []); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setShowCompare(false); }, []); + // Server-side filtering state (F-02) + const [serverData, setServerData] = useState(null); + const [serverTotal, setServerTotal] = useState(0); + const [page, setPage] = useState(0); + const [fetching, setFetching] = useState(false); + + // Debounced server-side fetch when filters change + const fetchDebounceRef = useRef | null>(null); + useEffect(() => { + if (fetchDebounceRef.current) clearTimeout(fetchDebounceRef.current); + fetchDebounceRef.current = setTimeout(() => { + const params: Record = { + limit: String(PAGE_SIZE), + skip: String(page * PAGE_SIZE), + total: "1", + }; + if (cpuFilter.trim()) params.cpu = cpuFilter.trim(); + if (gpuFilter.trim()) params.gpu = gpuFilter.trim(); + if (codecFilter.trim()) params.codecSearch = codecFilter.trim(); + setFetching(true); + fetchFilteredBenchmarks(params) + .then(({ data, total }) => { setServerData(data); setServerTotal(total); }) + .catch(() => { setServerData(null); }) // fallback to client-side + .finally(() => setFetching(false)); + }, 300); + return () => { if (fetchDebounceRef.current) clearTimeout(fetchDebounceRef.current); }; + }, [cpuFilter, gpuFilter, codecFilter, page]); + + // Reset page when filters change + const prevFiltersRef = useRef({ cpuFilter, gpuFilter, codecFilter }); + useEffect(() => { + const prev = prevFiltersRef.current; + if (prev.cpuFilter !== cpuFilter || prev.gpuFilter !== gpuFilter || prev.codecFilter !== codecFilter) { + setPage(0); + prevFiltersRef.current = { cpuFilter, gpuFilter, codecFilter }; + } + }, [cpuFilter, gpuFilter, codecFilter]); + + // Use server data if available, otherwise fall back to initialData + const activeData = serverData ?? initialData; + const totalRows = serverData ? serverTotal : initialData.length; + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + 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 contentClasses = useMemo(() => Array.from(new Set(initialData.map(d => d.contentClass ?? "mixed"))).sort(), [initialData]); + const resolutions = useMemo(() => { + const order = ["480p", "720p", "1080p", "1440p", "4k"]; + const set = new Set(initialData.map(d => d.resolution ?? "1080p")); + return order.filter(r => set.has(r)); + }, [initialData]); + 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(() => { - return initialData.map(row => { + return activeData.map(row => { const encLower = (row.encoderName ?? row.codec ?? "").toLowerCase(); return { ...row, _isHardware: isHardwareEncoder(encLower) }; }); - }, [initialData]); + }, [activeData]); const filtered = useMemo(() => { const cpu = cpuFilter.trim().toLowerCase(); @@ -140,75 +202,51 @@ 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 (contentClassFilter && (row.contentClass ?? "mixed") !== contentClassFilter) return false; + if (resolutionFilter && (row.resolution ?? "1080p") !== resolutionFilter) return false; + if (passesFilter && String(row.passes ?? 1) !== passesFilter) 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]); + }, [dataWithHwClass, cpuFilter, gpuFilter, codecFilter, presetFilter, contentClassFilter, resolutionFilter, passesFilter, softwareOnly, hardwareOnly]); - 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); - } + const plContext = useMemo(() => createPlScoreContext(filtered), [filtered]); - 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[] => { + return perRowMetrics.map((row): EnrichedBenchmark => { + const scored = scorePlBenchmarkV6(row, plContext, { + quality: wQuality, + size: wSize, + speed: wSpeed, + }); + return { ...row, _plScore: scored.total }; }); - }, [filtered, ranges, wQuality, wSize, wSpeed, sizeBaseline]); + }, [perRowMetrics, plContext, wQuality, wSize, wSpeed]); const sorted = useMemo((): EnrichedBenchmark[] => { const data = [...withScores]; @@ -216,7 +254,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); @@ -240,12 +278,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 +314,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 => ( +
setContentClassFilter(e.target.value)} className="input" aria-label="Filter by content class"> + + {contentClasses.map(cc => ())} + + +
-
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 {sorted.length === 0 ? 0 : page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalRows)} of {totalRows} + +
+ + Page {page + 1} of {totalPages} + +
{showDetailId && (() => { @@ -411,50 +437,194 @@ 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({ sorted, selectedIds, toggleSelect, setShowDetailId, setShowFfmpegId, sortKey, sortDir, setSort, fetching }: { sorted: 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; fetching: boolean }) { + const parentRef = useRef(null); + const virtualizer = useVirtualizer({ count: sorted.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
+
+
+
+ {fetching && sorted.length === 0 &&
Loading...
} + {virtualizer.getVirtualItems().map(vr => { + const row = sorted[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 : "-"}
+
+ ); + })} + {sorted.length === 0 && !fetching &&
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 ( +
+ {label}{active && } +
); } 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; + return (
-
+
Encode Details
-
- - - - - - - - - +
+
+
+
+
+
{isAggregate ? "Aggregate settings row" : "Single-submission row"}
+
+ {isAggregate + ? `Averages across ${acceptedSamples} accepted submissions with identical CPU/GPU, codec, preset, CRF, content class, resolution, and pass count.` + : "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 (
@@ -593,15 +763,14 @@ function renderGpuCell(row: Benchmark) { function isAppleSilicon(cpu: string | null | undefined): boolean { if (!cpu) return false; - const s = cpu.toLowerCase(); - return s.includes("apple m1") || s.includes("apple m2") || s.includes("apple m3") || s.includes("apple m4") || s.includes("m1 ") || s.includes("m2 ") || s.includes("m3 ") || s.includes("m4 "); + return /\bapple\s+m\d/i.test(cpu); } function wikipediaAppleUrl(model: string): string | null { - const m = model.toLowerCase(); - if (!m.includes("apple") && !m.startsWith("m")) return null; - const match = m.match(/\bm([1-5])\b/); + const match = model.match(/\bm(\d+)\b/i); if (!match) return null; + // Ensure it's actually an Apple chip reference + if (!/apple/i.test(model)) return null; const gen = match[1]; return `https://en.wikipedia.org/wiki/Apple_M${gen}`; } @@ -625,9 +794,3 @@ function WeightSlider({ label, value, onChange }: { label: string; value: number ); } - -function presetsForCodec(data: Benchmark[], codec: string): string[] { - const set = new Set(); - for (const r of data) if (r.codec === codec) set.add(r.preset); - return Array.from(set).sort(); -} diff --git a/frontend/app/components/ComparePanel.tsx b/frontend/app/components/ComparePanel.tsx index 8588641..b3fc916 100644 --- a/frontend/app/components/ComparePanel.tsx +++ b/frontend/app/components/ComparePanel.tsx @@ -3,7 +3,7 @@ import type { Benchmark } from "./BenchmarksTable"; import styles from "./ComparePanel.module.css"; -type CompareRow = Benchmark & { _plove: number; _relSize: number; _codecLabel: string }; +type CompareRow = Benchmark & { _plScore: number; _relSize: number; _codecLabel: string }; type Metric = { label: string; @@ -20,26 +20,33 @@ const METRICS: Metric[] = [ { label: "Preset", getValue: r => r.preset, getNumeric: () => null, higherIsBetter: true }, { label: "FPS", getValue: r => r.fps.toFixed(2), getNumeric: r => r.fps, higherIsBetter: true }, { label: "VMAF", getValue: r => r.vmaf == null ? "-" : r.vmaf.toFixed(1), getNumeric: r => r.vmaf, higherIsBetter: true }, + { label: "SSIM", getValue: r => r.ssim == null ? "-" : r.ssim.toFixed(4), getNumeric: r => r.ssim, higherIsBetter: true }, + { label: "PSNR (dB)", getValue: r => r.psnr == null ? "-" : r.psnr.toFixed(2), getNumeric: r => r.psnr, higherIsBetter: true }, { label: "File Size (MB)", getValue: r => (r.fileSizeBytes / (1024 * 1024)).toFixed(2), getNumeric: r => r.fileSizeBytes, higherIsBetter: false }, - { label: "PLOVE Score", getValue: r => r._plove > 0 ? r._plove.toFixed(2) : "-", getNumeric: r => r._plove > 0 ? r._plove : null, higherIsBetter: true }, + { label: "PL Score v6", getValue: r => r._plScore > 0 ? r._plScore.toFixed(2) : "-", getNumeric: r => r._plScore > 0 ? r._plScore : null, higherIsBetter: true }, + { label: "GPU Util (%)", getValue: r => r.gpuUtilAvg != null ? r.gpuUtilAvg.toFixed(1) : "-", getNumeric: r => r.gpuUtilAvg ?? null, higherIsBetter: true }, + { label: "GPU Power (W)", getValue: r => r.gpuPowerAvgW != null ? r.gpuPowerAvgW.toFixed(1) : "-", getNumeric: r => r.gpuPowerAvgW ?? null, higherIsBetter: false }, + { label: "FPS/Watt", getValue: r => r.fpsPerWatt != null ? r.fpsPerWatt.toFixed(2) : "-", getNumeric: r => r.fpsPerWatt ?? null, higherIsBetter: true }, + { label: "CPU Util (%)", getValue: r => r.cpuUtilAvg != null ? r.cpuUtilAvg.toFixed(1) : "-", getNumeric: r => r.cpuUtilAvg ?? null, higherIsBetter: false }, + { label: "Peak Memory (MB)", getValue: r => r.peakMemoryMB != null ? Math.round(r.peakMemoryMB).toString() : "-", getNumeric: r => r.peakMemoryMB ?? null, higherIsBetter: false }, ]; function findBestIndex(rows: CompareRow[], metric: Metric): number | null { + const numericVals = rows.map(r => metric.getNumeric(r)); + const nonNull = numericVals.filter(v => v != null); + if (nonNull.length < 2) return null; + if (nonNull.every(v => v === nonNull[0])) return null; + let bestIdx: number | null = null; let bestVal: number | null = null; for (let i = 0; i < rows.length; i++) { - const v = metric.getNumeric(rows[i]); + const v = numericVals[i]; if (v == null) continue; if (bestVal == null || (metric.higherIsBetter ? v > bestVal : v < bestVal)) { bestVal = v; bestIdx = i; } } - // Don't highlight if all values are the same - if (bestIdx !== null) { - const vals = rows.map(r => metric.getNumeric(r)).filter(v => v != null); - if (vals.length > 0 && vals.every(v => v === vals[0])) return null; - } return bestIdx; } diff --git a/frontend/app/components/ContentRadarChart.tsx b/frontend/app/components/ContentRadarChart.tsx new file mode 100644 index 0000000..1927acc --- /dev/null +++ b/frontend/app/components/ContentRadarChart.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const CONTENT_CLASSES = ["mixed", "talkingHead", "action", "animation", "screen", "nature", "gaming"] as const; +const CONTENT_LABELS: Record = { + mixed: "Mixed", + talkingHead: "Talking Head", + action: "Action", + animation: "Animation", + screen: "Screen", + nature: "Nature", + gaming: "Gaming", +}; +const COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_SELECTOR = 10; +const MAX_SELECTED = 6; + +type CodecData = { codec: string; samples: number; scores: Record }; + +function computeCodecScores(data: Benchmark[]): CodecData[] { + const map = new Map>(); + for (const row of data) { + if (typeof row.vmaf !== "number") continue; + const cc = row.contentClass ?? "mixed"; + if (!map.has(row.codec)) map.set(row.codec, new Map()); + const ccMap = map.get(row.codec)!; + if (!ccMap.has(cc)) ccMap.set(cc, { vmafSum: 0, count: 0 }); + const e = ccMap.get(cc)!; + e.vmafSum += row.vmaf; + e.count += 1; + } + return Array.from(map.entries()) + .map(([codec, ccMap]) => { + const scores: Record = {}; + let samples = 0; + for (const cc of CONTENT_CLASSES) { + const e = ccMap.get(cc); + scores[cc] = e && e.count > 0 ? e.vmafSum / e.count : 0; + if (e) samples += e.count; + } + return { codec, samples, scores }; + }) + .sort((a, b) => b.samples - a.samples || a.codec.localeCompare(b.codec)); +} + +export default function ContentRadarChart({ data, title = "Encoder Quality by Content Class" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + const allCodecs = useMemo(() => computeCodecScores(data), [data]); + const options = useMemo(() => allCodecs.slice(0, MAX_SELECTOR), [allCodecs]); + const [selected, setSelected] = useState>(() => new Set(allCodecs.slice(0, 4).map((d) => d.codec))); + + useEffect(() => { + if (options.length === 0) return; + setSelected((prev) => { + const allowed = new Set(options.map((d) => d.codec)); + const next = new Set(Array.from(prev).filter((c) => allowed.has(c))); + if (next.size === 0) options.slice(0, 4).forEach((d) => next.add(d.codec)); + return next.size > MAX_SELECTED ? new Set(Array.from(next).slice(0, MAX_SELECTED)) : next; + }); + }, [options]); + + const activeClasses = useMemo(() => { + const seen = new Set(data.map((r) => r.contentClass ?? "mixed")); + return CONTENT_CLASSES.filter((cc) => seen.has(cc)); + }, [data]); + + // Color is stable: keyed to index in options, not selectedData + const colorOf = (codec: string) => { + const idx = options.findIndex((d) => d.codec === codec); + return COLORS[(idx >= 0 ? idx : 0) % COLORS.length]; + }; + + const selectedData = options.filter((d) => selected.has(d.codec)); + + const option = useMemo(() => ({ + 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 = activeClasses + .map((cc, i) => `${CONTENT_LABELS[cc] ?? cc}: ${(params.value[i] || 0).toFixed(1)}`) + .join("
"); + return `${params.name}
${lines}`; + }, + }, + radar: { + indicator: activeClasses.map((cc) => ({ name: CONTENT_LABELS[cc] ?? cc, max: 100 })), + splitLine: { lineStyle: { color: t.border } }, + axisLine: { lineStyle: { color: t.border } }, + splitArea: { show: false }, + axisName: { color: t.fg, fontSize: 11 }, + center: ["50%", "50%"], + radius: "68%", + }, + series: [ + { + type: "radar", + data: selectedData.map((cd) => ({ + name: cd.codec, + value: activeClasses.map((cc) => cd.scores[cc] || 0), + lineStyle: { color: colorOf(cd.codec), width: 2 }, + areaStyle: { color: colorOf(cd.codec), opacity: 0.12 }, + itemStyle: { color: colorOf(cd.codec) }, + symbol: "circle", + symbolSize: 4, + })), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [selectedData, activeClasses, t]); + + if (allCodecs.length === 0 || activeClasses.length < 3) return null; + + const toggle = (codec: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(codec)) { next.delete(codec); } + else if (next.size < MAX_SELECTED) { next.add(codec); } + return next; + }); + }; + + return ( +
+
{title}
+
+ {options.map((entry) => { + const isOn = selected.has(entry.codec); + const disabled = !isOn && selected.size >= MAX_SELECTED; + const color = colorOf(entry.codec); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/app/components/CpuUtilHeatmap.tsx b/frontend/app/components/CpuUtilHeatmap.tsx new file mode 100644 index 0000000..592b102 --- /dev/null +++ b/frontend/app/components/CpuUtilHeatmap.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const HEATMAP_KEY_SEP = "\u241F"; +const MAX_CODECS = 8; +const MAX_PRESETS = 8; + +type Cell = { codec: string; preset: string; value: number; count: number }; + +function computeHeatmapData(rows: Benchmark[]): { cells: Cell[]; codecs: string[]; presets: string[] } { + const sums = new Map(); + const codecCounts = new Map(); + const presetCounts = new Map(); + for (const row of rows) { + if (typeof row.cpuUtilAvg !== "number") continue; + const key = [row.codec, row.preset].join(HEATMAP_KEY_SEP); + const cur = sums.get(key) || { total: 0, count: 0 }; + cur.total += row.cpuUtilAvg; + cur.count += 1; + sums.set(key, cur); + codecCounts.set(row.codec, (codecCounts.get(row.codec) || 0) + 1); + presetCounts.set(row.preset, (presetCounts.get(row.preset) || 0) + 1); + } + const codecs = Array.from(codecCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, MAX_CODECS).map(([c]) => c); + const presets = Array.from(presetCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, MAX_PRESETS).map(([p]) => p); + const allowed = { codecs: new Set(codecs), presets: new Set(presets) }; + const cells: Cell[] = []; + for (const [key, agg] of sums.entries()) { + const [codec, preset] = key.split(HEATMAP_KEY_SEP); + if (!allowed.codecs.has(codec) || !allowed.presets.has(preset)) continue; + cells.push({ codec, preset, value: agg.total / agg.count, count: agg.count }); + } + return { cells, codecs, presets }; +} + +export default function CpuUtilHeatmap({ data, title = "CPU Utilization by Encoder & Preset" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + const { cells, codecs, presets } = useMemo(() => computeHeatmapData(data), [data]); + + 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, number, number] }) => { + const [pi, ci, util, count] = params.value; + return `${codecs[ci]} / ${presets[pi]}
CPU: ${util.toFixed(1)}%
${count} run${count === 1 ? "" : "s"}`; + }, + }, + grid: { left: 100, right: 100, top: 12, bottom: 40, containLabel: false }, + xAxis: { + type: "category", + data: presets, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, rotate: presets.length > 5 ? 30 : 0 }, + splitArea: { show: true, areaStyle: { color: ["transparent"] } }, + }, + yAxis: { + type: "category", + data: codecs, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { show: false }, + axisLabel: { color: t.fg, fontSize: 11, width: 90, overflow: "truncate" as const }, + splitArea: { show: true, areaStyle: { color: ["transparent"] } }, + }, + visualMap: { + min: 0, + max: 100, + calculable: true, + orient: "vertical" as const, + right: 4, + top: "middle", + itemHeight: 120, + inRange: { color: ["#52b788", "#f0a54a", "#ea5455"] }, + text: ["100%", "0%"], + textStyle: { color: t.fg, fontSize: 10 }, + }, + series: [ + { + type: "heatmap", + data: cells.map((c) => [ + presets.indexOf(c.preset), + codecs.indexOf(c.codec), + c.value, + c.count, + ]), + label: { + show: true, + formatter: (params: { value: [number, number, number] }) => `${params.value[2].toFixed(0)}%`, + fontSize: 11, + fontWeight: 600, + }, + itemStyle: { + borderColor: t.surface, + borderWidth: 2, + borderRadius: 4, + }, + emphasis: { + itemStyle: { shadowBlur: 6, shadowColor: "rgba(0,0,0,0.3)" }, + label: { show: true }, + }, + }, + ], + }), [cells, codecs, presets, t]); + + if (cells.length === 0) { + return ( +
+
{title}
+
+ No CPU utilization data available yet. +
+
+ ); + } + + return ( +
+
{title}
+ +
+ ); +} diff --git a/frontend/app/components/EChart.tsx b/frontend/app/components/EChart.tsx new file mode 100644 index 0000000..d082263 --- /dev/null +++ b/frontend/app/components/EChart.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { BarChart, LineChart, ScatterChart, RadarChart, HeatmapChart } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + VisualMapComponent, + RadarComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +// Register all components once at module load +echarts.use([ + BarChart, + LineChart, + ScatterChart, + RadarChart, + HeatmapChart, + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + VisualMapComponent, + RadarComponent, + CanvasRenderer, +]); + +// Use a permissive type — ECharts validates the shape at runtime +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EChartsOption = Record; + +interface Props { + option: EChartsOption; + height?: number | string; + style?: React.CSSProperties; +} + +export default function EChart({ option, height = "100%", style }: Props) { + const divRef = useRef(null); + const chartRef = useRef | null>(null); + + useEffect(() => { + const el = divRef.current; + if (!el) return; + const chart = echarts.init(el, undefined, { renderer: "canvas" }); + chartRef.current = chart; + const ro = new ResizeObserver(() => chart.resize()); + ro.observe(el); + return () => { + ro.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + chartRef.current?.setOption(option, { replaceMerge: ["series"] }); + }, [option]); + + return
; +} diff --git a/frontend/app/components/EfficiencyChart.tsx b/frontend/app/components/EfficiencyChart.tsx new file mode 100644 index 0000000..313f6e0 --- /dev/null +++ b/frontend/app/components/EfficiencyChart.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const CHART_COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_BARS = 8; + +export default function EfficiencyChart({ data, title = "Encoding Efficiency (FPS/Watt) by Codec" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + + const bars = useMemo(() => { + const sums = new Map(); + for (const row of data) { + const power = row.gpuPowerAvgW; + if (typeof power !== "number" || power <= 0 || row.fps <= 0) continue; + const cur = sums.get(row.codec) || { total: 0, count: 0 }; + cur.total += row.fps / power; + cur.count += 1; + sums.set(row.codec, cur); + } + return Array.from(sums.entries()) + .filter(([, agg]) => agg.count > 0) + .map(([codec, agg]) => ({ codec, value: agg.total / agg.count })) + .sort((a, b) => b.value - a.value) + .slice(0, MAX_BARS); + }, [data]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}
${params[0].value.toFixed(3)} FPS/W`, + }, + grid: { left: 52, right: 12, top: 12, bottom: bars.length > 4 ? 72 : 48, containLabel: false }, + xAxis: { + type: "category", + data: bars.map((b) => b.codec), + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, rotate: bars.length > 4 ? 35 : 0, interval: 0 }, + }, + yAxis: { + type: "value", + name: "FPS/W", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: [ + { + type: "bar", + data: bars.map((b, i) => ({ + value: b.value, + itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: [3, 3, 0, 0] }, + })), + barMaxWidth: 60, + }, + ], + }), [bars, t]); + + if (bars.length === 0) { + return ( +
+
{title}
+
+ No power data yet. Submit benchmarks from a system with an NVIDIA GPU. +
+
+ ); + } + + return ( +
+
{title}
+
+
+ ); +} diff --git a/frontend/app/components/ErrorBoundary.tsx b/frontend/app/components/ErrorBoundary.tsx index bf37051..fed4d95 100644 --- a/frontend/app/components/ErrorBoundary.tsx +++ b/frontend/app/components/ErrorBoundary.tsx @@ -39,7 +39,7 @@ export default class ErrorBoundary extends Component { return (
-

+

Something went wrong

diff --git a/frontend/app/components/FpsByCodecChart.tsx b/frontend/app/components/FpsByCodecChart.tsx index 7842874..e441c9b 100644 --- a/frontend/app/components/FpsByCodecChart.tsx +++ b/frontend/app/components/FpsByCodecChart.tsx @@ -1,107 +1,87 @@ +"use client"; + +import { useMemo } from "react"; import type { Benchmark } from "./BenchmarksTable"; +import { CODEC_COLORS, codecColorKey } from "../lib/chartColors"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; -type Bar = { - label: string; - value: number; -}; +const FALLBACK_COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; -const CHART_COLORS = ["#6C8FD5", "#173B34", "#9693CC", "#d4a843", "#CDDBCD", "#8aabea"]; +export default function FpsByCodecChart({ data, title = "Average FPS by Codec" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); -function computeAverageFpsByCodec(rows: Benchmark[]): Bar[] { - const sums = new Map(); - for (const row of rows) { - const key = row.codec; - const current = sums.get(key) || { total: 0, count: 0 }; - current.total += Number(row.fps) || 0; - current.count += 1; - sums.set(key, current); - } - const bars: Bar[] = []; - for (const [codec, agg] of sums.entries()) { - if (agg.count > 0) { - bars.push({ label: codec, value: agg.total / agg.count }); + const bars = useMemo(() => { + const sums = new Map(); + for (const row of data) { + const cur = sums.get(row.codec) || { total: 0, count: 0 }; + cur.total += Number(row.fps) || 0; + cur.count += 1; + sums.set(row.codec, cur); } - } - bars.sort((a, b) => a.label.localeCompare(b.label)); - return bars; -} - -export default function FpsByCodecChart({ data, title = "Average FPS by Codec" }: { data: Benchmark[]; title?: string }) { - const bars = computeAverageFpsByCodec(data); - if (bars.length === 0) return null; + const result: { codec: string; avgFps: number; color: string }[] = []; + for (const [codec, agg] of sums.entries()) { + if (agg.count > 0) { + result.push({ + codec, + avgFps: agg.total / agg.count, + color: CODEC_COLORS[codecColorKey(codec)] || FALLBACK_COLORS[result.length % FALLBACK_COLORS.length], + }); + } + } + return result.sort((a, b) => a.codec.localeCompare(b.codec)); + }, [data]); - const height = 280; - const margin = { top: 32, right: 16, bottom: 80, left: 48 }; - const chartHeight = height - margin.top - margin.bottom; - let maxValue = 1; - for (const b of bars) if (b.value > maxValue) maxValue = b.value; - const barGap = 8; - const minBarWidth = 40; - const neededWidth = bars.length * (minBarWidth + barGap) - barGap; - const chartWidth = Math.max(576, neededWidth); - const width = chartWidth + margin.left + margin.right; - const barWidth = (chartWidth - barGap * (bars.length - 1)) / bars.length; + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}
${params[0].value.toFixed(1)} FPS`, + }, + grid: { left: 48, right: 12, top: 12, bottom: bars.length > 5 ? 72 : 48, containLabel: false }, + xAxis: { + type: "category", + data: bars.map((b) => b.codec), + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { + color: t.fg, + fontSize: 11, + rotate: bars.length > 5 ? 35 : 0, + interval: 0, + }, + }, + yAxis: { + type: "value", + name: "FPS", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: [ + { + type: "bar", + data: bars.map((b) => ({ + value: b.avgFps, + itemStyle: { color: b.color, borderRadius: [3, 3, 0, 0] }, + })), + barMaxWidth: 60, + }, + ], + }), [bars, t]); - const xForIndex = (i: number) => margin.left + i * (barWidth + barGap); - const yForValue = (v: number) => margin.top + chartHeight - (v / maxValue) * chartHeight; + if (bars.length === 0) return null; return ( -

-
{title}
-
- - {/* Y axis grid lines */} - {Array.from({ length: 5 }).map((_, i) => { - const y = margin.top + (i * chartHeight) / 4; - return ( - - ); - })} - - {/* Bars */} - {bars.map((b, i) => { - const x = xForIndex(i); - const y = yForValue(b.value); - const h = margin.top + chartHeight - y; - return ; - })} - - {/* X axis labels */} - {bars.map((b, i) => { - const cx = xForIndex(i) + barWidth / 2; - const cy = height - margin.bottom + 16; - return ( - - {b.label} - - ); - })} - - {/* Y axis ticks */} - {Array.from({ length: 5 }).map((_, i) => { - const value = (maxValue * (4 - i)) / 4; - const y = margin.top + (i * chartHeight) / 4; - return ( - - {value.toFixed(0)} - - ); - })} - - {/* Y axis title */} - - FPS - - -
+
+
{title}
+
); } diff --git a/frontend/app/components/GpuUtilChart.tsx b/frontend/app/components/GpuUtilChart.tsx new file mode 100644 index 0000000..0ec4260 --- /dev/null +++ b/frontend/app/components/GpuUtilChart.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const CHART_COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_BARS = 8; + +export default function GpuUtilChart({ data, title = "Average GPU Utilization by Codec" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + + const bars = useMemo(() => { + const sums = new Map(); + for (const row of data) { + const util = row.gpuUtilAvg; + if (typeof util !== "number") continue; + const cur = sums.get(row.codec) || { total: 0, count: 0 }; + cur.total += util; + cur.count += 1; + sums.set(row.codec, cur); + } + return Array.from(sums.entries()) + .filter(([, agg]) => agg.count > 0) + .map(([codec, agg]) => ({ codec, value: agg.total / agg.count })) + .sort((a, b) => b.value - a.value) + .slice(0, MAX_BARS); + }, [data]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}
${params[0].value.toFixed(1)}% GPU`, + }, + grid: { left: 52, right: 12, top: 12, bottom: bars.length > 4 ? 72 : 48, containLabel: false }, + xAxis: { + type: "category", + data: bars.map((b) => b.codec), + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, rotate: bars.length > 4 ? 35 : 0, interval: 0 }, + }, + yAxis: { + type: "value", + name: "GPU %", + min: 0, + max: 100, + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11, formatter: (v: number) => `${v}%` }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: [ + { + type: "bar", + data: bars.map((b, i) => ({ + value: b.value, + itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: [3, 3, 0, 0] }, + })), + barMaxWidth: 60, + }, + ], + }), [bars, t]); + + if (bars.length === 0) { + return ( +
+
{title}
+
+ No GPU utilization data available yet. +
+
+ ); + } + + return ( +
+
{title}
+
+
+ ); +} diff --git a/frontend/app/components/GroupedSizeByPreset.tsx b/frontend/app/components/GroupedSizeByPreset.tsx index f70d88a..b5253a4 100644 --- a/frontend/app/components/GroupedSizeByPreset.tsx +++ b/frontend/app/components/GroupedSizeByPreset.tsx @@ -1,153 +1,103 @@ "use client"; -import { useMemo, useState, useRef } from "react"; +import { useMemo } from "react"; import type { Benchmark } from "./BenchmarksTable"; -import styles from "./GroupedSizeByPreset.module.css"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; -type Group = { - preset: string; - codec: string; - avgMB: number; -}; +const CHART_COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_VISIBLE_CODECS = 6; -const CHART_COLORS = ["#6C8FD5", "#173B34", "#9693CC", "#d4a843", "#CDDBCD", "#8aabea"]; +function shortCodecName(codec: string): string { + if (codec.length <= 16) return codec; + return `${codec.slice(0, 13)}...`; +} export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { - const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null); - const svgRef = useRef(null); + const t = useChartTheme(); - const groups = useMemo(() => { + const { presets, codecs, seriesData, totalCodecs } = useMemo(() => { const map = new Map(); + const codecCounts = new Map(); for (const r of data) { const key = `${r.preset}|${r.codec}`; const g = map.get(key) || { sum: 0, count: 0, preset: r.preset, codec: r.codec }; g.sum += r.fileSizeBytes; g.count += 1; map.set(key, g); + codecCounts.set(r.codec, (codecCounts.get(r.codec) || 0) + 1); } - const out: Group[] = []; - for (const g of map.values()) { - out.push({ preset: g.preset, codec: g.codec, avgMB: (g.sum / Math.max(1, g.count)) / (1024 * 1024) }); - } - out.sort((a, b) => a.preset.localeCompare(b.preset) || a.codec.localeCompare(b.codec)); - return out; + const presets = Array.from(new Set(Array.from(map.values()).map((g) => g.preset))).sort(); + const rankedCodecs = Array.from(codecCounts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([c]) => c); + const codecs = rankedCodecs.slice(0, MAX_VISIBLE_CODECS); + const seriesData = codecs.map((codec) => + presets.map((preset) => { + const g = map.get(`${preset}|${codec}`); + return g ? (g.sum / Math.max(1, g.count)) / (1024 * 1024) : 0; + }), + ); + return { presets, codecs, seriesData, totalCodecs: rankedCodecs.length }; }, [data]); - const presets = Array.from(new Set(groups.map((g) => g.preset))); - const codecs = Array.from(new Set(groups.map((g) => g.codec))); - - // O(1) lookup map instead of O(n) .find() per bar - const groupMap = useMemo(() => { - const m = new Map(); - for (const g of groups) m.set(`${g.preset}|${g.codec}`, g); - return m; - }, [groups]); - - const height = 340; - const margin = { top: 24, right: 16, bottom: 84, left: 56 }; - const chartHeight = height - margin.top - margin.bottom; - const groupGap = 24; - const barGap = 4; - const minBarWidth = 24; - const barsPerGroup = Math.max(1, codecs.length); - const neededGroupWidth = barsPerGroup * minBarWidth + (barsPerGroup - 1) * barGap; - const neededChartWidth = presets.length * neededGroupWidth + (presets.length - 1) * groupGap; - const chartWidth = Math.max(648, neededChartWidth); - const width = chartWidth + margin.left + margin.right; - const groupWidth = (chartWidth - (presets.length - 1) * groupGap) / Math.max(1, presets.length); - const barWidth = (groupWidth - (barsPerGroup - 1) * barGap) / barsPerGroup; - const xStartForGroup = (i: number) => margin.left + i * (groupWidth + groupGap); - - let maxValue = 1; - for (const g of groups) if (g.avgMB > maxValue) maxValue = g.avgMB; - const yFor = (v: number) => margin.top + chartHeight - (v / maxValue) * chartHeight; + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { seriesName: string; value: number }[]) => + params + .filter((p) => p.value > 0) + .map((p) => `${p.seriesName}: ${p.value.toFixed(2)} MB`) + .join("
"), + }, + legend: { + data: codecs.map(shortCodecName), + textStyle: { color: t.fg, fontSize: 11 }, + top: 4, + type: "scroll" as const, + }, + grid: { left: 48, right: 12, top: 32, bottom: presets.length > 6 ? 64 : 36, containLabel: false }, + xAxis: { + type: "category", + data: presets, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, rotate: presets.length > 6 ? 30 : 0, interval: 0 }, + }, + yAxis: { + type: "value", + name: "MB", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: codecs.map((codec, i) => ({ + name: shortCodecName(codec), + type: "bar", + data: seriesData[i], + itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: [3, 3, 0, 0] }, + barMaxWidth: 40, + })), + }), [presets, codecs, seriesData, t]); - // 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, - }; - } + if (presets.length === 0) return null; return ( -
-
Average File Size by Preset and Codec
-
- setHover(null)}> - {/* Grid */} - {Array.from({ length: 4 }).map((_, i) => { - const y = margin.top + (i * chartHeight) / 3; - return ; - })} - - {/* Bars */} - {presets.map((p, pi) => { - const x0 = xStartForGroup(pi); - return codecs.map((c, ci) => { - const g = groupMap.get(`${p}|${c}`); - if (!g) return null; // Skip missing combinations - const v = g.avgMB; - const x = x0 + ci * (barWidth + barGap); - const y = yFor(v); - const h = margin.top + chartHeight - y; - const color = CHART_COLORS[ci % CHART_COLORS.length]; - return ( - { - const dom = svgToDom(x + barWidth / 2 + 8, y - 8); - setHover({ x: dom.x, y: dom.y, text: `${p} \u2022 ${c}: ${v.toFixed(2)} MB` }); - }} - onMouseLeave={() => setHover(null)} - > - - - ); - }); - })} - - {/* X axis labels */} - {presets.map((p, pi) => { - const cx = xStartForGroup(pi) + groupWidth / 2; - const cy = height - margin.bottom + 16; - return ( - - {p} - - ); - })} - - {/* Y axis */} - - {Array.from({ length: 5 }).map((_, i) => { - const value = (maxValue * (4 - i)) / 4; - const y = margin.top + (i * chartHeight) / 4; - return ( - - {value.toFixed(1)} MB - - ); - })} - -
- {hover && ( -
- {hover.text} +
+
Average File Size by Preset and Codec
+ {totalCodecs > codecs.length && ( +
+ Showing top {codecs.length} codecs by sample count (of {totalCodecs}).
)} - - {/* Legend */} -
- {codecs.map((c, i) => ( -
- - {c} -
- ))} -
+
); } diff --git a/frontend/app/components/HardwareRecommendation.tsx b/frontend/app/components/HardwareRecommendation.tsx new file mode 100644 index 0000000..30fab2d --- /dev/null +++ b/frontend/app/components/HardwareRecommendation.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Benchmark } from "./BenchmarksTable"; + +type HardwareProfile = { + cpuModel: string; + gpuModel: string; + avgFps: number; + avgVmaf: number | null; + avgPower: number | null; + fpsPerWatt: number | null; + samples: number; +}; + +type Priority = "speed" | "quality" | "efficiency" | "balanced"; + +const PROFILE_KEY_SEP = "\u241F"; // unit separator — safe for CPU/GPU model names + +export default function HardwareRecommendation({ data }: { data: Benchmark[] }) { + const [codec, setCodec] = useState(""); + const [priority, setPriority] = useState("balanced"); + + const codecs = useMemo(() => Array.from(new Set(data.map(d => d.codec))).sort(), [data]); + + const recommendations = useMemo(() => { + const filtered = codec ? data.filter(d => d.codec === codec) : data; + const profiles = new Map(); + + for (const row of filtered) { + if (row.fps <= 0) continue; + const key = [row.cpuModel, row.gpuModel ?? ""].join(PROFILE_KEY_SEP); + if (!profiles.has(key)) profiles.set(key, { fps: [], vmaf: [], power: [] }); + const p = profiles.get(key)!; + p.fps.push(row.fps); + if (row.vmaf != null) p.vmaf.push(row.vmaf); + const power = row.gpuPowerAvgW; + if (typeof power === "number" && power > 0) p.power.push(power); + } + + const results: HardwareProfile[] = []; + for (const [key, p] of profiles.entries()) { + const [cpuModel, gpuModel] = key.split(PROFILE_KEY_SEP); + const avgFps = p.fps.reduce((a, b) => a + b, 0) / p.fps.length; + const avgVmaf = p.vmaf.length > 0 ? p.vmaf.reduce((a, b) => a + b, 0) / p.vmaf.length : null; + const avgPower = p.power.length > 0 ? p.power.reduce((a, b) => a + b, 0) / p.power.length : null; + const fpsPerWatt = avgPower != null && avgPower > 0 ? avgFps / avgPower : null; + results.push({ cpuModel, gpuModel, avgFps, avgVmaf, avgPower, fpsPerWatt, samples: p.fps.length }); + } + + results.sort((a, b) => { + switch (priority) { + case "speed": + return b.avgFps - a.avgFps; + case "quality": + return (b.avgVmaf ?? 0) - (a.avgVmaf ?? 0); + case "efficiency": + return (b.fpsPerWatt ?? 0) - (a.fpsPerWatt ?? 0); + case "balanced": + default: { + const scoreA = normalizedScore(a, results); + const scoreB = normalizedScore(b, results); + return scoreB - scoreA; + } + } + }); + + return results.slice(0, 20); + }, [data, codec, priority]); + + return ( +
+
+
+ + +
+
+ + +
+
+ + {recommendations.length === 0 ? ( +
+ No benchmark data available for the selected filters. +
+ ) : ( +
+ + + + + + + + + + + + + + + {recommendations.map((hw, i) => ( + + + + + + + + + + + ))} + +
#CPUGPUAvg FPSAvg VMAFAvg Power (W)FPS/WattSamples
+ {i + 1} + {hw.cpuModel}{hw.gpuModel || "-"}{hw.avgFps.toFixed(2)}{hw.avgVmaf != null ? hw.avgVmaf.toFixed(1) : "-"}{hw.avgPower != null ? hw.avgPower.toFixed(1) : "-"}{hw.fpsPerWatt != null ? hw.fpsPerWatt.toFixed(2) : "-"}{hw.samples}
+
+ )} +
+ ); +} + +function normalizedScore(hw: HardwareProfile, all: HardwareProfile[]): number { + let maxFps = 1, maxVmaf = 1, maxEff = 1; + for (const h of all) { + if (h.avgFps > maxFps) maxFps = h.avgFps; + if (h.avgVmaf != null && h.avgVmaf > maxVmaf) maxVmaf = h.avgVmaf; + if (h.fpsPerWatt != null && h.fpsPerWatt > maxEff) maxEff = h.fpsPerWatt; + } + const speedScore = hw.avgFps / maxFps; + const qualityScore = hw.avgVmaf != null ? hw.avgVmaf / maxVmaf : 0.5; + const effScore = hw.fpsPerWatt != null ? hw.fpsPerWatt / maxEff : 0.3; + return 0.4 * speedScore + 0.35 * qualityScore + 0.25 * effScore; +} diff --git a/frontend/app/components/LazyChart.tsx b/frontend/app/components/LazyChart.tsx new file mode 100644 index 0000000..6f8821f --- /dev/null +++ b/frontend/app/components/LazyChart.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useRef, useState, useEffect, type ReactNode } from "react"; + +export default function LazyChart({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {visible ? children : null} +
+ ); +} diff --git a/frontend/app/components/LeaderboardTable.module.css b/frontend/app/components/LeaderboardTable.module.css new file mode 100644 index 0000000..dd22744 --- /dev/null +++ b/frontend/app/components/LeaderboardTable.module.css @@ -0,0 +1,81 @@ +.leaderboardCard { + padding: 16px; +} + +.title { + font-weight: 600; + font-size: 15px; + margin-bottom: 12px; +} + +.header { + display: grid; + grid-template-columns: 32px 1fr 80px; + gap: 8px; + font-size: 11px; + color: var(--muted); + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + margin-bottom: 4px; +} + +.row { + display: grid; + grid-template-columns: 32px 1fr 80px; + gap: 8px; + align-items: center; + padding: 6px 0; + font-size: 13px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} + +.rankCol { + font-weight: 600; + text-align: center; + font-size: 13px; +} + +.rank1 { + color: #d4a843; +} + +.rank2 { + color: #a0a0a0; +} + +.rank3 { + color: #b87333; +} + +.nameCol { + position: relative; + overflow: hidden; +} + +.barBg { + position: absolute; + inset: 0; + border-radius: 3px; + overflow: hidden; +} + +.bar { + height: 100%; + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-radius: 3px; + transition: width 0.3s ease; +} + +.nameText { + position: relative; + z-index: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.valueCol { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 13px; +} diff --git a/frontend/app/components/LeaderboardTable.tsx b/frontend/app/components/LeaderboardTable.tsx new file mode 100644 index 0000000..982a7a7 --- /dev/null +++ b/frontend/app/components/LeaderboardTable.tsx @@ -0,0 +1,52 @@ +"use client"; + +import styles from "./LeaderboardTable.module.css"; + +type LeaderboardEntry = { + name: string; + value: number; + formattedValue: string; +}; + +export default function LeaderboardTable({ + title, + entries, + valueLabel, +}: { + title: string; + entries: LeaderboardEntry[]; + valueLabel: string; +}) { + if (entries.length === 0) return null; + + const maxValue = entries.reduce((max, e) => Math.max(max, e.value), 0); + + return ( +
+
{title}
+
+ # + Name + {valueLabel} +
+ {entries.slice(0, 10).map((entry, i) => { + const rank = i + 1; + const barWidth = maxValue > 0 ? (entry.value / maxValue) * 100 : 0; + return ( +
+ + {rank} + + +
+
+
+ {entry.name} + + {entry.formattedValue} +
+ ); + })} +
+ ); +} diff --git a/frontend/app/components/PassSpeedComparison.tsx b/frontend/app/components/PassSpeedComparison.tsx new file mode 100644 index 0000000..356e5aa --- /dev/null +++ b/frontend/app/components/PassSpeedComparison.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const MAX_BARS = 8; + +function shortCodec(codec: string): string { + if (codec.length <= 14) return codec; + return `${codec.slice(0, 11)}...`; +} + +export default function PassSpeedComparison({ data, title = "1-Pass vs 2-Pass Encoding Speed" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + + const { visible, hasTwoPass } = useMemo(() => { + const map = new Map(); + for (const row of data) { + if (!map.has(row.codec)) map.set(row.codec, { fps1Sum: 0, fps1N: 0, fps2Sum: 0, fps2N: 0 }); + const e = map.get(row.codec)!; + if ((row.passes ?? 1) === 2) { e.fps2Sum += row.fps; e.fps2N += 1; } + else { e.fps1Sum += row.fps; e.fps1N += 1; } + } + const all = Array.from(map.entries()) + .map(([codec, e]) => ({ + codec, + fps1: e.fps1N > 0 ? e.fps1Sum / e.fps1N : 0, + fps2: e.fps2N > 0 ? e.fps2Sum / e.fps2N : 0, + })) + .filter((d) => d.fps1 > 0 || d.fps2 > 0) + .sort((a, b) => Math.max(b.fps1, b.fps2) - Math.max(a.fps1, a.fps2)); + return { visible: all.slice(0, MAX_BARS), hasTwoPass: all.some((d) => d.fps2 > 0) }; + }, [data]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { seriesName: string; value: number }[]) => + params.filter((p) => p.value > 0).map((p) => `${p.seriesName}: ${p.value.toFixed(1)} FPS`).join("
"), + }, + legend: { + data: ["1-pass (CRF)", "2-pass (CBR/VBR)"], + textStyle: { color: t.fg, fontSize: 11 }, + top: 4, + }, + grid: { left: 52, right: 12, top: 32, bottom: 32, containLabel: false }, + xAxis: { + type: "category", + data: visible.map((d) => shortCodec(d.codec)), + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11 }, + }, + yAxis: { + type: "value", + name: "FPS", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: [ + { + name: "1-pass (CRF)", + type: "bar", + data: visible.map((d) => d.fps1), + itemStyle: { color: "#6C8FD5", borderRadius: [3, 3, 0, 0] }, + barMaxWidth: 40, + }, + { + name: "2-pass (CBR/VBR)", + type: "bar", + data: visible.map((d) => d.fps2), + itemStyle: { color: "#d4a843", borderRadius: [3, 3, 0, 0] }, + barMaxWidth: 40, + }, + ], + }), [visible, t]); + + if (visible.length === 0 || !hasTwoPass) return null; + + return ( +
+
{title}
+
+
+ ); +} diff --git a/frontend/app/components/PowerConsumptionChart.tsx b/frontend/app/components/PowerConsumptionChart.tsx new file mode 100644 index 0000000..cc40de9 --- /dev/null +++ b/frontend/app/components/PowerConsumptionChart.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useMemo } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const CHART_COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_BARS = 8; + +export default function PowerConsumptionChart({ data, title = "Average GPU Power by Codec" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + + const bars = useMemo(() => { + const sums = new Map(); + for (const row of data) { + const power = row.gpuPowerAvgW; + if (typeof power !== "number" || power <= 0) continue; + const cur = sums.get(row.codec) || { total: 0, count: 0 }; + cur.total += power; + cur.count += 1; + sums.set(row.codec, cur); + } + return Array.from(sums.entries()) + .filter(([, agg]) => agg.count > 0) + .map(([codec, agg]) => ({ codec, value: agg.total / agg.count })) + .sort((a, b) => a.value - b.value) // ascending: lowest power first + .slice(0, MAX_BARS); + }, [data]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { name: string; value: number }[]) => + `${params[0].name}
${params[0].value.toFixed(1)} W`, + }, + grid: { left: 52, right: 12, top: 12, bottom: bars.length > 4 ? 72 : 48, containLabel: false }, + xAxis: { + type: "category", + data: bars.map((b) => b.codec), + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11, rotate: bars.length > 4 ? 35 : 0, interval: 0 }, + }, + yAxis: { + type: "value", + name: "Watts", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: [ + { + type: "bar", + data: bars.map((b, i) => ({ + value: b.value, + itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: [3, 3, 0, 0] }, + })), + barMaxWidth: 60, + }, + ], + }), [bars, t]); + + if (bars.length === 0) { + return ( +
+
{title}
+
+ No GPU power data available yet. +
+
+ ); + } + + return ( +
+
{title}
+
+
+ ); +} diff --git a/frontend/app/components/PsnrHistogram.tsx b/frontend/app/components/PsnrHistogram.tsx new file mode 100644 index 0000000..ccf6694 --- /dev/null +++ b/frontend/app/components/PsnrHistogram.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 PsnrHistogram({ data, bins = 20 }: { data: Benchmark[]; bins?: number }) { + const t = useChartTheme(); + + const values = useMemo(() => + data + .map((d) => (typeof d.psnr === "number" ? Math.max(0, Math.min(100, d.psnr)) : null)) + .filter((v): v is number => v != null), + [data]); + + const { binData, lo, hi } = useMemo(() => { + if (values.length === 0) return { binData: [], lo: 0, hi: 60 }; + 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 - 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; + } + 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 `PSNR ${(mid - step / 2).toFixed(1)}–${(mid + step / 2).toFixed(1)} dB
${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: "#d4a843", borderRadius: [3, 3, 0, 0] }, + emphasis: { itemStyle: { color: "#e8c06a" } }, + }, + ], + }), [binData, lo, hi, bins, t]); + + if (values.length === 0) return null; + + return ( +
+
PSNR Distribution
+
Scroll to zoom · drag to pan
+
+
+ ); +} diff --git a/frontend/app/components/RateDistortionChart.tsx b/frontend/app/components/RateDistortionChart.tsx new file mode 100644 index 0000000..31cccca --- /dev/null +++ b/frontend/app/components/RateDistortionChart.tsx @@ -0,0 +1,140 @@ +"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 EChart from "./EChart"; + +type QualityMetric = "vmaf" | "ssim" | "psnr"; + +const METRIC_LABELS: Record = { + vmaf: "VMAF", + ssim: "SSIM", + psnr: "PSNR (dB)", +}; + +const MAX_VISIBLE_LINES = 8; + +function shortName(name: string): string { + if (name.length <= 22) return name; + return `${name.slice(0, 19)}...`; +} + +export default function RateDistortionChart({ data }: { data: Benchmark[] }) { + const t = useChartTheme(); + const [metric, setMetric] = useState("vmaf"); + const [codecFilter, setCodecFilter] = useState(""); + const [showAll, setShowAll] = useState(false); + + const lines = useMemo(() => { + const filtered = data.filter( + (d) => d.crf != null && (!codecFilter || d.codec.toLowerCase().includes(codecFilter.toLowerCase())), + ); + const groups = new Map(); + for (const d of filtered) { + const key = `${d.codec} / ${d.preset}`; + const arr = groups.get(key) || []; + arr.push(d); + groups.set(key, arr); + } + const result: { name: string; color: string; data: [number, number][] }[] = []; + for (const [name, rows] of groups.entries()) { + const points = rows + .filter((r) => typeof r[metric] === "number") + .map((r) => [r.fileSizeBytes / (1024 * 1024), r[metric] as number] as [number, number]) + .sort((a, b) => a[0] - b[0]); + if (points.length < 2) continue; + result.push({ name, color: CODEC_COLORS[codecColorKey(rows[0].codec)] || CODEC_COLORS.other, data: points }); + } + return result.sort((a, b) => b.data.length - a.data.length || a.name.localeCompare(b.name)); + }, [data, metric, codecFilter]); + + const visible = useMemo(() => (showAll ? lines : lines.slice(0, MAX_VISIBLE_LINES)), [lines, showAll]); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg, fontSize: 12 }, + axisPointer: { type: "cross" as const, lineStyle: { color: t.border } }, + formatter: (params: { seriesName: string; value: [number, number] }[]) => + params + .filter((p) => p.value[1] != null) + .map((p) => `${shortName(p.seriesName)}: ${metric === "ssim" ? p.value[1].toFixed(4) : p.value[1].toFixed(2)}`) + .join("
") + `
${params[0]?.value[0]?.toFixed(2)} MB`, + }, + grid: { left: 52, right: 12, top: 12, bottom: 32, 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", + name: METRIC_LABELS[metric], + nameTextStyle: { color: t.muted, fontSize: 11 }, + min: metric === "ssim" ? 0.7 : "dataMin", + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: visible.map((line) => ({ + name: line.name, + type: "line", + data: line.data, + smooth: false, + symbol: "circle", + symbolSize: 5, + showSymbol: false, + lineStyle: { color: line.color, width: 2 }, + itemStyle: { color: line.color }, + emphasis: { showSymbol: true }, + })), + }), [visible, metric, t]); + + return ( +
+
+
Rate-Distortion Curves
+
+ + setCodecFilter(e.target.value)} style={{ maxWidth: 160 }} /> +
+
+ {lines.length === 0 ? ( +
+ No rate-distortion data (need encoder groups with 2+ CRF values) +
+ ) : ( + <> +
+
+ {visible.map((line) => ( +
+ + {shortName(line.name)} +
+ ))} +
+ {lines.length > MAX_VISIBLE_LINES && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/frontend/app/components/ResolutionComparisonChart.tsx b/frontend/app/components/ResolutionComparisonChart.tsx new file mode 100644 index 0000000..5f9eb08 --- /dev/null +++ b/frontend/app/components/ResolutionComparisonChart.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { Benchmark } from "./BenchmarksTable"; +import { useChartTheme } from "../lib/useChartTheme"; +import EChart from "./EChart"; + +const RESOLUTION_ORDER = ["480p", "720p", "1080p", "1440p", "4k"]; +const COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; +const MAX_SELECTED = 4; +const MAX_SELECTOR = 12; + +type ResolutionFps = { codec: string; fpsPerRes: Record; avgFps: number; samples: number }; + +function computeResolutionFps(data: Benchmark[]): ResolutionFps[] { + const byCodecAndRes = new Map>(); + for (const row of data) { + const res = row.resolution ?? "1080p"; + if (!byCodecAndRes.has(row.codec)) byCodecAndRes.set(row.codec, new Map()); + const resMap = byCodecAndRes.get(row.codec)!; + if (!resMap.has(res)) resMap.set(res, { fpsSum: 0, count: 0 }); + const e = resMap.get(res)!; + e.fpsSum += row.fps; + e.count += 1; + } + return Array.from(byCodecAndRes.entries()) + .map(([codec, resMap]) => { + const fpsPerRes: Record = {}; + let totalFps = 0, totalN = 0; + for (const res of RESOLUTION_ORDER) { + const e = resMap.get(res); + const avg = e && e.count > 0 ? e.fpsSum / e.count : 0; + fpsPerRes[res] = avg; + if (e && e.count > 0) { totalFps += e.fpsSum; totalN += e.count; } + } + return { codec, fpsPerRes, avgFps: totalN > 0 ? totalFps / totalN : 0, samples: totalN }; + }) + .sort((a, b) => b.avgFps - a.avgFps || a.codec.localeCompare(b.codec)); +} + +export default function ResolutionComparisonChart({ data, title = "FPS by Resolution per Codec" }: { data: Benchmark[]; title?: string }) { + const t = useChartTheme(); + const allCodecs = useMemo(() => computeResolutionFps(data), [data]); + const selectorOptions = useMemo(() => allCodecs.slice(0, Math.max(MAX_SELECTOR, MAX_SELECTED)), [allCodecs]); + const [selectedCodecs, setSelectedCodecs] = useState>(new Set()); + const codecColorMap = useMemo( + () => new Map(selectorOptions.map((entry, i) => [entry.codec, COLORS[i % COLORS.length]])), + [selectorOptions], + ); + + const activeResolutions = useMemo(() => { + const seen = new Set(data.map((r) => r.resolution ?? "1080p")); + return RESOLUTION_ORDER.filter((r) => seen.has(r)); + }, [data]); + + useEffect(() => { + if (selectorOptions.length === 0) return; + setSelectedCodecs((prev) => { + const names = selectorOptions.map((d) => d.codec); + const next = new Set(Array.from(prev).filter((c) => names.includes(c))); + return next.size > 0 ? next : new Set(names.slice(0, MAX_SELECTED)); + }); + }, [selectorOptions]); + + const shown = selectorOptions.filter((d) => selectedCodecs.has(d.codec)); + const forChart = shown.length > 0 ? shown : selectorOptions.slice(0, 1); + + const option = useMemo(() => ({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" as const }, + backgroundColor: t.surface, + borderColor: t.border, + textStyle: { color: t.fg }, + formatter: (params: { seriesName: string; value: number }[]) => + params + .filter((p) => p.value > 0) + .map((p) => `${p.seriesName}: ${p.value.toFixed(1)} FPS`) + .join("
"), + }, + grid: { left: 52, right: 12, top: 8, bottom: 32, containLabel: false }, + xAxis: { + type: "category", + data: activeResolutions, + axisLine: { lineStyle: { color: t.border } }, + axisTick: { lineStyle: { color: t.border } }, + axisLabel: { color: t.fg, fontSize: 11 }, + }, + yAxis: { + type: "value", + name: "FPS", + nameTextStyle: { color: t.muted, fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: t.muted, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border } }, + }, + series: forChart.map((entry) => ({ + name: entry.codec, + type: "bar", + data: activeResolutions.map((res) => entry.fpsPerRes[res] || 0), + itemStyle: { color: codecColorMap.get(entry.codec) ?? COLORS[0], borderRadius: [3, 3, 0, 0] }, + barMaxWidth: 40, + })), + }), [activeResolutions, codecColorMap, forChart, t]); + + if (allCodecs.length === 0 || activeResolutions.length < 2) return null; + + const toggle = (codec: string) => { + setSelectedCodecs((prev) => { + const next = new Set(prev); + if (next.has(codec)) next.delete(codec); + else if (next.size < MAX_SELECTED) next.add(codec); + return next; + }); + }; + + return ( +
+
{title}
+
+ {selectorOptions.map((entry) => { + const isOn = selectedCodecs.has(entry.codec); + const disabled = !isOn && selectedCodecs.size >= MAX_SELECTED; + const color = codecColorMap.get(entry.codec) ?? COLORS[0]; + return ( + + ); + })} +
+
+
+ ); +} 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..633fa41 100644 --- a/frontend/app/components/ScatterFpsSize.tsx +++ b/frontend/app/components/ScatterFpsSize.tsx @@ -1,111 +1,81 @@ "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 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 } }) => + `${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..8b4991f --- /dev/null +++ b/frontend/app/components/ScatterSsimVmaf.tsx @@ -0,0 +1,95 @@ +"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 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 } }) => + `${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..9ea2b19 --- /dev/null +++ b/frontend/app/hardware/page.tsx @@ -0,0 +1,64 @@ +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 { fetchBenchmarks } from "../lib/fetchBenchmarks"; +import styles from "./page.module.css"; + +export const dynamic = "force-dynamic"; + +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({ }} /> - +
- Showing {sorted.length === 0 ? 0 : page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalRows)} of {totalRows} + Showing {totalRows === 0 ? 0 : page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalRows)} of {totalRows}
@@ -437,9 +438,9 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar ); } -function VirtualTable({ sorted, selectedIds, toggleSelect, setShowDetailId, setShowFfmpegId, sortKey, sortDir, setSort, fetching }: { sorted: 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; fetching: boolean }) { +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: sorted.length, getScrollElement: () => parentRef.current, estimateSize: () => 48, overscan: 10 }); + const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => 48, overscan: 10 }); return (
@@ -456,9 +457,8 @@ function VirtualTable({ sorted, selectedIds, toggleSelect, setShowDetailId, setS
- {fetching && sorted.length === 0 &&
Loading...
} {virtualizer.getVirtualItems().map(vr => { - const row = sorted[vr.index]; + 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)" }} />
@@ -474,7 +474,7 @@ function VirtualTable({ sorted, selectedIds, toggleSelect, setShowDetailId, setS
); })} - {sorted.length === 0 && !fetching &&
No results for current filters.
} + {rows.length === 0 &&
No results for current filters.
}
@@ -483,8 +483,16 @@ function VirtualTable({ sorted, selectedIds, toggleSelect, setShowDetailId, setS function ThDiv({ label, onClick, active, dir, align }: { label: string; onClick: () => void; active: boolean; dir: "asc" | "desc"; align?: "left" | "right" }) { return ( -
- {label}{active && } +
+
); } @@ -639,6 +647,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"); @@ -651,31 +668,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 { @@ -719,6 +740,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/leaderboards/page.tsx b/frontend/app/leaderboards/page.tsx index c730c87..4b1f246 100644 --- a/frontend/app/leaderboards/page.tsx +++ b/frontend/app/leaderboards/page.tsx @@ -4,7 +4,7 @@ import LeaderboardTable from "../components/LeaderboardTable"; import { fetchBenchmarks } from "../lib/fetchBenchmarks"; import styles from "./page.module.css"; -export const dynamic = "force-dynamic"; +export const revalidate = 60; type GroupAgg = { key: string; diff --git a/frontend/app/lib/escapeHtml.ts b/frontend/app/lib/escapeHtml.ts new file mode 100644 index 0000000..8b62dd3 --- /dev/null +++ b/frontend/app/lib/escapeHtml.ts @@ -0,0 +1,11 @@ +const HTML_ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char] ?? char); +} diff --git a/frontend/app/lib/fetchBenchmarks.ts b/frontend/app/lib/fetchBenchmarks.ts index 2d1f77d..b862b72 100644 --- a/frontend/app/lib/fetchBenchmarks.ts +++ b/frontend/app/lib/fetchBenchmarks.ts @@ -1,30 +1,74 @@ -import { headers } from "next/headers"; import type { Benchmark } from "./types"; -export async function fetchBenchmarks(): Promise { - const internal = process.env.INTERNAL_API_BASE_URL; +const PAGE_SIZE = 500; +const MAX_PAGES = 50; +const FETCH_TIMEOUT_MS = 10_000; +export const BENCHMARKS_REVALIDATE_SECONDS = 60; - 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 +function parseTotalCount(raw: string | null): number | null { + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function resolveAppOrigin(): string { + const envOrigin = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; + if (envOrigin) return envOrigin.replace(/\/+$/, ""); + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + const port = process.env.PORT || "3000"; + return `http://127.0.0.1:${port}`; +} + +async function fetchBenchmarkPage(baseUrl: string, skip: number, limit: number): Promise<{ data: Benchmark[]; total: number | null }> { + const url = new URL(baseUrl); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("skip", String(skip)); + url.searchParams.set("total", "1"); + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + next: { revalidate: BENCHMARKS_REVALIDATE_SECONDS }, + }); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const total = parseTotalCount(res.headers.get("X-Total-Count")); + const data = await res.json() as Benchmark[]; + return { data, total }; +} + +async function fetchAllBenchmarksFromBase(baseUrl: string): Promise { + const rows: Benchmark[] = []; + const seenIds = new Set(); + let skip = 0; + let total: number | null = null; + + for (let page = 0; page < MAX_PAGES; page++) { + const { data, total: reportedTotal } = await fetchBenchmarkPage(baseUrl, skip, PAGE_SIZE); + if (total == null) total = reportedTotal; + if (data.length === 0) break; + + for (const row of data) { + if (seenIds.has(row.id)) continue; + seenIds.add(row.id); + rows.push(row); + } + + skip += data.length; + if (total != null && rows.length >= total) break; + if (data.length < PAGE_SIZE && total == null) break; } - const origin = `${proto}://${host}`; - const primaryUrl = internal ? `${internal}/query` : `${origin}/api/query`; + return rows; +} + +export async function fetchBenchmarks(): Promise { + const internal = process.env.INTERNAL_API_BASE_URL; + const fallbackBase = `${resolveAppOrigin()}/api/query`; + const primaryBase = internal ? `${internal}/query` : fallbackBase; + try { - const res = await fetch(primaryUrl, { signal: AbortSignal.timeout(10000) }); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - return res.json(); + return await fetchAllBenchmarksFromBase(primaryBase); } 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(); + if (internal && fallbackBase) { + return fetchAllBenchmarksFromBase(fallbackBase); } throw err; } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index b1d6774..24e8a89 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,7 +5,7 @@ import StatsCards from "./components/StatsCards"; import { fetchBenchmarks } from "./lib/fetchBenchmarks"; import styles from "./page.module.css"; -export const dynamic = "force-dynamic"; +export const revalidate = 60; export default async function Home() { let data: Benchmark[] = []; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 94c5a01..37b7e87 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,10 +1,20 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - eslint: { - ignoreDuringBuilds: true, - }, + headers: async () => [ + { + source: "/(.*)", + headers: [ + { key: "Referrer-Policy", value: "no-referrer" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" }, + { + key: "Content-Security-Policy", + value: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", + }, + ], + }, + ], }; export default nextConfig; - - diff --git a/frontend/next.config.ts b/frontend/next.config.ts deleted file mode 100644 index 48c7e52..0000000 --- a/frontend/next.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - // Skip ESLint during production builds (handled separately in CI) - eslint: { - ignoreDuringBuilds: true, - }, - headers: async () => [ - { - source: '/(.*)', - headers: [ - { key: 'Referrer-Policy', value: 'no-referrer' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'Permissions-Policy', value: "geolocation=(), microphone=(), camera=()" }, - // Basic CSP; adjust as needed if adding external resources - { key: 'Content-Security-Policy', value: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" }, - ], - }, - ], -}; - -export default nextConfig; From bdff08ba3c0e4ef40df01c6350c76a063bfe6044 Mon Sep 17 00:00:00 2001 From: ofhd Date: Thu, 19 Feb 2026 13:40:48 -0800 Subject: [PATCH 4/7] Prepare deployment fixes --- .github/workflows/api-smoke.yml | 12 +- .gitignore | 35 +- README.md | 46 +- client/config.py | 60 +- client/ffmpeg.py | 127 - client/hardware_monitor.py | 50 +- client/main.py | 194 +- client/network.py | 121 +- client/presets.json | 11 - client/test_videos.py | 249 - client/ui.py | 5 +- deploy.sh | 195 + docker-compose.yml | 2 +- env.example | 9 + frontend/README.md | 45 +- frontend/app/analytics/page.tsx | 6 - frontend/app/components/BenchmarksTable.tsx | 51 +- frontend/app/components/ContentRadarChart.tsx | 162 - .../app/components/PassSpeedComparison.tsx | 96 - .../components/ResolutionComparisonChart.tsx | 153 - frontend/app/lib/fetchBenchmarksClient.ts | 13 - frontend/app/lib/types.ts | 3 - frontend/eslint.config.mjs | 25 - frontend/package-lock.json | 4937 +---------------- frontend/package.json | 8 +- frontend/public/file.svg | 1 - frontend/public/globe.svg | 1 - frontend/public/next.svg | 1 - frontend/public/vercel.svg | 1 - frontend/public/window.svg | 1 - frontend/tsconfig.json | 24 +- scripts/NUKE_DATA/NUKE_DATA.sh | 1 - scripts/api_hardening_test.sh | 127 - scripts/build_linux_client.sh | 45 - scripts/build_macos_client.sh | 96 +- scripts/build_windows_client.ps1 | 169 - scripts/build_windows_client.sh | 163 +- scripts/client_test.sh | 20 + scripts/dev_frontend.sh | 33 - scripts/e2e.sh | 111 - scripts/local_test.sh | 10 +- scripts/manage_keys.sh | 16 - scripts/redploy.sh | 129 - scripts/setup_env.sh | 165 - scripts/test.sh | 495 ++ server/env.example | 9 + server/package-lock.json | 697 --- server/package.json | 3 +- server/prisma/schema.prisma | 14 +- server/src/index.ts | 18 +- server/src/routes.ts | 97 +- server/src/seedDummyDatabase.ts | 44 +- server/test/routes.smoke.test.js | 69 +- 53 files changed, 1660 insertions(+), 7515 deletions(-) delete mode 100644 client/test_videos.py create mode 100755 deploy.sh delete mode 100644 frontend/app/components/ContentRadarChart.tsx delete mode 100644 frontend/app/components/PassSpeedComparison.tsx delete mode 100644 frontend/app/components/ResolutionComparisonChart.tsx delete mode 100644 frontend/app/lib/fetchBenchmarksClient.ts delete mode 100644 frontend/eslint.config.mjs delete mode 100644 frontend/public/file.svg delete mode 100644 frontend/public/globe.svg delete mode 100644 frontend/public/next.svg delete mode 100644 frontend/public/vercel.svg delete mode 100644 frontend/public/window.svg delete mode 100644 scripts/NUKE_DATA/NUKE_DATA.sh delete mode 100755 scripts/api_hardening_test.sh delete mode 100755 scripts/build_linux_client.sh delete mode 100644 scripts/build_windows_client.ps1 mode change 100644 => 100755 scripts/build_windows_client.sh create mode 100755 scripts/client_test.sh delete mode 100755 scripts/dev_frontend.sh delete mode 100755 scripts/e2e.sh delete mode 100755 scripts/manage_keys.sh delete mode 100755 scripts/redploy.sh delete mode 100755 scripts/setup_env.sh create mode 100755 scripts/test.sh 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 a1a0435..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,20 +36,38 @@ 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* @@ -70,19 +89,33 @@ 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) diff --git a/README.md b/README.md index d43311a..ee1284f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ Encoder performance claims are often hard to compare because workloads, settings - `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/`: local testing, e2e checks, hardening checks, packaging, deployment helpers. +- `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, pass count. +- 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. @@ -58,7 +58,7 @@ The client submits an explicit allowlist of fields. This prevents accidental inc | 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`, `inputHash` | Ensures two rows are only compared when workload settings are equivalent. | +| 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. | @@ -102,8 +102,7 @@ python client/main.py \ --crf 24 \ --batch-size 0 \ --content-class mixed \ - --resolution 1080p \ - --passes 1 + --resolution 1080p ``` Common flags: @@ -129,6 +128,12 @@ Common flags: 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`. @@ -202,51 +207,42 @@ Additional controls: ## Build packaged clients -Windows: - -```powershell -scripts/build_windows_client.ps1 -``` - macOS: ```bash ./scripts/build_macos_client.sh ``` -Linux: +Windows (Git Bash/MSYS/WSL with Windows Python available): ```bash -./scripts/build_linux_client.sh +./scripts/build_windows_client.sh ``` -All packaging scripts expect platform FFmpeg/ffprobe binaries under `client/bin//`. +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/e2e.sh`: local end-to-end benchmark flow. -- `scripts/api_hardening_test.sh`: API validation, limits, and resilience checks. +- `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. Generate env files: +1. Configure env files from `env.example` and `server/env.example`. +2. One-command deploy (pull `main`, build, migrate, and start all services): ```bash -./scripts/setup_env.sh --domain your-domain.example +./deploy.sh ``` -2. Build and run: +3. Manual compose alternative: ```bash docker compose -f docker-compose.prod.yml up -d --build ``` -3. For scripted updates on a deployment host: - -```bash -./scripts/redploy.sh -``` +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`. @@ -254,6 +250,7 @@ Frontend-only deployment notes are in `frontend/DEPLOYMENT.md`. - 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). ## Contributing @@ -267,4 +264,3 @@ Issues and PRs are welcome. When contributing: ## License Apache 2.0 - diff --git a/client/config.py b/client/config.py index 5a5e486..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,7 +46,7 @@ _ALLOWED_PAYLOAD_KEYS: Tuple[str, ...] = ( 'cpuModel', 'gpuModel', 'ramGB', 'os', - 'codec', 'preset', 'crf', 'contentClass', 'resolution', 'passes', + 'codec', 'preset', 'crf', 'passes', 'fps', 'vmaf', 'ssim', 'psnr', 'fileSizeBytes', 'notes', 'ffmpegVersion', 'encoderName', 'clientVersion', 'inputHash', 'runMs', 'gpuUtilAvg', 'gpuPowerAvgW', 'gpuMemPeakMB', @@ -68,7 +69,7 @@ _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 @@ -200,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: @@ -207,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/ffmpeg.py b/client/ffmpeg.py index f83cd5d..c90c5af 100644 --- a/client/ffmpeg.py +++ b/client/ffmpeg.py @@ -416,133 +416,6 @@ def compute_metrics_parallel(input_path: str, artifacts: List[str], workers: int return results -RESOLUTION_DIMENSIONS: Dict[str, tuple] = { - "480p": (854, 480), - "720p": (1280, 720), - "1080p": (1920, 1080), - "1440p": (2560, 1440), - "4k": (3840, 2160), -} - -TWOPASS_BITRATE_TARGETS: Dict[str, str] = { - "480p": "1500k", - "720p": "3000k", - "1080p": "6000k", - "1440p": "12000k", - "4k": "20000k", -} - - -def scale_video(input_path: str, target_resolution: str, output_path: str) -> bool: - """Scale a video to target_resolution using a near-lossless intermediate. - - Returns True on success. - """ - dims = RESOLUTION_DIMENSIONS.get(target_resolution) - if not dims: - return False - w, h = dims - cmd = [ - config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "error", "-nostdin", - "-i", input_path, - "-vf", f"scale={w}:{h}:flags=lanczos", - "-c:v", "libx264", "-crf", "0", "-preset", "ultrafast", - "-an", output_path, - ] - try: - proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=300) - return proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0 - except Exception: - return False - - -def build_ffmpeg_twopass_cmds( - *, input_path: str, output_path: str, encoder: str, preset_name: str, - bitrate: str, passlogfile: str, -) -> tuple: - """Return (pass1_cmd, pass2_cmd) for two-pass encoding.""" - from .encoders import map_preset_for_encoder - - null_out = "/dev/null" if os.name != "nt" else "NUL" - - base = [ - config.ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "info", "-nostdin", - "-i", input_path, - "-c:v", encoder, - ] - base += map_preset_for_encoder(encoder, preset_name) - base += ["-b:v", bitrate] - - if encoder.endswith(("_nvenc", "_qsv", "_amf", "_videotoolbox", "_vaapi")): - base += ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"] - - pass1 = base + ["-pass", "1", "-passlogfile", passlogfile, "-an", "-f", "null", null_out] - pass2 = base + ["-pass", "2", "-passlogfile", passlogfile, "-an", output_path] - - return (pass1, pass2) - - -def encode_to_artifact_twopass( - *, input_path: str, encoder: str, preset: str, bitrate: str, - out_dir: str, artifact_name: str, -) -> Dict[str, Any]: - """Two-pass encode, measuring combined time for both passes.""" - os.makedirs(out_dir, exist_ok=True) - artifact_path = os.path.join(out_dir, artifact_name) - passlogfile = os.path.join(out_dir, "ffmpeg2pass") - - pass1_cmd, pass2_cmd = build_ffmpeg_twopass_cmds( - input_path=input_path, output_path=artifact_path, encoder=encoder, - preset_name=preset, bitrate=bitrate, passlogfile=passlogfile, - ) - - start = time.time() - - proc1 = subprocess.run(pass1_cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if proc1.returncode != 0: - stderr_lines = (proc1.stderr or "").splitlines() - err_msg = "; ".join([ln.strip() for ln in stderr_lines[-5:]]) if stderr_lines else "pass 1 failed" - end = time.time() - return { - "artifactPath": artifact_path, - "encoderUsed": encoder, - "elapsedMs": int(round((end - start) * 1000)), - "fps": 0.0, - "fileSizeBytes": 0, - "error": err_msg, - } - - proc2 = subprocess.run(pass2_cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - end = time.time() - elapsed = max(0.0001, end - start) - - total_frames = _parse_frame_count_from_stderr(proc2.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_2: Optional[str] = None - if proc2.returncode != 0 or size_val <= 0 or fps_val <= 0.0: - stderr_lines = (proc2.stderr or "").splitlines() - err_msg_2 = "; ".join([ln.strip() for ln in stderr_lines[-5:]]) if stderr_lines else "pass 2 failed" - - for ext in ("-0.log", "-0.log.mbtree", ".log", ".log.mbtree"): - try: - p = passlogfile + ext - if os.path.exists(p): - os.remove(p) - except OSError: - pass - - return { - "artifactPath": artifact_path, - "encoderUsed": encoder, - "elapsedMs": int(round(elapsed * 1000)), - "fps": float(fps_val), - "fileSizeBytes": int(size_val), - "error": err_msg_2, - } - - def run_single_benchmark(hardware: config.HardwareInfo, input_path: str, preset: str, codec: str = "libx264", crf: Optional[int] = None) -> Dict[str, Any]: # Single encode via encode_to_artifact (which handles HW→SW fallback), # then compute VMAF on the same artifact. No double-encode. (B-C01) diff --git a/client/hardware_monitor.py b/client/hardware_monitor.py index 9ceebb4..f1db421 100644 --- a/client/hardware_monitor.py +++ b/client/hardware_monitor.py @@ -6,10 +6,13 @@ 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: @@ -117,6 +120,7 @@ def __init__(self, ffmpeg_pid: Optional[int] = None, interval: float = 0.5): 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() @@ -254,7 +258,12 @@ def _sample_cpu(self) -> None: try: f = psutil.cpu_freq() if f and f.current and f.current > 0: - freq = float(f.current) + 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 @@ -476,3 +485,42 @@ def _read_battery_state(self) -> Tuple[Optional[float], Optional[str]]: 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 42211a4..99ad86b 100644 --- a/client/main.py +++ b/client/main.py @@ -36,16 +36,11 @@ ) from .ffmpeg import ( run_ffmpeg_test, encode_to_artifact, compute_vmaf_parallel, - compute_metrics_parallel, encode_to_artifact_twopass, - scale_video, RESOLUTION_DIMENSIONS, TWOPASS_BITRATE_TARGETS, + compute_metrics_parallel, EXTENDED_TELEMETRY_KEYS, run_single_benchmark, sha256_of_file, verify_sample_video, load_presets_config, get_default_sample_path, ) -from .test_videos import ( - CONTENT_CLASSES, CONTENT_CLASS_LABELS, RESOLUTION_ORDER, - ensure_test_videos, get_video_path, available_content_classes, -) from .network import submit, fetch_baseline_rows from .stats import should_skip_submission from .ui import ( @@ -58,31 +53,11 @@ def _resolve_input_for_task( - t: Dict[str, Any], default_input: str, batch_dir: str, + default_input: str, + default_input_hash: str, ) -> Tuple[str, str]: - """Return (effective_input_path, input_hash) for a task, scaling resolution if needed.""" - content_class = t.get('contentClass', 'mixed') - resolution = t.get('resolution', '1080p') - - video_path = get_video_path(str(content_class), str(resolution)) - if not video_path: - video_path = get_video_path(str(content_class)) - if not video_path: - video_path = default_input - - target_dims = RESOLUTION_DIMENSIONS.get(str(resolution)) - if target_dims and str(resolution) != '1080p' and video_path == default_input: - scaled_name = f"scaled_{resolution}.mp4" - scaled_path = os.path.join(batch_dir, scaled_name) - if not os.path.exists(scaled_path): - print(f" Scaling source to {resolution}...") - ok = scale_video(video_path, str(resolution), scaled_path) - if ok: - video_path = scaled_path - else: - print(f" Warning: scaling to {resolution} failed, using original", file=sys.stderr) - - return video_path, sha256_of_file(video_path) + """Return (effective_input_path, input_hash) for a task.""" + return default_input, default_input_hash def _infer_encoder_family(encoder: str) -> Optional[str]: @@ -129,18 +104,6 @@ def run_benchmark_batch(*, hardware: HardwareInfo, base_url: str, args: argparse if not getattr(args, 'no_submit', False): baseline_rows = fetch_baseline_rows(base_url) - needed_cc = set() - needed_res = set() - for t in tasks: - cc = t.get('contentClass', 'mixed') - res = t.get('resolution', '1080p') - if cc != 'mixed': - needed_cc.add(cc) - if res != '1080p': - needed_res.add(res) - if needed_cc or needed_res: - ensure_test_videos(list(needed_cc) if needed_cc else None, list(needed_res) if needed_res else None) - completed_count_local = 0 processed_total = 0 pre_batch_bg_load = measure_background_cpu_load(3.0, 0.5) @@ -172,47 +135,28 @@ def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> enc = t['encoder'] preset = t['preset'] crf = t.get('crf') - passes = t.get('passes', 1) - content_class = t.get('contentClass', 'mixed') - resolution = t.get('resolution', '1080p') 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 - label_parts = [enc, preset, f"crf={crf}"] - if content_class != 'mixed': - label_parts.append(f"content={content_class}") - if resolution != '1080p': - label_parts.append(f"res={resolution}") - if passes == 2: - label_parts.append("2-pass") - progress.set_description(_batch_status("Encoding", global_index, enc, preset) + f" [{', '.join(label_parts)}]") + 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=passes, - contentClass=content_class, - resolution=resolution, + passes=1, isHardware=is_hardware_encoder_name(enc), ) - effective_input, input_hash = _resolve_input_for_task(t, input_path, batch_dir) + effective_input, input_hash = _resolve_input_for_task(input_path, default_input_hash) - if passes == 2: - bitrate = TWOPASS_BITRATE_TARGETS.get(str(resolution), '6000k') - info = encode_to_artifact_twopass( - input_path=effective_input, encoder=enc, preset=preset, - bitrate=bitrate, out_dir=batch_dir, artifact_name=name, - ) - else: - info = encode_to_artifact( - input_path=effective_input, - encoder=enc, - preset=preset, - crf=crf, - out_dir=batch_dir, - artifact_name=name, - ) + 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 @@ -224,9 +168,7 @@ def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> encoder=final_encoder, preset=preset, crf=crf, - passes=passes, - contentClass=content_class, - resolution=resolution, + passes=1, isHardware=is_hardware_encoder_name(final_encoder), ) progress.update_machine_metrics(info) @@ -244,9 +186,7 @@ def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> encoder=str(info.get('encoderUsed') or info['task']['encoder']), preset=str(info['task']['preset']), crf=info['task'].get('crf'), - passes=info['task'].get('passes', 1), - contentClass=info['task'].get('contentClass', 'mixed'), - resolution=info['task'].get('resolution', '1080p'), + 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: @@ -281,9 +221,7 @@ def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> 'codec': info.get('encoderUsed') or t['encoder'], 'preset': t['preset'], 'crf': t.get('crf'), - 'contentClass': t.get('contentClass', 'mixed'), - 'resolution': t.get('resolution', '1080p'), - 'passes': t.get('passes', 1), + 'passes': 1, 'fps': float(info.get('fps') or 0.0), 'fileSizeBytes': int(info.get('fileSizeBytes') or 0), 'runMs': int(info.get('elapsedMs') or 0), @@ -333,8 +271,6 @@ def _batch_status(stage: str, index: int, codec: str = "", preset: str = "") -> preset=str(payload['preset']), crf=payload.get('crf'), passes=payload.get('passes', 1), - contentClass=payload.get('contentClass', 'mixed'), - resolution=payload.get('resolution', '1080p'), isHardware=is_hardware_encoder_name(str(payload['codec'])), ) if skip: @@ -537,9 +473,7 @@ def run_with_args(args: argparse.Namespace) -> int: payload["encoderName"] = payload.get("codec", resolved_encoder) payload["clientVersion"] = client_version payload["inputHash"] = input_hash - payload["contentClass"] = getattr(args, 'content_class', 'mixed') or 'mixed' - payload["resolution"] = getattr(args, 'resolution', '1080p') or '1080p' - payload["passes"] = getattr(args, 'passes', 1) or 1 + payload["passes"] = 1 size_val = payload.get("fileSizeBytes") try: @@ -751,59 +685,40 @@ 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] - - bench_key = {1: "smallBenchmark", 2: "mediumBenchmark", 3: "fullBenchmark"}.get(choice, "smallBenchmark") - bench_cfg = presets_cfg.get(bench_key, {}) - content_classes_list: List[str] = bench_cfg.get("contentClasses", ["mixed"]) - resolutions_list: List[str] = bench_cfg.get("resolutions", ["1080p"]) - passes_list: List[int] = [int(p) for p in bench_cfg.get("passes", [1])] - if not content_classes_list: - content_classes_list = ["mixed"] - if not resolutions_list: - resolutions_list = ["1080p"] - if not passes_list: - passes_list = [1] - tasks: List[Dict[str, Any]] = [] - for content_class in content_classes_list: - for resolution in resolutions_list: - for num_passes in passes_list: - for crf_val in crf_values: - for enc in encoders: - presets_for_encoder = enumerate_supported_presets_for_encoder(enc) - ordered = sort_presets_by_speed_desc(enc, presets_for_encoder) - if choice == 1: - if not ordered: - continue - mid_index = max(0, (len(ordered) - 1) // 2) - picks: List[str] = [] - faster1 = mid_index - 1 - faster2 = mid_index - 2 - if faster2 >= 0: - picks.append(ordered[faster2]) - if faster1 >= 0: - picks.append(ordered[faster1]) - picks.append(ordered[mid_index]) - seen: Dict[str, bool] = {} - final = [p for p in picks if not seen.setdefault(p, False)] - for preset_label in final: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, - 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) - elif choice == 2: - if len(ordered) > 0: - drop_count = int(round(len(ordered) * 0.2)) - if drop_count >= len(ordered): - drop_count = len(ordered) - 1 - keep = ordered[:-drop_count] if drop_count > 0 else ordered - else: - keep = ordered - for preset_label in keep: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, - 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) - else: - for preset_label in ordered: - tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val, - 'contentClass': content_class, 'resolution': resolution, 'passes': num_passes}) + for crf_val in crf_values: + for enc in encoders: + presets_for_encoder = enumerate_supported_presets_for_encoder(enc) + ordered = sort_presets_by_speed_desc(enc, presets_for_encoder) + if choice == 1: + if not ordered: + continue + mid_index = max(0, (len(ordered) - 1) // 2) + picks: List[str] = [] + faster1 = mid_index - 1 + faster2 = mid_index - 2 + if faster2 >= 0: + picks.append(ordered[faster2]) + if faster1 >= 0: + picks.append(ordered[faster1]) + picks.append(ordered[mid_index]) + seen: Dict[str, bool] = {} + final = [p for p in picks if not seen.setdefault(p, False)] + for preset_label in final: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) + elif choice == 2: + if len(ordered) > 0: + drop_count = int(round(len(ordered) * 0.2)) + if drop_count >= len(ordered): + drop_count = len(ordered) - 1 + keep = ordered[:-drop_count] if drop_count > 0 else ordered + else: + keep = ordered + for preset_label in keep: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) + else: + for preset_label in ordered: + tasks.append({'encoder': enc, 'preset': preset_label, 'crf': crf_val}) rc = run_benchmark_batch( hardware=detect_hardware(), @@ -846,9 +761,6 @@ def build_arg_parser() -> argparse.ArgumentParser: p.add_argument("--batch-size", type=int, default=0, help="Batch size for parallel VMAF (0=auto: cpu_count or 4)") p.add_argument("--use-token", action="store_true", help="Use short-lived submit token (opt-in; or set INGEST_USE_TOKENS=1)") p.add_argument("--pause-on-exit", action="store_true", help="On Windows, wait for Enter key after completion to keep the window open") - p.add_argument("--content-class", default="mixed", choices=CONTENT_CLASSES, help="Content class for the test video (default: mixed)") - p.add_argument("--resolution", default="1080p", choices=RESOLUTION_ORDER, help="Target resolution (default: 1080p)") - p.add_argument("--passes", type=int, default=1, choices=[1, 2], help="Number of encoding passes (default: 1)") return p diff --git a/client/network.py b/client/network.py index dd99ef9..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,6 +211,7 @@ 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]]: diff --git a/client/presets.json b/client/presets.json index 69dd8d2..ff1aee3 100644 --- a/client/presets.json +++ b/client/presets.json @@ -1,25 +1,14 @@ { - "contentClasses": ["mixed"], - "resolutions": ["1080p"], "smallBenchmark": { "crfValues": [24], - "contentClasses": ["mixed"], - "resolutions": ["1080p"], - "passes": [1], "approxMinutes": 15 }, "mediumBenchmark": { "crfValues": [22, 24, 26], - "contentClasses": ["mixed"], - "resolutions": ["720p", "1080p"], - "passes": [1], "approxHours": 3 }, "fullBenchmark": { "crfValues": [12, 14, 16, 18, 20, 22, 24, 26, 28, 30], - "contentClasses": ["mixed", "action", "animation"], - "resolutions": ["480p", "720p", "1080p", "1440p"], - "passes": [1, 2], "approxHours": 240 } } diff --git a/client/test_videos.py b/client/test_videos.py deleted file mode 100644 index aafcbe5..0000000 --- a/client/test_videos.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Test video catalog, download, and caching for multi-content benchmarks (Sprint 5).""" - -import hashlib -import os -import sys -from typing import Dict, List, Optional, Tuple - -from . import config - -CONTENT_CLASSES: List[str] = [ - "mixed", - "talkingHead", - "action", - "animation", - "screen", - "nature", - "gaming", -] - -CONTENT_CLASS_LABELS: Dict[str, str] = { - "mixed": "Mixed (Original)", - "talkingHead": "Talking Head", - "action": "Action / Sports", - "animation": "Animation / Cartoon", - "screen": "Screen Recording", - "nature": "Nature / Documentary", - "gaming": "Gaming", -} - -RESOLUTION_PRESETS: Dict[str, Tuple[int, int]] = { - "480p": (854, 480), - "720p": (1280, 720), - "1080p": (1920, 1080), - "1440p": (2560, 1440), - "4k": (3840, 2160), -} - -RESOLUTION_ORDER: List[str] = ["480p", "720p", "1080p", "1440p", "4k"] - -RELEASES_BASE_URL = ( - "https://github.com/oliverdougherC/Encoding_Database" - "/releases/download/test-clips-v1" -) - -TEST_VIDEO_CATALOG: List[Dict[str, object]] = [ - { - "name": "sample.mp4", - "contentClass": "mixed", - "resolution": "1080p", - "duration": 20.0, - "sha256": config.SAMPLE_VIDEO_SHA256, - "sizeBytes": config.SAMPLE_VIDEO_SIZE_BYTES, - }, - { - "name": "talking_head_1080p.mp4", - "contentClass": "talkingHead", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, - { - "name": "action_1080p.mp4", - "contentClass": "action", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, - { - "name": "animation_1080p.mp4", - "contentClass": "animation", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, - { - "name": "screen_1080p.mp4", - "contentClass": "screen", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, - { - "name": "nature_1080p.mp4", - "contentClass": "nature", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, - { - "name": "gaming_1080p.mp4", - "contentClass": "gaming", - "resolution": "1080p", - "duration": 15.0, - "sha256": "", - "sizeBytes": 0, - }, -] - - -def get_cache_dir() -> str: - """Return the directory used to cache downloaded test clips.""" - home = os.path.expanduser("~") - cache_dir = os.path.join(home, ".encodingdb", "test-clips") - os.makedirs(cache_dir, mode=0o755, exist_ok=True) - return cache_dir - - -def _sha256_file(path: str) -> str: - hasher = hashlib.sha256() - with open(path, "rb") as f: - while True: - chunk = f.read(1024 * 1024) - if not chunk: - break - hasher.update(chunk) - return hasher.hexdigest() - - -def download_test_video(video_meta: Dict[str, object], force: bool = False) -> Optional[str]: - """Download a test video from GitHub Releases and verify its SHA256. - - Returns the local file path on success, or None on failure. - """ - name = str(video_meta["name"]) - sha256 = str(video_meta.get("sha256") or "") - size_bytes = int(video_meta.get("sizeBytes") or 0) - - if name == "sample.mp4": - from .ffmpeg import get_default_sample_path - local = get_default_sample_path() - if local and os.path.exists(local): - return local - - cache_dir = get_cache_dir() - local_path = os.path.join(cache_dir, name) - - if not force and os.path.exists(local_path): - if sha256 and _sha256_file(local_path) == sha256.lower(): - return local_path - if not sha256 and os.path.getsize(local_path) > 0: - return local_path - - url = f"{RELEASES_BASE_URL}/{name}" - print(f"Downloading test clip: {name}...") - - try: - import requests # lazy import - resp = requests.get(url, stream=True, timeout=120, verify=config.REQUESTS_VERIFY) - resp.raise_for_status() - - total = int(resp.headers.get("content-length", 0)) - downloaded = 0 - tmp_path = local_path + ".tmp" - - with open(tmp_path, "wb") as f: - for chunk in resp.iter_content(chunk_size=256 * 1024): - f.write(chunk) - downloaded += len(chunk) - if total > 0: - pct = (downloaded / total) * 100 - print(f"\r {downloaded / (1024*1024):.1f} / {total / (1024*1024):.1f} MB ({pct:.0f}%)", end="", flush=True) - - print() - - if sha256: - actual = _sha256_file(tmp_path) - if actual != sha256.lower(): - print(f" SHA256 mismatch for {name}: expected {sha256[:16]}..., got {actual[:16]}...", file=sys.stderr) - os.remove(tmp_path) - return None - - if size_bytes > 0 and os.path.getsize(tmp_path) != size_bytes: - print(f" Size mismatch for {name}: expected {size_bytes}, got {os.path.getsize(tmp_path)}", file=sys.stderr) - os.remove(tmp_path) - return None - - os.replace(tmp_path, local_path) - print(f" Cached: {local_path}") - return local_path - - except Exception as e: - print(f" Failed to download {name}: {e}", file=sys.stderr) - return None - - -def ensure_test_videos( - content_classes: Optional[List[str]] = None, - resolutions: Optional[List[str]] = None, -) -> Dict[str, str]: - """Download and cache all required test clips. - - Returns a dict mapping "contentClass:resolution" keys to local file paths. - """ - wanted_cc = set(content_classes) if content_classes else {"mixed"} - wanted_res = set(resolutions) if resolutions else {"1080p"} - result: Dict[str, str] = {} - - for meta in TEST_VIDEO_CATALOG: - cc = str(meta["contentClass"]) - res = str(meta["resolution"]) - if cc not in wanted_cc: - continue - if res not in wanted_res: - continue - - path = download_test_video(meta) - if path: - result[f"{cc}:{res}"] = path - - return result - - -def get_video_path(content_class: str, resolution: str = "1080p") -> Optional[str]: - """Return the cached path for a specific test video, or None.""" - cache_key = f"{content_class}:{resolution}" - - if content_class == "mixed" and resolution == "1080p": - from .ffmpeg import get_default_sample_path - local = get_default_sample_path() - if local and os.path.exists(local): - return local - - cache_dir = get_cache_dir() - for meta in TEST_VIDEO_CATALOG: - if str(meta["contentClass"]) == content_class and str(meta["resolution"]) == resolution: - name = str(meta["name"]) - local_path = os.path.join(cache_dir, name) - if os.path.exists(local_path): - return local_path - - return None - - -def available_content_classes() -> List[str]: - """Return content classes that have at least one cached test video locally.""" - avail: List[str] = [] - for cc in CONTENT_CLASSES: - for meta in TEST_VIDEO_CATALOG: - if str(meta["contentClass"]) == cc: - path = get_video_path(cc, str(meta["resolution"])) - if path: - avail.append(cc) - break - return avail diff --git a/client/ui.py b/client/ui.py index 1e93710..2d2f820 100644 --- a/client/ui.py +++ b/client/ui.py @@ -721,15 +721,12 @@ def _test_info_lines(self, compact: bool = False) -> List[str]: enc = str(self._task_info.get("encoder") or "-") preset = str(self._task_info.get("preset") or "-") crf = self._task_info.get("crf") - passes = self._task_info.get("passes") - cc = str(self._task_info.get("contentClass") or "mixed") - res = str(self._task_info.get("resolution") or "1080p") 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]Content:[/muted] {cc} [muted]Resolution:[/muted] {res} [muted]Passes:[/muted] {passes if passes is not None else 1}", + 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}", ] 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.yml b/docker-compose.yml index 5c57abc..5106ec3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,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 6851424..3f9c0d8 100644 --- a/frontend/app/analytics/page.tsx +++ b/frontend/app/analytics/page.tsx @@ -7,9 +7,6 @@ import SsimHistogram from "../components/SsimHistogram"; import PsnrHistogram from "../components/PsnrHistogram"; import ScatterSsimVmaf from "../components/ScatterSsimVmaf"; import RateDistortionChart from "../components/RateDistortionChart"; -import ResolutionComparisonChart from "../components/ResolutionComparisonChart"; -import ContentRadarChart from "../components/ContentRadarChart"; -import PassSpeedComparison from "../components/PassSpeedComparison"; import LazyChart from "../components/LazyChart"; import { fetchBenchmarks } from "../lib/fetchBenchmarks"; import styles from "./page.module.css"; @@ -47,10 +44,7 @@ export default async function AnalyticsPage() { - - -
); diff --git a/frontend/app/components/BenchmarksTable.tsx b/frontend/app/components/BenchmarksTable.tsx index 1e726ef..725510b 100644 --- a/frontend/app/components/BenchmarksTable.tsx +++ b/frontend/app/components/BenchmarksTable.tsx @@ -36,16 +36,6 @@ type EnrichedBenchmark = PerRowMetrics & { _plScore: number; }; -const CONTENT_CLASS_LABELS: Record = { - mixed: "Mixed (Original)", - talkingHead: "Talking Head", - action: "Action / Sports", - animation: "Animation / Cartoon", - screen: "Screen Recording", - nature: "Nature / Documentary", - gaming: "Gaming", -}; - type SortKey = "cpuModel" | "gpuModel" | "codec" | "crf" | "preset" | "_plScore"; export default function BenchmarksTable({ initialData }: { initialData: Benchmark[] }) { @@ -66,10 +56,6 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar const d = searchParams.get("dir"); return d === "asc" ? "asc" : "desc"; }); - // Multi-content filters (Sprint 5) - const [contentClassFilter, setContentClassFilter] = useState(() => searchParams.get("cc") || ""); - const [resolutionFilter, setResolutionFilter] = useState(() => searchParams.get("res") || ""); - const [passesFilter, setPassesFilter] = useState(() => searchParams.get("passes") || ""); // Encoder type filters const [softwareOnly, setSoftwareOnly] = useState(() => searchParams.get("sw") === "1"); const [hardwareOnly, setHardwareOnly] = useState(() => searchParams.get("hw") === "1"); @@ -88,9 +74,6 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar if (presetFilter) params.set("preset", presetFilter); if (sortKey !== "_plScore") params.set("sort", sortKey); if (sortDir !== "desc") params.set("dir", sortDir); - if (contentClassFilter) params.set("cc", contentClassFilter); - if (resolutionFilter) params.set("res", resolutionFilter); - if (passesFilter) params.set("passes", passesFilter); if (softwareOnly) params.set("sw", "1"); if (hardwareOnly) params.set("hw", "1"); const qs = params.toString(); @@ -98,7 +81,7 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar window.history.replaceState(null, "", qs ? `${base}?${qs}` : base); }, 300); return () => { if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); }; - }, [cpuFilter, gpuFilter, codecFilter, presetFilter, sortKey, sortDir, contentClassFilter, resolutionFilter, passesFilter, softwareOnly, hardwareOnly]); + }, [cpuFilter, gpuFilter, codecFilter, presetFilter, sortKey, sortDir, softwareOnly, hardwareOnly]); // Core PL Score v6 weights (sum normalized to 1.0) const [wQuality, setWQuality] = useState(1 / 3); @@ -137,16 +120,10 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar // Reset page when any filter changes useEffect(() => { setPage(0); - }, [cpuFilter, gpuFilter, codecFilter, presetFilter, contentClassFilter, resolutionFilter, passesFilter, softwareOnly, hardwareOnly]); + }, [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 contentClasses = useMemo(() => Array.from(new Set(initialData.map(d => d.contentClass ?? "mixed"))).sort(), [initialData]); - const resolutions = useMemo(() => { - const order = ["480p", "720p", "1080p", "1440p", "4k"]; - const set = new Set(initialData.map(d => d.resolution ?? "1080p")); - return order.filter(r => set.has(r)); - }, [initialData]); const filteredPresets = useMemo(() => { if (!codecFilter) return presets; const lower = codecFilter.toLowerCase(); @@ -170,14 +147,11 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar if (gpu && !(row.gpuModel ?? "").toLowerCase().includes(gpu)) return false; if (codecFilter && !row.codec.toLowerCase().includes(codecFilter.toLowerCase())) return false; if (presetFilter && row.preset !== presetFilter) return false; - if (contentClassFilter && (row.contentClass ?? "mixed") !== contentClassFilter) return false; - if (resolutionFilter && (row.resolution ?? "1080p") !== resolutionFilter) return false; - if (passesFilter && String(row.passes ?? 1) !== passesFilter) return false; if (softwareOnly && !hardwareOnly) return !row._isHardware; if (hardwareOnly && !softwareOnly) return row._isHardware; return true; }); - }, [dataWithHwClass, cpuFilter, gpuFilter, codecFilter, presetFilter, contentClassFilter, resolutionFilter, passesFilter, softwareOnly, hardwareOnly]); + }, [dataWithHwClass, cpuFilter, gpuFilter, codecFilter, presetFilter, softwareOnly, hardwareOnly]); const plContext = useMemo(() => createPlScoreContext(filtered), [filtered]); @@ -343,19 +317,6 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar
- - -
diff --git a/frontend/app/components/ContentRadarChart.tsx b/frontend/app/components/ContentRadarChart.tsx deleted file mode 100644 index 165f0cd..0000000 --- a/frontend/app/components/ContentRadarChart.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import type { Benchmark } from "./BenchmarksTable"; -import { useChartTheme } from "../lib/useChartTheme"; -import { escapeHtml } from "../lib/escapeHtml"; -import EChart from "./EChart"; - -const CONTENT_CLASSES = ["mixed", "talkingHead", "action", "animation", "screen", "nature", "gaming"] as const; -const CONTENT_LABELS: Record = { - mixed: "Mixed", - talkingHead: "Talking Head", - action: "Action", - animation: "Animation", - screen: "Screen", - nature: "Nature", - gaming: "Gaming", -}; -const COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; -const MAX_SELECTOR = 10; -const MAX_SELECTED = 6; - -type CodecData = { codec: string; samples: number; scores: Record }; - -function computeCodecScores(data: Benchmark[]): CodecData[] { - const map = new Map>(); - for (const row of data) { - if (typeof row.vmaf !== "number") continue; - const cc = row.contentClass ?? "mixed"; - if (!map.has(row.codec)) map.set(row.codec, new Map()); - const ccMap = map.get(row.codec)!; - if (!ccMap.has(cc)) ccMap.set(cc, { vmafSum: 0, count: 0 }); - const e = ccMap.get(cc)!; - e.vmafSum += row.vmaf; - e.count += 1; - } - return Array.from(map.entries()) - .map(([codec, ccMap]) => { - const scores: Record = {}; - let samples = 0; - for (const cc of CONTENT_CLASSES) { - const e = ccMap.get(cc); - scores[cc] = e && e.count > 0 ? e.vmafSum / e.count : 0; - if (e) samples += e.count; - } - return { codec, samples, scores }; - }) - .sort((a, b) => b.samples - a.samples || a.codec.localeCompare(b.codec)); -} - -export default function ContentRadarChart({ data, title = "Encoder Quality by Content Class" }: { data: Benchmark[]; title?: string }) { - const t = useChartTheme(); - const allCodecs = useMemo(() => computeCodecScores(data), [data]); - const options = useMemo(() => allCodecs.slice(0, MAX_SELECTOR), [allCodecs]); - const [selected, setSelected] = useState>(() => new Set(allCodecs.slice(0, 4).map((d) => d.codec))); - - useEffect(() => { - if (options.length === 0) return; - setSelected((prev) => { - const allowed = new Set(options.map((d) => d.codec)); - const next = new Set(Array.from(prev).filter((c) => allowed.has(c))); - if (next.size === 0) options.slice(0, 4).forEach((d) => next.add(d.codec)); - return next.size > MAX_SELECTED ? new Set(Array.from(next).slice(0, MAX_SELECTED)) : next; - }); - }, [options]); - - const activeClasses = useMemo(() => { - const seen = new Set(data.map((r) => r.contentClass ?? "mixed")); - return CONTENT_CLASSES.filter((cc) => seen.has(cc)); - }, [data]); - - const colorByCodec = useMemo( - () => new Map(options.map((entry, i) => [entry.codec, COLORS[i % COLORS.length]])), - [options], - ); - - const selectedData = options.filter((d) => selected.has(d.codec)); - - const option = useMemo(() => ({ - 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 = activeClasses - .map((cc, i) => `${CONTENT_LABELS[cc] ?? cc}: ${(params.value[i] || 0).toFixed(1)}`) - .join("
"); - return `${escapeHtml(params.name)}
${lines}`; - }, - }, - radar: { - indicator: activeClasses.map((cc) => ({ name: CONTENT_LABELS[cc] ?? cc, max: 100 })), - splitLine: { lineStyle: { color: t.border } }, - axisLine: { lineStyle: { color: t.border } }, - splitArea: { show: false }, - axisName: { color: t.fg, fontSize: 11 }, - center: ["50%", "50%"], - radius: "68%", - }, - series: [ - { - type: "radar", - data: selectedData.map((cd) => ({ - name: cd.codec, - value: activeClasses.map((cc) => cd.scores[cc] || 0), - lineStyle: { color: colorByCodec.get(cd.codec) ?? COLORS[0], width: 2 }, - areaStyle: { color: colorByCodec.get(cd.codec) ?? COLORS[0], opacity: 0.12 }, - itemStyle: { color: colorByCodec.get(cd.codec) ?? COLORS[0] }, - symbol: "circle", - symbolSize: 4, - })), - }, - ], - }), [selectedData, activeClasses, colorByCodec, t]); - - if (allCodecs.length === 0 || activeClasses.length < 3) return null; - - const toggle = (codec: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(codec)) { next.delete(codec); } - else if (next.size < MAX_SELECTED) { next.add(codec); } - return next; - }); - }; - - return ( -
-
{title}
-
- {options.map((entry) => { - const isOn = selected.has(entry.codec); - const disabled = !isOn && selected.size >= MAX_SELECTED; - const color = colorByCodec.get(entry.codec) ?? COLORS[0]; - return ( - - ); - })} -
-
-
- ); -} diff --git a/frontend/app/components/PassSpeedComparison.tsx b/frontend/app/components/PassSpeedComparison.tsx deleted file mode 100644 index 356e5aa..0000000 --- a/frontend/app/components/PassSpeedComparison.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import type { Benchmark } from "./BenchmarksTable"; -import { useChartTheme } from "../lib/useChartTheme"; -import EChart from "./EChart"; - -const MAX_BARS = 8; - -function shortCodec(codec: string): string { - if (codec.length <= 14) return codec; - return `${codec.slice(0, 11)}...`; -} - -export default function PassSpeedComparison({ data, title = "1-Pass vs 2-Pass Encoding Speed" }: { data: Benchmark[]; title?: string }) { - const t = useChartTheme(); - - const { visible, hasTwoPass } = useMemo(() => { - const map = new Map(); - for (const row of data) { - if (!map.has(row.codec)) map.set(row.codec, { fps1Sum: 0, fps1N: 0, fps2Sum: 0, fps2N: 0 }); - const e = map.get(row.codec)!; - if ((row.passes ?? 1) === 2) { e.fps2Sum += row.fps; e.fps2N += 1; } - else { e.fps1Sum += row.fps; e.fps1N += 1; } - } - const all = Array.from(map.entries()) - .map(([codec, e]) => ({ - codec, - fps1: e.fps1N > 0 ? e.fps1Sum / e.fps1N : 0, - fps2: e.fps2N > 0 ? e.fps2Sum / e.fps2N : 0, - })) - .filter((d) => d.fps1 > 0 || d.fps2 > 0) - .sort((a, b) => Math.max(b.fps1, b.fps2) - Math.max(a.fps1, a.fps2)); - return { visible: all.slice(0, MAX_BARS), hasTwoPass: all.some((d) => d.fps2 > 0) }; - }, [data]); - - const option = useMemo(() => ({ - backgroundColor: "transparent", - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" as const }, - backgroundColor: t.surface, - borderColor: t.border, - textStyle: { color: t.fg }, - formatter: (params: { seriesName: string; value: number }[]) => - params.filter((p) => p.value > 0).map((p) => `${p.seriesName}: ${p.value.toFixed(1)} FPS`).join("
"), - }, - legend: { - data: ["1-pass (CRF)", "2-pass (CBR/VBR)"], - textStyle: { color: t.fg, fontSize: 11 }, - top: 4, - }, - grid: { left: 52, right: 12, top: 32, bottom: 32, containLabel: false }, - xAxis: { - type: "category", - data: visible.map((d) => shortCodec(d.codec)), - axisLine: { lineStyle: { color: t.border } }, - axisTick: { lineStyle: { color: t.border } }, - axisLabel: { color: t.fg, fontSize: 11 }, - }, - yAxis: { - type: "value", - name: "FPS", - nameTextStyle: { color: t.muted, fontSize: 11 }, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { color: t.muted, fontSize: 11 }, - splitLine: { lineStyle: { color: t.border } }, - }, - series: [ - { - name: "1-pass (CRF)", - type: "bar", - data: visible.map((d) => d.fps1), - itemStyle: { color: "#6C8FD5", borderRadius: [3, 3, 0, 0] }, - barMaxWidth: 40, - }, - { - name: "2-pass (CBR/VBR)", - type: "bar", - data: visible.map((d) => d.fps2), - itemStyle: { color: "#d4a843", borderRadius: [3, 3, 0, 0] }, - barMaxWidth: 40, - }, - ], - }), [visible, t]); - - if (visible.length === 0 || !hasTwoPass) return null; - - return ( -
-
{title}
-
-
- ); -} diff --git a/frontend/app/components/ResolutionComparisonChart.tsx b/frontend/app/components/ResolutionComparisonChart.tsx deleted file mode 100644 index 7abbe29..0000000 --- a/frontend/app/components/ResolutionComparisonChart.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import type { Benchmark } from "./BenchmarksTable"; -import { useChartTheme } from "../lib/useChartTheme"; -import { escapeHtml } from "../lib/escapeHtml"; -import EChart from "./EChart"; - -const RESOLUTION_ORDER = ["480p", "720p", "1080p", "1440p", "4k"]; -const COLORS = ["#6C8FD5", "#52b788", "#9693CC", "#d4a843", "#e07a5f", "#8aabea"]; -const MAX_SELECTED = 4; -const MAX_SELECTOR = 12; - -type ResolutionFps = { codec: string; fpsPerRes: Record; avgFps: number; samples: number }; - -function computeResolutionFps(data: Benchmark[]): ResolutionFps[] { - const byCodecAndRes = new Map>(); - for (const row of data) { - const res = row.resolution ?? "1080p"; - if (!byCodecAndRes.has(row.codec)) byCodecAndRes.set(row.codec, new Map()); - const resMap = byCodecAndRes.get(row.codec)!; - if (!resMap.has(res)) resMap.set(res, { fpsSum: 0, count: 0 }); - const e = resMap.get(res)!; - e.fpsSum += row.fps; - e.count += 1; - } - return Array.from(byCodecAndRes.entries()) - .map(([codec, resMap]) => { - const fpsPerRes: Record = {}; - let totalFps = 0, totalN = 0; - for (const res of RESOLUTION_ORDER) { - const e = resMap.get(res); - const avg = e && e.count > 0 ? e.fpsSum / e.count : 0; - fpsPerRes[res] = avg; - if (e && e.count > 0) { totalFps += e.fpsSum; totalN += e.count; } - } - return { codec, fpsPerRes, avgFps: totalN > 0 ? totalFps / totalN : 0, samples: totalN }; - }) - .sort((a, b) => b.avgFps - a.avgFps || a.codec.localeCompare(b.codec)); -} - -export default function ResolutionComparisonChart({ data, title = "FPS by Resolution per Codec" }: { data: Benchmark[]; title?: string }) { - const t = useChartTheme(); - const allCodecs = useMemo(() => computeResolutionFps(data), [data]); - const selectorOptions = useMemo(() => allCodecs.slice(0, Math.max(MAX_SELECTOR, MAX_SELECTED)), [allCodecs]); - const [selectedCodecs, setSelectedCodecs] = useState>(new Set()); - const codecColorMap = useMemo( - () => new Map(selectorOptions.map((entry, i) => [entry.codec, COLORS[i % COLORS.length]])), - [selectorOptions], - ); - - const activeResolutions = useMemo(() => { - const seen = new Set(data.map((r) => r.resolution ?? "1080p")); - return RESOLUTION_ORDER.filter((r) => seen.has(r)); - }, [data]); - - useEffect(() => { - if (selectorOptions.length === 0) return; - setSelectedCodecs((prev) => { - const names = selectorOptions.map((d) => d.codec); - const next = new Set(Array.from(prev).filter((c) => names.includes(c))); - return next.size > 0 ? next : new Set(names.slice(0, MAX_SELECTED)); - }); - }, [selectorOptions]); - - const shown = selectorOptions.filter((d) => selectedCodecs.has(d.codec)); - const forChart = shown.length > 0 ? shown : selectorOptions.slice(0, 1); - - const option = useMemo(() => ({ - backgroundColor: "transparent", - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" as const }, - backgroundColor: t.surface, - borderColor: t.border, - textStyle: { color: t.fg }, - formatter: (params: { seriesName: string; value: number }[]) => - params - .filter((p) => p.value > 0) - .map((p) => `${escapeHtml(p.seriesName)}: ${p.value.toFixed(1)} FPS`) - .join("
"), - }, - grid: { left: 52, right: 12, top: 8, bottom: 32, containLabel: false }, - xAxis: { - type: "category", - data: activeResolutions, - axisLine: { lineStyle: { color: t.border } }, - axisTick: { lineStyle: { color: t.border } }, - axisLabel: { color: t.fg, fontSize: 11 }, - }, - yAxis: { - type: "value", - name: "FPS", - nameTextStyle: { color: t.muted, fontSize: 11 }, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { color: t.muted, fontSize: 11 }, - splitLine: { lineStyle: { color: t.border } }, - }, - series: forChart.map((entry) => ({ - name: entry.codec, - type: "bar", - data: activeResolutions.map((res) => entry.fpsPerRes[res] || 0), - itemStyle: { color: codecColorMap.get(entry.codec) ?? COLORS[0], borderRadius: [3, 3, 0, 0] }, - barMaxWidth: 40, - })), - }), [activeResolutions, codecColorMap, forChart, t]); - - if (allCodecs.length === 0 || activeResolutions.length < 2) return null; - - const toggle = (codec: string) => { - setSelectedCodecs((prev) => { - const next = new Set(prev); - if (next.has(codec)) next.delete(codec); - else if (next.size < MAX_SELECTED) next.add(codec); - return next; - }); - }; - - return ( -
-
{title}
-
- {selectorOptions.map((entry) => { - const isOn = selectedCodecs.has(entry.codec); - const disabled = !isOn && selectedCodecs.size >= MAX_SELECTED; - const color = codecColorMap.get(entry.codec) ?? COLORS[0]; - return ( - - ); - })} -
-
-
- ); -} diff --git a/frontend/app/lib/fetchBenchmarksClient.ts b/frontend/app/lib/fetchBenchmarksClient.ts deleted file mode 100644 index 9dca697..0000000 --- a/frontend/app/lib/fetchBenchmarksClient.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Benchmark } from "./types"; - -/** Client-side fetch with filter params, returns data + total count. Safe to use from "use client" components. */ -export async function fetchFilteredBenchmarks( - params: Record, -): Promise<{ data: Benchmark[]; total: number }> { - const qs = new URLSearchParams(params).toString(); - const res = await fetch(`/api/query?${qs}`, { signal: AbortSignal.timeout(10000) }); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); - const total = Number(res.headers.get("X-Total-Count") ?? "0"); - const data: Benchmark[] = await res.json(); - return { data, total }; -} diff --git a/frontend/app/lib/types.ts b/frontend/app/lib/types.ts index 959a2c1..a809a83 100644 --- a/frontend/app/lib/types.ts +++ b/frontend/app/lib/types.ts @@ -25,9 +25,6 @@ export type Benchmark = { vmafSamples?: number; ssimSamples?: number; psnrSamples?: number; - contentClass?: string | null; - resolution?: string | null; - passes?: number | null; gpuUtilAvg?: number | null; gpuPowerAvgW?: number | null; gpuMemPeakMB?: number | null; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs deleted file mode 100644 index 719cea2..0000000 --- a/frontend/eslint.config.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), - { - ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ], - }, -]; - -export default eslintConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2155303..826a017 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,32 +10,17 @@ "dependencies": { "@tanstack/react-virtual": "^3.13.18", "echarts": "^6.0.0", - "next": "^15.5.12", + "next": "^16.1.6", "react": "19.1.0", "react-dom": "19.1.0" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "15.5.4", "typescript": "^5" } }, - "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", @@ -46,210 +31,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.2", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -678,39 +459,16 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@next/env": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", - "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, - "node_modules/@next/eslint-plugin-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.4.tgz", - "integrity": "sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } - }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", - "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "cpu": [ "arm64" ], @@ -724,9 +482,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", - "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "cpu": [ "x64" ], @@ -740,9 +498,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", - "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "cpu": [ "arm64" ], @@ -756,9 +514,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", - "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "cpu": [ "arm64" ], @@ -772,9 +530,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", - "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "cpu": [ "x64" ], @@ -788,9 +546,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", - "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "cpu": [ "x64" ], @@ -804,9 +562,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", - "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "cpu": [ "arm64" ], @@ -820,9 +578,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", - "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "cpu": [ "x64" ], @@ -835,68 +593,6 @@ "node": ">= 10" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -933,38 +629,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", @@ -995,4196 +659,303 @@ "@types/react": "^19.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", - "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/type-utils": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.45.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=6.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", - "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", - "dev": true, - "license": "MIT", + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "tslib": "2.3.0", + "zrender": "6.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", - "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", - "dev": true, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.45.0", - "@typescript-eslint/types": "^8.45.0", - "debug": "^4.3.4" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", - "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", - "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", - "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", - "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", - "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.45.0", - "@typescript-eslint/tsconfig-utils": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", - "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.45.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001746", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", - "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/echarts": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", - "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "2.3.0", - "zrender": "6.0.0" - } - }, - "node_modules/echarts/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.4.tgz", - "integrity": "sha512-BzgVVuT3kfJes8i2GHenC1SRJ+W3BTML11lAOYFOOPzrk2xp66jBOAGEFRw+3LkYCln5UzvFsLhojrshb5Zfaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "15.5.4", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/next": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", - "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.12", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.12", - "@next/swc-darwin-x64": "15.5.12", - "@next/swc-linux-arm64-gnu": "15.5.12", - "@next/swc-linux-arm64-musl": "15.5.12", - "@next/swc-linux-x64-gnu": "15.5.12", - "@next/swc-linux-x64-musl": "15.5.12", - "@next/swc-win32-arm64-msvc": "15.5.12", - "@next/swc-win32-x64-msvc": "15.5.12", - "sharp": "^0.34.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "bin": { + "next": "dist/bin/next" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.9.0" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" }, "peerDependenciesMeta": { - "picomatch": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { "optional": true } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, "engines": { - "node": ">=8.0" + "node": "^10 || ^12 || >=14" } }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" + "node": ">=0.10.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "scheduler": "^0.26.0" }, - "engines": { - "node": ">= 0.8.0" + "peerDependencies": { + "react": "^19.1.0" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" + "node": ">=10" } }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" }, "engines": { - "node": ">= 0.4" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "client-only": "0.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5199,25 +970,6 @@ "node": ">=14.17" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5225,179 +977,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zrender": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 35cd599..3326055 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,22 +6,20 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "typecheck": "tsc --noEmit", + "lint": "npm run typecheck" }, "dependencies": { "@tanstack/react-virtual": "^3.13.18", "echarts": "^6.0.0", - "next": "^15.5.12", + "next": "^16.1.6", "react": "19.1.0", "react-dom": "19.1.0" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "15.5.4", "typescript": "^5" } } diff --git a/frontend/public/file.svg b/frontend/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/frontend/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d8b9323..e7ff3a2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/scripts/NUKE_DATA/NUKE_DATA.sh b/scripts/NUKE_DATA/NUKE_DATA.sh deleted file mode 100644 index 9c7fcff..0000000 --- a/scripts/NUKE_DATA/NUKE_DATA.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec db psql -U app -d benchmarks -c 'TRUNCATE TABLE "Benchmark" RESTART IDENTITY CASCADE;' \ No newline at end of file diff --git a/scripts/api_hardening_test.sh b/scripts/api_hardening_test.sh deleted file mode 100755 index e3329b7..0000000 --- a/scripts/api_hardening_test.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Resolve repo root relative to this script (portable for CI and local) -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)" -COMPOSE="docker compose -f \"$ROOT_DIR/docker-compose.prod.yml\"" - -# If API_BASE_URL is provided (e.g., in CI), test against it and avoid local stack control -BASE_URL="${API_BASE_URL:-http://localhost:3001}" -CAN_CONTROL_STACK=1 -if [[ -n "${API_BASE_URL:-}" ]]; then - CAN_CONTROL_STACK=0 -fi -# Allow mutation tests (POST /submit, outage) on remote only when explicitly opted in -ALLOW_MUTATION_REMOTE="${ALLOW_MUTATION_REMOTE:-0}" - -pass() { echo -e "[PASS] $1"; } -fail() { echo -e "[FAIL] $1"; exit 1; } - -retry_curl_json() { - local path="$1"; shift - for i in {1..90}; do - if curl -sf "$BASE_URL$path" >/dev/null; then return 0; fi - sleep 1 - done - return 1 -} - -echo "[prep] Ensuring stack is up or reachable" -if [[ "$CAN_CONTROL_STACK" -eq 1 ]]; then - eval "$COMPOSE up -d >/dev/null" -else - echo "[prep] Using remote API_BASE_URL=$BASE_URL; skipping docker compose up" -fi - -echo "[wait] Waiting for readiness" -if ! retry_curl_json /health/ready; then - if [[ "$CAN_CONTROL_STACK" -eq 1 ]]; then - eval "$COMPOSE logs server | tail -n 200" || true - fi - fail "Server not ready" -fi - -echo "[check] Health endpoints" -curl -sf "$BASE_URL/health/live" >/dev/null || fail "live" -curl -sf "$BASE_URL/health/ready" >/dev/null || fail "ready" -curl -sf "$BASE_URL/health" >/dev/null || fail "health" -pass "health endpoints" - -if [[ "$CAN_CONTROL_STACK" -eq 1 || "$ALLOW_MUTATION_REMOTE" == "1" ]]; then - echo "[check] Validation and coercion" - code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/submit" -H "Content-Type: application/json" -d '{}') - [[ "$code" == "400" ]] || fail "invalid payload should 400" - code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/submit" -H "Content-Type: application/json" -d '{"cpuModel":"CPU","gpuModel":null,"ramGB":"16","os":"macOS","codec":"libx264","preset":"fast","fps":"12.3","fileSizeBytes":"1000"}') - [[ "$code" == "201" || "$code" == "200" ]] || fail "coercion submit should 201/200" - pass "validation/coercion" -else - echo "[skip] Validation/coercion (no stack control and ALLOW_MUTATION_REMOTE!=1)" -fi - -# Public ingest: ensure no API key required and method guard works -echo "[check] Public ingest behavior" -code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/submit" -H "Content-Type: application/json" -d '{"cpuModel":"CPU","gpuModel":null,"ramGB":16,"os":"macOS","codec":"libx264","preset":"fast","fps":1,"fileSizeBytes":10240}') -[[ "$code" == "201" || "$code" == "200" || "$code" == "400" ]] || fail "public ingest should not 401" -code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE_URL/submit") -[[ "$code" == "405" ]] || fail "/submit should reject GET with 405" -pass "public ingest" - -echo "[check] CORS preflight" -if [[ "$CAN_CONTROL_STACK" -eq 1 ]]; then - out=$(curl -i -s -X OPTIONS "$BASE_URL/submit" -H 'Origin: http://localhost:3000' -H 'Access-Control-Request-Method: POST') - echo "$out" | grep -qi "Access-Control-Allow-Origin: http://localhost:3000" || fail "cors origin" - pass "cors preflight" -else - echo "[skip] CORS preflight (remote URL; origin rules may differ)" -fi - -if [[ "$CAN_CONTROL_STACK" -eq 1 || "$ALLOW_MUTATION_REMOTE" == "1" ]]; then - echo "[check] Body size limit" - tmpbig=$(mktemp) - python3 - <<'PY' > "$tmpbig" -print('{'+"\"cpuModel\":\"CPU\",\"ramGB\":16,\"os\":\"macOS\",\"codec\":\"libx264\",\"preset\":\"fast\",\"fps\":1,\"fileSizeBytes\":1,\"notes\":\"" + ('x'*2*1024*1024) + "\"}") -PY - code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/submit" -H "Content-Type: application/json" --data-binary @"$tmpbig") - rm -f "$tmpbig" - [[ "$code" == "413" || "$code" == "400" ]] || fail "body limit should reject large payload" - pass "body limit" -else - echo "[skip] Body size limit (no stack control and ALLOW_MUTATION_REMOTE!=1)" -fi - -echo "[check] Rate limiting" -codes=$(for i in {1..50}; do curl -s -o /dev/null -w "%{http_code} " "$BASE_URL/health"; done) -echo "$codes" | grep -q "429" && fail "health endpoints should be skipped from rate limit" -pass "rate limit skip for health" - -if [[ "$CAN_CONTROL_STACK" -eq 1 ]]; then - echo "[check] DB outage handling" - eval "$COMPOSE stop db >/dev/null" - sleep 1 - code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/health/ready") - [[ "$code" == "503" ]] || fail "ready should degrade when DB down" - eval "$COMPOSE start db >/dev/null" - retry_curl_json /health/ready || fail "ready should recover after DB up" - pass "db outage and recovery" -else - echo "[skip] DB outage handling (no stack control)" -fi - -if [[ "$CAN_CONTROL_STACK" -eq 1 ]]; then - echo "[check] Graceful shutdown" - eval "$COMPOSE stop server >/dev/null" || true - sleep 1 - eval "$COMPOSE start server >/dev/null" - retry_curl_json /health/ready || fail "server should recover after restart" - pass "graceful restart" -else - echo "[skip] Graceful restart (no stack control)" -fi - -echo "[check] Query endpoint returns data" -curl -sf "$BASE_URL/query" >/dev/null || fail "query" -pass "query" - -echo "ALL CHECKS PASSED" - diff --git a/scripts/build_linux_client.sh b/scripts/build_linux_client.sh deleted file mode 100755 index 206754c..0000000 --- a/scripts/build_linux_client.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build a Linux standalone client with PyInstaller -# Requirements: python3, pip, pyinstaller; ffmpeg/ffprobe in client/bin/linux - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" -CLIENT_DIR="$ROOT_DIR/client" -BUILD_DIR="$CLIENT_DIR/dist/linux" -BIN_SRC_DIR="$CLIENT_DIR/bin/linux" - -echo "[Linux] Preparing build directories..." -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR/bin/linux" - -echo "[Linux] Verifying ffmpeg/ffprobe binaries..." -if [[ ! -x "$BIN_SRC_DIR/ffmpeg" ]] || [[ ! -x "$BIN_SRC_DIR/ffprobe" ]]; then - echo "ERROR: Expected ffmpeg and ffprobe at $BIN_SRC_DIR" >&2 - exit 1 -fi - -echo "[Linux] Copying resources..." -cp -f "$BIN_SRC_DIR/ffmpeg" "$BUILD_DIR/bin/linux/" -cp -f "$BIN_SRC_DIR/ffprobe" "$BUILD_DIR/bin/linux/" -chmod +x "$BUILD_DIR/bin/linux/ffmpeg" "$BUILD_DIR/bin/linux/ffprobe" -cp -f "$ROOT_DIR/sample.mp4" "$BUILD_DIR/" || true -cp -f "$CLIENT_DIR/presets.json" "$BUILD_DIR/" || true - -echo "[Linux] Running PyInstaller..." -cd "$ROOT_DIR" -pyinstaller \ - --onefile \ - --name encodingdb-client-linux \ - --paths . \ - --add-data "$BUILD_DIR/bin/linux:bin/linux" \ - --add-data "$BUILD_DIR/sample.mp4:." \ - --add-data "$BUILD_DIR/presets.json:." \ - client/_pyinstaller_entry.py - -echo "[Linux] Moving artifact to $BUILD_DIR..." -mv -f "$ROOT_DIR/dist/encodingdb-client-linux" "$BUILD_DIR/encodingdb-client-linux" 2>/dev/null || true -mv -f "$ROOT_DIR/dist/encodingdb-client-linux"* "$BUILD_DIR/" 2>/dev/null || true - -echo "[Linux] Build complete: $BUILD_DIR" - diff --git a/scripts/build_macos_client.sh b/scripts/build_macos_client.sh index 9959c58..4fcfcc8 100755 --- a/scripts/build_macos_client.sh +++ b/scripts/build_macos_client.sh @@ -1,45 +1,79 @@ #!/usr/bin/env bash set -euo pipefail -# Build a macOS standalone client with PyInstaller -# Requirements: python3, pip, pyinstaller installed in a venv; ffmpeg/ffprobe binaries present under client/bin/mac/ - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" CLIENT_DIR="$ROOT_DIR/client" -BUILD_DIR="$CLIENT_DIR/dist/macos" -BIN_SRC_DIR="$CLIENT_DIR/bin/mac" +BIN_DIR="$CLIENT_DIR/bin/mac" +APP_NAME="encodingdb-client-macos" +ENTRYPOINT="$CLIENT_DIR/_pyinstaller_entry.py" +BUILD_ROOT="$ROOT_DIR/.build/clients/macos" +LEGACY_DIST_DIR="$CLIENT_DIR/dist/macos" +PYI_DIST_DIR="$BUILD_ROOT/dist" +PYI_WORK_DIR="$BUILD_ROOT/work" +PYI_SPEC_DIR="$BUILD_ROOT/spec" +OUTPUT_PATH="$ROOT_DIR/$APP_NAME" -echo "[macOS] Preparing build directories..." -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR/bin/mac" +log() { + echo "[macOS] $*" +} -echo "[macOS] Verifying ffmpeg/ffprobe binaries..." -if [[ ! -x "$BIN_SRC_DIR/ffmpeg" ]] || [[ ! -x "$BIN_SRC_DIR/ffprobe" ]]; then - echo "ERROR: Expected ffmpeg and ffprobe at $BIN_SRC_DIR" >&2 +die() { + echo "[macOS] ERROR: $*" >&2 exit 1 +} + +if [[ ! -x "$BIN_DIR/ffmpeg" ]] || [[ ! -x "$BIN_DIR/ffprobe" ]]; then + die "Expected executable ffmpeg and ffprobe at $BIN_DIR" +fi +if [[ ! -f "$ROOT_DIR/sample.mp4" ]]; then + die "Missing $ROOT_DIR/sample.mp4" +fi +if [[ ! -f "$CLIENT_DIR/presets.json" ]]; then + die "Missing $CLIENT_DIR/presets.json" +fi +if [[ ! -f "$ENTRYPOINT" ]]; then + die "Missing entrypoint: $ENTRYPOINT" fi -echo "[macOS] Copying resources..." -cp -f "$BIN_SRC_DIR/ffmpeg" "$BUILD_DIR/bin/mac/" -cp -f "$BIN_SRC_DIR/ffprobe" "$BUILD_DIR/bin/mac/" -chmod +x "$BUILD_DIR/bin/mac/ffmpeg" "$BUILD_DIR/bin/mac/ffprobe" -cp -f "$ROOT_DIR/sample.mp4" "$BUILD_DIR/" || true -cp -f "$CLIENT_DIR/presets.json" "$BUILD_DIR/" || true +if command -v pyinstaller >/dev/null 2>&1; then + PYI_CMD=(pyinstaller) +elif python3 -m PyInstaller --version >/dev/null 2>&1; then + PYI_CMD=(python3 -m PyInstaller) +else + die "PyInstaller not found. Install it with: python3 -m pip install pyinstaller" +fi + +log "Preparing output directory..." +rm -rf "$BUILD_ROOT" +rm -rf "$LEGACY_DIST_DIR" +rm -f "$OUTPUT_PATH" +rm -f "$ROOT_DIR/dist/$APP_NAME" +rm -rf "$ROOT_DIR/build/$APP_NAME" +mkdir -p "$PYI_DIST_DIR" "$PYI_WORK_DIR" "$PYI_SPEC_DIR" -echo "[macOS] Running PyInstaller..." +log "Running PyInstaller..." cd "$ROOT_DIR" -pyinstaller \ +"${PYI_CMD[@]}" \ + --clean \ --onefile \ - --name encodingdb-client-macos \ - --paths . \ - --add-data "$BUILD_DIR/bin/mac:bin/mac" \ - --add-data "$BUILD_DIR/sample.mp4:." \ - --add-data "$BUILD_DIR/presets.json:." \ - client/_pyinstaller_entry.py + --name "$APP_NAME" \ + --distpath "$PYI_DIST_DIR" \ + --workpath "$PYI_WORK_DIR" \ + --specpath "$PYI_SPEC_DIR" \ + --paths "$ROOT_DIR" \ + --add-data "$BIN_DIR/ffmpeg:bin/mac" \ + --add-data "$BIN_DIR/ffprobe:bin/mac" \ + --add-data "$ROOT_DIR/sample.mp4:." \ + --add-data "$CLIENT_DIR/presets.json:." \ + "$ENTRYPOINT" -echo "[macOS] Moving artifact to $BUILD_DIR..." -mv -f "$ROOT_DIR/dist/encodingdb-client-macos" "$BUILD_DIR/encodingdb-client-macos" 2>/dev/null || true -mv -f "$ROOT_DIR/dist/encodingdb-client-macos"* "$BUILD_DIR/" 2>/dev/null || true - -echo "[macOS] Build complete: $BUILD_DIR" +if [[ ! -f "$PYI_DIST_DIR/$APP_NAME" ]]; then + die "Build output not found at $PYI_DIST_DIR/$APP_NAME" +fi +log "Placing executable in repository root..." +mv -f "$PYI_DIST_DIR/$APP_NAME" "$OUTPUT_PATH" +chmod +x "$OUTPUT_PATH" || true +log "Build complete: $OUTPUT_PATH" +log "Hidden build artifacts: $BUILD_ROOT" diff --git a/scripts/build_windows_client.ps1 b/scripts/build_windows_client.ps1 deleted file mode 100644 index 9416465..0000000 --- a/scripts/build_windows_client.ps1 +++ /dev/null @@ -1,169 +0,0 @@ -# PowerShell script to build Windows standalone client with PyInstaller -# Run this from Windows PowerShell or PowerShell Core -# Requires: Windows Python with PyInstaller installed, and ffmpeg/ffprobe in client/bin/win - -param( - [switch]$Verbose, - [switch]$PauseOnExit -) - -# Enable strict error handling -$ErrorActionPreference = "Stop" - -# Get script directory and set up paths -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RootDir = Split-Path -Parent $ScriptDir -$ClientDir = Join-Path $RootDir "client" -$BuildDir = Join-Path $ClientDir "dist\windows" -$BinSrcDir = Join-Path $ClientDir "bin\win" -$VenvScriptsDir = Join-Path $RootDir ".myenv\Scripts" - -# Enable verbose output if requested -if ($Verbose) { - $VerbosePreference = "Continue" -} - -Write-Host "[Windows] Preparing build directories..." -ForegroundColor Green -# Remove existing build directory -if (Test-Path $BuildDir) { - Remove-Item -Path $BuildDir -Recurse -Force -} -# Create build directory structure -New-Item -Path $BuildDir -ItemType Directory -Force | Out-Null -New-Item -Path (Join-Path $BuildDir "bin\win") -ItemType Directory -Force | Out-Null - -# Set up log file -$LogFile = Join-Path $BuildDir "build.log" -Write-Host "[Windows] Build log will be saved to: $LogFile" -ForegroundColor Yellow - -Write-Host "[Windows] Verifying ffmpeg/ffprobe binaries..." -ForegroundColor Green -$FfmpegPath = Join-Path $BinSrcDir "ffmpeg.exe" -$FfprobePath = Join-Path $BinSrcDir "ffprobe.exe" - -if (-not (Test-Path $FfmpegPath) -or -not (Test-Path $FfprobePath)) { - Write-Error "ERROR: Expected ffmpeg.exe and ffprobe.exe at $BinSrcDir" - exit 1 -} - -Write-Host "[Windows] Copying resources..." -ForegroundColor Green -Copy-Item -Path $FfmpegPath -Destination (Join-Path $BuildDir "bin\win\ffmpeg.exe") -Force -Copy-Item -Path $FfprobePath -Destination (Join-Path $BuildDir "bin\win\ffprobe.exe") -Force - -# Copy sample video if it exists -$SampleVideo = Join-Path $RootDir "sample.mp4" -if (Test-Path $SampleVideo) { - Copy-Item -Path $SampleVideo -Destination $BuildDir -Force -} - -# Copy presets.json if it exists -$PresetsFile = Join-Path $ClientDir "presets.json" -if (Test-Path $PresetsFile) { - Copy-Item -Path $PresetsFile -Destination $BuildDir -Force -} - -Write-Host "[Windows] Running PyInstaller..." -ForegroundColor Green -Set-Location $ClientDir - -# Determine which Python/PyInstaller to use -$PyInstallerCmd = $null - -# Check for local venv PyInstaller first -$VenvPyInstaller = Join-Path $VenvScriptsDir "pyinstaller.exe" -if (Test-Path $VenvPyInstaller) { - Write-Host "[Windows] Using local venv PyInstaller: $VenvPyInstaller" -ForegroundColor Yellow - $PyInstallerCmd = $VenvPyInstaller -} -# Check for local venv Python -elseif (Test-Path (Join-Path $VenvScriptsDir "python.exe")) { - $VenvPython = Join-Path $VenvScriptsDir "python.exe" - Write-Host "[Windows] Using local venv Python: $VenvPython" -ForegroundColor Yellow - $PyInstallerCmd = @($VenvPython, "-m", "PyInstaller") -} -# Try Windows Python launcher -elseif (Get-Command py -ErrorAction SilentlyContinue) { - Write-Host "[Windows] Using Windows Python launcher: py -3" -ForegroundColor Yellow - $PyInstallerCmd = @("py", "-3", "-m", "PyInstaller") -} -# Try py.exe -elseif (Get-Command py.exe -ErrorAction SilentlyContinue) { - Write-Host "[Windows] Using Windows Python launcher: py.exe -3" -ForegroundColor Yellow - $PyInstallerCmd = @("py.exe", "-3", "-m", "PyInstaller") -} -# Try python.exe -elseif (Get-Command python.exe -ErrorAction SilentlyContinue) { - Write-Host "[Windows] Using python.exe" -ForegroundColor Yellow - $PyInstallerCmd = @("python.exe", "-m", "PyInstaller") -} -# Fallback to python -elseif (Get-Command python -ErrorAction SilentlyContinue) { - Write-Host "[Windows] Using python (fallback)" -ForegroundColor Yellow - $PyInstallerCmd = @("python", "-m", "PyInstaller") -} -else { - Write-Error "ERROR: No Python interpreter found. Please install Python or activate your virtual environment." - exit 1 -} - -# Verify PyInstaller is available -Write-Host "[Windows] Verifying PyInstaller is available..." -ForegroundColor Green -try { - if ($PyInstallerCmd -is [string]) { - & $PyInstallerCmd --version | Out-Null - } else { - & $PyInstallerCmd[0] $PyInstallerCmd[1..($PyInstallerCmd.Length-1)] --version | Out-Null - } -} catch { - Write-Error "ERROR: PyInstaller is not installed for this Python interpreter." - Write-Error " Install with: $($PyInstallerCmd -join ' ') -m pip install pyinstaller" - exit 3 -} - -# Run PyInstaller from project root so package imports resolve -Write-Host "[Windows] Building executable..." -ForegroundColor Green -Set-Location $RootDir -$PyInstallerArgs = @( - "--clean", - "--onefile", - "--name", "encodingdb-client-windows", - "--paths", ".", - "--add-data", "client\bin\win\ffmpeg.exe;bin\win", - "--add-data", "client\bin\win\ffprobe.exe;bin\win", - "--add-data", "sample.mp4;.", - "--add-data", "client\presets.json;.", - "client\_pyinstaller_entry.py" -) - -try { - if ($PyInstallerCmd -is [string]) { - & $PyInstallerCmd @PyInstallerArgs - } else { - & $PyInstallerCmd[0] $PyInstallerCmd[1..($PyInstallerCmd.Length-1)] @PyInstallerArgs - } -} catch { - Write-Error "ERROR: PyInstaller failed to build the executable." - Write-Error $_.Exception.Message - exit 2 -} - -Write-Host "[Windows] Moving artifact to $BuildDir..." -ForegroundColor Green -$ExePath = Join-Path $RootDir "dist\encodingdb-client-windows.exe" -$DirPath = Join-Path $RootDir "dist\encodingdb-client-windows" - -if (Test-Path $ExePath) { - Move-Item -Path $ExePath -Destination $BuildDir -Force - Write-Host "[Windows] Build complete: $BuildDir" -ForegroundColor Green -} elseif (Test-Path $DirPath) { - Move-Item -Path $DirPath -Destination $BuildDir -Force - Write-Host "[Windows] Build complete: $BuildDir" -ForegroundColor Green -} else { - Write-Error "ERROR: PyInstaller did not produce encodingdb-client-windows.exe." - Write-Error " Ensure Windows Python (py -3) is used and PyInstaller is installed." - exit 2 -} - -Write-Host "[Windows] Build log saved to: $LogFile" -ForegroundColor Yellow - -# Optional pause for double-click runs -if ($PauseOnExit) { - Read-Host "Press Enter to close..." -} diff --git a/scripts/build_windows_client.sh b/scripts/build_windows_client.sh old mode 100644 new mode 100755 index 6de6868..04ae307 --- a/scripts/build_windows_client.sh +++ b/scripts/build_windows_client.sh @@ -1,109 +1,104 @@ - #!/usr/bin/env bash -set -e -set -u -# Enable pipefail if supported (older shells may not support it) -set -o pipefail 2>/dev/null || true - -# MSYS2/Git Bash: ignore CR characters if the file has CRLF endings -export SHELLOPTS -set -o igncr 2>/dev/null || true +set -euo pipefail -# Build a Windows standalone client with PyInstaller. -# Run this from Windows PowerShell/CMD or from Git Bash/WSL that can invoke Windows Python (py launcher). -# Requires: Windows Python ("py" launcher) with PyInstaller installed, and ffmpeg/ffprobe in client/bin/win. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" CLIENT_DIR="$ROOT_DIR/client" -BUILD_DIR="$CLIENT_DIR/dist/windows" -BIN_SRC_DIR="$CLIENT_DIR/bin/win" -VENV_SCRIPTS_DIR="$ROOT_DIR/.myenv/Scripts" +BIN_DIR="$CLIENT_DIR/bin/win" +APP_NAME="encodingdb-client-windows" +ENTRYPOINT="$CLIENT_DIR/_pyinstaller_entry.py" +BUILD_ROOT="$ROOT_DIR/.build/clients/windows" +LEGACY_DIST_DIR="$CLIENT_DIR/dist/windows" +PYI_DIST_DIR="$BUILD_ROOT/dist" +PYI_WORK_DIR="$BUILD_ROOT/work" +PYI_SPEC_DIR="$BUILD_ROOT/spec" +OUTPUT_PATH="$ROOT_DIR/$APP_NAME.exe" # Optional: set VERBOSE=1 to enable shell tracing; set PAUSE_ON_EXIT=1 to pause at end if [[ "${VERBOSE:-0}" == "1" ]]; then set -x fi -echo "[Windows] Preparing build directories..." -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR/bin/win" - -# Capture full build output to a log file for debugging even if the window closes -LOG_FILE="$BUILD_DIR/build.log" -: > "$LOG_FILE" || true -# tee may not be available in all shells, but is present in Git Bash/MSYS2; ignore failure -exec > >(tee -a "$LOG_FILE") 2>&1 || true +log() { + echo "[Windows] $*" +} -echo "[Windows] Verifying ffmpeg/ffprobe binaries..." -if [[ ! -f "$BIN_SRC_DIR/ffmpeg.exe" ]] || [[ ! -f "$BIN_SRC_DIR/ffprobe.exe" ]]; then - echo "ERROR: Expected ffmpeg.exe and ffprobe.exe at $BIN_SRC_DIR" >&2 +die() { + echo "[Windows] ERROR: $*" >&2 exit 1 -fi - -echo "[Windows] Copying resources..." -cp -f "$BIN_SRC_DIR/ffmpeg.exe" "$BUILD_DIR/bin/win/" -cp -f "$BIN_SRC_DIR/ffprobe.exe" "$BUILD_DIR/bin/win/" -cp -f "$ROOT_DIR/sample.mp4" "$BUILD_DIR/" || true -cp -f "$CLIENT_DIR/presets.json" "$BUILD_DIR/" || true +} -echo "[Windows] Running PyInstaller..." -cd "$CLIENT_DIR" +if [[ ! -f "$BIN_DIR/ffmpeg.exe" ]] || [[ ! -f "$BIN_DIR/ffprobe.exe" ]]; then + die "Expected ffmpeg.exe and ffprobe.exe at $BIN_DIR" +fi +if [[ ! -f "$ROOT_DIR/sample.mp4" ]]; then + die "Missing $ROOT_DIR/sample.mp4" +fi +if [[ ! -f "$CLIENT_DIR/presets.json" ]]; then + die "Missing $CLIENT_DIR/presets.json" +fi +if [[ ! -f "$ENTRYPOINT" ]]; then + die "Missing entrypoint: $ENTRYPOINT" +fi -# Prefer local Windows venv tools if present -if [[ -x "$VENV_SCRIPTS_DIR/pyinstaller.exe" ]]; then - echo "[Windows] Using local venv PyInstaller: $VENV_SCRIPTS_DIR/pyinstaller.exe" - PYI_CMD=("$VENV_SCRIPTS_DIR/pyinstaller.exe") -elif [[ -x "$VENV_SCRIPTS_DIR/python.exe" ]]; then - echo "[Windows] Using local venv Python: $VENV_SCRIPTS_DIR/python.exe" - PYI_CMD=("$VENV_SCRIPTS_DIR/python.exe" -m PyInstaller) +log "Preparing build directories..." +rm -rf "$BUILD_ROOT" +rm -rf "$LEGACY_DIST_DIR" +rm -f "$OUTPUT_PATH" +rm -f "$ROOT_DIR/dist/$APP_NAME.exe" +rm -rf "$ROOT_DIR/build/$APP_NAME" +mkdir -p "$PYI_DIST_DIR" "$PYI_WORK_DIR" "$PYI_SPEC_DIR" + +# Capture build output for troubleshooting double-click or CI runs. +LOG_FILE="$BUILD_ROOT/build.log" +: > "$LOG_FILE" +exec > >(tee -a "$LOG_FILE") 2>&1 + +PY_CMD=() +if command -v py >/dev/null 2>&1; then + PY_CMD=(py -3) +elif command -v py.exe >/dev/null 2>&1; then + PY_CMD=(py.exe -3) +elif command -v python.exe >/dev/null 2>&1; then + PY_CMD=(python.exe) +elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]] && command -v python >/dev/null 2>&1; then + PY_CMD=(python) else - # Pick Windows Python launcher if available to ensure a native .exe is produced - PY_CMD=( ) - if command -v py >/dev/null 2>&1; then - PY_CMD=(py -3) - elif command -v py.exe >/dev/null 2>&1; then - PY_CMD=(py.exe -3) - elif command -v python.exe >/dev/null 2>&1; then - PY_CMD=(python.exe) - else - # Fallback (may build for non-Windows if not using Windows Python!) - PY_CMD=(python) - fi - echo "[Windows] Using Python: ${PY_CMD[*]}" - # Verify PyInstaller is available in this interpreter - if ! "${PY_CMD[@]}" -m PyInstaller --version >/dev/null 2>&1; then - echo "ERROR: PyInstaller is not installed for this Python interpreter (${PY_CMD[*]})." >&2 - echo " Install with: ${PY_CMD[*]} -m pip install pyinstaller" >&2 - exit 3 - fi - PYI_CMD=("${PY_CMD[@]}" -m PyInstaller) + die "No Windows Python interpreter found. Use py/py.exe/python.exe from Windows." +fi + +log "Using Python command: ${PY_CMD[*]}" +if ! "${PY_CMD[@]}" -m PyInstaller --version >/dev/null 2>&1; then + die "PyInstaller is not installed for this interpreter. Install with: ${PY_CMD[*]} -m pip install pyinstaller" fi -"${PYI_CMD[@]}" \ +log "Running PyInstaller..." +cd "$ROOT_DIR" +"${PY_CMD[@]}" -m PyInstaller \ --clean \ --onefile \ - --name encodingdb-client-windows \ - --add-data "bin/win/ffmpeg.exe;bin/win" \ - --add-data "bin/win/ffprobe.exe;bin/win" \ - --add-data "../sample.mp4;." \ - --add-data "presets.json;." \ - main.py - -echo "[Windows] Moving artifact to $BUILD_DIR..." -if [[ -f "$CLIENT_DIR/dist/encodingdb-client-windows.exe" ]]; then - mv -f "$CLIENT_DIR/dist/encodingdb-client-windows.exe" "$BUILD_DIR/encodingdb-client-windows.exe" -elif [[ -d "$CLIENT_DIR/dist/encodingdb-client-windows" ]]; then - mv -f "$CLIENT_DIR/dist/encodingdb-client-windows" "$BUILD_DIR/" -else - echo "ERROR: PyInstaller did not produce encodingdb-client-windows.exe. Ensure Windows Python (py -3) is used and PyInstaller is installed." >&2 - exit 2 + --name "$APP_NAME" \ + --distpath "$PYI_DIST_DIR" \ + --workpath "$PYI_WORK_DIR" \ + --specpath "$PYI_SPEC_DIR" \ + --paths "$ROOT_DIR" \ + --add-data "client/bin/win/ffmpeg.exe;bin/win" \ + --add-data "client/bin/win/ffprobe.exe;bin/win" \ + --add-data "sample.mp4;." \ + --add-data "client/presets.json;." \ + "$ENTRYPOINT" + +if [[ ! -f "$PYI_DIST_DIR/$APP_NAME.exe" ]]; then + die "Build output not found at $PYI_DIST_DIR/$APP_NAME.exe" fi -echo "[Windows] Build complete: $BUILD_DIR" -echo "[Windows] Build log saved to: $LOG_FILE" +log "Placing executable in repository root..." +mv -f "$PYI_DIST_DIR/$APP_NAME.exe" "$OUTPUT_PATH" +log "Build complete: $OUTPUT_PATH" +log "Build log saved to: $LOG_FILE" +log "Hidden build artifacts: $BUILD_ROOT" # Optional pause for double-click runs (set PAUSE_ON_EXIT=1) if [[ "${PAUSE_ON_EXIT:-0}" == "1" ]]; then read -r -p "Press Enter to close..." _ fi - diff --git a/scripts/client_test.sh b/scripts/client_test.sh new file mode 100755 index 0000000..48fb8bb --- /dev/null +++ b/scripts/client_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SERVER_PORT="${SERVER_PORT:-3001}" +LOCAL_BASE_URL="${BASE_URL:-http://127.0.0.1:${SERVER_PORT}}" + +cd "$ROOT_DIR" + +if command -v python3 >/dev/null 2>&1; then + exec env BACKEND_BASE_URL="$LOCAL_BASE_URL" python3 -m client --base-url "$LOCAL_BASE_URL" "$@" +fi + +if command -v python >/dev/null 2>&1; then + exec env BACKEND_BASE_URL="$LOCAL_BASE_URL" python -m client --base-url "$LOCAL_BASE_URL" "$@" +fi + +echo "[client_test] ERROR: Python not found in PATH." >&2 +exit 1 diff --git a/scripts/dev_frontend.sh b/scripts/dev_frontend.sh deleted file mode 100755 index 3aba480..0000000 --- a/scripts/dev_frontend.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh - -set -e - -# Simple helper to run the frontend against mock data for quick UI testing. -# Usage: scripts/dev_frontend.sh - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -FRONTEND_DIR="$ROOT_DIR/frontend" - -echo "[dev] Starting frontend in mock-data mode..." -echo "[dev] Tip: Unset INTERNAL_API_BASE_URL to force Next.js to use /api/query mock endpoint." - -cd "$FRONTEND_DIR" - -if [ ! -d node_modules ]; then - echo "[dev] Installing dependencies..." - npm ci -fi - -PORT=${PORT:-3000} -echo "[dev] Running next dev on http://localhost:$PORT" -echo "[dev] Opening browser..." - -# Open the browser in the background (macOS/darwin) -if command -v open >/dev/null 2>&1; then - open "http://localhost:$PORT" || true -fi - -npm run dev -- --port "$PORT" - - diff --git a/scripts/e2e.sh b/scripts/e2e.sh deleted file mode 100755 index 137ffb6..0000000 --- a/scripts/e2e.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -CLIENT_DIR="$ROOT_DIR/client" -PORT="${PORT:-3001}" -BASE_URL="http://127.0.0.1:${PORT}" -LOCAL_TEST_LOG="$SCRIPT_DIR/.e2e_local_test.log" -CLIENT_LOG="$SCRIPT_DIR/.e2e_client.log" - -NO_SUBMIT=0 -SEED_COUNT="${SEED_DUMMY_BENCHMARKS_COUNT:-480}" -while [[ $# -gt 0 ]]; do - case "$1" in - --no-submit) NO_SUBMIT=1; shift ;; - -h|--help) - echo "Usage: $0 [--no-submit]" - echo " --no-submit Run client smoke test without submitting to API." - echo "" - echo "Env:" - echo " PORT Backend port (default: 3001)" - echo " SEED_DUMMY_BENCHMARKS_COUNT Local dummy rows for local_test (default: 480)" - exit 0 - ;; - *) echo "Unknown option: $1" >&2; exit 2 ;; - esac -done - -echo "[e2e] ROOT_DIR=$ROOT_DIR" -echo "[e2e] BASE_URL=$BASE_URL" - -LOCAL_TEST_PID="" -cleanup() { - if [[ -n "$LOCAL_TEST_PID" ]] && kill -0 "$LOCAL_TEST_PID" >/dev/null 2>&1; then - echo "[e2e] Stopping local_test stack (PID $LOCAL_TEST_PID)" - kill "$LOCAL_TEST_PID" >/dev/null 2>&1 || true - wait "$LOCAL_TEST_PID" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -echo "[e2e] Starting local stack via scripts/local_test.sh" -: > "$LOCAL_TEST_LOG" -( - cd "$ROOT_DIR" - SEED_DUMMY_BENCHMARKS=1 \ - SEED_DUMMY_BENCHMARKS_COUNT="$SEED_COUNT" \ - PORT="$PORT" \ - ./scripts/local_test.sh --no-frontend --no-client-check -) >> "$LOCAL_TEST_LOG" 2>&1 & -LOCAL_TEST_PID=$! - -echo "[e2e] Waiting for /health/ready" -for i in {1..120}; do - if ! kill -0 "$LOCAL_TEST_PID" >/dev/null 2>&1; then - echo "[e2e] local_test.sh exited unexpectedly. Last 80 lines:" >&2 - tail -n 80 "$LOCAL_TEST_LOG" >&2 || true - exit 1 - fi - code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/health/ready" || true)" - if [[ "$code" == "200" ]]; then - echo "[e2e] Backend is ready" - break - fi - if [[ "$i" -eq 120 ]]; then - echo "[e2e] Timed out waiting for backend readiness (last code=$code)." >&2 - tail -n 80 "$LOCAL_TEST_LOG" >&2 || true - exit 1 - fi - sleep 1 -done - -echo "[e2e] Preparing Python client venv" -cd "$CLIENT_DIR" -python3 -m venv .venv >/dev/null 2>&1 || true -source .venv/bin/activate -pip install --upgrade pip >/dev/null -pip install -r requirements.txt >/dev/null - -echo "[e2e] Running automated client menu flow (single benchmark)" -: > "$CLIENT_LOG" -CLIENT_CMD=(python3 main.py --base-url "$BASE_URL") -if [[ "$NO_SUBMIT" -eq 1 ]]; then - CLIENT_CMD+=(--no-submit) -fi - -# Menu automation: -# 1) Run Single Benchmark -# 2) encoder: default -# 3) CRF: default -# 4) preset: default -printf '1\n\n\n\n' | "${CLIENT_CMD[@]}" | tee "$CLIENT_LOG" - -if [[ "$NO_SUBMIT" -eq 0 ]]; then - if ! grep -q "Submitted Results" "$CLIENT_LOG"; then - echo "[e2e] Client run did not report a successful submission." >&2 - tail -n 120 "$CLIENT_LOG" >&2 || true - exit 1 - fi -fi - -echo "[e2e] Verifying API query endpoint" -code_query="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/query")" -if [[ "$code_query" != "200" ]]; then - echo "[e2e] /query returned HTTP $code_query" >&2 - exit 1 -fi -curl -s "$BASE_URL/query?limit=3" | head -c 600 && echo - -echo "[e2e] PASS" diff --git a/scripts/local_test.sh b/scripts/local_test.sh index 8b7977b..4e6eb36 100755 --- a/scripts/local_test.sh +++ b/scripts/local_test.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash # Local testing script: spin up DB, server, and frontend with verbose logging. -# Similar to redploy.sh but for local development (no git pull, no prod compose). # Usage: ./scripts/local_test.sh [--no-frontend] [--no-client-check] [--keep] # Env: LOCAL_TEST_DATABASE defaults to benchmarks_test so migrations run against a # clean DB (avoids "failed migration" state in your main benchmarks DB). @@ -21,7 +20,9 @@ PG_USER="${POSTGRES_USER:-app}" PG_PASS="${POSTGRES_PASSWORD:-app}" # Default to a separate DB so we never touch a production/failed-migration state DB_NAME="${LOCAL_TEST_DATABASE:-benchmarks_test}" -DATABASE_URL_LOCAL="postgresql://${PG_USER}:${PG_PASS}@127.0.0.1:5432/${DB_NAME}?schema=public" +PG_PORT="${POSTGRES_PORT:-55432}" +export POSTGRES_PORT="$PG_PORT" +DATABASE_URL_LOCAL="postgresql://${PG_USER}:${PG_PASS}@127.0.0.1:${PG_PORT}/${DB_NAME}?schema=public" SEED_DUMMY_BENCHMARKS="${SEED_DUMMY_BENCHMARKS:-1}" SEED_DUMMY_BENCHMARKS_COUNT="${SEED_DUMMY_BENCHMARKS_COUNT:-480}" @@ -42,6 +43,7 @@ while [[ $# -gt 0 ]]; do echo "" echo "Env: LOCAL_TEST_DATABASE Database name (default: benchmarks_test). Use a separate" echo " DB so migrations always run clean. Set to 'benchmarks' to use the main DB." + echo " POSTGRES_PORT Host port for Postgres (default: 55432)." echo " SEED_DUMMY_BENCHMARKS Seed synthetic benchmark rows after migration" echo " (default: 1 for local test only)." echo " SEED_DUMMY_BENCHMARKS_COUNT Number of synthetic rows to insert (default: 480)." @@ -87,7 +89,7 @@ cd "$ROOT_DIR" log "Repo root: $ROOT_DIR" log "Logs: server=$SERVER_LOG frontend=$FRONTEND_LOG" -for cmd in docker node npm; do +for cmd in docker node npm curl; do command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd" done @@ -247,7 +249,7 @@ log " Backend: http://127.0.0.1:$SERVER_PORT (logs: $SERVER_LOG)" if [[ $NO_FRONTEND -eq 0 ]]; then log " Frontend: http://127.0.0.1:$FRONTEND_PORT (logs: $FRONTEND_LOG)" fi -log " DB: postgresql://${PG_USER}:****@127.0.0.1:5432/${DB_NAME}" +log " DB: postgresql://${PG_USER}:****@127.0.0.1:${PG_PORT}/${DB_NAME}" log "Press Ctrl+C to stop server and frontend." log "==========================================" wait $SERVER_PID 2>/dev/null || true diff --git a/scripts/manage_keys.sh b/scripts/manage_keys.sh deleted file mode 100755 index c179fdf..0000000 --- a/scripts/manage_keys.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cat >&2 <<'EOF' -This script is intentionally disabled. - -Reason: -- The current backend does not expose /admin/api-keys endpoints. -- Keeping a "working" key-management script would imply production controls that do not exist. - -Action: -- Use ingest mode + rate limits currently implemented by the backend. -- Re-enable this script only after admin key APIs are implemented server-side. -EOF - -exit 1 diff --git a/scripts/redploy.sh b/scripts/redploy.sh deleted file mode 100755 index b900b7c..0000000 --- a/scripts/redploy.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd ~/Encoding_Database - -# --- Helpers --- -have_cmd() { command -v "$1" >/dev/null 2>&1; } -get_val() { - # get_val FILE VAR_NAME - # Grep last clean assignment of VAR=... ignoring merge markers - local file="$1" var="$2" - if [ -f "$file" ]; then - grep -E "^${var}=" "$file" | grep -v '<<<<<<<\|=======\|>>>>>>>' | tail -n1 | sed -E "s/^${var}=//" - fi -} - -# --- Ensure env files are ignored and not tracked --- -ensure_gitignore() { - local gi=".gitignore" - touch "$gi" - if ! grep -q '^\.env$' "$gi"; then echo ".env" >> "$gi"; fi - if ! grep -q '^server/\.env$' "$gi"; then echo "server/.env" >> "$gi"; fi - if ! grep -q '^*.bak$' "$gi"; then echo "*.bak" >> "$gi"; fi - git add "$gi" >/dev/null 2>&1 || true - git commit -m "chore: ignore env files on deploy host" >/dev/null 2>&1 || true -} - -untrack_env_files() { - git rm --cached .env server/.env >/dev/null 2>&1 || true -} - -# --- Abort any in-progress merge/rebase cleanly --- -git merge --abort >/dev/null 2>&1 || true -git rebase --abort >/dev/null 2>&1 || true - -# --- Prepare/repair .env before pulling --- -ensure_gitignore -untrack_env_files - -# --- Pull latest code safely --- -# Allow overrides via env: REMOTE=origin BRANCH=main ./scripts/redploy.sh -REMOTE_REF="${REMOTE:-origin}" -BRANCH_REF="${BRANCH:-main}" -OLD_HEAD=$(git rev-parse --short HEAD 2>/dev/null || echo "none") -git fetch --all --prune -git fetch "$REMOTE_REF" "$BRANCH_REF" || true -REMOTE_URL=$(git remote get-url "$REMOTE_REF" 2>/dev/null || echo "unknown") -git reset --hard "$REMOTE_REF/$BRANCH_REF" -NEW_HEAD=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") -echo "Source: $REMOTE_REF/$BRANCH_REF ($REMOTE_URL)" -echo "Updated: $OLD_HEAD -> $NEW_HEAD" -echo "Recent commits on $REMOTE_REF/$BRANCH_REF:" -git log --oneline -n 3 "$REMOTE_REF/$BRANCH_REF" || true - -# --- Regenerate .env with preserved values (AFTER pulling latest scripts) --- -DEFAULT_DOMAIN="encodingdb.platinumlabs.dev" -POSTGRES_PASSWORD="$(get_val .env POSTGRES_PASSWORD || get_val .env.bak POSTGRES_PASSWORD || true)" -INGEST_HMAC_SECRET="$(get_val .env INGEST_HMAC_SECRET || get_val .env.bak INGEST_HMAC_SECRET || true)" -CORS_ORIGIN="$(get_val .env CORS_ORIGIN || get_val .env.bak CORS_ORIGIN || echo "https://${DEFAULT_DOMAIN}")" -POSTGRES_USER="$(get_val .env POSTGRES_USER || echo "app")" -POSTGRES_DB="$(get_val .env POSTGRES_DB || echo "benchmarks")" -PORT_VAL="$(get_val .env PORT || echo "3001")" -NEXT_PUBLIC_API_BASE_URL="$(get_val .env NEXT_PUBLIC_API_BASE_URL || echo "https://${DEFAULT_DOMAIN}")" - -# If .env contains merge markers, back it up before regenerating -if grep -q '<<<<<<<\|=======\|>>>>>>>' .env 2>/dev/null; then - cp .env .env.autofix.bak || true -fi - -# Derive domain from CORS_ORIGIN when available; fall back to default domain -DOMAIN_CAND="${CORS_ORIGIN#https://}" -DOMAIN_CAND="${DOMAIN_CAND#http://}" -DOMAIN_CAND="${DOMAIN_CAND%%/*}" -if [ -z "$DOMAIN_CAND" ] || [ "$DOMAIN_CAND" = "" ]; then DOMAIN_CAND="$DEFAULT_DOMAIN"; fi - -./scripts/setup_env.sh \ - --domain "$DOMAIN_CAND" \ - --cors-origins "$CORS_ORIGIN" \ - --postgres-user "${POSTGRES_USER:-app}" \ - ${POSTGRES_PASSWORD:+--postgres-password "$POSTGRES_PASSWORD"} \ - --postgres-db "${POSTGRES_DB:-benchmarks}" \ - ${INGEST_HMAC_SECRET:+--ingest-secret "$INGEST_HMAC_SECRET"} \ - --port "${PORT_VAL:-3001}" \ - --public-api-base "${NEXT_PUBLIC_API_BASE_URL:-https://${DEFAULT_DOMAIN}}" - -# Make sure env files are not tracked -untrack_env_files - -# --- Ensure DB user password matches .env (handles existing volumes) --- -echo "Ensuring database is up..." -docker compose -f docker-compose.prod.yml up -d db -# Wait for Postgres to accept connections -DB_READY=0 -for i in {1..30}; do - docker compose -f docker-compose.prod.yml exec -T db pg_isready -U "${POSTGRES_USER:-app}" -d "${POSTGRES_DB:-benchmarks}" >/dev/null 2>&1 && DB_READY=1 && break || true - sleep 2 -done -if [ "$DB_READY" -ne 1 ]; then - echo "Postgres not ready after timeout; continuing anyway..." -fi -# Align user password inside DB to match .env (safe if already aligned) -docker compose -f docker-compose.prod.yml exec -T db sh -lc "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \"ALTER USER \\\"${POSTGRES_USER:-app}\\\" WITH PASSWORD '${POSTGRES_PASSWORD}';\"" >/dev/null 2>&1 || true - -# --- Build & deploy --- -docker compose -f docker-compose.prod.yml build --no-cache server frontend -docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate server frontend - -# Apply Prisma migrations (fail fast if schema update cannot be applied) -docker compose -f docker-compose.prod.yml exec server npx prisma migrate deploy - -# Wait for backend readiness via nginx proxy -echo "Waiting for backend readiness at http://localhost/health/ready ..." -READY=0 -for i in {1..30}; do - code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health/ready || true) - if [ "$code" = "200" ]; then READY=1; break; fi - sleep 2 -done -if [ "$READY" -ne 1 ]; then - echo "Edge not ready, checking directly inside server container..." - docker compose -f docker-compose.prod.yml exec -T server sh -lc "wget -qO- http://localhost:3001/health/ready >/dev/null && echo 'Server responded OK'" || echo "Server not ready yet" -fi - -# Optionally check frontend -echo "Checking frontend at http://localhost ..." -curl -s -o /dev/null -w "Frontend HTTP %{http_code}\n" http://localhost || true - -# Tail recent logs -docker compose -f docker-compose.prod.yml logs --tail=80 server diff --git a/scripts/setup_env.sh b/scripts/setup_env.sh deleted file mode 100755 index 9b3b9bc..0000000 --- a/scripts/setup_env.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Simple environment setup script for Encoding Database -# - Writes ./.env (used by docker-compose.prod.yml and server) -# - Mirrors to server/.env for local runs -# - Accepts flags to override defaults; otherwise generates sensible defaults -# -# Usage examples: -# scripts/setup_env.sh # use defaults + random secrets -# scripts/setup_env.sh --domain example.com # set domain and derive CORS -# scripts/setup_env.sh --ingest-secret abc123 # set specific HMAC secret -# scripts/setup_env.sh --postgres-password s3cr3t --cors-origins https://a.com,https://b.com - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" - -color() { printf "\033[%sm%s\033[0m" "$1" "$2"; } -green() { color 32 "$1"; } -yellow() { color 33 "$1"; } -red() { color 31 "$1"; } - -have_cmd() { command -v "$1" >/dev/null 2>&1; } - -rand_hex() { - local bytes="${1:-32}" - if have_cmd openssl; then - openssl rand -hex "$bytes" - elif have_cmd hexdump; then - hexdump -vn "$bytes" -e '"%02x"' /dev/urandom - else - # Very weak fallback; should not occur on typical systems - date +%s%N | shasum | awk '{print $1}' - fi -} - -# Defaults -DOMAIN="encodingdb.platinumlabs.dev" -POSTGRES_USER="app" -POSTGRES_PASSWORD="" -POSTGRES_DB="benchmarks" -PORT="3001" -INGEST_HMAC_SECRET="" -CORS_ORIGINS="" -BODY_LIMIT="1mb" -RATE_LIMIT_WINDOW_MS="60000" -RATE_LIMIT_MAX="300" -SUBMIT_RATE_WINDOW_MS="60000" -SUBMIT_RATE_MAX="30" -NEXT_PUBLIC_API_BASE_URL="" - -print_help() { - cat <<'EOF' -Usage: setup_env.sh [options] - -Options: - --domain DOMAIN Public site domain (default: encodingdb.platinumlabs.dev) - --postgres-user USER Postgres username (default: app) - --postgres-password PASS Postgres password (default: random) - --postgres-db NAME Postgres database name (default: benchmarks) - --port PORT Backend port (default: 3001) - --ingest-secret HEX HMAC secret for /submit (default: random 32 bytes hex) - --cors-origins CSV Comma-separated allowlist for CORS_ORIGIN (default: https://) - --body-limit SIZE express.json size limit (default: 1mb) - --rate-window-ms MS Global rate limit window (default: 60000) - --rate-max N Global rate limit max (default: 300) - --submit-window-ms MS /submit rate limit window (default: 60000) - --submit-max N /submit rate limit max (default: 30) - --public-api-base URL NEXT_PUBLIC_API_BASE_URL (default: https://) - -h, --help Show this help - -This script writes .env at repo root and mirrors it to server/.env. -EOF -} - -# Parse args -while [[ $# -gt 0 ]]; do - case "$1" in - --domain) DOMAIN="$2"; shift 2 ;; - --postgres-user) POSTGRES_USER="$2"; shift 2 ;; - --postgres-password) POSTGRES_PASSWORD="$2"; shift 2 ;; - --postgres-db) POSTGRES_DB="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; - --ingest-secret) INGEST_HMAC_SECRET="$2"; shift 2 ;; - --cors-origins) CORS_ORIGINS="$2"; shift 2 ;; - --body-limit) BODY_LIMIT="$2"; shift 2 ;; - --rate-window-ms) RATE_LIMIT_WINDOW_MS="$2"; shift 2 ;; - --rate-max) RATE_LIMIT_MAX="$2"; shift 2 ;; - --submit-window-ms) SUBMIT_RATE_WINDOW_MS="$2"; shift 2 ;; - --submit-max) SUBMIT_RATE_MAX="$2"; shift 2 ;; - --public-api-base) NEXT_PUBLIC_API_BASE_URL="$2"; shift 2 ;; - --api-key-header|--per-key-per-minute|--per-key-per-day|--disk-min-free-gb|--disk-path|--admin-token) - echo "$(yellow "Warning: $1 is deprecated and ignored (feature not implemented in backend).")" >&2 - shift 2 - ;; - -h|--help) print_help; exit 0 ;; - *) echo "Unknown arg: $1" >&2; print_help; exit 2 ;; - esac -done - -# Sanitize/derive DOMAIN and public URLs -DOMAIN="$(echo "${DOMAIN}" | sed -E 's#^https?://##; s#/+$##')" -if [[ -z "$DOMAIN" ]]; then DOMAIN="encodingdb.platinumlabs.dev"; fi - -# Fill defaults -if [[ -z "$CORS_ORIGINS" || "$CORS_ORIGINS" = "https://" || "$CORS_ORIGINS" = "http://" ]]; then CORS_ORIGINS="https://$DOMAIN"; fi -if [[ -z "$NEXT_PUBLIC_API_BASE_URL" || "$NEXT_PUBLIC_API_BASE_URL" = "https://" || "$NEXT_PUBLIC_API_BASE_URL" = "http://" ]]; then NEXT_PUBLIC_API_BASE_URL="https://$DOMAIN"; fi -if [[ -z "$POSTGRES_PASSWORD" ]]; then POSTGRES_PASSWORD="$(rand_hex 24)"; fi -if [[ -z "$INGEST_HMAC_SECRET" ]]; then INGEST_HMAC_SECRET="$(rand_hex 32)"; fi - -DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public" - -write_env() { - local path="$1" - cat >"$path" <&2; usage >&2; exit 2 ;; + esac +done + +mkdir -p "$RUN_DIR" + +slugify() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' +} + +update_overall() { + local status="$1" + if [[ "$status" == "FAIL" || "$status" == "BLOCKED" ]]; then + OVERALL=2 + return + fi + if [[ "$status" == "WARN" && "$OVERALL" -lt 2 ]]; then + OVERALL=1 + fi +} + +record_step() { + local name="$1" + local status="$2" + local log="$3" + local note="$4" + STEP_NAMES+=("$name") + STEP_STATUS+=("$status") + STEP_LOGS+=("$log") + STEP_NOTES+=("$note") + LAST_STATUS="$status" + update_overall "$status" +} + +issue_scan() { + local input_log="$1" + local output_log="$2" + local raw_log="${output_log}.raw" + local scan_log="${output_log}.scan" + : > "$output_log" + : > "$raw_log" + : > "$scan_log" + + # Scan only command output; exclude our injected command line header. + if command -v rg >/dev/null 2>&1; then + rg -n -v -e '^\[command\]' "$input_log" > "$scan_log" || true + else + grep -Env '^\[command\]' "$input_log" > "$scan_log" || true + fi + + local pattern='(^|[^[:alnum:]_])(warn|warning|deprecated|error|failed|failure|fail|vulnerability|vulnerabilities)([^[:alnum:]_]|$)' + if command -v rg >/dev/null 2>&1; then + rg -n -i -e "$pattern" "$scan_log" > "$raw_log" || true + else + grep -Ein "$pattern" "$scan_log" > "$raw_log" || true + fi + + local ignore='0 warnings?|no warnings?|0 errors?|no errors?|without warnings?|without errors?|found 0 vulnerabilities|fail[[:space:]]*[:=]?[[:space:]]*0([^0-9]|$)|failed[[:space:]]*[:=]?[[:space:]]*0([^0-9]|$)|failures?[[:space:]]*[:=]?[[:space:]]*0([^0-9]|$)' + if command -v rg >/dev/null 2>&1; then + rg -n -v -i -e "$ignore" "$raw_log" > "$output_log" || true + else + grep -Eiv "$ignore" "$raw_log" > "$output_log" || true + fi +} + +mark_blocked() { + local name="$1" + local reason="$2" + local idx="$(( ${#STEP_NAMES[@]} + 1 ))" + local slug + slug="$(slugify "$name")" + local log="$RUN_DIR/$(printf '%02d' "$idx")-${slug}.log" + { + echo "BLOCKED: $reason" + } > "$log" + record_step "$name" "BLOCKED" "$log" "$reason" + printf '[%02d] BLOCKED: %s (%s)\n' "$idx" "$name" "$reason" +} + +run_step() { + local name="$1" + local command="$2" + + local idx="$(( ${#STEP_NAMES[@]} + 1 ))" + local slug + slug="$(slugify "$name")" + local log="$RUN_DIR/$(printf '%02d' "$idx")-${slug}.log" + local issues="${log}.issues" + + printf '[%02d] RUN: %s\n' "$idx" "$name" + { + echo "[command] $command" + echo + } > "$log" + + local rc=0 + bash -lc "cd \"$ROOT_DIR\" && $command" >> "$log" 2>&1 || rc=$? + issue_scan "$log" "$issues" + + local note="" + if [[ "$rc" -ne 0 ]]; then + note="exit=$rc" + if [[ -s "$issues" ]]; then + note="$note; issue=$(head -n 1 "$issues" | tr -d '\r' | cut -c1-180)" + fi + record_step "$name" "FAIL" "$log" "$note" + printf '[%02d] FAIL: %s (%s)\n' "$idx" "$name" "$note" + return "$rc" + fi + + if [[ -s "$issues" ]]; then + note="warnings detected; first=$(head -n 1 "$issues" | tr -d '\r' | cut -c1-180)" + record_step "$name" "WARN" "$log" "$note" + printf '[%02d] WARN: %s (%s)\n' "$idx" "$name" "$note" + return 0 + fi + + record_step "$name" "PASS" "$log" "ok" + printf '[%02d] PASS: %s\n' "$idx" "$name" + return 0 +} + +start_server() { + local idx="$(( ${#STEP_NAMES[@]} + 1 ))" + local name="Server start and readiness" + local slug + slug="$(slugify "$name")" + local log="$RUN_DIR/$(printf '%02d' "$idx")-${slug}.log" + local issues="${log}.issues" + local ready_url="http://127.0.0.1:${SERVER_PORT}/health/ready" + + printf '[%02d] RUN: %s\n' "$idx" "$name" + : > "$log" + if command -v lsof >/dev/null 2>&1; then + local listeners + listeners="$(lsof -nP -iTCP:"${SERVER_PORT}" -sTCP:LISTEN | awk 'NR>1 {print $1 "/" $2}' | paste -sd, -)" + if [[ -n "$listeners" ]]; then + echo "Port ${SERVER_PORT} already in use by: ${listeners}" >> "$log" + record_step "$name" "FAIL" "$log" "port ${SERVER_PORT} already in use (${listeners})" + printf '[%02d] FAIL: %s (port %s in use)\n' "$idx" "$name" "$SERVER_PORT" + return 1 + fi + fi + ( + cd "$ROOT_DIR/server" + export PORT="$SERVER_PORT" DATABASE_URL="$DATABASE_URL_LOCAL" NODE_ENV=development + exec node dist/index.js + ) >> "$log" 2>&1 & + SERVER_PID=$! + + local rc=0 + for _ in $(seq 1 50); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + rc=1 + break + fi + code="$(curl -s -o /dev/null -w '%{http_code}' "$ready_url" 2>/dev/null || true)" + if [[ "$code" == "200" ]]; then + rc=0 + break + fi + sleep 1 + done + + if [[ "$rc" -eq 0 ]] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + rc=1 + echo "Server process exited after readiness probe." >> "$log" + fi + + issue_scan "$log" "$issues" + if [[ "$rc" -ne 0 ]]; then + record_step "$name" "FAIL" "$log" "server did not become ready" + printf '[%02d] FAIL: %s\n' "$idx" "$name" + return 1 + fi + + if [[ -s "$issues" ]]; then + local note="warnings detected; first=$(head -n 1 "$issues" | tr -d '\r' | cut -c1-180)" + record_step "$name" "WARN" "$log" "$note" + printf '[%02d] WARN: %s (%s)\n' "$idx" "$name" "$note" + return 0 + fi + + record_step "$name" "PASS" "$log" "ok" + printf '[%02d] PASS: %s\n' "$idx" "$name" + return 0 +} + +start_frontend() { + local idx="$(( ${#STEP_NAMES[@]} + 1 ))" + local name="Frontend start and readiness" + local slug + slug="$(slugify "$name")" + local log="$RUN_DIR/$(printf '%02d' "$idx")-${slug}.log" + local issues="${log}.issues" + local frontend_url="http://127.0.0.1:${FRONTEND_PORT}/" + + printf '[%02d] RUN: %s\n' "$idx" "$name" + : > "$log" + if command -v lsof >/dev/null 2>&1; then + local listeners + listeners="$(lsof -nP -iTCP:"${FRONTEND_PORT}" -sTCP:LISTEN | awk 'NR>1 {print $1 "/" $2}' | paste -sd, -)" + if [[ -n "$listeners" ]]; then + echo "Port ${FRONTEND_PORT} already in use by: ${listeners}" >> "$log" + record_step "$name" "FAIL" "$log" "port ${FRONTEND_PORT} already in use (${listeners})" + printf '[%02d] FAIL: %s (port %s in use)\n' "$idx" "$name" "$FRONTEND_PORT" + return 1 + fi + fi + ( + cd "$ROOT_DIR/frontend" + export PORT="$FRONTEND_PORT" + export INTERNAL_API_BASE_URL="http://127.0.0.1:${SERVER_PORT}" + export NEXT_PUBLIC_API_BASE_URL="http://127.0.0.1:${SERVER_PORT}" + exec npm run start + ) >> "$log" 2>&1 & + FRONTEND_PID=$! + + local rc=0 + for _ in $(seq 1 50); do + if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then + rc=1 + break + fi + code="$(curl -s -o /dev/null -w '%{http_code}' "$frontend_url" 2>/dev/null || true)" + if [[ "$code" == "200" || "$code" == "304" ]]; then + rc=0 + break + fi + sleep 1 + done + + if [[ "$rc" -eq 0 ]] && ! kill -0 "$FRONTEND_PID" 2>/dev/null; then + rc=1 + echo "Frontend process exited after readiness probe." >> "$log" + fi + + issue_scan "$log" "$issues" + if [[ "$rc" -ne 0 ]]; then + record_step "$name" "FAIL" "$log" "frontend did not become ready" + printf '[%02d] FAIL: %s\n' "$idx" "$name" + return 1 + fi + + if [[ -s "$issues" ]]; then + local note="warnings detected; first=$(head -n 1 "$issues" | tr -d '\r' | cut -c1-180)" + record_step "$name" "WARN" "$log" "$note" + printf '[%02d] WARN: %s (%s)\n' "$idx" "$name" "$note" + return 0 + fi + + record_step "$name" "PASS" "$log" "ok" + printf '[%02d] PASS: %s\n' "$idx" "$name" + return 0 +} + +run_api_submit_accepts_sample() { + local idx="$(( ${#STEP_NAMES[@]} + 1 ))" + local name="API: submit accepts sample payload" + local slug + slug="$(slugify "$name")" + local log="$RUN_DIR/$(printf '%02d' "$idx")-${slug}.log" + + local base_url="http://127.0.0.1:${SERVER_PORT}/submit" + local modern_payload='{"cpuModel":"Test CPU Model","gpuModel":"","ramGB":16,"os":"macOS","codec":"libx264","preset":"medium","crf":24,"contentClass":"mixed","resolution":"1080p","passes":1,"fps":42.0,"vmaf":92.0,"ssim":0.97,"psnr":39.2,"fileSizeBytes":12345678,"notes":"local test submission","ffmpegVersion":"test","encoderName":"libx264","clientVersion":"test","inputHash":"53a87df054e65d284bc808b8f73e62e938b815cb6aeec8379f904ad6d792aab8","runMs":10000}' + local legacy_payload='{"cpuModel":"Test CPU Model","gpuModel":"","ramGB":16,"os":"macOS","codec":"libx264","preset":"medium","crf":24,"fps":42.0,"vmaf":92.0,"ssim":0.97,"psnr":39.2,"fileSizeBytes":12345678,"notes":"local test submission","ffmpegVersion":"test","encoderName":"libx264","clientVersion":"test","inputHash":"53a87df054e65d284bc808b8f73e62e938b815cb6aeec8379f904ad6d792aab8","runMs":10000}' + + printf '[%02d] RUN: %s\n' "$idx" "$name" + : > "$log" + echo "[command] POST $base_url (modern payload first, legacy fallback on schema mismatch)" >> "$log" + + local resp body code + resp="$(curl -sS -w $'\n__HTTP_CODE:%{http_code}' -X POST "$base_url" -H "Content-Type: application/json" -d "$modern_payload")" + body="${resp%__HTTP_CODE:*}" + code="${resp##*__HTTP_CODE:}" + { + echo "--- modern status: $code ---" + echo "$body" + } >> "$log" + + if [[ "$code" == "200" || "$code" == "201" ]]; then + record_step "$name" "PASS" "$log" "ok" + printf '[%02d] PASS: %s\n' "$idx" "$name" + return 0 + fi + + if [[ "$code" == "400" && "$body" == *"Unrecognized keys"* ]]; then + resp="$(curl -sS -w $'\n__HTTP_CODE:%{http_code}' -X POST "$base_url" -H "Content-Type: application/json" -d "$legacy_payload")" + body="${resp%__HTTP_CODE:*}" + code="${resp##*__HTTP_CODE:}" + { + echo "--- legacy fallback status: $code ---" + echo "$body" + } >> "$log" + if [[ "$code" == "200" || "$code" == "201" ]]; then + record_step "$name" "WARN" "$log" "legacy schema fallback used (modern fields rejected)" + printf '[%02d] WARN: %s (legacy schema fallback used)\n' "$idx" "$name" + return 0 + fi + fi + + record_step "$name" "FAIL" "$log" "submit rejected (status=$code)" + printf '[%02d] FAIL: %s (status=%s)\n' "$idx" "$name" "$code" + return 1 +} + +cleanup() { + if [[ -n "$FRONTEND_PID" ]] && kill -0 "$FRONTEND_PID" 2>/dev/null; then + kill "$FRONTEND_PID" 2>/dev/null || true + wait "$FRONTEND_PID" 2>/dev/null || true + fi + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [[ "$KEEP_DB" -ne 1 ]]; then + (cd "$ROOT_DIR" && docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1) || true + fi +} +trap cleanup EXIT + +echo "[test] Report directory: $RUN_DIR" +echo "[test] Base URLs: server=http://127.0.0.1:${SERVER_PORT} frontend=http://127.0.0.1:${FRONTEND_PORT}" +echo "[test] Database port: ${PG_PORT}" +echo "[test] Docker project: ${COMPOSE_PROJECT_NAME}" + +PRECHECK_OK=1 +SERVER_SETUP_OK=1 +SERVER_RUNNING_OK=1 +FRONTEND_BUILD_OK=1 + +run_step "Precheck: required commands" "command -v bash python3 node npm docker curl >/dev/null" +if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + PRECHECK_OK=0 +fi + +run_step "Precheck: key repository paths" "test -f \"$ROOT_DIR/scripts/client_test.sh\" && test -d \"$ROOT_DIR/client\" && test -d \"$ROOT_DIR/server\" && test -d \"$ROOT_DIR/frontend\"" +if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + PRECHECK_OK=0 +fi + +if [[ "$PRECHECK_OK" -eq 1 ]]; then + run_step "Client: compile Python modules" "PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m compileall client" + run_step "Client: import core modules" "python3 -c \"import client.config, client.network, client.ffmpeg, client.main\"" + run_step "Client: CLI help and localhost base URL wiring" "BASE_URL=http://127.0.0.1:${SERVER_PORT} scripts/client_test.sh --help" + + run_step "Docker: compose config validation" "docker compose -f \"$COMPOSE_FILE\" config -q" + run_step "Database: start container" "docker compose -f \"$COMPOSE_FILE\" up -d db" + run_step "Database: readiness wait" "for i in \$(seq 1 50); do docker compose -f \"$COMPOSE_FILE\" exec -T db pg_isready -U \"$PG_USER\" -d postgres >/dev/null 2>&1 && exit 0; sleep 2; done; echo 'Database not ready in time' >&2; exit 1" + run_step "Database: create test database if missing" "docker compose -f \"$COMPOSE_FILE\" exec -T db psql -U \"$PG_USER\" -d postgres -v ON_ERROR_STOP=1 -c \"CREATE DATABASE \\\"$DB_NAME\\\";\" 2>/dev/null || true" + + run_step "Server: npm ci" "cd \"$ROOT_DIR/server\" && npm ci --no-fund" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + SERVER_SETUP_OK=0 + fi + run_step "Server: prisma generate" "cd \"$ROOT_DIR/server\" && PRISMA_TELEMETRY_DISABLED=1 npx prisma generate" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + SERVER_SETUP_OK=0 + fi + run_step "Server: build" "cd \"$ROOT_DIR/server\" && npm run build" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + SERVER_SETUP_OK=0 + fi + run_step "Server: migrate deploy" "cd \"$ROOT_DIR/server\" && PRISMA_TELEMETRY_DISABLED=1 DATABASE_URL=\"$DATABASE_URL_LOCAL\" npx prisma migrate deploy" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + SERVER_SETUP_OK=0 + fi + run_step "Server: node test suite" "cd \"$ROOT_DIR/server\" && DATABASE_URL=\"$DATABASE_URL_LOCAL\" npm test" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + SERVER_SETUP_OK=0 + fi + + if [[ "$SERVER_SETUP_OK" -eq 1 ]]; then + start_server || SERVER_RUNNING_OK=0 + if [[ "$SERVER_RUNNING_OK" -eq 1 ]]; then + run_step "API: health live" "curl -fsS \"http://127.0.0.1:${SERVER_PORT}/health/live\" >/dev/null" + run_step "API: health ready" "curl -fsS \"http://127.0.0.1:${SERVER_PORT}/health/ready\" >/dev/null" + run_step "API: query returns array" "curl -fsS \"http://127.0.0.1:${SERVER_PORT}/query?limit=5\" | python3 -c \"import json,sys; data=json.load(sys.stdin); assert isinstance(data, list)\"" + run_api_submit_accepts_sample + run_step "API: submit method guard" "test \"\$(curl -s -o /dev/null -w '%{http_code}' -X GET \"http://127.0.0.1:${SERVER_PORT}/submit\")\" = \"405\"" + + run_step "Frontend: npm ci" "cd \"$ROOT_DIR/frontend\" && npm ci --no-fund" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + FRONTEND_BUILD_OK=0 + fi + run_step "Frontend: lint" "cd \"$ROOT_DIR/frontend\" && npm run lint" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + FRONTEND_BUILD_OK=0 + fi + run_step "Frontend: build" "cd \"$ROOT_DIR/frontend\" && INTERNAL_API_BASE_URL=\"http://127.0.0.1:${SERVER_PORT}\" NEXT_PUBLIC_API_BASE_URL=\"http://127.0.0.1:${SERVER_PORT}\" npm run build" + if [[ "$LAST_STATUS" == "FAIL" || "$LAST_STATUS" == "BLOCKED" ]]; then + FRONTEND_BUILD_OK=0 + fi + + if [[ "$FRONTEND_BUILD_OK" -eq 1 ]]; then + start_frontend || true + run_step "Frontend: homepage response" "code=\$(curl -s -o /dev/null -w '%{http_code}' \"http://127.0.0.1:${FRONTEND_PORT}/\"); test \"\$code\" = \"200\" -o \"\$code\" = \"304\"" + run_step "Frontend: leaderboard response" "code=\$(curl -s -o /dev/null -w '%{http_code}' \"http://127.0.0.1:${FRONTEND_PORT}/leaderboards\"); test \"\$code\" = \"200\" -o \"\$code\" = \"304\"" + else + mark_blocked "Frontend: start and route checks" "frontend build pipeline failed earlier" + fi + else + mark_blocked "API and frontend runtime checks" "server failed to start" + fi + else + mark_blocked "Server runtime checks" "server setup/build/test pipeline failed earlier" + mark_blocked "Frontend checks" "server pipeline failed, frontend integration skipped" + fi +else + mark_blocked "All functional checks" "precheck failed" +fi + +echo +echo "==================== Test Summary ====================" +pass_count=0 +warn_count=0 +fail_count=0 +for i in "${!STEP_NAMES[@]}"; do + status="${STEP_STATUS[$i]}" + case "$status" in + PASS) pass_count=$((pass_count + 1)) ;; + WARN) warn_count=$((warn_count + 1)) ;; + FAIL|BLOCKED) fail_count=$((fail_count + 1)) ;; + esac + printf '%02d. %-7s %-45s %s\n' "$((i + 1))" "$status" "${STEP_NAMES[$i]}" "${STEP_NOTES[$i]}" + printf ' log: %s\n' "${STEP_LOGS[$i]}" +done +echo "------------------------------------------------------" +echo "PASS=$pass_count WARN=$warn_count FAIL=$fail_count" +echo "Report directory: $RUN_DIR" + +if [[ "$OVERALL" -eq 2 ]]; then + echo "Overall result: FAIL" + exit 1 +fi +if [[ "$OVERALL" -eq 1 ]]; then + echo "Overall result: WARN (treated as failure by policy)" + exit 2 +fi +echo "Overall result: PASS" +exit 0 diff --git a/server/env.example b/server/env.example index 6eb029b..426f9bb 100644 --- a/server/env.example +++ b/server/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/server/package-lock.json b/server/package-lock.json index 47cab0b..23d1996 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -28,24 +28,10 @@ "@types/node": "^24.6.1", "@types/uuid": "^9.0.7", "prisma": "^6.16.3", - "ts-node-dev": "^2.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -488,34 +474,6 @@ "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@prisma/client": { "version": "6.16.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz", @@ -608,34 +566,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -774,20 +704,6 @@ "@types/send": "*" } }, - "node_modules/@types/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/strip-json-comments": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", - "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -808,60 +724,6 @@ "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -880,19 +742,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -917,37 +766,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1108,13 +926,6 @@ "node": ">= 0.6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -1184,13 +995,6 @@ "node": ">= 0.10" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1241,16 +1045,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -1277,16 +1071,6 @@ "node": ">= 0.4" } }, - "node_modules/dynamic-dedupe": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", - "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1497,19 +1281,6 @@ "node": ">=8.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -1545,13 +1316,6 @@ "node": ">= 0.8" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1644,41 +1408,6 @@ "giget": "dist/cli.mjs" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1765,18 +1494,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1792,68 +1509,6 @@ "node": ">= 0.10" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1870,13 +1525,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1928,42 +1576,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -2029,16 +1641,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -2126,23 +1728,6 @@ "node": ">= 0.8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -2167,19 +1752,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -2328,27 +1900,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2359,20 +1910,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2530,27 +2067,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2560,39 +2076,6 @@ "node": ">= 0.8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -2600,19 +2083,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2622,146 +2092,6 @@ "node": ">=0.6" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node-dev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", - "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.1", - "dynamic-dedupe": "^0.3.0", - "minimist": "^1.2.6", - "mkdirp": "^1.0.4", - "resolve": "^1.0.0", - "rimraf": "^2.6.1", - "source-map-support": "^0.5.12", - "tree-kill": "^1.2.2", - "ts-node": "^10.4.0", - "tsconfig": "^7.0.0" - }, - "bin": { - "ts-node-dev": "lib/bin.js", - "tsnd": "lib/bin.js" - }, - "engines": { - "node": ">=0.8.0" - }, - "peerDependencies": { - "node-notifier": "*", - "typescript": "*" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/ts-node-dev/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/ts-node-dev/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/tsconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", - "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/strip-bom": "^3.0.0", - "@types/strip-json-comments": "0.0.30", - "strip-bom": "^3.0.0", - "strip-json-comments": "^2.0.0" - } - }, "node_modules/tsx": { "version": "4.20.6", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", @@ -2839,13 +2169,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2861,26 +2184,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/zod": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", diff --git a/server/package.json b/server/package.json index bc2a2af..2970b95 100644 --- a/server/package.json +++ b/server/package.json @@ -33,8 +33,7 @@ "@types/morgan": "^1.9.9", "@types/uuid": "^9.0.7", "prisma": "^6.16.3", - "ts-node-dev": "^2.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index c6a407c..24dca48 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -28,8 +28,6 @@ model Benchmark { codec String preset String crf Int @default(24) - - // Multi-content and resolution (Sprint 5) contentClass String @default("mixed") resolution String @default("1080p") passes Int @default(1) @@ -123,14 +121,14 @@ model Benchmark { @@index([status]) @@index([codec]) @@index([preset]) + @@index([contentClass]) + @@index([resolution]) + @@index([contentClass, resolution]) @@index([inputHash]) @@index([codec, preset]) @@index([status, createdAt]) @@index([cpuModel]) @@index([gpuModel]) - @@index([contentClass]) - @@index([resolution]) - @@index([contentClass, resolution]) @@unique([cpuModel, gpuModel, ramGB, os, codec, preset, crf, contentClass, resolution, passes]) } @@ -149,8 +147,6 @@ model Submission { codec String preset String crf Int @default(24) - - // Multi-content and resolution (Sprint 5) contentClass String @default("mixed") resolution String @default("1080p") passes Int @default(1) @@ -205,9 +201,10 @@ model Submission { @@index([status]) @@index([codec]) @@index([preset]) - @@index([codec, preset]) @@index([contentClass]) @@index([resolution]) + @@index([contentClass, resolution]) + @@index([codec, preset]) @@index([cpuModel, gpuModel, ramGB, os, codec, preset, crf, contentClass, resolution, passes]) } @@ -223,5 +220,6 @@ model TestVideo { createdAt DateTime @default(now()) @@index([contentClass]) + @@index([resolution]) @@index([contentClass, resolution]) } diff --git a/server/src/index.ts b/server/src/index.ts index dcd857f..1763a28 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,8 +12,21 @@ import crypto from 'node:crypto'; const app = express(); -// Trust proxy when behind nginx/reverse proxy -app.set('trust proxy', 1); +const isProd = process.env.NODE_ENV === 'production'; + +function parseTrustProxySetting(value: string | undefined): boolean | number | string { + const raw = String(value || '').trim(); + if (!raw) return isProd ? 1 : false; + const lower = raw.toLowerCase(); + if (lower === 'true') return true; + if (lower === 'false') return false; + const asNumber = Number(raw); + if (Number.isInteger(asNumber) && asNumber >= 0) return asNumber; + return raw; +} + +// Trust reverse-proxy headers only when explicitly configured or in production. +app.set('trust proxy', parseTrustProxySetting(process.env.TRUST_PROXY)); // Hide implementation header app.disable('x-powered-by'); @@ -50,7 +63,6 @@ app.use(morgan((tokens: any, req, res) => { })); // CORS configuration (defaults assume hosting behind Nginx Proxy Manager at encodingdb.platinumlabs.dev) -const isProd = process.env.NODE_ENV === 'production'; const corsOriginEnv = process.env.CORS_ORIGIN || (isProd ? 'https://encodingdb.platinumlabs.dev' : '*'); const allowedOrigins = corsOriginEnv.split(',').map(v => v.trim()).filter(Boolean); const isWildcard = corsOriginEnv === '*'; diff --git a/server/src/routes.ts b/server/src/routes.ts index 8e19f3c..6dd1cc2 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -23,7 +23,7 @@ const benchmarkSchema = z.object({ crf: z.coerce.number().int().min(0).max(63).optional().nullable(), contentClass: z.enum(VALID_CONTENT_CLASSES).optional().nullable(), resolution: z.enum(VALID_RESOLUTIONS).optional().nullable(), - passes: z.coerce.number().int().min(1).max(2).optional().nullable(), + passes: z.coerce.number().int().min(1).max(1).optional().nullable(), fps: z.coerce.number().nonnegative().max(5000), vmaf: z.coerce.number().min(0).max(100).optional().nullable(), ssim: z.coerce.number().min(0).max(1).optional().nullable(), @@ -60,17 +60,44 @@ const benchmarkSchema = z.object({ monitorDurationMs: z.coerce.number().int().min(0).max(24 * 60 * 60 * 1000).optional().nullable(), }).strict(); +const CPU_FREQ_MIN_MHZ = 100; +const CPU_FREQ_MAX_MHZ = 10_000; + +export function normalizeCpuFreqMHz(value: unknown): number | null { + const raw = Number(value); + if (!Number.isFinite(raw) || raw <= 0) return null; + + const candidates = [ + raw, // already MHz + raw * 1000, // GHz -> MHz + raw / 1000, // KHz -> MHz + raw / 1_000_000, // Hz -> MHz + ]; + const plausible = candidates.filter((n, i, arr) => { + if (!Number.isFinite(n)) return false; + if (n < CPU_FREQ_MIN_MHZ || n > CPU_FREQ_MAX_MHZ) return false; + return arr.findIndex((x) => x === n) === i; + }); + if (plausible.length === 0) return null; + + if (raw >= CPU_FREQ_MIN_MHZ && raw <= CPU_FREQ_MAX_MHZ) { + return raw; + } + if (raw > 0 && raw <= 15) { + return raw * 1000; + } + return plausible.reduce((best, current) => ( + Math.abs(current - 3000) < Math.abs(best - 3000) ? current : best + )); +} + // Type inferred from Zod schema for proper type safety type BenchmarkSubmission = z.infer; -type SubmissionDimensions = { - contentClass: typeof VALID_CONTENT_CLASSES[number]; - resolution: typeof VALID_RESOLUTIONS[number]; - passes: number; -}; - -export function buildSubmissionPayloadHash(data: BenchmarkSubmission, dims: SubmissionDimensions): string { +export function buildSubmissionPayloadHash(data: BenchmarkSubmission): string { const normalizedGpuModel = (data.gpuModel && data.gpuModel.trim()) ? data.gpuModel.trim() : ''; + const contentClassValue = data.contentClass ?? 'mixed'; + const resolutionValue = data.resolution ?? '1080p'; const significant = { cpuModel: data.cpuModel, gpuModel: normalizedGpuModel, @@ -79,9 +106,9 @@ export function buildSubmissionPayloadHash(data: BenchmarkSubmission, dims: Subm codec: data.codec, preset: data.preset, crf: Number(data.crf ?? 24), - contentClass: dims.contentClass, - resolution: dims.resolution, - passes: dims.passes === 2 ? 2 : 1, + contentClass: contentClassValue, + resolution: resolutionValue, + passes: 1, fps: Number(data.fps), vmaf: data.vmaf ?? null, ssim: data.ssim ?? null, @@ -222,15 +249,15 @@ router.post('/submit', async (req, res) => { if (data.crf == null || !Number.isFinite(Number(data.crf))) { data.crf = 24; } + if (data.passes == null) { + data.passes = 1; + } _applyTelemetryFallback(data); + data.cpuFreqAvgMHz = normalizeCpuFreqMHz(data.cpuFreqAvgMHz); const contentClassValue = data.contentClass ?? 'mixed'; const resolutionValue = data.resolution ?? '1080p'; - const passesValue = data.passes ?? 1; - const payloadHash = buildSubmissionPayloadHash(data, { - contentClass: contentClassValue, - resolution: resolutionValue, - passes: passesValue, - }); + const passesValue: 1 = 1; + const payloadHash = buildSubmissionPayloadHash(data); try { // Fast path: if the exact same payload was already counted, return existing (idempotency) @@ -411,7 +438,10 @@ router.post('/submit', async (req, res) => { }, }); - const existing = await tx.benchmark.findUnique({ where: { cpuModel_gpuModel_ramGB_os_codec_preset_crf_contentClass_resolution_passes: key } }); + const existing = await tx.benchmark.findFirst({ + where: key, + orderBy: { createdAt: 'desc' }, + }); if (!existing) { createdNew = true; // For new benchmarks, only count as sample if accepted @@ -541,7 +571,7 @@ router.post('/submit', async (req, res) => { if (status !== 'accepted') { const nextStatus = existing.status === 'accepted' ? 'accepted' : (existing.status ?? status); return tx.benchmark.update({ - where: { cpuModel_gpuModel_ramGB_os_codec_preset_crf_contentClass_resolution_passes: key }, + where: { id: existing.id }, data: { status: nextStatus }, }); } @@ -739,20 +769,11 @@ router.post('/submit', async (req, res) => { END, "status" = 'accepted', "updatedAt" = NOW() - WHERE "cpuModel" = ${key.cpuModel} - AND "gpuModel" = ${key.gpuModel} - AND "ramGB" = ${key.ramGB} - AND "os" = ${key.os} - AND "codec" = ${key.codec} - AND "preset" = ${key.preset} - AND "crf" = ${key.crf} - AND "contentClass" = ${key.contentClass} - AND "resolution" = ${key.resolution} - AND "passes" = ${key.passes} + WHERE "id" = ${existing.id} `; // Return the updated row - const updated = await tx.benchmark.findUnique({ where: { cpuModel_gpuModel_ramGB_os_codec_preset_crf_contentClass_resolution_passes: key } }); + const updated = await tx.benchmark.findUnique({ where: { id: existing.id } }); if (!updated) throw new Error('Row disappeared after atomic update'); return updated; }); @@ -867,21 +888,21 @@ router.get('/query', async (req, res) => { const skip = Number.isFinite(rawSkip) && rawSkip > 0 ? rawSkip : undefined; const where: Record = { status: 'accepted' }; - if (query.contentClass && VALID_CONTENT_CLASSES.includes(query.contentClass as typeof VALID_CONTENT_CLASSES[number])) { - where.contentClass = query.contentClass; - } - if (query.resolution && VALID_RESOLUTIONS.includes(query.resolution as typeof VALID_RESOLUTIONS[number])) { - where.resolution = query.resolution; - } if (query.passes) { const p = Number(query.passes); - if (p === 1 || p === 2) where.passes = p; + if (p === 1) where.passes = 1; } // Sprint 4: additional filters if (query.codec) where.codec = query.codec; if (query.codecSearch) where.codec = { contains: query.codecSearch, mode: 'insensitive' }; if (query.cpu) where.cpuModel = { contains: query.cpu, mode: 'insensitive' }; if (query.gpu) where.gpuModel = { contains: query.gpu, mode: 'insensitive' }; + if (query.contentClass && VALID_CONTENT_CLASSES.includes(query.contentClass as typeof VALID_CONTENT_CLASSES[number])) { + where.contentClass = query.contentClass; + } + if (query.resolution && VALID_RESOLUTIONS.includes(query.resolution as typeof VALID_RESOLUTIONS[number])) { + where.resolution = query.resolution; + } if (query.powerSource === 'ac' || query.powerSource === 'battery') { where.powerSource = query.powerSource; } @@ -976,7 +997,7 @@ router.get('/query', async (req, res) => { // Test video catalog (Sprint 5) export const TEST_VIDEO_CATALOG = [ - { name: 'sample.mp4', contentClass: 'mixed', resolution: '1080p', duration: 20.0, sha256: '53a87df054e65d284bc808b8f73e62e938b815cb6aeec8379f904ad6d792aab8', sizeBytes: 66045059 }, + { name: 'sample.mp4', duration: 20.0, sha256: '53a87df054e65d284bc808b8f73e62e938b815cb6aeec8379f904ad6d792aab8', sizeBytes: 66045059 }, ]; router.get('/test-videos', (_req, res) => { diff --git a/server/src/seedDummyDatabase.ts b/server/src/seedDummyDatabase.ts index 7522c76..d527f52 100644 --- a/server/src/seedDummyDatabase.ts +++ b/server/src/seedDummyDatabase.ts @@ -1,10 +1,7 @@ -import { PrismaClient, type Prisma } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -const CONTENT_CLASSES = ['mixed', 'talkingHead', 'action', 'animation', 'screen', 'nature', 'gaming'] as const; -const RESOLUTIONS = ['480p', '720p', '1080p', '1440p', '4k'] as const; - const CPU_MODELS = [ 'AMD Ryzen 9 7950X', 'AMD Ryzen 7 7800X3D', @@ -72,7 +69,6 @@ const PRESETS = [ ]; const CRFS = [18, 22, 26, 30]; -const PASSES = [1, 2]; const FPS_CODEC_FACTOR: Record = { libx264: 1.0, @@ -138,21 +134,8 @@ const FPS_PRESET_FACTOR: Record = { hq: 0.88, }; -const RESOLUTION_SPEED_FACTOR: Record = { - '480p': 2.0, - '720p': 1.45, - '1080p': 1.0, - '1440p': 0.63, - '4k': 0.31, -}; - -const BASE_SIZE_MB: Record = { - '480p': 24, - '720p': 48, - '1080p': 92, - '1440p': 145, - '4k': 285, -}; +const SINGLE_SAMPLE_SPEED_FACTOR = 1.0; +const BASE_SAMPLE_SIZE_MB = 92; function jitter(seed: number): number { const x = Math.sin(seed * 12.9898) * 43758.5453123; @@ -221,7 +204,7 @@ function gpuToPowerBase(gpuModel: string): number { return 130; } -function buildRow(index: number): Prisma.BenchmarkCreateManyInput { +function buildRow(index: number) { const seed = index + 1; const cpuModel = pickBySeed(CPU_MODELS, seed, 3); const gpuModel = pickBySeed(GPU_MODELS, seed, 5); @@ -229,28 +212,23 @@ function buildRow(index: number): Prisma.BenchmarkCreateManyInput { const codec = CODECS[(index * 11 + 3) % CODECS.length]!; const preset = PRESETS[(index * 13 + 5) % PRESETS.length]!; const crf = CRFS[(index * 17 + 2) % CRFS.length]!; - const contentClass = CONTENT_CLASSES[index % CONTENT_CLASSES.length]!; - const resolution = RESOLUTIONS[Math.floor(index / CONTENT_CLASSES.length) % RESOLUTIONS.length]!; - const passes = PASSES[Math.floor(index / (CONTENT_CLASSES.length * RESOLUTIONS.length)) % PASSES.length]!; + const passes = 1; const gpuSpeed = gpuToSpeedFactor(gpuModel); const codecSpeed = FPS_CODEC_FACTOR[codec] ?? 1; const presetSpeed = FPS_PRESET_FACTOR[preset] ?? 1; - const resolutionSpeed = RESOLUTION_SPEED_FACTOR[resolution] ?? 1; - const passSpeed = passes === 2 ? 0.66 : 1.0; const crfSpeed = 1 + (crf - 24) * 0.018; - const baseFps = 36 * gpuSpeed * codecSpeed * presetSpeed * resolutionSpeed * passSpeed * crfSpeed; + const baseFps = 36 * gpuSpeed * codecSpeed * presetSpeed * SINGLE_SAMPLE_SPEED_FACTOR * crfSpeed; const fps = round2(clamp(baseFps * (0.9 + jitter(seed) * 0.2), 4, 420)); - const qualityBase = 95 - (crf - 18) * 1.5 + (QUALITY_CODEC_BONUS[codec] ?? 0) + (passes === 2 ? 0.9 : 0) + (jitter(seed + 7) - 0.5) * 2.4; + const qualityBase = 95 - (crf - 18) * 1.5 + (QUALITY_CODEC_BONUS[codec] ?? 0) + (jitter(seed + 7) - 0.5) * 2.4; const vmaf = round2(clamp(qualityBase, 50, 99.5)); const ssim = round4(clamp(0.81 + (vmaf - 60) * 0.0044 + (jitter(seed + 11) - 0.5) * 0.008, 0.72, 0.999)); const psnr = round2(clamp(24 + (vmaf - 60) * 0.42 + (jitter(seed + 13) - 0.5) * 1.8, 20, 56)); - const sizeBase = (BASE_SIZE_MB[resolution] ?? 100) * (SIZE_CODEC_FACTOR[codec] ?? 1); + const sizeBase = BASE_SAMPLE_SIZE_MB * (SIZE_CODEC_FACTOR[codec] ?? 1); const crfScale = 1 - (crf - 24) * 0.04; - const passScale = passes === 2 ? 0.92 : 1.0; - const sizeMb = sizeBase * crfScale * passScale * (0.9 + jitter(seed + 17) * 0.2); + const sizeMb = sizeBase * crfScale * (0.9 + jitter(seed + 17) * 0.2); const fileSizeBytes = Math.max(120_000, Math.round(sizeMb * 1024 * 1024)); const samples = 3 + Math.floor(jitter(seed + 19) * 10); @@ -270,8 +248,6 @@ function buildRow(index: number): Prisma.BenchmarkCreateManyInput { codec, preset, crf, - contentClass, - resolution, passes, fps, vmaf, @@ -320,7 +296,7 @@ async function main(): Promise { } const targetCount = Math.min(readCountEnv('SEED_DUMMY_BENCHMARKS_COUNT', 480), 5_000); - const rows: Prisma.BenchmarkCreateManyInput[] = []; + const rows: Array> = []; for (let i = 0; i < targetCount; i += 1) { rows.push(buildRow(i)); } diff --git a/server/test/routes.smoke.test.js b/server/test/routes.smoke.test.js index f61d95f..ded5261 100644 --- a/server/test/routes.smoke.test.js +++ b/server/test/routes.smoke.test.js @@ -2,7 +2,7 @@ import test, { after } from 'node:test'; import assert from 'node:assert/strict'; import net from 'node:net'; import express from 'express'; -import routes, { buildSubmissionPayloadHash, DEFAULT_QUERY_LIMIT, TEST_VIDEO_CATALOG } from '../dist/routes.js'; +import routes, { buildSubmissionPayloadHash, DEFAULT_QUERY_LIMIT, TEST_VIDEO_CATALOG, normalizeCpuFreqMHz } from '../dist/routes.js'; import { prisma } from '../dist/db.js'; process.env.DATABASE_URL ||= 'postgresql://app:app@localhost:5432/benchmarks?schema=public'; @@ -123,6 +123,36 @@ test('POST /submit rejects invalid payloads with 400', async (t) => { } }); +test('POST /submit rejects non-single-pass payloads', async (t) => { + if (!CAN_BIND_LOOPBACK) { + t.skip('Loopback listen is unavailable in this runtime'); + return; + } + const { server, baseUrl } = await startTestServer(); + try { + const res = await fetch(`${baseUrl}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cpuModel: 'Intel Core i7-14700K', + ramGB: 32, + os: 'Windows 11', + codec: 'libx264', + preset: 'fast', + passes: 2, + fps: 120.5, + fileSizeBytes: 123_456_789, + }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error, 'Invalid payload'); + assert.ok(body.details?.fieldErrors?.passes, 'Expected passes field validation error'); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + test('buildSubmissionPayloadHash changes when telemetry changes', () => { const base = { cpuModel: 'Intel Core i7-14700K', @@ -169,8 +199,32 @@ test('buildSubmissionPayloadHash changes when telemetry changes', () => { monitorDurationMs: 125000, }; const variant = { ...base, cpuUtilMax: 99.1 }; - const h1 = buildSubmissionPayloadHash(base, { contentClass: 'mixed', resolution: '1080p', passes: 1 }); - const h2 = buildSubmissionPayloadHash(variant, { contentClass: 'mixed', resolution: '1080p', passes: 1 }); + const h1 = buildSubmissionPayloadHash(base); + const h2 = buildSubmissionPayloadHash(variant); + assert.notEqual(h1, h2); +}); + +test('buildSubmissionPayloadHash changes when content dimensions change', () => { + const base = { + cpuModel: 'Intel Core i7-14700K', + gpuModel: 'NVIDIA RTX 4070', + ramGB: 32, + os: 'Windows 11', + codec: 'libx264', + preset: 'fast', + crf: 24, + contentClass: 'mixed', + resolution: '1080p', + passes: 1, + fps: 120.5, + vmaf: 95.3, + ssim: 0.98, + psnr: 41.2, + fileSizeBytes: 123_456_789, + }; + const variant = { ...base, contentClass: 'action', resolution: '720p' }; + const h1 = buildSubmissionPayloadHash(base); + const h2 = buildSubmissionPayloadHash(variant); assert.notEqual(h1, h2); }); @@ -187,3 +241,12 @@ test('TEST_VIDEO_CATALOG has no placeholders', () => { assert.ok(Number(row.sizeBytes) > 0); } }); + +test('normalizeCpuFreqMHz converts GHz-like values and drops invalid telemetry', () => { + assert.equal(normalizeCpuFreqMHz(4), 4000); + assert.equal(normalizeCpuFreqMHz(4050), 4050); + assert.equal(normalizeCpuFreqMHz(4_050_000), 4050); + assert.equal(normalizeCpuFreqMHz(0), null); + assert.equal(normalizeCpuFreqMHz(-1), null); + assert.equal(normalizeCpuFreqMHz('not-a-number'), null); +}); From 9fbd1ce983a2123a513a2a0b93bc9c56b4666d3a Mon Sep 17 00:00:00 2001 From: ofhd Date: Thu, 19 Feb 2026 13:55:03 -0800 Subject: [PATCH 5/7] Document v1.1.0 changelog --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index ee1284f..f3c0953 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,52 @@ Encoding Database is an open benchmarking platform for video encoding performanc 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. + +### Sprint completion snapshot + +- Sprint 1: Data Integrity (`13/13` complete) +- Sprint 2: Core Optimizations (`17/17` complete) +- Sprint 3: SSIM + PSNR (`15/15` complete) +- Sprint 4: Frontend Overhaul (`12/13` complete) +- Sprint 6: Hardware Intelligence (`9/9` complete) +- Sprint 5: CRF single-pass policy enforcement shipped (`passes=1`), broader multi-content rollout still pending + +### 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. +- Added lazy-loaded charts and canvas-based scatter rendering for significantly better chart performance. +- 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: From 8e9af16ef2c9a429b478377d01b2803670fa73a2 Mon Sep 17 00:00:00 2001 From: ofhd Date: Thu, 19 Feb 2026 13:56:09 -0800 Subject: [PATCH 6/7] readme --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index f3c0953..4a6f969 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,6 @@ With the changes brought by version *v1.1.0*, the project has moved well beyond This release documents work completed since `v1.0.2` and reflects a major platform overhaul. -### Sprint completion snapshot - -- Sprint 1: Data Integrity (`13/13` complete) -- Sprint 2: Core Optimizations (`17/17` complete) -- Sprint 3: SSIM + PSNR (`15/15` complete) -- Sprint 4: Frontend Overhaul (`12/13` complete) -- Sprint 6: Hardware Intelligence (`9/9` complete) -- Sprint 5: CRF single-pass policy enforcement shipped (`passes=1`), broader multi-content rollout still pending - ### Client (Python benchmark runner) - Reworked benchmark execution to avoid double-encoding and measure speed/size/quality from one artifact. From 1964abdd9d98bfdeeca107c288933f5fa346471f Mon Sep 17 00:00:00 2001 From: ofhd Date: Thu, 19 Feb 2026 14:02:30 -0800 Subject: [PATCH 7/7] readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 4a6f969..39abd10 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ This release documents work completed since `v1.0.2` and reflects a major platfo ### Frontend (Next.js analytics platform) - Overhauled large-dataset handling with virtualized benchmark tables, server-side filtering, and pagination. -- Added lazy-loaded charts and canvas-based scatter rendering for significantly better chart performance. - 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.