Files
VideoDetect/ui/app.py
T
2026-09-25 22:40:24 -04:00

587 lines
22 KiB
Python

"""
VideoDetect Review UI — Flask Application
Lightweight web interface for annotating pending REVIEW tasks.
Accessible via http://<server-ip>:5000 — no auth, no SSL (per TC-06).
The UI queries the generic API (http://api:3000/api/v1/...) for all data
and posts review results back through the same API. No direct DB access.
"""
import json
import mimetypes
import os
from urllib.parse import quote
import requests
from flask import Flask, Response, jsonify, render_template_string, request, send_file
app = Flask(__name__)
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
VIDEO_CHUNK_SIZE = 1024 * 1024
VIDEO_MIME_TYPES = {
".3gp": "video/3gpp",
".avi": "video/x-msvideo",
".flv": "video/x-flv",
".m2ts": "video/mp2t",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".ogv": "video/ogg",
".ts": "video/mp2t",
".webm": "video/webm",
".wmv": "video/x-ms-wmv",
}
def api_get(path: str, params: dict = None) -> dict:
"""Perform a GET request to the API and return JSON."""
url = f"{API_BASE}/{path}"
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
return resp.json()
def api_post(path: str, data: dict = None) -> dict:
"""Perform a POST request to the API and return JSON."""
url = f"{API_BASE}/{path}"
resp = requests.post(url, json=data or {}, timeout=10)
resp.raise_for_status()
return resp.json()
def candidate_video_paths(file_path: str) -> list[str]:
"""Return possible local paths for a video path stored by the API."""
if os.path.isabs(file_path):
return [file_path]
nfs_base = os.environ.get("NAS_INPUT_PATH", "/data")
return [os.path.join(nfs_base, file_path)]
def resolve_video_path(file_path: str) -> str | None:
"""Find the first readable local path for a video."""
for full_path in candidate_video_paths(file_path):
if os.path.isfile(full_path):
return full_path
return None
def guess_video_mimetype(file_path: str) -> str:
"""Infer a browser-facing media type from the filename, then file signature."""
ext = os.path.splitext(file_path)[1].lower()
if ext in VIDEO_MIME_TYPES:
return VIDEO_MIME_TYPES[ext]
guessed, _ = mimetypes.guess_type(file_path)
if guessed:
return guessed
try:
with open(file_path, "rb") as fh:
header = fh.read(512)
except OSError:
return "application/octet-stream"
if len(header) >= 12 and header[4:8] == b"ftyp":
return "video/quicktime" if header[8:12] == b"qt " else "video/mp4"
if header.startswith(b"\x1a\x45\xdf\xa3"):
return "video/webm" if b"webm" in header.lower() else "video/x-matroska"
if header.startswith(b"RIFF") and header[8:12] == b"AVI ":
return "video/x-msvideo"
if header.startswith(b"FLV"):
return "video/x-flv"
if header.startswith(b"\x00\x00\x01\xba") or header.startswith(b"\x00\x00\x01"):
return "video/mpeg"
return "application/octet-stream"
def parse_range_header(range_header: str, file_size: int) -> tuple[int, int] | None:
"""Parse a single HTTP byte range, returning inclusive start/end offsets."""
if not range_header.lower().startswith("bytes="):
return None
ranges = range_header.partition("=")[2].split(",")
if len(ranges) != 1 or "-" not in ranges[0]:
return None
start_s, end_s = ranges[0].strip().split("-", 1)
try:
if start_s:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
else:
suffix_length = int(end_s)
if suffix_length <= 0:
return None
start = max(file_size - suffix_length, 0)
end = file_size - 1
except ValueError:
return None
if start < 0 or end < start or start >= file_size:
return None
return start, min(end, file_size - 1)
def iter_file_range(file_path: str, start: int, end: int):
"""Yield a byte range from disk without loading the full video into memory."""
with open(file_path, "rb") as fh:
fh.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = fh.read(min(VIDEO_CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
def content_disposition(filename: str) -> str:
"""Build an inline Content-Disposition value that tolerates spaces/unicode."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii").replace('"', "")
ascii_name = ascii_name or "video"
return f"inline; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
# ---------------------------------------------------------------------------
# HTML routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
"""Index view: list of pending REVIEW tasks with pagination."""
page = request.args.get("page", 1, type=int)
per_page = int(os.environ.get("PER_PAGE", 20))
try:
data = api_get("tasks", {
"type": "REVIEW",
"status": "PENDING",
"page": page,
"per_page": per_page,
})
except requests.RequestException as exc:
return jsonify({"error": f"API unavailable: {exc}"}), 503
tasks = data.get("tasks", [])
total = data.get("total", 0)
total_pages = max(1, (total + per_page - 1) // per_page)
# Enrich each task with its associated video info from the API
enriched = []
for t in tasks:
vid_id = t.get("video_id")
video_path = "unknown"
resolution = None
try:
vdata = api_get(f"video/{vid_id}")
video_path = vdata.get("file_path", "unknown")
res_w = vdata.get("resolution_w")
res_h = vdata.get("resolution_h")
if res_w and res_h:
resolution = f"{res_w}x{res_h}"
except requests.RequestException:
pass
enriched.append({
"id": t["id"],
"video_id": vid_id,
"file_path": video_path,
"task_type": t.get("task_type"),
"status": t.get("status"),
"created_at": t.get("assigned_at") or t.get("created_at"),
"resolution": resolution,
})
return render_template_string(
_INDEX_TEMPLATE,
tasks=enriched,
page=page,
per_page=per_page,
total=total,
total_pages=total_pages,
)
@app.route("/review/<int:task_id>")
def review_task(task_id):
"""Detail view: show task info + embedded video player."""
return_page = request.args.get("page", 1, type=int) or 1
return_page = max(return_page, 1)
return_queue_url = "/" if return_page == 1 else f"/?page={return_page}"
try:
data = api_get(f"task/{task_id}")
if not data or not data.get("id"):
return "Task not found", 404
video_id = data["video_id"]
vid_data = api_get(f"video/{video_id}")
file_path = vid_data.get("file_path", "unknown")
except requests.RequestException as exc:
return jsonify({"error": f"API unavailable: {exc}"}), 503
video_obj = dict(data)
video_obj["file_path"] = file_path
video_obj["video_duration"] = vid_data.get("duration")
video_obj["resolution"] = vid_data.get("resolution_w") and vid_data.get("resolution_h") \
and f"{vid_data['resolution_w']}x{vid_data['resolution_h']}"
resolved_path = resolve_video_path(file_path)
video_obj["mime_type"] = guess_video_mimetype(resolved_path or file_path)
# Parse results if already present
raw_results = data.get("results", {})
try:
video_obj["prev_results"] = json.loads(raw_results) if isinstance(raw_results, str) else (raw_results or {})
except (json.JSONDecodeError, TypeError):
video_obj["prev_results"] = {}
return render_template_string(
_REVIEW_TEMPLATE,
video=video_obj,
return_queue_url=return_queue_url,
)
# ---------------------------------------------------------------------------
# API routes (Flask → NFS file serving + JSON wrappers)
# ---------------------------------------------------------------------------
@app.route("/video/<int:video_id>")
def serve_video(video_id):
"""Stream video from NFS-mounted /data; supports HTTP range requests."""
# Resolve video path via the API first
try:
vid_data = api_get(f"video/{video_id}")
file_path = vid_data.get("file_path", "")
except requests.RequestException:
return "Not found", 404
if not file_path or file_path == "unknown":
return "Video file path unknown", 404
full_path = resolve_video_path(file_path)
if not full_path:
return "Video file not found on disk", 404
mime_type = guess_video_mimetype(full_path)
file_size = os.path.getsize(full_path)
range_header = request.headers.get("Range")
if range_header:
byte_range = parse_range_header(range_header, file_size)
if not byte_range:
return Response(
status=416,
headers={"Content-Range": f"bytes */{file_size}"},
)
start, end = byte_range
response = Response(
iter_file_range(full_path, start, end),
status=206,
mimetype=mime_type,
direct_passthrough=True,
)
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = str(end - start + 1)
response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
response.headers["Content-Disposition"] = content_disposition(os.path.basename(full_path))
return response
response = send_file(
full_path,
mimetype=mime_type,
as_attachment=False,
download_name=os.path.basename(full_path),
conditional=True,
)
response.headers["Accept-Ranges"] = "bytes"
return response
@app.route("/api/review/submit/<int:task_id>", methods=["POST"])
def api_submit_review(task_id):
"""Submit review verdict for a task via the generic complete endpoint."""
data = request.get_json(silent=True) or {}
verdict = data.get("verdict") # "false_positive", "correct", or other string
if not verdict:
return jsonify({"error": "verdict is required"}), 400
try:
api_response = api_post(f"task/{task_id}/complete", {
"results": {
"review_verdict": verdict,
},
})
return jsonify({
"status": "completed",
"task_id": task_id,
"verdict": verdict,
"api_response": api_response,
})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
@app.route("/api/review/skip/<int:task_id>", methods=["POST"])
def api_skip_review(task_id):
"""Skip (unassign + reset) a task so it returns to the PENDING queue."""
try:
result = api_post(f"task/{task_id}/unassign", {"reset": 1})
return jsonify({"status": "skipped", "task_id": task_id})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
@app.route("/api/review/stats")
def api_stats():
"""Return a summary of REVIEW tasks (via two API calls)."""
try:
pending = api_get("tasks", {"type": "REVIEW", "status": "PENDING"}) or {}
completed = api_get("tasks", {"type": "REVIEW", "status": "COMPLETED"}) or {}
failed = api_get("tasks", {"type": "REVIEW", "status": "FAILED"}) or {}
return jsonify({
"total_in_queue": pending.get("total", 0),
"completed": completed.get("total", 0),
"failed": failed.get("total", 0),
})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
_INDEX_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review Queue</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:22px}.header .meta{font-size:13px;opacity:.75}
.container{max-width:1280px;margin:30px auto;padding:0 20px}
.stats{background:#fff;border-radius:8px;padding:16px 20px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;gap:24px;flex-wrap:wrap}
.stat{text-align:center}.stat .num{font-size:24px;font-weight:700;color:#007bff}.stat .lbl{font-size:11px;color:#888;text-transform:uppercase}
.card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
table{width:100%;border-collapse:collapse}
th{background:#f8f9fa;padding:12px 14px;text-align:left;font-size:12px;text-transform:uppercase;color:#666;border-bottom:2px solid #e9ecef}
td{padding:12px 14px;border-bottom:1px solid #e9ecef;font-size:13px}
tr:hover{background:#fafafa}
.badge{display:inline-block;padding:3px 9px;border-radius:10px;font-size:11px;font-weight:600;background:#fff3cd;color:#856404}
.btn{padding:6px 14px;background:#007bff;color:#fff;text-decoration:none;border-radius:5px;font-size:12px;border:none;cursor:pointer}
.btn:hover{background:#0056b3}
.pagination{display:flex;justify-content:center;gap:6px;margin-top:20px}
.pagination a,.pagination span{padding:7px 13px;border-radius:5px;font-size:13px;text-decoration:none}
.pagination a{background:#fff;color:#007bff;border:1px solid #dee2e6}.pagination a:hover{background:#e9ecef}
.pagination .cur{background:#007bff;color:#fff}
.empty{text-align:center;padding:60px;color:#999}
</style>
</head>
<body>
<div class="header">
<h1>VideoDetect</h1>
<span class="meta">Review Queue &mdash; {{ total }} pending &mdash; Page {{ page }}/{{ total_pages }}</span>
</div>
<div class="container">
<div class="stats">
<div class="stat"><div class="num">{{ total }}</div><div class="lbl">In Queue</div></div>
<div class="stat"><div class="num">{{ per_page }}</div><div class="lbl">Per Page</div></div>
</div>
{% if tasks %}
<div class="card">
<table>
<thead><tr>
<th>ID</th><th>File Path</th><th>Resolution</th><th>Task Type</th><th>Status</th><th>Date</th><th></th>
</tr></thead>
<tbody>
{% for t in tasks %}
<tr>
<td>{{ t.id }}</td>
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ t.file_path }}">{{ t.file_path }}</td>
<td>{{ t.resolution or '&mdash;' }}</td>
<td><span class="badge">{{ t.task_type }}</span></td>
<td>{{ t.status }}</td>
<td>{{ t.created_at|string|truncate(19, True, '') if t.created_at else '&mdash;' }}</td>
<td><a href="{{ url_for('review_task', task_id=t.id, page=page) }}" class="btn">Review</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
{% if page > 1 %}<a href="?page={{ page-1 }}">&laquo; Prev</a>{% endif %}
<span class="cur">{{ page }}</span>
{% if page < total_pages %}<a href="?page={{ page+1 }}">Next &raquo;</a>{% endif %}
</div>
{% else %}
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW tasks have been completed.</p></div>
{% endif %}
</div>
</body></html>
"""
_REVIEW_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review #{{ video.id }}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:22px}.header a{color:#adb5bd;text-decoration:none}.header a:hover{color:#fff}
.container{max-width:960px;margin:30px auto;padding:0 20px}
.card{background:#fff;border-radius:8px;padding:22px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.card h2{font-size:16px;margin-bottom:14px;color:#1a1a2e}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.info-label{font-size:11px;text-transform:uppercase;color:#888;margin-bottom:3px}
.info-value{font-size:15px;font-weight:600}
.video-wrap{width:100%;background:#000;border-radius:6px;overflow:hidden}
video{width:100%;display:block}
.actions{display:flex;gap:12px;margin-top:14px}
.action-btn{flex:1;padding:14px;border:2px solid #dee2e6;border-radius:8px;background:#fff;cursor:pointer;font-size:15px;font-weight:600;transition:all .15s;text-align:center}
.action-btn:hover{border-color:#007bff;transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.1)}
.action-btn:disabled{opacity:.6;cursor:not-allowed;transform:none}
.action-btn.false-positive:hover{border-color:#dc3545;color:#dc3545;background:#fff5f5}
.action-btn.correct:hover{border-color:#28a745;color:#28a745;background:#f0fff4}
.skip-btn:hover{border-color:#6c757d;color:#6c757d;background:#f8f9fa}
.already{color:#28a745;font-weight:600;margin-top:8px;padding:14px;background:#d4edda;border-radius:8px;text-align:center}
#flash{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;color:#fff;font-weight:600;z-index:999;display:none}
</style>
</head>
<body>
<div id="flash"></div>
<div class="header">
<h1>VideoDetect</h1>
<a href="{{ return_queue_url }}">&laquo; Back to queue</a>
</div>
<div class="container">
{% if video.prev_results and video.prev_results.review_verdict %}
<div class="already">Previously reviewed: {{ video.prev_results.review_verdict }}</div>
{% endif %}
<div class="card">
<h2>Task information</h2>
<div class="grid">
<div><div class="info-label">Task ID</div><div class="info-value">{{ video.id }}</div></div>
<div><div class="info-label">Task Type</div><div class="info-value">{{ video.task_type }}</div></div>
<div><div class="info-label">Status</div><div class="info-value">{{ video.status }}</div></div>
<div><div class="info-label">Resolution</div><div class="info-value">{{ video.resolution or '&mdash;' }}</div></div>
<div><div class="info-label">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '&mdash;' }}s</div></div>
<div><div class="info-label">Assigned</div><div class="info-value">{{ video.assigned_at|string|truncate(19, True, '') if video.assigned_at else '—' }}</div></div>
</div>
<div style="margin-top:10px;font-size:12px;color:#888;word-break:break-all">{{ video.file_path }}</div>
</div>
<div class="card">
<h2>Video</h2>
<div class="video-wrap">
<video controls preload="metadata" id="review-video">
<source src="/video/{{ video.video_id }}"{% if video.mime_type.startswith('video/') %} type="{{ video.mime_type }}"{% endif %}>
Video playback not supported.
</video>
</div>
</div>
<div class="card">
<h2>Review Actions</h2>
<div class="actions">
<button class="action-btn false-positive" onclick="submitVerdict('false_positive')">False Positive</button>
<button class="action-btn correct" onclick="submitVerdict('correct')">Correct</button>
<button class="action-btn skip-btn" onclick="skipTask()">Skip</button>
</div>
</div>
</div>
<script>
const returnQueueUrl = {{ return_queue_url|tojson }};
async function submitVerdict(verdict) {
setActionButtonsDisabled(true);
showFlash('Submitting verdict...', '#007bff');
try {
const resp = await fetch(`/api/review/submit/{{ video.id }}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({verdict})
});
const body = await resp.json().catch(() => ({}));
if (resp.ok) {
showFlash('Verdict submitted', '#28a745');
setTimeout(() => location.href = returnQueueUrl, 1000);
} else {
alert(body.error || 'Submission failed');
setActionButtonsDisabled(false);
}
} catch (err) {
alert(`Submission failed: ${err.message}`);
setActionButtonsDisabled(false);
}
}
async function skipTask() {
if (!confirm('Skip this task? It will be returned to the PENDING queue.')) return;
setActionButtonsDisabled(true);
showFlash('Skipping task...', '#6c757d');
try {
const resp = await fetch(`/api/review/skip/{{ video.id }}`, {method: 'POST'});
if (resp.ok) {
showFlash('Task skipped, returned to queue', '#6c757d');
setTimeout(() => location.href = returnQueueUrl, 1000);
} else {
alert('Skip failed');
setActionButtonsDisabled(false);
}
} catch (err) {
alert(`Skip failed: ${err.message}`);
setActionButtonsDisabled(false);
}
}
function setActionButtonsDisabled(disabled) {
document.querySelectorAll('.action-btn').forEach((button) => {
button.disabled = disabled;
});
}
function showFlash(msg, color) {
const el = document.getElementById('flash');
if (!el) return;
el.textContent = msg; el.style.background = color; el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 2500);
}
</script>
</body></html>
"""
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
app.run(host="0.0.0.0", port=port, debug=debug)