diff --git a/.gitignore b/.gitignore index 81d5ac8..bcfe412 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ brightness_analyzer_settings.json *.csv *.png +# Web frontend +node_modules/ +*.tsbuildinfo +.playwright-mcp/ + # IDE .vscode/ .idea/ @@ -34,3 +39,6 @@ brightness_analyzer_settings.json # OS .DS_Store Thumbs.db + +# Old v2 spike data +.ecl_v2_data/ diff --git a/README.md b/README.md index 4950ef0..0365663 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,23 @@ pip install -e ".[audio,interactive-plots]" python main.py ``` +### Web UI (new) + +A browser-based UI is available alongside the desktop app. It runs a local +server that decodes frames and executes the same analysis pipeline. + +```bash +# One-time: install web extras and build the frontend (requires bun) +pip install -e ".[web]" +cd web && bun install && bun run build && cd .. + +# Run — then open http://127.0.0.1:8765 +python -m ecl_analysis.server +``` + +For frontend development, run `bun run dev` in `web/` (Vite proxies `/api` to +the server on port 8765) and open the printed URL instead. + Only need the core features? `pip install -e .` skips the optional `pygame`/`librosa`/`soundfile`/`plotly` extras. ### Updating When New Code Is Pushed diff --git a/ecl_analysis/analysis/__init__.py b/ecl_analysis/analysis/__init__.py index f6eac16..6bb1955 100644 --- a/ecl_analysis/analysis/__init__.py +++ b/ecl_analysis/analysis/__init__.py @@ -4,14 +4,19 @@ from .brightness import compute_brightness, compute_brightness_stats, compute_l_star_frame from .duration import validate_run_duration from .models import AnalysisRequest, AnalysisResult +from .runner import AnalysisCancelled, AnalysisRunError, normalized_slice_bounds, run_analysis __all__ = [ + "AnalysisCancelled", "AnalysisRequest", "AnalysisResult", + "AnalysisRunError", "BackgroundComputationError", "compute_background_brightness", "compute_brightness", "compute_brightness_stats", "compute_l_star_frame", + "normalized_slice_bounds", + "run_analysis", "validate_run_duration", ] diff --git a/ecl_analysis/analysis/runner.py b/ecl_analysis/analysis/runner.py new file mode 100644 index 0000000..4e4f2b2 --- /dev/null +++ b/ecl_analysis/analysis/runner.py @@ -0,0 +1,201 @@ +"""UI-free execution of frame analysis requests. + +This module owns the core frame loop so that both the PyQt worker +(:class:`ecl_analysis.workers.AnalysisWorker`) and the local web API can run +identical analyses. It has no Qt or HTTP dependencies: callers provide plain +callables for progress reporting and cancellation checks. +""" + +from __future__ import annotations + +import time +from typing import Callable, List, Optional, Tuple + +import cv2 +import numpy as np + +from .background import compute_background_brightness +from .brightness import compute_brightness_stats, compute_l_star_frame +from .models import AnalysisRequest, AnalysisResult + +ProgressCallback = Callable[[int, int], None] +MessageCallback = Callable[[str], None] +CancelCheck = Callable[[], bool] + + +class AnalysisCancelled(Exception): + """Raised when a cancel check reports True mid-run.""" + + +class AnalysisRunError(RuntimeError): + """Raised when an analysis run cannot start or produce valid results.""" + + +def normalized_slice_bounds( + pt1: Tuple[int, int], + pt2: Tuple[int, int], + frame_width: int, + frame_height: int, +) -> Tuple[int, int, int, int]: + """Return clamped, normalized ROI bounds for NumPy slicing (exclusive end).""" + left, right = sorted((int(pt1[0]), int(pt2[0]))) + top, bottom = sorted((int(pt1[1]), int(pt2[1]))) + + x1 = max(0, min(left, frame_width)) + x2 = max(0, min(right, frame_width)) + y1 = max(0, min(top, frame_height)) + y2 = max(0, min(bottom, frame_height)) + + return x1, y1, x2, y2 + + +def run_analysis( + request: AnalysisRequest, + progress_callback: Optional[ProgressCallback] = None, + message_callback: Optional[MessageCallback] = None, + cancel_check: Optional[CancelCheck] = None, +) -> AnalysisResult: + """Execute a frame analysis request and return its result. + + Raises AnalysisRunError when the video cannot be opened or the first frame + fails to read, and AnalysisCancelled when ``cancel_check`` returns True. + Computation errors (e.g. cv2.error) propagate to the caller so that a + faulty run is never silently converted into fabricated measurements. + """ + req = request + total_frames = req.end_frame - req.start_frame + 1 + non_background_rois = [i for i in range(len(req.rects)) if i != req.background_roi_idx] + + brightness_mean_data: List[List[float]] = [[] for _ in non_background_rois] + brightness_median_data: List[List[float]] = [[] for _ in non_background_rois] + blue_mean_data: List[List[float]] = [[] for _ in non_background_rois] + blue_median_data: List[List[float]] = [[] for _ in non_background_rois] + background_values_per_frame: List[float] = [] + + def cancelled() -> bool: + return cancel_check is not None and cancel_check() + + start_time = time.time() + cap = cv2.VideoCapture(req.video_path) + if not cap.isOpened(): + raise AnalysisRunError(f"Could not open video file: {req.video_path}") + + try: + cap.set(cv2.CAP_PROP_POS_FRAMES, req.start_frame) + frames_processed = 0 + truncated = False + + for _frame_idx in range(req.start_frame, req.end_frame + 1): + if cancelled(): + raise AnalysisCancelled() + + ret, frame = cap.read() + if not ret: + if frames_processed == 0: + raise AnalysisRunError("Failed to read first frame during analysis.") + brightness_mean_data = [lst[:frames_processed] for lst in brightness_mean_data] + brightness_median_data = [lst[:frames_processed] for lst in brightness_median_data] + blue_mean_data = [lst[:frames_processed] for lst in blue_mean_data] + blue_median_data = [lst[:frames_processed] for lst in blue_median_data] + truncated = True + break + + l_star_frame = compute_l_star_frame(frame) + background_value = compute_background_brightness( + frame=frame, + rects=req.rects, + background_roi_idx=req.background_roi_idx, + background_percentile=req.background_percentile, + frame_l_star=l_star_frame, + ) + if req.background_roi_idx is None and req.manual_threshold > 0: + # Manual threshold mode: no background ROI configured, so the + # user-set manual threshold acts as the active threshold. + background_value = req.manual_threshold + background_values_per_frame.append(background_value if background_value is not None else 0.0) + + frame_height, frame_width = frame.shape[:2] + for data_idx, roi_idx in enumerate(non_background_rois): + pt1, pt2 = req.rects[roi_idx] + x1, y1, x2, y2 = normalized_slice_bounds(pt1, pt2, frame_width, frame_height) + + if x2 > x1 and y2 > y1: + roi = frame[y1:y2, x1:x2] + roi_l_star = l_star_frame[y1:y2, x1:x2] + roi_mask = None + if req.use_fixed_mask and roi_idx < len(req.fixed_roi_masks): + candidate_mask = req.fixed_roi_masks[roi_idx] + if isinstance(candidate_mask, np.ndarray) and candidate_mask.shape[:2] == roi.shape[:2]: + roi_mask = candidate_mask + + ( + l_raw_mean, + l_raw_median, + l_bg_sub_mean, + l_bg_sub_median, + b_raw_mean, + b_raw_median, + b_bg_sub_mean, + b_bg_sub_median, + ) = compute_brightness_stats( + roi_bgr=roi, + background_brightness=background_value, + roi_mask=roi_mask, + roi_l_star=roi_l_star, + morphological_kernel_size=req.morphological_kernel_size, + noise_floor_threshold=req.noise_floor_threshold, + ) + + if background_value is not None: + brightness_mean_data[data_idx].append(l_bg_sub_mean) + brightness_median_data[data_idx].append(l_bg_sub_median) + blue_mean_data[data_idx].append(b_bg_sub_mean) + blue_median_data[data_idx].append(b_bg_sub_median) + else: + brightness_mean_data[data_idx].append(l_raw_mean) + brightness_median_data[data_idx].append(l_raw_median) + blue_mean_data[data_idx].append(b_raw_mean) + blue_median_data[data_idx].append(b_raw_median) + else: + brightness_mean_data[data_idx].append(0.0) + brightness_median_data[data_idx].append(0.0) + blue_mean_data[data_idx].append(0.0) + blue_median_data[data_idx].append(0.0) + + frames_processed += 1 + + if message_callback is not None and frames_processed % 10 == 0: + elapsed = time.time() - start_time + fps = frames_processed / elapsed if elapsed > 0 else 0.0 + remaining = total_frames - frames_processed + eta_seconds = remaining / fps if fps > 0 else 0.0 + message_callback( + f"Analyzing frame {frames_processed}/{total_frames} • " + f"Speed: {fps:.1f} fps • ETA: {eta_seconds:.0f}s" + ) + + if progress_callback is not None: + progress_callback(frames_processed, total_frames) + else: + frames_processed = total_frames + + if cancelled(): + raise AnalysisCancelled() + + elapsed_seconds = time.time() - start_time + return AnalysisResult( + brightness_mean_data=brightness_mean_data, + brightness_median_data=brightness_median_data, + blue_mean_data=blue_mean_data, + blue_median_data=blue_median_data, + background_values_per_frame=background_values_per_frame, + frames_processed=frames_processed, + total_frames=total_frames, + non_background_rois=non_background_rois, + elapsed_seconds=elapsed_seconds, + start_frame=req.start_frame, + end_frame=req.end_frame, + truncated=truncated, + ) + finally: + cap.release() diff --git a/ecl_analysis/analysis/scans.py b/ecl_analysis/analysis/scans.py new file mode 100644 index 0000000..1137b18 --- /dev/null +++ b/ecl_analysis/analysis/scans.py @@ -0,0 +1,248 @@ +"""UI-free brightest-frame and per-ROI mask capture scans. + +Like :mod:`.runner`, these functions own loops that were previously embedded in +Qt workers so the desktop app and the web server share one implementation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence + +import cv2 +import numpy as np + +from .background import compute_background_brightness +from .brightness import compute_l_star_frame +from .models import RoiRect +from .runner import ( + AnalysisCancelled, + AnalysisRunError, + CancelCheck, + MessageCallback, + ProgressCallback, + normalized_slice_bounds, +) + + +@dataclass(frozen=True) +class MaskScanRequest: + """Immutable scan inputs for brightest-frame mask workflows.""" + + video_path: str + rects: Sequence[RoiRect] + background_roi_idx: Optional[int] + start_frame: int + end_frame: int + step: int + background_percentile: float + morphological_kernel_size: int + + +@dataclass +class BrightestFrameResult: + """Result payload for global brightest frame detection.""" + + brightest_frame_idx: int + max_brightness: float + + +@dataclass +class PerRoiMaskCaptureResult: + """Result payload for per-ROI mask capture.""" + + masks: List[Optional[np.ndarray]] + sources: List[Optional[int]] + max_brightness: Dict[int, float] + + +def _check_cancelled(cancel_check: Optional[CancelCheck]) -> None: + if cancel_check is not None and cancel_check(): + raise AnalysisCancelled() + + +def find_brightest_frame( + request: MaskScanRequest, + progress_callback: Optional[ProgressCallback] = None, + message_callback: Optional[MessageCallback] = None, + cancel_check: Optional[CancelCheck] = None, +) -> BrightestFrameResult: + """Find the single frame with the highest mean L* across non-background ROIs.""" + req = request + frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) + if not frame_indices: + raise AnalysisRunError("No frames available for brightest-frame scan.") + + non_background_rois = [i for i in range(len(req.rects)) if i != req.background_roi_idx] + if not non_background_rois: + raise AnalysisRunError("No non-background ROI available for brightest-frame scan.") + + cap = cv2.VideoCapture(req.video_path) + if not cap.isOpened(): + raise AnalysisRunError(f"Could not open video file: {req.video_path}") + + brightest_frame_idx = frame_indices[0] + max_brightness = float("-inf") + + try: + total = len(frame_indices) + for idx, frame_idx in enumerate(frame_indices): + _check_cancelled(cancel_check) + + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + if not ret or frame is None: + continue + + l_star_frame = compute_l_star_frame(frame) + frame_height, frame_width = frame.shape[:2] + brightness_sum = 0.0 + roi_count = 0 + + for roi_idx in non_background_rois: + pt1, pt2 = req.rects[roi_idx] + x1, y1, x2, y2 = normalized_slice_bounds(pt1, pt2, frame_width, frame_height) + if x2 > x1 and y2 > y1: + roi_l_star = l_star_frame[y1:y2, x1:x2] + if roi_l_star.size: + brightness_sum += float(np.mean(roi_l_star)) + roi_count += 1 + + if roi_count > 0: + frame_brightness = brightness_sum / roi_count + if frame_brightness > max_brightness: + max_brightness = frame_brightness + brightest_frame_idx = frame_idx + + if progress_callback is not None: + progress_callback(idx + 1, total) + if message_callback is not None and ((idx + 1) % 10 == 0 or idx + 1 == total): + message_callback(f"Scanning frame {idx + 1}/{total} for global brightest mask source") + + if max_brightness == float("-inf"): + max_brightness = 0.0 + + return BrightestFrameResult( + brightest_frame_idx=brightest_frame_idx, + max_brightness=max_brightness, + ) + finally: + cap.release() + + +def capture_per_roi_masks( + request: MaskScanRequest, + progress_callback: Optional[ProgressCallback] = None, + message_callback: Optional[MessageCallback] = None, + cancel_check: Optional[CancelCheck] = None, +) -> PerRoiMaskCaptureResult: + """Find each ROI's brightest frame and capture an above-background mask there.""" + req = request + roi_indices = [i for i in range(len(req.rects)) if i != req.background_roi_idx] + if not roi_indices: + raise AnalysisRunError("No non-background ROI available.") + + frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) + if not frame_indices: + raise AnalysisRunError("No frames available for per-ROI scan.") + + cap = cv2.VideoCapture(req.video_path) + if not cap.isOpened(): + raise AnalysisRunError(f"Could not open video file: {req.video_path}") + + brightest_frames: Dict[int, int] = {idx: frame_indices[0] for idx in roi_indices} + max_brightness: Dict[int, float] = {idx: float("-inf") for idx in roi_indices} + + scan_total = len(frame_indices) + total = scan_total + len(roi_indices) + + try: + for idx, frame_idx in enumerate(frame_indices): + _check_cancelled(cancel_check) + + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + if not ret or frame is None: + if progress_callback is not None: + progress_callback(idx + 1, total) + continue + + l_star_frame = compute_l_star_frame(frame) + frame_height, frame_width = frame.shape[:2] + + for roi_idx in roi_indices: + pt1, pt2 = req.rects[roi_idx] + x1, y1, x2, y2 = normalized_slice_bounds(pt1, pt2, frame_width, frame_height) + if x2 > x1 and y2 > y1: + roi_l_star = l_star_frame[y1:y2, x1:x2] + if roi_l_star.size: + roi_mean = float(np.mean(roi_l_star)) + if roi_mean > max_brightness[roi_idx]: + max_brightness[roi_idx] = roi_mean + brightest_frames[roi_idx] = frame_idx + + if progress_callback is not None: + progress_callback(idx + 1, total) + if message_callback is not None and ((idx + 1) % 10 == 0 or idx + 1 == scan_total): + message_callback(f"Scanning frame {idx + 1}/{scan_total} for per-ROI brightest sources") + + masks: List[Optional[np.ndarray]] = [None] * len(req.rects) + sources: List[Optional[int]] = [None] * len(req.rects) + + for idx, roi_idx in enumerate(roi_indices): + _check_cancelled(cancel_check) + + frame_idx = brightest_frames[roi_idx] + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + if not ret or frame is None: + if progress_callback is not None: + progress_callback(scan_total + idx + 1, total) + continue + + l_star_frame = compute_l_star_frame(frame) + background = compute_background_brightness( + frame=frame, + rects=req.rects, + background_roi_idx=req.background_roi_idx, + background_percentile=req.background_percentile, + frame_l_star=l_star_frame, + ) + + frame_height, frame_width = frame.shape[:2] + pt1, pt2 = req.rects[roi_idx] + x1, y1, x2, y2 = normalized_slice_bounds(pt1, pt2, frame_width, frame_height) + + if x2 > x1 and y2 > y1: + roi_l_star = l_star_frame[y1:y2, x1:x2] + if background is not None: + mask = roi_l_star > background + if np.any(mask): + kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, + (req.morphological_kernel_size, req.morphological_kernel_size), + ) + mask_uint8 = mask.astype(np.uint8) * 255 + cleaned = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel) + mask = cleaned > 0 + else: + mask = np.ones(roi_l_star.shape, dtype=bool) + masks[roi_idx] = mask + sources[roi_idx] = frame_idx + + if progress_callback is not None: + progress_callback(scan_total + idx + 1, total) + if message_callback is not None: + message_callback(f"Capturing mask {idx + 1}/{len(roi_indices)} from frame {frame_idx}") + + for roi_idx, value in max_brightness.items(): + if value == float("-inf"): + max_brightness[roi_idx] = 0.0 + + return PerRoiMaskCaptureResult( + masks=masks, + sources=sources, + max_brightness=max_brightness, + ) + finally: + cap.release() diff --git a/ecl_analysis/server/__init__.py b/ecl_analysis/server/__init__.py new file mode 100644 index 0000000..e5ceaa2 --- /dev/null +++ b/ecl_analysis/server/__init__.py @@ -0,0 +1 @@ +"""Local web API for the browser-based Brightness Sorcerer UI.""" diff --git a/ecl_analysis/server/__main__.py b/ecl_analysis/server/__main__.py new file mode 100644 index 0000000..24546d9 --- /dev/null +++ b/ecl_analysis/server/__main__.py @@ -0,0 +1,31 @@ +"""Launch the local web UI server: `python -m ecl_analysis.server`.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import uvicorn + +from .app import create_app + +DEFAULT_PORT = 8765 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Brightness Sorcerer web UI server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--web-dist", + default=str(Path(__file__).resolve().parents[2] / "web" / "dist"), + help="Built frontend directory to serve at / (skipped when missing).", + ) + args = parser.parse_args() + + app = create_app(web_dist=args.web_dist) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/ecl_analysis/server/app.py b/ecl_analysis/server/app.py new file mode 100644 index 0000000..31c2c20 --- /dev/null +++ b/ecl_analysis/server/app.py @@ -0,0 +1,476 @@ +"""FastAPI application exposing the analysis pipeline to the browser UI.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import List, Optional, Tuple + +import matplotlib + +matplotlib.use("Agg") # Plots render on worker threads with no GUI event loop. + +import cv2 +import numpy as np +from fastapi import FastAPI, HTTPException, Query, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from ecl_analysis.analysis.brightness import compute_l_star_frame +from ecl_analysis.analysis.models import AnalysisRequest, AnalysisResult, has_analyzable_rois +from ecl_analysis.analysis.runner import run_analysis +from ecl_analysis.analysis.scans import ( + BrightestFrameResult, + MaskScanRequest, + PerRoiMaskCaptureResult, + capture_per_roi_masks, + find_brightest_frame, +) +from ecl_analysis.export.csv_exporter import ExportOptions, save_analysis_outputs + +from .jobs import Job, JobManager +from .videos import VIDEO_EXTENSIONS, VideoOpenError, VideoRegistry + +JPEG_DEFAULT_QUALITY = 85 +THRESHOLD_TINT_BGR = (255, 0, 200) # magenta highlight for above-threshold pixels + + +class VideoOpenRequest(BaseModel): + path: str = Field(min_length=1) + + +class RoiModel(BaseModel): + """Axis-aligned ROI rectangle in frame coordinates.""" + + x1: int + y1: int + x2: int + y2: int + name: str = "" + + def as_rect(self) -> Tuple[Tuple[int, int], Tuple[int, int]]: + return ((self.x1, self.y1), (self.x2, self.y2)) + + +class AnalyzeRequest(BaseModel): + rois: List[RoiModel] = Field(min_length=1) + background_roi_idx: Optional[int] = None + start_frame: int = Field(ge=0) + end_frame: int = Field(ge=0) + background_percentile: float = Field(default=90.0, ge=0.0, le=100.0) + morphological_kernel_size: int = Field(default=3, ge=1) + noise_floor_threshold: float = Field(default=0.0, ge=0.0) + manual_threshold: float = Field(default=0.0, ge=0.0) + # Analyze inside masks captured by an earlier per-ROI mask scan job. + mask_job_id: Optional[str] = None + + +class MaskScanRequestModel(BaseModel): + mode: str = Field(pattern="^(global|per_roi)$") + rois: List[RoiModel] = Field(min_length=1) + background_roi_idx: Optional[int] = None + start_frame: int = Field(ge=0) + end_frame: int = Field(ge=0) + step: int = Field(default=5, ge=1) + background_percentile: float = Field(default=90.0, ge=0.0, le=100.0) + morphological_kernel_size: int = Field(default=3, ge=1) + + +class DetectRangeRequest(BaseModel): + expected_duration: float = Field(gt=0.0) + + +class ExportRequest(BaseModel): + analysis_name: str = Field(min_length=1) + save_dir: Optional[str] = None + csv: bool = True + json_export: bool = False + plot: bool = True + interactive_plot: bool = False + + +def create_app(web_dist: Optional[str] = None) -> FastAPI: + videos = VideoRegistry() + jobs = JobManager() + + @asynccontextmanager + async def lifespan(_app: FastAPI): + yield + videos.close_all() + + app = FastAPI(title="Brightness Sorcerer API", lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_methods=["*"], + allow_headers=["*"], + ) + + app.state.videos = videos + app.state.jobs = jobs + + # ---------- filesystem browsing (for the open-video picker) ---------- + + @app.get("/api/fs") + def list_directory(path: Optional[str] = None) -> dict: + base = Path(path).expanduser() if path else Path.home() + try: + base = base.resolve() + if not base.is_dir(): + raise HTTPException(status_code=400, detail=f"Not a directory: {base}") + entries = sorted(base.iterdir(), key=lambda p: p.name.lower()) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + dirs = [e.name for e in entries if e.is_dir() and not e.name.startswith(".")] + videos_found = [ + e.name + for e in entries + if e.is_file() and e.suffix.lower() in VIDEO_EXTENSIONS + ] + return { + "path": str(base), + "parent": str(base.parent) if base.parent != base else None, + "dirs": dirs, + "videos": videos_found, + } + + # ---------- video sessions ---------- + + @app.post("/api/videos") + def open_video(payload: VideoOpenRequest) -> dict: + try: + session = videos.open(payload.path) + except VideoOpenError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return session.metadata() + + @app.get("/api/videos/{video_id}") + def video_metadata(video_id: str) -> dict: + session = videos.get(video_id) + if session is None: + raise HTTPException(status_code=404, detail="Unknown video id") + return session.metadata() + + @app.get("/api/videos/{video_id}/frame/{index}") + def video_frame( + video_id: str, + index: int, + threshold: Optional[float] = Query(default=None, ge=0.0, le=100.0), + quality: int = Query(default=JPEG_DEFAULT_QUALITY, ge=10, le=100), + ) -> Response: + session = videos.get(video_id) + if session is None: + raise HTTPException(status_code=404, detail="Unknown video id") + frame = session.read_frame(index) + if frame is None: + raise HTTPException(status_code=404, detail=f"Frame {index} could not be read") + + if threshold is not None and threshold > 0: + l_star = compute_l_star_frame(frame) + mask = l_star > threshold + if np.any(mask): + tint = np.empty_like(frame) + tint[:] = THRESHOLD_TINT_BGR + blended = cv2.addWeighted(frame, 0.45, tint, 0.55, 0.0) + frame = frame.copy() + frame[mask] = blended[mask] + + ok, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if not ok: + raise HTTPException(status_code=500, detail="Frame could not be encoded") + return Response( + content=encoded.tobytes(), + media_type="image/jpeg", + headers={"Cache-Control": "max-age=3600"}, + ) + + # ---------- analysis jobs ---------- + + @app.post("/api/videos/{video_id}/analyze") + def start_analysis(video_id: str, payload: AnalyzeRequest) -> dict: + session = videos.get(video_id) + if session is None: + raise HTTPException(status_code=404, detail="Unknown video id") + + if payload.end_frame < payload.start_frame: + raise HTTPException(status_code=400, detail="end_frame must be >= start_frame") + if session.frame_count > 0 and payload.end_frame >= session.frame_count: + raise HTTPException( + status_code=400, + detail=f"end_frame {payload.end_frame} exceeds last frame {session.frame_count - 1}", + ) + if payload.background_roi_idx is not None and not ( + 0 <= payload.background_roi_idx < len(payload.rois) + ): + raise HTTPException(status_code=400, detail="background_roi_idx out of range") + + rects = [roi.as_rect() for roi in payload.rois] + if not has_analyzable_rois(rects, payload.background_roi_idx): + raise HTTPException( + status_code=400, + detail="At least one non-background ROI is required", + ) + + fixed_roi_masks: list = [] + use_fixed_mask = False + if payload.mask_job_id is not None: + mask_job = jobs.get(payload.mask_job_id) + if mask_job is None or mask_job.kind != "mask_scan_per_roi": + raise HTTPException(status_code=400, detail="Unknown mask scan job id") + if mask_job.status != "done" or not isinstance(mask_job.result, PerRoiMaskCaptureResult): + raise HTTPException(status_code=409, detail="Mask scan has not completed") + if mask_job.video_id != session.video_id: + raise HTTPException(status_code=400, detail="Masks belong to a different video") + masks = mask_job.result.masks + if len(masks) != len(rects): + raise HTTPException( + status_code=409, + detail="Captured masks no longer match the region list; recapture masks.", + ) + for idx, (mask, roi) in enumerate(zip(masks, payload.rois)): + if mask is None: + continue + expected_shape = (abs(roi.y2 - roi.y1), abs(roi.x2 - roi.x1)) + if mask.shape[:2] != expected_shape: + raise HTTPException( + status_code=409, + detail=( + f"Region {idx + 1} was resized or moved since masks were captured; " + "recapture masks." + ), + ) + fixed_roi_masks = masks + use_fixed_mask = True + + request = AnalysisRequest( + video_path=session.path, + rects=rects, + background_roi_idx=payload.background_roi_idx, + start_frame=payload.start_frame, + end_frame=payload.end_frame, + use_fixed_mask=use_fixed_mask, + fixed_roi_masks=fixed_roi_masks, + background_percentile=payload.background_percentile, + morphological_kernel_size=payload.morphological_kernel_size, + noise_floor_threshold=payload.noise_floor_threshold, + manual_threshold=payload.manual_threshold, + ) + job = jobs.start( + "analysis", + session.video_id, + lambda progress, message, cancelled: run_analysis( + request, + progress_callback=progress, + message_callback=message, + cancel_check=cancelled, + ), + ) + return {"job_id": job.job_id} + + @app.post("/api/videos/{video_id}/mask-scan") + def start_mask_scan(video_id: str, payload: MaskScanRequestModel) -> dict: + session = videos.get(video_id) + if session is None: + raise HTTPException(status_code=404, detail="Unknown video id") + if payload.end_frame < payload.start_frame: + raise HTTPException(status_code=400, detail="end_frame must be >= start_frame") + if payload.background_roi_idx is not None and not ( + 0 <= payload.background_roi_idx < len(payload.rois) + ): + raise HTTPException(status_code=400, detail="background_roi_idx out of range") + + request = MaskScanRequest( + video_path=session.path, + rects=[roi.as_rect() for roi in payload.rois], + background_roi_idx=payload.background_roi_idx, + start_frame=payload.start_frame, + end_frame=payload.end_frame, + step=payload.step, + background_percentile=payload.background_percentile, + morphological_kernel_size=payload.morphological_kernel_size, + ) + scan = find_brightest_frame if payload.mode == "global" else capture_per_roi_masks + job = jobs.start( + f"mask_scan_{payload.mode}", + session.video_id, + lambda progress, message, cancelled: scan( + request, + progress_callback=progress, + message_callback=message, + cancel_check=cancelled, + ), + ) + return {"job_id": job.job_id} + + @app.post("/api/videos/{video_id}/detect-range") + def detect_range(video_id: str, payload: DetectRangeRequest) -> dict: + session = videos.get(video_id) + if session is None: + raise HTTPException(status_code=404, detail="Unknown video id") + + # Imported lazily so the server works without the optional audio extras. + from ecl_analysis.audio import AudioAnalyzer + + analyzer = AudioAnalyzer() + if not analyzer.is_available(): + raise HTTPException( + status_code=501, + detail="Audio analysis is not installed. Run: pip install -e '.[audio]'", + ) + + beeps = analyzer.find_completion_beeps(session.path, payload.expected_duration) + fps = session.fps if session.fps > 0 else 30.0 + last_frame = max(0, session.frame_count - 1) + results = [] + for beep_time, end_frame in beeps: + # The beep marks the run's end; count back the expected duration. + start_frame = max(0, int((beep_time - payload.expected_duration) * fps)) + clamped_end = min(end_frame, last_frame) + results.append( + { + "beep_time": beep_time, + "start_frame": min(start_frame, clamped_end), + "end_frame": clamped_end, + } + ) + return {"beeps": results} + + def _serialize_result(job: Job) -> Optional[dict]: + result = job.result + if isinstance(result, AnalysisResult): + return { + "start_frame": result.start_frame, + "end_frame": result.end_frame, + "frames_processed": result.frames_processed, + "total_frames": result.total_frames, + "truncated": result.truncated, + "elapsed_seconds": result.elapsed_seconds, + "background_values_per_frame": result.background_values_per_frame, + "rois": [ + { + "roi_index": roi_idx, + "brightness_mean": result.brightness_mean_data[data_idx], + "brightness_median": result.brightness_median_data[data_idx], + "blue_mean": result.blue_mean_data[data_idx], + "blue_median": result.blue_median_data[data_idx], + } + for data_idx, roi_idx in enumerate(result.non_background_rois) + ], + } + if isinstance(result, BrightestFrameResult): + return { + "brightest_frame_idx": result.brightest_frame_idx, + "max_brightness": result.max_brightness, + } + if isinstance(result, PerRoiMaskCaptureResult): + return { + "sources": result.sources, + "max_brightness": {str(k): v for k, v in result.max_brightness.items()}, + "mask_coverage": [ + float(np.count_nonzero(mask)) / mask.size if mask is not None and mask.size else None + for mask in result.masks + ], + } + return None + + @app.get("/api/jobs/{job_id}") + def job_status(job_id: str) -> dict: + job = jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job id") + + payload: dict = { + "job_id": job.job_id, + "kind": job.kind, + "video_id": job.video_id, + "status": job.status, + "progress": {"done": job.progress_done, "total": job.progress_total}, + "message": job.message, + "error": job.error, + } + serialized = _serialize_result(job) + if serialized is not None: + payload["result"] = serialized + return payload + + @app.post("/api/jobs/{job_id}/cancel") + def cancel_job(job_id: str) -> dict: + if not jobs.cancel(job_id): + raise HTTPException(status_code=404, detail="Unknown job id") + return {"cancelled": True} + + # ---------- export ---------- + + @app.post("/api/jobs/{job_id}/export") + def export_analysis(job_id: str, payload: ExportRequest) -> dict: + job = jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job id") + if job.status != "done" or not isinstance(job.result, AnalysisResult): + raise HTTPException(status_code=409, detail="Job has no completed analysis result to export") + + session = videos.get(job.video_id) + video_path = session.path if session is not None else job.request.video_path + + if payload.save_dir: + save_dir = Path(payload.save_dir).expanduser() + else: + save_dir = Path(video_path).parent / f"{Path(video_path).stem}_analysis" + try: + save_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise HTTPException(status_code=400, detail=f"Cannot create {save_dir}: {exc}") from exc + + # Imported lazily: pulls in matplotlib figure machinery (and the Qt + # main-window module for a color helper) only when an export happens. + from ecl_analysis.export.plotting import generate_enhanced_plot + + export_result = save_analysis_outputs( + analysis_result=job.result, + save_dir=str(save_dir), + video_path=video_path, + analysis_name=payload.analysis_name, + plot_builder=generate_enhanced_plot, + export_options=ExportOptions( + csv=payload.csv, + json=payload.json_export, + plot=payload.plot, + interactive_plot=payload.interactive_plot, + ), + ) + job.exported_paths.extend(export_result.out_paths) + return { + "save_dir": str(save_dir), + "out_paths": export_result.out_paths, + "summary_lines": export_result.summary_lines, + "avg_brightness_summary": export_result.avg_brightness_summary, + "plot_failed": export_result.plot_failed, + } + + @app.get("/api/jobs/{job_id}/files") + def exported_file(job_id: str, path: str) -> FileResponse: + job = jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job id") + resolved = str(Path(path).expanduser().resolve()) + if resolved not in {str(Path(p).resolve()) for p in job.exported_paths}: + raise HTTPException(status_code=403, detail="Path was not produced by this job") + if not os.path.isfile(resolved): + raise HTTPException(status_code=404, detail="File no longer exists") + return FileResponse(resolved) + + # ---------- built frontend (production mode) ---------- + + if web_dist and Path(web_dist).is_dir(): + app.mount("/", StaticFiles(directory=web_dist, html=True), name="web") + + return app diff --git a/ecl_analysis/server/jobs.py b/ecl_analysis/server/jobs.py new file mode 100644 index 0000000..2a2451a --- /dev/null +++ b/ecl_analysis/server/jobs.py @@ -0,0 +1,92 @@ +"""Threaded background jobs with cooperative cancellation and polled progress. + +A job wraps any UI-free runner function (analysis, mask scans) that accepts +progress/message callbacks and a cancel check — the same contract the Qt +workers use, so both frontends drive identical code. +""" + +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from ecl_analysis.analysis.runner import AnalysisCancelled + +JobRunner = Callable[ + [Callable[[int, int], None], Callable[[str], None], Callable[[], bool]], + Any, +] + + +@dataclass +class Job: + """State of one background run, mutated only under the manager lock.""" + + job_id: str + kind: str # analysis | mask_scan_global | mask_scan_per_roi + video_id: str + status: str = "queued" # queued | running | done | error | cancelled + progress_done: int = 0 + progress_total: int = 0 + message: str = "" + error: Optional[str] = None + result: Optional[Any] = None + exported_paths: List[str] = field(default_factory=list) + cancel_event: threading.Event = field(default_factory=threading.Event) + + +class JobManager: + """Run job functions on daemon threads and expose polled status.""" + + def __init__(self) -> None: + self._jobs: Dict[str, Job] = {} + self._lock = threading.Lock() + + def start(self, kind: str, video_id: str, runner: JobRunner) -> Job: + job = Job(job_id=uuid.uuid4().hex[:12], kind=kind, video_id=video_id) + with self._lock: + self._jobs[job.job_id] = job + + thread = threading.Thread(target=self._run, args=(job, runner), daemon=True) + thread.start() + return job + + def get(self, job_id: str) -> Optional[Job]: + with self._lock: + return self._jobs.get(job_id) + + def cancel(self, job_id: str) -> bool: + job = self.get(job_id) + if job is None: + return False + job.cancel_event.set() + return True + + def _run(self, job: Job, runner: JobRunner) -> None: + with self._lock: + job.status = "running" + + def on_progress(done: int, total: int) -> None: + with self._lock: + job.progress_done = done + job.progress_total = total + + def on_message(message: str) -> None: + with self._lock: + job.message = message + + try: + result = runner(on_progress, on_message, job.cancel_event.is_set) + except AnalysisCancelled: + with self._lock: + job.status = "cancelled" + except Exception as exc: # noqa: BLE001 — surfaced to the client as job.error + with self._lock: + job.status = "error" + job.error = str(exc) + else: + with self._lock: + job.status = "done" + job.result = result diff --git a/ecl_analysis/server/videos.py b/ecl_analysis/server/videos.py new file mode 100644 index 0000000..2abdfa9 --- /dev/null +++ b/ecl_analysis/server/videos.py @@ -0,0 +1,130 @@ +"""Open-video registry with cached, lock-guarded frame access. + +The browser cannot decode arbitrary lab video codecs (MJPEG AVIs, exotic MOVs), +so frames are decoded server-side with OpenCV and served as JPEG. One +``cv2.VideoCapture`` is kept open per video; sequential reads avoid a seek so +forward scrubbing stays fast. +""" + +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Optional + +import cv2 +import numpy as np + +VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".m4v", ".mpg", ".mpeg", ".wmv"} + + +class VideoOpenError(RuntimeError): + """Raised when a video path cannot be opened for reading.""" + + +@dataclass +class VideoSession: + """An opened video and its cached capture handle.""" + + video_id: str + path: str + frame_count: int + fps: float + width: int + height: int + _cap: cv2.VideoCapture = field(repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + _next_read_index: int = 0 + + @property + def duration_seconds(self) -> float: + return self.frame_count / self.fps if self.fps > 0 else 0.0 + + def metadata(self) -> Dict[str, object]: + return { + "video_id": self.video_id, + "path": self.path, + "name": Path(self.path).name, + "frame_count": self.frame_count, + "fps": self.fps, + "width": self.width, + "height": self.height, + "duration_seconds": self.duration_seconds, + } + + def read_frame(self, index: int) -> Optional[np.ndarray]: + """Read frame ``index`` (BGR) or None when it cannot be decoded.""" + if index < 0 or (self.frame_count > 0 and index >= self.frame_count): + return None + with self._lock: + if index != self._next_read_index: + self._cap.set(cv2.CAP_PROP_POS_FRAMES, index) + ret, frame = self._cap.read() + if not ret or frame is None: + # A failed read leaves the decoder position unknown; force a + # seek on the next request instead of trusting the counter. + self._next_read_index = -1 + return None + self._next_read_index = index + 1 + return frame + + def close(self) -> None: + with self._lock: + self._cap.release() + + +class VideoRegistry: + """Thread-safe registry of opened videos keyed by opaque id.""" + + def __init__(self) -> None: + self._sessions: Dict[str, VideoSession] = {} + self._by_path: Dict[str, str] = {} + self._lock = threading.Lock() + + def open(self, path: str) -> VideoSession: + resolved = str(Path(path).expanduser().resolve()) + with self._lock: + existing_id = self._by_path.get(resolved) + if existing_id is not None: + return self._sessions[existing_id] + + if not Path(resolved).is_file(): + raise VideoOpenError(f"File not found: {resolved}") + + cap = cv2.VideoCapture(resolved) + if not cap.isOpened(): + cap.release() + raise VideoOpenError(f"Could not open video file: {resolved}") + + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = float(cap.get(cv2.CAP_PROP_FPS)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + session = VideoSession( + video_id=uuid.uuid4().hex[:12], + path=resolved, + frame_count=frame_count, + fps=fps, + width=width, + height=height, + _cap=cap, + ) + with self._lock: + self._sessions[session.video_id] = session + self._by_path[resolved] = session.video_id + return session + + def get(self, video_id: str) -> Optional[VideoSession]: + with self._lock: + return self._sessions.get(video_id) + + def close_all(self) -> None: + with self._lock: + sessions = list(self._sessions.values()) + self._sessions.clear() + self._by_path.clear() + for session in sessions: + session.close() diff --git a/ecl_analysis/workers.py b/ecl_analysis/workers.py index d4625c4..61b0d8e 100644 --- a/ecl_analysis/workers.py +++ b/ecl_analysis/workers.py @@ -3,19 +3,32 @@ from __future__ import annotations import threading -import time -from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence import cv2 -import numpy as np from PyQt5 import QtCore -from .analysis.background import compute_background_brightness -from .analysis.brightness import compute_brightness_stats, compute_l_star_frame -from .analysis.models import AnalysisRequest, AnalysisResult, RoiRect +from .analysis.models import AnalysisRequest +from .analysis.runner import AnalysisCancelled, AnalysisRunError, run_analysis +from .analysis.scans import ( + BrightestFrameResult, + MaskScanRequest, + PerRoiMaskCaptureResult, + capture_per_roi_masks, + find_brightest_frame, +) from .audio import AudioAnalyzer +__all__ = [ + "AnalysisWorker", + "AudioDetectionWorker", + "BrightestFrameResult", + "BrightestFrameWorker", + "CancellationToken", + "MaskScanRequest", + "PerRoiMaskCaptureResult", + "PerRoiMaskCaptureWorker", +] + class CancellationToken: """Thread-safe cancellation flag shared between the GUI and worker threads. @@ -34,55 +47,6 @@ def is_cancelled(self) -> bool: return self._event.is_set() -def _normalized_slice_bounds( - pt1: tuple[int, int], - pt2: tuple[int, int], - frame_width: int, - frame_height: int, -) -> tuple[int, int, int, int]: - """Return clamped, normalized ROI bounds for NumPy slicing (exclusive end).""" - left, right = sorted((int(pt1[0]), int(pt2[0]))) - top, bottom = sorted((int(pt1[1]), int(pt2[1]))) - - x1 = max(0, min(left, frame_width)) - x2 = max(0, min(right, frame_width)) - y1 = max(0, min(top, frame_height)) - y2 = max(0, min(bottom, frame_height)) - - return x1, y1, x2, y2 - - -@dataclass(frozen=True) -class MaskScanRequest: - """Immutable scan inputs for brightest-frame mask workflows.""" - - video_path: str - rects: Sequence[RoiRect] - background_roi_idx: Optional[int] - start_frame: int - end_frame: int - step: int - background_percentile: float - morphological_kernel_size: int - - -@dataclass -class BrightestFrameResult: - """Result payload for global brightest frame detection.""" - - brightest_frame_idx: int - max_brightness: float - - -@dataclass -class PerRoiMaskCaptureResult: - """Result payload for per-ROI mask capture.""" - - masks: List[Optional[np.ndarray]] - sources: List[Optional[int]] - max_brightness: Dict[int, float] - - class AnalysisWorker(QtCore.QObject): """Execute frame analysis outside the UI thread.""" @@ -99,149 +63,23 @@ def __init__(self, request: AnalysisRequest): @QtCore.pyqtSlot() def run(self) -> None: - req = self._request - total_frames = req.end_frame - req.start_frame + 1 - non_background_rois = [i for i in range(len(req.rects)) if i != req.background_roi_idx] - - brightness_mean_data = [[] for _ in non_background_rois] - brightness_median_data = [[] for _ in non_background_rois] - blue_mean_data = [[] for _ in non_background_rois] - blue_median_data = [[] for _ in non_background_rois] - background_values_per_frame: List[float] = [] - - start_time = time.time() - cap = cv2.VideoCapture(req.video_path) - if not cap.isOpened(): - self.error.emit(f"Could not open video file: {req.video_path}") - return - try: - cap.set(cv2.CAP_PROP_POS_FRAMES, req.start_frame) - frames_processed = 0 - truncated = False - - for _frame_idx in range(req.start_frame, req.end_frame + 1): - if self._cancel_token.is_cancelled(): - self.cancelled.emit() - return - - ret, frame = cap.read() - if not ret: - if frames_processed == 0: - self.error.emit("Failed to read first frame during analysis.") - return - brightness_mean_data = [lst[:frames_processed] for lst in brightness_mean_data] - brightness_median_data = [lst[:frames_processed] for lst in brightness_median_data] - blue_mean_data = [lst[:frames_processed] for lst in blue_mean_data] - blue_median_data = [lst[:frames_processed] for lst in blue_median_data] - truncated = True - break - - l_star_frame = compute_l_star_frame(frame) - background_value = compute_background_brightness( - frame=frame, - rects=req.rects, - background_roi_idx=req.background_roi_idx, - background_percentile=req.background_percentile, - frame_l_star=l_star_frame, - ) - if req.background_roi_idx is None and req.manual_threshold > 0: - # Manual threshold mode: no background ROI configured, so the - # user-set manual threshold acts as the active threshold. - background_value = req.manual_threshold - background_values_per_frame.append(background_value if background_value is not None else 0.0) - - frame_height, frame_width = frame.shape[:2] - for data_idx, roi_idx in enumerate(non_background_rois): - pt1, pt2 = req.rects[roi_idx] - x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) - - if x2 > x1 and y2 > y1: - roi = frame[y1:y2, x1:x2] - roi_l_star = l_star_frame[y1:y2, x1:x2] - roi_mask = None - if req.use_fixed_mask and roi_idx < len(req.fixed_roi_masks): - candidate_mask = req.fixed_roi_masks[roi_idx] - if isinstance(candidate_mask, np.ndarray) and candidate_mask.shape[:2] == roi.shape[:2]: - roi_mask = candidate_mask - - ( - l_raw_mean, - l_raw_median, - l_bg_sub_mean, - l_bg_sub_median, - b_raw_mean, - b_raw_median, - b_bg_sub_mean, - b_bg_sub_median, - ) = compute_brightness_stats( - roi_bgr=roi, - background_brightness=background_value, - roi_mask=roi_mask, - roi_l_star=roi_l_star, - morphological_kernel_size=req.morphological_kernel_size, - noise_floor_threshold=req.noise_floor_threshold, - ) - - if background_value is not None: - brightness_mean_data[data_idx].append(l_bg_sub_mean) - brightness_median_data[data_idx].append(l_bg_sub_median) - blue_mean_data[data_idx].append(b_bg_sub_mean) - blue_median_data[data_idx].append(b_bg_sub_median) - else: - brightness_mean_data[data_idx].append(l_raw_mean) - brightness_median_data[data_idx].append(l_raw_median) - blue_mean_data[data_idx].append(b_raw_mean) - blue_median_data[data_idx].append(b_raw_median) - else: - brightness_mean_data[data_idx].append(0.0) - brightness_median_data[data_idx].append(0.0) - blue_mean_data[data_idx].append(0.0) - blue_median_data[data_idx].append(0.0) - - frames_processed += 1 - - if frames_processed % 10 == 0: - elapsed = time.time() - start_time - fps = frames_processed / elapsed if elapsed > 0 else 0.0 - remaining = total_frames - frames_processed - eta_seconds = remaining / fps if fps > 0 else 0.0 - self.progress_message.emit( - f"Analyzing frame {frames_processed}/{total_frames} • " - f"Speed: {fps:.1f} fps • ETA: {eta_seconds:.0f}s" - ) - - self.progress_changed.emit(frames_processed, total_frames) - else: - frames_processed = total_frames - - if self._cancel_token.is_cancelled(): - self.cancelled.emit() - return - - elapsed_seconds = time.time() - start_time - self.finished.emit( - AnalysisResult( - brightness_mean_data=brightness_mean_data, - brightness_median_data=brightness_median_data, - blue_mean_data=blue_mean_data, - blue_median_data=blue_median_data, - background_values_per_frame=background_values_per_frame, - frames_processed=frames_processed, - total_frames=total_frames, - non_background_rois=non_background_rois, - elapsed_seconds=elapsed_seconds, - start_frame=req.start_frame, - end_frame=req.end_frame, - truncated=truncated, - ) + result = run_analysis( + self._request, + progress_callback=self.progress_changed.emit, + message_callback=self.progress_message.emit, + cancel_check=self._cancel_token.is_cancelled, ) + except AnalysisCancelled: + self.cancelled.emit() + except AnalysisRunError as exc: + self.error.emit(str(exc)) except cv2.error as exc: self.error.emit(f"OpenCV error during analysis: {exc}") except Exception as exc: self.error.emit(str(exc)) - finally: - cap.release() + else: + self.finished.emit(result) @QtCore.pyqtSlot() def cancel(self) -> None: @@ -305,78 +143,23 @@ def __init__(self, request: MaskScanRequest): @QtCore.pyqtSlot() def run(self) -> None: - req = self._request - frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) - if not frame_indices: - self.error.emit("No frames available for brightest-frame scan.") - return - - non_background_rois = [i for i in range(len(req.rects)) if i != req.background_roi_idx] - if not non_background_rois: - self.error.emit("No non-background ROI available for brightest-frame scan.") - return - - cap = cv2.VideoCapture(req.video_path) - if not cap.isOpened(): - self.error.emit(f"Could not open video file: {req.video_path}") - return - - brightest_frame_idx = frame_indices[0] - max_brightness = float("-inf") - try: - total = len(frame_indices) - for idx, frame_idx in enumerate(frame_indices): - if self._cancel_token.is_cancelled(): - self.cancelled.emit() - return - - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) - ret, frame = cap.read() - if not ret or frame is None: - continue - - l_star_frame = compute_l_star_frame(frame) - frame_height, frame_width = frame.shape[:2] - brightness_sum = 0.0 - roi_count = 0 - - for roi_idx in non_background_rois: - pt1, pt2 = req.rects[roi_idx] - x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) - if x2 > x1 and y2 > y1: - roi_l_star = l_star_frame[y1:y2, x1:x2] - if roi_l_star.size: - brightness_sum += float(np.mean(roi_l_star)) - roi_count += 1 - - if roi_count > 0: - frame_brightness = brightness_sum / roi_count - if frame_brightness > max_brightness: - max_brightness = frame_brightness - brightest_frame_idx = frame_idx - - self.progress_changed.emit(idx + 1, total) - if (idx + 1) % 10 == 0 or idx + 1 == total: - self.progress_message.emit( - f"Scanning frame {idx + 1}/{total} for global brightest mask source" - ) - - if max_brightness == float("-inf"): - max_brightness = 0.0 - - self.finished.emit( - BrightestFrameResult( - brightest_frame_idx=brightest_frame_idx, - max_brightness=max_brightness, - ) + result = find_brightest_frame( + self._request, + progress_callback=self.progress_changed.emit, + message_callback=self.progress_message.emit, + cancel_check=self._cancel_token.is_cancelled, ) + except AnalysisCancelled: + self.cancelled.emit() + except AnalysisRunError as exc: + self.error.emit(str(exc)) except cv2.error as exc: self.error.emit(f"OpenCV error during brightest-frame scan: {exc}") except Exception as exc: self.error.emit(str(exc)) - finally: - cap.release() + else: + self.finished.emit(result) @QtCore.pyqtSlot() def cancel(self) -> None: @@ -400,127 +183,23 @@ def __init__(self, request: MaskScanRequest): @QtCore.pyqtSlot() def run(self) -> None: - req = self._request - roi_indices = [i for i in range(len(req.rects)) if i != req.background_roi_idx] - if not roi_indices: - self.error.emit("No non-background ROI available.") - return - - frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) - if not frame_indices: - self.error.emit("No frames available for per-ROI scan.") - return - - cap = cv2.VideoCapture(req.video_path) - if not cap.isOpened(): - self.error.emit(f"Could not open video file: {req.video_path}") - return - - brightest_frames: Dict[int, int] = {idx: frame_indices[0] for idx in roi_indices} - max_brightness: Dict[int, float] = {idx: float("-inf") for idx in roi_indices} - - scan_total = len(frame_indices) - total = scan_total + len(roi_indices) - try: - for idx, frame_idx in enumerate(frame_indices): - if self._cancel_token.is_cancelled(): - self.cancelled.emit() - return - - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) - ret, frame = cap.read() - if not ret or frame is None: - self.progress_changed.emit(idx + 1, total) - continue - - l_star_frame = compute_l_star_frame(frame) - frame_height, frame_width = frame.shape[:2] - - for roi_idx in roi_indices: - pt1, pt2 = req.rects[roi_idx] - x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) - if x2 > x1 and y2 > y1: - roi_l_star = l_star_frame[y1:y2, x1:x2] - if roi_l_star.size: - roi_mean = float(np.mean(roi_l_star)) - if roi_mean > max_brightness[roi_idx]: - max_brightness[roi_idx] = roi_mean - brightest_frames[roi_idx] = frame_idx - - self.progress_changed.emit(idx + 1, total) - if (idx + 1) % 10 == 0 or idx + 1 == scan_total: - self.progress_message.emit( - f"Scanning frame {idx + 1}/{scan_total} for per-ROI brightest sources" - ) - - masks: List[Optional[np.ndarray]] = [None] * len(req.rects) - sources: List[Optional[int]] = [None] * len(req.rects) - - for idx, roi_idx in enumerate(roi_indices): - if self._cancel_token.is_cancelled(): - self.cancelled.emit() - return - - frame_idx = brightest_frames[roi_idx] - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) - ret, frame = cap.read() - if not ret or frame is None: - self.progress_changed.emit(scan_total + idx + 1, total) - continue - - l_star_frame = compute_l_star_frame(frame) - background = compute_background_brightness( - frame=frame, - rects=req.rects, - background_roi_idx=req.background_roi_idx, - background_percentile=req.background_percentile, - frame_l_star=l_star_frame, - ) - - frame_height, frame_width = frame.shape[:2] - pt1, pt2 = req.rects[roi_idx] - x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) - - if x2 > x1 and y2 > y1: - roi_l_star = l_star_frame[y1:y2, x1:x2] - if background is not None: - mask = roi_l_star > background - if np.any(mask): - kernel = cv2.getStructuringElement( - cv2.MORPH_ELLIPSE, - (req.morphological_kernel_size, req.morphological_kernel_size), - ) - mask_uint8 = mask.astype(np.uint8) * 255 - cleaned = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel) - mask = cleaned > 0 - else: - mask = np.ones(roi_l_star.shape, dtype=bool) - masks[roi_idx] = mask - sources[roi_idx] = frame_idx - - self.progress_changed.emit(scan_total + idx + 1, total) - self.progress_message.emit( - f"Capturing mask {idx + 1}/{len(roi_indices)} from frame {frame_idx}" - ) - - for roi_idx, value in max_brightness.items(): - if value == float("-inf"): - max_brightness[roi_idx] = 0.0 - - self.finished.emit( - PerRoiMaskCaptureResult( - masks=masks, - sources=sources, - max_brightness=max_brightness, - ) + result = capture_per_roi_masks( + self._request, + progress_callback=self.progress_changed.emit, + message_callback=self.progress_message.emit, + cancel_check=self._cancel_token.is_cancelled, ) + except AnalysisCancelled: + self.cancelled.emit() + except AnalysisRunError as exc: + self.error.emit(str(exc)) except cv2.error as exc: self.error.emit(f"OpenCV error during per-ROI scan: {exc}") except Exception as exc: self.error.emit(str(exc)) - finally: - cap.release() + else: + self.finished.emit(result) @QtCore.pyqtSlot() def cancel(self) -> None: diff --git a/pyproject.toml b/pyproject.toml index 4f50df2..e070f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,11 @@ audio = [ interactive-plots = [ "plotly>=5.0.0", ] +web = [ + "fastapi>=0.115.0", + "uvicorn>=0.30.0", + "httpx>=0.27.0", +] dev = [ "pytest>=8.0.0", "pytest-cov>=5.0.0", @@ -33,6 +38,7 @@ dev = [ [project.scripts] brightness-sorcerer = "ecl_analysis.app:run_app" +brightness-sorcerer-web = "ecl_analysis.server.__main__:main" [tool.setuptools.packages.find] include = ["ecl_analysis*"] diff --git a/tests/integration/test_server_api.py b/tests/integration/test_server_api.py new file mode 100644 index 0000000..d812917 --- /dev/null +++ b/tests/integration/test_server_api.py @@ -0,0 +1,271 @@ +"""API-level tests for the local web server against a synthetic video.""" + +from __future__ import annotations + +import time + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from ecl_analysis.server.app import create_app + + +@pytest.fixture(scope="module") +def synthetic_video(tmp_path_factory): + """Write a small MP4 whose left half brightens over 30 frames.""" + path = tmp_path_factory.mktemp("videos") / "synthetic.mp4" + writer = cv2.VideoWriter( + str(path), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48) + ) + assert writer.isOpened() + for i in range(30): + frame = np.zeros((48, 64, 3), dtype=np.uint8) + frame[:, :32] = min(255, 8 * i) + writer.write(frame) + writer.release() + return str(path) + + +@pytest.fixture() +def client(): + app = create_app() + with TestClient(app) as test_client: + yield test_client + + +def _open_video(client, path): + response = client.post("/api/videos", json={"path": path}) + assert response.status_code == 200, response.text + return response.json() + + +def test_open_video_returns_metadata(client, synthetic_video): + meta = _open_video(client, synthetic_video) + assert meta["frame_count"] == 30 + assert meta["width"] == 64 + assert meta["height"] == 48 + assert meta["fps"] == pytest.approx(30.0) + + +def test_open_missing_video_is_a_client_error(client): + response = client.post("/api/videos", json={"path": "/nonexistent/video.mp4"}) + assert response.status_code == 400 + + +def test_frame_endpoint_serves_jpeg(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.get(f"/api/videos/{meta['video_id']}/frame/5") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + decoded = cv2.imdecode( + np.frombuffer(response.content, dtype=np.uint8), cv2.IMREAD_COLOR + ) + assert decoded.shape == (48, 64, 3) + + +def test_frame_out_of_range_is_404(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.get(f"/api/videos/{meta['video_id']}/frame/999") + assert response.status_code == 404 + + +def test_threshold_overlay_changes_bright_frame(client, synthetic_video): + meta = _open_video(client, synthetic_video) + plain = client.get(f"/api/videos/{meta['video_id']}/frame/29") + tinted = client.get(f"/api/videos/{meta['video_id']}/frame/29?threshold=50") + assert plain.status_code == tinted.status_code == 200 + assert plain.content != tinted.content + + +def _wait_for_job(client, job_id, timeout=15.0): + deadline = time.time() + timeout + while time.time() < deadline: + response = client.get(f"/api/jobs/{job_id}") + assert response.status_code == 200 + payload = response.json() + if payload["status"] in {"done", "error", "cancelled"}: + return payload + time.sleep(0.05) + pytest.fail(f"Job {job_id} did not finish within {timeout}s") + + +def test_analysis_job_produces_series(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.post( + f"/api/videos/{meta['video_id']}/analyze", + json={ + "rois": [ + {"x1": 0, "y1": 0, "x2": 32, "y2": 48, "name": "electrode"}, + {"x1": 40, "y1": 0, "x2": 64, "y2": 48, "name": "background"}, + ], + "background_roi_idx": 1, + "start_frame": 0, + "end_frame": 29, + }, + ) + assert response.status_code == 200, response.text + payload = _wait_for_job(client, response.json()["job_id"]) + + assert payload["status"] == "done", payload + result = payload["result"] + assert result["frames_processed"] == 30 + assert len(result["rois"]) == 1 + series = result["rois"][0]["brightness_mean"] + assert len(series) == 30 + # The left half brightens monotonically, so late frames must beat early ones. + assert series[-1] > series[0] + + +def test_analysis_rejects_background_only(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.post( + f"/api/videos/{meta['video_id']}/analyze", + json={ + "rois": [{"x1": 0, "y1": 0, "x2": 32, "y2": 48}], + "background_roi_idx": 0, + "start_frame": 0, + "end_frame": 29, + }, + ) + assert response.status_code == 400 + + +def test_export_writes_csv_and_serves_it(client, synthetic_video, tmp_path): + meta = _open_video(client, synthetic_video) + response = client.post( + f"/api/videos/{meta['video_id']}/analyze", + json={ + "rois": [{"x1": 0, "y1": 0, "x2": 32, "y2": 48, "name": "electrode"}], + "start_frame": 0, + "end_frame": 29, + }, + ) + job_id = response.json()["job_id"] + payload = _wait_for_job(client, job_id) + assert payload["status"] == "done", payload + + export_dir = tmp_path / "exports" + response = client.post( + f"/api/jobs/{job_id}/export", + json={ + "analysis_name": "synthetic_run", + "save_dir": str(export_dir), + "csv": True, + "plot": False, + "interactive_plot": False, + }, + ) + assert response.status_code == 200, response.text + out_paths = response.json()["out_paths"] + csv_paths = [p for p in out_paths if p.endswith(".csv")] + assert csv_paths, out_paths + + served = client.get(f"/api/jobs/{job_id}/files", params={"path": csv_paths[0]}) + assert served.status_code == 200 + assert "frame" in served.text.splitlines()[0] + + denied = client.get( + f"/api/jobs/{job_id}/files", params={"path": synthetic_video} + ) + assert denied.status_code == 403 + + +def test_fs_listing(client, tmp_path): + (tmp_path / "clip.mp4").touch() + (tmp_path / "subdir").mkdir() + response = client.get("/api/fs", params={"path": str(tmp_path)}) + assert response.status_code == 200 + payload = response.json() + assert "clip.mp4" in payload["videos"] + assert "subdir" in payload["dirs"] + + +def test_global_mask_scan_finds_brightest_frame(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.post( + f"/api/videos/{meta['video_id']}/mask-scan", + json={ + "mode": "global", + "rois": [{"x1": 0, "y1": 0, "x2": 32, "y2": 48}], + "start_frame": 0, + "end_frame": 29, + "step": 1, + }, + ) + assert response.status_code == 200, response.text + payload = _wait_for_job(client, response.json()["job_id"]) + assert payload["status"] == "done", payload + assert payload["kind"] == "mask_scan_global" + # The clip brightens monotonically, so the last frame wins. + assert payload["result"]["brightest_frame_idx"] == 29 + + +def test_per_roi_mask_capture_and_masked_analysis(client, synthetic_video): + meta = _open_video(client, synthetic_video) + rois = [ + {"x1": 0, "y1": 0, "x2": 32, "y2": 48, "name": "electrode"}, + {"x1": 40, "y1": 0, "x2": 64, "y2": 48, "name": "background"}, + ] + response = client.post( + f"/api/videos/{meta['video_id']}/mask-scan", + json={ + "mode": "per_roi", + "rois": rois, + "background_roi_idx": 1, + "start_frame": 0, + "end_frame": 29, + "step": 2, + }, + ) + assert response.status_code == 200, response.text + mask_job_id = response.json()["job_id"] + payload = _wait_for_job(client, mask_job_id) + assert payload["status"] == "done", payload + result = payload["result"] + assert result["sources"][0] is not None + assert result["sources"][1] is None # background ROI gets no mask + assert result["mask_coverage"][0] is not None + + # Analysis restricted to the captured masks succeeds. + response = client.post( + f"/api/videos/{meta['video_id']}/analyze", + json={ + "rois": rois, + "background_roi_idx": 1, + "start_frame": 0, + "end_frame": 29, + "mask_job_id": mask_job_id, + }, + ) + assert response.status_code == 200, response.text + payload = _wait_for_job(client, response.json()["job_id"]) + assert payload["status"] == "done", payload + assert len(payload["result"]["rois"][0]["brightness_mean"]) == 30 + + # Resizing a region after capture must fail loudly, not silently ignore masks. + resized = [dict(rois[0], x2=30), rois[1]] + response = client.post( + f"/api/videos/{meta['video_id']}/analyze", + json={ + "rois": resized, + "background_roi_idx": 1, + "start_frame": 0, + "end_frame": 29, + "mask_job_id": mask_job_id, + }, + ) + assert response.status_code == 409 + + +def test_detect_range_on_silent_video_returns_no_beeps(client, synthetic_video): + meta = _open_video(client, synthetic_video) + response = client.post( + f"/api/videos/{meta['video_id']}/detect-range", + json={"expected_duration": 0.5}, + ) + # Either audio extras are absent (501) or the silent clip yields no beeps. + assert response.status_code in (200, 501) + if response.status_code == 200: + assert response.json()["beeps"] == [] diff --git a/tests/integration/test_workers.py b/tests/integration/test_workers.py index a8dd252..21796bd 100644 --- a/tests/integration/test_workers.py +++ b/tests/integration/test_workers.py @@ -4,7 +4,7 @@ import numpy as np import pytest -import ecl_analysis.workers as workers_module +import ecl_analysis.analysis.runner as runner_module from ecl_analysis.analysis.background import BackgroundComputationError from ecl_analysis.analysis.brightness import compute_l_star_frame from ecl_analysis.analysis.models import AnalysisRequest @@ -155,7 +155,7 @@ def test_analysis_worker_aborts_on_brightness_computation_failure(monkeypatch): def boom(*args, **kwargs): raise cv2.error("synthetic brightness computation failure") - monkeypatch.setattr(workers_module, "compute_brightness_stats", boom) + monkeypatch.setattr(runner_module, "compute_brightness_stats", boom) request = AnalysisRequest( video_path="dummy.mp4", @@ -193,7 +193,7 @@ def test_analysis_worker_aborts_on_background_computation_failure(monkeypatch): def boom(*args, **kwargs): raise BackgroundComputationError("synthetic background computation failure") - monkeypatch.setattr(workers_module, "compute_background_brightness", boom) + monkeypatch.setattr(runner_module, "compute_background_brightness", boom) request = AnalysisRequest( video_path="dummy.mp4", diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..c12533f --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,451 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "brightness-sorcerer-web", + "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.0", + "@fontsource/ibm-plex-sans": "^5.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.2.0", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.3.0", "", {}, "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q=="], + + "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.3.0", "", {}, "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.20", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..eba987f --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + + Brightness Sorcerer + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a08991f --- /dev/null +++ b/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "brightness-sorcerer-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.0", + "@fontsource/ibm-plex-sans": "^5.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.2.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..1d9a3d2 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import * as api from './api' +import { AnalyzePanel } from './components/AnalyzePanel' +import { FilePicker } from './components/FilePicker' +import { MasksPanel } from './components/MasksPanel' +import { ResultsDrawer } from './components/ResultsDrawer' +import { RoiPanel } from './components/RoiPanel' +import { SettingsPanel } from './components/SettingsPanel' +import { Transport } from './components/Transport' +import { VideoWell } from './components/VideoWell' +import { nextRoiName } from './roi' +import type { AnalysisSettings, JobStatus, Roi, VideoMeta } from './types' +import { DEFAULT_SETTINGS } from './types' + +export interface FrameRange { + start: number + end: number +} + +export function App() { + const [video, setVideo] = useState(null) + const [frame, setFrame] = useState(0) + const [playing, setPlaying] = useState(false) + const [rois, setRois] = useState([]) + const [selectedRoiId, setSelectedRoiId] = useState(null) + const [backgroundRoiId, setBackgroundRoiId] = useState(null) + const [range, setRange] = useState({ start: 0, end: 0 }) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [previewThreshold, setPreviewThreshold] = useState(false) + const [drawMode, setDrawMode] = useState(false) + const [job, setJob] = useState(null) + const [pickerOpen, setPickerOpen] = useState(false) + const [openError, setOpenError] = useState(null) + const [speed, setSpeed] = useState(1) + const [maskJobId, setMaskJobId] = useState(null) + const [useMasks, setUseMasks] = useState(false) + const nextRoiId = useRef(1) + + const lastFrame = video ? Math.max(0, video.frame_count - 1) : 0 + + const openVideo = useCallback(async (path: string) => { + setOpenError(null) + try { + const meta = await api.openVideo(path) + setVideo(meta) + setFrame(0) + setPlaying(false) + setRois([]) + setSelectedRoiId(null) + setBackgroundRoiId(null) + setRange({ start: 0, end: Math.max(0, meta.frame_count - 1) }) + setJob(null) + setPickerOpen(false) + } catch (error) { + setOpenError(error instanceof Error ? error.message : String(error)) + } + }, []) + + // Playback. At >=1× we tick at native fps and skip frames (server decode + // can't exceed native rate); below 1× we slow the tick instead. + useEffect(() => { + if (!playing || !video) return + const fps = video.fps > 0 ? video.fps : 30 + const stride = speed >= 1 ? Math.round(speed) : 1 + const intervalMs = speed >= 1 ? 1000 / fps : 1000 / (fps * speed) + const interval = window.setInterval(() => { + setFrame((current) => { + if (current >= lastFrame) { + setPlaying(false) + return current + } + return Math.min(current + stride, lastFrame) + }) + }, intervalMs) + return () => window.clearInterval(interval) + }, [playing, video, lastFrame, speed]) + + // Captured masks are pinned to region geometry; any move/resize/add/delete + // (or background change) invalidates them so analysis can't silently use + // masks that no longer match. + const geometrySignature = JSON.stringify([ + rois.map((roi) => [roi.id, roi.x1, roi.y1, roi.x2, roi.y2]), + backgroundRoiId, + ]) + const lastGeometry = useRef(geometrySignature) + useEffect(() => { + if (lastGeometry.current !== geometrySignature) { + lastGeometry.current = geometrySignature + setMaskJobId(null) + setUseMasks(false) + } + }, [geometrySignature]) + + // Poll a queued/running job until it settles. + useEffect(() => { + if (!job || (job.status !== 'queued' && job.status !== 'running')) return + const interval = window.setInterval(async () => { + try { + setJob(await api.jobStatus(job.job_id)) + } catch { + // Poll again on transient failures; the next tick will retry. + } + }, 300) + return () => window.clearInterval(interval) + }, [job]) + + const seek = useCallback( + (target: number) => { + setFrame(Math.max(0, Math.min(Math.round(target), lastFrame))) + }, + [lastFrame], + ) + + // Keyboard transport: arrows step, PageUp/Down jump 10, space plays — the + // same bindings the desktop app trained users on. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'SELECT' || target.tagName === 'TEXTAREA') { + return + } + if (!video) return + switch (event.key) { + case 'ArrowLeft': + seek(frame - 1) + break + case 'ArrowRight': + seek(frame + 1) + break + case 'PageUp': + seek(frame - 10) + break + case 'PageDown': + seek(frame + 10) + break + case ' ': + setPlaying((value) => !value) + break + case 'Escape': + setDrawMode(false) + return + default: + return + } + event.preventDefault() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [video, frame, seek]) + + const addRoi = useCallback( + (rect: { x1: number; y1: number; x2: number; y2: number }) => { + const roi: Roi = { id: nextRoiId.current, name: '', ...rect } + roi.name = nextRoiName(rois) + nextRoiId.current += 1 + setRois((current) => [...current, roi]) + setSelectedRoiId(roi.id) + setDrawMode(false) + }, + [rois], + ) + + const updateRoi = useCallback((id: number, patch: Partial) => { + setRois((current) => current.map((roi) => (roi.id === id ? { ...roi, ...patch } : roi))) + }, []) + + const deleteRoi = useCallback( + (id: number) => { + setRois((current) => current.filter((roi) => roi.id !== id)) + if (selectedRoiId === id) setSelectedRoiId(null) + if (backgroundRoiId === id) setBackgroundRoiId(null) + }, + [selectedRoiId, backgroundRoiId], + ) + + // Manual threshold only gates analysis when no background ROI exists, so the + // live preview mirrors that rule instead of showing a mask that won't apply. + const effectivePreview = + previewThreshold && backgroundRoiId === null && settings.manual_threshold > 0 + ? settings.manual_threshold + : undefined + + return ( +
+
+
+ Brightness Sorcerer +
+ {video ? ( +
+ {video.name} + + {video.width}×{video.height} · {video.fps.toFixed(2)} fps · {video.frame_count} frames + +
+ ) : ( +
+ )} + +
+ +
+
+ setPickerOpen(true)} + /> + {video && ( + setPlaying((value) => !value)} + onSpeedChange={setSpeed} + onRangeChange={setRange} + /> + )} + {job?.status === 'done' && job.result && ( + + )} +
+ + +
+ + {pickerOpen && ( + { + setPickerOpen(false) + setOpenError(null) + }} + /> + )} +
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..13e98f2 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,124 @@ +import type { + AnalysisSettings, + DetectedBeep, + ExportResponse, + FsListing, + JobStatus, + Roi, + VideoMeta, +} from './types' + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + if (!response.ok) { + let detail = `${response.status} ${response.statusText}` + try { + const body = await response.json() + if (typeof body?.detail === 'string') detail = body.detail + } catch { + // Non-JSON error body; keep the status text. + } + throw new Error(detail) + } + return response.json() as Promise +} + +export function openVideo(path: string): Promise { + return request('/api/videos', { method: 'POST', body: JSON.stringify({ path }) }) +} + +export function frameUrl(videoId: string, index: number, threshold?: number): string { + const params = threshold && threshold > 0 ? `?threshold=${threshold}` : '' + return `/api/videos/${videoId}/frame/${index}${params}` +} + +export function listDirectory(path?: string): Promise { + const params = path ? `?path=${encodeURIComponent(path)}` : '' + return request(`/api/fs${params}`) +} + +function serializeRois(rois: Roi[]) { + return rois.map((roi) => ({ x1: roi.x1, y1: roi.y1, x2: roi.x2, y2: roi.y2, name: roi.name })) +} + +export function startAnalysis( + videoId: string, + rois: Roi[], + backgroundRoiIdx: number | null, + startFrame: number, + endFrame: number, + settings: AnalysisSettings, + maskJobId?: string | null, +): Promise<{ job_id: string }> { + return request(`/api/videos/${videoId}/analyze`, { + method: 'POST', + body: JSON.stringify({ + rois: serializeRois(rois), + background_roi_idx: backgroundRoiIdx, + start_frame: startFrame, + end_frame: endFrame, + mask_job_id: maskJobId ?? null, + ...settings, + }), + }) +} + +export function startMaskScan( + videoId: string, + mode: 'global' | 'per_roi', + rois: Roi[], + backgroundRoiIdx: number | null, + startFrame: number, + endFrame: number, + step: number, + settings: AnalysisSettings, +): Promise<{ job_id: string }> { + return request(`/api/videos/${videoId}/mask-scan`, { + method: 'POST', + body: JSON.stringify({ + mode, + rois: serializeRois(rois), + background_roi_idx: backgroundRoiIdx, + start_frame: startFrame, + end_frame: endFrame, + step, + background_percentile: settings.background_percentile, + morphological_kernel_size: settings.morphological_kernel_size, + }), + }) +} + +export function detectRange(videoId: string, expectedDuration: number): Promise<{ beeps: DetectedBeep[] }> { + return request(`/api/videos/${videoId}/detect-range`, { + method: 'POST', + body: JSON.stringify({ expected_duration: expectedDuration }), + }) +} + +export function jobStatus(jobId: string): Promise { + return request(`/api/jobs/${jobId}`) +} + +export function cancelJob(jobId: string): Promise<{ cancelled: boolean }> { + return request(`/api/jobs/${jobId}/cancel`, { method: 'POST' }) +} + +export interface ExportRequestBody { + analysis_name: string + save_dir?: string + csv: boolean + json_export: boolean + plot: boolean + interactive_plot: boolean +} + +export function exportAnalysis(jobId: string, body: ExportRequestBody): Promise { + return request(`/api/jobs/${jobId}/export`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function exportedFileUrl(jobId: string, path: string): string { + return `/api/jobs/${jobId}/files?path=${encodeURIComponent(path)}` +} diff --git a/web/src/components/AnalyzePanel.tsx b/web/src/components/AnalyzePanel.tsx new file mode 100644 index 0000000..89dbc71 --- /dev/null +++ b/web/src/components/AnalyzePanel.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react' +import * as api from '../api' +import type { FrameRange } from '../App' +import type { AnalysisSettings, DetectedBeep, JobStatus, Roi, VideoMeta } from '../types' + +interface AnalyzePanelProps { + video: VideoMeta | null + rois: Roi[] + backgroundRoiId: number | null + range: FrameRange + settings: AnalysisSettings + job: JobStatus | null + maskJobId: string | null + onJobChange: (job: JobStatus | null) => void + onRangeDetected: (range: FrameRange) => void +} + +export function AnalyzePanel({ + video, + rois, + backgroundRoiId, + range, + settings, + job, + maskJobId, + onJobChange, + onRangeDetected, +}: AnalyzePanelProps) { + const [startError, setStartError] = useState(null) + const [expectedDuration, setExpectedDuration] = useState(10) + const [detecting, setDetecting] = useState(false) + const [beeps, setBeeps] = useState(null) + const [detectError, setDetectError] = useState(null) + + const backgroundIdx = backgroundRoiId === null + ? null + : rois.findIndex((roi) => roi.id === backgroundRoiId) + const analyzableCount = rois.filter((roi) => roi.id !== backgroundRoiId).length + const running = job?.status === 'queued' || job?.status === 'running' + const canAnalyze = Boolean(video) && analyzableCount > 0 && !running + + const start = async () => { + if (!video) return + setStartError(null) + try { + const { job_id } = await api.startAnalysis( + video.video_id, + rois, + backgroundIdx === -1 ? null : backgroundIdx, + range.start, + range.end, + settings, + maskJobId, + ) + onJobChange(await api.jobStatus(job_id)) + } catch (error) { + setStartError(error instanceof Error ? error.message : String(error)) + } + } + + const detect = async () => { + if (!video) return + setDetecting(true) + setDetectError(null) + setBeeps(null) + try { + const { beeps: found } = await api.detectRange(video.video_id, expectedDuration) + setBeeps(found) + if (found.length === 1) { + onRangeDetected({ start: found[0].start_frame, end: found[0].end_frame }) + } + } catch (error) { + setDetectError(error instanceof Error ? error.message : String(error)) + } finally { + setDetecting(false) + } + } + + const progress = job && job.progress.total > 0 ? job.progress.done / job.progress.total : 0 + + return ( +
+
+ Run + + frames {range.start}–{range.end} + {maskJobId ? ' · masked' : ''} + +
+ +
+ + setExpectedDuration(Number(event.target.value))} + /> +
+ + {beeps !== null && beeps.length === 0 && ( +
No completion beeps found in this video's audio.
+ )} + {beeps !== null && beeps.length === 1 && ( +
+ Range set from beep at {beeps[0].beep_time.toFixed(1)}s +
+ )} + {beeps !== null && beeps.length > 1 && ( +
+
Several beeps found — pick the run's end:
+ {beeps.map((beep) => ( + + ))} +
+ )} + {detectError &&
{detectError}
} + + {running ? ( + + ) : ( + + )} + + {!running && analyzableCount === 0 && ( +
+ Draw at least one non-background region to analyze. +
+ )} + + {running && ( +
+
+
+
+
+ {job?.message || `Frame ${job?.progress.done ?? 0} / ${job?.progress.total ?? 0}`} +
+
+ )} + + {job?.status === 'cancelled' &&
Analysis cancelled.
} + {job?.status === 'error' &&
{job.error}
} + {job?.status === 'done' && job.result && 'frames_processed' in job.result && ( +
+ {job.result.frames_processed} frames in {job.result.elapsed_seconds.toFixed(1)}s + {job.result.truncated ? ' · truncated at end of file' : ''} +
+ )} + {startError &&
{startError}
} +
+ ) +} diff --git a/web/src/components/FilePicker.tsx b/web/src/components/FilePicker.tsx new file mode 100644 index 0000000..9776d6b --- /dev/null +++ b/web/src/components/FilePicker.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react' +import * as api from '../api' +import type { FsListing } from '../types' + +interface FilePickerProps { + error: string | null + onPick: (path: string) => void + onClose: () => void +} + +export function FilePicker({ error, onPick, onClose }: FilePickerProps) { + const [listing, setListing] = useState(null) + const [pathInput, setPathInput] = useState('') + const [browseError, setBrowseError] = useState(null) + + const browse = async (path?: string, options?: { keepTypedPath?: boolean }) => { + setBrowseError(null) + try { + const result = await api.listDirectory(path) + setListing(result) + if (options?.keepTypedPath) { + // The initial listing must never clobber a path the user is typing. + setPathInput((current) => (current === '' ? result.path : current)) + } else { + setPathInput(result.path) + } + } catch (browseFailure) { + setBrowseError(browseFailure instanceof Error ? browseFailure.message : String(browseFailure)) + } + } + + useEffect(() => { + void browse(undefined, { keepTypedPath: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const submitPath = () => { + const trimmed = pathInput.trim() + if (!trimmed) return + // A file path opens directly; a directory browses into it. + if (/\.(mp4|mov|avi|mkv|m4v|mpg|mpeg|wmv)$/i.test(trimmed)) { + onPick(trimmed) + } else { + void browse(trimmed) + } + } + + return ( +
+
event.stopPropagation()}> +
+ Open video +
+ setPathInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') submitPath() + }} + /> + +
+
+
+ {listing?.parent && ( + + )} + {listing?.dirs.map((dir) => ( + + ))} + {listing?.videos.map((videoFile) => ( + + ))} + {listing && listing.dirs.length === 0 && listing.videos.length === 0 && ( +
No folders or videos here.
+ )} +
+ {(error || browseError) && ( +
+ {error || browseError} +
+ )} +
+ +
+
+
+ ) +} diff --git a/web/src/components/MasksPanel.tsx b/web/src/components/MasksPanel.tsx new file mode 100644 index 0000000..a675d59 --- /dev/null +++ b/web/src/components/MasksPanel.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react' +import * as api from '../api' +import type { FrameRange } from '../App' +import { useJob } from '../hooks' +import type { + AnalysisSettings, + BrightestFrameResultPayload, + PerRoiMaskResultPayload, + Roi, + VideoMeta, +} from '../types' + +interface MasksPanelProps { + video: VideoMeta | null + rois: Roi[] + backgroundRoiId: number | null + range: FrameRange + settings: AnalysisSettings + maskJobId: string | null + useMasks: boolean + onMaskJobChange: (jobId: string | null) => void + onUseMasksChange: (useMasks: boolean) => void + onSeek: (frame: number) => void +} + +export function MasksPanel({ + video, + rois, + backgroundRoiId, + range, + settings, + maskJobId, + useMasks, + onMaskJobChange, + onUseMasksChange, + onSeek, +}: MasksPanelProps) { + const [step, setStep] = useState(5) + const [globalJobId, setGlobalJobId] = useState(null) + const [captureJobId, setCaptureJobId] = useState(null) + const [scanError, setScanError] = useState(null) + + const globalJob = useJob(globalJobId) + const captureJob = useJob(captureJobId) + + const backgroundIdx = backgroundRoiId === null + ? null + : rois.findIndex((roi) => roi.id === backgroundRoiId) + const analyzableCount = rois.filter((roi) => roi.id !== backgroundRoiId).length + const scanning = + globalJob?.status === 'queued' || + globalJob?.status === 'running' || + captureJob?.status === 'queued' || + captureJob?.status === 'running' + const canScan = Boolean(video) && analyzableCount > 0 && !scanning + + // Jump to the brightest frame as soon as the global scan lands. + useEffect(() => { + if (globalJob?.status === 'done' && globalJob.result) { + onSeek((globalJob.result as BrightestFrameResultPayload).brightest_frame_idx) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [globalJob?.status]) + + // Register a finished capture with the app so analysis can use it. + useEffect(() => { + if (captureJob?.status === 'done' && captureJobId) { + onMaskJobChange(captureJobId) + onUseMasksChange(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [captureJob?.status]) + + const startScan = async (mode: 'global' | 'per_roi') => { + if (!video) return + setScanError(null) + try { + const { job_id } = await api.startMaskScan( + video.video_id, + mode, + rois, + backgroundIdx === -1 ? null : backgroundIdx, + range.start, + range.end, + step, + settings, + ) + if (mode === 'global') setGlobalJobId(job_id) + else { + onMaskJobChange(null) + setCaptureJobId(job_id) + } + } catch (error) { + setScanError(error instanceof Error ? error.message : String(error)) + } + } + + const activeScan = [globalJob, captureJob].find( + (job) => job?.status === 'queued' || job?.status === 'running', + ) + const captureResult = + maskJobId && captureJob?.status === 'done' + ? (captureJob.result as PerRoiMaskResultPayload) + : null + + return ( +
+
+ Masks + + + setStep(Math.max(1, Number(event.target.value)))} + /> + +
+ +
+ + +
+
+ Masks lock analysis to the pixels lit at each region's brightest frame. +
+ + {activeScan && ( +
+
+
0 + ? (activeScan.progress.done / activeScan.progress.total) * 100 + : 0 + }%`, + }} + /> +
+
{activeScan.message || 'Scanning…'}
+
+ )} + + {globalJob?.status === 'done' && globalJob.result && ( +
+ Brightest: frame {(globalJob.result as BrightestFrameResultPayload).brightest_frame_idx} (L*{' '} + {(globalJob.result as BrightestFrameResultPayload).max_brightness.toFixed(1)}) +
+ )} + + {captureResult && ( + <> + +
    + {rois.map((roi, index) => + captureResult.sources[index] !== null ? ( +
  • + {roi.name}: frame {captureResult.sources[index]} + {captureResult.mask_coverage[index] !== null + ? ` · ${(captureResult.mask_coverage[index]! * 100).toFixed(0)}% of region` + : ''} +
  • + ) : null, + )} +
+ + )} + {maskJobId === null && captureJob?.status === 'done' && ( +
+ Regions changed since capture — masks were discarded. Recapture to use them. +
+ )} + + {(globalJob?.status === 'error' || captureJob?.status === 'error') && ( +
{globalJob?.error || captureJob?.error}
+ )} + {scanError &&
{scanError}
} +
+ ) +} diff --git a/web/src/components/ResultsDrawer.tsx b/web/src/components/ResultsDrawer.tsx new file mode 100644 index 0000000..a0584ed --- /dev/null +++ b/web/src/components/ResultsDrawer.tsx @@ -0,0 +1,338 @@ +import { useMemo, useRef, useState } from 'react' +import * as api from '../api' +import { roiColor } from '../roi' +import type { AnalysisResultPayload, ExportResponse, JobStatus, MetricKey, Roi } from '../types' +import { METRIC_LABELS } from '../types' + +interface ResultsDrawerProps { + job: JobStatus + rois: Roi[] + backgroundRoiId: number | null +} + +interface SeriesView { + name: string + color: string + values: number[] +} + +const CHART_WIDTH = 800 +const CHART_HEIGHT = 260 +const MARGIN = { top: 12, right: 16, bottom: 28, left: 48 } + +export function ResultsDrawer({ job, rois, backgroundRoiId }: ResultsDrawerProps) { + const [metric, setMetric] = useState('brightness_mean') + const result = job.result as AnalysisResultPayload + + const series: SeriesView[] = useMemo(() => { + const nonBackground = rois.filter((roi) => roi.id !== backgroundRoiId) + return result.rois.map((roiSeries, index) => { + const roi = rois[roiSeries.roi_index] ?? nonBackground[index] + return { + name: roi?.name ?? `ROI ${roiSeries.roi_index + 1}`, + color: roi ? roiColor(roi.id) : roiColor(index), + values: roiSeries[metric], + } + }) + }, [result, rois, backgroundRoiId, metric]) + + return ( +
+
+ Results · {METRIC_LABELS[metric]} +
+ + +
+
+ + +
+ ) +} + +function BrightnessChart({ + series, + startFrame, + metricLabel, +}: { + series: SeriesView[] + startFrame: number + metricLabel: string +}) { + const wrapRef = useRef(null) + const [hoverIndex, setHoverIndex] = useState(null) + const [hoverPx, setHoverPx] = useState<{ x: number; y: number } | null>(null) + + const frameCount = Math.max(...series.map((entry) => entry.values.length), 0) + const plotWidth = CHART_WIDTH - MARGIN.left - MARGIN.right + const plotHeight = CHART_HEIGHT - MARGIN.top - MARGIN.bottom + + const [yMin, yMax] = useMemo(() => { + let min = Infinity + let max = -Infinity + for (const entry of series) { + for (const value of entry.values) { + if (value < min) min = value + if (value > max) max = value + } + } + if (!Number.isFinite(min)) return [0, 1] + if (min === max) return [min - 1, max + 1] + const pad = (max - min) * 0.06 + return [min - pad, max + pad] + }, [series]) + + const xAt = (index: number) => MARGIN.left + (frameCount > 1 ? (index / (frameCount - 1)) * plotWidth : 0) + const yAt = (value: number) => MARGIN.top + plotHeight - ((value - yMin) / (yMax - yMin)) * plotHeight + + const paths = useMemo( + () => + series.map((entry) => + entry.values + .map((value, index) => `${index === 0 ? 'M' : 'L'}${xAt(index).toFixed(1)},${yAt(value).toFixed(1)}`) + .join(''), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [series, yMin, yMax, frameCount], + ) + + const yTicks = useMemo(() => { + const ticks: number[] = [] + for (let i = 0; i <= 4; i += 1) ticks.push(yMin + ((yMax - yMin) * i) / 4) + return ticks + }, [yMin, yMax]) + + const onMove = (event: React.MouseEvent) => { + const svg = event.currentTarget + const rect = svg.getBoundingClientRect() + const px = ((event.clientX - rect.left) / rect.width) * CHART_WIDTH + if (frameCount < 1) return + const ratio = (px - MARGIN.left) / plotWidth + const index = Math.max(0, Math.min(frameCount - 1, Math.round(ratio * (frameCount - 1)))) + setHoverIndex(index) + const wrapRect = wrapRef.current?.getBoundingClientRect() + if (wrapRect) { + setHoverPx({ x: event.clientX - wrapRect.left, y: event.clientY - wrapRect.top }) + } + } + + if (frameCount === 0) { + return
No data points to plot.
+ } + + return ( +
+ { + setHoverIndex(null) + setHoverPx(null) + }} + > + {yTicks.map((tick) => ( + + + + {tick.toFixed(1)} + + + ))} + {[0, 0.5, 1].map((ratio) => { + const index = Math.round(ratio * (frameCount - 1)) + return ( + + {startFrame + index} + + ) + })} + {series.map((entry, index) => ( + + ))} + {hoverIndex !== null && ( + + + {series.map((entry) => + hoverIndex < entry.values.length ? ( + + ) : null, + )} + + )} + + {hoverIndex !== null && hoverPx && ( +
+
frame {startFrame + hoverIndex}
+ {series.map((entry) => ( +
+ + {entry.name}: {hoverIndex < entry.values.length ? entry.values[hoverIndex].toFixed(2) : '—'} +
+ ))} +
+ )} + {series.length >= 2 && ( +
+ {series.map((entry) => ( + + + {entry.name} + + ))} +
+ )} +
+ ) +} + +function ExportPanel({ job }: { job: JobStatus }) { + const [analysisName, setAnalysisName] = useState('analysis') + const [saveDir, setSaveDir] = useState('') + const [csv, setCsv] = useState(true) + const [plot, setPlot] = useState(true) + const [busy, setBusy] = useState(false) + const [exportResult, setExportResult] = useState(null) + const [exportError, setExportError] = useState(null) + + const runExport = async () => { + setBusy(true) + setExportError(null) + try { + const response = await api.exportAnalysis(job.job_id, { + analysis_name: analysisName.trim() || 'analysis', + save_dir: saveDir.trim() || undefined, + csv, + json_export: false, + plot, + interactive_plot: false, + }) + setExportResult(response) + } catch (error) { + setExportError(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const plotPaths = exportResult?.out_paths.filter((path) => path.endsWith('.png')) ?? [] + + return ( +
+
+ Export +
+
+
+ + setAnalysisName(event.target.value)} + style={{ width: 180 }} + /> +
+
+ + setSaveDir(event.target.value)} + style={{ width: 180, fontFamily: 'var(--font-data)', fontSize: 12 }} + /> +
+
+ + + +
+
+ {exportError &&
{exportError}
} + {exportResult && ( + <> +
+ Wrote {exportResult.out_paths.length} file{exportResult.out_paths.length === 1 ? '' : 's'} to{' '} + {exportResult.save_dir} + {exportResult.plot_failed ? ' — plot generation failed, see server log.' : ''} +
+
    + {exportResult.out_paths.map((path) => ( +
  • {path}
  • + ))} +
+ {plotPaths.map((path) => ( + {`Plot: + ))} + + )} +
+ ) +} diff --git a/web/src/components/RoiPanel.tsx b/web/src/components/RoiPanel.tsx new file mode 100644 index 0000000..80cb13e --- /dev/null +++ b/web/src/components/RoiPanel.tsx @@ -0,0 +1,91 @@ +import { BACKGROUND_ROI_COLOR, roiColor } from '../roi' +import type { Roi } from '../types' + +interface RoiPanelProps { + rois: Roi[] + selectedRoiId: number | null + backgroundRoiId: number | null + drawMode: boolean + disabled: boolean + onArmDraw: () => void + onSelect: (id: number) => void + onRename: (id: number, name: string) => void + onSetBackground: (id: number) => void + onDelete: (id: number) => void +} + +export function RoiPanel({ + rois, + selectedRoiId, + backgroundRoiId, + drawMode, + disabled, + onArmDraw, + onSelect, + onRename, + onSetBackground, + onDelete, +}: RoiPanelProps) { + return ( +
+
+ Regions + +
+ {rois.length === 0 && ( +
+ Draw regions over each electrode. Mark one as background to enable + background subtraction. +
+ )} + {rois.map((roi) => { + const isBackground = roi.id === backgroundRoiId + return ( +
onSelect(roi.id)} + > + + onRename(roi.id, event.target.value)} + onClick={(event) => event.stopPropagation()} + /> + {isBackground && BG} + + {roi.x2 - roi.x1}×{roi.y2 - roi.y1} + + + +
+ ) + })} +
+ ) +} diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..9b619ab --- /dev/null +++ b/web/src/components/SettingsPanel.tsx @@ -0,0 +1,98 @@ +import type { AnalysisSettings } from '../types' + +interface SettingsPanelProps { + settings: AnalysisSettings + backgroundRoiSet: boolean + previewThreshold: boolean + onChange: (settings: AnalysisSettings) => void + onPreviewThresholdChange: (enabled: boolean) => void +} + +export function SettingsPanel({ + settings, + backgroundRoiSet, + previewThreshold, + onChange, + onPreviewThresholdChange, +}: SettingsPanelProps) { + const set = (patch: Partial) => onChange({ ...settings, ...patch }) + + return ( +
+
+ Analysis settings +
+ +
+ + set({ background_percentile: Number(event.target.value) })} + /> +
+ +
+ + set({ morphological_kernel_size: Number(event.target.value) })} + /> +
+ +
+ + set({ noise_floor_threshold: Number(event.target.value) })} + /> +
+ +
+ + set({ manual_threshold: Number(event.target.value) })} + /> +
+ {backgroundRoiSet ? ( +
+ The background region sets the threshold; the manual value is ignored. +
+ ) : ( +
+ Applied when no background region is marked. 0 disables thresholding. +
+ )} + + +
+ ) +} diff --git a/web/src/components/Transport.tsx b/web/src/components/Transport.tsx new file mode 100644 index 0000000..6d799a6 --- /dev/null +++ b/web/src/components/Transport.tsx @@ -0,0 +1,140 @@ +import { useCallback, useRef } from 'react' +import type { FrameRange } from '../App' + +export const PLAYBACK_SPEEDS = [0.25, 0.5, 1, 2, 4] as const + +interface TransportProps { + frame: number + frameCount: number + fps: number + playing: boolean + speed: number + range: FrameRange + onSeek: (frame: number) => void + onTogglePlay: () => void + onSpeedChange: (speed: number) => void + onRangeChange: (range: FrameRange) => void +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, '0') +} + +export function Transport({ + frame, + frameCount, + fps, + playing, + speed, + range, + onSeek, + onTogglePlay, + onSpeedChange, + onRangeChange, +}: TransportProps) { + const scrubberRef = useRef(null) + const lastFrame = Math.max(0, frameCount - 1) + const digits = String(lastFrame).length + const seconds = fps > 0 ? frame / fps : 0 + + const frameFromEvent = useCallback( + (clientX: number) => { + const element = scrubberRef.current + if (!element || lastFrame === 0) return 0 + const rect = element.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + return Math.round(ratio * lastFrame) + }, + [lastFrame], + ) + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId) + onSeek(frameFromEvent(event.clientX)) + }, + [frameFromEvent, onSeek], + ) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (event.buttons !== 1) return + onSeek(frameFromEvent(event.clientX)) + }, + [frameFromEvent, onSeek], + ) + + const toPercent = (value: number) => (lastFrame > 0 ? (value / lastFrame) * 100 : 0) + + return ( +
+ + + + {pad(frame, digits)} + / {lastFrame} · {seconds.toFixed(2)}s + +
+
+
+
+
+ + {pad(range.start, digits)}–{pad(range.end, digits)} + +
+ + + +
+
+ ) +} diff --git a/web/src/components/VideoWell.tsx b/web/src/components/VideoWell.tsx new file mode 100644 index 0000000..6a7ec13 --- /dev/null +++ b/web/src/components/VideoWell.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { frameUrl } from '../api' +import { BACKGROUND_ROI_COLOR, dragRoi, hitTestRoi, normalizeRoi, roiArea, roiColor } from '../roi' +import type { Handle } from '../roi' +import type { Roi, VideoMeta } from '../types' + +const MIN_ROI_AREA = 16 + +interface DragState { + roiId: number | 'new' + handle: Handle + lastX: number + lastY: number + draft?: { x1: number; y1: number; x2: number; y2: number } +} + +interface VideoWellProps { + video: VideoMeta | null + frame: number + previewThreshold?: number + rois: Roi[] + selectedRoiId: number | null + backgroundRoiId: number | null + drawMode: boolean + onSelect: (id: number | null) => void + onRoiChange: (id: number, patch: Partial) => void + onRoiDrawn: (rect: { x1: number; y1: number; x2: number; y2: number }) => void + onOpenClick: () => void +} + +export function VideoWell({ + video, + frame, + previewThreshold, + rois, + selectedRoiId, + backgroundRoiId, + drawMode, + onSelect, + onRoiChange, + onRoiDrawn, + onOpenClick, +}: VideoWellProps) { + const canvasRef = useRef(null) + const imageRef = useRef(null) + const [imageVersion, setImageVersion] = useState(0) + const [drag, setDrag] = useState(null) + + // Load the current frame; keep the previous image on screen until the new + // one decodes so scrubbing never flashes black. + useEffect(() => { + if (!video) { + imageRef.current = null + setImageVersion((version) => version + 1) + return + } + let stale = false + const image = new Image() + image.onload = () => { + if (stale) return + imageRef.current = image + setImageVersion((version) => version + 1) + } + image.src = frameUrl(video.video_id, frame, previewThreshold) + return () => { + stale = true + } + }, [video, frame, previewThreshold]) + + // Repaint the canvas whenever the frame image or any overlay input changes. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !video) return + const context = canvas.getContext('2d') + if (!context) return + + canvas.width = video.width + canvas.height = video.height + context.fillStyle = '#0a0d10' + context.fillRect(0, 0, canvas.width, canvas.height) + if (imageRef.current) { + context.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height) + } + + const drawRect = ( + rect: { x1: number; y1: number; x2: number; y2: number }, + color: string, + options: { selected?: boolean; dashed?: boolean; label?: string }, + ) => { + const width = rect.x2 - rect.x1 + const height = rect.y2 - rect.y1 + context.save() + context.lineWidth = options.selected ? 2.5 : 1.5 + context.strokeStyle = color + if (options.dashed) context.setLineDash([6, 4]) + context.strokeRect(rect.x1 + 0.5, rect.y1 + 0.5, width, height) + context.setLineDash([]) + + if (options.label) { + context.font = '600 12px "IBM Plex Mono", monospace' + const metrics = context.measureText(options.label) + const labelY = rect.y1 >= 18 ? rect.y1 - 5 : rect.y2 + 14 + context.fillStyle = 'rgba(10, 13, 16, 0.8)' + context.fillRect(rect.x1, labelY - 11, metrics.width + 8, 15) + context.fillStyle = color + context.fillText(options.label, rect.x1 + 4, labelY) + } + + if (options.selected) { + context.fillStyle = color + for (const [cx, cy] of [ + [rect.x1, rect.y1], + [rect.x2, rect.y1], + [rect.x1, rect.y2], + [rect.x2, rect.y2], + ]) { + context.fillRect(cx - 3, cy - 3, 6, 6) + } + } + context.restore() + } + + for (const roi of rois) { + const isBackground = roi.id === backgroundRoiId + drawRect(roi, isBackground ? BACKGROUND_ROI_COLOR : roiColor(roi.id), { + selected: roi.id === selectedRoiId, + dashed: isBackground, + label: isBackground ? `${roi.name} · BG` : roi.name, + }) + } + + if (drag?.draft) { + drawRect(normalizeRoi(drag.draft, video.width, video.height), '#53d8ff', { + dashed: true, + }) + } + }, [video, rois, selectedRoiId, backgroundRoiId, drag, imageVersion]) + + const toFrameCoords = useCallback((event: React.PointerEvent) => { + const canvas = canvasRef.current! + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + scale: scaleX, + } + }, []) + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + if (!video) return + event.currentTarget.setPointerCapture(event.pointerId) + const { x, y, scale } = toFrameCoords(event) + + if (drawMode) { + setDrag({ roiId: 'new', handle: 'se', lastX: x, lastY: y, draft: { x1: x, y1: y, x2: x, y2: y } }) + return + } + + // Topmost ROI wins: iterate back-to-front. + for (let i = rois.length - 1; i >= 0; i -= 1) { + const roi = rois[i] + const handle = hitTestRoi(roi, x, y, scale) + if (handle !== null) { + onSelect(roi.id) + setDrag({ roiId: roi.id, handle, lastX: x, lastY: y }) + return + } + } + onSelect(null) + }, + [video, drawMode, rois, onSelect, toFrameCoords], + ) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (!drag || !video) return + const { x, y } = toFrameCoords(event) + + if (drag.roiId === 'new' && drag.draft) { + setDrag({ ...drag, draft: { ...drag.draft, x2: x, y2: y } }) + return + } + + const roi = rois.find((candidate) => candidate.id === drag.roiId) + if (!roi) return + const moved = dragRoi(roi, drag.handle, x - drag.lastX, y - drag.lastY, video.width, video.height) + onRoiChange(roi.id, moved) + setDrag({ ...drag, lastX: x, lastY: y }) + }, + [drag, video, rois, onRoiChange, toFrameCoords], + ) + + const onPointerUp = useCallback(() => { + if (drag?.roiId === 'new' && drag.draft && video) { + const rect = normalizeRoi(drag.draft, video.width, video.height) + if (roiArea(rect) >= MIN_ROI_AREA) { + onRoiDrawn(rect) + } + } + setDrag(null) + }, [drag, video, onRoiDrawn]) + + if (!video) { + return ( +
+
+
+
+
No video loaded
+
Open an experiment recording to begin.
+
+ +
+
+ ) + } + + return ( +
+ + {drawMode &&
Drag to draw a region · Esc to cancel
} + {previewThreshold !== undefined && ( +
+ Threshold preview · pixels above L* {previewThreshold.toFixed(1)} highlighted +
+ )} +
+ ) +} diff --git a/web/src/hooks.ts b/web/src/hooks.ts new file mode 100644 index 0000000..b2c7ad4 --- /dev/null +++ b/web/src/hooks.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import * as api from './api' +import type { JobStatus } from './types' + +/** Poll a job until it settles. Returns null until the first status arrives. */ +export function useJob(jobId: string | null): JobStatus | null { + const [job, setJob] = useState(null) + + useEffect(() => { + if (jobId === null) { + setJob(null) + return + } + let cancelled = false + let timer: number | undefined + + const poll = async () => { + try { + const status = await api.jobStatus(jobId) + if (cancelled) return + setJob(status) + if (status.status === 'queued' || status.status === 'running') { + timer = window.setTimeout(poll, 300) + } + } catch { + if (!cancelled) timer = window.setTimeout(poll, 600) + } + } + void poll() + return () => { + cancelled = true + if (timer !== undefined) window.clearTimeout(timer) + } + }, [jobId]) + + return job +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..22ffe8e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,15 @@ +import '@fontsource/ibm-plex-mono/400.css' +import '@fontsource/ibm-plex-mono/600.css' +import '@fontsource/ibm-plex-sans/400.css' +import '@fontsource/ibm-plex-sans/500.css' +import '@fontsource/ibm-plex-sans/600.css' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/roi.test.ts b/web/src/roi.test.ts new file mode 100644 index 0000000..ab53a11 --- /dev/null +++ b/web/src/roi.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { dragRoi, hitTestRoi, nextRoiName, normalizeRoi, roiColor } from './roi' + +describe('normalizeRoi', () => { + it('orders coordinates and clamps to the frame', () => { + expect(normalizeRoi({ x1: 50, y1: 40, x2: 10, y2: -5 }, 100, 100)).toEqual({ + x1: 10, + y1: 0, + x2: 50, + y2: 40, + }) + }) + + it('clamps beyond-frame coordinates', () => { + expect(normalizeRoi({ x1: -10, y1: 0, x2: 500, y2: 60 }, 100, 50)).toEqual({ + x1: 0, + y1: 0, + x2: 100, + y2: 50, + }) + }) +}) + +describe('hitTestRoi', () => { + const roi = { x1: 20, y1: 20, x2: 80, y2: 60 } + + it('prefers a corner handle over the interior', () => { + expect(hitTestRoi(roi, 21, 21, 1)).toBe('nw') + expect(hitTestRoi(roi, 79, 59, 1)).toBe('se') + }) + + it('returns move inside and null outside', () => { + expect(hitTestRoi(roi, 50, 40, 1)).toBe('move') + expect(hitTestRoi(roi, 5, 5, 1)).toBeNull() + }) + + it('scales the grab radius with zoom', () => { + // At scale 4 (zoomed out), a point 20 frame-px from the corner still grabs it. + expect(hitTestRoi(roi, 50, 40, 4)).not.toBeNull() + expect(hitTestRoi(roi, 44, 44, 4)).toBe('nw') + }) +}) + +describe('dragRoi', () => { + const roi = { x1: 20, y1: 20, x2: 80, y2: 60 } + + it('moves without resizing and clamps at frame edges', () => { + const moved = dragRoi(roi, 'move', -100, 5, 100, 100) + expect(moved).toEqual({ x1: 0, y1: 25, x2: 60, y2: 65 }) + }) + + it('resizes from a corner', () => { + const resized = dragRoi(roi, 'se', 10, -10, 100, 100) + expect(resized).toEqual({ x1: 20, y1: 20, x2: 90, y2: 50 }) + }) + + it('normalizes when a corner crosses its opposite edge', () => { + const crossed = dragRoi(roi, 'se', -70, 0, 100, 100) + expect(crossed.x1).toBeLessThanOrEqual(crossed.x2) + }) +}) + +describe('roiColor', () => { + it('is stable per id so colors follow the entity', () => { + expect(roiColor(3)).toBe(roiColor(3)) + expect(roiColor(1)).not.toBe(roiColor(2)) + }) + + it('assigns palette slot 1 to the first ROI drawn (ids are 1-based)', () => { + expect(roiColor(1)).toBe('#3987e5') + }) +}) + +describe('nextRoiName', () => { + it('fills the first free slot', () => { + const rois = [ + { id: 1, name: 'ROI 1', x1: 0, y1: 0, x2: 1, y2: 1 }, + { id: 3, name: 'ROI 3', x1: 0, y1: 0, x2: 1, y2: 1 }, + ] + expect(nextRoiName(rois)).toBe('ROI 2') + }) +}) diff --git a/web/src/roi.ts b/web/src/roi.ts new file mode 100644 index 0000000..e2f25bc --- /dev/null +++ b/web/src/roi.ts @@ -0,0 +1,100 @@ +import type { Roi } from './types' + +/** Series colors: validated categorical order (dark mode), assigned by ROI id, + * never re-cycled when ROIs are deleted — color follows the entity. */ +export const ROI_COLORS = [ + '#3987e5', + '#008300', + '#d55181', + '#c98500', + '#199e70', + '#d95926', + '#9085e9', + '#e66767', +] as const + +export const BACKGROUND_ROI_COLOR = '#ffb454' + +export function roiColor(roiId: number): string { + // Ids are 1-based; slot 1 of the palette goes to the first ROI ever drawn. + const index = Math.max(0, roiId - 1) % ROI_COLORS.length + return ROI_COLORS[index] +} + +export interface NormalizedRoi { + x1: number + y1: number + x2: number + y2: number +} + +/** Return the ROI with x1 a - b) + const [y1, y2] = [roi.y1, roi.y2].sort((a, b) => a - b) + return { + x1: Math.max(0, Math.min(Math.round(x1), frameW)), + y1: Math.max(0, Math.min(Math.round(y1), frameH)), + x2: Math.max(0, Math.min(Math.round(x2), frameW)), + y2: Math.max(0, Math.min(Math.round(y2), frameH)), + } +} + +export function roiArea(roi: NormalizedRoi): number { + return Math.abs(roi.x2 - roi.x1) * Math.abs(roi.y2 - roi.y1) +} + +export type Handle = 'nw' | 'ne' | 'sw' | 'se' | 'move' | null + +const HANDLE_HIT_RADIUS = 8 + +/** Hit-test a frame-space point against a normalized ROI. Corners win over the + * interior so small ROIs stay resizable. `scale` converts the on-screen grab + * radius into frame pixels (frame px per CSS px). */ +export function hitTestRoi(roi: NormalizedRoi, x: number, y: number, scale: number): Handle { + const radius = HANDLE_HIT_RADIUS * scale + const corners: Array<[Handle, number, number]> = [ + ['nw', roi.x1, roi.y1], + ['ne', roi.x2, roi.y1], + ['sw', roi.x1, roi.y2], + ['se', roi.x2, roi.y2], + ] + for (const [handle, cx, cy] of corners) { + if (Math.abs(x - cx) <= radius && Math.abs(y - cy) <= radius) return handle + } + if (x >= roi.x1 && x <= roi.x2 && y >= roi.y1 && y <= roi.y2) return 'move' + return null +} + +/** Apply a drag to a ROI given the active handle. */ +export function dragRoi( + roi: NormalizedRoi, + handle: Handle, + dx: number, + dy: number, + frameW: number, + frameH: number, +): NormalizedRoi { + if (handle === null) return roi + if (handle === 'move') { + const width = roi.x2 - roi.x1 + const height = roi.y2 - roi.y1 + const x1 = Math.max(0, Math.min(roi.x1 + dx, frameW - width)) + const y1 = Math.max(0, Math.min(roi.y1 + dy, frameH - height)) + return { x1: Math.round(x1), y1: Math.round(y1), x2: Math.round(x1 + width), y2: Math.round(y1 + height) } + } + const next = { ...roi } + if (handle === 'nw' || handle === 'sw') next.x1 += dx + if (handle === 'ne' || handle === 'se') next.x2 += dx + if (handle === 'nw' || handle === 'ne') next.y1 += dy + if (handle === 'sw' || handle === 'se') next.y2 += dy + return normalizeRoi(next, frameW, frameH) +} + +export function nextRoiName(rois: Roi[]): string { + const used = new Set(rois.map((roi) => roi.name)) + for (let n = 1; ; n += 1) { + const candidate = `ROI ${n}` + if (!used.has(candidate)) return candidate + } +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..bcfa559 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,669 @@ +/* Brightness Sorcerer — instrument console theme. + The UI is deliberately dark: users judge faint ECL glows by eye, so chrome + must never outshine the specimen. One accent (ECL emission blue) is spent + on the transport bar and live states; amber is reserved for the background + ROI and warnings. */ + +:root { + --ink-0: #0a0d10; /* video well */ + --ink-1: #10151a; /* app background */ + --ink-2: #161c23; /* panels */ + --ink-3: #1d242d; /* raised controls */ + --line: #29323c; + --text: #d9e1e8; + --dim: #8c98a5; + --faint: #5c6773; + --ecl: #53d8ff; + --ecl-deep: #1fa8d4; + --ecl-glow: rgba(83, 216, 255, 0.35); + --amber: #ffb454; + --danger: #ff6b6b; + --ok: #6ee7a0; + --font-ui: 'IBM Plex Sans', system-ui, sans-serif; + --font-data: 'IBM Plex Mono', ui-monospace, monospace; + --radius: 6px; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--ink-1); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--ink-0); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 5px 8px; +} + +input:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid var(--ecl); + outline-offset: 1px; +} + +input[type='number'] { + font-family: var(--font-data); + width: 72px; +} + +/* ---------- primitives ---------- */ + +.eyebrow { + font-family: var(--font-data); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--faint); +} + +.mono { + font-family: var(--font-data); + font-variant-numeric: tabular-nums; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--ink-3); + color: var(--text); + transition: border-color 120ms, background 120ms; +} + +.btn:hover:not(:disabled) { + border-color: var(--ecl-deep); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: var(--ecl-deep); + border-color: var(--ecl-deep); + color: #04121a; + font-weight: 600; +} + +.btn-primary:hover:not(:disabled) { + background: var(--ecl); + border-color: var(--ecl); +} + +.btn-danger { + border-color: var(--danger); + color: var(--danger); +} + +.btn-ghost { + border-color: transparent; + background: transparent; + color: var(--dim); +} + +.btn-ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--line); +} + +/* ---------- app shell ---------- */ + +.app { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--line); + background: var(--ink-2); +} + +.wordmark { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-data); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.wordmark .lumen { + color: var(--ecl); + text-shadow: 0 0 10px var(--ecl-glow); +} + +.topbar-video { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; + flex: 1; +} + +.topbar-video .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-video .meta { + color: var(--dim); + font-size: 12px; + white-space: nowrap; +} + +.workspace { + display: grid; + grid-template-columns: 1fr 300px; + min-height: 0; +} + +.stage { + display: grid; + grid-template-rows: 1fr auto auto; + min-width: 0; + min-height: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1px; + border-left: 1px solid var(--line); + background: var(--ink-2); + overflow-y: auto; +} + +.panel { + padding: 14px 16px; + border-bottom: 1px solid var(--line); +} + +.panel-title { + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; +} + +/* ---------- video well ---------- */ + +.well { + position: relative; + background: var(--ink-0); + min-height: 0; + display: grid; + place-items: center; + overflow: hidden; +} + +.well canvas { + max-width: 100%; + max-height: 100%; + display: block; +} + +.well-empty { + display: grid; + gap: 12px; + justify-items: center; + color: var(--dim); + text-align: center; + padding: 40px; +} + +.well-empty .glyph { + font-size: 40px; + color: var(--ecl); + text-shadow: 0 0 24px var(--ecl-glow); +} + +.well-hint { + position: absolute; + top: 10px; + left: 12px; + font-family: var(--font-data); + font-size: 11px; + color: var(--dim); + background: rgba(10, 13, 16, 0.75); + padding: 3px 8px; + border-radius: var(--radius); + pointer-events: none; +} + +/* ---------- transport (signature element) ---------- */ + +.transport { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 16px; + border-top: 1px solid var(--line); + background: var(--ink-2); +} + +.transport .counter { + font-family: var(--font-data); + font-variant-numeric: tabular-nums; + font-size: 13px; + color: var(--text); + white-space: nowrap; +} + +.transport .counter .total { + color: var(--faint); +} + +.scrubber { + position: relative; + flex: 1; + height: 26px; + cursor: pointer; + touch-action: none; +} + +.scrubber .track { + position: absolute; + inset: 10px 0; + border-radius: 3px; + background: var(--ink-0); + border: 1px solid var(--line); +} + +.scrubber .range { + position: absolute; + top: 10px; + bottom: 10px; + background: rgba(83, 216, 255, 0.16); + border-top: 1px solid var(--ecl-deep); + border-bottom: 1px solid var(--ecl-deep); +} + +.scrubber .playhead { + position: absolute; + top: 4px; + bottom: 4px; + width: 2px; + margin-left: -1px; + background: var(--ecl); + box-shadow: 0 0 8px var(--ecl-glow), 0 0 2px var(--ecl); + pointer-events: none; +} + +.range-buttons { + display: flex; + gap: 6px; +} + +/* ---------- ROI panel ---------- */ + +.roi-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + margin: 0 -8px; + border-radius: var(--radius); + cursor: pointer; +} + +.roi-row:hover { + background: var(--ink-3); +} + +.roi-row.selected { + background: var(--ink-3); + outline: 1px solid var(--line); +} + +.roi-chip { + width: 10px; + height: 10px; + border-radius: 2px; + flex: none; +} + +.roi-chip.background { + border-radius: 50%; +} + +.roi-name-input { + background: transparent; + border-color: transparent; + padding: 2px 4px; + min-width: 0; + flex: 1; +} + +.roi-name-input:hover, +.roi-name-input:focus { + border-color: var(--line); + background: var(--ink-0); +} + +.roi-coords { + font-size: 11px; + color: var(--faint); + white-space: nowrap; +} + +.roi-tag { + font-family: var(--font-data); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--amber); + border: 1px solid var(--amber); + border-radius: 3px; + padding: 1px 4px; +} + +/* ---------- settings ---------- */ + +.field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} + +.field label { + color: var(--dim); + font-size: 13px; +} + +.field-hint { + font-size: 11px; + color: var(--faint); + margin: -4px 0 10px; +} + +.switch-row { + display: flex; + align-items: center; + gap: 8px; + color: var(--dim); + font-size: 13px; + margin-bottom: 8px; +} + +/* ---------- analysis / progress ---------- */ + +.analyze-panel .btn-primary { + width: 100%; + justify-content: center; + padding: 9px; +} + +.progress { + margin-top: 10px; +} + +.progress-track { + height: 4px; + border-radius: 2px; + background: var(--ink-0); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--ecl); + box-shadow: 0 0 8px var(--ecl-glow); + transition: width 200ms; +} + +.progress-message { + margin-top: 6px; + font-family: var(--font-data); + font-size: 11px; + color: var(--dim); +} + +.error-text { + color: var(--danger); + font-size: 12px; + margin-top: 8px; + word-break: break-word; +} + +.ok-text { + color: var(--ok); + font-size: 12px; + margin-top: 8px; +} + +/* ---------- results drawer ---------- */ + +.results { + border-top: 1px solid var(--line); + background: var(--ink-2); + padding: 12px 16px 16px; + max-height: 44vh; + overflow-y: auto; +} + +.results-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.results-header .controls { + display: flex; + gap: 8px; + align-items: center; +} + +.chart-wrap { + background: var(--ink-1); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 8px; +} + +.chart-svg { + display: block; + width: 100%; +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 8px; + font-size: 12px; + color: var(--dim); +} + +.legend .item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tooltip { + position: absolute; + pointer-events: none; + background: var(--ink-3); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 6px 9px; + font-family: var(--font-data); + font-size: 11px; + white-space: nowrap; + z-index: 10; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} + +.tooltip .row { + display: flex; + align-items: center; + gap: 6px; +} + +/* ---------- export ---------- */ + +.export-grid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.export-paths { + margin: 8px 0 0; + padding: 0; + list-style: none; + font-family: var(--font-data); + font-size: 11px; + color: var(--dim); + word-break: break-all; +} + +.export-plot { + max-width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius); + margin-top: 8px; +} + +/* ---------- file picker modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(4, 6, 8, 0.7); + display: grid; + place-items: center; + z-index: 100; +} + +.modal { + width: min(560px, 92vw); + max-height: 80vh; + display: flex; + flex-direction: column; + background: var(--ink-2); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6); +} + +.modal-head { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--line); +} + +.modal-path { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.modal-path input { + flex: 1; + font-family: var(--font-data); + font-size: 12px; +} + +.modal-list { + overflow-y: auto; + padding: 8px; + min-height: 200px; +} + +.fs-entry { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + padding: 6px 10px; + border-radius: var(--radius); + color: var(--text); +} + +.fs-entry:hover { + background: var(--ink-3); +} + +.fs-entry .icon { + color: var(--faint); + width: 16px; + text-align: center; +} + +.fs-entry.video .icon { + color: var(--ecl); +} + +.modal-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid var(--line); +} + +/* ---------- reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +@media (max-width: 900px) { + .workspace { + grid-template-columns: 1fr; + } + + .sidebar { + border-left: none; + border-top: 1px solid var(--line); + } +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..7cf5111 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,106 @@ +export interface VideoMeta { + video_id: string + path: string + name: string + frame_count: number + fps: number + width: number + height: number + duration_seconds: number +} + +export interface Roi { + id: number + name: string + x1: number + y1: number + x2: number + y2: number +} + +export interface AnalysisSettings { + background_percentile: number + morphological_kernel_size: number + noise_floor_threshold: number + manual_threshold: number +} + +export const DEFAULT_SETTINGS: AnalysisSettings = { + background_percentile: 90, + morphological_kernel_size: 3, + noise_floor_threshold: 0, + manual_threshold: 0, +} + +export interface RoiSeries { + roi_index: number + brightness_mean: number[] + brightness_median: number[] + blue_mean: number[] + blue_median: number[] +} + +export interface AnalysisResultPayload { + start_frame: number + end_frame: number + frames_processed: number + total_frames: number + truncated: boolean + elapsed_seconds: number + background_values_per_frame: number[] + rois: RoiSeries[] +} + +export interface BrightestFrameResultPayload { + brightest_frame_idx: number + max_brightness: number +} + +export interface PerRoiMaskResultPayload { + sources: Array + max_brightness: Record + mask_coverage: Array +} + +export type JobKind = 'analysis' | 'mask_scan_global' | 'mask_scan_per_roi' + +export interface JobStatus { + job_id: string + kind: JobKind + video_id: string + status: 'queued' | 'running' | 'done' | 'error' | 'cancelled' + progress: { done: number; total: number } + message: string + error: string | null + result?: AnalysisResultPayload | BrightestFrameResultPayload | PerRoiMaskResultPayload +} + +export interface DetectedBeep { + beep_time: number + start_frame: number + end_frame: number +} + +export interface FsListing { + path: string + parent: string | null + dirs: string[] + videos: string[] +} + +export interface ExportResponse { + save_dir: string + out_paths: string[] + summary_lines: string[] + avg_brightness_summary: string[] + plot_failed: boolean +} + +export type MetricKey = 'brightness_mean' | 'brightness_median' | 'blue_mean' | 'blue_median' + +export const METRIC_LABELS: Record = { + brightness_mean: 'L* mean', + brightness_median: 'L* median', + blue_mean: 'Blue mean', + blue_median: 'Blue median', +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f952bbc --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..a1be962 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +/// +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8765', + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['src/test/setup.ts'], + globals: true, + }, +})