This commit is contained in:
2026-03-23 09:58:40 -03:00
parent 9c9c7dff09
commit 8186bb5fe6
40 changed files with 3996 additions and 17 deletions

72
core/api/detect_sse.py Normal file
View File

@@ -0,0 +1,72 @@
"""
SSE endpoint for detection pipeline events.
Uses Redis as the event bus between pipeline workers and the SSE stream.
Mirrors chunker_sse.py but polls detect_events:{job_id}.
GET /detect/stream/{job_id} → text/event-stream
"""
import asyncio
import json
import logging
import time
from typing import AsyncGenerator
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from core.events import poll_events
from detect.events import DETECT_EVENTS_PREFIX, TERMINAL_EVENTS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
async def _event_generator(job_id: str) -> AsyncGenerator[str, None]:
cursor = 0
timeout = time.monotonic() + 3600 # 1 hour max (detection jobs are long)
while time.monotonic() < timeout:
events, cursor = poll_events(job_id, cursor, prefix=DETECT_EVENTS_PREFIX)
if not events:
yield f"event: waiting\ndata: {json.dumps({'job_id': job_id})}\n\n"
await asyncio.sleep(0.1)
continue
for data in events:
event_type = data.pop("event", "update")
payload = {**data, "job_id": job_id}
yield f"event: {event_type}\ndata: {json.dumps(payload)}\n\n"
if event_type in TERMINAL_EVENTS:
yield f"event: done\ndata: {json.dumps({'job_id': job_id})}\n\n"
return
await asyncio.sleep(0.05)
yield f"event: timeout\ndata: {json.dumps({'job_id': job_id})}\n\n"
@router.get("/stream/{job_id}")
async def stream_detect_job(job_id: str):
"""
SSE stream for a detection pipeline job.
The UI connects via native EventSource:
const es = new EventSource('/api/detect/stream/<job_id>');
es.addEventListener('graph_update', (e) => { ... });
es.addEventListener('detection', (e) => { ... });
"""
return StreamingResponse(
_event_generator(job_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -24,6 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
from strawberry.fastapi import GraphQLRouter from strawberry.fastapi import GraphQLRouter
from core.api.chunker_sse import router as chunker_router from core.api.chunker_sse import router as chunker_router
from core.api.detect_sse import router as detect_router
from core.api.graphql import schema as graphql_schema from core.api.graphql import schema as graphql_schema
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "") CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
@@ -52,6 +53,9 @@ app.include_router(graphql_router, prefix="/graphql")
# Chunker SSE # Chunker SSE
app.include_router(chunker_router) app.include_router(chunker_router)
# Detection SSE
app.include_router(detect_router)
@app.get("/") @app.get("/")
def root(): def root():

View File

@@ -17,19 +17,23 @@ def _get_redis():
return redis.from_url(REDIS_URL, decode_responses=True) return redis.from_url(REDIS_URL, decode_responses=True)
def push_event(job_id: str, event_type: str, data: dict) -> None: def push_event(
job_id: str, event_type: str, data: dict, prefix: str = "chunk_events"
) -> None:
"""Push an event to the Redis list for a job.""" """Push an event to the Redis list for a job."""
r = _get_redis() r = _get_redis()
key = f"chunk_events:{job_id}" key = f"{prefix}:{job_id}"
event = json.dumps({"event": event_type, **data}) event = json.dumps({"event": event_type, **data})
r.rpush(key, event) r.rpush(key, event)
r.expire(key, 3600) r.expire(key, 3600)
def poll_events(job_id: str, cursor: int = 0) -> tuple[list[dict], int]: def poll_events(
job_id: str, cursor: int = 0, prefix: str = "chunk_events"
) -> tuple[list[dict], int]:
"""Poll new events from Redis. Returns (events, new_cursor).""" """Poll new events from Redis. Returns (events, new_cursor)."""
r = _get_redis() r = _get_redis()
key = f"chunk_events:{job_id}" key = f"{prefix}:{job_id}"
raw_events = r.lrange(key, cursor, -1) raw_events = r.lrange(key, cursor, -1)
parsed = [] parsed = []
for raw in raw_events: for raw in raw_events:

View File

@@ -20,6 +20,16 @@
"target": "protobuf", "target": "protobuf",
"output": "core/rpc/protos/worker.proto", "output": "core/rpc/protos/worker.proto",
"include": ["grpc"] "include": ["grpc"]
},
{
"target": "pydantic",
"output": "detect/sse_contract.py",
"include": ["detect_views"]
},
{
"target": "typescript",
"output": "ui/detection-app/src/types/sse-contract.ts",
"include": ["detect_views"]
} }
] ]
} }

View File

