From 34ecc33ee59a5e9d38d3d5a5668101edaca10605 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 16 Apr 2026 14:53:01 +0800 Subject: [PATCH] live sonar feed, and incremental action feed --- FishMeasure/dataset/zed_reader.py | 4 +- FishMeasure/fish_video_weight_evaluation.py | 36 +- .../fish_video_weight_evaluation__v1.py | 32 +- FishMeasure/generate_video_with_labels.py | 25 +- .../optical_flow/visualize_optical_flow.py | 612 +++++++++++++++++ FishMeasure/predict_weigth_from_svo2.py | 2 +- docs/conda-deploy.md | 18 +- fish_api/README.md | 61 +- fish_api/app/db.py | 110 ++-- fish_api/app/main.py | 3 + fish_api/app/routers/ingest.py | 14 +- fish_api/app/services/action.py | 131 ++-- fish_api/app/services/action_watch.py | 100 ++- fish_api/app/services/measure.py | 175 ++++- fish_api/app/services/sonar_optical_flow.py | 77 +++ fish_api/app/services/sonar_video.py | 383 ++++++++--- fish_api/app/services/water_video.py | 264 +------- fish_api/app/services/zed_svo_record.py | 4 +- fish_api/app/settings.py | 33 +- fish_api/app/state.py | 1 + fish_api/pyproject.toml | 4 +- fish_api/requirements.txt | 9 + fish_api/start_fresh.sh | 16 +- fish_api/uv.lock | 619 ------------------ record.sh | 24 + scripts/measure_debug.sh | 5 +- start_recording.sh | 11 +- stop_recording.sh | 9 +- 28 files changed, 1555 insertions(+), 1227 deletions(-) create mode 100644 FishMeasure/optical_flow/visualize_optical_flow.py create mode 100644 fish_api/app/services/sonar_optical_flow.py create mode 100644 fish_api/requirements.txt delete mode 100644 fish_api/uv.lock create mode 100755 record.sh diff --git a/FishMeasure/dataset/zed_reader.py b/FishMeasure/dataset/zed_reader.py index defd906..9d68513 100644 --- a/FishMeasure/dataset/zed_reader.py +++ b/FishMeasure/dataset/zed_reader.py @@ -44,8 +44,8 @@ class ZEDReader: else: # 实时相机模式设置 print("使用实时相机模式") - self.init_params.camera_resolution = sl.RESOLUTION.HD720 - self.init_params.depth_mode = sl.DEPTH_MODE.ULTRA + self.init_params.camera_resolution = sl.RESOLUTION.HD1200 + self.init_params.depth_mode = sl.DEPTH_MODE.NEURAL self.init_params.coordinate_units = sl.UNIT.MILLIMETER self.init_params.camera_fps = 30 # 设置帧率 diff --git a/FishMeasure/fish_video_weight_evaluation.py b/FishMeasure/fish_video_weight_evaluation.py index 811a0bb..4293834 100644 --- a/FishMeasure/fish_video_weight_evaluation.py +++ b/FishMeasure/fish_video_weight_evaluation.py @@ -10,6 +10,28 @@ import json import numpy as np import torch from pathlib import Path +from typing import Tuple + + +def _open_video_writer(path: Path, fps: float, size: Tuple[int, int]) -> cv2.VideoWriter: + """Open a VideoWriter, preferring GStreamer NVENC on Jetson for hardware H.264.""" + w, h = size + try: + if hasattr(cv2, "CAP_GSTREAMER"): + loc = str(path).replace('"', '\\"') + gst_pipe = ( + f'appsrc ! videoconvert ! video/x-raw,format=BGRx ! ' + f'nvvidconv ! video/x-raw(memory:NVMM) ! ' + f'nvv4l2h264enc bitrate=4000000 ! h264parse ! ' + f'mp4mux ! filesink location="{loc}"' + ) + writer = cv2.VideoWriter(gst_pipe, cv2.CAP_GSTREAMER, 0, fps, (w, h)) + if writer.isOpened(): + return writer + writer.release() + except Exception: + pass + return cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) if not hasattr(argparse, "BooleanOptionalAction"): class _BooleanOptionalAction(argparse.Action): @@ -1293,8 +1315,7 @@ def finalize_preview_video_with_weights( video_path = output_images_folder / f"{svo_name}_preview.mp4" if out_frames: h, w = out_frames[0].shape[:2] - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps_video, (w, h)) + video_writer = _open_video_writer(video_path, fps_video, (w, h)) for frame in out_frames: video_writer.write(frame) video_writer.release() @@ -1930,8 +1951,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de video_path = output_images_folder / f"{svo_name}_preview.mp4" h, w = video_frames[0].shape[:2] fps = 10.0 - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (w, h)) + video_writer = _open_video_writer(video_path, fps, (w, h)) for frame in video_frames: video_writer.write(frame) video_writer.release() @@ -3103,13 +3123,9 @@ def main(): print(f"\nCreating video from {len(video_frames)} frames...") if len(video_frames) > 0: - # Get frame dimensions h, w = video_frames[0].shape[:2] - fps = 10.0 # Frames per second - - # Create video writer - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (w, h)) + fps = 10.0 + video_writer = _open_video_writer(video_path, fps, (w, h)) for frame in video_frames: video_writer.write(frame) diff --git a/FishMeasure/fish_video_weight_evaluation__v1.py b/FishMeasure/fish_video_weight_evaluation__v1.py index 91130c1..5847f0e 100644 --- a/FishMeasure/fish_video_weight_evaluation__v1.py +++ b/FishMeasure/fish_video_weight_evaluation__v1.py @@ -11,6 +11,27 @@ import numpy as np import torch from pathlib import Path from typing import List, Dict, Any, Optional, Tuple + + +def _open_video_writer(path: Path, fps: float, size: Tuple[int, int]) -> cv2.VideoWriter: + """Open a VideoWriter, preferring GStreamer NVENC on Jetson for hardware H.264.""" + w, h = size + try: + if hasattr(cv2, "CAP_GSTREAMER"): + loc = str(path).replace('"', '\\"') + gst_pipe = ( + f'appsrc ! videoconvert ! video/x-raw,format=BGRx ! ' + f'nvvidconv ! video/x-raw(memory:NVMM) ! ' + f'nvv4l2h264enc bitrate=4000000 ! h264parse ! ' + f'mp4mux ! filesink location="{loc}"' + ) + writer = cv2.VideoWriter(gst_pipe, cv2.CAP_GSTREAMER, 0, fps, (w, h)) + if writer.isOpened(): + return writer + writer.release() + except Exception: + pass + return cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) from ultralytics import YOLO from seg import init_models from pointcloud_filter import filter_point_cloud @@ -1185,8 +1206,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de video_path = output_images_folder / f"{svo_name}_preview.mp4" h, w = video_frames[0].shape[:2] fps = 10.0 - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (w, h)) + video_writer = _open_video_writer(video_path, fps, (w, h)) for frame in video_frames: video_writer.write(frame) video_writer.release() @@ -2297,13 +2317,9 @@ def main(): print(f"\nCreating video from {len(video_frames)} frames...") if len(video_frames) > 0: - # Get frame dimensions h, w = video_frames[0].shape[:2] - fps = 10.0 # Frames per second - - # Create video writer - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (w, h)) + fps = 10.0 + video_writer = _open_video_writer(video_path, fps, (w, h)) for frame in video_frames: video_writer.write(frame) diff --git a/FishMeasure/generate_video_with_labels.py b/FishMeasure/generate_video_with_labels.py index 4b326fa..998f23c 100644 --- a/FishMeasure/generate_video_with_labels.py +++ b/FishMeasure/generate_video_with_labels.py @@ -30,6 +30,27 @@ except ImportError: ZED_AVAILABLE = False +def _open_video_writer(path: Path, fps: float, size: Tuple[int, int]) -> cv2.VideoWriter: + """Open a VideoWriter, preferring GStreamer NVENC on Jetson for hardware H.264.""" + w, h = size + try: + if hasattr(cv2, "CAP_GSTREAMER"): + loc = str(path).replace('"', '\\"') + gst_pipe = ( + f'appsrc ! videoconvert ! video/x-raw,format=BGRx ! ' + f'nvvidconv ! video/x-raw(memory:NVMM) ! ' + f'nvv4l2h264enc bitrate=4000000 ! h264parse ! ' + f'mp4mux ! filesink location="{loc}"' + ) + writer = cv2.VideoWriter(gst_pipe, cv2.CAP_GSTREAMER, 0, fps, (w, h)) + if writer.isOpened(): + return writer + writer.release() + except Exception: + pass + return cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) + + def _parse_weight_json(weight_json: Path) -> Tuple[ Dict[int, Tuple[float, float]], Optional[float], @@ -310,7 +331,7 @@ def generate_video( video_path = images_dir / (output_video_name or f"{svo_name}_preview.mp4") h, w = frames[0].shape[:2] - writer = cv2.VideoWriter(str(video_path), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (w, h)) + writer = _open_video_writer(video_path, 10.0, (w, h)) for f in frames: writer.write(f) writer.release() @@ -332,7 +353,7 @@ def main(): parser.add_argument("--show-large-labels-at-top-right", action="store_true") parser.add_argument( "--summary-star", - action=argparse.BooleanOptionalAction, + action="store_true", default=False, help="Whether to draw * on the Final summary line; caller/DB is the source of truth.", ) diff --git a/FishMeasure/optical_flow/visualize_optical_flow.py b/FishMeasure/optical_flow/visualize_optical_flow.py new file mode 100644 index 0000000..557b173 --- /dev/null +++ b/FishMeasure/optical_flow/visualize_optical_flow.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +""" +从视频读取连续帧,使用 OpenCV Farneback 稠密光流估计运动, +输出带光流伪彩色(方向=色调、速度=亮度)的可视化视频。 + +声呐类画面背景噪声也会在光流里产生响应;可用 --fish-mask 仅在高亮「鱼团」 +区域显示光流,并用连通域面积过滤掉细碎亮点。 +""" + +from __future__ import annotations +import argparse +import logging +import time +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import cv2 +import numpy as np + +_log = logging.getLogger("optical_flow") + + +def flow_to_bgr( + flow: np.ndarray, + mag_clip_percentile: float = 95.0, + valid_mask: np.ndarray | None = None, +) -> np.ndarray: + """将 (H,W,2) 光流转为 BGR 伪彩色图。若给定 valid_mask,幅值分位数仅在掩膜内统计。""" + fx = flow[..., 0].astype(np.float32) + fy = flow[..., 1].astype(np.float32) + mag = np.sqrt(fx * fx + fy * fy) + ang = np.arctan2(fy, fx) + + if valid_mask is not None and valid_mask.size > 0: + vm = valid_mask > 0 + if np.any(vm): + clip = float(np.percentile(mag[vm], mag_clip_percentile)) + else: + clip = float(np.percentile(mag, mag_clip_percentile)) + else: + clip = float(np.percentile(mag, mag_clip_percentile)) + if clip < 1e-6: + clip = 1e-6 + mag_norm = np.clip(mag / clip, 0.0, 1.0) + + h, w = flow.shape[:2] + hsv = np.zeros((h, w, 3), dtype=np.float32) + hsv[..., 0] = (ang + np.pi) / (2.0 * np.pi) * 179.0 + hsv[..., 1] = 255.0 + hsv[..., 2] = mag_norm * 255.0 + hsv_u8 = hsv.astype(np.uint8) + return cv2.cvtColor(hsv_u8, cv2.COLOR_HSV2BGR) + + +def _odd_k(k: int) -> int: + k = max(1, int(k)) + return k if k % 2 == 1 else k + 1 + + +def build_fish_mask( + gray_u8: np.ndarray, + *, + bright_percentile: float, + min_blob_area: int, + open_k: int, + close_k: int, + dilate_k: int, + blur_sigma: float, + keep_largest_blobs: int = 0, +) -> np.ndarray: + """ + 声呐/暗背景高亮目标:取灰度高分位作为阈值,形态学去噪,保留足够大的连通域。 + """ + g = gray_u8 + if blur_sigma > 1e-6: + k = _odd_k(int(round(blur_sigma * 6)) | 1) + k = max(3, min(k, 31)) + g = cv2.GaussianBlur(g, (k, k), blur_sigma) + + thr = float(np.percentile(g.astype(np.float32), bright_percentile)) + binary = (g.astype(np.float32) >= thr).astype(np.uint8) * 255 + + if open_k > 0: + ok = _odd_k(open_k) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ok, ok)) + binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) + if close_k > 0: + ck = _odd_k(close_k) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ck, ck)) + binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) + + num, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) + out = np.zeros_like(binary) + for i in range(1, num): + if stats[i, cv2.CC_STAT_AREA] >= min_blob_area: + out[labels == i] = 255 + + if dilate_k > 0: + dk = _odd_k(dilate_k) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dk, dk)) + out = cv2.dilate(out, kernel) + + if keep_largest_blobs > 0: + num, labels, stats, _ = cv2.connectedComponentsWithStats(out, connectivity=8) + ranked: list[tuple[int, int]] = [] + for i in range(1, num): + ranked.append((int(stats[i, cv2.CC_STAT_AREA]), i)) + ranked.sort(reverse=True) + trimmed = np.zeros_like(out) + for _, comp_idx in ranked[:keep_largest_blobs]: + trimmed[labels == comp_idx] = 255 + out = trimmed + return out + + +def apply_flow_mask(flow_bgr: np.ndarray, mask_u8: np.ndarray) -> np.ndarray: + """掩膜外置黑;三通道与 mask 相乘。""" + m = (mask_u8.astype(np.float32) / 255.0)[..., np.newaxis] + return np.clip(flow_bgr.astype(np.float32) * m, 0, 255).astype(np.uint8) + + +def draw_flow_arrows( + flow: np.ndarray, + canvas_bgr: np.ndarray, + *, + mask_u8: np.ndarray | None, + step: int, + scale: float, + min_magnitude: float, + color: tuple[int, int, int], + thickness: int, +) -> np.ndarray: + """ + 将稠密光流按网格采样绘制为箭头,便于精确观察局部方向与速度。 + """ + out = canvas_bgr.copy() + h, w = flow.shape[:2] + s = max(2, int(step)) + t = max(1, int(thickness)) + mag2_thr = float(min_magnitude) * float(min_magnitude) + + for y in range(s // 2, h, s): + for x in range(s // 2, w, s): + if mask_u8 is not None and mask_u8[y, x] == 0: + continue + fx = float(flow[y, x, 0]) + fy = float(flow[y, x, 1]) + if fx * fx + fy * fy < mag2_thr: + continue + x2 = int(round(x + fx * scale)) + y2 = int(round(y + fy * scale)) + x2 = max(0, min(w - 1, x2)) + y2 = max(0, min(h - 1, y2)) + cv2.arrowedLine( + out, + (x, y), + (x2, y2), + color=color, + thickness=t, + tipLength=0.3, + ) + return out + + +def build_output_frame( + frame_bgr: np.ndarray, + flow_bgr: np.ndarray, + mode: str, +) -> np.ndarray: + if mode == "sidebyside": + return np.hstack([frame_bgr, flow_bgr]) + if mode == "overlay": + return cv2.addWeighted(frame_bgr, 0.55, flow_bgr, 0.45, 0.0) + raise ValueError(f"未知 mode: {mode}") + + +def _try_gst_nvenc_writer( + path: Path, + fps: float, + size: tuple[int, int], +) -> cv2.VideoWriter | None: + """Try to create a cv2.VideoWriter backed by GStreamer + Jetson NVENC. + + Returns None if GStreamer or the hardware encoder is unavailable. + """ + try: + if not hasattr(cv2, "CAP_GSTREAMER"): + return None + w, h = size + loc = str(path).replace('"', '\\"') + gst_pipe = ( + f'appsrc ! videoconvert ! video/x-raw,format=BGRx ! ' + f'nvvidconv ! video/x-raw(memory:NVMM) ! ' + f'nvv4l2h264enc bitrate=4000000 ! h264parse ! ' + f'mp4mux ! filesink location="{loc}"' + ) + writer = cv2.VideoWriter(gst_pipe, cv2.CAP_GSTREAMER, 0, fps, (w, h)) + if writer.isOpened(): + _log.info("[optical-flow] using GStreamer NVENC writer for %s", path.name) + return writer + writer.release() + except Exception: + pass + return None + + +def open_writer( + path: Path, + fps: float, + size: tuple[int, int], + fourcc_str: str, +) -> cv2.VideoWriter: + gst = _try_gst_nvenc_writer(path, fps, size) + if gst is not None: + return gst + fourcc = cv2.VideoWriter_fourcc(*fourcc_str) + writer = cv2.VideoWriter(str(path), fourcc, fps, size) + if not writer.isOpened(): + raise RuntimeError( + f"无法创建输出视频: {path}(codec={fourcc_str})。" + "可尝试 --fourcc avc1 或 XVID 并配合 .avi 扩展名。" + ) + return writer + + +def _validate_flow_args(args: Any) -> None: + if not isinstance(args.resize, (int, float)) or float(args.resize) <= 0: + raise ValueError("--resize 必须为正数") + if not 0 < float(args.bright_percentile) < 100: + raise ValueError("--bright-percentile 应在 (0, 100) 内") + + +def _run_flow_core(args: Any) -> None: + """稠密光流 + 可视化;``args`` 需含与 CLI 相同的字段(含 ``input`` / ``output`` 路径)。""" + in_path = Path(args.input) + out_path = Path(args.output) + if not in_path.is_file(): + raise FileNotFoundError(f"找不到输入视频: {in_path}") + + src_size_mb = in_path.stat().st_size / (1024 * 1024) + _log.info("[optical-flow] start: %s (%.1f MB), mode=%s, resize=%.2f", + in_path.name, src_size_mb, args.mode, args.resize) + t0 = time.monotonic() + + cap = cv2.VideoCapture(str(in_path)) + if not cap.isOpened(): + raise RuntimeError(f"无法打开视频: {in_path}") + + writer: cv2.VideoWriter | None = None + try: + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + w_in = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h_in = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + _log.info("[optical-flow] src %dx%d @ %.1f fps", w_in, h_in, fps) + scale = float(args.resize) + + w = max(1, int(round(w_in * scale))) + h = max(1, int(round(h_in * scale))) + + if args.mode == "sidebyside": + out_w, out_h = w * 2, h + else: + out_w, out_h = w, h + + writer = open_writer(out_path, fps, (out_w, out_h), args.fourcc) + + ret, prev_bgr = cap.read() + if not ret: + raise RuntimeError("视频为空或无法读取首帧") + + if scale != 1.0: + prev_bgr = cv2.resize(prev_bgr, (w, h), interpolation=cv2.INTER_AREA) + prev_gray = cv2.cvtColor(prev_bgr, cv2.COLOR_BGR2GRAY) + + black_flow = np.zeros((h, w, 3), dtype=np.uint8) + first_out = build_output_frame(prev_bgr, black_flow, args.mode) + writer.write(first_out) + written = 1 + frame_idx = 0 + + fb_flags = 0 + progress_log = bool(getattr(args, "progress_log", False)) + while True: + ret, frame_bgr = cap.read() + if not ret: + break + if scale != 1.0: + frame_bgr = cv2.resize(frame_bgr, (w, h), interpolation=cv2.INTER_AREA) + gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) + + flow = cv2.calcOpticalFlowFarneback( + prev_gray, + gray, + None, + args.pyr_scale, + args.levels, + args.winsize, + args.iterations, + args.poly_n, + args.poly_sigma, + fb_flags, + ) + mask: np.ndarray | None = None + if args.fish_mask: + mask = build_fish_mask( + gray, + bright_percentile=args.bright_percentile, + min_blob_area=args.min_blob_area, + open_k=args.mask_open, + close_k=args.mask_close, + dilate_k=args.mask_dilate, + blur_sigma=args.mask_blur, + keep_largest_blobs=args.keep_largest_blobs, + ) + has_mask = np.count_nonzero(mask) > 0 + else: + has_mask = False + + if args.viz_style in ("hsv", "hsv_arrows"): + if args.fish_mask and has_mask and mask is not None: + hsv_bgr = flow_to_bgr(flow, valid_mask=mask) + hsv_bgr = apply_flow_mask(hsv_bgr, mask) + elif args.fish_mask: + hsv_bgr = np.zeros((h, w, 3), dtype=np.uint8) + else: + hsv_bgr = flow_to_bgr(flow) + else: + hsv_bgr = np.zeros((h, w, 3), dtype=np.uint8) + + if args.viz_style in ("arrows", "hsv_arrows"): + arrow_base = np.zeros((h, w, 3), dtype=np.uint8) + arrow_bgr = draw_flow_arrows( + flow, + arrow_base, + mask_u8=mask if (args.fish_mask and has_mask) else None, + step=args.arrow_step, + scale=args.arrow_scale, + min_magnitude=args.arrow_threshold, + color=(0, 255, 255), + thickness=args.arrow_thickness, + ) + else: + arrow_bgr = np.zeros((h, w, 3), dtype=np.uint8) + + flow_bgr = cv2.addWeighted(hsv_bgr, 0.8, arrow_bgr, 1.0, 0.0) + out_frame = build_output_frame(frame_bgr, flow_bgr, args.mode) + writer.write(out_frame) + written += 1 + + prev_gray = gray + frame_idx += 1 + if progress_log and frame_idx % 30 == 0: + print(f"已处理 {frame_idx} 帧光流…", flush=True) + + elapsed = time.monotonic() - t0 + _log.info("[optical-flow] done: %d frames in %.1fs (%.1f fps) -> %s", + written, elapsed, written / max(elapsed, 0.001), out_path.name) + if progress_log: + print(f"完成,共写入 {written} 帧({elapsed:.1f}s),保存至: {out_path}", flush=True) + finally: + cap.release() + if writer is not None: + writer.release() + + +def run_optical_flow_video( + input_path: Path, + output_path: Path, + *, + mode: str = "overlay", + viz_style: str = "hsv", + resize: float = 1.0, + fourcc: str = "mp4v", + pyr_scale: float = 0.5, + levels: int = 3, + winsize: int = 15, + iterations: int = 3, + poly_n: int = 5, + poly_sigma: float = 1.2, + fish_mask: bool = True, + bright_percentile: float = 97.5, + min_blob_area: int = 500, + mask_open: int = 3, + mask_close: int = 11, + mask_dilate: int = 5, + mask_blur: float = 1.0, + keep_largest_blobs: int = 0, + arrow_step: int = 12, + arrow_scale: float = 2.0, + arrow_threshold: float = 0.8, + arrow_thickness: int = 1, + progress_log: bool = False, +) -> bool: + """供 fish_api 等调用:成功写出 ``output_path`` 返回 True,否则 False。""" + args = SimpleNamespace( + input=input_path, + output=output_path, + mode=mode, + viz_style=viz_style, + resize=resize, + fourcc=fourcc, + pyr_scale=pyr_scale, + levels=levels, + winsize=winsize, + iterations=iterations, + poly_n=poly_n, + poly_sigma=poly_sigma, + fish_mask=fish_mask, + bright_percentile=bright_percentile, + min_blob_area=min_blob_area, + mask_open=mask_open, + mask_close=mask_close, + mask_dilate=mask_dilate, + mask_blur=mask_blur, + keep_largest_blobs=keep_largest_blobs, + arrow_step=arrow_step, + arrow_scale=arrow_scale, + arrow_threshold=arrow_threshold, + arrow_thickness=arrow_thickness, + progress_log=progress_log, + ) + try: + _validate_flow_args(args) + _run_flow_core(args) + except Exception: + _log.exception("[optical-flow] run_optical_flow_video failed: %s -> %s", + input_path, output_path) + return False + return output_path.is_file() and output_path.stat().st_size > 0 + + +def main() -> None: + script_dir = Path(__file__).resolve().parent + default_in = script_dir / "fish_echo.MP4" + default_out = script_dir / "fish_echo_flow_vis.mp4" + + p = argparse.ArgumentParser(description="视频稠密光流可视化(OpenCV Farneback)") + p.add_argument( + "--input", + "-i", + type=Path, + default=default_in, + help=f"输入视频路径(默认: {default_in.name})", + ) + p.add_argument( + "--output", + "-o", + type=Path, + default=default_out, + help=f"输出视频路径(默认: {default_out.name})", + ) + p.add_argument( + "--mode", + choices=("sidebyside", "overlay"), + default="sidebyside", + help="sidebyside: 原图|光流;overlay: 原图与光流半透明叠加", + ) + p.add_argument( + "--viz-style", + choices=("hsv", "arrows", "hsv_arrows"), + default="hsv", + help="光流可视化风格:hsv 伪彩色、arrows 箭头、hsv_arrows 组合", + ) + p.add_argument( + "--resize", + type=float, + default=1.0, + metavar="SCALE", + help="处理前将帧宽高乘以该比例以加速(例如 0.5)", + ) + p.add_argument( + "--fourcc", + default="mp4v", + help="VideoWriter 四字符编码,常见: mp4v, avc1, XVID", + ) + p.add_argument( + "--pyr-scale", + type=float, + default=0.5, + help="Farneback 金字塔缩放(OpenCV pyr_scale)", + ) + p.add_argument( + "--levels", + type=int, + default=3, + help="Farneback 金字塔层数", + ) + p.add_argument( + "--winsize", + type=int, + default=15, + help="Farneback 窗口大小", + ) + p.add_argument( + "--iterations", + type=int, + default=3, + help="Farneback 每层迭代次数", + ) + p.add_argument( + "--poly-n", + type=int, + default=5, + help="Farneback 像素邻域大小(poly_n)", + ) + p.add_argument( + "--poly-sigma", + type=float, + default=1.2, + help="Farneback 高斯标准差(poly_sigma)", + ) + p.add_argument( + "--fish-mask", + dest="fish_mask", + action="store_true", + default=True, + help="仅在高亮鱼团区域显示光流(默认开启,抑制背景噪声)", + ) + p.add_argument( + "--no-fish-mask", + dest="fish_mask", + action="store_false", + help="关闭鱼团掩膜,整幅画面显示光流(与旧行为一致)", + ) + p.add_argument( + "--bright-percentile", + type=float, + default=97.5, + metavar="P", + help="灰度阈值:取 >= 该分位数的像素作为「亮斑」候选(越高越只保留最亮区域)", + ) + p.add_argument( + "--min-blob-area", + type=int, + default=500, + metavar="PX", + help="连通域最小面积(像素),小于此面积的亮斑视为噪声并丢弃", + ) + p.add_argument( + "--mask-open", + type=int, + default=3, + help="形态学开运算核大小(奇数,0 表示跳过),去小噪点", + ) + p.add_argument( + "--mask-close", + type=int, + default=11, + help="形态学闭运算核大小(奇数,0 表示跳过),连接同一鱼团碎片", + ) + p.add_argument( + "--mask-dilate", + type=int, + default=5, + help="掩膜膨胀核大小(奇数,0 表示不膨胀),略扩大显示区域包住边缘运动", + ) + p.add_argument( + "--mask-blur", + type=float, + default=1.0, + help="阈值前高斯模糊 sigma(0 表示不模糊),可平滑细碎纹理", + ) + p.add_argument( + "--keep-largest-blobs", + type=int, + default=0, + metavar="N", + help="在面积过滤后仅保留面积最大的 N 个连通域(0 表示不限制,适合多鱼场景)", + ) + p.add_argument( + "--arrow-step", + type=int, + default=12, + help="箭头网格步长(像素),越小越密", + ) + p.add_argument( + "--arrow-scale", + type=float, + default=2.0, + help="箭头长度缩放系数", + ) + p.add_argument( + "--arrow-threshold", + type=float, + default=0.8, + help="绘制箭头的最小速度阈值(像素/帧)", + ) + p.add_argument( + "--arrow-thickness", + type=int, + default=1, + help="箭头线宽", + ) + args = p.parse_args() + if not args.input.is_file(): + raise SystemExit(f"找不到输入视频: {args.input}") + try: + _validate_flow_args(args) + except ValueError as e: + raise SystemExit(str(e)) from e + args.progress_log = True + try: + _run_flow_core(args) + except FileNotFoundError as e: + raise SystemExit(str(e)) from e + except RuntimeError as e: + raise SystemExit(str(e)) from e + + +if __name__ == "__main__": + main() diff --git a/FishMeasure/predict_weigth_from_svo2.py b/FishMeasure/predict_weigth_from_svo2.py index 48a178b..e66e6cb 100644 --- a/FishMeasure/predict_weigth_from_svo2.py +++ b/FishMeasure/predict_weigth_from_svo2.py @@ -645,7 +645,7 @@ def main() -> None: ) parser.add_argument( "--summary-star", - action=argparse.BooleanOptionalAction, + action="store_true", default=False, help="Pass to generate_video_with_labels: whether the summary line should draw *.", ) diff --git a/docs/conda-deploy.md b/docs/conda-deploy.md index 26970bf..580aadc 100644 --- a/docs/conda-deploy.md +++ b/docs/conda-deploy.md @@ -32,6 +32,7 @@ conda activate fishserver ```bash cd /path/to/FishServer/fish_api pip install -e . +# 或:pip install -r requirements.txt ``` 可选开发依赖(本地冒烟测试等): @@ -71,20 +72,11 @@ python -c "import torch; print(torch.__version__)" **方案 A 下不要填写** `PYTHON_FISH_MEASURE`、`PYTHON_FISH_ACTION`(留空),让子进程使用当前环境的 `python`。 ---- - -## 5. 与 `uv` 的优先级 - -`fish_api/start_fresh.sh` 若检测到系统中有 `uv` 命令,会优先使用 `uv run`。若你希望**强制使用当前 conda 环境**: - -- 启动前执行 **`conda activate fishserver`**,并确认 `which python` / `which uvicorn` 指向 `fishserver`;或 -- 在不含 `uv` 的 PATH 下启动;或临时从 PATH 中移除 `uv`。 - -若未安装 `uv`,脚本会自动使用 `python3` 与 `uvicorn`(依赖已激活的 conda)。 +启动前请 **`conda activate fishserver`**,使 `which python` / `which uvicorn` 指向该环境。`fish_api/start_fresh.sh` 使用环境变量 **`PYTHON`**(可选)覆盖解释器,默认 **`python3`**。 --- -## 6. 启动服务 +## 5. 启动服务 在**仓库根目录**执行: @@ -109,7 +101,7 @@ HOST=0.0.0.0 PORT=8000 bash scripts/start_fresh.sh --- -## 7. 自检清单 +## 6. 自检清单 | 检查项 | 说明 | |--------|------| @@ -122,7 +114,7 @@ HOST=0.0.0.0 PORT=8000 bash scripts/start_fresh.sh --- -## 8. 参考 +## 7. 参考 - 根目录 `README.md`、`fish_api/README.md`:环境变量表与启动说明。 - `fish_api/start_fresh.sh`:启动前 `prestart_fresh` 与 uvicorn 参数。 diff --git a/fish_api/README.md b/fish_api/README.md index 50324a3..2b19b0f 100644 --- a/fish_api/README.md +++ b/fish_api/README.md @@ -2,52 +2,54 @@ CLEAR_SQLITE_DATABASE=1 CLEAR_MEASURE_OUTPUT=1 CLEAR_ACTION_OUTPUT=1 CLEAR_MEDIA=1 CLEAR_STREAM_TMP=1 LD_PRELOAD=/lib/aarch64-linux-gnu/libGLdispatch.so.0 bash scripts/start_fresh.sh - - ## 配置(环境变量) -| 变量 | 说明 | 默认 | -|------|------|------| -| `PUBLIC_BASE_URL` | 返回 JSON 中 `video_left` / `video_right` 的前缀(勿带末尾 `/`) | `http://127.0.0.1:8000` | -| `INGEST_API_KEY` | 非空时,`/api/v1/ingest/*` 需请求头 `X-API-Key` | 空(不校验) | -| `STREAM_TMP_DIR` | 分块上传临时目录 | `/fish_api/.data/ingest` | -| `MEDIA_ROOT` | 对外托管每次测量生成的 `*_left.mp4` / `*_right.mp4` | `/fish_api/.data/media` | -| `FISH_MEASURE_ROOT` | `FishMeasure` 根目录 | 自动相对仓库 | -| `FISH_ACTION_ROOT` | `FishAction` 根目录 | 自动相对仓库 | -| `MEASURE_OUTPUT_ROOT` | 传给 `--save-output` 的目录 | `/fish_api/.data/measure_output` | -| `YOLO_MODEL` / `WEIGHT_CHECKPOINT` / `ACTION_CHECKPOINT` | 模型路径 | 与仓库内脚本默认一致 | -| `SAM_DEVICE` | `cuda` 或 `cpu` | `cuda` | -| `MEASURE_FINAL_AGGREGATE_MODE` | 齐套后对各段 former 体重/体长聚合:`median` / `mean` / `trimmed_mean` | `median` | -可在 `fish_api/.env` 中填写上述变量(`pydantic-settings` 会读取)。 + +| 变量 | 说明 | 默认 | +| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | +| `PUBLIC_BASE_URL` | 返回 JSON 中 `video_left` / `video_right` 的前缀(勿带末尾 `/`) | `http://127.0.0.1:8000` | +| `INGEST_API_KEY` | 非空时,`/api/v1/ingest/*` 需请求头 `X-API-Key` | 空(不校验) | +| `STREAM_TMP_DIR` | 分块上传临时目录 | `/fish_api/.data/ingest` | +| `MEDIA_ROOT` | 对外托管每次测量生成的 `*_left.mp4` / `*_right.mp4` | `/fish_api/.data/media` | +| `FISH_MEASURE_ROOT` | `FishMeasure` 根目录 | 自动相对仓库 | +| `FISH_ACTION_ROOT` | `FishAction` 根目录 | 自动相对仓库 | +| `MEASURE_OUTPUT_ROOT` | 传给 `--save-output` 的目录 | `/fish_api/.data/measure_output` | +| `YOLO_MODEL` / `WEIGHT_CHECKPOINT` / `ACTION_CHECKPOINT` | 模型路径 | 与仓库内脚本默认一致 | +| `SAM_DEVICE` | `cuda` 或 `cpu` | `cuda` | +| `MEASURE_FINAL_AGGREGATE_MODE` | 齐套后对各段 former 体重/体长聚合:`median` / `mean` / `trimmed_mean` | `median` | +| 可在 `fish_api/.env` 中填写上述变量(`pydantic-settings` 会读取)。 | | | + ## 安装与启动 ```bash cd fish_api -python3 -m pip install -e . # 安装 pyproject 依赖(无 venv 时可用 --user 或系统 site-packages) -./scripts/start_fresh.sh # 默认仅重置 client_id 投递进度,保留 SQLite 历史与快照 +python3 -m pip install -e . # 或:python3 -m pip install -r requirements.txt +bash start_fresh.sh # 默认仅重置 client_id 投递进度,保留 SQLite 历史与快照 # CLEAR_SQLITE_DATABASE=1 bash start_fresh.sh # 需要时才彻底清 SQLite # python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 # 需自行 prestart ``` -## SVO 输入与 ``measure_watch``(两种来源,同一套目录逻辑) +## SVO 输入与 `measure_watch`(两种来源,同一套目录逻辑) -后台 **[``measure_watch``](app/services/measure_watch.py)** 只认 **`MEASURE_WATCH_DIR`** 下一层的 **`fish{N}/`** 子目录及其中的 **``.svo2``**(见 ``iter_svo2_folders``)。与入口无关: +后台 `**[measure_watch](app/services/measure_watch.py)**` 只认 `**MEASURE_WATCH_DIR**` 下一层的 `**fish{N}/**` 子目录及其中的 `**.svo2**`(见 `iter_svo2_folders`)。与入口无关: -| 方式 | 说明 | -|------|------| -| **ZED 分段录制** | 每次会话分配 ``fish_id``:取库表(``fish_id`` 与 ``output_dir`` 路径)、父目录下 ``fish``+数字 子目录名、以及 ``fish{N}/`` 下已有 ``.svo2`` 路径 四者编号的最大值再加 1(磁盘有数据而库未记时也不冲突);文件写入 ``{MEASURE_WATCH_DIR}/fish{N}/``(若未配置 ``MEASURE_WATCH_DIR`` 则 ``{STREAM_TMP_DIR}/zed_svo2/fish{N}/``,此时**不会**启用 ``measure_watch``,除非把 ``MEASURE_WATCH_DIR`` 指到 ``…/ingest/zed_svo2``) | -| **手工拷贝** | 将 ``.svo2`` 放入 ``MEASURE_WATCH_DIR/fish{N}/``(自建 ``fish{N}`` 即可) | -**逐段测量与齐套 final**:对每个 ``.svo2`` 稳定后轮询跑 FishMeasure,写入 SQLite;服务端可在 ``calculation_log`` 中区分 segment/final。``GET /api/v1/biomass/real/camera/`` 的 ``data.result[]`` **仅含** ``id``、``type``、``length``、``weight``、``date``(与历史客户端约定一致);按投递顺序先可能收到多段再收到聚合行。``video_left`` / ``video_right`` 规则不变。 +| 方式 | 说明 | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ZED 分段录制** | 每次会话分配 `fish_id`:取库表(`fish_id` 与 `output_dir` 路径)、父目录下 `fish`+数字 子目录名、以及 `fish{N}/` 下已有 `.svo2` 路径 四者编号的最大值再加 1(磁盘有数据而库未记时也不冲突);文件写入 `{MEASURE_WATCH_DIR}/fish{N}/`(若未配置 `MEASURE_WATCH_DIR` 则 `{STREAM_TMP_DIR}/zed_svo2/fish{N}/`,此时**不会**启用 `measure_watch`,除非把 `MEASURE_WATCH_DIR` 指到 `…/ingest/zed_svo2`) | +| **手工拷贝** | 将 `.svo2` 放入 `MEASURE_WATCH_DIR/fish{N}/`(自建 `fish{N}` 即可) | -**ingest** 的 ``/api/v1/ingest/svo/...`` 为分块上传流程,落盘与上述 ``fish{N}`` 路径独立;``fish{N}`` 目录的测量以 ``measure_watch`` 为准。 -**ZED 相机**:**fish_api 启动/停止不会自动开关相机录制**;录制由独立服务或仓库根 ``start_recording.sh`` / ``zed_record_cli`` 等自行管理,写入 ``MEASURE_WATCH_DIR/fish{N}/`` 后由 ``measure_watch`` 消费。需要时也可调 ``/api/v1/zed/recording/start|stop``(不经过 uvicorn lifespan)。 +**逐段测量与齐套 final**:对每个 `.svo2` 稳定后轮询跑 FishMeasure,写入 SQLite;服务端可在 `calculation_log` 中区分 segment/final。`GET /api/v1/biomass/real/camera/` 的 `data.result[]` **仅含** `id`、`type`、`length`、`weight`、`date`(与历史客户端约定一致);按投递顺序先可能收到多段再收到聚合行。`video_left` / `video_right` 规则不变。 + +**ingest** 的 `/api/v1/ingest/svo/...` 为分块上传流程,落盘与上述 `fish{N}` 路径独立;`fish{N}` 目录的测量以 `measure_watch` 为准。 + +**ZED 相机**:**fish_api 启动/停止不会自动开关相机录制**;录制由独立服务或仓库根 `start_recording.sh` / `zed_record_cli` 等自行管理,写入 `MEASURE_WATCH_DIR/fish{N}/` 后由 `measure_watch` 消费。需要时也可调 `/api/v1/zed/recording/start|stop`(不经过 uvicorn lifespan)。 ## ZED 分段录制 CLI -每次 **开始/停止** 录制会在 SQLite 表 ``zed_recording_sessions`` 中记录一行(``fish_id``、``started_at`` / ``stopped_at``、``output_dir``;分配规则见上表)。 +每次 **开始/停止** 录制会在 SQLite 表 `zed_recording_sessions` 中记录一行(`fish_id`、`started_at` / `stopped_at`、`output_dir`;分配规则见上表)。 在 `fish_api` 目录下执行(与 `app` 包路径一致;依赖已 `pip install -e .`): @@ -79,7 +81,7 @@ python3 -m app.zed_record_cli status 1. **440g 全池均值保护**(规则 B):若 `avg_g_filtered`(所有 candidates 均值)> `--mean-pool-fallback-max-if-over-g`(默认 440g),则 `pred_weight_g = max_predicted_weight_g_after_filter`,`pred_weight_rule = "max_after_filter_high_mean_pool_over_g"`。 2. **400g mean-all fallback**(规则 A,仅 `--average-all-after-filter` 开启时):若全池 mean > `--average-all-fallback-max-if-mean-over-g`(默认 400g),则 `pred_weight_g = max_predicted_weight_g_after_filter`,`pred_weight_rule = "max_after_filter_high_mean_all"`。 -3. **`--average-all-after-filter`**(默认关):全部 candidates 均值作为最终值,`pred_weight_rule = "mean_all_filtered"`。 +3. `**--average-all-after-filter`**(默认关):全部 candidates 均值作为最终值,`pred_weight_rule = "mean_all_filtered"`。 4. **Top-K 聚合**(默认路径):按 `--top-by-length`(默认开)选 top-K 帧,对选中帧的预测重量取**算术平均**(与日志中 `top{k}_avg` 一致),`pred_weight_rule = "top_k_aggregate"`。 DGCNN 明细中同时输出 `mean_all_pred_g_after_filters`、`avg_topk_mean_pred_g` 等供对比参考。 @@ -88,7 +90,7 @@ DGCNN 明细中同时输出 `mean_all_pred_g_after_filters`、`avg_topk_mean_pre 不启动 uvicorn、**不写 SQLite**、**不发布** `MEDIA_ROOT`(与 `run_full_measure_batch` 相比仅少快照与媒体发布;FishMeasure 子进程与 `measure_output/fish{N}` 与线上一致)。 -调用 [`app/services/measure.py`](app/services/measure.py) 中的 `run_measure_batch_subprocess`,配置与 `fish_api/.env` 相同(`get_settings()`)。 +调用 `[app/services/measure.py](app/services/measure.py)` 中的 `run_measure_batch_subprocess`,配置与 `fish_api/.env` 相同(`get_settings()`)。 **必须在 `fish_api` 下执行 `python -m app...`,或从仓库根用下面脚本**;若在仓库根直接运行 `python -m app.measure_debug_cli`,会因找不到 `app` 包报错(`ModuleNotFoundError: No module named 'app'`)。 @@ -115,3 +117,4 @@ python3 -m app.measure_debug_cli --batch-folder /path/to/fish1 --fish-id 1 --out - RTSP:用 `ffmpeg` 切段写入 MP4 后调用现有 `finalize` 逻辑 - 任务状态:`finalize` 返回 `job_id`,增加 `GET /jobs/{id}` 查询进度 + diff --git a/fish_api/app/db.py b/fish_api/app/db.py index 5125096..f1392df 100644 --- a/fish_api/app/db.py +++ b/fish_api/app/db.py @@ -23,37 +23,6 @@ from app.state import HealthSnapshot, MeasureSnapshot DEFAULT_CLIENT_ID = "default" MAX_CLIENT_ID_LEN = 128 -# 客户端切片索引起缓存:记录每个 client_id 上次返回的切片索引(用于对齐 water/video 端点) -_client_health_slice_index = {} # type: Dict[str, int] - - -def _parse_slice_index_from_source_path(source_path: Optional[str]) -> int: - """从 source_path 解析切片索引,格式为 video.mp4#slice{N}。 - - Returns: - 切片序号(>=0),如果不是切片则返回 -1 - """ - if not source_path: - return -1 - if "#slice" not in source_path: - return -1 - try: - idx_part = source_path.split("#slice")[-1] - return int(idx_part) - except (ValueError, IndexError): - return -1 - - -def get_last_health_slice_index(client_id: str) -> int: - """获取指定 client_id 上次返回的切片索引(用于 water/video 端点对齐)。 - - Returns: - 切片序号(>=0),如果没有记录则返回 -1 - """ - cid = normalize_client_id(client_id) - return _client_health_slice_index.get(cid, -1) - - def normalize_client_id(raw: Optional[str]) -> str: """供轮询接口使用;过长截断,空值回退为 DEFAULT_CLIENT_ID。""" if raw is None: @@ -103,7 +72,8 @@ def init_db(settings: Settings) -> None: health_result TEXT NOT NULL DEFAULT '', raw_class_en TEXT NOT NULL DEFAULT '', error TEXT, - source_path TEXT + source_path TEXT, + video_url TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS watch_processed ( @@ -136,6 +106,7 @@ def init_db(settings: Settings) -> None: _migrate_add_client_id_column(conn) _migrate_add_pred_star_columns(conn) _migrate_add_calculation_log_column(conn) + _migrate_add_health_video_url_column(conn) finally: conn.close() @@ -221,7 +192,7 @@ def begin_zed_recording_session(settings: Settings) -> Tuple[int, int, Path]: fs_dir = _max_numeric_fish_folder_id(parent) fs_svo = _max_fish_id_from_svo2_under_parent(parent) fs_max = max(fs_dir, fs_svo) - fish_id = max(db_max, fs_max) + 1 + fish_id = max(db_max, fs_max, 99) + 1 ts = datetime.now(timezone.utc).isoformat() output_dir = (parent / f"fish{fish_id}").resolve() # 极端情况:并发或其它原因导致目录已存在,则顺延编号 @@ -327,6 +298,22 @@ def _migrate_add_calculation_log_column(conn: sqlite3.Connection) -> None: conn.commit() +def _migrate_add_health_video_url_column(conn: sqlite3.Connection) -> None: + """为旧数据库的 health_snapshots 添加 video_url 列(如果不存在)。""" + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='health_snapshots'" + ).fetchone() + if row is None: + return + cols = conn.execute("PRAGMA table_info(health_snapshots)").fetchall() + col_names = {col[1] for col in cols} + if "video_url" not in col_names: + conn.execute( + "ALTER TABLE health_snapshots ADD COLUMN video_url TEXT NOT NULL DEFAULT ''" + ) + conn.commit() + + def _migrate_delivery_cursor_from_legacy(conn: sqlite3.Connection) -> None: """旧表 delivery_cursor(kind) → delivery_client_cursor(default, kind)。""" row = conn.execute( @@ -419,6 +406,7 @@ def save_health_snapshot( settings: Settings, snap: HealthSnapshot, source_path: Optional[str] = None, + video_url: str = "", ) -> None: init_db(settings) conn = _connect(settings.sqlite_path) @@ -432,8 +420,8 @@ def save_health_snapshot( """ INSERT INTO health_snapshots ( created_at, behavior_result, health_result, - raw_class_en, error, source_path - ) VALUES (?, ?, ?, ?, ?, ?) + raw_class_en, error, source_path, video_url + ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( ts, @@ -442,6 +430,7 @@ def save_health_snapshot( snap.raw_class_en, snap.error, source_path, + video_url, ), ) finally: @@ -537,7 +526,7 @@ def list_all_health_snapshots(settings: Settings) -> List[Dict[str, Any]]: rows = conn.execute( """ SELECT id, created_at, behavior_result, health_result, - raw_class_en, error, source_path + raw_class_en, error, source_path, video_url FROM health_snapshots ORDER BY id DESC """ @@ -553,6 +542,7 @@ def list_all_health_snapshots(settings: Settings) -> List[Dict[str, Any]]: "raw_class_en": row["raw_class_en"] or "", "error": row["error"], "source_path": row["source_path"], + "video_path": row["video_url"] or "", } ) return out @@ -567,7 +557,7 @@ def get_latest_health(settings: Settings) -> HealthSnapshot: row = conn.execute( """ SELECT created_at, behavior_result, health_result, - raw_class_en, error + raw_class_en, error, video_url FROM health_snapshots ORDER BY id DESC LIMIT 1 @@ -581,6 +571,7 @@ def get_latest_health(settings: Settings) -> HealthSnapshot: updated_at=_parse_dt(row["created_at"]), error=row["error"], raw_class_en=row["raw_class_en"] or "", + video_path=row["video_url"] or "", ) finally: conn.close() @@ -770,8 +761,6 @@ def pop_next_health( client_id: str = DEFAULT_CLIENT_ID, ) -> Tuple[HealthSnapshot, bool, Optional[int]]: """取该客户端队首未投递且可交付的 health 快照并推进其游标;跳过不可交付行仅推进游标。""" - global _client_health_slice_index - cid = normalize_client_id(client_id) init_db(settings) conn = _connect(settings.sqlite_path) @@ -783,7 +772,7 @@ def pop_next_health( next_row = conn.execute( """ SELECT id, created_at, behavior_result, health_result, - raw_class_en, error, source_path + raw_class_en, error, source_path, video_url FROM health_snapshots WHERE id > ? ORDER BY id ASC @@ -801,7 +790,6 @@ def pop_next_health( hlth = next_row["health_result"] or "" raw_en = next_row["raw_class_en"] or "" err: Optional[str] = next_row["error"] - source_path: Optional[str] = next_row["source_path"] conn.execute( """ @@ -817,16 +805,13 @@ def pop_next_health( conn.commit() - # 解析并记录切片索引(用于与 water/video 端点对齐) - slice_idx = _parse_slice_index_from_source_path(source_path) - _client_health_slice_index[cid] = slice_idx - snap = HealthSnapshot( behavior_result=beh, health_result=hlth, updated_at=_parse_dt(next_row["created_at"]), error=err, raw_class_en=raw_en, + video_path=next_row["video_url"] or "", ) return snap, True, nid except Exception: @@ -836,6 +821,41 @@ def pop_next_health( conn.close() +def peek_last_delivered_health_video_url( + settings: Settings, + client_id: str = DEFAULT_CLIENT_ID, +) -> str: + """返回该客户端上一次 ``pop_next_health`` 投递的行的 ``video_url``(不推进游标)。 + + 用于 ``/water/video/`` 端点对齐:返回与最近投递的 health 快照相同的视频 URL。 + """ + cid = normalize_client_id(client_id) + init_db(settings) + conn = _connect(settings.sqlite_path) + try: + cursor_row = conn.execute( + """ + SELECT last_delivered_id FROM delivery_client_cursor + WHERE kind = ? AND client_id = ? + """, + ("health", cid), + ).fetchone() + if cursor_row is None: + return "" + last_id = int(cursor_row["last_delivered_id"]) + if last_id <= 0: + return "" + row = conn.execute( + "SELECT video_url FROM health_snapshots WHERE id = ?", + (last_id,), + ).fetchone() + if row is None: + return "" + return row["video_url"] or "" + finally: + conn.close() + + def _load_json_processed_set(path: Path) -> Set[str]: if not path.is_file(): return set() diff --git a/fish_api/app/main.py b/fish_api/app/main.py index 2c171d2..9c5858a 100644 --- a/fish_api/app/main.py +++ b/fish_api/app/main.py @@ -12,6 +12,7 @@ from app.db import init_db from app.routers import biomass, debug, ingest, zed from app.services.action_watch import run_action_watch_loop from app.services.measure_watch import run_measure_watch_loop +from app.services.sonar_video import run_sonar_video_watch_loop from app.settings import get_settings setup_logging() @@ -29,6 +30,8 @@ async def lifespan(app: FastAPI): tasks.append(asyncio.create_task(run_action_watch_loop(s))) if s.measure_watch_dir is not None: tasks.append(asyncio.create_task(run_measure_watch_loop(s))) + if s.biomass_sonar_video_dir is not None: + tasks.append(asyncio.create_task(run_sonar_video_watch_loop(s))) yield for t in tasks: diff --git a/fish_api/app/routers/ingest.py b/fish_api/app/routers/ingest.py index 88ae8fd..5dc36d9 100644 --- a/fish_api/app/routers/ingest.py +++ b/fish_api/app/routers/ingest.py @@ -50,15 +50,17 @@ async def _action_job_serial(mp4_path: Path, settings: Settings) -> None: async with app_state.action_lock: app_state.action_status = "running" try: - # 返回 (第一个快照, 所有切片快照列表) - first_snap, all_snaps = await to_thread( - action_svc.run_full_action, mp4_path, settings + slice_files, duration = await to_thread( + action_svc.prepare_action_slices, mp4_path, settings ) + total_slices = len(slice_files) - # 将所有切片作为独立视频保存到数据库 - for i, snap in enumerate(all_snaps): + for i, slice_file in enumerate(slice_files): + snap = await to_thread( + action_svc.run_single_slice_inference, + slice_file, i, total_slices, duration, mp4_path.name, settings, + ) if health_snapshot_deliverable(snap): - # 为每个切片生成独立的 source_path slice_source = f"{mp4_path.resolve()}#slice{i}" save_health_snapshot(settings, snap, source_path=slice_source) diff --git a/fish_api/app/services/action.py b/fish_api/app/services/action.py index ed3eb7a..8a9c963 100644 --- a/fish_api/app/services/action.py +++ b/fish_api/app/services/action.py @@ -102,109 +102,78 @@ def run_action_subprocess(mp4_path: Path, settings: Settings) -> str: Path(out_json).unlink(missing_ok=True) -def run_full_action(mp4_path: Path, settings: Settings) -> Tuple[HealthSnapshot, List[HealthSnapshot]]: - """运行 FishAction 健康检测。如果视频较长,会自动切片后分别检测。 +def prepare_action_slices( + mp4_path: Path, settings: Settings +) -> Tuple[List[Path], float]: + """Check video duration and create slices if needed. - 每个切片被视为独立的视频,返回所有切片的结果列表。 - - Args: - mp4_path: 输入视频路径 - settings: 应用配置 - - Returns: - Tuple[HealthSnapshot, List[HealthSnapshot]]: (第一个切片/完整视频的快照, 所有切片快照列表) - - 如果视频被切片:返回 (第一个切片, 所有切片列表) - - 如果视频未被切片:返回 (完整视频快照, [完整视频快照]) + Returns ``(slice_files, duration)``. If the video is short enough, + returns ``([mp4_path], duration)`` without slicing. """ logger.info("[FishAction] start mp4={}", mp4_path.resolve()) - - # 检查视频时长 duration = get_video_duration(mp4_path) - should_slice = duration > DEFAULT_MIN_DURATION_FOR_SLICE - if should_slice: - # 视频较长,需要切片处理 + if duration > DEFAULT_MIN_DURATION_FOR_SLICE: logger.info( "[FishAction] video duration {}s > {}s, slicing into {}s segments", duration, DEFAULT_MIN_DURATION_FOR_SLICE, DEFAULT_SLICE_DURATION, ) - - slice_files, slice_dir = slice_video(mp4_path, DEFAULT_SLICE_DURATION) - + slice_files, _slice_dir = slice_video(mp4_path, DEFAULT_SLICE_DURATION) if len(slice_files) > 1: logger.info( "[FishAction] processing {} slices for {}", len(slice_files), mp4_path.name, ) + return slice_files, duration - # 处理每个切片 - all_snaps = [] # type: List[HealthSnapshot] - for i, slice_file in enumerate(slice_files): - start_time = i * DEFAULT_SLICE_DURATION - end_time = min(start_time + DEFAULT_SLICE_DURATION, duration) + return [mp4_path], duration - try: - pred_en = run_action_subprocess(slice_file, settings) - zh = BEHAVIOR_EN_TO_ZH[pred_en] - health = behavior_to_health(pred_en) - snap = HealthSnapshot( - behavior_result=zh, - health_result=health, - updated_at=datetime.now(timezone.utc), - raw_class_en=pred_en, - ) +def run_single_slice_inference( + slice_file: Path, + slice_index: int, + total_slices: int, + duration: float, + mp4_name: str, + settings: Settings, +) -> HealthSnapshot: + """Run FishAction inference on a single video file / slice. - logger.info( - "[FishAction] slice {} ({}s-{}s): pred={} behavior={} health={}", - i, start_time, end_time, pred_en, zh, health, - ) - - all_snaps.append(snap) - except Exception as e: - logger.error("[FishAction] failed to process slice {}: {}", i, e) - # 创建一个表示失败的快照 - error_snap = HealthSnapshot( - behavior_result="处理失败", - health_result="未知", - updated_at=datetime.now(timezone.utc), - raw_class_en="error", - error=str(e), - ) - all_snaps.append(error_snap) + Returns a ``HealthSnapshot`` (may contain error info if inference fails). + """ + start_time = slice_index * DEFAULT_SLICE_DURATION + end_time = min(start_time + DEFAULT_SLICE_DURATION, duration) + try: + pred_en = run_action_subprocess(slice_file, settings) + zh = BEHAVIOR_EN_TO_ZH[pred_en] + health = behavior_to_health(pred_en) + snap = HealthSnapshot( + behavior_result=zh, + health_result=health, + updated_at=datetime.now(timezone.utc), + raw_class_en=pred_en, + ) + if total_slices > 1: logger.info( - "[FishAction] done mp4={} total_slices={}", - mp4_path.name, - len(slice_files), + "[FishAction] slice {} ({}s-{}s): pred={} behavior={} health={}", + slice_index, start_time, end_time, pred_en, zh, health, ) - - # 返回第一个切片的结果和所有切片列表 - first_snap = all_snaps[0] if all_snaps else HealthSnapshot( - behavior_result="", - health_result="", - updated_at=datetime.now(timezone.utc), + else: + logger.info( + "[FishAction] done mp4={} pred_3class={} behavior_zh={} health={}", + mp4_name, pred_en, zh, health, ) - return first_snap, all_snaps - - # 视频较短,直接处理(原有逻辑) - pred_en = run_action_subprocess(mp4_path, settings) - zh = BEHAVIOR_EN_TO_ZH[pred_en] - health = behavior_to_health(pred_en) - logger.info( - "[FishAction] done mp4={} pred_3class={} behavior_zh={} health={}", - mp4_path.name, - pred_en, - zh, - health, - ) - snap = HealthSnapshot( - behavior_result=zh, - health_result=health, - updated_at=datetime.now(timezone.utc), - raw_class_en=pred_en, - ) - return snap, [snap] + return snap + except Exception as e: + logger.error("[FishAction] failed to process slice {}: {}", slice_index, e) + return HealthSnapshot( + behavior_result="处理失败", + health_result="未知", + updated_at=datetime.now(timezone.utc), + raw_class_en="error", + error=str(e), + ) diff --git a/fish_api/app/services/action_watch.py b/fish_api/app/services/action_watch.py index fc42ac5..1b4b3e4 100644 --- a/fish_api/app/services/action_watch.py +++ b/fish_api/app/services/action_watch.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import shutil from pathlib import Path from typing import Dict, List, Set, Tuple @@ -15,6 +16,7 @@ from app.db import ( save_health_snapshot, ) from app.services import action as action_svc +from app.services.measure import transcode_src_to_h264_dst from app.settings import Settings from app.state import app_state from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch @@ -30,20 +32,68 @@ def _state_path(settings: Settings) -> Path: return settings.action_watch_dir / ".fishaction_watch_processed.json" +_VIDEO_SUFFIXES = frozenset({".mp4", ".mkv", ".mov"}) + + def iter_mp4(watch_dir: Path, recursive: bool) -> List[Path]: + """List video files (.mp4, .mkv, .mov) in *watch_dir*.""" if recursive: return sorted( p for p in watch_dir.rglob("*") - if p.is_file() and p.suffix.lower() == ".mp4" + if p.is_file() and p.suffix.lower() in _VIDEO_SUFFIXES ) return sorted( p for p in watch_dir.iterdir() - if p.is_file() and p.suffix.lower() == ".mp4" + if p.is_file() and p.suffix.lower() in _VIDEO_SUFFIXES ) +def _public_media_url(settings: Settings, basename: str) -> str: + base = settings.public_base_url.rstrip("/") + return f"{base}/media/{basename}" + + +def _slice_media_basename(base_name: str, slice_index: int) -> str: + """生成切片视频的媒体文件名。""" + base = Path(base_name).stem + return f"{base}_slice_{slice_index:03d}.mp4" + + +def _publish_slice_video( + src: Path, dst: Path, settings: Settings, +) -> str: + """Transcode and publish a single video slice to media_root. + + Returns the public URL on success, empty string on failure. + """ + tmp = dst.with_name(dst.stem + "_tmp.mp4") + tmp.unlink(missing_ok=True) + try: + ok = transcode_src_to_h264_dst(src, tmp) + if ok and tmp.is_file() and tmp.stat().st_size > 0: + tmp.replace(dst) + logger.info( + "[action-watch] published H.264: {} -> {}", src.name, dst.name, + ) + else: + tmp.unlink(missing_ok=True) + shutil.copy2(src, dst) + logger.warning( + "[action-watch] transcode failed, copied raw: {} -> {}", + src.name, + dst.name, + ) + return _public_media_url(settings, dst.name) + except Exception: + logger.exception("[action-watch] publish failed for {}", src.name) + tmp.unlink(missing_ok=True) + if dst.is_file(): + return _public_media_url(settings, dst.name) + return "" + + async def _run_inference_and_state( mp4: Path, settings: Settings, @@ -57,19 +107,43 @@ async def _run_inference_and_state( async with app_state.action_lock: app_state.action_status = "running" try: - # 返回 (第一个快照, 所有切片快照列表) - first_snap, all_snaps = await to_thread( - action_svc.run_full_action, mp4, settings + slice_files, duration = await to_thread( + action_svc.prepare_action_slices, mp4, settings + ) + + settings.media_root.mkdir(parents=True, exist_ok=True) + base_name = ( + settings.biomass_water_video_media_name or "biomass_water_surface.mp4" ) - # 将所有切片作为独立视频保存到数据库 saved_count = 0 - for i, snap in enumerate(all_snaps): - if health_snapshot_deliverable(snap): - # 为每个切片生成独立的 source_path - slice_key = f"{key}#slice{i}" - save_health_snapshot(settings, snap, source_path=slice_key) - saved_count += 1 + first_snap = None + total_slices = len(slice_files) + + for i, slice_file in enumerate(slice_files): + snap = await to_thread( + action_svc.run_single_slice_inference, + slice_file, i, total_slices, duration, mp4.name, settings, + ) + + if first_snap is None: + first_snap = snap + + if not health_snapshot_deliverable(snap): + continue + + video_url = "" + media_basename = _slice_media_basename(base_name, i) + dst = settings.media_root / media_basename + video_url = await to_thread( + _publish_slice_video, slice_file, dst, settings, + ) + + slice_key = f"{key}#slice{i}" + save_health_snapshot( + settings, snap, source_path=slice_key, video_url=video_url, + ) + saved_count += 1 if saved_count == 0: logger.warning( @@ -82,7 +156,7 @@ async def _run_inference_and_state( if settings.action_watch_use_state_file: add_watch_processed(settings, key, "action") - pred = (first_snap.raw_class_en or "").strip() + pred = (first_snap.raw_class_en or "").strip() if first_snap else "" logger.info( "[action-watch] done: {} -> {} (saved {} slices)", mp4.name, diff --git a/fish_api/app/services/measure.py b/fish_api/app/services/measure.py index 96e014e..30f6f63 100644 --- a/fish_api/app/services/measure.py +++ b/fish_api/app/services/measure.py @@ -1030,6 +1030,31 @@ def _run_generate_video_with_labels_cli( return video_path +def _open_cv2_video_writer( + dst: Path, fps: float, size: Tuple[int, int], +) -> "cv2.VideoWriter": + """Open a cv2.VideoWriter, preferring GStreamer NVENC on Jetson.""" + import cv2 + w, h = size + try: + if hasattr(cv2, "CAP_GSTREAMER"): + loc = str(dst).replace('"', '\\"') + gst_pipe = ( + f'appsrc ! videoconvert ! video/x-raw,format=BGRx ! ' + f'nvvidconv ! video/x-raw(memory:NVMM) ! ' + f'nvv4l2h264enc bitrate=4000000 ! h264parse ! ' + f'mp4mux ! filesink location="{loc}"' + ) + writer = cv2.VideoWriter(gst_pipe, cv2.CAP_GSTREAMER, 0, fps, (w, h)) + if writer.isOpened(): + logger.debug("[FishMeasure] cv2 writer using GStreamer NVENC: {}", dst.name) + return writer + writer.release() + except Exception: + pass + return cv2.VideoWriter(str(dst), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) + + def _concat_preview_videos(inputs: List[Path], dst: Path) -> bool: if not inputs: return False @@ -1059,12 +1084,7 @@ def _concat_preview_videos(inputs: List[Path], dst: Path) -> bool: if writer is None: target_size = (width, height) fps = cur_fps if cur_fps > 0.0 else 10.0 - writer = cv2.VideoWriter( - str(dst), - cv2.VideoWriter_fourcc(*"mp4v"), - fps, - target_size, - ) + writer = _open_cv2_video_writer(dst, fps, target_size) while True: ok, frame = cap.read() if not ok: @@ -1224,6 +1244,96 @@ def _get_h264_encoder() -> Tuple[str, List[str], str]: return "", [], ffmpeg_path +_gst_nvenc_available: Optional[bool] = None + + +def _check_gst_nvenc() -> bool: + """Detect whether GStreamer nvv4l2h264enc (Jetson hardware encoder) is usable.""" + global _gst_nvenc_available + if _gst_nvenc_available is not None: + return _gst_nvenc_available + + try: + r = subprocess.run( + ["gst-inspect-1.0", "nvv4l2h264enc"], + capture_output=True, text=True, timeout=5, + ) + _gst_nvenc_available = r.returncode == 0 + except (FileNotFoundError, Exception): + _gst_nvenc_available = False + + if _gst_nvenc_available: + logger.info("[FishMeasure] GStreamer nvv4l2h264enc (Jetson NVENC) available") + else: + logger.debug("[FishMeasure] GStreamer nvv4l2h264enc not available, will use software encoder") + return _gst_nvenc_available + + +_GST_DEMUX_BY_EXT = { + ".mp4": "qtdemux", + ".mov": "qtdemux", + ".m4v": "qtdemux", + ".mkv": "matroskademux", + ".webm": "matroskademux", + ".ts": "tsdemux", + ".avi": "avidemux", +} + + +def _transcode_with_gst_nvenc(src: Path, dst: Path) -> bool: + """Transcode to H.264 using GStreamer with Jetson NVENC hardware encoder. + + Pipeline: filesrc → demux → decode → nvvidconv (to NVMM) → nvv4l2h264enc → mp4mux → filesink + Falls back gracefully: returns False so callers can try software encoding. + """ + if not _check_gst_nvenc(): + return False + + demux = _GST_DEMUX_BY_EXT.get(src.suffix.lower()) + if demux is None: + logger.debug("[FishMeasure] NVENC: unsupported container {}, skipping", src.suffix) + return False + + tmp = dst.with_suffix(".gst_tmp.mp4") + tmp.unlink(missing_ok=True) + + cmd = [ + "gst-launch-1.0", "-e", + "filesrc", f"location={src}", + "!", demux, "name=demux", + "demux.video_0", "!", "decodebin", + "!", "nvvidconv", + "!", "video/x-raw(memory:NVMM)", + "!", "nvv4l2h264enc", "bitrate=4000000", + "!", "h264parse", + "!", "mp4mux", + "!", "filesink", f"location={tmp}", + ] + + logger.info("[FishMeasure] NVENC transcode: {} -> {}", src.name, dst.name) + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=600, + ) + if result.returncode == 0 and tmp.is_file() and tmp.stat().st_size > 0: + tmp.replace(dst) + logger.info( + "[FishMeasure] NVENC transcode SUCCESS: {} ({} bytes)", + dst.name, dst.stat().st_size, + ) + return True + + stderr = (result.stderr or "")[-500:] + logger.warning("[FishMeasure] NVENC transcode FAILED (rc={}): {}", result.returncode, stderr) + except subprocess.TimeoutExpired: + logger.warning("[FishMeasure] NVENC transcode timed out for {}", src.name) + except Exception as e: + logger.warning("[FishMeasure] NVENC transcode exception: {}", e) + + tmp.unlink(missing_ok=True) + return False + + def _get_x264_path() -> Optional[str]: """检测系统上是否有可用的 x264 命令行工具。""" for path in ["/usr/bin/x264", "/usr/local/bin/x264", "x264"]: @@ -1390,17 +1500,56 @@ def _transcode_fallback(src: Path, dst: Path) -> bool: shutil.rmtree(tmp_dir, ignore_errors=True) -def _transcode_to_h264(src: Path, dst: Path) -> bool: - """使用 ffmpeg 将视频转码为 H.264 (浏览器兼容格式)。 +def _is_already_h264(src: Path) -> bool: + """Quick ffprobe check: is the video stream already H.264?""" + try: + from app.services.video_slice import _get_ffprobe_path + r = subprocess.run( + [_get_ffprobe_path(), "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", + str(src)], + capture_output=True, text=True, timeout=5, + ) + return r.returncode == 0 and r.stdout.strip() == "h264" + except Exception: + return False - 尝试多种H.264编码器,包括软件编码和硬件加速编码。 - 如果直接转码失败,依次尝试备选方案: - 1. 提取帧重新编码 - 2. 使用 x264 命令行工具(当 ffmpeg 的 H.264 编码器都不可用时) + +def _remux_h264_faststart(src: Path, dst: Path) -> bool: + """Stream-copy an already-H.264 file into an MP4 with moov at the front.""" + ffmpeg_path = _get_ffmpeg_path() + try: + r = subprocess.run( + [ffmpeg_path, "-y", "-i", str(src), + "-c", "copy", "-movflags", "+faststart", "-an", + str(dst)], + capture_output=True, text=True, timeout=60, + ) + if r.returncode == 0 and dst.is_file() and dst.stat().st_size > 0: + logger.info("[FishMeasure] remuxed H.264 (no re-encode): {} -> {} ({} bytes)", + src.name, dst.name, dst.stat().st_size) + return True + except Exception: + pass + return False + + +def _transcode_to_h264(src: Path, dst: Path) -> bool: + """将视频转码为 H.264 (浏览器兼容格式)。 + + If the source is already H.264, remux without re-encoding (instant). + Otherwise try Jetson NVENC hardware, then fall back to ffmpeg software. """ + if _is_already_h264(src) and _remux_h264_faststart(src, dst): + return True + + if _transcode_with_gst_nvenc(src, dst): + return True + encoder, encoder_options, ffmpeg_path = _get_h264_encoder() - # 如果有可用的 ffmpeg H.264 编码器,先尝试直接转码 if encoder: try: # 基础参数 diff --git a/fish_api/app/services/sonar_optical_flow.py b/fish_api/app/services/sonar_optical_flow.py new file mode 100644 index 0000000..df2d6b1 --- /dev/null +++ b/fish_api/app/services/sonar_optical_flow.py @@ -0,0 +1,77 @@ +"""声呐视频发布前:在 FishMeasure ``optical_flow/visualize_optical_flow.py`` 中做稠密光流 overlay。""" + +from __future__ import annotations + +import importlib.util +import threading +import time +from pathlib import Path +from types import ModuleType +from typing import Optional + +from loguru import logger + +from app.settings import Settings + +_mod_lock = threading.Lock() +_cached_mod: Optional[ModuleType] = None +_cached_mod_path: Optional[str] = None + + +def _load_optical_flow_module(settings: Settings) -> Optional[ModuleType]: + global _cached_mod, _cached_mod_path + path = settings.fish_measure_root / "optical_flow" / "visualize_optical_flow.py" + path_str = str(path) + with _mod_lock: + if _cached_mod is not None and _cached_mod_path == path_str: + return _cached_mod + if not path.is_file(): + logger.warning("[sonar-video] optical flow module not found: {}", path) + return None + spec = importlib.util.spec_from_file_location("fishmeasure_optical_flow_viz", path) + if spec is None or spec.loader is None: + return None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + _cached_mod = mod + _cached_mod_path = path_str + return mod + + +def run_sonar_optical_flow_overlay( + src: Path, + flow_out: Path, + settings: Settings, +) -> bool: + """将 ``src`` 处理为 overlay 光流视频写入 ``flow_out``。失败返回 False(调用方应回退直转码)。""" + if not settings.biomass_sonar_optical_flow: + return False + mod = _load_optical_flow_module(settings) + if mod is None: + return False + run_fn = getattr(mod, "run_optical_flow_video", None) + if not callable(run_fn): + logger.warning("[sonar-video] run_optical_flow_video missing in optical flow module") + return False + src_size_mb = src.stat().st_size / (1024 * 1024) if src.is_file() else 0 + logger.info("[sonar-video] optical flow starting: {} ({:.1f} MB), resize={}", src.name, src_size_mb, settings.biomass_sonar_optical_flow_resize) + t0 = time.monotonic() + try: + ok = run_fn( + src, + flow_out, + mode="overlay", + resize=float(settings.biomass_sonar_optical_flow_resize), + progress_log=False, + ) + elapsed = time.monotonic() - t0 + if ok: + out_size_mb = flow_out.stat().st_size / (1024 * 1024) if flow_out.is_file() else 0 + logger.info("[sonar-video] optical flow done in {:.1f}s: {} -> {} ({:.1f} MB)", elapsed, src.name, flow_out.name, out_size_mb) + else: + logger.warning("[sonar-video] optical flow returned False after {:.1f}s for {}", elapsed, src.name) + return bool(ok) + except Exception: + elapsed = time.monotonic() - t0 + logger.exception("[sonar-video] optical flow raised exception after {:.1f}s for {}", elapsed, src.name) + return False diff --git a/fish_api/app/services/sonar_video.py b/fish_api/app/services/sonar_video.py index 3512185..1149544 100644 --- a/fish_api/app/services/sonar_video.py +++ b/fish_api/app/services/sonar_video.py @@ -1,32 +1,37 @@ -"""声呐视频:由显式文件或专用目录中的最新 MP4 发布 H.264 到 MEDIA_ROOT。 +"""声呐视频:后台处理 ``BIOMASS_SONAR_VIDEO_DIR`` 中的**当前最新**视频文件。 -与水上视频使用相同的转码路径(``transcode_src_to_h264_dst`` / ffmpeg)。 -不切片:整段源转成一个固定文件,每次 GET 返回同一 ``video_path`` URL。 +支持 MP4、MKV、MOV。MP4/MOV 在录制中缺少 ``moov`` atom,须等录完才能处理; +MKV 的元数据写在文件头,录制中即可读取,无需等待。 + +每次轮询取目录中 **mtime 最新** 的文件;当其 **(path, size)** 变化时,用 ffmpeg +``-sseof`` 截取**最后 N 秒**(默认 60,见 ``BIOMASS_SONAR_VIDEO_SLICE_SEC``),再对该 +切片做光流 overlay → H.264 转码并原子替换发布。``GET /api/v1/biomass/sonar/video/`` +立即返回最近成功发布的 URL。 """ from __future__ import annotations import asyncio -import shutil +import datetime +import subprocess +import time from pathlib import Path - from typing import Optional, Tuple from loguru import logger from app.compat import to_thread - from app.services.action_watch import iter_mp4 from app.services.measure import transcode_src_to_h264_dst +from app.services.sonar_optical_flow import run_sonar_optical_flow_overlay +from app.services.video_slice import _get_ffmpeg_path from app.settings import Settings -_publish_lock = asyncio.Lock() - DEFAULT_CLIENT_ID = "default" -# 源路径 + mtime,用于跳过重复转码 -_last_src_key = None # type: Optional[Tuple[str, float]] -_cached_public_url: str = "" +# --- published state (read by GET endpoint) --- +_published_url: str = "" +_published_lock = asyncio.Lock() def _public_media_url(settings: Settings, basename: str) -> str: @@ -41,104 +46,286 @@ def _safe_sonar_media_basename(raw: str) -> str: return Path(n).name or "biomass_sonar.mp4" -def resolve_sonar_video_source(settings: Settings) -> Optional[Path]: - """优先 BIOMASS_SONAR_VIDEO_SOURCE;否则在 BIOMASS_SONAR_VIDEO_DIR 中取 mtime 最新的 .mp4。""" - cfg = settings.biomass_sonar_video_source - if cfg is not None: - if cfg.is_file(): - return cfg - logger.warning( - "[sonar-video] BIOMASS_SONAR_VIDEO_SOURCE is not a file: {}", - cfg, - ) - return None - d = settings.biomass_sonar_video_dir - if d is None or not d.is_dir(): - return None - mp4s = iter_mp4(d, settings.biomass_sonar_video_recursive) - if not mp4s: - return None - try: - return max(mp4s, key=lambda p: p.stat().st_mtime) - except OSError as e: - logger.warning("[sonar-video] could not pick latest mp4: {}", e) - return None +# Formats that store metadata at the start → readable while recording +_STREAMING_SUFFIXES = frozenset({".mkv", ".ts", ".webm"}) +# --------------------------------------------------------------------------- +# Probe: is the video file ready to process? +# --------------------------------------------------------------------------- + +def _is_ready_to_process(path: Path) -> bool: + """MKV/TS/WebM are always readable (metadata at start); MP4/MOV need moov at end.""" + if path.suffix.lower() in _STREAMING_SUFFIXES: + return path.is_file() and path.stat().st_size > 0 + return _probe_moov_readable(path) + + +def _probe_moov_readable(path: Path) -> bool: + """Quick check via ffprobe (fallback cv2): does the MP4/MOV have a moov atom?""" + try: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, timeout=5, + ) + if r.returncode == 0 and r.stdout.strip(): + return True + logger.debug("[sonar-watch] ffprobe: moov missing: {}", path.name) + return False + except FileNotFoundError: + pass + except Exception as e: + logger.debug("[sonar-watch] ffprobe failed ({}), trying cv2", e) + + try: + import cv2 + cap = cv2.VideoCapture(str(path)) + ok = cap.isOpened() and cap.get(cv2.CAP_PROP_FRAME_COUNT) > 0 + cap.release() + if not ok: + logger.debug("[sonar-watch] cv2: unreadable: {}", path.name) + return ok + except Exception: + return False + + +def _extract_tail_slice(src: Path, slice_out: Path, duration_sec: float) -> bool: + """Extract last ``duration_sec`` seconds with ``ffmpeg -sseof`` + stream copy. + + For growing MKV files ``ffprobe`` often returns ``N/A`` for duration, so we + always attempt ``-sseof`` first (ffmpeg clamps to file start when the file is + shorter than the requested window). Only when ``-sseof`` fails do we fall + back to a plain ``-c copy`` of the entire file. + """ + slice_out.parent.mkdir(parents=True, exist_ok=True) + slice_out.unlink(missing_ok=True) + if not src.is_file() or src.stat().st_size <= 0: + return False + + ffmpeg = _get_ffmpeg_path() + sec = float(duration_sec) + + # --- primary: -sseof (works on growing MKV even when duration is unknown) --- + sseof_cmd = [ + ffmpeg, "-y", "-hide_banner", "-loglevel", "error", + "-sseof", f"-{sec}", + "-i", str(src), + "-t", str(sec), + "-c", "copy", + "-avoid_negative_ts", "make_zero", + str(slice_out), + ] + try: + r = subprocess.run(sseof_cmd, capture_output=True, text=True, timeout=600) + if r.returncode == 0 and slice_out.is_file() and slice_out.stat().st_size > 0: + logger.debug("[sonar-watch] tail slice ok (-sseof {:.0f}s): {} -> {}", + sec, src.name, slice_out.name) + return True + except Exception as e: + logger.debug("[sonar-watch] tail slice -sseof error: {} ({})", src.name, e) + + # --- fallback: copy entire file (source shorter than window or -sseof unsupported) --- + slice_out.unlink(missing_ok=True) + copy_cmd = [ + ffmpeg, "-y", "-hide_banner", "-loglevel", "error", + "-i", str(src), + "-c", "copy", + str(slice_out), + ] + try: + r = subprocess.run(copy_cmd, capture_output=True, text=True, timeout=600) + if r.returncode == 0 and slice_out.is_file() and slice_out.stat().st_size > 0: + logger.debug("[sonar-watch] tail slice ok (full copy fallback): {} -> {}", + src.name, slice_out.name) + return True + except Exception as e: + logger.warning("[sonar-watch] tail slice fallback error: {} ({})", src.name, e) + + logger.warning("[sonar-watch] tail slice failed for {} | stderr={}", + src.name, (r.stderr or "")[:500]) + slice_out.unlink(missing_ok=True) + return False + + +# --------------------------------------------------------------------------- +# Publish pipeline: tail slice → optical flow (optional) → H.264 transcode → atomic replace +# --------------------------------------------------------------------------- + async def _publish_video( src: Path, - dst: Path, + media_root: Path, + dst_stem: str, settings: Settings, -) -> str: - """与 water_video._publish_video 相同:ffmpeg H.264,失败则回退复制。""" - tmp = dst.with_name(dst.stem + "_tmp.mp4") +) -> Optional[Path]: + """Extract tail slice → optical-flow → H.264 transcode → verify → save. + + Every output file gets a unique timestamp so successive cycles never + overwrite each other: + + * ``_optical_flow_.mp4`` — optical-flow overlay (kept) + * ``_.mp4`` — final H.264 output (kept, published) + + Returns the path of the published file, or ``None`` on failure. + """ + src_size_mb = src.stat().st_size / (1024 * 1024) if src.is_file() else 0 + slice_sec = float(settings.biomass_sonar_video_slice_sec) + logger.info( + "[sonar-watch] processing: {} ({:.1f} MB), slice_sec={:.0f}, optical_flow={}", + src.name, src_size_mb, slice_sec, settings.biomass_sonar_optical_flow, + ) + t0 = time.monotonic() + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + slice_tmp = media_root / f"{dst_stem}_tail_slice.mkv" + slice_tmp.unlink(missing_ok=True) + tmp = media_root / f"{dst_stem}_tmp.mp4" tmp.unlink(missing_ok=True) try: - ok = await to_thread(transcode_src_to_h264_dst, src, tmp) - if ok and tmp.is_file() and tmp.stat().st_size > 0: - tmp.replace(dst) - logger.info("[sonar-video] published H.264: {} -> {}", src.name, dst.name) - else: - tmp.unlink(missing_ok=True) - await to_thread(shutil.copy2, src, dst) - logger.warning( - "[sonar-video] transcode failed, copied raw: {} -> {}", - src.name, - dst.name, - ) - return _public_media_url(settings, dst.name) - except Exception: - logger.exception("[sonar-video] publish failed") - tmp.unlink(missing_ok=True) - if dst.is_file(): - return _public_media_url(settings, dst.name) - return "" + slice_ok = await to_thread(_extract_tail_slice, src, slice_tmp, slice_sec) + if not slice_ok: + logger.warning("[sonar-watch] tail extract failed, skip publish: {}", src.name) + return None + transcode_src = slice_tmp + if settings.biomass_sonar_optical_flow: + flow_dst = media_root / f"{dst_stem}_optical_flow_{ts}.mp4" + flow_tmp = media_root / f"{dst_stem}_flow_tmp.mp4" + flow_tmp.unlink(missing_ok=True) + flow_ok = await to_thread( + run_sonar_optical_flow_overlay, slice_tmp, flow_tmp, settings, + ) + if flow_ok and flow_tmp.is_file() and flow_tmp.stat().st_size > 0: + flow_tmp.replace(flow_dst) + transcode_src = flow_dst + logger.info("[sonar-watch] optical flow saved: {} ({:.1f} MB)", + flow_dst.name, + flow_dst.stat().st_size / (1024 * 1024)) + else: + flow_tmp.unlink(missing_ok=True) + logger.warning("[sonar-watch] optical flow failed, transcoding raw slice: {}", src.name) + + ok = await to_thread(transcode_src_to_h264_dst, transcode_src, tmp) + if not (ok and tmp.is_file() and tmp.stat().st_size > 0): + tmp.unlink(missing_ok=True) + logger.warning("[sonar-watch] transcode failed for {}", src.name) + return None + + if not _probe_moov_readable(tmp): + logger.warning("[sonar-watch] transcoded file not playable, discarding: {}", src.name) + tmp.unlink(missing_ok=True) + return None + + dst = media_root / f"{dst_stem}_{ts}.mp4" + tmp.replace(dst) + elapsed = time.monotonic() - t0 + dst_mb = dst.stat().st_size / (1024 * 1024) + logger.info("[sonar-watch] published in {:.1f}s: {} -> {} ({:.1f} MB)", + elapsed, src.name, dst.name, dst_mb) + return dst + except Exception: + logger.exception("[sonar-watch] publish exception for {}", src.name) + tmp.unlink(missing_ok=True) + return None + finally: + slice_tmp.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Background watcher loop (started from lifespan, like action_watch) +# --------------------------------------------------------------------------- + +async def run_sonar_video_watch_loop(settings: Settings) -> None: + """Poll ``BIOMASS_SONAR_VIDEO_DIR``, follow the **latest** file by mtime. + + When that file's ``(resolved_path, size)`` changes and the file is ready to read, + extract tail slice → publish. Each published video gets a unique timestamped + filename; the GET endpoint always returns the most recent one. + """ + global _published_url + + d = settings.biomass_sonar_video_dir + if d is None: + logger.info("[sonar-watch] BIOMASS_SONAR_VIDEO_DIR not set, sonar watch disabled") + return + + poll = max(1.0, settings.biomass_sonar_video_poll_interval) + + basename = _safe_sonar_media_basename(settings.biomass_sonar_video_media_name) + dst_stem = Path(basename).stem # e.g. "biomass_sonar" + media_root = settings.media_root + media_root.mkdir(parents=True, exist_ok=True) + + # Seed published URL from the newest existing published file in media_root + existing = sorted( + media_root.glob(f"{dst_stem}_*.mp4"), + key=lambda p: p.stat().st_mtime, + ) + # Exclude intermediates (_tmp, _flow_tmp, _optical_flow_, _tail_slice) + existing = [ + p for p in existing + if not any(tag in p.stem for tag in ("_tmp", "_flow_tmp", "_optical_flow_", "_tail_slice")) + ] + if existing: + seed = existing[-1] + async with _published_lock: + _published_url = _public_media_url(settings, seed.name) + logger.info("[sonar-watch] seeded from existing: {}", _published_url) + + last_published_key: Optional[Tuple[str, int]] = None + + logger.info("[sonar-watch] watching {} (poll={:.0f}s, recursive={})", + d, poll, settings.biomass_sonar_video_recursive) + + while True: + try: + if d.is_dir(): + all_videos = iter_mp4(d, settings.biomass_sonar_video_recursive) + if all_videos: + latest = max(all_videos, key=lambda p: p.stat().st_mtime) + try: + rp = str(latest.resolve()) + sz = latest.stat().st_size + except OSError: + await asyncio.sleep(poll) + continue + + if sz > 0 and last_published_key != (rp, sz): + ready = await to_thread(_is_ready_to_process, latest) + if ready: + published = await _publish_video( + latest, media_root, dst_stem, settings, + ) + if published is not None: + async with _published_lock: + _published_url = _public_media_url( + settings, published.name, + ) + last_published_key = (rp, sz) + else: + logger.debug( + "[sonar-watch] {} not ready yet (still recording?), waiting", + latest.name, + ) + + except asyncio.CancelledError: + raise + except Exception: + logger.exception("[sonar-watch] unexpected error in watch loop") + + await asyncio.sleep(poll) + + +# --------------------------------------------------------------------------- +# GET endpoint helper (called from biomass router, no change to API contract) +# --------------------------------------------------------------------------- async def get_sonar_video_public_url( settings: Settings, _client_id: str = DEFAULT_CLIENT_ID, ) -> str: - """转码并发布到 MEDIA_ROOT 后返回绝对 URL;无可用源且无已发布文件时返回空串。 - - 始终对应同一发布文件(BIOMASS_SONAR_VIDEO_MEDIA_NAME),所有客户端每次请求返回同一 URL。 - """ - settings.media_root.mkdir(parents=True, exist_ok=True) - - global _last_src_key, _cached_public_url - - async with _publish_lock: - basename = _safe_sonar_media_basename(settings.biomass_sonar_video_media_name) - dst = settings.media_root / basename - src = resolve_sonar_video_source(settings) - - if src is None: - _last_src_key = None - _cached_public_url = "" - if dst.is_file(): - return _public_media_url(settings, dst.name) - return "" - - try: - key = (str(src.resolve()), src.stat().st_mtime) - except OSError: - if dst.is_file(): - return _public_media_url(settings, dst.name) - return "" - - if key == _last_src_key and _cached_public_url: - return _cached_public_url - - url = await _publish_video(src, dst, settings) - if url: - _last_src_key = key - _cached_public_url = url - return url - - if dst.is_file(): - u = _public_media_url(settings, dst.name) - _last_src_key = key - _cached_public_url = u - return u - return "" + """Return the URL of the latest successfully published sonar video, or ``""``.""" + async with _published_lock: + return _published_url diff --git a/fish_api/app/services/water_video.py b/fish_api/app/services/water_video.py index e6232fa..3eeef45 100644 --- a/fish_api/app/services/water_video.py +++ b/fish_api/app/services/water_video.py @@ -1,265 +1,25 @@ -"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。 +"""水上视频:返回与最近投递的 health 快照对齐的视频 URL。 -支持长视频切片:如果视频较长,会切分为多个10秒片段并分别转码发布。 -每个切片被视为独立的视频。 - -对齐机制:使用 client_id 区分不同客户端的轮询进度,确保 health/result 和 -water/video 两个端点对齐返回同一切片。 +视频由 action_watch 在推理完成后统一发布到 MEDIA_ROOT,URL 存储在 +health_snapshots.video_url 列中,确保 /health/result/ 与 /water/video/ +两个端点始终返回同一切片的结果。 """ from __future__ import annotations -import asyncio -import shutil -from pathlib import Path -from typing import Dict, List, Optional - -from loguru import logger - -from app.compat import to_thread - -from app.services.action_watch import iter_mp4 -from app.services.measure import transcode_src_to_h264_dst -from app.services.video_slice import get_video_duration, slice_video +from app.db import peek_last_delivered_health_video_url from app.settings import Settings -_publish_lock = asyncio.Lock() - -# 视频切片配置 -SLICE_DURATION = 10.0 # 每个切片的时长(秒) -MIN_DURATION_FOR_SLICE = 15.0 # 超过此时长才切片 - -# 默认客户端ID(与 db.py 保持一致) DEFAULT_CLIENT_ID = "default" -def _public_media_url(settings: Settings, basename: str) -> str: - base = settings.public_base_url.rstrip("/") - return f"{base}/media/{basename}" - - -def _safe_water_media_basename(raw: str) -> str: - n = (raw or "").strip() - if not n: - return "biomass_water_surface.mp4" - return Path(n).name or "biomass_water_surface.mp4" - - -def _slice_media_basename(base_name: str, slice_index: int) -> str: - """生成切片视频的媒体文件名。""" - base = Path(base_name).stem - return f"{base}_slice_{slice_index:03d}.mp4" - - -# 客户端独立的状态:每个 client_id 有自己的切片队列和索引 -# _client_slice_queues[client_id] = [url0, url1, url2, ...] -# _client_slice_indices[client_id] = 当前应该返回的索引 -_client_slice_queues: Dict[str, List[str]] = {} -_client_slice_indices: Dict[str, int] = {} - -# 全局缓存的切片列表(用于检测源文件变化) -_global_slice_urls: List[str] = [] -_last_source_mtime: float = 0.0 - - -def resolve_water_video_source(settings: Settings) -> Optional[Path]: - """优先 BIOMASS_WATER_VIDEO_SOURCE;否则取 ACTION_WATCH_DIR 中 mtime 最新的 .mp4。""" - cfg = settings.biomass_water_video_source - if cfg is not None: - if cfg.is_file(): - return cfg - logger.warning( - "[water-video] BIOMASS_WATER_VIDEO_SOURCE is not a file: {}", - cfg, - ) - return None - aw = settings.action_watch_dir - if aw is None or not aw.is_dir(): - return None - mp4s = iter_mp4(aw, settings.action_watch_recursive) - if not mp4s: - return None - try: - return max(mp4s, key=lambda p: p.stat().st_mtime) - except OSError as e: - logger.warning("[water-video] could not pick latest mp4: {}", e) - return None - - -async def _publish_video( - src: Path, - dst: Path, - settings: Settings, +async def get_water_video_public_url( + settings: Settings, client_id: str = DEFAULT_CLIENT_ID, ) -> str: - """发布视频到 MEDIA_ROOT。 + """返回该客户端上一次 ``/health/result/`` 投递的切片视频 URL。 - Args: - src: 源视频路径 - dst: 目标路径 - settings: 应用配置 - - Returns: - 发布的视频URL,失败返回空串 + 视频文件在 ``action_watch`` 推理完成后即发布到 ``MEDIA_ROOT`` 并将 URL + 写入 ``health_snapshots.video_url``;本函数仅做一次 DB 查询,不再独立 + 解析源文件或切片。 """ - tmp = dst.with_name(dst.stem + "_tmp.mp4") - tmp.unlink(missing_ok=True) - - try: - ok = await to_thread(transcode_src_to_h264_dst, src, tmp) - if ok and tmp.is_file() and tmp.stat().st_size > 0: - tmp.replace(dst) - logger.info("[water-video] published H.264: {} -> {}", src.name, dst.name) - else: - tmp.unlink(missing_ok=True) - await to_thread(shutil.copy2, src, dst) - logger.warning( - "[water-video] transcode failed, copied raw: {} -> {}", - src.name, - dst.name, - ) - return _public_media_url(settings, dst.name) - except Exception: - logger.exception("[water-video] publish failed") - tmp.unlink(missing_ok=True) - if dst.is_file(): - return _public_media_url(settings, dst.name) - return "" - - -async def _prepare_slices(settings: Settings) -> List[str]: - """预处理:如果视频较长,切分为多个片段并发布到 MEDIA_ROOT。 - - 返回切片URL列表(供各客户端使用)。 - """ - global _global_slice_urls, _last_source_mtime - - base_basename = _safe_water_media_basename(settings.biomass_water_video_media_name) - src = resolve_water_video_source(settings) - - if src is None: - return [] - - # 检查是否需要重新切片 - try: - current_mtime = src.stat().st_mtime - except OSError: - current_mtime = 0.0 - - # 如果源文件未变化且已有缓存,直接返回缓存 - if current_mtime == _last_source_mtime and _global_slice_urls: - return _global_slice_urls - - # 检查视频时长 - duration = get_video_duration(src) - should_slice = duration > MIN_DURATION_FOR_SLICE - - new_urls: List[str] = [] - - if should_slice: - # 视频较长,切片处理 - logger.info( - "[water-video] video duration {}s > {}s, slicing into {}s segments", - duration, - MIN_DURATION_FOR_SLICE, - SLICE_DURATION, - ) - - slice_files, slice_dir = slice_video(src, SLICE_DURATION) - - if len(slice_files) > 1: - # 发布每个切片 - for i, slice_file in enumerate(slice_files): - slice_basename = _slice_media_basename(base_basename, i) - dst = settings.media_root / slice_basename - - # 检查是否需要重新发布 - need_publish = True - if dst.is_file(): - try: - if dst.stat().st_mtime >= slice_file.stat().st_mtime: - need_publish = False - except OSError: - pass - - if need_publish: - url = await _publish_video(slice_file, dst, settings) - else: - url = _public_media_url(settings, dst.name) - - if url: - new_urls.append(url) - - logger.info( - "[water-video] prepared {} slices for {}", - len(new_urls), - src.name, - ) - - # 更新全局缓存 - _global_slice_urls = new_urls - _last_source_mtime = current_mtime - return new_urls - - # 视频较短,只保留完整视频 - dst = settings.media_root / base_basename - url = await _publish_video(src, dst, settings) - - if url: - new_urls = [url] - _global_slice_urls = new_urls - _last_source_mtime = current_mtime - return new_urls - - return [] - - -async def get_water_video_public_url(settings: Settings, client_id: str = DEFAULT_CLIENT_ID) -> str: - """转码并发布到 MEDIA_ROOT 后返回绝对 URL;无可用源且无已发布文件时返回空串。 - - 如果视频较长被切片,会根据 health/result 端点的状态返回对应的切片URL。 - - 对齐机制:查询 health 数据库记录的切片索引,确保与 health/result 端点对齐。 - 只有当 health/result 返回了第 N 个切片的行为结果后,本端点才会返回第 N 个切片的视频。 - - Args: - settings: 应用配置 - client_id: 客户端标识,默认为 "default" - - Returns: - 视频URL,失败返回空串 - """ - from app.db import get_last_health_slice_index - - settings.media_root.mkdir(parents=True, exist_ok=True) - - async with _publish_lock: - # 确保切片已准备好 - slice_urls = await _prepare_slices(settings) - - if not slice_urls: - # 没有切片,尝试返回已发布的文件 - basename = _safe_water_media_basename(settings.biomass_water_video_media_name) - dst = settings.media_root / basename - if dst.is_file(): - return _public_media_url(settings, dst.name) - return "" - - # 查询 health 端点上次返回的切片索引 - target_slice_idx = get_last_health_slice_index(client_id) - - if target_slice_idx >= 0 and target_slice_idx < len(slice_urls): - # 返回与 health 结果对齐的切片 - logger.debug( - "[water-video] client_id={} aligned to health slice {}/{}", - client_id, - target_slice_idx, - len(slice_urls), - ) - return slice_urls[target_slice_idx] - else: - # 没有对齐的 health 结果,返回空(等待 health/result 先被调用) - logger.debug( - "[water-video] client_id={} no health index yet, returning empty", - client_id, - ) - return "" + return peek_last_delivered_health_video_url(settings, client_id) diff --git a/fish_api/app/services/zed_svo_record.py b/fish_api/app/services/zed_svo_record.py index b8febd6..ff4a173 100644 --- a/fish_api/app/services/zed_svo_record.py +++ b/fish_api/app/services/zed_svo_record.py @@ -50,9 +50,9 @@ def run_zed_svo_record_loop( zed = sl.Camera() init = sl.InitParameters() - init.camera_resolution = sl.RESOLUTION.HD720 + init.camera_resolution = sl.RESOLUTION.HD1200 init.camera_fps = 30 - init.depth_mode = sl.DEPTH_MODE.ULTRA + init.depth_mode = sl.DEPTH_MODE.NEURAL init.coordinate_units = sl.UNIT.MILLIMETER if settings.zed_serial_number is not None: init.set_from_serial_number(settings.zed_serial_number) diff --git a/fish_api/app/settings.py b/fish_api/app/settings.py index d133d3a..ca86e0d 100644 --- a/fish_api/app/settings.py +++ b/fish_api/app/settings.py @@ -255,12 +255,43 @@ class Settings(BaseSettings): #: 优先作为「声呐视频」源文件;未设置时在 BIOMASS_SONAR_VIDEO_DIR 取最新 .mp4。**BIOMASS_SONAR_VIDEO_SOURCE** biomass_sonar_video_source: Optional[Path] = None - #: 声呐 MP4 目录(与 ACTION_WATCH_DIR 独立,避免与水面视频混用)。**BIOMASS_SONAR_VIDEO_DIR** + #: 声呐 MP4 目录(与 ACTION_WATCH_DIR 独立,避免与水面视频混用)。须**绝对路径**(例如 ``/home/you/shared``);``/shared`` 与 ``~/shared`` 不是同一目录。**BIOMASS_SONAR_VIDEO_DIR** biomass_sonar_video_dir: Optional[Path] = None #: 是否在 SONAR_VIDEO_DIR 中递归查找 .mp4。**BIOMASS_SONAR_VIDEO_RECURSIVE** biomass_sonar_video_recursive: bool = False + #: 后台轮询间隔(秒):扫描 BIOMASS_SONAR_VIDEO_DIR 并处理已录完的 MP4。**BIOMASS_SONAR_VIDEO_POLL_INTERVAL** + biomass_sonar_video_poll_interval: float = Field( + default=5.0, + ge=1.0, + validation_alias=AliasChoices( + "BIOMASS_SONAR_VIDEO_POLL_INTERVAL", "biomass_sonar_video_poll_interval" + ), + ) + #: ffmpeg ``-sseof`` 取源码最后 N 秒再送光流/转码(避免处理整段长录像)。**BIOMASS_SONAR_VIDEO_SLICE_SEC** + biomass_sonar_video_slice_sec: float = Field( + default=60.0, + ge=1.0, + validation_alias=AliasChoices( + "BIOMASS_SONAR_VIDEO_SLICE_SEC", "biomass_sonar_video_slice_sec" + ), + ) #: 发布到 MEDIA_ROOT 的 H.264 文件名。**BIOMASS_SONAR_VIDEO_MEDIA_NAME** biomass_sonar_video_media_name: str = "biomass_sonar.mp4" + #: 为 True 时声呐发布管线在 ffmpeg 转 H.264 之前先做 Farneback 光流 overlay(与 ``/sonar/video/`` 返回的仍是同一 ``video_path`` 字段)。**BIOMASS_SONAR_OPTICAL_FLOW** + biomass_sonar_optical_flow: bool = Field( + default=True, + validation_alias=AliasChoices( + "BIOMASS_SONAR_OPTICAL_FLOW", "biomass_sonar_optical_flow" + ), + ) + #: 光流处理前缩放帧(例如 0.5 减轻 Jetson 负载)。**BIOMASS_SONAR_OPTICAL_FLOW_RESIZE** + biomass_sonar_optical_flow_resize: float = Field( + default=1.0, + gt=0, + validation_alias=AliasChoices( + "BIOMASS_SONAR_OPTICAL_FLOW_RESIZE", "biomass_sonar_optical_flow_resize" + ), + ) #: 非空时后台持续扫描该目录中的新 .svo2 并跑 FishMeasure(与 ingest 共用 SQLite 最新结果) measure_watch_dir: Optional[Path] = None diff --git a/fish_api/app/state.py b/fish_api/app/state.py index 346ff97..d93e3c2 100644 --- a/fish_api/app/state.py +++ b/fish_api/app/state.py @@ -28,6 +28,7 @@ class HealthSnapshot: updated_at: Optional[datetime] = None error: Optional[str] = None raw_class_en: str = "" + video_path: str = "" @dataclass diff --git a/fish_api/pyproject.toml b/fish_api/pyproject.toml index 2de57e5..eec5c3e 100644 --- a/fish_api/pyproject.toml +++ b/fish_api/pyproject.toml @@ -7,11 +7,13 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115.0", "loguru>=0.7.0", + "numpy>=1.26.0", + "opencv-python-headless>=4.8.0", "uvicorn[standard]>=0.32.0", "pydantic-settings>=2.6.0", ] -[dependency-groups] +[project.optional-dependencies] dev = ["httpx>=0.28.1"] [project.scripts] diff --git a/fish_api/requirements.txt b/fish_api/requirements.txt new file mode 100644 index 0000000..ab6fc37 --- /dev/null +++ b/fish_api/requirements.txt @@ -0,0 +1,9 @@ +# Install: cd fish_api && python3 -m pip install -r requirements.txt +# Or editable: python3 -m pip install -e . +# (Keep in sync with pyproject.toml [project].dependencies) +fastapi>=0.115.0 +loguru>=0.7.0 +numpy>=1.26.0 +opencv-python-headless>=4.8.0 +uvicorn[standard]>=0.32.0 +pydantic-settings>=2.6.0 diff --git a/fish_api/start_fresh.sh b/fish_api/start_fresh.sh index bb1fb83..066a0f8 100644 --- a/fish_api/start_fresh.sh +++ b/fish_api/start_fresh.sh @@ -16,7 +16,7 @@ # CLEAR_MEDIA=1 bash fish_api/start_fresh.sh # CLEAR_STREAM_TMP=1 bash fish_api/start_fresh.sh # -# 首次使用请先:cd fish_api && uv sync +# 首次使用请先:cd fish_api && python3 -m pip install -e . # set -euo pipefail @@ -27,19 +27,11 @@ cd "$DIR" # 未设置时由 app/settings.py 的默认值或 .env 中的 PUBLIC_BASE_URL 决定。 unset PYTHON_FISH_MEASURE PYTHON_FISH_ACTION 2>/dev/null || true -if command -v uv >/dev/null 2>&1; then - PY=(uv run python) -else - PY=(python3) -fi +PY="${PYTHON:-python3.8}" -"${PY[@]}" -m app.prestart_fresh +"$PY" -m app.prestart_fresh PORT="${PORT:-8000}" HOST="${HOST:-0.0.0.0}" -if command -v uv >/dev/null 2>&1; then - exec uv run uvicorn app.main:app --host "$HOST" --port "$PORT" -else - exec uvicorn app.main:app --host "$HOST" --port "$PORT" -fi +exec uvicorn app.main:app --host "$HOST" --port "$PORT" diff --git a/fish_api/uv.lock b/fish_api/uv.lock deleted file mode 100644 index ecf16ba..0000000 --- a/fish_api/uv.lock +++ /dev/null @@ -1,619 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "certifi" -version = "2026.2.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, -] - -[[package]] -name = "click" -version = "8.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "fastapi" -version = "0.135.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, -] - -[[package]] -name = "fish-api" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "fastapi" }, - { name = "pydantic-settings" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "pydantic-settings", specifier = ">=2.6.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "httpx", specifier = ">=0.28.1" }] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "starlette" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] diff --git a/record.sh b/record.sh new file mode 100755 index 0000000..78c5473 --- /dev/null +++ b/record.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# ZED 录制统一入口。 +# 用法(在仓库根目录): +# ./record.sh # 本地前台录制,Ctrl+C 停止 +# ./record.sh --remote # HTTP 请求已运行的 fish_api 启录 +# ./record.sh --stop # HTTP 停止录制 +# ./record.sh --status # HTTP 查询录制状态 +# ./record.sh --segment-sec 300 # 自定义分段时长 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT/fish_api" + +PY="${PYTHON:-python3.8}" + +# Translate convenience flags into subcommands +for arg in "$@"; do + case "$arg" in + --stop) shift; exec "$PY" -m app.zed_record_cli stop "$@" ;; + --status) shift; exec "$PY" -m app.zed_record_cli status "$@" ;; + esac +done + +exec "$PY" -m app.zed_record_cli start "$@" diff --git a/scripts/measure_debug.sh b/scripts/measure_debug.sh index c94da49..a30a931 100755 --- a/scripts/measure_debug.sh +++ b/scripts/measure_debug.sh @@ -5,7 +5,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT/fish_api" -if command -v uv >/dev/null 2>&1; then - exec uv run python -m app.measure_debug_cli "$@" -fi -exec "${PYTHON:-python3}" -m app.measure_debug_cli "$@" +exec "${PYTHON:-python3.8}" -m app.measure_debug_cli "$@" diff --git a/start_recording.sh b/start_recording.sh index 6a82f78..a9c43a1 100755 --- a/start_recording.sh +++ b/start_recording.sh @@ -4,16 +4,11 @@ # ./start_recording.sh # ./start_recording.sh --remote # ./start_recording.sh --segment-sec 300 -# 依赖:fish_api 已 pip install -e .;直连相机时需 pyzed。 +# 依赖:fish_api 已 python3 -m pip install -e .;直连相机时需 pyzed。 set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT/fish_api" -if command -v uv >/dev/null 2>&1; then - PY=(uv run python) -else - PY=(python3) -fi - -exec "${PY[@]}" -m app.zed_record_cli start "$@" +PY="${PYTHON:-python3.8}" +exec "$PY" -m app.zed_record_cli start "$@" diff --git a/stop_recording.sh b/stop_recording.sh index 87809f3..9cbcd79 100644 --- a/stop_recording.sh +++ b/stop_recording.sh @@ -7,10 +7,5 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT/fish_api" -if command -v uv >/dev/null 2>&1; then - PY=(uv run python) -else - PY=(python3) -fi - -exec "${PY[@]}" -m app.zed_record_cli stop "$@" +PY="${PYTHON:-python3.8}" +exec "$PY" -m app.zed_record_cli stop "$@"