CoolFace
Apppublic

MarneMorgan/piper_ffmpeg

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
app.py281 linesDownload Raw Back to root
1import os2import subprocess3import tempfile4import uuid5from typing import Optional, List6 7import requests8from fastapi import FastAPI, HTTPException, BackgroundTasks9from fastapi.responses import FileResponse10from pydantic import BaseModel11 12app = FastAPI(title="Piper + FFmpeg (Single App)")13 14# Cache voice models locally (HF storage is ephemeral; cache reduces repeat downloads)15MODEL_DIR = os.getenv("MODEL_DIR", "/tmp/piper_models")16os.makedirs(MODEL_DIR, exist_ok=True)17 18# -----------------------------19# English-only voice catalog20# -----------------------------21VOICE_CATALOG = {22    "en_US-amy": {23        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx",24        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json",25    },26    "en_US-ryan": {27        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx",28        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx.json",29    },30    "en_GB-alan": {31        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx",32        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx.json",33    },34    "en_GB-sarah": {35        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/sarah/medium/en_GB-sarah-medium.onnx",36        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/sarah/medium/en_GB-sarah-medium.onnx.json",37    },38    "en_AU-nat": {39        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_AU/nat/medium/en_AU-nat-medium.onnx",40        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_AU/nat/medium/en_AU-nat-medium.onnx.json",41    },42    "en_US-joe": {43        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/joe/medium/en_US-joe-medium.onnx",44        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/joe/medium/en_US-joe-medium.onnx.json",45    },46    "en_US-kathleen": {47        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/kathleen/medium/en_US-kathleen-medium.onnx",48        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/kathleen/medium/en_US-kathleen-medium.onnx.json",49    },50    "en_US-danny": {51        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/danny/medium/en_US-danny-medium.onnx",52        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/danny/medium/en_US-danny-medium.onnx.json",53    },54    "en_GB-jenny": {55        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny/medium/en_GB-jenny-medium.onnx",56        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny/medium/en_GB-jenny-medium.onnx.json",57    },58    "en_US-lessac": {59        "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx",60        "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",61    },62}63 64def _download(url: str, path: str, timeout: int = 180):65    r = requests.get(url, timeout=timeout)66    r.raise_for_status()67    with open(path, "wb") as f:68        f.write(r.content)69 70def ensure_voice_model(voice_id: str) -> str:71    if voice_id not in VOICE_CATALOG:72        raise HTTPException(404, f"Unknown voiceId: {voice_id}")73 74    onnx_path = os.path.join(MODEL_DIR, f"{voice_id}.onnx")75    json_path = os.path.join(MODEL_DIR, f"{voice_id}.onnx.json")76 77    if not os.path.exists(onnx_path):78        _download(VOICE_CATALOG[voice_id]["onnx"], onnx_path)79 80    if "json" in VOICE_CATALOG[voice_id] and not os.path.exists(json_path):81        try:82            _download(VOICE_CATALOG[voice_id]["json"], json_path)83        except Exception:84            pass85 86    return onnx_path87 88def _cleanup_files(paths: List[str]):89    for p in paths:90        try:91            if p and os.path.exists(p):92                os.remove(p)93        except Exception:94            pass95 96# -----------------------------97# Routes98# -----------------------------99@app.get("/")100def root():101    return {102        "ok": True,103        "service": "piper_ffmpeg",104        "endpoints": ["/docs", "/health", "/voices", "/tts", "/render"]105    }106 107@app.get("/health")108def health():109    def has(cmd):110        return subprocess.run(["bash", "-lc", f"command -v {cmd} >/dev/null 2>&1"]).returncode == 0111    return {"ok": True, "ffmpeg": has("ffmpeg"), "piper": has("piper")}112 113@app.get("/voices")114def voices():115    return {"voices": sorted(list(VOICE_CATALOG.keys()))}116 117# -----------------------------118# TTS (Piper -> mp3/wav -> returns file)119# -----------------------------120class TTSReq(BaseModel):121    jobId: str122    voiceId: str123    text: str124    format: str = "mp3"  # mp3|wav125    length_scale: float = 0.95126    noise_scale: float = 0.75127    noise_w: float = 0.80128    fx: bool = True129 130@app.post("/tts")131def tts(req: TTSReq, background_tasks: BackgroundTasks):132    if not req.text.strip():133        raise HTTPException(400, "text required")134 135    model_path = ensure_voice_model(req.voiceId)136    fmt = req.format.lower().strip()137 138    # IMPORTANT: output must NOT live inside a TemporaryDirectory that auto-deletes.139    # We create stable files under /tmp and delete them AFTER response is sent.140    uid = uuid.uuid4().hex141    wav_path = f"/tmp/tts_{req.jobId}_{uid}.wav"142    mp3_path = f"/tmp/tts_{req.jobId}_{uid}.mp3"143 144    # 1) Piper outputs WAV145    p = subprocess.run(146        ["piper", "--model", model_path, "--output_file", wav_path],147        input=req.text,148        text=True,149        capture_output=True150    )151    if p.returncode != 0:152        _cleanup_files([wav_path, mp3_path])153        raise HTTPException(500, f"piper failed: {p.stderr[-1500:]}")154 155    # 2) Optionally convert156    if fmt == "wav":157        out_path = wav_path158        media_type = "audio/wav"159        filename = f"{req.jobId}_{req.voiceId}.wav"160        cleanup = [wav_path]  # delete after send161    elif fmt == "mp3":162        c = subprocess.run(163            ["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-q:a", "3", mp3_path],164            capture_output=True,165            text=True166        )167        if c.returncode != 0 or not os.path.exists(mp3_path):168            _cleanup_files([wav_path, mp3_path])169            raise HTTPException(500, f"ffmpeg mp3 convert failed: {c.stderr[-1500:]}")170        out_path = mp3_path171        media_type = "audio/mpeg"172        filename = f"{req.jobId}_{req.voiceId}.mp3"173        cleanup = [wav_path, mp3_path]  # delete after send174    else:175        _cleanup_files([wav_path, mp3_path])176        raise HTTPException(400, "format must be mp3 or wav")177 178    background_tasks.add_task(_cleanup_files, cleanup)179    return FileResponse(path=out_path, media_type=media_type, filename=filename)180 181# -----------------------------182# Render (FFmpeg “wonders” -> returns mp4)183# -----------------------------184class RenderReq(BaseModel):185    jobId: str186    preset: str = "shorts_hypercut"  # shorts_hypercut|story_cinematic|facts_clean187    clipUrls: List[str]188    voiceUrl: Optional[str] = None189    srt: Optional[str] = None  # optional captions190 191@app.post("/render")192def render(req: RenderReq, background_tasks: BackgroundTasks):193    if not req.clipUrls:194        raise HTTPException(400, "clipUrls required")195 196    uid = uuid.uuid4().hex197    work_dir = f"/tmp/render_{req.jobId}_{uid}"198    os.makedirs(work_dir, exist_ok=True)199 200    clips_dir = os.path.join(work_dir, "clips")201    os.makedirs(clips_dir, exist_ok=True)202 203    concat_txt = os.path.join(work_dir, "concat.txt")204    merged = os.path.join(work_dir, "merged.mp4")205    final_mp4 = os.path.join(work_dir, f"final_{req.preset}.mp4")206 207    try:208        # Download clips209        with open(concat_txt, "w") as f:210            for i, url in enumerate(req.clipUrls):211                p = os.path.join(clips_dir, f"clip_{i}.mp4")212                r = requests.get(url, timeout=120)213                r.raise_for_status()214                with open(p, "wb") as w:215                    w.write(r.content)216                f.write(f"file '{p}'\n")217 218        # concat with fallback219        r1 = subprocess.run(220            ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_txt, "-c", "copy", merged],221            capture_output=True, text=True222        )223        if r1.returncode != 0:224            r2 = subprocess.run(225                ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_txt,226                 "-c:v", "libx264", "-crf", "19", "-preset", "veryfast",227                 "-c:a", "aac", "-b:a", "128k", merged],228                capture_output=True, text=True229            )230            if r2.returncode != 0:231                raise HTTPException(500, f"concat failed: {r2.stderr[-1500:]}")232 233        # Caption file (optional)234        srt_path = None235        if req.srt:236            srt_path = os.path.join(work_dir, "captions.srt")237            with open(srt_path, "w", encoding="utf-8") as w:238                w.write(req.srt)239 240        # Filter stack241        vf = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=contrast=1.08:saturation=1.12,unsharp=5:5:0.7:5:5:0.0,fps=30"242        if req.preset == "story_cinematic":243            vf += ",vignette=PI/6,eq=contrast=1.10:saturation=1.05"244        elif req.preset == "facts_clean":245            vf += ",eq=contrast=1.06:saturation=1.06"246 247        if srt_path:248            vf += f",subtitles={srt_path}:force_style='FontName=Arial,FontSize=20,Outline=3,Shadow=1,Alignment=2'"249 250        if req.voiceUrl:251            voice_path = os.path.join(work_dir, "voice.mp3")252            vr = requests.get(req.voiceUrl, timeout=120)253            vr.raise_for_status()254            with open(voice_path, "wb") as w:255                w.write(vr.content)256 257            cmd = [258                "ffmpeg", "-y", "-i", merged, "-i", voice_path,259                "-filter_complex", f"[0:v]{vf}[v];[1:a]loudnorm=I=-16:TP=-1.5:LRA=11[a]",260                "-map", "[v]", "-map", "[a]",261                "-shortest", "-movflags", "+faststart", "-pix_fmt", "yuv420p",262                final_mp4263            ]264        else:265            cmd = ["ffmpeg", "-y", "-i", merged, "-vf", vf, "-movflags", "+faststart", "-pix_fmt", "yuv420p", final_mp4]266 267        rr = subprocess.run(cmd, capture_output=True, text=True)268        if rr.returncode != 0 or not os.path.exists(final_mp4):269            raise HTTPException(500, f"render failed: {rr.stderr[-1500:]}")270 271        # cleanup AFTER response is sent272        background_tasks.add_task(lambda: subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"]))273        return FileResponse(path=final_mp4, media_type="video/mp4", filename=f"{req.jobId}_{req.preset}.mp4")274 275    except HTTPException:276        # cleanup on error too277        subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"])278        raise279    except Exception as e:280        subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"])281        raise HTTPException(500, str(e))