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 @@ + + +
+ + + +