why
This commit is contained in:
1 parent
0cd543a21a
commit
61af362da5
6 files changed
+420
-14
No files matched your search
+73
-1
@@ -1,9 +1,11 @@
|
||||
"""API route definitions for the smoking detection server."""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Form, Response
|
||||
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import server.runtime as rt
|
||||
@@ -133,6 +135,76 @@ def live_switch_source(source: str = Form(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clips/submit")
|
||||
async def clips_submit(
|
||||
file: UploadFile | None = File(default=None),
|
||||
path: str | None = Form(default=None),
|
||||
):
|
||||
"""Queue a clip processing job from uploaded file or existing local path."""
|
||||
if file is None and not path:
|
||||
return {
|
||||
"status": "fail",
|
||||
"message": "Provide either an uploaded file or a local file path",
|
||||
}
|
||||
|
||||
clip_input_path = ""
|
||||
source_name = None
|
||||
|
||||
try:
|
||||
if file is not None:
|
||||
uploads_dir = Path("media/clip_jobs/uploads")
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_name = file.filename or "upload.mp4"
|
||||
suffix = Path(source_name).suffix or ".mp4"
|
||||
safe_name = f"clip_{int(time.time() * 1000)}{suffix}"
|
||||
saved_path = uploads_dir / safe_name
|
||||
|
||||
with saved_path.open("wb") as out:
|
||||
shutil.copyfileobj(file.file, out)
|
||||
|
||||
clip_input_path = str(saved_path)
|
||||
else:
|
||||
local_path = Path(path or "")
|
||||
if not local_path.exists() or not local_path.is_file():
|
||||
return {
|
||||
"status": "fail",
|
||||
"message": f"File not found: {local_path}",
|
||||
}
|
||||
clip_input_path = str(local_path)
|
||||
source_name = local_path.name
|
||||
finally:
|
||||
if file is not None:
|
||||
await file.close()
|
||||
|
||||
job = rt.clip_jobs.submit(clip_input_path, source_name)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Clip job queued",
|
||||
"job": job,
|
||||
"queue": rt.clip_jobs.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clips")
|
||||
def clips_list(limit: int = 20):
|
||||
"""List recent clip jobs."""
|
||||
return {
|
||||
"status": "success",
|
||||
"queue": rt.clip_jobs.status(),
|
||||
"jobs": rt.clip_jobs.list(limit=max(1, min(limit, 200))),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clips/{job_id}")
|
||||
def clips_get(job_id: str):
|
||||
"""Get one clip job state."""
|
||||
job = rt.clip_jobs.get(job_id)
|
||||
if not job:
|
||||
return {"status": "fail", "message": "Job not found", "job_id": job_id}
|
||||
return {"status": "success", "job": job}
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
def snapshot():
|
||||
"""Return the latest frame as a JPEG."""
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from queue import Empty, Queue
|
||||
from typing import Any, Callable
|
||||
|
||||
from db import SQLiteRepository
|
||||
from db.repository import Repository
|
||||
from pipeline.protocols import Sink
|
||||
from pipeline.sources.file import FileStreamSource
|
||||
from pipeline.sources.stream import ThreadedStreamSource
|
||||
from pipeline.types import Frame
|
||||
|
||||
@@ -25,6 +30,10 @@ SourceFactory = Callable[[str], ThreadedStreamSource]
|
||||
SinkFactory = Callable[[ThreadedStreamSource], list[Sink]]
|
||||
PipelineFactory = Callable[[ThreadedStreamSource, list[Sink]], Any]
|
||||
|
||||
ClipSourceFactory = Callable[[str], FileStreamSource]
|
||||
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
|
||||
ClipPipelineFactory = Callable[[FileStreamSource, list[Sink]], Any]
|
||||
|
||||
|
||||
class LiveFrameSink(Sink):
|
||||
"""Captures the latest frame so API endpoints can stream it."""
|
||||
@@ -257,6 +266,213 @@ class PipelineManager:
|
||||
manager = PipelineManager()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipJob:
|
||||
job_id: str
|
||||
input_path: str
|
||||
submitted_at: str
|
||||
source_name: str | None = None
|
||||
status: str = "queued" # queued | running | done | failed
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"input_path": self.input_path,
|
||||
"source_name": self.source_name,
|
||||
"status": self.status,
|
||||
"submitted_at": self.submitted_at,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class ClipJobManager:
|
||||
"""Simple queued clip processor with a single worker."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._queue: Queue[str] = Queue()
|
||||
self._jobs: dict[str, ClipJob] = {}
|
||||
self._ordered_ids: list[str] = []
|
||||
|
||||
self._source_factory: ClipSourceFactory | None = None
|
||||
self._sink_factory: ClipSinkFactory | None = None
|
||||
self._pipeline_factory: ClipPipelineFactory | None = None
|
||||
|
||||
self._shutdown = False
|
||||
self._worker = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
daemon=True,
|
||||
name="clip-worker",
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def configure(
|
||||
self,
|
||||
source_factory: ClipSourceFactory,
|
||||
sink_factory: ClipSinkFactory,
|
||||
pipeline_factory: ClipPipelineFactory,
|
||||
):
|
||||
with self._lock:
|
||||
self._source_factory = source_factory
|
||||
self._sink_factory = sink_factory
|
||||
self._pipeline_factory = pipeline_factory
|
||||
|
||||
def submit(self, input_path: str, source_name: str | None = None) -> dict[str, Any]:
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
job_id = f"job_{int(time.time() * 1000)}"
|
||||
job = ClipJob(
|
||||
job_id=job_id,
|
||||
input_path=input_path,
|
||||
source_name=source_name,
|
||||
submitted_at=now,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job
|
||||
self._ordered_ids.append(job_id)
|
||||
self._queue.put(job_id)
|
||||
return job.to_dict()
|
||||
|
||||
def get(self, job_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
return job.to_dict() if job else None
|
||||
|
||||
def list(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
ids = self._ordered_ids[-limit:]
|
||||
ids.reverse()
|
||||
return [self._jobs[job_id].to_dict() for job_id in ids]
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
queued = sum(1 for job in self._jobs.values() if job.status == "queued")
|
||||
running = sum(1 for job in self._jobs.values() if job.status == "running")
|
||||
done = sum(1 for job in self._jobs.values() if job.status == "done")
|
||||
failed = sum(1 for job in self._jobs.values() if job.status == "failed")
|
||||
return {
|
||||
"queued": queued,
|
||||
"running": running,
|
||||
"done": done,
|
||||
"failed": failed,
|
||||
"worker_alive": self._worker.is_alive(),
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
self._shutdown = True
|
||||
|
||||
if self._worker.is_alive():
|
||||
self._worker.join(timeout=2.0)
|
||||
|
||||
def _set_job_state(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
*,
|
||||
started_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
error: str | None = None,
|
||||
):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.status = status
|
||||
if started_at is not None:
|
||||
job.started_at = started_at
|
||||
if finished_at is not None:
|
||||
job.finished_at = finished_at
|
||||
if error is not None:
|
||||
job.error = error
|
||||
|
||||
def _worker_loop(self):
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
try:
|
||||
job_id = self._queue.get(timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
self._process_job(job_id)
|
||||
self._queue.task_done()
|
||||
|
||||
def _process_job(self, job_id: str):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
source_factory = self._source_factory
|
||||
sink_factory = self._sink_factory
|
||||
pipeline_factory = self._pipeline_factory
|
||||
|
||||
if not job:
|
||||
return
|
||||
|
||||
if not source_factory or not sink_factory or not pipeline_factory:
|
||||
self._set_job_state(job_id, "failed", error="ClipJobManager not configured")
|
||||
return
|
||||
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"running",
|
||||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
paused_live = False
|
||||
try:
|
||||
live_status = manager.status()
|
||||
if live_status.get("running"):
|
||||
ok, _ = manager.pause()
|
||||
paused_live = ok
|
||||
|
||||
src = source_factory(job.input_path)
|
||||
sinks = sink_factory(src, job_id)
|
||||
pipe = pipeline_factory(src, sinks)
|
||||
|
||||
try:
|
||||
pipe.process_stream(src)
|
||||
finally:
|
||||
try:
|
||||
src.stop()
|
||||
except Exception:
|
||||
logger.exception("Failed to stop clip source for %s", job_id)
|
||||
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.close()
|
||||
except Exception:
|
||||
logger.exception("Failed to close clip sink for %s", job_id)
|
||||
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"done",
|
||||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||||
)
|
||||
logger.info("Clip job completed: %s", job_id)
|
||||
except Exception as e:
|
||||
logger.exception("Clip job failed: %s", job_id)
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"failed",
|
||||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
if paused_live:
|
||||
manager.resume()
|
||||
|
||||
|
||||
clip_jobs = ClipJobManager()
|
||||
|
||||
|
||||
def get_lan_address() -> tuple[str, str]:
|
||||
"""Return (ip, hostname) for the primary LAN interface."""
|
||||
ip = "127.0.0.1"
|
||||
|
||||
Reference in new issue
Block a user