Spaces:
Sleeping
Sleeping
| """ | |
| Tools router β Phase 4.6 (ROADMAP.md). | |
| Standalone utilities exposed as first-class endpoints, independent of the | |
| dub pipeline. The Tools page UI consumes these. Headless CLI consumers | |
| (omnivoice-dub) will share the same service layer. | |
| Shipped today: | |
| POST /tools/probe β ffprobe-style metadata for a file path. | |
| POST /tools/incremental β plan what segments need regenerating. | |
| POST /tools/direction β parse a natural-language direction into tokens. | |
| POST /tools/rate-fit β LLM-assisted slot-fit for translated text. | |
| More utilities (vocal separation, alignment, merge) are wired through | |
| existing dub helpers and land in follow-up passes. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| from typing import Optional | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel, Field | |
| from services import director, speech_rate, incremental | |
| from services.ffmpeg_utils import find_ffmpeg, find_ffprobe | |
| logger = logging.getLogger("omnivoice.tools") | |
| router = APIRouter() | |
| # ββ Probe (ffprobe wrapper) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ProbeReq(BaseModel): | |
| path: str | |
| async def probe(req: ProbeReq): | |
| target = os.path.realpath(os.path.expanduser(req.path)) | |
| if not os.path.exists(target): | |
| raise HTTPException( | |
| status_code=404, | |
| detail="File not found. Provide an absolute path to an existing file.", | |
| ) | |
| ffprobe = find_ffprobe() | |
| if not ffprobe: | |
| raise HTTPException( | |
| status_code=501, | |
| detail="ffprobe binary not available. Install system ffmpeg or re-run the setup.", | |
| ) | |
| proc = await asyncio.create_subprocess_exec( | |
| ffprobe, "-v", "quiet", "-print_format", "json", | |
| "-show_format", "-show_streams", target, | |
| stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, | |
| ) | |
| stdout, stderr = await proc.communicate() | |
| if proc.returncode != 0: | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"ffprobe failed: {stderr.decode(errors='replace')[:400]}", | |
| ) | |
| try: | |
| return json.loads(stdout.decode("utf-8")) | |
| except json.JSONDecodeError: | |
| return {"raw": stdout.decode("utf-8", errors="replace")} | |
| # ββ Incremental plan (what needs regenerating) βββββββββββββββββββββββββββββ | |
| class IncrementalReq(BaseModel): | |
| segments: list[dict] | |
| stored_hashes: Optional[dict[str, str]] = None | |
| def plan_incremental(req: IncrementalReq): | |
| return incremental.plan_incremental( | |
| req.segments, | |
| stored_hashes=req.stored_hashes or {}, | |
| ) | |
| # ββ Directorial AI parse βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class DirectionReq(BaseModel): | |
| text: str = Field(..., description="Natural-language direction, e.g. 'urgent and surprised'") | |
| def parse_direction(req: DirectionReq): | |
| d = director.parse(req.text) | |
| return { | |
| "tokens": d.tokens, | |
| "instruct_prompt": d.instruct_prompt(), | |
| "translate_hint": d.translate_hint(), | |
| "rate_bias": d.rate_bias(), | |
| "method": d.method, | |
| "error": d.error, | |
| "taxonomy": director.TAXONOMY, | |
| } | |
| # ββ Speech-rate fit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RateFitReq(BaseModel): | |
| text: str | |
| slot_seconds: float | |
| target_lang: str | |
| source_text: Optional[str] = None | |
| def rate_fit(req: RateFitReq): | |
| return speech_rate.adjust_for_slot( | |
| req.text, | |
| slot_seconds=req.slot_seconds, | |
| target_lang=req.target_lang, | |
| source_text=req.source_text, | |
| ) | |
| # ββ Audio effects presets ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_effects(): | |
| """Return available audio effect presets (Broadcast, Cinematic, etc.).""" | |
| from services.audio_dsp import list_effect_presets | |
| return list_effect_presets() | |
| # ββ TTS Plugin SDK βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_tts_plugins(): | |
| """Return all registered TTS engine plugins and their availability.""" | |
| from services.plugin_sdk import list_plugins | |
| return list_plugins() | |
| # ββ Video context analysis βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def analyse_video_context(job_id: str): | |
| """Analyse the source video's visual context for dubbing decisions. | |
| Returns per-segment mood, brightness, and complexity cues that | |
| can be used as TTS instruct hints. | |
| """ | |
| import os | |
| from api.routers.dub_core import _get_job | |
| from core.config import DUB_DIR | |
| from services.video_context import analyse_video | |
| job = _get_job(job_id) | |
| if not job: | |
| from fastapi import HTTPException | |
| raise HTTPException(status_code=404, detail="Job not found") | |
| video_path = os.path.join(DUB_DIR, job_id, "source.mp4") | |
| if not os.path.exists(video_path): | |
| video_path = job.get("video_path", "") | |
| if not video_path or not os.path.exists(video_path): | |
| return {"error": "Source video not found", "segments": {}} | |
| segments = job.get("segments") or [] | |
| ctx = await analyse_video(video_path, segments) | |
| return ctx.to_dict() | |