@@ -28,6 +28,7 @@ from .grpc import (
from .jobs import ChunkJob, ChunkJobStatus, JobStatus, TranscodeJob from .jobs import ChunkJob, ChunkJobStatus, JobStatus, TranscodeJob
from .media import AssetStatus, MediaAsset from .media import AssetStatus, MediaAsset
from .presets import BUILTIN_PRESETS, TranscodePreset from .presets import BUILTIN_PRESETS, TranscodePreset
from .detect import DETECT_VIEWS # noqa: F401 — discovered by modelgen generic loader
from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent
# Core domain models - generates Django, Pydantic, TypeScript # Core domain models - generates Django, Pydantic, TypeScript
@@ -50,6 +51,7 @@ ENUMS = [AssetStatus, JobStatus, ChunkJobStatus]
# View/event models - generates TypeScript for UI consumption # View/event models - generates TypeScript for UI consumption
VIEWS = [ChunkEvent, WorkerEvent, PipelineStats, ChunkOutputFile] VIEWS = [ChunkEvent, WorkerEvent, PipelineStats, ChunkOutputFile]
# gRPC messages - generates Proto # gRPC messages - generates Proto
GRPC_MESSAGES = [ GRPC_MESSAGES = [
JobRequest, JobRequest,

View File

@@ -0,0 +1,166 @@
"""
Detection Pipeline Schema Definitions
Source of truth for all detection SSE events and wire-format models.
Generates: Pydantic (detect/sse_contract.py), TypeScript (ui/detection-app/src/types/sse-contract.ts)
Pipeline-internal models that never cross the wire (e.g. Frame with np.ndarray)
live in detect/models.py and are NOT generated.
"""
from dataclasses import dataclass, field
from typing import List, Literal, Optional
# --- Enums as Literal unions (wire format, not Python Enum) ---
NodeStatus = Literal["idle", "processing", "completed", "error"]
DetectionSource = Literal["ocr", "local_vlm", "cloud_llm", "logo_match", "auxiliary"]
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
# --- Nested components ---
@dataclass
class GraphNode:
"""A pipeline stage node."""
id: str
status: str = "idle" # NodeStatus
items_in: int = 0
items_out: int = 0
@dataclass
class GraphEdge:
"""An edge between pipeline stages."""
source: str
target: str
throughput: int = 0
@dataclass
class BoundingBoxEvent:
"""Bounding box in SSE event payloads."""
x: int
y: int
w: int
h: int
confidence: float
label: str
resolved_brand: Optional[str] = None
source: Optional[str] = None
@dataclass
class BrandSummary:
"""Per-brand stats in the final report."""
brand: str
total_appearances: int = 0
total_screen_time: float = 0.0
avg_confidence: float = 0.0
first_seen: float = 0.0
last_seen: float = 0.0
# --- SSE event payloads ---
@dataclass
class GraphUpdate:
"""Pipeline node state transition. SSE event: graph_update"""
nodes: List[GraphNode] = field(default_factory=list)
edges: List[GraphEdge] = field(default_factory=list)
active_path: List[str] = field(default_factory=list)
@dataclass
class StatsUpdate:
"""Funnel statistics snapshot. SSE event: stats_update"""
frames_extracted: int = 0
frames_after_scene_filter: int = 0
regions_detected: int = 0
regions_resolved_by_ocr: int = 0
regions_escalated_to_local_vlm: int = 0
regions_escalated_to_cloud_llm: int = 0
cloud_llm_calls: int = 0
processing_time_seconds: float = 0.0
estimated_cloud_cost_usd: float = 0.0
@dataclass
class FrameUpdate:
"""Current frame being processed. SSE event: frame_update"""
frame_ref: int
timestamp: float
jpeg_b64: str
boxes: List[BoundingBoxEvent] = field(default_factory=list)
@dataclass
class Detection:
"""A confirmed brand detection. SSE event: detection"""
brand: str
timestamp: float
duration: float
confidence: float
source: str # DetectionSource
content_type: str
bbox: Optional[BoundingBoxEvent] = None
frame_ref: Optional[int] = None
@dataclass
class LogEvent:
"""Pipeline log line. SSE event: log"""
level: str # LogLevel
stage: str
msg: str
ts: str
trace_id: Optional[str] = None
@dataclass
class DetectionReportSummary:
"""Final detection report summary."""
video_source: str
content_type: str
duration_seconds: float
total_detections: int = 0
brands: List[BrandSummary] = field(default_factory=list)
stats: Optional[StatsUpdate] = None
@dataclass
class JobComplete:
"""Final report when pipeline finishes. SSE event: job_complete"""
job_id: str
report: Optional[DetectionReportSummary] = None
# --- Export lists for modelgen ---
DETECT_VIEWS = [
GraphNode,
GraphEdge,
BoundingBoxEvent,
BrandSummary,
GraphUpdate,
StatsUpdate,
FrameUpdate,
Detection,
LogEvent,
DetectionReportSummary,
JobComplete,
]

View File

@@ -190,6 +190,20 @@ services:
- ../ui/chunker/vite.config.ts:/app/vite.config.ts - ../ui/chunker/vite.config.ts:/app/vite.config.ts
- ../ui/common:/common - ../ui/common:/common
detection:
build:
context: ../ui/detection-app
dockerfile: Dockerfile
ports:
- "5175:5175"
environment:
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-}
volumes:
- ../ui/detection-app/src:/app/src
- ../ui/detection-app/vite.config.ts:/app/vite.config.ts
- ../ui/detection-app/index.html:/app/index.html
- ../ui/framework:/app/node_modules/mpr-ui-framework
volumes: volumes:
postgres-data: postgres-data:
redis-data: redis-data:

View File

@@ -97,6 +97,11 @@
<div class="card-title">Chunker</div> <div class="card-title">Chunker</div>
<div class="card-desc">Split media into segments, pipeline visualization</div> <div class="card-desc">Split media into segments, pipeline visualization</div>
</a> </a>
<a class="card" href="/detection/">
<div class="card-icon">&#9678;</div>
<div class="card-title">Detection</div>
<div class="card-desc">Media brand detection, realtime observability</div>
</a>
</div> </div>
<div class="links"> <div class="links">
<a href="/admin/">Admin</a> <a href="/admin/">Admin</a>

View File

@@ -29,6 +29,10 @@ http {
server minio:9000; server minio:9000;
} }
upstream detection {
server detection:5175;
}
upstream envoy { upstream envoy {
server envoy:8090; server envoy:8090;
} }
@@ -76,6 +80,35 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
# Detection UI
location /detection/ {
proxy_pass http://detection;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Vite HMR websocket (detection)
location /detection/@vite {
proxy_pass http://detection;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# SSE streams — disable buffering for realtime delivery
location /api/detect/stream/ {
proxy_pass http://fastapi/detect/stream/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}
# Chunker UI # Chunker UI
location /chunker/ { location /chunker/ {
proxy_pass http://chunker; proxy_pass http://chunker;

View File

@@ -3,14 +3,15 @@
# Usage: ./run.sh [OPTIONS] [docker-compose args] # Usage: ./run.sh [OPTIONS] [docker-compose args]
# #
# Options: # Options:
# -f, --foreground Run in foreground (don't detach) # -d, --detach Run detached (background)
# --build Rebuild images before starting # --build Rebuild images before starting
# stop Stop all services
# #
# Examples: # Examples:
# ./run.sh # Start detached # ./run.sh # Start attached (see logs)
# ./run.sh -f # Start in foreground (see logs) # ./run.sh -d # Start detached
# ./run.sh --build # Rebuild and start # ./run.sh --build # Rebuild and start
# ./run.sh logs -f # Follow logs # ./run.sh stop # Stop all services
set -e set -e
@@ -35,19 +36,23 @@ if ! grep -q "mpr.local.ar" /etc/hosts 2>/dev/null; then
fi fi
# Parse options # Parse options
DETACH="-d" DETACH=""
BUILD="" BUILD=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
-f|--foreground) -d|--detach)
DETACH="" DETACH="-d"
shift shift
;; ;;
--build) --build)
BUILD="--build" BUILD="--build"
shift shift
;; ;;
stop)
docker compose down
exit $?
;;
*) *)
# Pass remaining args to docker compose # Pass remaining args to docker compose
docker compose "$@" docker compose "$@"
@@ -56,5 +61,5 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Default: up with options # Default: up attached
docker compose up $DETACH $BUILD docker compose up $DETACH $BUILD

