This commit is contained in:
lda committed 2026-03-01 19:47:58 +07:00
1 parent 0cd543a21a
commit 61af362da5
6 files changed
+420 -14

No files matched your search

+73 -1
View File
@@ -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."""
+216
View File
@@ -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"