Browse Source

OTB Cloud v1.1.0-alpha4: dual-GPU video pipeline and health metrics

master
Don Kingdon 2 weeks ago
parent
commit
386a6783d7
  1. 110
      PROJECT_STATE.md
  2. 15
      README.md
  3. 2
      VERSION
  4. 407
      app/main/routes.py
  5. 150
      app/services/video_jobs.py
  6. 383
      app/services/video_worker.py
  7. 2
      app/templates/cloud/dashboard.html
  8. 19
      app/templates/cloud/device_files.html
  9. 51
      app/templates/cloud/health.html
  10. 134
      app/templates/cloud/video_jobs.html
  11. 384
      app/templates/cloud/workshop.html

110
PROJECT_STATE.md

@ -1,51 +1,85 @@
# PROJECT_STATE.md # PROJECT_STATE.md
Project: OTB Cloud Project: OTB Cloud
Version: v1.1.0-alpha3 Version: v1.1.0-alpha4
Updated: 2026-04-19 Updated: 2026-04-20
Location: /opt/otb_cloud Location: /opt/otb_cloud
## Current State ## Current State
OTB Cloud now has a functioning workshop-driven video processing pipeline. OTB Cloud now has a working multi-profile, multi-GPU video processing pipeline integrated into the tenant storage platform.
### Confirmed Working ### Confirmed Working
- Portal and branded UI shell - Portal-branded OTB Cloud dashboard
- Device browser - Device creation and browsing
- File selection flow into Video Workshop - File-ID based workshop staging
- Device-specific Video Workspace access from dashboard
- Video Workshop page - Video Workshop page
- Enqueue API - Multi-profile processing selection:
- Jobs API - default
- MariaDB-backed video_jobs integration - compress
- Tenant/device path resolution for queued jobs - hq
- Worker service startup and queue pickup - Manual rotation override option:
- Worker-side absolute path resolution from tenant storage_root - auto/default behavior when unchecked
- Intel iGPU processing path - selectable 90 / 180 / 270 override when enabled
- Successful completed output for device 27 (ripper) - Job queue API and job listing API
- File-ID based source resolution
### Latest Proven Result - Output routing to:
A queued workshop job for: - devices/<device>/video/
- source file: 05142013003.mp4 - Profile-specific output filenames
- device: 27 (ripper) - Completed job actions:
- View
completed successfully with: - Send to LTS
- assigned_processor: intel - Download Output
- status: complete - Delete
- progress_percent: 100 - Failed job delete action
- output_relative_path: - LTS routing by file type:
devices/ripper/originals/20260413T210325474049Z__05142013003_processed.mp4 - lts/video
- lts/archived
- lts/pictures
- Health page
- Lifetime processing metrics retained after visible job deletion
- Intel + AMD GPU processing both in service
- GPU time accounting active in Health page
- Global video jobs route exists in codebase
- Processed video section exists in device browser flow
### Processing / GPU Behavior
Current live behavior:
- both GPUs take jobs
- AMD prioritizes heavier / HQ work first
- Intel handles lighter work
- workers continue taking suitable jobs from the queue batch as available
### Latest Proven Health State
Health page currently shows stable cumulative values including:
- uploaded file counts and space
- LTS counts and space
- archive counts and space
- total jobs
- completed jobs
- failed jobs
- cumulative GPU time that does not zero out when workshop cards are deleted
### Current Storage Layout
- originals remain in device originals tree
- processed outputs go to:
- devices/<device>/video/
- LTS destinations include:
- lts/video/
- lts/archived/
- lts/pictures/
## Known Remaining Improvements ## Known Remaining Improvements
- Jobs panel is still raw JSON instead of a polished table/cards view - README is now being realigned to actual live state
- Failed jobs do not yet surface log_excerpt nicely in UI - Global video jobs page should be fully wired into UI navigation and polished
- No direct preview/download button for completed outputs in workshop - Dashboard template still contains some mixed button class styles that should be normalized
- No health/storage/GPU dashboard panel yet - Health page can be expanded with per-processor breakdown later
- No explicit processor chooser in UI - Processing metrics can be refined further into Intel/AMD/CPU buckets if desired
- Output placement may later deserve a dedicated derived/video output area - Output browsing UX can still be improved further with richer previewing and filtering
- Existing patch helper scripts were moved out of repo to keep git clean
## Recommended Next Step ## Recommended Next Step
Proceed to alpha3-b: Proceed after alpha4 with:
- replace raw JSON jobs output with styled job cards/table 1. global video jobs page polish and filters
- add output links for completed jobs 2. per-processor GPU metrics split (Intel / AMD / CPU)
- add visible failure details from log_excerpt 3. scheduler documentation and/or scheduler UI visibility
- add storage/GPU/worker health panel 4. processed output browsing improvements in device view

15
README.md

@ -1,5 +1,20 @@
# OTB Cloud # OTB Cloud
## v1.1.0-alpha4 - 2026-04-20
- Promoted project state to alpha4 based on current live multi-GPU video pipeline
- Added file-ID based workshop queueing and source resolution
- Added multi-profile processing with distinct output naming
- Added manual rotation override controls in workshop
- Added processed output routing to devices/<device>/video/
- Added completed-job actions for View, Send to LTS, Download Output, and Delete
- Added failed-job delete action
- Added LTS storage routing for video, archived content, and pictures
- Added Health page with persistent cumulative processing and GPU-time tracking
- Confirmed health metrics remain intact even after workshop job cards are deleted
- Dashboard now exposes device-level Video Workspace access
- Dual GPU behavior in active use: AMD prioritizes heavier/HQ work, Intel handles lighter work
## v1.1.0-alpha3 - 2026-04-19 ## v1.1.0-alpha3 - 2026-04-19
- Added Video Workshop UI for queued processing - Added Video Workshop UI for queued processing

2
VERSION

@ -1 +1 @@
v1.1.0-alpha3 v1.1.0-alpha4

407
app/main/routes.py