0
detect/__init__.py Normal file
View File

41
detect/events.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Detection pipeline event helpers.
Non-generated runtime code for pushing SSE events.
The event payload types are in sse_contract.py (generated by modelgen).
"""
from pydantic import BaseModel
from core.events import push_event
DETECT_EVENTS_PREFIX = "detect_events"
# SSE event type names
EVENT_GRAPH_UPDATE = "graph_update"
EVENT_STATS_UPDATE = "stats_update"
EVENT_FRAME_UPDATE = "frame_update"
EVENT_DETECTION = "detection"
EVENT_LOG = "log"
EVENT_JOB_COMPLETE = "job_complete"
ALL_EVENT_TYPES = [
EVENT_GRAPH_UPDATE,
EVENT_STATS_UPDATE,
EVENT_FRAME_UPDATE,
EVENT_DETECTION,
EVENT_LOG,
EVENT_JOB_COMPLETE,
]
TERMINAL_EVENTS = [EVENT_JOB_COMPLETE]
def push_detect_event(job_id: str, event_type: str, data: BaseModel) -> None:
"""Push a typed detection event to Redis."""
push_event(
job_id=job_id,
event_type=event_type,
data=data.model_dump(mode="json"),
prefix=DETECT_EVENTS_PREFIX,
)

86
detect/models.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Core domain models for the detection pipeline.
These are pipeline-internal models — the data structures that flow
between LangGraph nodes. SSE event payloads (sse_contract.py) are
derived from these when emitting to the UI.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
@dataclass
class Frame:
sequence: int
chunk_id: int
timestamp: float # position in video (seconds)
image: np.ndarray
perceptual_hash: str = ""
@dataclass
class BoundingBox:
x: int
y: int
w: int
h: int
confidence: float
label: str
@dataclass
class TextCandidate:
frame: Frame
bbox: BoundingBox
text: str
ocr_confidence: float
@dataclass
class BrandDetection:
brand: str
timestamp: float
duration: float
confidence: float
source: Literal["ocr", "local_vlm", "cloud_llm", "logo_match", "auxiliary"]
bbox: BoundingBox | None = None
frame_ref: int | None = None
content_type: str = ""
@dataclass
class BrandStats:
total_appearances: int = 0
total_screen_time: float = 0.0
avg_confidence: float = 0.0
first_seen: float = 0.0
last_seen: float = 0.0
@dataclass
class PipelineStats:
frames_extracted: int = 0
frames_after_scene_filter: int = 0
regions_detected: int = 0
regions_resolved_by_ocr: int = 0
regions_escalated_to_local_vlm: int = 0
regions_escalated_to_cloud_llm: int = 0
auxiliary_detections: int = 0
cloud_llm_calls: int = 0
processing_time_seconds: float = 0.0
estimated_cloud_cost_usd: float = 0.0
@dataclass
class DetectionReport:
video_source: str
content_type: str
duration_seconds: float
brands: dict[str, BrandStats] = field(default_factory=dict)
timeline: list[BrandDetection] = field(default_factory=list)
pipeline_stats: PipelineStats = field(default_factory=PipelineStats)

103
detect/sse_contract.py Normal file
View File

@@ -0,0 +1,103 @@
"""
Pydantic Models - GENERATED FILE
Do not edit directly. Regenerate using modelgen.
"""
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class GraphNode(BaseModel):
"""A pipeline stage node."""
id: str
status: str = "idle"
items_in: int = 0
items_out: int = 0
class GraphEdge(BaseModel):
"""An edge between pipeline stages."""
source: str
target: str
throughput: int = 0
class BoundingBoxEvent(BaseModel):
"""Bounding box in SSE event payloads."""
x: int
y: int
w: int
h: int
confidence: float
label: str
resolved_brand: Optional[str] = None
source: Optional[str] = None
class BrandSummary(BaseModel):
"""Per-brand stats in the final report."""
brand: str
total_appearances: int = 0
total_screen_time: float = 0.0
avg_confidence: float = 0.0
first_seen: float = 0.0
last_seen: float = 0.0
class GraphUpdate(BaseModel):
"""Pipeline node state transition. SSE event: graph_update"""
nodes: List[GraphNode] = Field(default_factory=list)
edges: List[GraphEdge] = Field(default_factory=list)
active_path: List[str] = Field(default_factory=list)
class StatsUpdate(BaseModel):
"""Funnel statistics snapshot. SSE event: stats_update"""
frames_extracted: int = 0
frames_after_scene_filter: int = 0
regions_detected: int = 0
regions_resolved_by_ocr: int = 0
regions_escalated_to_local_vlm: int = 0
regions_escalated_to_cloud_llm: int = 0
cloud_llm_calls: int = 0
processing_time_seconds: float = 0.0
estimated_cloud_cost_usd: float = 0.0
class FrameUpdate(BaseModel):
"""Current frame being processed. SSE event: frame_update"""
frame_ref: int
timestamp: float
jpeg_b64: str
boxes: List[BoundingBoxEvent] = Field(default_factory=list)
class Detection(BaseModel):
"""A confirmed brand detection. SSE event: detection"""
brand: str
timestamp: float
duration: float
confidence: float
source: str
content_type: str
bbox: Optional[BoundingBoxEvent] = None
frame_ref: Optional[int] = None
class LogEvent(BaseModel):
"""Pipeline log line. SSE event: log"""
level: str
stage: str
msg: str
ts: str
trace_id: Optional[str] = None
class DetectionReportSummary(BaseModel):
"""Final detection report summary."""
video_source: str
content_type: str
duration_seconds: float
total_detections: int = 0
brands: List[BrandSummary] = Field(default_factory=list)
stats: Optional[StatsUpdate] = None
class JobComplete(BaseModel):
"""Final report when pipeline finishes. SSE event: job_complete"""
job_id: str
report: Optional[DetectionReportSummary] = None

View File

@@ -12,7 +12,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, List, get_type_hints from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, get_type_name, unwrap_optional from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
from ..types import PYDANTIC_RESOLVERS from ..types import PYDANTIC_RESOLVERS
from .base import BaseGenerator from .base import BaseGenerator
@@ -54,8 +54,9 @@ class PydanticGenerator(BaseGenerator):
if hasattr(models, "get_shared_component"): if hasattr(models, "get_shared_component"):
content = self._generate_from_config(models) content = self._generate_from_config(models)
elif hasattr(models, "models"): elif hasattr(models, "models"):
all_models = models.models + getattr(models, "api_models", [])
content = self._generate_from_definitions( content = self._generate_from_definitions(
models.models, getattr(models, "enums", []) all_models, getattr(models, "enums", [])
) )
elif isinstance(models, tuple): elif isinstance(models, tuple):
content = self._generate_from_definitions(models[0], models[1]) content = self._generate_from_definitions(models[0], models[1])
@@ -307,6 +308,11 @@ class PydanticGenerator(BaseGenerator):
if isinstance(base, type) and issubclass(base, Enum) if isinstance(base, type) and issubclass(base, Enum)
else None else None
) )
or (
PYDANTIC_RESOLVERS["dataclass"]
if is_dataclass_type(base)
else None
)
) )
result = resolver(base) if resolver else "str" result = resolver(base) if resolver else "str"
return f"Optional[{result}]" if optional else result return f"Optional[{result}]" if optional else result

View File

@@ -8,7 +8,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, List, get_type_hints from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, get_type_name, unwrap_optional from ..helpers import get_origin_name, get_type_name, is_dataclass_type, unwrap_optional
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
from ..types import TS_RESOLVERS from ..types import TS_RESOLVERS
from .base import BaseGenerator from .base import BaseGenerator
@@ -139,6 +139,11 @@ class TypeScriptGenerator(BaseGenerator):
if isinstance(base, type) and issubclass(base, Enum) if isinstance(base, type) and issubclass(base, Enum)
else None else None
) )
or (
TS_RESOLVERS["dataclass"]
if is_dataclass_type(base)
else None
)
) )
result = resolver(base) if resolver else "string" result = resolver(base) if resolver else "string"

View File

@@ -44,6 +44,17 @@ def get_list_inner(type_hint: Any) -> str:
return "str" return "str"
def is_dataclass_type(type_hint: Any) -> bool:
"""Check if type is a dataclass (nested model reference)."""
return isinstance(type_hint, type) and dc.is_dataclass(type_hint)
def get_list_inner_type(type_hint: Any) -> Any:
"""Get the raw inner type of List[T] (not stringified)."""
args = get_args(type_hint)
return args[0] if args else None
def get_field_default(field: dc.Field) -> Any: def get_field_default(field: dc.Field) -> Any:
"""Get default value from dataclass field.""" """Get default value from dataclass field."""
if field.default is not dc.MISSING: if field.default is not dc.MISSING:

View File

@@ -123,6 +123,20 @@ class SchemaLoader:
methods=grpc_service.get("methods", []), methods=grpc_service.get("methods", []),
) )
# Generic group loader: any include group not handled above
# is looked up as UPPER_CASE attribute on the module.
# e.g. include "detect_views" → module.DETECT_VIEWS
if include:
known_groups = {"dataclasses", "enums", "api", "views", "grpc"}
for group in include - known_groups:
attr_name = group.upper()
items = getattr(module, attr_name, [])
for cls in items:
if isinstance(cls, type) and dc.is_dataclass(cls):
self.api_models.append(self._parse_dataclass(cls))
elif isinstance(cls, type) and issubclass(cls, Enum):
self.enums.append(self._parse_enum(cls))
return self return self
def _import_module(self, path: Path): def _import_module(self, path: Path):

View File

@@ -5,6 +5,7 @@ Type mappings for each output format.
Used by generators to convert Python types to target framework types. Used by generators to convert Python types to target framework types.
""" """
import dataclasses as dc
from typing import Any, Callable, get_args from typing import Any, Callable, get_args
# ============================================================================= # =============================================================================
@@ -39,8 +40,12 @@ DJANGO_SPECIAL: dict[str, str] = {
def _get_list_inner(type_hint: Any) -> str: def _get_list_inner(type_hint: Any) -> str:
"""Get inner type of List[T] for Pydantic.""" """Get inner type of List[T] for Pydantic."""
args = get_args(type_hint) args = get_args(type_hint)
if args and args[0] in (str, int, float, bool): if args:
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]] inner = args[0]
if inner in (str, int, float, bool):
return {str: "str", int: "int", float: "float", bool: "bool"}[inner]
if isinstance(inner, type) and dc.is_dataclass(inner):
return inner.__name__
return "str" return "str"
@@ -54,6 +59,7 @@ PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
"dict": lambda _: "Dict[str, Any]", "dict": lambda _: "Dict[str, Any]",
"list": lambda base: f"List[{_get_list_inner(base)}]", "list": lambda base: f"List[{_get_list_inner(base)}]",
"enum": lambda base: base.__name__, "enum": lambda base: base.__name__,
"dataclass": lambda base: base.__name__,
} }
# ============================================================================= # =============================================================================
@@ -72,6 +78,8 @@ def _resolve_ts_list(base: Any) -> str:
return "number[]" return "number[]"
elif inner is bool: elif inner is bool:
return "boolean[]" return "boolean[]"
elif isinstance(inner, type) and dc.is_dataclass(inner):
return f"{inner.__name__}[]"
return "string[]" return "string[]"
@@ -85,6 +93,7 @@ TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
"dict": lambda _: "Record<string, unknown>", "dict": lambda _: "Record<string, unknown>",
"list": _resolve_ts_list, "list": _resolve_ts_list,
"enum": lambda base: base.__name__, "enum": lambda base: base.__name__,
"dataclass": lambda base: base.__name__,
} }
# ============================================================================= # =============================================================================

0
tests/detect/__init__.py Normal file
View File

View File

@@ -0,0 +1,114 @@
"""Round-trip serialization tests for SSE contract models."""
from detect.sse_contract import (
BoundingBoxEvent,
BrandSummary,
Detection,
DetectionReportSummary,
FrameUpdate,
GraphEdge,
GraphNode,
GraphUpdate,
JobComplete,
LogEvent,
StatsUpdate,
)
def test_graph_update_roundtrip():
obj = GraphUpdate(
nodes=[
GraphNode(id="extract_frames", status="completed", items_in=100, items_out=100),
GraphNode(id="filter_scenes", status="processing", items_in=100, items_out=0),
],
edges=[GraphEdge(source="extract_frames", target="filter_scenes", throughput=50)],
active_path=["filter_scenes"],
)
data = obj.model_dump(mode="json")
restored = GraphUpdate.model_validate(data)
assert restored == obj
def test_stats_update_defaults():
obj = StatsUpdate()
data = obj.model_dump(mode="json")
assert data["frames_extracted"] == 0
assert data["estimated_cloud_cost_usd"] == 0.0
restored = StatsUpdate.model_validate(data)
assert restored == obj
def test_frame_update_with_boxes():
obj = FrameUpdate(
frame_ref=42,
timestamp=71.5,
jpeg_b64="abc123==",
boxes=[
BoundingBoxEvent(x=10, y=20, w=100, h=50, confidence=0.91, label="text_region",
resolved_brand="Adidas", source="ocr"),
],
)
data = obj.model_dump(mode="json")
restored = FrameUpdate.model_validate(data)
assert restored.boxes[0].resolved_brand == "Adidas"
def test_detection():
obj = Detection(
brand="Emirates",
timestamp=68.5,
duration=3.0,
confidence=0.93,
source="ocr",
content_type="soccer_broadcast",
)
data = obj.model_dump(mode="json")
assert data["source"] == "ocr"
assert data["bbox"] is None
restored = Detection.model_validate(data)
assert restored == obj
def test_log_event():
obj = LogEvent(
level="INFO",
stage="BrandResolver",
msg="Exact match: Emirates",
trace_id="lf-abc123",
ts="2024-03-01T12:00:01Z",
)
data = obj.model_dump(mode="json")
restored = LogEvent.model_validate(data)
assert restored.trace_id == "lf-abc123"
def test_job_complete():
obj = JobComplete(
job_id="test-123",
report=DetectionReportSummary(
video_source="match.mp4",
content_type="soccer_broadcast",
duration_seconds=5400.0,
total_detections=142,
brands=[
BrandSummary(
brand="Adidas",
total_appearances=45,
total_screen_time=120.5,
avg_confidence=0.89,
first_seen=12.0,
last_seen=5380.0,
),
],
stats=StatsUpdate(
frames_extracted=10800,
frames_after_scene_filter=3200,
regions_detected=1500,
regions_resolved_by_ocr=1200,
),
),
)
data = obj.model_dump(mode="json")
restored = JobComplete.model_validate(data)
assert restored.report.brands[0].brand == "Adidas"
assert restored.report.stats.frames_extracted == 10800

View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json ./
RUN pnpm install
COPY . .
EXPOSE 5175
CMD ["pnpm", "dev", "--host"]

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MPR Detection</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "mpr-detection-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5",
"pinia": "^2.2",
"mpr-ui-framework": "link:../framework"
},
"devDependencies": {
"typescript": "^5.6",
"vite": "^6",
"@vitejs/plugin-vue": "^5",
"vue-tsc": "^2"
}
}

990
ui/detection-app/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,990 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
mpr-ui-framework:
specifier: link:../framework
version: link:../framework
pinia:
specifier: ^2.2
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
vue:
specifier: ^3.5
version: 3.5.30(typescript@5.9.3)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5
version: 5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))
typescript:
specifier: ^5.6
version: 5.9.3
vite:
specifier: ^6
version: 6.4.1
vue-tsc:
specifier: ^2
version: 2.2.12(typescript@5.9.3)
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@rollup/rollup-android-arm-eabi@4.60.0':
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.0':
resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.0':
resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.0':
resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.0':
resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.0':
resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.60.0':
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.60.0':
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.60.0':
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-loong64-musl@4.60.0':
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-ppc64-musl@4.60.0':
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.60.0':
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.60.0':
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.60.0':
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.60.0':
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openbsd-x64@4.60.0':
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.0':
resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.0':
resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.0':
resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.0':
resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.0':
resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
'@volar/source-map@2.4.15':
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
'@vue/compiler-core@3.5.30':
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
'@vue/compiler-dom@3.5.30':
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
'@vue/compiler-sfc@3.5.30':
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
'@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@2.2.12':
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.30':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
'@vue/runtime-core@3.5.30':
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
'@vue/runtime-dom@3.5.30':
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
'@vue/server-renderer@3.5.30':
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
peerDependencies:
vue: 3.5.30
'@vue/shared@3.5.30':
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pinia@2.3.1:
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.0:
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: '>=1.21.0'
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-tsc@2.2.12:
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.30:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.60.0':
optional: true
'@rollup/rollup-android-arm64@4.60.0':
optional: true
'@rollup/rollup-darwin-arm64@4.60.0':
optional: true
'@rollup/rollup-darwin-x64@4.60.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.0':
optional: true
'@rollup/rollup-freebsd-x64@4.60.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.0':
optional: true
'@rollup/rollup-openbsd-x64@4.60.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.0':
optional: true
'@types/estree@1.0.8': {}
'@vitejs/plugin-vue@5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))':
dependencies:
vite: 6.4.1
vue: 3.5.30(typescript@5.9.3)
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.30':
dependencies:
'@babel/parser': 7.29.2
'@vue/shared': 3.5.30
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.30':
dependencies:
'@vue/compiler-core': 3.5.30
'@vue/shared': 3.5.30
'@vue/compiler-sfc@3.5.30':
dependencies:
'@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.30
'@vue/compiler-dom': 3.5.30
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.30':
dependencies:
'@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.30
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/devtools-api@6.6.4': {}
'@vue/language-core@2.2.12(typescript@5.9.3)':
dependencies:
'@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.30
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.30
alien-signals: 1.0.13
minimatch: 9.0.9
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.9.3
'@vue/reactivity@3.5.30':
dependencies:
'@vue/shared': 3.5.30
'@vue/runtime-core@3.5.30':
dependencies:
'@vue/reactivity': 3.5.30
'@vue/shared': 3.5.30
'@vue/runtime-dom@3.5.30':
dependencies:
'@vue/reactivity': 3.5.30
'@vue/runtime-core': 3.5.30
'@vue/shared': 3.5.30
csstype: 3.2.3
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
vue: 3.5.30(typescript@5.9.3)
'@vue/shared@3.5.30': {}
alien-signals@1.0.13: {}
balanced-match@1.0.2: {}
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
csstype@3.2.3: {}
de-indent@1.0.2: {}
entities@7.0.1: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fsevents@2.3.3:
optional: true
he@1.2.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
muggle-string@0.4.1: {}
nanoid@3.3.11: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.30(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@vue/composition-api'
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.0
'@rollup/rollup-android-arm64': 4.60.0
'@rollup/rollup-darwin-arm64': 4.60.0
'@rollup/rollup-darwin-x64': 4.60.0
'@rollup/rollup-freebsd-arm64': 4.60.0
'@rollup/rollup-freebsd-x64': 4.60.0
'@rollup/rollup-linux-arm-gnueabihf': 4.60.0
'@rollup/rollup-linux-arm-musleabihf': 4.60.0
'@rollup/rollup-linux-arm64-gnu': 4.60.0
'@rollup/rollup-linux-arm64-musl': 4.60.0
'@rollup/rollup-linux-loong64-gnu': 4.60.0
'@rollup/rollup-linux-loong64-musl': 4.60.0
'@rollup/rollup-linux-ppc64-gnu': 4.60.0
'@rollup/rollup-linux-ppc64-musl': 4.60.0
'@rollup/rollup-linux-riscv64-gnu': 4.60.0
'@rollup/rollup-linux-riscv64-musl': 4.60.0
'@rollup/rollup-linux-s390x-gnu': 4.60.0
'@rollup/rollup-linux-x64-gnu': 4.60.0
'@rollup/rollup-linux-x64-musl': 4.60.0
'@rollup/rollup-openbsd-x64': 4.60.0
'@rollup/rollup-openharmony-arm64': 4.60.0
'@rollup/rollup-win32-arm64-msvc': 4.60.0
'@rollup/rollup-win32-ia32-msvc': 4.60.0
'@rollup/rollup-win32-x64-gnu': 4.60.0
'@rollup/rollup-win32-x64-msvc': 4.60.0
fsevents: 2.3.3
source-map-js@1.2.1: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
typescript@5.9.3: {}
vite@6.4.1:
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)):
dependencies:
vue: 3.5.30(typescript@5.9.3)
vue-tsc@2.2.12(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3
vue@3.5.30(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.30
'@vue/compiler-sfc': 3.5.30
'@vue/runtime-dom': 3.5.30
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
'@vue/shared': 3.5.30
optionalDependencies:
typescript: 5.9.3

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref } from 'vue'
import { SSEDataSource } from 'mpr-ui-framework'
import type { LogEvent, StatsUpdate } from './types/sse-contract'
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
const logs = ref<LogEvent[]>([])
const stats = ref<StatsUpdate | null>(null)
const status = ref('idle')
const source = new SSEDataSource({
id: 'detect-stream',
url: `/api/detect/stream/${jobId.value}`,
eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'],
})
source.on<LogEvent>('log', (e) => {
logs.value.push(e)
if (logs.value.length > 200) logs.value.shift()
})
source.on<StatsUpdate>('stats_update', (e) => {
stats.value = e
})
// Expose status reactively
const checkStatus = () => { status.value = source.status.value }
setInterval(checkStatus, 500)
source.connect()
</script>
<template>
<div class="app">
<header>
<h1>Detection Pipeline</h1>
<span class="status" :class="status">{{ status }}</span>
<span class="job-id">job: {{ jobId }}</span>
</header>
<section class="stats" v-if="stats">
<div class="stat">
<span class="label">Frames</span>
<span class="value">{{ stats.frames_extracted }}</span>
</div>
<div class="stat">
<span class="label">After filter</span>
<span class="value">{{ stats.frames_after_scene_filter }}</span>
</div>
<div class="stat">
<span class="label">Regions</span>
<span class="value">{{ stats.regions_detected }}</span>
</div>
<div class="stat">
<span class="label">OCR resolved</span>
<span class="value">{{ stats.regions_resolved_by_ocr }}</span>
</div>
<div class="stat">
<span class="label">Cloud calls</span>
<span class="value">{{ stats.cloud_llm_calls }}</span>
</div>
<div class="stat">
<span class="label">Cost</span>
<span class="value">${{ stats.estimated_cloud_cost_usd.toFixed(4) }}</span>
</div>
</section>
<section class="logs">
<h2>Log</h2>
<div class="log-scroll">
<div v-for="(log, i) in logs" :key="i" class="log-line" :class="log.level.toLowerCase()">
<span class="ts">{{ log.ts }}</span>
<span class="level">{{ log.level }}</span>
<span class="stage">{{ log.stage }}</span>
<span class="msg">{{ log.msg }}</span>
</div>
<div v-if="logs.length === 0" class="empty">Waiting for events...</div>
</div>
</section>
</div>
</template>
<style>
:root {
--bg: #0d0d0f;
--surface: #16161a;
--border: #2e2e38;
--text: #e8e8f0;
--dim: #555568;
--green: #3ecf8e;
--blue: #4f9cf9;
--amber: #f5a623;
--red: #f06565;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
header h1 { font-size: 15px; font-weight: 600; }
.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
text-transform: uppercase;
}
.status.idle { background: var(--dim); }
.status.connecting { background: var(--blue); color: #000; }
.status.live { background: var(--green); color: #000; }
.status.error { background: var(--red); color: #000; }
.job-id { color: var(--dim); font-size: 11px; margin-left: auto; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
}
.stat .label { display: block; color: var(--dim); font-size: 11px; margin-bottom: 4px; }
.stat .value { font-size: 20px; font-weight: 600; }
.logs h2 { font-size: 13px; margin-bottom: 8px; color: var(--dim); }
.log-scroll {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
max-height: 500px;
overflow-y: auto;
}
.log-line {
display: flex;
gap: 8px;
padding: 2px 0;
font-size: 12px;
line-height: 1.5;
}
.log-line .ts { color: var(--dim); min-width: 80px; }
.log-line .level { min-width: 56px; font-weight: 600; }
.log-line .stage { color: var(--blue); min-width: 120px; }
.log-line.info .level { color: var(--green); }
.log-line.warning .level { color: var(--amber); }
.log-line.error .level { color: var(--red); }
.log-line.debug .level { color: var(--dim); }
.empty { color: var(--dim); padding: 20px; text-align: center; }
</style>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,97 @@
/**
* TypeScript Types - GENERATED FILE
*
* Do not edit directly. Regenerate using modelgen.
*/
export interface GraphNode {
id: string;
status: string;
items_in: number;
items_out: number;
}
export interface GraphEdge {
source: string;
target: string;
throughput: number;
}
export interface BoundingBoxEvent {
x: number;
y: number;
w: number;
h: number;
confidence: number;
label: string;
resolved_brand: string | null;
source: string | null;
}
export interface BrandSummary {
brand: string;
total_appearances: number;
total_screen_time: number;
avg_confidence: number;
first_seen: number;
last_seen: number;
}
export interface GraphUpdate {
nodes: GraphNode[];
edges: GraphEdge[];
active_path: string[];
}
export interface StatsUpdate {
frames_extracted: number;
frames_after_scene_filter: number;
regions_detected: number;
regions_resolved_by_ocr: number;
regions_escalated_to_local_vlm: number;
regions_escalated_to_cloud_llm: number;
cloud_llm_calls: number;
processing_time_seconds: number;
estimated_cloud_cost_usd: number;
}
export interface FrameUpdate {
frame_ref: number;
timestamp: number;
jpeg_b64: string;
boxes: BoundingBoxEvent[];
}
export interface Detection {
brand: string;
timestamp: number;
duration: number;
confidence: number;
source: string;
content_type: string;
bbox: BoundingBoxEvent | null;
frame_ref: number | null;
}
export interface LogEvent {
level: string;
stage: string;
msg: string;
ts: string;
trace_id: string | null;
}
export interface DetectionReportSummary {
video_source: string;
content_type: string;
duration_seconds: number;
total_detections: number;
brands: BrandSummary[];
stats: StatsUpdate | null;
}
export interface JobComplete {
job_id: string;
report: DetectionReportSummary | null;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: '/detection/',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5175,
allowedHosts: ['mpr.local.ar'],
proxy: {
'/api': {
target: 'http://localhost:8702',
changeOrigin: true,
},
},
},
})

23
ui/framework/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "mpr-ui-framework",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5",
"pinia": "^2.2"
},
"devDependencies": {
"typescript": "^5.6",
"vitest": "^2",
"vue-tsc": "^2",
"vite": "^6",
"@vitejs/plugin-vue": "^5"
}
}

1558
ui/framework/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
/**
* Composable that connects a component to a DataSource.
*
* Connects on mount, disconnects on unmount.
* Returns reactive refs for data, status, and error.
*/
export function useDataSource<T = unknown>(source: DataSource<T>): {
data: Ref<T | null>
status: Ref<DataSourceStatus>
error: Ref<string | null>
} {
onMounted(() => source.connect())
onUnmounted(() => source.disconnect())
return {
data: source.data as Ref<T | null>,
status: source.status,
error: source.error as Ref<string | null>,
}
}

View File

@@ -0,0 +1,40 @@
import { type Ref, ref } from 'vue'
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
/**
* Base class for all data sources.
*
* A DataSource connects to some event stream, exposes reactive state,
* and lets consumers subscribe to typed events. Panels read from these
* reactively — they never touch the transport layer directly.
*/
export abstract class DataSource<T = unknown> {
readonly id: string
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
readonly status: Ref<DataSourceStatus> = ref('idle')
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
private listeners = new Map<string, Set<(payload: any) => void>>()
constructor(id: string) {
this.id = id
}
abstract connect(): void
abstract disconnect(): void
/** Subscribe to a specific event type */
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set())
}
this.listeners.get(eventType)!.add(handler)
return () => this.listeners.get(eventType)?.delete(handler)
}
/** Emit an event to subscribers (called by subclasses) */
protected emit(eventType: string, payload: unknown): void {
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
}
}

View File

@@ -0,0 +1,94 @@
import { DataSource } from './DataSource'
export interface SSEDataSourceOptions {
/** Unique identifier for this source */
id: string
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
url: string
/** Event types to listen for. Each is dispatched to subscribers via on(). */
eventTypes: string[]
/** Max reconnection attempts before giving up. Default: 10 */
maxRetries?: number
}
/**
* DataSource backed by native EventSource (Server-Sent Events).
*
* Connects to a single SSE endpoint and demultiplexes events by type.
* Multiple panels can subscribe to different event types from the same source.
*/
export class SSEDataSource extends DataSource {
private es: EventSource | null = null
private url: string
private eventTypes: string[]
private maxRetries: number
private retryCount = 0
constructor(opts: SSEDataSourceOptions) {
super(opts.id)
this.url = opts.url
this.eventTypes = opts.eventTypes
this.maxRetries = opts.maxRetries ?? 10
}
connect(): void {
if (this.es) return
this.status.value = 'connecting'
this.error.value = null
this.es = new EventSource(this.url)
this.es.onopen = () => {
this.status.value = 'live'
this.retryCount = 0
}
this.es.onerror = () => {
if (this.es?.readyState === EventSource.CLOSED) {
this.retryCount++
if (this.retryCount >= this.maxRetries) {
this.status.value = 'error'
this.error.value = `Connection lost after ${this.maxRetries} retries`
this.disconnect()
} else {
this.status.value = 'connecting'
}
}
}
// Register a listener for each event type
for (const eventType of this.eventTypes) {
this.es.addEventListener(eventType, (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data)
this.data.value = parsed
this.emit(eventType, parsed)
} catch {
// ignore malformed events
}
})
}
// Also listen to the generic 'done' terminal event
this.es.addEventListener('done', () => {
this.status.value = 'idle'
this.disconnect()
})
}
disconnect(): void {
if (this.es) {
this.es.close()
this.es = null
}
}
/** Update the URL (e.g. when job ID changes) and reconnect */
setUrl(url: string): void {
this.url = url
if (this.status.value === 'live' || this.status.value === 'connecting') {
this.disconnect()
this.connect()
}
}
}

View File

@@ -0,0 +1,45 @@
import { DataSource } from './DataSource'
export interface StaticEvent {
type: string
data: unknown
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
delay?: number
}
/**
* DataSource that replays a fixture array of events.
*
* Used for development and testing without a running backend.
* Events are emitted in sequence with optional delays.
*/
export class StaticDataSource extends DataSource {
private events: StaticEvent[]
private timeouts: ReturnType<typeof setTimeout>[] = []
constructor(id: string, events: StaticEvent[]) {
super(id)
this.events = events
}
connect(): void {
this.status.value = 'live'
this.error.value = null
let cumDelay = 0
for (const event of this.events) {
cumDelay += event.delay ?? 0
const timeout = setTimeout(() => {
this.data.value = event.data
this.emit(event.type, event.data)
}, cumDelay)
this.timeouts.push(timeout)
}
}
disconnect(): void {
for (const t of this.timeouts) clearTimeout(t)
this.timeouts = []
this.status.value = 'idle'
}
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { StaticDataSource } from '../StaticDataSource'
describe('StaticDataSource', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('emits events in order', async () => {
const source = new StaticDataSource('test', [
{ type: 'log', data: { msg: 'first' } },
{ type: 'log', data: { msg: 'second' } },
{ type: 'stats', data: { count: 42 } },
])
const received: { type: string; data: unknown }[] = []
source.on('log', (d) => received.push({ type: 'log', data: d }))
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
source.connect()
// Events with delay=0 fire on next microtask via setTimeout(0)
await new Promise((r) => setTimeout(r, 10))
expect(source.status.value).toBe('live')
expect(received).toHaveLength(3)
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
source.disconnect()
expect(source.status.value).toBe('idle')
})
it('respects delays between events', async () => {
const source = new StaticDataSource('test-delay', [
{ type: 'a', data: 1 },
{ type: 'b', data: 2, delay: 50 },
])
const received: unknown[] = []
source.on('a', (d) => received.push(d))
source.on('b', (d) => received.push(d))
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(received).toHaveLength(1) // only 'a' so far
await new Promise((r) => setTimeout(r, 60))
expect(received).toHaveLength(2) // 'b' arrived after delay
source.disconnect()
})
it('updates data ref with latest event payload', async () => {
const source = new StaticDataSource('test-data', [
{ type: 'x', data: { v: 1 } },
{ type: 'x', data: { v: 2 } },
])
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(source.data.value).toEqual({ v: 2 })
source.disconnect()
})
it('cleans up on disconnect', async () => {
const source = new StaticDataSource('test-cleanup', [
{ type: 'a', data: 1 },
{ type: 'b', data: 2, delay: 100 },
])
const received: unknown[] = []
source.on('b', (d) => received.push(d))
source.connect()
await new Promise((r) => setTimeout(r, 10))
source.disconnect()
// 'b' should never fire since we disconnected before its delay
await new Promise((r) => setTimeout(r, 150))
expect(received).toHaveLength(0)
})
it('unsubscribe removes listener', async () => {
const source = new StaticDataSource('test-unsub', [
{ type: 'x', data: 1 },
])
const received: unknown[] = []
const unsub = source.on('x', (d) => received.push(d))
unsub()
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(received).toHaveLength(0)
source.disconnect()
})
})

View File

@@ -0,0 +1,5 @@
// Framework public API
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'
export { useDataSource } from './composables/useDataSource'

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})