robertjojo/cine-encoder
0
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 