CoolFace
Apppublic

robertjojo/cine-encoder

sourceHugging Faceupdated 1y agoView on Hugging Face
0likes
cine_encoder_server.py300 linesDownload Raw Back to root
1import os, io, json, shutil, subprocess, tempfile, time2from typing import Dict, Any, Optional3 4import requests5from fastapi import FastAPI, HTTPException6from fastapi.responses import JSONResponse7from PIL import Image8 9# -------------------- CONFIG --------------------10GH_DIR     = os.environ.get("GH_DIR", "/data/ghpages")              # must be writable11PAGES_REPO = os.environ.get("PAGES_REPO")                           # e.g. https://user:token@github.com/robertjojo123/cine-host.git12PAGES_BASE = os.environ.get("PAGES_BASE", "https://robertjojo123.github.io/cine-host/")13 14FFMPEG     = os.environ.get("FFMPEG", "ffmpeg")15AUDIO_RATE = 4800016CHUNK_MB   = 24            # ~24 MB per video text chunk17HALFBLOCK  = True          # export half-block blit triplets18 19os.makedirs(GH_DIR, exist_ok=True)20 21# -------------------- HELPERS --------------------22def run(cmd: list[str], cwd: Optional[str] = None, check: bool = True) -> subprocess.CompletedProcess:23    """Run a shell command and capture output; raise with stdout on failure."""24    try:25        return subprocess.run(cmd, cwd=cwd, check=check, text=True,26                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)27    except subprocess.CalledProcessError as e:28        # include stdout in exception for easier debugging29        raise RuntimeError(f"cmd failed: {' '.join(cmd)}\n{e.stdout or ''}") from None30 31def http_to_file(url: str, out_path: str):32    """Stream a URL to a local file."""33    with requests.get(url, stream=True, timeout=60) as r:34        r.raise_for_status()35        with open(out_path, "wb") as f:36            for chunk in r.iter_content(chunk_size=1024 * 256):37                if chunk:38                    f.write(chunk)39 40def nearest_cc_index(rgb):41    """Map RGB to nearest CC 16-color index (simple Euclidean)."""42    CC_RGB = [43        (0,0,0),(255,255,255),(255,0,0),(255,128,0),(255,255,0),(0,255,0),44        (0,255,255),(0,0,255),(255,0,255),(128,128,128),(192,192,192),45        (128,0,0),(128,64,0),(128,128,0),(0,128,0),(0,128,128)46    ]47    r,g,b = rgb48    best_i, best_d = 0, 10**949    for i,(R,G,B) in enumerate(CC_RGB):50        d = (R-r)*(R-r)+(G-g)*(G-g)+(B-b)*(B-b)51        if d < best_d:52            best_d, best_i = d, i53    return best_i54 55# -------------------- DFPWM ENCODER --------------------56def dfpwm_encode_pcm_s16le(raw: bytes) -> bytes:57    """58    Minimal DFPWM1a encoder.59    Input: little-endian 16-bit mono PCM @ AUDIO_RATE60    Output: bytes (8 samples per byte).61    """62    it = memoryview(raw).cast("h")  # signed short63    charge = 064    strength = 065    prev_bit = 066    PREC = 1067    out = bytearray()68 69    def clamp(x, lo, hi): return lo if x < lo else hi if x > hi else x70 71    for i in range(0, len(it), 8):72        b = 073        for j in range(8):74            if i + j >= len(it): break75            # scale 16-bit to about [-128..127]76            level = clamp(it[i+j] >> 9, -128, 127)77            cur_bit = 1 if (level > charge or (level == charge and charge == 127)) else 078            target  = 127 if cur_bit else -12879 80            next_charge = charge + (((strength * (target - charge)) + (1 << (PREC - 1))) >> PREC)81            if next_charge == charge and next_charge != target:82                next_charge += (1 if cur_bit else -1)83 84            z = ((1 << PREC) - 1) if (cur_bit == prev_bit) else 085            next_strength = strength86            if strength != z:87                next_strength += (1 if cur_bit == prev_bit else -1)88            if next_strength < (2 << (PREC - 8)):89                next_strength = (2 << (PREC - 8))90 91            charge = next_charge92            strength = next_strength93            prev_bit = cur_bit94            b = (b >> 1) + (128 if cur_bit else 0)95        out.append(b)96    return bytes(out)97 98# -------------------- VIDEO TEXT CHUNKS --------------------99def frames_to_sp_chunks(png_dir: str, w: int, h: int, fps: int, out_dir: str):100    """101    Write CC blit half-block triplets per rendered line:102      TEXT  (e.g. "▀▀▀...")  -> top half (foreground)103      FGHEX (e.g. "0f49...") -> 16-color hex for foreground per cell104      BGHEX (e.g. "9a0c...") -> 16-color hex for background per cell105    Separate frames with a single line "=".106    """107    os.makedirs(out_dir, exist_ok=True)108    frames = sorted([f for f in os.listdir(png_dir) if f.endswith(".png")])109 110    # meta111    with open(os.path.join(out_dir, "meta.txt"), "w", encoding="utf-8") as m:112        m.write(f"{w} {h} {fps} halfblock\n")113 114    # chunk writer115    max_bytes = CHUNK_MB * 1024 * 1024116    chunk_index = 0117    cur_size = 0118    cur_path = os.path.join(out_dir, f"sp_chunk_{chunk_index:04d}.txt")119    cur = open(cur_path, "w", encoding="utf-8")120 121    def rotate():122        nonlocal chunk_index, cur, cur_path, cur_size123        cur.close()124        chunk_index += 1125        cur_path = os.path.join(out_dir, f"sp_chunk_{chunk_index:04d}.txt")126        cur = open(cur_path, "w", encoding="utf-8")127        cur_size = 0128 129    HEX = "0123456789abcdef"130 131    for fp in frames:132        img = Image.open(os.path.join(png_dir, fp)).convert("RGB").resize((w, h), Image.BICUBIC)133 134        y = 0135        while y < h:136            # top and bottom scanlines for each half-block row137            top_px = list(img.crop((0, y, w, y+1)).getdata())138            bot_px = list(img.crop((0, min(y+1, h-1), w, min(y+2, h))).getdata())139 140            text_chars = []141            fg_hex = []142            bg_hex = []143            for x in range(w):144                fg = nearest_cc_index(top_px[x])145                bg = nearest_cc_index(bot_px[x])146                text_chars.append("▀")              # draw the upper half-block147                fg_hex.append(HEX[fg])148                bg_hex.append(HEX[bg])149 150            line_txt = "".join(text_chars) + "\n"151            line_fg  = "".join(fg_hex) + "\n"152            line_bg  = "".join(bg_hex) + "\n"153 154            to_write = line_txt + line_fg + line_bg155            size_inc = len(to_write.encode("utf-8"))156            if cur_size + size_inc > max_bytes:157                rotate()158            cur.write(to_write)159            cur_size += size_inc160            y += 2161 162        # frame separator163        sep = "=\n"164        if cur_size + len(sep.encode("utf-8")) > max_bytes:165            rotate()166        cur.write(sep)167        cur_size += len(sep.encode("utf-8"))168 169    cur.close()170 171# -------------------- TRANSCODE PIPELINE --------------------172def transcode_to_assets(mp4_path: str, w: int, h: int, fps: int, build_dir: str) -> str:173    frames_dir = os.path.join(build_dir, "frames")174    os.makedirs(frames_dir, exist_ok=True)175 176    # 1) Extract frames at W×H and fps177    run([178        FFMPEG, "-y", "-i", mp4_path,179        "-vf", f"fps={fps},scale={w}:{h}:flags=bicubic",180        os.path.join(frames_dir, "frame_%06d.png"),181    ])182 183    # 2) Extract mono PCM s16le at AUDIO_RATE184    pcm_path = os.path.join(build_dir, "audio_s16le.raw")185    run([FFMPEG, "-y", "-i", mp4_path, "-ac", "1", "-ar", str(AUDIO_RATE), "-f", "s16le", pcm_path])186 187    # 3) Encode DFPWM188    with open(pcm_path, "rb") as f:189        raw = f.read()190    df_bytes = dfpwm_encode_pcm_s16le(raw)191    with open(os.path.join(build_dir, "audio.dfpwm"), "wb") as f:192        f.write(df_bytes)193 194    # 4) Write video chunks195    out_dir = os.path.join(build_dir, "publish")196    frames_to_sp_chunks(frames_dir, w, h, fps, out_dir)197    return out_dir198 199# -------------------- GH PAGES PUBLISH --------------------200def ensure_repo():201    """Clone/init the pages repo and set identity; ensure .nojekyll exists."""202    if not PAGES_REPO:203        raise RuntimeError("PAGES_REPO not set in Space secrets")204 205    if not os.path.isdir(os.path.join(GH_DIR, ".git")):206        entries = [e for e in os.listdir(GH_DIR) if e not in (".", "..")]207        if entries:208            run(["git", "init"], cwd=GH_DIR)209            run(["git", "remote", "add", "origin", PAGES_REPO], cwd=GH_DIR)210        else:211            run(["git", "clone", "--depth", "1", PAGES_REPO, GH_DIR])212    # identity213    run(["git", "config", "user.name", "cine-encoder"], cwd=GH_DIR)214    run(["git", "config", "user.email", "cine-encoder@users.noreply.github.com"], cwd=GH_DIR)215    # .nojekyll216    path = os.path.join(GH_DIR, ".nojekyll")217    if not os.path.exists(path):218        open(path, "w").close()219        run(["git", "add", ".nojekyll"], cwd=GH_DIR)220        run(["git", "commit", "-m", "add .nojekyll"], cwd=GH_DIR)221        run(["git", "push", "origin", "HEAD:main"], cwd=GH_DIR)222 223def publish_folder(job_id: str, publish_dir: str) -> str:224    ensure_repo()225    job_root = os.path.join(GH_DIR, job_id)226    if os.path.exists(job_root):227        shutil.rmtree(job_root)228    shutil.copytree(publish_dir, job_root)229 230    # copy audio next to chunks231    audio_src = os.path.join(os.path.dirname(publish_dir), "audio.dfpwm")232    shutil.copy2(audio_src, os.path.join(job_root, "audio.dfpwm"))233 234    run(["git", "add", job_id], cwd=GH_DIR)235    run(["git", "commit", "-m", f"publish job {job_id}"], cwd=GH_DIR)236    run(["git", "push", "origin", "HEAD:main"], cwd=GH_DIR)237    return f"{PAGES_BASE.rstrip('/')}/{job_id}/"238 239# -------------------- FASTAPI APP --------------------240app = FastAPI()241_jobs: Dict[str, Dict[str, Any]] = {}242 243# Health/root endpoints (so Spaces marks the app Running)244@app.get("/")245def root(logs: Optional[str] = None):246    try:247        ensure_repo()248        return {"ok": True, "gh_dir": GH_DIR, "base": PAGES_BASE, "logs": logs}249    except Exception as e:250        return JSONResponse({"ok": False, "error": str(e)}, status_code=500)251 252@app.head("/")253def root_head(logs: Optional[str] = None):254    return JSONResponse({"ok": True})255 256@app.get("/health")257def health():258    return {"ok": True}259 260# Job API261@app.post("/job")262def job_new(req: Dict[str, Any]):263    mp4_url = req.get("mp4_url")264    w   = int(req.get("w",   164))265    h   = int(req.get("h",   162))266    fps = int(req.get("fps", 20))267    if not mp4_url:268        raise HTTPException(status_code=400, detail="mp4_url is required")269 270    job_id = f"{int(time.time())}{str(hash(mp4_url))[-6:]}"271    _jobs[job_id] = {"status": "queued"}272 273    try:274        with tempfile.TemporaryDirectory(prefix="cine_") as td:275            in_mp4 = os.path.join(td, "in.mp4")276            http_to_file(mp4_url, in_mp4)277 278            build_dir = os.path.join(td, "build")279            os.makedirs(build_dir, exist_ok=True)280 281            _jobs[job_id]["status"] = "running"282            out_pub = transcode_to_assets(in_mp4, w, h, fps, build_dir)283            base = publish_folder(job_id, out_pub)284 285            _jobs[job_id] = {286                "status": "ready",287                "result": {"video_base": base, "audio_url": base + "audio.dfpwm"}288            }289    except Exception as e:290        _jobs[job_id] = {"status": "error", "error": str(e)}291 292    return {"id": job_id, "status": _jobs[job_id]["status"]}293 294@app.get("/job/{job_id}")295def job_get(job_id: str):296    j = _jobs.get(job_id)297    if not j:298        return {"id": job_id, "status": "unknown"}299    return {"id": job_id, **j}300