@ -1285,6 +1285,28 @@ def browse_device_files(device_id: int):
parts = current_path.split("/") parts = current_path.split("/")
parent_path = "/".join(parts[:-1]) parent_path = "/".join(parts[:-1])
processed_videos = []
try:
from pathlib import Path
tenant = session.get("tenant") or "def"
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if tenant_row:
storage_root = Path(tenant_row["storage_root"])
device_root = storage_root / device["relative_path"]
video_dir = device_root / "video"
if video_dir.exists():
for p in sorted(video_dir.glob("*"), reverse=True):
if p.is_file():
processed_videos.append({
"name": p.name,
"relative_path": str(p.relative_to(storage_root)),
"size": p.stat().st_size,
})
except Exception:
processed_videos = []
return render_template( return render_template(
"cloud/device_files.html", "cloud/device_files.html",
user_email=session.get("otb_email"), user_email=session.get("otb_email"),
@ -1623,21 +1645,400 @@ def video_enqueue():
tenant = session.get("tenant") or 'def' tenant = session.get("tenant") or 'def'
device_id = data.get("device_id") device_id = data.get("device_id")
files = data.get("files", []) files = data.get("files", [])
profile = data.get("profile", "default") profiles = data.get("profiles", [])
rotation_override = data.get("rotation_override")
if not profiles:
profiles = ["default"]
job_ids = [] job_ids = []
for f in files: for f in files:
for profile in profiles:
job_id = create_video_job( job_id = create_video_job(
tenant=tenant, tenant=tenant,
device_id=device_id, device_id=device_id,
input_filename=f, source_file_id=f,
profile=profile profile=profile,
rotation_override=rotation_override
) )
job_ids.append(job_id) job_ids.append(job_id)
return jsonify({"status": "ok", "jobs": job_ids}) return jsonify({"status": "ok", "jobs": job_ids})
@bp.route("/video-jobs")
def global_video_jobs():
from app.db import get_db
from pathlib import Path
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return "Tenant not found", 404
tenant_id = tenant_row["id"]
storage_root = Path(tenant_row["storage_root"])
with db.cursor() as cur:
cur.execute(
"""
SELECT
vj.id,
vj.device_id,
d.device_name,
vj.source_file_id,
vj.source_relative_path,
vj.source_original_filename,
vj.requested_profile,
vj.requested_rotation_degrees,
vj.status,
vj.progress_percent,
vj.assigned_processor,
vj.output_relative_path,
vj.error_message,
vj.created_at,
vj.started_at,
vj.completed_at,
vj.gpu_seconds
FROM video_jobs vj
LEFT JOIN devices d ON d.id = vj.device_id
WHERE vj.tenant_id = %s
ORDER BY vj.id DESC
LIMIT 300
""",
(tenant_id,)
)
rows = cur.fetchall()
def safe_size(rel_path):
if not rel_path:
return None
p = storage_root / rel_path
try:
if p.exists() and p.is_file():
return p.stat().st_size
except Exception:
pass
return None
jobs = []
for r in rows:
jobs.append({
"id": r["id"],
"device_id": r["device_id"],
"device_name": r["device_name"] or f"Device {r['device_id']}",
"source_file_id": r["source_file_id"],
"filename": r["source_original_filename"],
"source_relative_path": r["source_relative_path"],
"profile": r["requested_profile"],
"rotation_override": r["requested_rotation_degrees"],
"status": r["status"],
"progress_percent": r["progress_percent"],
"assigned_processor": r["assigned_processor"],
"output_relative_path": r["output_relative_path"],
"error_message": r["error_message"],
"original_size": safe_size(r["source_relative_path"]),
"processed_size": safe_size(r["output_relative_path"]),
"gpu_seconds": r["gpu_seconds"] or 0,
"created_at": str(r["created_at"]) if r["created_at"] else "",
"started_at": str(r["started_at"]) if r["started_at"] else "",
"completed_at": str(r["completed_at"]) if r["completed_at"] else "",
})
return render_template("cloud/video_jobs.html", jobs=jobs)
@bp.route("/health")
def cloud_health():
from app.db import get_db
from pathlib import Path
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return "Tenant not found", 404
tenant_id = tenant_row["id"]
storage_root = Path(tenant_row["storage_root"])
def scan_dir(rel_path):
p = storage_root / rel_path
count = 0
total = 0
if p.exists():
for f in p.rglob("*"):
if f.is_file():
count += 1
total += f.stat().st_size
return count, total
uploaded_count, uploaded_bytes = scan_dir("devices")
lts_count, lts_bytes = scan_dir("lts")
archive_count, archive_bytes = scan_dir("archive")
total_used = 0
if storage_root.exists():
for f in storage_root.rglob("*"):
if f.is_file():
total_used += f.stat().st_size
with db.cursor() as cur:
cur.execute(
"""
SELECT
COALESCE(video_jobs_total,0) AS total_jobs,
COALESCE(video_jobs_complete,0) AS complete_jobs,
COALESCE(video_jobs_failed,0) AS failed_jobs,
COALESCE(gpu_seconds_total,0) AS gpu_seconds
FROM tenant_usage_metrics
WHERE tenant_id = %s
LIMIT 1
""",
(tenant_id,)
)
stats = cur.fetchone() or {
"total_jobs": 0,
"complete_jobs": 0,
"failed_jobs": 0,
"gpu_seconds": 0,
}
def human_bytes(n):
n = int(n or 0)
if n < 1024:
return f"{n} B"
if n < 1024**2:
return f"{n/1024:.1f} KB"
if n < 1024**3:
return f"{n/1024**2:.2f} MB"
return f"{n/1024**3:.2f} GB"
def human_seconds(n):
n = int(n or 0)
h = n // 3600
m = (n % 3600) // 60
s = n % 60
parts = []
if h:
parts.append(f"{h}h")
if m:
parts.append(f"{m}m")
parts.append(f"{s}s")
return " ".join(parts)
return render_template(
"cloud/health.html",
uploaded_count=uploaded_count,
uploaded_bytes=human_bytes(uploaded_bytes),
lts_count=lts_count,
lts_bytes=human_bytes(lts_bytes),
archive_count=archive_count,
archive_bytes=human_bytes(archive_bytes),
total_used=human_bytes(total_used),
total_jobs=stats["total_jobs"] or 0,
complete_jobs=stats["complete_jobs"] or 0,
failed_jobs=stats["failed_jobs"] or 0,
gpu_time=human_seconds(stats["gpu_seconds"] or 0),
)
@bp.route("/video-output/<int:job_id>/view")
def view_video_output(job_id):
from app.db import get_db
from pathlib import Path
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return "Tenant not found", 404
tenant_id = tenant_row["id"]
storage_root = tenant_row["storage_root"]
cur.execute(
"""
SELECT output_relative_path
FROM video_jobs
WHERE id = %s AND tenant_id = %s
LIMIT 1
""",
(job_id, tenant_id)
)
job = cur.fetchone()
if not job or not job["output_relative_path"]:
return "No output file for this job", 404
full_path = Path(storage_root) / job["output_relative_path"]
if not full_path.exists():
return "Output file missing on disk", 404
return send_file(full_path, as_attachment=False)
@bp.route("/video-output/<int:job_id>/send-to-lts", methods=["POST"])
def send_video_output_to_lts(job_id):
from app.db import get_db
from pathlib import Path
import shutil
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return jsonify({"ok": False, "error": "Tenant not found"}), 404
tenant_id = tenant_row["id"]
storage_root = Path(tenant_row["storage_root"])
cur.execute(
"""
SELECT id, output_relative_path
FROM video_jobs
WHERE id = %s AND tenant_id = %s
LIMIT 1
""",
(job_id, tenant_id)
)
job = cur.fetchone()
if not job or not job["output_relative_path"]:
return jsonify({"ok": False, "error": "Job output not found"}), 404
src = storage_root / job["output_relative_path"]
if not src.exists():
return jsonify({"ok": False, "error": "Output file missing on disk"}), 404
ext = src.suffix.lower()
if ext in [".mp4", ".mov", ".mkv", ".webm", ".avi"]:
lts_rel_dir = Path("lts") / "video"
elif ext in [".zip", ".tar", ".gz", ".7z", ".rar"]:
lts_rel_dir = Path("lts") / "archived"
elif ext in [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]:
lts_rel_dir = Path("lts") / "pictures"
else:
lts_rel_dir = Path("lts") / "archived"
lts_dir = storage_root / lts_rel_dir
lts_dir.mkdir(parents=True, exist_ok=True)
dest = lts_dir / src.name
if dest.exists():
stem = dest.stem
suffix = dest.suffix
n = 2
while True:
candidate = lts_dir / f"{stem}-{n}{suffix}"
if not candidate.exists():
dest = candidate
break
n += 1
shutil.move(str(src), str(dest))
with db.cursor() as cur:
cur.execute(
"""
UPDATE video_jobs
SET output_relative_path = %s
WHERE id = %s AND tenant_id = %s
""",
(str(dest.relative_to(storage_root)), job_id, tenant_id)
)
db.commit()
return jsonify({"ok": True, "output_relative_path": str(dest.relative_to(storage_root))})
@bp.route("/video-output/<int:job_id>/download")
def download_video_output(job_id):
from app.db import get_db
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id, storage_root FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return "Tenant not found", 404
tenant_id = tenant_row["id"]
storage_root = tenant_row["storage_root"]
cur.execute(
"""
SELECT output_relative_path, source_original_filename, status
FROM video_jobs
WHERE id = %s AND tenant_id = %s
LIMIT 1
""",
(job_id, tenant_id)
)
job = cur.fetchone()
if not job:
return "Job not found", 404
if not job["output_relative_path"]:
return "No output file for this job", 404
from pathlib import Path
full_path = Path(storage_root) / job["output_relative_path"]
if not full_path.exists():
return "Output file missing on disk", 404
download_name = Path(job["output_relative_path"]).name
return send_file(full_path, as_attachment=True, download_name=download_name)
@bp.route("/api/video/jobs/<int:job_id>/delete", methods=["POST"])
def video_job_delete(job_id):
from app.db import get_db
tenant = session.get("tenant") or "def"
db = get_db()
with db.cursor() as cur:
cur.execute("SELECT id FROM tenants WHERE slug = %s LIMIT 1", (tenant,))
tenant_row = cur.fetchone()
if not tenant_row:
return jsonify({"ok": False, "error": "tenant not found"}), 404
tenant_id = tenant_row["id"]
cur.execute(
"DELETE FROM video_jobs WHERE id = %s AND tenant_id = %s",
(job_id, tenant_id)
)
deleted = cur.rowcount
db.commit()
if not deleted:
return jsonify({"ok": False, "error": "job not found"}), 404
return jsonify({"ok": True, "deleted_id": job_id})
@bp.route("/api/video/jobs") @bp.route("/api/video/jobs")
def video_jobs(): def video_jobs():
tenant = session.get("tenant") or 'def' tenant = session.get("tenant") or 'def'

150
app/services/video_jobs.py

@ -12,61 +12,106 @@ def get_tenant_row(db, tenant):
return None return None
return row return row
def get_device_row(db, device_id): def _current_db_name(db):
cur = db.cursor() with db.cursor() as cur:
cur.execute("SELECT DATABASE() AS dbname")
row = cur.fetchone()
return row["dbname"]
def _table_exists(db, table_name):
dbname = _current_db_name(db)
with db.cursor() as cur:
cur.execute( cur.execute(
"SELECT id, device_name, relative_path FROM devices WHERE id = %s LIMIT 1", """
(device_id,) SELECT COUNT(*) AS c
FROM information_schema.tables
WHERE table_schema = %s AND table_name = %s
""",
(dbname, table_name)
) )
row = cur.fetchone() row = cur.fetchone()
if not row: return int(row["c"]) > 0
def _table_columns(db, table_name):
dbname = _current_db_name(db)
with db.cursor() as cur:
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s
""",
(dbname, table_name)
)
rows = cur.fetchall()
return {r["column_name"] for r in rows}
def _pick(cols, *names):
for n in names:
if n in cols:
return n
return None return None
return row
def resolve_source_relative_path(storage_root, device_relative_path, input_filename): def resolve_source_from_file_id(db, tenant_id, device_id, source_file_id):
base = Path(storage_root) / device_relative_path candidate_tables = ["files", "device_files", "uploaded_files"]
if not base.exists():
raise FileNotFoundError(f"Device base path not found: {base}")
candidates = [] for table in candidate_tables:
if not _table_exists(db, table):
continue
for p in base.rglob("*"): cols = _table_columns(db, table)
if not p.is_file():
id_col = _pick(cols, "id")
rel_col = _pick(cols, "relative_path", "source_relative_path", "path", "storage_relative_path")
orig_col = _pick(cols, "original_filename", "filename", "display_filename", "basename")
tenant_col = _pick(cols, "tenant_id")
device_col = _pick(cols, "device_id")
if not id_col or not rel_col or not orig_col:
continue continue
name = p.name
if name == input_filename or name.endswith("__" + input_filename):
candidates.append(p)
if not candidates: sql = f"SELECT {id_col} AS id, {rel_col} AS rel_path, {orig_col} AS orig_name FROM {table} WHERE {id_col} = %s"
raise FileNotFoundError( args = [source_file_id]
f"Could not locate source file for {input_filename} under {base}"
) if tenant_col:
sql += f" AND {tenant_col} = %s"
args.append(tenant_id)
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) if device_col:
chosen = candidates[0] sql += f" AND {device_col} = %s"
args.append(device_id)
rel = chosen.relative_to(Path(storage_root)) sql += " LIMIT 1"
return str(rel)
def create_video_job(tenant, device_id, input_filename, profile="default"): with db.cursor() as cur:
cur.execute(sql, tuple(args))
row = cur.fetchone()
if row:
return {
"source_relative_path": row["rel_path"],
"source_original_filename": row["orig_name"],
}
raise RuntimeError(
f"Could not resolve file metadata for source_file_id={source_file_id}. "
f"Tried tables: files, device_files, uploaded_files"
)
def create_video_job(tenant, device_id, source_file_id, profile="default", rotation_override=None):
db = get_db() db = get_db()
tenant_row = get_tenant_row(db, tenant) tenant_row = get_tenant_row(db, tenant)
if not tenant_row: if not tenant_row:
raise Exception(f"Tenant not found: {tenant}") raise Exception(f"Tenant not found: {tenant}")
device_row = get_device_row(db, device_id)
if not device_row:
raise Exception(f"Device not found: {device_id}")
tenant_id = tenant_row["id"] tenant_id = tenant_row["id"]
storage_root = tenant_row["storage_root"]
device_relative_path = device_row["relative_path"]
source_relative_path = resolve_source_relative_path( file_meta = resolve_source_from_file_id(
storage_root, db=db,
device_relative_path, tenant_id=tenant_id,
input_filename device_id=device_id,
source_file_id=int(source_file_id),
) )
cur = db.cursor() cur = db.cursor()
@ -79,16 +124,36 @@ def create_video_job(tenant, device_id, input_filename, profile="default"):
source_relative_path, source_relative_path,
source_original_filename, source_original_filename,
requested_profile, requested_profile,
requested_rotation_degrees,
requested_gpu_preference, requested_gpu_preference,
status, status,
progress_percent progress_percent
) VALUES (%s, %s, NULL, %s, %s, %s, 'auto', 'queued', 0) ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'auto', 'queued', 0)
""", """,
(tenant_id, device_id, source_relative_path, input_filename, profile) (
tenant_id,
device_id,
int(source_file_id),
file_meta["source_relative_path"],
file_meta["source_original_filename"],
profile,
rotation_override,
)
) )
db.commit() db.commit()
return cur.lastrowid return cur.lastrowid
def _safe_size(storage_root, rel_path):
if not rel_path:
return None
try:
p = Path(storage_root) / rel_path
if p.exists() and p.is_file():
return p.stat().st_size
except Exception:
return None
return None
def list_jobs_for_tenant(tenant): def list_jobs_for_tenant(tenant):
db = get_db() db = get_db()
@ -97,6 +162,7 @@ def list_jobs_for_tenant(tenant):
return [] return []
tenant_id = tenant_row["id"] tenant_id = tenant_row["id"]
storage_root = tenant_row["storage_root"]
cur = db.cursor() cur = db.cursor()
cur.execute( cur.execute(
@ -104,8 +170,11 @@ def list_jobs_for_tenant(tenant):
SELECT SELECT
id, id,
device_id, device_id,
source_file_id,
source_relative_path,
source_original_filename, source_original_filename,
requested_profile, requested_profile,
requested_rotation_degrees,
status, status,
progress_percent, progress_percent,
assigned_processor, assigned_processor,
@ -126,16 +195,23 @@ def list_jobs_for_tenant(tenant):
out = [] out = []
for r in rows: for r in rows:
original_size = _safe_size(storage_root, r["source_relative_path"])
processed_size = _safe_size(storage_root, r["output_relative_path"])
out.append({ out.append({
"id": r["id"], "id": r["id"],
"device_id": r["device_id"], "device_id": r["device_id"],
"source_file_id": r["source_file_id"],
"filename": r["source_original_filename"], "filename": r["source_original_filename"],
"profile": r["requested_profile"], "profile": r["requested_profile"],
"rotation_override": r["requested_rotation_degrees"],
"status": r["status"], "status": r["status"],
"progress_percent": r["progress_percent"], "progress_percent": r["progress_percent"],
"assigned_processor": r["assigned_processor"], "assigned_processor": r["assigned_processor"],
"output_relative_path": r["output_relative_path"], "output_relative_path": r["output_relative_path"],
"error_message": r["error_message"], "error_message": r["error_message"],
"original_size": original_size,
"processed_size": processed_size,
"created_at": str(r["created_at"]) if r["created_at"] is not None else None, "created_at": str(r["created_at"]) if r["created_at"] is not None else None,
"started_at": str(r["started_at"]) if r["started_at"] is not None else None, "started_at": str(r["started_at"]) if r["started_at"] is not None else None,
"completed_at": str(r["completed_at"]) if r["completed_at"] is not None else None, "completed_at": str(r["completed_at"]) if r["completed_at"] is not None else None,

383
app/services/video_worker.py

@ -1,17 +1,58 @@
import time import time
import subprocess import subprocess
import json
import threading
import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from app import create_app from app import create_app
from app.db import get_db from app.db import get_db
from app.services.gpu_select import select_processor, release
INTEL_DEV = "/dev/dri/renderD129" INTEL_DEV = "/dev/dri/renderD129"
AMD_DEV = "/dev/dri/renderD128" AMD_DEV = "/dev/dri/renderD128"
def run_ffmpeg(cmd): def run_ffprobe_json(src):
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) cmd = [
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=duration:stream_tags=rotate",
"-of", "json",
src,
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
return {}
try:
return json.loads(result.stdout or "{}")
except Exception:
return {}
def get_rotation_degrees(src):
data = run_ffprobe_json(src)
streams = data.get("streams", [])
if not streams:
return 0
tags = streams[0].get("tags", {}) or {}
rotate_tag = tags.get("rotate")
if rotate_tag is not None:
try:
return int(rotate_tag) % 360
except Exception:
pass
return 0
def get_duration_seconds(src):
data = run_ffprobe_json(src)
streams = data.get("streams", [])
if not streams:
return None
duration = streams[0].get("duration")
try:
return float(duration)
except Exception:
return None
def build_absolute_source_path(db, job): def build_absolute_source_path(db, job):
with db.cursor() as cur: with db.cursor() as cur:
@ -24,69 +65,215 @@ def build_absolute_source_path(db, job):
if not tenant_row: if not tenant_row:
raise RuntimeError(f"Tenant id {job['tenant_id']} not found") raise RuntimeError(f"Tenant id {job['tenant_id']} not found")
storage_root = tenant_row["storage_root"] return str(Path(tenant_row["storage_root"]) / job["source_relative_path"])
return str(Path(storage_root) / job["source_relative_path"])
def build_absolute_output_path(db, job, src):
with db.cursor() as cur:
cur.execute(
"SELECT storage_root FROM tenants WHERE id = %s",
(job["tenant_id"],)
)
tenant_row = cur.fetchone()
cur.execute(
"SELECT relative_path FROM devices WHERE id = %s",
(job["device_id"],)
)
device_row = cur.fetchone()
if not tenant_row:
raise RuntimeError(f"Tenant id {job['tenant_id']} not found")
if not device_row:
raise RuntimeError(f"Device id {job['device_id']} not found")
storage_root = Path(tenant_row["storage_root"])
device_relative_path = Path(device_row["relative_path"])
out_dir = storage_root / device_relative_path / "video"
out_dir.mkdir(parents=True, exist_ok=True)
profile = (job.get("requested_profile") or "default").lower()
out_name = Path(src).stem + f"_{profile}_processed.mp4"
return str(out_dir / out_name)
def to_relative_output_path(db, job, absolute_output):
with db.cursor() as cur:
cur.execute(
"SELECT storage_root FROM tenants WHERE id = %s",
(job["tenant_id"],)
)
tenant_row = cur.fetchone()
if not tenant_row:
return absolute_output
try:
return str(Path(absolute_output).relative_to(Path(tenant_row["storage_root"])))
except Exception:
return absolute_output
def build_profile_settings(profile):
profile = (profile or "default").lower()
if profile == "compress":
return {"width": 720, "height": 1280, "va_bitrate": "1200k", "va_maxrate": "1400k", "va_bufsize": "2400k", "crf": "30"}
elif profile == "hq":
return {"width": 1080, "height": 1920, "va_bitrate": "4500k", "va_maxrate": "5000k", "va_bufsize": "9000k", "crf": "18"}
else:
return {"width": 900, "height": 1600, "va_bitrate": "2500k", "va_maxrate": "3000k", "va_bufsize": "5000k", "crf": "23"}
def build_filter_chain(src, settings, rotation_override=None, use_vaapi=True):
rotation = rotation_override if rotation_override in (90, 180, 270) else get_rotation_degrees(src)
filters = []
if rotation == 90:
filters.append("transpose=1")
elif rotation == 270:
filters.append("transpose=2")
elif rotation == 180:
filters.append("hflip,vflip")
if use_vaapi:
filters.append("format=nv12")
filters.append("hwupload")
filters.append(f"scale_vaapi=w={settings['width']}:h={settings['height']}:force_original_aspect_ratio=decrease")
else:
filters.append(f"scale={settings['width']}:{settings['height']}:force_original_aspect_ratio=decrease")
return ",".join(filters)
def ensure_metrics_row(db, tenant_id):
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO tenant_usage_metrics (tenant_id)
VALUES (%s)
ON DUPLICATE KEY UPDATE tenant_id = tenant_id
""",
(tenant_id,)
)
db.commit()
def bump_metrics(db, tenant_id, complete=False, failed=False, gpu_seconds=0):
ensure_metrics_row(db, tenant_id)
with db.cursor() as cur:
cur.execute(
"""
UPDATE tenant_usage_metrics
SET video_jobs_total = video_jobs_total + 1,
video_jobs_complete = video_jobs_complete + %s,
video_jobs_failed = video_jobs_failed + %s,
gpu_seconds_total = gpu_seconds_total + %s
WHERE tenant_id = %s
""",
(
1 if complete else 0,
1 if failed else 0,
int(gpu_seconds or 0),
tenant_id,
),
)
db.commit()
def update_progress(db, job_id, percent):
percent = max(5, min(99, int(percent)))
with db.cursor() as cur:
cur.execute(
"UPDATE video_jobs SET progress_percent = %s WHERE id = %s",
(percent, job_id),
)
db.commit()
def run_ffmpeg_with_progress(db, job_id, cmd, duration_seconds):
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
log_lines = []
time_re = re.compile(r"time=(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)")
for line in process.stdout:
log_lines.append(line.rstrip())
if len(log_lines) > 200:
log_lines = log_lines[-200:]
def process_job(db, job): if duration_seconds and duration_seconds > 0:
m = time_re.search(line)
if m:
hh = int(m.group(1))
mm = int(m.group(2))
ss = float(m.group(3))
current = hh * 3600 + mm * 60 + ss
percent = (current / duration_seconds) * 100.0
update_progress(db, job_id, percent)
process.wait()
return process.returncode, "\n".join(log_lines[-120:])
def process_job(db, job, processor):
job_id = job["id"] job_id = job["id"]
src = build_absolute_source_path(db, job) src = build_absolute_source_path(db, job)
profile = job["requested_profile"] profile = job["requested_profile"]
rotation_override = job.get("requested_rotation_degrees")
processor = select_processor()
device = INTEL_DEV if processor == "intel" else AMD_DEV device = INTEL_DEV if processor == "intel" else AMD_DEV
output = build_absolute_output_path(db, job, src)
output = str(Path(src).with_name(Path(src).stem + "_processed.mp4")) settings = build_profile_settings(profile)
duration_seconds = get_duration_seconds(src)
if profile == "portrait_web":
vf = "format=nv12,hwupload,scale_vaapi=w=720:h=1280:force_original_aspect_ratio=decrease"
else:
vf = "format=nv12,hwupload,scale_vaapi=w=1280:h=720:force_original_aspect_ratio=decrease"
if processor in ("intel", "amd"): if processor in ("intel", "amd"):
vf = build_filter_chain(src, settings, rotation_override=rotation_override, use_vaapi=True)
cmd = [ cmd = [
"ffmpeg", "-hide_banner", "-y", "ffmpeg", "-hide_banner", "-y",
"-noautorotate",
"-fflags", "+genpts",
"-vaapi_device", device, "-vaapi_device", device,
"-i", src, "-i", src,
"-vf", vf, "-vf", vf,
"-c:v", "h264_vaapi", "-c:v", "h264_vaapi",
"-b:v", "3M", "-b:v", settings["va_bitrate"],
"-maxrate", "3M", "-maxrate", settings["va_maxrate"],
"-bufsize", "6M", "-bufsize", settings["va_bufsize"],
"-c:a", "aac", "-b:a", "128k", "-c:a", "aac",
"-b:a", "160k",
"-ac", "2",
"-ar", "48000",
"-movflags", "+faststart",
output output
] ]
else: else:
vf = build_filter_chain(src, settings, rotation_override=rotation_override, use_vaapi=False)
cmd = [ cmd = [
"ffmpeg", "-hide_banner", "-y", "ffmpeg", "-hide_banner", "-y",
"-noautorotate",
"-fflags", "+genpts",
"-i", src, "-i", src,
"-vf", vf,
"-c:v", "libx264", "-c:v", "libx264",
"-preset", "medium", "-preset", "medium",
"-crf", "23", "-crf", settings["crf"],
"-c:a", "aac", "-b:a", "128k", "-c:a", "aac",
"-b:a", "160k",
"-ac", "2",
"-ar", "48000",
"-movflags", "+faststart",
output output
] ]
start = datetime.utcnow() start = datetime.utcnow()
try: try:
result = run_ffmpeg(cmd) returncode, log_excerpt = run_ffmpeg_with_progress(db, job_id, cmd, duration_seconds)
end = datetime.utcnow() end = datetime.utcnow()
gpu_seconds = max(0, int((end - start).total_seconds()))
with db.cursor() as cur: with db.cursor() as cur:
if result.returncode == 0: if returncode == 0:
rel_output = None rel_output = to_relative_output_path(db, job, output)
try:
with db.cursor() as cur2:
cur2.execute(
"SELECT storage_root FROM tenants WHERE id = %s",
(job["tenant_id"],)
)
tenant_row = cur2.fetchone()
if tenant_row:
rel_output = str(Path(output).relative_to(Path(tenant_row["storage_root"])))
except Exception:
rel_output = output
cur.execute( cur.execute(
""" """
UPDATE video_jobs UPDATE video_jobs
@ -94,6 +281,7 @@ def process_job(db, job):
assigned_processor=%s, assigned_processor=%s,
output_relative_path=%s, output_relative_path=%s,
progress_percent=100, progress_percent=100,
gpu_seconds=%s,
started_at=COALESCE(started_at, %s), started_at=COALESCE(started_at, %s),
completed_at=%s, completed_at=%s,
log_excerpt=%s, log_excerpt=%s,
@ -102,31 +290,40 @@ def process_job(db, job):
""", """,
( (
processor, processor,
rel_output or output, rel_output,
gpu_seconds,
start, start,
end, end,
(result.stderr or "")[:1000], log_excerpt[:4000],
job_id, job_id,
), ),
) )
db.commit()
bump_metrics(db, job["tenant_id"], complete=True, failed=False, gpu_seconds=gpu_seconds)
else: else:
cur.execute( with db.cursor() as cur2:
cur2.execute(
""" """
UPDATE video_jobs UPDATE video_jobs
SET status='failed', SET status='failed',
assigned_processor=%s,
gpu_seconds=%s,
error_message=%s, error_message=%s,
log_excerpt=%s, log_excerpt=%s,
completed_at=%s completed_at=%s
WHERE id=%s WHERE id=%s
""", """,
( (
processor,
gpu_seconds,
"ffmpeg failed", "ffmpeg failed",
(result.stderr or "")[:4000], log_excerpt[:4000],
end, end,
job_id, job_id,
), ),
) )
db.commit() db.commit()
bump_metrics(db, job["tenant_id"], complete=False, failed=True, gpu_seconds=gpu_seconds)
except Exception as e: except Exception as e:
with db.cursor() as cur: with db.cursor() as cur:
@ -134,64 +331,122 @@ def process_job(db, job):
""" """
UPDATE video_jobs UPDATE video_jobs
SET status='failed', SET status='failed',
assigned_processor=%s,
error_message=%s, error_message=%s,
completed_at=UTC_TIMESTAMP() completed_at=UTC_TIMESTAMP()
WHERE id=%s WHERE id=%s
""", """,
(str(e)[:1000], job_id), (processor, str(e)[:1000], job_id),
) )
db.commit() db.commit()
finally: bump_metrics(db, job["tenant_id"], complete=False, failed=True, gpu_seconds=0)
if processor in ("intel", "amd"):
release(processor)
def run_worker(): def claim_next_job(db, processor):
app = create_app() """
Intel: prefer default/compress, then anything.
AMD: prefer hq, then anything.
"""
preferred_profile = "hq" if processor == "amd" else None
with app.app_context(): with db.cursor() as cur:
print("video worker started", flush=True) cur.execute("START TRANSACTION")
while True: job = None
try:
db = get_db()
try: if preferred_profile:
db.rollback() cur.execute(
except Exception: """
pass SELECT *
FROM video_jobs
WHERE status='queued' AND requested_profile = %s
ORDER BY id ASC
LIMIT 1
FOR UPDATE
""",
(preferred_profile,),
)
job = cur.fetchone()
with db.cursor() as cur: if not job:
if processor == "intel":
cur.execute( cur.execute(
""" """
SELECT * SELECT *
FROM video_jobs FROM video_jobs
WHERE status='queued' WHERE status='queued' AND requested_profile IN ('default','compress')
ORDER BY id ASC ORDER BY id ASC
LIMIT 1 LIMIT 1
FOR UPDATE
""" """
) )
job = cur.fetchone() job = cur.fetchone()
if job: if not job:
print( cur.execute(
f"worker picked job id={job['id']} source={job['source_relative_path']}", """
flush=True SELECT *
FROM video_jobs
WHERE status='queued'
ORDER BY id ASC
LIMIT 1
FOR UPDATE
"""
) )
job = cur.fetchone()
if not job:
db.rollback()
return None
cur.execute( cur.execute(
""" """
UPDATE video_jobs UPDATE video_jobs
SET status='processing', SET status='processing',
assigned_processor=%s,
started_at=COALESCE(started_at, UTC_TIMESTAMP()), started_at=COALESCE(started_at, UTC_TIMESTAMP()),
progress_percent=5 progress_percent=5
WHERE id=%s WHERE id=%s
""", """,
(job["id"],), (processor, job["id"]),
) )
db.commit() db.commit()
job["assigned_processor"] = processor
return job
def worker_loop(app, processor):
with app.app_context():
print(f"{processor} worker started", flush=True)
while True:
try:
db = get_db()
try:
db.rollback()
except Exception:
pass
process_job(db, job) job = claim_next_job(db, processor)
if job:
print(f"{processor} worker picked job id={job['id']} source={job['source_relative_path']}", flush=True)
process_job(db, job, processor)
else:
time.sleep(2)
except Exception as e: except Exception as e:
print(f"worker loop error: {e}", flush=True) print(f"{processor} worker loop error: {e}", flush=True)
time.sleep(2)
def run_worker():
app = create_app()
time.sleep(5) threads = [
threading.Thread(target=worker_loop, args=(app, "intel"), daemon=True),
threading.Thread(target=worker_loop, args=(app, "amd"), daemon=True),
]
for t in threads:
t.start()
while True:
time.sleep(60)

