perplexica / runner.py
abc1181's picture
Motion Factory: OpenReels edition (part 2)
9dbb64a verified
Raw History Blame Contribute Delete
6.17 kB
"""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()