a
This commit is contained in:
@@ -11,6 +11,7 @@ from .run import router as run_router
|
||||
from .sse import router as sse_router
|
||||
from .replay import router as replay_router
|
||||
from .config import router as config_router
|
||||
from .timeline import router as timeline_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(sources_router)
|
||||
@@ -18,3 +19,4 @@ router.include_router(run_router)
|
||||
router.include_router(sse_router)
|
||||
router.include_router(replay_router)
|
||||
router.include_router(config_router)
|
||||
router.include_router(timeline_router)
|
||||
|
||||
@@ -137,12 +137,15 @@ class CheckpointData(BaseModel):
|
||||
|
||||
@router.get("/checkpoints/{timeline_id}/{stage}", response_model=CheckpointData)
|
||||
def get_checkpoint_data(timeline_id: str, stage: str):
|
||||
"""Load checkpoint frames + metadata for the editor UI."""
|
||||
"""Load checkpoint frames + metadata for the editor UI.
|
||||
|
||||
Reads from the timeline's frame cache (local filesystem).
|
||||
"""
|
||||
from uuid import UUID
|
||||
from core.db.models import Timeline, Checkpoint
|
||||
from core.db.connection import get_session
|
||||
from core.db.checkpoint import list_checkpoints
|
||||
from core.detect.checkpoint.frames import load_frames_b64
|
||||
from core.detect.checkpoint.frames import load_cached_frames_b64
|
||||
|
||||
with get_session() as session:
|
||||
timeline = session.get(Timeline, UUID(timeline_id))
|
||||
@@ -152,16 +155,14 @@ def get_checkpoint_data(timeline_id: str, stage: str):
|
||||
checkpoints = list_checkpoints(session, UUID(timeline_id))
|
||||
if not checkpoints:
|
||||
raise HTTPException(status_code=404, detail=f"No checkpoints for timeline {timeline_id}")
|
||||
# Prefer a checkpoint that has this stage's output; fall back to latest
|
||||
# Prefer a checkpoint for this stage; fall back to latest
|
||||
checkpoint = next(
|
||||
(c for c in reversed(checkpoints) if stage in (c.stage_outputs or {})),
|
||||
(c for c in reversed(checkpoints) if c.stage_name == stage),
|
||||
checkpoints[-1],
|
||||
)
|
||||
|
||||
raw_manifest = timeline.frames_manifest or {}
|
||||
manifest = {int(k): v for k, v in raw_manifest.items()}
|
||||
frames_b64 = load_frames_b64(manifest, timeline.frames_meta or [])
|
||||
|
||||
# Read from timeline's frame cache
|
||||
frames_b64 = load_cached_frames_b64(timeline_id)
|
||||
frame_list = [
|
||||
CheckpointFrameInfo(seq=f["seq"], timestamp=f["timestamp"], jpeg_b64=f["jpeg_b64"])
|
||||
for f in frames_b64
|
||||
@@ -171,7 +172,7 @@ def get_checkpoint_data(timeline_id: str, stage: str):
|
||||
timeline_id=timeline_id,
|
||||
stage=stage,
|
||||
profile_name=timeline.profile_name,
|
||||
video_path=timeline.source_video,
|
||||
video_path=timeline.chunk_paths[0] if timeline.chunk_paths else "",
|
||||
is_scenario=checkpoint.is_scenario,
|
||||
scenario_label=checkpoint.scenario_label,
|
||||
frames=frame_list,
|
||||
@@ -195,14 +196,12 @@ def list_scenarios_endpoint():
|
||||
timeline = session.get(Timeline, s.timeline_id)
|
||||
if not timeline:
|
||||
continue
|
||||
last_stage = next(reversed(s.stage_outputs), "") if s.stage_outputs else ""
|
||||
info = ScenarioInfo(
|
||||
timeline_id=str(s.timeline_id),
|
||||
stage=last_stage,
|
||||
stage=s.stage_name,
|
||||
scenario_label=s.scenario_label,
|
||||
profile_name=timeline.profile_name,
|
||||
video_path=timeline.source_video,
|
||||
frame_count=len(timeline.frames_manifest or {}),
|
||||
video_path=timeline.chunk_paths[0] if timeline.chunk_paths else "",
|
||||
created_at=str(s.created_at) if s.created_at else "",
|
||||
)
|
||||
result.append(info)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Pipeline run endpoints.
|
||||
|
||||
POST /detect/run — launch pipeline on selected source
|
||||
POST /detect/run — launch pipeline on a timeline
|
||||
POST /detect/stop/{job_id} — cancel a running pipeline
|
||||
POST /detect/pause/{job_id} — pause after current stage
|
||||
POST /detect/resume/{job_id} — resume a paused pipeline
|
||||
@@ -30,20 +30,20 @@ _cancelled_jobs: set[str] = set()
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
video_path: str # storage key
|
||||
timeline_id: str
|
||||
profile_name: str = "soccer_broadcast"
|
||||
source_asset_id: str = ""
|
||||
checkpoint: bool = True
|
||||
skip_vlm: bool = False
|
||||
skip_cloud: bool = False
|
||||
log_level: str = "INFO" # INFO | DEBUG
|
||||
pause_after_stage: bool = False
|
||||
config_overrides: dict | None = None
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
status: str
|
||||
job_id: str
|
||||
video_path: str
|
||||
timeline_id: str
|
||||
|
||||
|
||||
def _resolve_video_path(video_path: str) -> str:
|
||||
@@ -59,13 +59,41 @@ def _resolve_video_path(video_path: str) -> str:
|
||||
|
||||
@router.post("/run", response_model=RunResponse)
|
||||
def run_pipeline(req: RunRequest):
|
||||
"""Launch a detection pipeline run on a source chunk."""
|
||||
"""Launch a detection pipeline run on a timeline."""
|
||||
from core.detect import emit
|
||||
from core.detect.graph import get_pipeline
|
||||
from core.detect.state import DetectState
|
||||
from core.detect.checkpoint.storage import get_timeline
|
||||
from core.db.connection import get_session
|
||||
from core.db.job import create_job, update_job_status
|
||||
|
||||
local_path = _resolve_video_path(req.video_path)
|
||||
job_id = str(uuid.uuid4())
|
||||
# Load timeline
|
||||
try:
|
||||
timeline = get_timeline(req.timeline_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Timeline not found: {req.timeline_id}")
|
||||
|
||||
chunk_paths = timeline["chunk_paths"]
|
||||
if not chunk_paths:
|
||||
raise HTTPException(status_code=400, detail="Timeline has no chunk paths")
|
||||
|
||||
# Resolve first chunk to local path for the pipeline
|
||||
local_path = _resolve_video_path(chunk_paths[0])
|
||||
|
||||
# Create job in DB
|
||||
source_asset_id_str = timeline.get("source_asset_id", "")
|
||||
with get_session() as session:
|
||||
from uuid import UUID as _UUID
|
||||
source_asset_id = _UUID(source_asset_id_str) if source_asset_id_str else uuid.uuid4()
|
||||
job = create_job(
|
||||
session,
|
||||
source_asset_id=source_asset_id,
|
||||
video_path=chunk_paths[0],
|
||||
timeline_id=_UUID(req.timeline_id),
|
||||
profile_name=req.profile_name,
|
||||
config_overrides=req.config_overrides,
|
||||
)
|
||||
job_id = str(job.id)
|
||||
|
||||
if req.skip_vlm:
|
||||
os.environ["SKIP_VLM"] = "1"
|
||||
@@ -77,7 +105,7 @@ def run_pipeline(req: RunRequest):
|
||||
elif "SKIP_CLOUD" in os.environ:
|
||||
del os.environ["SKIP_CLOUD"]
|
||||
|
||||
# Clear any stale events from a previous run with same job_id
|
||||
# Clear any stale events
|
||||
from core.events import _get_redis
|
||||
from core.detect.events import DETECT_EVENTS_PREFIX
|
||||
r = _get_redis()
|
||||
@@ -94,7 +122,9 @@ def run_pipeline(req: RunRequest):
|
||||
video_path=local_path,
|
||||
job_id=job_id,
|
||||
profile_name=req.profile_name,
|
||||
source_asset_id=req.source_asset_id,
|
||||
source_asset_id=source_asset_id_str or str(source_asset_id),
|
||||
timeline_id=req.timeline_id,
|
||||
config_overrides=req.config_overrides or {},
|
||||
)
|
||||
|
||||
from core.detect.graph import (
|
||||
@@ -105,18 +135,29 @@ def run_pipeline(req: RunRequest):
|
||||
set_cancel_check(job_id, lambda: job_id in _cancelled_jobs)
|
||||
init_pause(job_id, pause_after_stage=req.pause_after_stage)
|
||||
|
||||
def _update_job(status, stage=None, error=None):
|
||||
from core.db.connection import get_session
|
||||
from core.db.job import update_job_status
|
||||
with get_session() as session:
|
||||
update_job_status(session, _UUID(job_id), status,
|
||||
current_stage=stage, error_message=error)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
_update_job("running")
|
||||
emit.log(job_id, "Pipeline", "INFO",
|
||||
f"Starting pipeline: {req.video_path} (profile={req.profile_name})")
|
||||
f"Starting pipeline: {chunk_paths[0]} (profile={req.profile_name})")
|
||||
pipeline.invoke(initial_state)
|
||||
_update_job("completed")
|
||||
emit.log(job_id, "Pipeline", "INFO", "Pipeline completed successfully")
|
||||
emit.job_complete(job_id, {"status": "completed"})
|
||||
except PipelineCancelled:
|
||||
_update_job("cancelled")
|
||||
emit.log(job_id, "Pipeline", "INFO", "Pipeline cancelled")
|
||||
emit.job_complete(job_id, {"status": "cancelled"})
|
||||
except Exception as e:
|
||||
logger.exception("Pipeline run %s failed: %s", job_id, e)
|
||||
_update_job("failed", error=str(e))
|
||||
from core.detect.graph import _node_states, NODES
|
||||
if job_id in _node_states:
|
||||
states = _node_states[job_id]
|
||||
@@ -134,12 +175,14 @@ def run_pipeline(req: RunRequest):
|
||||
clear_cancel_check(job_id)
|
||||
clear_pause(job_id)
|
||||
emit.clear_run_context()
|
||||
from core.detect.checkpoint.runner_bridge import reset_checkpoint_state
|
||||
reset_checkpoint_state(job_id)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True, name=f"pipeline-{job_id}")
|
||||
_running_jobs[job_id] = thread
|
||||
thread.start()
|
||||
|
||||
return RunResponse(status="started", job_id=job_id, video_path=req.video_path)
|
||||
return RunResponse(status="started", job_id=job_id, timeline_id=req.timeline_id)
|
||||
|
||||
|
||||
@router.post("/stop/{job_id}")
|
||||
@@ -224,18 +267,6 @@ def pipeline_status(job_id: str):
|
||||
return {"status": status, "job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/timeline/{job_id}")
|
||||
def get_timeline_for_job(job_id: str):
|
||||
"""Get the timeline_id for a running or completed job."""
|
||||
from core.detect.checkpoint.runner_bridge import get_timeline_id
|
||||
|
||||
tid = get_timeline_id(job_id)
|
||||
if tid is None:
|
||||
raise HTTPException(status_code=404, detail=f"No timeline for job: {job_id}")
|
||||
|
||||
return {"timeline_id": tid, "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/clear/{job_id}")
|
||||
def clear_pipeline(job_id: str):
|
||||
"""Clear events for a job from Redis."""
|
||||
|
||||
226
core/api/detect/timeline.py
Normal file
226
core/api/detect/timeline.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Timeline + Job management endpoints.
|
||||
|
||||
POST /detect/timeline — create timeline from chunk selection
|
||||
GET /detect/timeline — list timelines
|
||||
GET /detect/timeline/{id} — timeline detail
|
||||
DELETE /detect/timeline/{id}/cache — clear frame cache
|
||||
|
||||
GET /detect/jobs — list jobs (optionally by timeline)
|
||||
GET /detect/jobs/{id} — job detail + checkpoints + stage outputs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/detect", tags=["detect"])
|
||||
|
||||
|
||||
# --- Request/Response models ---
|
||||
|
||||
class CreateTimelineRequest(BaseModel):
|
||||
chunk_paths: list[str]
|
||||
profile_name: str = "soccer_broadcast"
|
||||
name: str = ""
|
||||
source_asset_id: str = ""
|
||||
fps: float = 2.0
|
||||
|
||||
|
||||
class TimelineResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
chunk_paths: list[str]
|
||||
profile_name: str
|
||||
status: str
|
||||
fps: float
|
||||
frame_count: int
|
||||
source_ephemeral: bool
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class JobResponse(BaseModel):
|
||||
id: str
|
||||
timeline_id: str | None
|
||||
source_asset_id: str
|
||||
video_path: str
|
||||
profile_name: str
|
||||
run_type: str
|
||||
status: str
|
||||
current_stage: str | None
|
||||
config_overrides: dict
|
||||
error_message: str | None
|
||||
created_at: str | None
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
class JobDetailResponse(JobResponse):
|
||||
checkpoints: list[dict]
|
||||
stage_outputs: dict[str, dict]
|
||||
|
||||
|
||||
# --- Timeline endpoints ---
|
||||
|
||||
@router.post("/timeline", response_model=TimelineResponse)
|
||||
def create_timeline_endpoint(req: CreateTimelineRequest):
|
||||
"""Create a timeline from a chunk selection."""
|
||||
from uuid import UUID
|
||||
from core.detect.checkpoint.storage import create_timeline
|
||||
|
||||
source_asset_id = UUID(req.source_asset_id) if req.source_asset_id else None
|
||||
tid = create_timeline(
|
||||
chunk_paths=req.chunk_paths,
|
||||
profile_name=req.profile_name,
|
||||
name=req.name,
|
||||
source_asset_id=source_asset_id,
|
||||
fps=req.fps,
|
||||
)
|
||||
|
||||
from core.detect.checkpoint.storage import get_timeline
|
||||
tl = get_timeline(tid)
|
||||
return TimelineResponse(
|
||||
id=tl["id"],
|
||||
name=tl["name"],
|
||||
chunk_paths=tl["chunk_paths"],
|
||||
profile_name=tl["profile_name"],
|
||||
status=tl["status"],
|
||||
fps=tl["fps"],
|
||||
frame_count=0,
|
||||
source_ephemeral=False,
|
||||
created_at=tl["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/timeline", response_model=list[TimelineResponse])
|
||||
def list_timelines():
|
||||
"""List all timelines."""
|
||||
from sqlmodel import select
|
||||
from core.db.models import Timeline
|
||||
from core.db.connection import get_session
|
||||
|
||||
with get_session() as session:
|
||||
stmt = select(Timeline).order_by(Timeline.created_at.desc())
|
||||
timelines = session.exec(stmt).all()
|
||||
|
||||
return [
|
||||
TimelineResponse(
|
||||
id=str(t.id),
|
||||
name=t.name,
|
||||
chunk_paths=t.chunk_paths or [],
|
||||
profile_name=t.profile_name,
|
||||
status=t.status,
|
||||
fps=t.fps,
|
||||
frame_count=t.frame_count,
|
||||
source_ephemeral=t.source_ephemeral,
|
||||
created_at=str(t.created_at) if t.created_at else None,
|
||||
)
|
||||
for t in timelines
|
||||
]
|
||||
|
||||
|
||||
@router.get("/timeline/{timeline_id}", response_model=TimelineResponse)
|
||||
def get_timeline_endpoint(timeline_id: str):
|
||||
"""Get timeline detail."""
|
||||
from core.detect.checkpoint.storage import get_timeline
|
||||
try:
|
||||
tl = get_timeline(timeline_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Timeline not found: {timeline_id}")
|
||||
|
||||
from core.detect.checkpoint.frames import cache_exists
|
||||
from uuid import UUID
|
||||
from core.db.models import Timeline
|
||||
from core.db.connection import get_session
|
||||
|
||||
with get_session() as session:
|
||||
timeline = session.get(Timeline, UUID(timeline_id))
|
||||
|
||||
return TimelineResponse(
|
||||
id=tl["id"],
|
||||
name=tl["name"],
|
||||
chunk_paths=tl["chunk_paths"],
|
||||
profile_name=tl["profile_name"],
|
||||
status=tl["status"],
|
||||
fps=tl["fps"],
|
||||
frame_count=timeline.frame_count if timeline else 0,
|
||||
source_ephemeral=timeline.source_ephemeral if timeline else False,
|
||||
created_at=tl["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/timeline/{timeline_id}/cache")
|
||||
def clear_timeline_cache(timeline_id: str):
|
||||
"""Clear the frame cache for a timeline."""
|
||||
from core.detect.checkpoint.frames import clear_cache
|
||||
from core.detect.checkpoint.storage import update_timeline_status
|
||||
|
||||
clear_cache(timeline_id)
|
||||
update_timeline_status(timeline_id, "created")
|
||||
return {"status": "cleared", "timeline_id": timeline_id}
|
||||
|
||||
|
||||
# --- Job endpoints ---
|
||||
|
||||
def _job_to_response(job) -> JobResponse:
|
||||
return JobResponse(
|
||||
id=str(job.id),
|
||||
timeline_id=str(job.timeline_id) if job.timeline_id else None,
|
||||
source_asset_id=str(job.source_asset_id),
|
||||
video_path=job.video_path,
|
||||
profile_name=job.profile_name,
|
||||
run_type=job.run_type,
|
||||
status=job.status,
|
||||
current_stage=job.current_stage,
|
||||
config_overrides=job.config_overrides or {},
|
||||
error_message=job.error_message,
|
||||
created_at=str(job.created_at) if job.created_at else None,
|
||||
started_at=str(job.started_at) if job.started_at else None,
|
||||
completed_at=str(job.completed_at) if job.completed_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=list[JobResponse])
|
||||
def list_jobs_endpoint(timeline_id: str | None = Query(None)):
|
||||
"""List jobs, optionally filtered by timeline."""
|
||||
from uuid import UUID
|
||||
from core.db.connection import get_session
|
||||
from core.db.job import list_jobs
|
||||
|
||||
tid = UUID(timeline_id) if timeline_id else None
|
||||
with get_session() as session:
|
||||
jobs = list_jobs(session, timeline_id=tid)
|
||||
|
||||
return [_job_to_response(j) for j in jobs]
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=JobDetailResponse)
|
||||
def get_job_endpoint(job_id: str):
|
||||
"""Get job detail with checkpoints and stage outputs."""
|
||||
from uuid import UUID
|
||||
from core.db.connection import get_session
|
||||
from core.db.job import get_job
|
||||
from core.detect.checkpoint.storage import (
|
||||
get_checkpoints_for_job,
|
||||
load_stage_outputs_for_job,
|
||||
)
|
||||
|
||||
with get_session() as session:
|
||||
job = get_job(session, UUID(job_id))
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
|
||||
|
||||
checkpoints = get_checkpoints_for_job(job_id)
|
||||
stage_outputs = load_stage_outputs_for_job(job_id)
|
||||
|
||||
base = _job_to_response(job)
|
||||
return JobDetailResponse(
|
||||
**base.model_dump(),
|
||||
checkpoints=checkpoints,
|
||||
stage_outputs=stage_outputs,
|
||||
)
|
||||
Reference in New Issue
Block a user