feat: add counting bench, triage, and dataset modules
This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
This commit is contained in:
1 parent
8285400254
commit
5c7c122105
80 files changed
+20074
-1412
No files matched your search
+79
-16
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v")
|
||||
@@ -44,7 +45,7 @@ def probe(path: str) -> dict:
|
||||
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height,avg_frame_rate",
|
||||
"-show_entries", "stream=width,height,avg_frame_rate,codec_name",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "json", path],
|
||||
capture_output=True, text=True,
|
||||
@@ -59,12 +60,14 @@ def probe(path: str) -> dict:
|
||||
"width": int(streams[0].get("width") or 0),
|
||||
"height": int(streams[0].get("height") or 0),
|
||||
"fps": _parse_fps(streams[0].get("avg_frame_rate")),
|
||||
"codec_name": str(streams[0].get("codec_name") or ""),
|
||||
"size": stat.st_size,
|
||||
}
|
||||
_probe_cache[key] = info
|
||||
return info
|
||||
|
||||
|
||||
|
||||
def _parse_fps(value: Optional[str]) -> float:
|
||||
# ffprobe reports "30000/1001", and "0/0" for streams it cannot work out.
|
||||
if not value or "/" not in value:
|
||||
@@ -111,23 +114,30 @@ def extract_frames(
|
||||
"-vf", f"fps={fps}", "-q:v", "2",
|
||||
os.path.join(out_dir, "%06d.jpg"),
|
||||
]
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
# stderr goes to a file, not a pipe: a damaged recording emits decode errors
|
||||
# without end, and nothing reads the pipe until ffmpeg exits — so a 64KB
|
||||
# buffer would fill and block ffmpeg forever, hanging the whole job queue.
|
||||
with tempfile.TemporaryFile("w+") as stderr_log:
|
||||
process = subprocess.Popen(command, stdout=subprocess.DEVNULL,
|
||||
stderr=stderr_log, text=True)
|
||||
|
||||
expected = frame_count(start_sec, end_sec, fps)
|
||||
while process.poll() is None:
|
||||
if should_stop is not None and should_stop():
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
raise VideoError("cancelled")
|
||||
if on_progress is not None:
|
||||
on_progress(min(_written(out_dir), expected))
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
expected = frame_count(start_sec, end_sec, fps)
|
||||
while process.poll() is None:
|
||||
if should_stop is not None and should_stop():
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
raise VideoError("cancelled")
|
||||
if on_progress is not None:
|
||||
on_progress(min(_written(out_dir), expected))
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
if process.returncode != 0:
|
||||
raise VideoError((process.stderr.read() or "ffmpeg failed").strip().splitlines()[-1])
|
||||
if process.returncode != 0:
|
||||
stderr_log.seek(0)
|
||||
lines = stderr_log.read().strip().splitlines()
|
||||
raise VideoError(lines[-1] if lines else "ffmpeg failed")
|
||||
|
||||
return sorted(name for name in os.listdir(out_dir) if name.endswith(".jpg"))
|
||||
|
||||
@@ -137,3 +147,56 @@ def _written(out_dir: str) -> int:
|
||||
return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg"))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def convert_to_h264(
|
||||
input_path: str,
|
||||
output_path: Optional[str] = None,
|
||||
crf: int = 18,
|
||||
preset: str = "medium",
|
||||
) -> str:
|
||||
"""Transcode input video to browser-supported H.264 / AAC MP4.
|
||||
|
||||
Uses CRF 18 for visually lossless compression (no perceptible visual loss,
|
||||
significantly reduced file size), yuv420p for universal browser playback,
|
||||
and +faststart for instant web streaming.
|
||||
"""
|
||||
if not available():
|
||||
raise VideoError("ffmpeg is not installed in this environment")
|
||||
|
||||
if not os.path.isfile(input_path):
|
||||
raise VideoError(f"Input video file not found: {input_path}")
|
||||
|
||||
if not output_path:
|
||||
base, _ = os.path.splitext(input_path)
|
||||
output_path = f"{base}_h264.mp4"
|
||||
|
||||
temp_output_path = output_path.replace(".mp4", "_tmp.mp4") if output_path.endswith(".mp4") else output_path + ".tmp.mp4"
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
|
||||
command = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", input_path,
|
||||
"-c:v", "libx264",
|
||||
"-crf", str(crf),
|
||||
"-preset", preset,
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-f", "mp4",
|
||||
temp_output_path,
|
||||
]
|
||||
|
||||
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
if os.path.isfile(temp_output_path):
|
||||
os.remove(temp_output_path)
|
||||
err_msg = result.stderr.strip().splitlines()[-1] if result.stderr else "ffmpeg conversion failed"
|
||||
raise VideoError(err_msg)
|
||||
|
||||
os.replace(temp_output_path, output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
Reference in new issue
Block a user