2
app/templates/cloud/dashboard.html

@ -17,6 +17,7 @@
<a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a> <a class="portal-btn primary" href="{{ url_for('main.add_device') }}">Add Device</a>
<a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a> <a class="portal-btn primary" href="{{ url_for('main.create_android_device') }}">Add Android Device</a>
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Archive Workspace</a> <a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Archive Workspace</a>
<a href="/video-jobs" class="btn btn-secondary">Video Jobs</a>
<a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a> <a class="portal-btn" href="{{ url_for('main.deleted_files') }}">Deleted Files</a>
<a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a> <a class="portal-btn" href="https://otb-billing.outsidethebox.top/portal/services">Back to Services</a>
<a class="portal-btn" href="/auth/logout">Logout</a> <a class="portal-btn" href="/auth/logout">Logout</a>
@ -70,6 +71,7 @@
<span class="portal-btn" style="opacity:0.6;cursor:not-allowed;">APK Upload Only</span> <span class="portal-btn" style="opacity:0.6;cursor:not-allowed;">APK Upload Only</span>
{% endif %} {% endif %}
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a> <a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id) }}">Browse Files</a>
<a href="/workshop/{{ device.id }}" class="btn btn-secondary">Video Workspace</a>
<form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}"> <form method="post" action="{{ url_for('main.delete_device', device_id=device.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button> <button class="portal-btn" type="submit" onclick="return confirm('Remove device {{ device.device_name|e }}? This only works if no files are linked to it.');">Remove Device</button>
</form> </form>

19
app/templates/cloud/device_files.html

@ -346,7 +346,7 @@
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} {% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}"> <div class="otb-gallery-card" title="Name: {{ visible_name }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap"> <div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.original_filename }}" form="bulk-actions-form"> <input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" data-filename="{{ file.original_filename }}" form="bulk-actions-form">
{% if is_image %} {% if is_image %}
<img <img
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}" src="{{ url_for('main.thumbnail_file', file_id=file.id) }}"
@ -413,7 +413,7 @@
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} {% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %}
<tr><th>Select</th> <tr><th>Select</th>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.original_filename }}" form="bulk-actions-form"> <input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" data-filename="{{ file.original_filename }}" form="bulk-actions-form">
</td> </td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div class="otb-list-name-wrap"> <div class="otb-list-name-wrap">
@ -543,15 +543,18 @@
<script> <script>
window.sendToWorkshop = function () { window.sendToWorkshop = function () {
const checked = Array.from( const selected = Array.from(
document.querySelectorAll("input[name='selected_files']:checked, input.row-check:checked") document.querySelectorAll("input[name='selected_files']:checked, input.row-check:checked")
) )
.map(cb => cb.value) .map(cb => ({
.filter(v => v && v !== "on"); id: cb.value,
filename: cb.dataset.filename || cb.value
}))
.filter(v => v && v.id && v.id !== "on");
console.log("workshop checked =", checked); console.log("workshop selected =", selected);
if (checked.length === 0) { if (selected.length === 0) {
alert("No files selected"); alert("No files selected");
return; return;
} }
@ -559,7 +562,7 @@ window.sendToWorkshop = function () {
const parts = window.location.pathname.split("/"); const parts = window.location.pathname.split("/");
const deviceId = parts[2]; const deviceId = parts[2];
localStorage.setItem("videoSelection", JSON.stringify(checked)); localStorage.setItem("videoSelection", JSON.stringify(selected));
window.location.href = "/workshop/" + deviceId; window.location.href = "/workshop/" + deviceId;
}; };
</script> </script>

51
app/templates/cloud/health.html

@ -0,0 +1,51 @@
{% extends "portal_base.html" %}
{% block title %}OTB Cloud Health{% endblock %}
{% block portal_content %}
<div style="max-width:1100px;margin:0 auto;">
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">OTB Cloud Health</h1>
<p class="portal-page-subtitle">Storage, processing, and usage overview.</p>
</div>
<div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;">
<a class="portal-btn" href="/dashboard">Back to Dashboard</a>
</div>
</div>
<div class="job-list" style="display:flex;flex-direction:column;gap:14px;margin-top:18px;">
<div class="job-card" style="background:rgba(255,255,255,0.04);padding:14px;border-radius:14px;">
<h2>Uploads</h2>
<p>Files: <strong>{{ uploaded_count }}</strong></p>
<p>Space used: <strong>{{ uploaded_bytes }}</strong></p>
</div>
<div class="job-card" style="background:rgba(255,255,255,0.04);padding:14px;border-radius:14px;">
<h2>LTS Storage</h2>
<p>Files: <strong>{{ lts_count }}</strong></p>
<p>Space used: <strong>{{ lts_bytes }}</strong></p>
</div>
<div class="job-card" style="background:rgba(255,255,255,0.04);padding:14px;border-radius:14px;">
<h2>Archive Storage</h2>
<p>Files: <strong>{{ archive_count }}</strong></p>
<p>Space used: <strong>{{ archive_bytes }}</strong></p>
</div>
<div class="job-card" style="background:rgba(255,255,255,0.04);padding:14px;border-radius:14px;">
<h2>Processing</h2>
<p>Total jobs: <strong>{{ total_jobs }}</strong></p>
<p>Completed jobs: <strong>{{ complete_jobs }}</strong></p>
<p>Failed jobs: <strong>{{ failed_jobs }}</strong></p>
<p>Total GPU time used: <strong>{{ gpu_time }}</strong></p>
</div>
<div class="job-card" style="background:rgba(255,255,255,0.04);padding:14px;border-radius:14px;">
<h2>Disk Usage</h2>
<p>Total tenant storage used: <strong>{{ total_used }}</strong></p>
<p>Storage costs: <strong>placeholder</strong></p>
</div>
</div>
</div>
{% endblock %}

134
app/templates/cloud/video_jobs.html

@ -0,0 +1,134 @@
{% extends "portal_base.html" %}
{% block title %}Global Video Jobs - OTB Cloud{% endblock %}
{% block portal_content %}
<style>
.jobs-wrap { max-width: 1200px; margin: 0 auto; }
.job-list { display:flex; flex-direction:column; gap:14px; margin-top:14px; }
.job-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:14px; }
.job-head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:8px; }
.job-file { font-weight:700; word-break:break-word; }
.job-head-right { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.job-badge { display:inline-block; padding:4px 10px; border-radius:999px; font-size:0.85rem; font-weight:700; border:1px solid rgba(255,255,255,0.14); }
.job-badge.queued { background:rgba(245,158,11,0.18); }
.job-badge.processing { background:rgba(59,130,246,0.18); }
.job-badge.complete { background:rgba(34,197,94,0.18); }
.job-badge.failed { background:rgba(239,68,68,0.18); }
.job-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; margin-top:8px; }
.job-meta { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px; }
.job-label { font-size:0.8rem; opacity:0.75; margin-bottom:4px; }
.job-value { word-break:break-word; }
.job-path, .job-error { margin-top:10px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.03); word-break:break-word; }
.job-error { border:1px solid rgba(239,68,68,0.22); }
.job-empty { padding:16px; border-radius:12px; background:rgba(255,255,255,0.04); opacity:0.85; }
.filter-note { opacity:0.8; margin-top:6px; }
</style>
<div class="jobs-wrap">
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Global Video Jobs</h1>
<p class="portal-page-subtitle">All video processing jobs across your devices.</p>
<div class="filter-note">Newest first. Use this as a cross-device overview.</div>
</div>
<div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;">
<a class="portal-btn" href="/dashboard">Back to Dashboard</a>
<a class="portal-btn" href="/health">Health</a>
</div>
</div>
{% if jobs and jobs|length > 0 %}
<div class="job-list">
{% for j in jobs %}
<div class="job-card">
<div class="job-head">
<div class="job-file">{{ j.filename }}</div>
<div class="job-head-right">
{% if j.output_relative_path and j.status == 'complete' %}
<a class="portal-btn" href="/video-output/{{ j.id }}/view" target="_blank">View</a>
<button class="portal-btn" type="button" onclick="sendToLTS({{ j.id }})">Send to LTS</button>
<a class="portal-btn" href="/video-output/{{ j.id }}/download">Download Output</a>
{% endif %}
{% if j.status in ['failed', 'complete'] %}
<button class="portal-btn" type="button" onclick="deleteJob({{ j.id }})">Delete</button>
{% endif %}
<span class="job-badge {{ j.status|lower }}">{{ j.status|upper }}</span>
</div>
</div>
<div class="job-grid">
<div class="job-meta"><div class="job-label">Job ID</div><div class="job-value">{{ j.id }}</div></div>
<div class="job-meta"><div class="job-label">Device</div><div class="job-value">{{ j.device_name }} ({{ j.device_id }})</div></div>
<div class="job-meta"><div class="job-label">Profile</div><div class="job-value">{{ j.profile }}</div></div>
<div class="job-meta"><div class="job-label">Rotation Override</div><div class="job-value">{{ j.rotation_override or 'auto' }}</div></div>
<div class="job-meta"><div class="job-label">Processor</div><div class="job-value">{{ j.assigned_processor or 'pending' }}</div></div>
<div class="job-meta"><div class="job-label">Progress</div><div class="job-value">{{ j.progress_percent }}%</div></div>
<div class="job-meta"><div class="job-label">Created</div><div class="job-value">{{ j.created_at }}</div></div>
<div class="job-meta"><div class="job-label">Started</div><div class="job-value">{{ j.started_at }}</div></div>
<div class="job-meta"><div class="job-label">Completed</div><div class="job-value">{{ j.completed_at }}</div></div>
<div class="job-meta"><div class="job-label">Original Size</div><div class="job-value" data-bytes="{{ j.original_size or '' }}"></div></div>
<div class="job-meta"><div class="job-label">Processed Size</div><div class="job-value" data-bytes="{{ j.processed_size or '' }}"></div></div>
<div class="job-meta"><div class="job-label">GPU Time</div><div class="job-value">{{ j.gpu_seconds }}s</div></div>
<div class="job-meta"><div class="job-label">Source File ID</div><div class="job-value">{{ j.source_file_id or '' }}</div></div>
</div>
{% if j.output_relative_path %}
<div class="job-path">
<div class="job-label">Output</div>
<div class="job-value">{{ j.output_relative_path }}</div>
</div>
{% endif %}
{% if j.error_message %}
<div class="job-error">
<div class="job-label">Error</div>
<div class="job-value">{{ j.error_message }}</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="job-empty">No video jobs yet.</div>
{% endif %}
</div>
<script>
function fmtBytes(v){
if(v === null || v === undefined || v === "") return "";
const n = Number(v);
if(!Number.isFinite(n)) return String(v);
if(n < 1024) return `${n} B`;
if(n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
if(n < 1024*1024*1024) return `${(n/1024/1024).toFixed(2)} MB`;
return `${(n/1024/1024/1024).toFixed(2)} GB`;
}
document.querySelectorAll("[data-bytes]").forEach(el => {
el.textContent = fmtBytes(el.getAttribute("data-bytes"));
});
async function deleteJob(jobId){
if(!confirm("Delete this job from the list?")) return;
const r = await fetch(`/api/video/jobs/${jobId}/delete`, { method: "POST" });
if(r.ok){
window.location.reload();
} else {
const text = await r.text();
alert(text || "Delete failed");
}
}
async function sendToLTS(jobId){
if(!confirm("Send this output to LTS storage?")) return;
const r = await fetch(`/video-output/${jobId}/send-to-lts`, { method: "POST" });
if(r.ok){
window.location.reload();
} else {
const text = await r.text();
alert(text || "Send to LTS failed");
}
}
</script>
{% endblock %}

384
app/templates/cloud/workshop.html

@ -3,65 +3,130 @@
{% block title %}Video Workshop - OTB Cloud{% endblock %} {% block title %}Video Workshop - OTB Cloud{% endblock %}
{% block portal_content %} {% block portal_content %}
<style> <style>
#profile { #rotationSelect {
background: #1e293b; background: #1e293b;
color: #e5e7eb; color: #e5e7eb;
border: 1px solid rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.18);
} }
#profile option { #rotationSelect option {
background: #1e293b; background: #1e293b;
color: #e5e7eb; color: #e5e7eb;
} }
.workshop-wrap { max-width: 1100px; margin: 0 auto; }
.job-list { display:flex; flex-direction:column; gap:14px; margin-top:14px; }
.job-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:14px; }
.job-head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:8px; }
.job-file { font-weight:700; word-break:break-word; }
.job-head-right { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.job-badge { display:inline-block; padding:4px 10px; border-radius:999px; font-size:0.85rem; font-weight:700; border:1px solid rgba(255,255,255,0.14); }
.job-badge.queued { background:rgba(245,158,11,0.18); }
.job-badge.processing { background:rgba(59,130,246,0.18); }
.job-badge.complete { background:rgba(34,197,94,0.18); }
.job-badge.failed { background:rgba(239,68,68,0.18); }
.job-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; margin-top:8px; }
.job-meta { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px; }
.job-label { font-size:0.8rem; opacity:0.75; margin-bottom:4px; }
.job-value { word-break:break-word; }
.job-path, .job-error { margin-top:10px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.03); word-break:break-word; }
.job-error { border:1px solid rgba(239,68,68,0.22); }
.job-empty { padding:16px; border-radius:12px; background:rgba(255,255,255,0.04); opacity:0.85; }
.actions-row { display:flex; gap:10px; flex-wrap:wrap; }
.profile-checks { display:flex; gap:16px; flex-wrap:wrap; margin-top:8px; }
.rotation-wrap { margin-top:12px; }
.selected-box {
background:rgba(255,255,255,0.04);
padding:12px;
border-radius:12px;
overflow:auto;
min-height:80px;
}
.staged-list {
display:flex;
flex-direction:column;
gap:8px;
}
.staged-row {
display:flex;
align-items:center;
gap:10px;
padding:8px 10px;
border-radius:10px;
background:rgba(255,255,255,0.03);
}
.staged-name {
word-break:break-word;
}
</style> </style>
<div class="portal-page-header"> <div class="workshop-wrap">
<div class="portal-page-header">
<div> <div>
<h1 class="portal-page-title">Video Workshop</h1> <h1 class="portal-page-title">Video Workshop</h1>
<p class="portal-page-subtitle">Device ID: <strong>{{ device_id }}</strong></p> <p class="portal-page-subtitle">Device ID: <strong>{{ device_id }}</strong></p>
</div> </div>
<div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;"> <div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;">
<a class="portal-btn" href="/devices/{{ device_id }}/files">Back to Device Files</a> <a class="portal-btn" href="/devices/{{ device_id }}/files">Back to Device Files</a>
<a class="portal-btn" href="/portal">Back to Portal</a> <a class="portal-btn" href="/health">Health</a>
<a class="portal-btn" href="/dashboard">Back to Dashboard</a>
</div>
</div> </div>
</div>
<div class="service-card" style="margin-top:18px;"> <div class="service-card" style="margin-top:18px;">
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Queue Video Jobs</h2> <h2>Queue Video Jobs</h2>
<p>Selected files from the device browser are staged in your browser and can now be queued for processing.</p> <p>Selected files from the device browser are staged here. Only checked staged files will be processed.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">alpha3-a</span> <span class="service-badge service-badge-beta">alpha3-l</span>
</div> </div>
</div> </div>
<div class="service-card-body" style="display:flex;flex-direction:column;gap:16px;"> <div class="service-card-body" style="display:flex;flex-direction:column;gap:16px;">
<div> <div>
<label for="profile"><strong>Profile</strong></label><br> <label><strong>Profiles</strong></label>
<select id="profile" class="portal-input" style="max-width:320px;margin-top:8px;"> <div class="profile-checks">
<option value="default">Default</option> <label><input type="checkbox" class="profile-check" value="default" checked> Default</label>
<option value="compress">Compress</option> <label><input type="checkbox" class="profile-check" value="compress"> Compress</label>
<option value="hq">High Quality</option> <label><input type="checkbox" class="profile-check" value="hq"> High Quality</label>
</div>
</div>
<div class="rotation-wrap">
<label><input type="checkbox" id="manualRotationToggle"> Manual rotation override</label>
<div id="rotationSelectWrap" style="display:none;margin-top:8px;">
<select id="rotationSelect">
<option value="90">90</option>
<option value="180">180</option>
<option value="270">270</option>
</select> </select>
</div> </div>
</div>
<div class="rotation-wrap">
<label><input type="checkbox" id="mkvAcknowledge"> I understand MKV conversion is not recommended (can increase size and produce poor audio on some sources)</label>
</div>
<div> <div>
<strong>Selected items</strong> <strong>Staged files</strong>
<pre id="selected-files" style="white-space:pre-wrap;background:rgba(255,255,255,0.04);padding:12px;border-radius:12px;overflow:auto;min-height:80px;"></pre> <div id="selected-files" class="selected-box"></div>
</div> </div>
<div style="display:flex;gap:10px;flex-wrap:wrap;"> <div class="actions-row">
<button class="portal-btn primary" type="button" onclick="processWorkshop()">Process</button> <button class="portal-btn primary" type="button" onclick="processWorkshop()">Process Checked</button>
<button class="portal-btn" type="button" onclick="selectAllStaged(true)">Check All</button>
<button class="portal-btn" type="button" onclick="selectAllStaged(false)">Uncheck All</button>
<button class="portal-btn" type="button" onclick="removeCheckedStaged()">Remove Checked</button>
<button class="portal-btn" type="button" onclick="loadJobs()">Refresh Jobs</button> <button class="portal-btn" type="button" onclick="loadJobs()">Refresh Jobs</button>
<button class="portal-btn" type="button" onclick="clearWorkshopSelection()">Clear Selection</button> <button class="portal-btn" type="button" onclick="clearWorkshopSelection()">Clear All Staged</button>
</div>
</div> </div>
</div> </div>
</div>
<div class="service-card" style="margin-top:18px;"> <div class="service-card" style="margin-top:18px;">
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Jobs</h2> <h2>Jobs</h2>
@ -69,69 +134,262 @@
</div> </div>
</div> </div>
<div class="service-card-body"> <div class="service-card-body">
<pre id="jobs" style="white-space:pre-wrap;background:rgba(255,255,255,0.04);padding:12px;border-radius:12px;overflow:auto;min-height:140px;"></pre> <div id="jobs-panel"></div>
</div>
</div> </div>
</div> </div>
<script> <script>
function getWorkshopSelection() { function fmtBytes(v){
try { if(v === null || v === undefined || v === "") return "";
return JSON.parse(localStorage.getItem("videoSelection") || "[]"); const n = Number(v);
} catch (e) { if(!Number.isFinite(n)) return String(v);
return []; if(n < 1024) return `${n} B`;
} if(n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
if(n < 1024*1024*1024) return `${(n/1024/1024).toFixed(2)} MB`;
return `${(n/1024/1024/1024).toFixed(2)} GB`;
}
function esc(v){
return (v===null||v===undefined) ? "" :
String(v).replaceAll("&","&amp;")
.replaceAll("<","&lt;")
.replaceAll(">","&gt;")
.replaceAll('"',"&quot;");
} }
function renderWorkshopSelection() { function getSel(){
const files = getWorkshopSelection(); try{return JSON.parse(localStorage.getItem("videoSelection")||"[]");}
document.getElementById("selected-files").textContent = catch{return [];}
files.length ? JSON.stringify(files, null, 2) : "No files currently staged.";
} }
function clearWorkshopSelection() { function setSel(items){
localStorage.setItem("videoSelection", JSON.stringify(items));
}
function renderSel(){
let f=getSel();
let el=document.getElementById("selected-files");
if(!f.length){
el.innerHTML = '<div class="job-empty">No files staged</div>';
return;
}
el.innerHTML = '<div class="staged-list">' + f.map((item, idx) => {
const id = typeof item === "object" ? item.id : item;
const filename = typeof item === "object" ? item.filename : String(item);
const checked = (typeof item === "object" && item.checked === false) ? "" : "checked";
return `
<label class="staged-row">
<input type="checkbox" class="staged-check" data-index="${idx}" ${checked}>
<span class="staged-name">${esc(filename)} [id:${esc(id)}]</span>
</label>
`;
}).join("") + '</div>';
document.querySelectorAll(".staged-check").forEach(cb => {
cb.addEventListener("change", function(){
let items = getSel();
let idx = Number(this.dataset.index);
if(items[idx] && typeof items[idx] === "object"){
items[idx].checked = this.checked;
} else if(items[idx] !== undefined) {
items[idx] = { id: items[idx], filename: String(items[idx]), checked: this.checked };
}
setSel(items);
});
});
}
function clearWorkshopSelection(){
localStorage.removeItem("videoSelection"); localStorage.removeItem("videoSelection");
renderWorkshopSelection(); renderSel();
}
function selectAllStaged(state){
let items = getSel().map(item => {
if(typeof item === "object"){
item.checked = state;
return item;
}
return { id: item, filename: String(item), checked: state };
});
setSel(items);
renderSel();
}
function removeCheckedStaged(){
let items = getSel().filter(item => {
if(typeof item === "object"){
return item.checked === false;
}
return false;
});
setSel(items);
renderSel();
}
function getCheckedStaged(){
return getSel().filter(item => {
if(typeof item === "object"){
return item.checked !== false;
}
return true;
});
}
function getSelectedProfiles(){
return Array.from(document.querySelectorAll(".profile-check:checked")).map(el => el.value);
} }
function processWorkshop() { async function deleteJob(jobId){
const files = getWorkshopSelection(); if(!confirm("Delete this job from the list?")) return;
if (!files.length) {
alert("No files staged for workshop."); const r = await fetch(`/api/video/jobs/${jobId}/delete`, { method: "POST" });
const text = await r.text();
let data = {};
try { data = JSON.parse(text); } catch(e) {}
if(!r.ok){
alert(data.error || text || "Delete failed");
return; return;
} }
fetch("/api/video/enqueue", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
device_id: {{ device_id }},
files: files,
profile: document.getElementById("profile").value
})
})
.then(r => r.json())
.then(d => {
document.getElementById("jobs").textContent = JSON.stringify(d, null, 2);
loadJobs(); loadJobs();
}) }
.catch(err => {
document.getElementById("jobs").textContent = "Enqueue failed: " + err; async function sendToLTS(jobId){
if(!confirm("Send this output to LTS storage?")) return;
const r = await fetch(`/video-output/${jobId}/send-to-lts`, { method: "POST" });
const text = await r.text();
let data = {};
try { data = JSON.parse(text); } catch(e) {}
if(!r.ok){
alert(data.error || text || "Send to LTS failed");
return;
}
loadJobs();
}
function renderJobs(jobs){
let p=document.getElementById("jobs-panel");
if(!jobs.length){
p.innerHTML='<div class="job-empty">No jobs yet</div>';
return;
}
p.innerHTML='<div class="job-list">'+jobs.map(j=>{
let badge = (j.status||"").toLowerCase();
let deleteBtn = (badge === "failed" || badge === "complete")
? `<button class="portal-btn" type="button" onclick="deleteJob(${Number(j.id)})">Delete</button>`
: "";
let viewBtn = (badge === "complete" && j.output_relative_path)
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/view" target="_blank">View</a>`
: "";
let ltsBtn = (badge === "complete" && j.output_relative_path)
? `<button class="portal-btn" type="button" onclick="sendToLTS(${Number(j.id)})">Send to LTS</button>`
: "";
let downloadBtn = (badge === "complete" && j.output_relative_path)
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/download">Download Output</a>`
: "";
return `
<div class="job-card">
<div class="job-head">
<div class="job-file">${esc(j.filename)}</div>
<div class="job-head-right">
${viewBtn}
${ltsBtn}
${downloadBtn}
${deleteBtn}
<span class="job-badge ${badge}">${esc(j.status).toUpperCase()}</span>
</div>
</div>
<div class="job-grid">
<div class="job-meta"><div class="job-label">Job ID</div><div class="job-value">${esc(j.id)}</div></div>
<div class="job-meta"><div class="job-label">Device</div><div class="job-value">${esc(j.device_id)}</div></div>
<div class="job-meta"><div class="job-label">Profile</div><div class="job-value">${esc(j.profile)}</div></div>
<div class="job-meta"><div class="job-label">Rotation Override</div><div class="job-value">${esc(j.rotation_override || "auto")}</div></div>
<div class="job-meta"><div class="job-label">Processor</div><div class="job-value">${esc(j.assigned_processor||"pending")}</div></div>
<div class="job-meta"><div class="job-label">Progress</div><div class="job-value">${esc(j.progress_percent)}%</div></div>
<div class="job-meta"><div class="job-label">Created</div><div class="job-value">${esc(j.created_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Started</div><div class="job-value">${esc(j.started_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Completed</div><div class="job-value">${esc(j.completed_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Original Size</div><div class="job-value">${esc(fmtBytes(j.original_size)||"")}</div></div>
<div class="job-meta"><div class="job-label">Processed Size</div><div class="job-value">${esc(fmtBytes(j.processed_size)||"")}</div></div>
</div>
${j.output_relative_path ? `<div class="job-path"><div class="job-label">Output</div><div class="job-value">${esc(j.output_relative_path)}</div></div>` : ""}
${j.error_message ? `<div class="job-error"><div class="job-label">Error</div><div class="job-value">${esc(j.error_message)}</div></div>` : ""}
</div>
`;
}).join("")+'</div>';
}
function processWorkshop(){
let files=getCheckedStaged();
if(!files.length){alert("No staged files checked");return;}
const profiles = getSelectedProfiles();
if(!profiles.length){
alert("Select at least one profile");
return;
}
const hasMkv = files.some(item => {
const name = (typeof item === "object" ? item.filename : String(item)) || "";
return name.toLowerCase().endsWith(".mkv");
}); });
if(hasMkv && !document.getElementById("mkvAcknowledge").checked){
alert("MKV conversion is not recommended. Please acknowledge the MKV warning checkbox if you still want to proceed.");
return;
}
const fileIds = files.map(item => {
if(typeof item === "object") return Number(item.id);
return Number(item);
}).filter(v => Number.isFinite(v));
let rotation_override = null;
if(document.getElementById("manualRotationToggle").checked){
rotation_override = Number(document.getElementById("rotationSelect").value);
}
fetch("/api/video/enqueue",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
device_id:{{device_id}},
files:fileIds,
profiles:profiles,
rotation_override:rotation_override
})
}).then(()=>loadJobs());
} }
function loadJobs() { function loadJobs(){
fetch("/api/video/jobs") fetch("/api/video/jobs")
.then(r => r.json()) .then(r=>r.json())
.then(d => { .then(d=>renderJobs(d));
document.getElementById("jobs").textContent = JSON.stringify(d, null, 2);
})
.catch(err => {
document.getElementById("jobs").textContent = "Job load failed: " + err;
});
} }
renderWorkshopSelection(); document.getElementById("manualRotationToggle").addEventListener("change", function(){
document.getElementById("rotationSelectWrap").style.display = this.checked ? "block" : "none";
});
renderSel();
loadJobs(); loadJobs();
setInterval(loadJobs, 3000); setInterval(loadJobs,5000);
</script> </script>
{% endblock %} {% endblock %}

Loading…
Cancel
Save