"""Motion Factory daily cron — drives the OpenReels API inside this container. POST /api/v1/jobs {topic, providers{...}} -> poll GET /api/v1/jobs/:id -> copy final mp4 to /data/motion/renders + index.jsonl. Env: RUN_HOUR_UTC (14), RUNNER_ENABLED (1), CHANNEL_NICHE, OMNIROUTE_* (topic generation + pipeline LLM), PEXELS_API_KEY. """ import json import os import shutil import time from datetime import datetime, timezone from pathlib import Path import requests API = "http://127.0.0.1:7860" RUN_HOUR_UTC = int(os.environ.get("RUN_HOUR_UTC", "14")) DATA_ROOT = Path("/data/motion") if os.path.isdir("/data") else Path.home() / ".motion" RENDERS = DATA_ROOT / "renders" TOPICS_FILE = DATA_ROOT / "topics.json" INDEX_FILE = RENDERS / "index.jsonl" POLL_INTERVAL = 30 POLL_MAX = 60 * 50 # 50 min ceiling def log(m): print(f"[runner {datetime.now(timezone.utc):%H:%M:%S}] {m}", flush=True) def load_topics(): try: return json.loads(TOPICS_FILE.read_text()) except Exception: return {"topics": [], "used": []} def save_topics(d): TOPICS_FILE.parent.mkdir(parents=True, exist_ok=True) TOPICS_FILE.write_text(json.dumps(d, indent=2)) def llm_topic(niche): key, base = os.environ.get("OMNIROUTE_API_KEY"), os.environ.get("OMNIROUTE_BASE_URL", "").rstrip("/") model = os.environ.get("OMNIROUTE_MODEL", "") if not (key and base): return None try: r = requests.post( f"{base}/v1/chat/completions", headers={"Authorization": f"Bearer {key}"}, json={"model": model, "max_tokens": 60, "messages": [{"role": "user", "content": f"Give me ONE specific, viral-worthy topic for a faceless YouTube Short " f"about {niche}. Reply with ONLY the topic text."}]}, timeout=90) r.raise_for_status() return (r.json()["choices"][0]["message"]["content"].strip().strip('"') or None)[:120] except Exception as e: log(f"llm topic failed: {e}") return None def next_topic(): d = load_topics() while d["topics"]: t = d["topics"].pop(0).strip() if t and t not in d["used"]: d["used"].append(t) save_topics(d) return t niche = os.environ.get("CHANNEL_NICHE", "surprising science and technology facts") save_topics(d) return llm_topic(niche) def create_job(topic): body = { "topic": topic, "noVideo": True, "providers": { "llm": "openai-compatible", "llmModel": os.environ.get("OMNIROUTE_MODEL", ""), "llmBaseUrl": os.environ.get("OMNIROUTE_BASE_URL", "").rstrip("/") + "/v1", "tts": "kokoro", "stock": "pexels", "music": "bundled", "searchProvider": "none", }, } for attempt in range(3): try: r = requests.post(f"{API}/api/v1/jobs", json=body, timeout=60) if r.status_code == 200: jid = r.json().get("jobId") or r.json().get("id") log(f"job created: {jid}") return jid log(f"create attempt {attempt+1} -> {r.status_code} {r.text[:200]}") except Exception as e: log(f"create attempt {attempt+1} error: {e}") time.sleep(20) return None def poll_job(jid): waited = 0 while waited < POLL_MAX: time.sleep(POLL_INTERVAL) waited += POLL_INTERVAL try: r = requests.get(f"{API}/api/v1/jobs/{jid}", timeout=30) r.raise_for_status() meta = r.json() except Exception as e: log(f"poll error: {e}") continue status = meta.get("status") stages = meta.get("stages", {}) cur = " ".join(f"{k}:{v.get('status','?')}" for k, v in stages.items() if v.get("status") not in ("pending",)) log(f"job {jid}: {status} | {cur[:160]}") if status in ("completed", "failed", "cancelled"): return meta log("poll timeout") return None def archive(meta, topic): vp = meta.get("videoPath") if not vp: log(f"no videoPath; error={str(meta.get('error'))[:200]}") return src = Path(vp) if not src.exists(): cand = list(Path("/app/jobs").rglob("*.mp4")) src = max(cand, key=lambda p: p.stat().st_size) if cand else None if not src: log("mp4 not found on disk") return RENDERS.mkdir(parents=True, exist_ok=True) stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") slug = "".join(c if c.isalnum() else "-" for c in topic.lower())[:40].strip("-") dst = RENDERS / f"{stamp}_{slug}.mp4" shutil.copy2(src, dst) with INDEX_FILE.open("a") as f: f.write(json.dumps({"date": stamp, "topic": topic, "file": dst.name, "size_mb": round(dst.stat().st_size / 1e6, 2)}) + "\n") log(f"ARCHIVED {dst.name}") def run_once(): topic = next_topic() if not topic: log("no topic; skipping") return log(f"creating job for: {topic!r}") jid = create_job(topic) if not jid: return meta = poll_job(jid) if meta and meta.get("status") == "completed": archive(meta, topic) def seconds_until_next_run(): now = datetime.now(timezone.utc) t = now.replace(hour=RUN_HOUR_UTC, minute=0, second=0, microsecond=0) from datetime import timedelta if t <= now: t += timedelta(days=1) return (t - now).total_seconds() def main(): if os.environ.get("RUNNER_ENABLED", "1") != "1": log("disabled; idling") while True: time.sleep(3600) for _ in range(120): try: requests.get(f"{API}/api/v1/health", timeout=5) break except Exception: time.sleep(5) log(f"online; daily run at {RUN_HOUR_UTC}:00 UTC") while True: w = seconds_until_next_run() log(f"next run in {w/3600:.1f}h") time.sleep(max(w, 10)) try: run_once() except Exception as e: log(f"cycle crashed: {e}") if __name__ == "__main__": main()