from __future__ import annotations import json import os import platform import shutil import subprocess import threading import time import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path from typing import Any import requests from dotenv import load_dotenv from flask import Flask, jsonify, render_template, request, send_file from werkzeug.utils import secure_filename from app.estimates import DEFAULT_MAX_PARALLEL, DEFAULT_SEGMENT_SECONDS, estimate_run ROOT = Path(__file__).resolve().parents[1] RUNS_DIR = ROOT / "runs" API_BASE = "https://api.replicate.com/v1" MODEL_PATH = "heygen/video-translate" POLL_SECONDS = 8 class RunCancelledError(RuntimeError): pass def ensure_ffmpeg() -> None: if shutil.which("ffmpeg") is None: raise RuntimeError("`ffmpeg` is not installed or not on PATH.") @dataclass class SegmentTaskResult: index: int status: str error: str | None = None class TranslationRunManager: def __init__(self) -> None: self._lock = threading.Lock() self._threads: dict[str, threading.Thread] = {} self._cancel_flags: dict[str, threading.Event] = {} self._load_existing_runs() def _load_existing_runs(self) -> None: RUNS_DIR.mkdir(parents=True, exist_ok=True) for run_dir in RUNS_DIR.iterdir(): if not run_dir.is_dir(): continue run_file = run_dir / "run.json" if not run_file.exists(): continue data = json.loads(run_file.read_text()) if data.get("status") == "running": data["status"] = "interrupted" data["error"] = "App restarted while run was in progress." self._save_run(data) def list_runs(self) -> list[dict[str, Any]]: runs = [] for run_dir in sorted(RUNS_DIR.iterdir(), reverse=True): if run_dir.name.startswith("_"): continue run_file = run_dir / "run.json" if run_file.exists(): runs.append(json.loads(run_file.read_text())) return runs def get_run(self, run_id: str) -> dict[str, Any] | None: run_file = RUNS_DIR / run_id / "run.json" if not run_file.exists(): return None return json.loads(run_file.read_text()) def create_run( self, video_path: Path, output_language: str, mode: str, segment_seconds: int, max_parallel: int, ) -> dict[str, Any]: run_id = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6] run_dir = RUNS_DIR / run_id run_dir.mkdir(parents=True, exist_ok=False) input_dir = run_dir / "input" input_dir.mkdir(exist_ok=True) input_video = input_dir / secure_filename(video_path.name) shutil.copy2(video_path, input_video) duration_seconds = self._probe_duration(input_video) run_estimate = ( estimate_run(duration_seconds, mode) if duration_seconds is not None else None ) run_data = { "id": run_id, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "input_filename": input_video.name, "input_duration_seconds": duration_seconds, "estimate": run_estimate, "status": "queued", "error": None, "settings": { "output_language": output_language, "mode": mode, "segment_seconds": segment_seconds, "max_parallel": max_parallel, }, "paths": { "run_dir": str(run_dir), "input_video": str(input_video), "segments_dir": str(run_dir / "segments"), "outputs_dir": str(run_dir / "translated_segments"), "merged_output": str(run_dir / "output" / f"{input_video.stem}_translated.mp4"), "log_file": str(run_dir / "events.log"), }, "segments": [], } self._save_run(run_data) return run_data def start_run(self, run_id: str) -> None: run = self.get_run(run_id) if not run: raise RuntimeError(f"Run `{run_id}` not found.") if run.get("status") == "running": raise RuntimeError("Run is already active.") with self._lock: self._cancel_flags[run_id] = threading.Event() thread = threading.Thread(target=self._execute_run, args=(run_id, None), daemon=True) with self._lock: self._threads[run_id] = thread thread.start() def cancel_run(self, run_id: str) -> dict[str, Any]: run = self.get_run(run_id) if not run: raise RuntimeError(f"Run `{run_id}` not found.") if run.get("status") not in {"queued", "running"}: raise RuntimeError("Only active runs can be cancelled.") with self._lock: if run_id not in self._cancel_flags: self._cancel_flags[run_id] = threading.Event() self._cancel_flags[run_id].set() token = os.environ.get("REPLICATE_API_TOKEN") if token: for segment in run.get("segments", []): prediction_id = segment.get("prediction_id") if prediction_id and segment.get("status") == "processing": self._cancel_prediction(prediction_id, token) self._set_run_status(run_id, "cancelled", "Cancelled by user.") self._log(run, "Cancellation requested.") updated = self.get_run(run_id) if not updated: raise RuntimeError(f"Run `{run_id}` not found.") return updated def delete_run(self, run_id: str) -> None: run = self.get_run(run_id) if not run: raise RuntimeError(f"Run `{run_id}` not found.") if run.get("status") == "running": raise RuntimeError("Cannot delete a running job. Cancel it first.") with self._lock: thread = self._threads.get(run_id) if thread and thread.is_alive(): raise RuntimeError("Cannot delete while run is active.") run_dir = Path(run["paths"]["run_dir"]) if run_dir.exists(): shutil.rmtree(run_dir) with self._lock: self._threads.pop(run_id, None) self._cancel_flags.pop(run_id, None) def _is_cancelled(self, run_id: str) -> bool: flag = self._cancel_flags.get(run_id) return flag is not None and flag.is_set() def retry_failed(self, run_id: str) -> None: run = self.get_run(run_id) if not run: raise RuntimeError(f"Run `{run_id}` not found.") if run.get("status") == "running": raise RuntimeError("Run is currently running.") failed_indices = [seg["index"] for seg in run["segments"] if seg["status"] == "failed"] if not failed_indices: raise RuntimeError("No failed segments to retry.") with self._lock: self._cancel_flags[run_id] = threading.Event() thread = threading.Thread(target=self._execute_run, args=(run_id, failed_indices), daemon=True) with self._lock: self._threads[run_id] = thread thread.start() def _execute_run(self, run_id: str, retry_indices: list[int] | None) -> None: run = self.get_run(run_id) if not run: return try: token = os.environ.get("REPLICATE_API_TOKEN") if not token: raise RuntimeError("Missing REPLICATE_API_TOKEN in environment.") ensure_ffmpeg() if retry_indices is None: self._split_video(run) if self._is_cancelled(run_id): return self._set_run_status(run_id, "running", None) updated_run = self.get_run(run_id) if not updated_run: return max_parallel = int(updated_run["settings"]["max_parallel"]) targets = updated_run["segments"] if retry_indices is not None: targets = [seg for seg in targets if seg["index"] in retry_indices] with ThreadPoolExecutor(max_workers=max_parallel) as executor: futures = [ executor.submit(self._process_segment, run_id, segment["index"], token) for segment in targets ] for _ in as_completed(futures): if self._is_cancelled(run_id): break if self._is_cancelled(run_id): return final = self.get_run(run_id) if not final: return failed = [seg for seg in final["segments"] if seg["status"] != "succeeded"] if failed: if not self._is_cancelled(run_id): self._set_run_status(run_id, "failed", "Some segments failed. Retry failed segments.") return merged_path = self._merge_segments(final) current = self.get_run(run_id) if not current: return current["paths"]["merged_output"] = str(merged_path) current["status"] = "completed" current["error"] = None self._save_run(current) self._log(current, "Run completed successfully.") except Exception as exc: # pylint: disable=broad-except if not self._is_cancelled(run_id): self._set_run_status(run_id, "failed", str(exc)) finally: with self._lock: self._threads.pop(run_id, None) def _split_video(self, run: dict[str, Any]) -> None: run_dir = Path(run["paths"]["run_dir"]) segments_dir = run_dir / "segments" outputs_dir = run_dir / "translated_segments" output_dir = run_dir / "output" for directory in (segments_dir, outputs_dir, output_dir): directory.mkdir(parents=True, exist_ok=True) input_video = Path(run["paths"]["input_video"]) segment_seconds = int(run["settings"]["segment_seconds"]) self._log(run, f"Splitting input into {segment_seconds}s chunks.") segment_pattern = str(segments_dir / "seg_%03d.mp4") command = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(input_video), "-c", "copy", "-f", "segment", "-segment_time", str(segment_seconds), "-reset_timestamps", "1", segment_pattern, ] self._run_cmd(command, "Video segmentation failed.") segments = sorted(segments_dir.glob("seg_*.mp4")) if not segments: raise RuntimeError("No segments produced. Check ffmpeg and input file.") run["segments"] = [ { "index": idx, "name": seg.name, "path": str(seg), "status": "queued", "prediction_id": None, "input_video_url": None, "output_url": None, "downloaded_path": str(outputs_dir / seg.name), "error": None, } for idx, seg in enumerate(segments) ] self._save_run(run) self._log(run, f"Created {len(segments)} segment(s).") def _process_segment(self, run_id: str, index: int, token: str) -> SegmentTaskResult: run = self.get_run(run_id) if not run: return SegmentTaskResult(index=index, status="failed", error="Run missing.") if self._is_cancelled(run_id): return SegmentTaskResult(index=index, status="failed", error="Cancelled") segment = run["segments"][index] try: self._update_segment(run_id, index, status="uploading", error=None) if self._is_cancelled(run_id): raise RunCancelledError("Run cancelled by user.") file_url = self._upload_segment(Path(segment["path"]), token) self._update_segment(run_id, index, input_video_url=file_url) self._update_segment(run_id, index, status="processing") prediction = self._submit_prediction(file_url, run["settings"], token) prediction_id = prediction["id"] self._update_segment(run_id, index, prediction_id=prediction_id) terminal = self._poll_prediction( prediction_id, token, is_cancelled=lambda: self._is_cancelled(run_id), ) if terminal["status"] != "succeeded": raise RuntimeError(terminal.get("error") or f"Prediction {terminal['status']}") output_url = terminal.get("output") if not output_url: raise RuntimeError("Prediction succeeded but output URL missing.") if self._is_cancelled(run_id): raise RunCancelledError("Run cancelled by user.") self._update_segment(run_id, index, status="downloading", output_url=output_url) output_path = Path(segment["downloaded_path"]) self._download_file(output_url, output_path) self._update_segment(run_id, index, status="succeeded", error=None) return SegmentTaskResult(index=index, status="succeeded") except RunCancelledError: self._update_segment(run_id, index, status="failed", error="Cancelled") return SegmentTaskResult(index=index, status="failed", error="Cancelled") except Exception as exc: # pylint: disable=broad-except self._update_segment(run_id, index, status="failed", error=str(exc)) return SegmentTaskResult(index=index, status="failed", error=str(exc)) def _upload_segment(self, file_path: Path, token: str) -> str: with file_path.open("rb") as handle: response = requests.post( f"{API_BASE}/files", headers={"Authorization": f"Token {token}"}, files={ "content": (file_path.name, handle, "video/mp4"), "metadata": ("metadata", "{}", "application/json"), }, timeout=1800, ) if response.status_code >= 400: raise RuntimeError(f"Replicate file upload failed: {response.status_code} {response.text}") payload = response.json() url = (payload.get("urls") or {}).get("get") if not url: raise RuntimeError("Replicate file upload missing urls.get") return url def _submit_prediction(self, file_url: str, settings: dict[str, Any], token: str) -> dict[str, Any]: body = { "input": { "video": file_url, "output_language": settings["output_language"], "mode": settings["mode"], } } response = requests.post( f"{API_BASE}/models/{MODEL_PATH}/predictions", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "video-translator-local-app/1.0", }, json=body, timeout=120, ) if response.status_code >= 400: raise RuntimeError(f"Prediction submit failed: {response.status_code} {response.text}") return response.json() def _poll_prediction( self, prediction_id: str, token: str, is_cancelled: Any = None, ) -> dict[str, Any]: while True: if is_cancelled and is_cancelled(): self._cancel_prediction(prediction_id, token) raise RunCancelledError("Run cancelled by user.") response = requests.get( f"{API_BASE}/predictions/{prediction_id}", headers={"Authorization": f"Bearer {token}", "User-Agent": "video-translator-local-app/1.0"}, timeout=60, ) if response.status_code >= 400: raise RuntimeError(f"Prediction poll failed: {response.status_code} {response.text}") payload = response.json() if payload["status"] in {"succeeded", "failed", "canceled"}: return payload time.sleep(POLL_SECONDS) def _cancel_prediction(self, prediction_id: str, token: str) -> None: try: requests.post( f"{API_BASE}/predictions/{prediction_id}/cancel", headers={"Authorization": f"Bearer {token}", "User-Agent": "video-translator-local-app/1.0"}, timeout=30, ) except requests.RequestException: pass def _download_file(self, url: str, destination: Path) -> None: destination.parent.mkdir(parents=True, exist_ok=True) with requests.get(url, stream=True, timeout=1800) as response: if response.status_code >= 400: raise RuntimeError(f"Download failed: {response.status_code} {response.text}") with destination.open("wb") as handle: for chunk in response.iter_content(chunk_size=1024 * 1024): if chunk: handle.write(chunk) def _merge_segments(self, run: dict[str, Any]) -> Path: output_dir = Path(run["paths"]["run_dir"]) / "output" output_dir.mkdir(parents=True, exist_ok=True) concat_file = Path(run["paths"]["run_dir"]) / "concat.txt" lines = [] for segment in run["segments"]: downloaded = Path(segment["downloaded_path"]).resolve() if not downloaded.exists(): raise RuntimeError(f"Missing downloaded segment: {downloaded}") escaped = str(downloaded).replace("'", "'\\''") lines.append(f"file '{escaped}'") concat_file.write_text("\n".join(lines) + "\n") merged_output = Path(run["paths"]["merged_output"]) command = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), "-c", "copy", str(merged_output), ] self._run_cmd(command, "Merging translated segments failed.") return merged_output def _update_segment(self, run_id: str, index: int, **changes: Any) -> None: run = self.get_run(run_id) if not run: return segment = run["segments"][index] segment.update(changes) self._save_run(run) label = segment["name"] status = segment.get("status") error = segment.get("error") if status: message = f"[{label}] {status}" if error: message = f"{message} :: {error}" self._log(run, message) def _set_run_status(self, run_id: str, status: str, error: str | None) -> None: run = self.get_run(run_id) if not run: return run["status"] = status run["error"] = error self._save_run(run) self._log(run, f"Run status -> {status}" + (f" ({error})" if error else "")) def _save_run(self, run_data: dict[str, Any]) -> None: run_dir = Path(run_data["paths"]["run_dir"]) run_dir.mkdir(parents=True, exist_ok=True) run_file = run_dir / "run.json" with self._lock: run_file.write_text(json.dumps(run_data, indent=2)) def _log(self, run_data: dict[str, Any], message: str) -> None: timestamp = time.strftime("%Y-%m-%d %H:%M:%S") log_line = f"[{timestamp}] {message}\n" log_file = Path(run_data["paths"]["log_file"]) with self._lock: log_file.parent.mkdir(parents=True, exist_ok=True) with log_file.open("a") as handle: handle.write(log_line) @staticmethod def _probe_duration(video_path: Path) -> float | None: if shutil.which("ffprobe") is None: return None result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(video_path), ], capture_output=True, text=True, check=False, ) if result.returncode != 0: return None try: return float(result.stdout.strip()) except ValueError: return None @staticmethod def _run_cmd(command: list[str], failure_message: str) -> None: result = subprocess.run(command, capture_output=True, text=True, check=False) if result.returncode != 0: stderr = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"{failure_message} {stderr}") load_dotenv(ROOT / ".env") app = Flask(__name__, template_folder="templates", static_folder="static") manager = TranslationRunManager() @app.get("/") def index() -> str: return render_template("index.html") @app.get("/api/health") def health() -> Any: token_present = bool(os.environ.get("REPLICATE_API_TOKEN")) ffmpeg_present = shutil.which("ffmpeg") is not None return jsonify( { "token_present": token_present, "ffmpeg_present": ffmpeg_present, "platform": platform.platform(), "defaults": { "segment_seconds": DEFAULT_SEGMENT_SECONDS, "max_parallel": DEFAULT_MAX_PARALLEL, }, } ) @app.get("/api/estimate") def get_estimate() -> Any: try: duration_seconds = float(request.args.get("duration_seconds", "0")) except ValueError: return jsonify({"error": "duration_seconds must be a number."}), 400 if duration_seconds <= 0: return jsonify({"error": "duration_seconds must be greater than 0."}), 400 mode = request.args.get("mode", "precision").strip() if mode not in {"precision", "speed"}: return jsonify({"error": "Invalid mode."}), 400 return jsonify(estimate_run(duration_seconds, mode)) @app.get("/api/runs") def list_runs() -> Any: return jsonify({"runs": manager.list_runs()}) @app.get("/api/runs/") def get_run(run_id: str) -> Any: run = manager.get_run(run_id) if not run: return jsonify({"error": "Run not found"}), 404 return jsonify(run) @app.post("/api/runs") def create_run() -> Any: if "video" not in request.files: return jsonify({"error": "No video uploaded."}), 400 upload = request.files["video"] if not upload.filename: return jsonify({"error": "Please choose a video file."}), 400 temp_dir = ROOT / "runs" / "_incoming" temp_dir.mkdir(parents=True, exist_ok=True) temp_path = temp_dir / secure_filename(upload.filename) upload.save(temp_path) output_language = request.form.get("output_language", "English").strip() or "English" mode = request.form.get("mode", "precision").strip() if mode not in {"precision", "speed"}: return jsonify({"error": "Invalid mode."}), 400 segment_seconds = DEFAULT_SEGMENT_SECONDS max_parallel = DEFAULT_MAX_PARALLEL try: run_data = manager.create_run( video_path=temp_path, output_language=output_language, mode=mode, segment_seconds=segment_seconds, max_parallel=max_parallel, ) manager.start_run(run_data["id"]) return jsonify(run_data) except Exception as exc: # pylint: disable=broad-except return jsonify({"error": str(exc)}), 500 finally: if temp_path.exists(): temp_path.unlink() @app.post("/api/runs//cancel") def cancel_run(run_id: str) -> Any: try: run = manager.cancel_run(run_id) return jsonify(run) except Exception as exc: # pylint: disable=broad-except return jsonify({"error": str(exc)}), 400 @app.delete("/api/runs/") def delete_run(run_id: str) -> Any: try: manager.delete_run(run_id) return jsonify({"ok": True}) except Exception as exc: # pylint: disable=broad-except return jsonify({"error": str(exc)}), 400 @app.post("/api/runs//retry") def retry_failed(run_id: str) -> Any: try: manager.retry_failed(run_id) run = manager.get_run(run_id) return jsonify(run) except Exception as exc: # pylint: disable=broad-except return jsonify({"error": str(exc)}), 400 @app.get("/api/runs//output") def stream_output(run_id: str) -> Any: run = manager.get_run(run_id) if not run: return jsonify({"error": "Run not found."}), 404 output = Path(run["paths"]["merged_output"]) if not output.exists(): return jsonify({"error": "Output file is not ready."}), 400 return send_file(output, mimetype="video/mp4", as_attachment=False) @app.get("/api/runs//download") def download_output(run_id: str) -> Any: run = manager.get_run(run_id) if not run: return jsonify({"error": "Run not found."}), 404 output = Path(run["paths"]["merged_output"]) if not output.exists(): return jsonify({"error": "Output file is not ready."}), 400 download_name = run.get("input_filename") or output.name if download_name and not download_name.endswith(".mp4"): stem = Path(download_name).stem download_name = f"{stem}_translated.mp4" return send_file(output, mimetype="video/mp4", as_attachment=True, download_name=download_name) @app.post("/api/runs//open-output") def open_output(run_id: str) -> Any: run = manager.get_run(run_id) if not run: return jsonify({"error": "Run not found."}), 404 output = Path(run["paths"]["merged_output"]) if not output.exists(): return jsonify({"error": "Output file is not ready."}), 400 try: if platform.system() == "Darwin": subprocess.run(["open", str(output.parent)], check=False) elif platform.system() == "Windows": os.startfile(str(output.parent)) # type: ignore[attr-defined] else: subprocess.run(["xdg-open", str(output.parent)], check=False) except Exception as exc: # pylint: disable=broad-except return jsonify({"error": f"Unable to open folder: {exc}"}), 500 return jsonify({"ok": True, "path": str(output)}) if __name__ == "__main__": app.run(host="127.0.0.1", port=5050, debug=False)