#!/usr/bin/env python3 """Ежедневный ролик про Simbiot Harness: сценарий → озвучка → hyperframes → соцсети. Запуск (cron, 06:25 UTC; публикация ровно в 07:00 UTC = 10:00 Минск): venv/bin/python video/daily/daily.py --publish-at 07:00 Флаги: --dry-run (без публикации), --date YYYY-MM-DD, --spec file.json (свой сценарий, без LLM), --platforms x,telegram,linkedin,facebook,instagram,youtube. Предохранители (публикация без ревью человеком — решение Alex 2026-10-17): - факты только из README/SOUL/свежих коммитов; любое число в тексте должно встречаться в источниках, иначе сценарий отклоняется и переписывается; - темы не повторяются (state.json хранит историю); - hyperframes check с ошибками или вылезающим текстом → без публикации; - любой сбой → без публикации + уведомление владельцу в TG; - повторный запуск за ту же дату не постит дважды (результаты по площадкам в state). """ import argparse import datetime as dt import fcntl import json import math import os import re import shutil import subprocess import sys import time import traceback import urllib.request REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.insert(0, REPO) sys.path.insert(0, os.path.dirname(__file__)) import template # noqa: E402 HOME = os.environ.get("SIMBIOT_HOME", "/var/lib/simbiot-harness") os.environ.setdefault("SIMBIOT_HOME", HOME) BASE = os.path.join(HOME, "daily-video") STATE = os.path.join(BASE, "state.json") ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # шрифты, gsap BGM_SRC = os.path.join(REPO, "video", "simbiot-enterprise", "assets", "bgm", "track.wav") HF = ["npx", "--yes", "hyperframes@0.8.168"] KOKORO_PY = "/opt/kokoro-venv/bin/python" VOICE, SPEED = "am_michael", "1.1" PUBLIC_DIR, PUBLIC_URL = "/srv/raw.cil.by/simbiot/daily", "https://raw.cil.by/simbiot/daily" SOCIAL = "/root/coop/social" # (провайдер, модель): 9router первым; при его сбое — OpenRouter (ключ в env харнеса) MODELS = [("router", "ocz/claude-sonnet-5"), ("openrouter", "anthropic/claude-sonnet-5.5"), ("openrouter", "anthropic/claude-opus-5.5"), ("router", "auto")] ALL_PLATFORMS = ["x", "telegram", "linkedin", "facebook", "instagram", "youtube"] BUFFER_CHANNELS = {"facebook": "facebook", "instagram": "instagram", "youtube": "youtube"} LAYOUT_BAD = ("overflow", "out_of_canvas", "clipped", "escaped") def log(*a): print(f"[{dt.datetime.now(dt.timezone.utc):%H:%M:%S}]", *a, flush=True) def sh(cmd, timeout=1800, env=None, cwd=None): r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd, env={**os.environ, **(env or {})}, stdin=subprocess.DEVNULL) if r.returncode: raise RuntimeError(f"{' '.join(map(str, cmd[:4]))}… rc={r.returncode}: {(r.stderr or r.stdout)[-800:]}") return r.stdout def load_state(): try: return json.load(open(STATE)) except (OSError, ValueError): return {"days": {}} def save_state(st): os.makedirs(BASE, exist_ok=True) tmp = STATE + ".tmp" json.dump(st, open(tmp, "w"), ensure_ascii=False, indent=2) os.replace(tmp, STATE) # ───────────────────────── источники и сценарий ───────────────────────── def sources(): readme = open(os.path.join(REPO, "README.md")).read() soul = open(os.path.join(REPO, "docs", "SOUL.md")).read() commits = sh(["git", "-C", REPO, "log", "--since=4 days ago", "--date=short", "--pretty=format:### %ad %s%n%b"], timeout=30)[:14000] return {"readme": readme, "soul": soul, "commits": commits} SCHEMA_HELP = """ Return ONLY a JSON object: { "topic": "short English topic label (≤60 chars), must differ from past topics", "series": "short uppercase tag for the HUD, e.g. 'DAILY · SECURITY' (≤22 chars)", "title": "video title (≤80 chars)", "youtube_title": "≤90 chars", "x_text": "post for X, ≤260 chars incl. hashtags and 'cil.by'", "post_text": "post for LinkedIn/Telegram/Facebook/Instagram, 450–950 chars, short paragraphs, a few emoji ok, ends with a call to action mentioning cil.by and 3–5 hashtags", "scenes": [ ...5 to 7 scenes... ] } Scene types (every scene also has "narration": what the voice says during the scene): {"type":"hook","line1":"≤60 chars","strike":"one word from line1 to strike through (optional)","line2":"≤60 chars, may contain *gold*","narration":"…"} {"type":"points","kicker":"NN · Label","headline":"≤48 chars, may contain *gold*","items":[{"title":"≤30 chars","desc":"≤75 chars"}] (2–4 items),"narration":"…"} {"type":"flow","kicker":"…","headline":"≤48 chars","steps":[{"title":"≤20 chars","desc":"≤55 chars"}] (3–4 steps),"narration":"…"} {"type":"stat","kicker":"…","headline":"≤48 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤90 chars","narration":"…"} {"type":"compare","kicker":"…","headline":"≤48 chars","left_title":"≤22 chars (typical AI bot / status quo)","left":["≤40 chars", …2–4],"right_title":"Simbiot Harness","right":["≤40 chars", …same count],"narration":"…"} {"type":"statement","kicker":"…","text":"≤70 chars, may contain *gold*","sub":"≤110 chars","narration":"…"} {"type":"outro","line1":"≤40 chars","line2":"≤40 chars, may contain *gold*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"} Structure: first scene "hook", last scene "outro", 3–5 middle scenes of varied types. Kickers numbered "01 · Cost", "02 · …". Narration: English, natural spoken style, 15–45 words per scene, 150–200 words total. Each sentence of a scene's narration should introduce the next on-screen item in order (items appear on sentence starts). """ RULES = """You write a daily ~80-second explainer video + social posts about Simbiot Harness (an AI agent built by Cooperative 42, cil.by), for business decision-makers, in English. Hard rules: - Everything must be about Simbiot Harness / Cooperative 42. Use ONLY facts present in the SOURCES. No invented customers, metrics, prices, benchmarks, partners, dates or promises. Every number you write must appear in the sources. - Pick the topic that is most relevant NOW: prefer what changed in the RECENT COMMITS, explained as a business benefit; otherwise pick an important capability not covered by PAST TOPICS. Never repeat a past topic. - Translate internal Russian terms into plain business English. No internal task ids (t-…, e-…), no file paths. - Tone: confident, concrete, no hype words like "revolutionary". """ def llm(messages): from harness import runtime prov = {"router": (runtime._router_base_url(), runtime._router_key()), "openrouter": (runtime._env_or_file("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), runtime._env_or_file("OPENROUTER_KEY"))} last = None for name, model in MODELS: base, key = prov[name] if not key: continue try: body = {"model": model, "messages": messages, "temperature": 0.7, "max_tokens": 6000, "stream": False} req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=json.dumps(body).encode(), headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) out = json.loads(urllib.request.urlopen(req, timeout=240).read()) txt = out["choices"][0]["message"]["content"] log("llm ok:", name, model, out.get("usage", {})) return txt except Exception as e: # следующий по списку last = e log("llm fail:", name, model, e) raise RuntimeError(f"all models failed: {last}") def parse_json(txt): m = re.search(r"\{.*\}", txt, re.S) if not m: raise ValueError("no JSON in LLM answer") return json.loads(m.group(0)) LIMITS = {"line1": 60, "line2": 60, "headline": 48, "title": 30, "desc": 75, "value": 6, "caption": 90, "text": 70, "sub": 110, "left_title": 22, "right_title": 22, "cta": 60, "kicker": 28} def validate(spec, src_text, past): errs = [] sc = spec.get("scenes") or [] if not 5 <= len(sc) <= 7: errs.append(f"need 5–7 scenes, got {len(sc)}") if sc and sc[0].get("type") != "hook": errs.append("first scene must be hook") if sc and sc[-1].get("type") != "outro": errs.append("last scene must be outro") words = 0 for i, s in enumerate(sc): n = len(str(s.get("narration", "")).split()) words += n if not 10 <= n <= 50: errs.append(f"scene {i + 1}: narration {n} words (need 15–45)") for k, lim in LIMITS.items(): v = s.get(k) if isinstance(v, str) and len(v.replace("*", "")) > lim: errs.append(f"scene {i + 1}: {k} too long ({len(v)}>{lim}): {v!r}") t = s.get("type") need = {"hook": ["line1", "line2"], "points": ["headline", "items"], "flow": ["headline", "steps"], "stat": ["headline", "value", "caption"], "compare": ["headline", "left", "right", "left_title", "right_title"], "statement": ["text"], "outro": ["line1", "line2"]}.get(t) if need is None: errs.append(f"scene {i + 1}: unknown type {t}") continue for k in need: if not s.get(k): errs.append(f"scene {i + 1}: missing {k}") for key, lo, hi, tl, dl in (("items", 2, 4, 30, 75), ("steps", 3, 4, 20, 55)): if t in ("points", "flow") and key in s: if not lo <= len(s[key]) <= hi: errs.append(f"scene {i + 1}: {key} count {len(s[key])} (need {lo}–{hi})") for it in s[key]: if len(it.get("title", "")) > tl or len(it.get("desc", "")) > dl: errs.append(f"scene {i + 1}: {key} entry too long: {it}") if t == "compare" and (len(s.get("left", [])) != len(s.get("right", [])) or not 2 <= len(s.get("left", [])) <= 4): errs.append(f"scene {i + 1}: compare needs 2–4 rows on each side, equal counts") for row in s.get("left", []) + s.get("right", []): if len(row) > 40: errs.append(f"scene {i + 1}: compare row too long: {row!r}") if not 130 <= words <= 220: errs.append(f"total narration {words} words (need 150–200)") if len(spec.get("x_text", "")) > 270: errs.append("x_text over 270 chars") if not 300 <= len(spec.get("post_text", "")) <= 1000: errs.append(f"post_text length {len(spec.get('post_text', ''))} (need 450–950)") if "cil.by" not in spec.get("post_text", "") or "cil.by" not in spec.get("x_text", ""): errs.append("posts must mention cil.by") # числа — только из источников blob = json.dumps(spec, ensure_ascii=False) norm_src = src_text.replace(" ", "").replace(",", "").lower() for num in set(re.findall(r"\d[\d.,:/]*\d|\d", blob)): n = num.strip(".,:/").replace(",", "") if n in ("1", "2", "3", "4", "5", "6", "7", "01", "02", "03", "04", "05", "06", "42", "24", "7"): continue # номера глав/пунктов, «42» — имя кооператива if n.lower() not in norm_src: errs.append(f"number {num!r} not found in sources — remove it or use a sourced one") if spec.get("topic", "").strip().lower() in {p.lower() for p in past}: errs.append("topic repeats a past topic") return errs def write_spec(src, past, date): src_text = src["readme"] + src["soul"] + src["commits"] user = (f"TODAY: {date}\n\nPAST TOPICS (do not repeat):\n- " + "\n- ".join(past[-40:] or ["(none)"]) + f"\n\nRECENT COMMITS (last 4 days):\n{src['commits']}\n\nSOUL:\n{src['soul']}\n\nREADME:\n{src['readme']}\n\n{SCHEMA_HELP}") msgs = [{"role": "system", "content": RULES}, {"role": "user", "content": user}] for attempt in range(4): txt = llm(msgs) try: spec = parse_json(txt) errs = validate(spec, src_text, past) except Exception as e: spec, errs = None, [f"invalid JSON: {e}"] if not errs: return spec log(f"spec attempt {attempt + 1} rejected:", errs[:8]) msgs += [{"role": "assistant", "content": txt}, {"role": "user", "content": "Fix these problems and return the full corrected JSON only:\n- " + "\n- ".join(errs)}] raise RuntimeError(f"spec still invalid after retries: {errs[:6]}") # ───────────────────────── озвучка и сборка ───────────────────────── def tts(text, out_wav, workdir): raw = os.path.join(workdir, "raw.wav") sh(HF + ["tts", text, "-v", VOICE, "-s", SPEED, "-o", raw], timeout=600, env={"HYPERFRAMES_PYTHON": KOKORO_PY}) sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-i", raw, "-af", "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse," "loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav]) os.remove(raw) dur = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip()) det = subprocess.run(["ffmpeg", "-nostdin", "-i", out_wav, "-af", "silencedetect=n=-38dB:d=0.18", "-f", "null", "-"], capture_output=True, text=True).stderr ends = [float(x) for x in re.findall(r"silence_end: ([0-9.]+)", det)] return dur, [0.0] + [e for e in ends if e < dur - 0.3] def bgm_bed(total, out_wav): n = math.ceil(total / 26) + 1 ins = sum((["-i", BGM_SRC] for _ in range(n)), []) chain, prev = [], "[0]" for k in range(1, n): lab = f"[x{k}]" chain.append(f"{prev}[{k}]acrossfade=d=4:c1=tri:c2=tri{lab}") prev = lab chain.append(f"{prev}atrim=0:{total:.2f},afade=t=in:d=0.8,afade=t=out:st={total - 4:.2f}:d=4,loudnorm=I=-16:TP=-1.5") sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", *ins, "-filter_complex", ";".join(chain), "-ar", "48000", out_wav]) def build_project(spec, work, date): proj = os.path.join(work, "project") shutil.rmtree(proj, ignore_errors=True) os.makedirs(os.path.join(proj, "assets", "vo")) os.makedirs(os.path.join(proj, "assets", "bgm")) shutil.copytree(os.path.join(ASSETS, "fonts"), os.path.join(proj, "fonts")) for f in ("gsap.min.js", "hyperframes.json", "meta.json", "package.json"): shutil.copy(os.path.join(ASSETS, f), proj) timing, t = [], 0.0 for i, s in enumerate(spec["scenes"]): wav = os.path.join(proj, "assets", "vo", f"v{i + 1}.wav") dur, starts = tts(s["narration"], wav, work) lead, tail = 0.5, (2.6 if i == len(spec["scenes"]) - 1 else 0.8) vo = t + lead timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), "vo_dur": round(dur, 2), "phrase_starts": [round(vo + x, 2) for x in starts]}) t += lead + dur + tail log(f"scene {i + 1} {s['type']}: vo {dur:.1f}s") total = timing[-1]["start"] + timing[-1]["dur"] bgm_bed(total, os.path.join(proj, "assets", "bgm", "bed.wav")) spec["date_label"] = dt.date.fromisoformat(date).strftime("%d %b %Y").upper() open(os.path.join(proj, "index.html"), "w").write(template.build(spec, timing, spec.get("series", "DAILY"))) json.dump(timing, open(os.path.join(work, "timing.json"), "w"), indent=1) return proj, total def check(proj): out = subprocess.run(HF + ["check", "--json"], cwd=proj, capture_output=True, text=True, timeout=900, stdin=subprocess.DEVNULL).stdout rep = json.loads(out[out.index("{"):]) problems = [] for sect in ("lint", "runtime", "layout", "motion"): s = rep.get(sect) or {} if s.get("errorCount"): problems.append(f"{sect}: {s['errorCount']} errors") if sect == "layout": for f in s.get("findings", []): code = str(f.get("code") or f.get("rule") or f.get("type") or "") if any(b in code for b in LAYOUT_BAD) and "bg" not in json.dumps(f)[:300]: problems.append(f"layout {code}: {str(f.get('message') or f)[:160]}") return problems def render(proj, out_mp4): sh(HF + ["render", "-o", out_mp4], timeout=2400, cwd=proj) info = sh(["ffprobe", "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", out_mp4]) if "video" not in info or "audio" not in info: raise RuntimeError(f"render missing streams: {info!r}") # ───────────────────────── публикация ───────────────────────── def buffer_post(service, text, url, spec): env = dict(l.split("=", 1) for l in open(os.path.join(SOCIAL, ".env")).read().splitlines() if "=" in l) key, org = env["BUFFER_ACCESS_TOKEN"].strip(), env.get("BUFFER_ORG_ID", "6151d52eea195d0f412ff493").strip() def gql(q, v=None): req = urllib.request.Request("https://api.buffer.com", data=json.dumps({"query": q, "variables": v or {}}).encode(), headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) return json.loads(urllib.request.urlopen(req, timeout=90).read()) chans = gql('query($o: OrganizationId!){ channels(input:{organizationId:$o}){ id service isDisconnected isLocked } }', {"o": org})["data"]["channels"] ch = next((c for c in chans if c["service"] == service and not c["isDisconnected"] and not c["isLocked"]), None) if not ch: return "skip: channel not connected" meta = {"facebook": {"facebook": {"type": "post"}}, "instagram": {"instagram": {"type": "reel", "shouldShareToFeed": True}}, "youtube": {"youtube": {"title": spec.get("youtube_title") or spec["title"], "privacy": "public", "categoryId": "28", "madeForKids": False, "isAiGenerated": True, "notifySubscribers": True}}}[service] video = {"url": url, "metadata": {"title": spec["title"][:100]}} if service == "instagram": video = {"url": url, "metadata": {"thumbnailOffset": int(spec.get("_thumb_ms", 3000))}} inp = {"channelId": ch["id"], "text": text, "assets": [{"video": video}], "mode": "shareNow", "schedulingType": "automatic", "needsApproval": False, "metadata": meta, "source": "simbiot-daily"} r = gql("mutation($input: CreatePostInput!){ createPost(input:$input){ __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }", {"input": inp})["data"]["createPost"] if r["__typename"] != "PostActionSuccess": raise RuntimeError(r.get("message")) pid = r["post"]["id"] for _ in range(60): p = gql("query($id: PostId!){ post(input:{id:$id}){ status error { message } externalLink } }", {"id": pid})["data"]["post"] if p["status"] == "sent": return p.get("externalLink") or f"sent {pid}" if p["status"] in ("error", "failed") or p.get("error"): raise RuntimeError(f"buffer {service}: {p}") time.sleep(10) return f"pending {pid}" def publish(spec, mp4, date, platforms, results): os.makedirs(PUBLIC_DIR, exist_ok=True) pub = os.path.join(PUBLIC_DIR, f"{date}.mp4") shutil.copy(mp4, pub) url = f"{PUBLIC_URL}/{date}.mp4" for p in platforms: if str(results.get(p, "")).startswith(("ok", "http", "sent")): continue # уже опубликовано этим днём — не дублируем try: if p in ("x", "telegram", "linkedin"): text = spec["x_text"] if p == "x" else spec["post_text"] out = sh(["python3", os.path.join(SOCIAL, "publish.py"), "--text", text, "--media", mp4, "--platforms", p], timeout=1500, env={"LINKEDIN_VIDEO_TITLE": spec["title"][:100]}) res = json.loads(out[out.index("{"):]).get(p, out[-200:]) if str(res).startswith("ERROR"): raise RuntimeError(res) results[p] = res else: results[p] = buffer_post(BUFFER_CHANNELS[p], spec["post_text"], url, spec) except Exception as e: results[p] = f"ERROR: {str(e)[:300]}" log("publish", p, "→", results[p]) return results def notify(text, key): try: sh([os.path.join(REPO, "venv", "bin", "python"), os.path.join(REPO, "cli.py"), "tg-notify", text, "-k", key], timeout=60) except Exception as e: log("notify failed:", e) def links(results): tg_ch = "shkorvision" out = [] for p, r in results.items(): r = str(r) if p == "x" and r.startswith("ok id="): r = f"https://x.com/alexshkor/status/{r[6:]}" elif p == "telegram" and r.startswith("ok id="): r = f"https://t.me/{tg_ch}/{r[6:]}" elif p == "linkedin" and "urn:li:" in r: r = "https://www.linkedin.com/feed/update/" + r[r.index("urn:li:"):] out.append(f"• {p}: {r}") return "\n".join(out) # ───────────────────────── main ───────────────────────── def main(): ap = argparse.ArgumentParser() ap.add_argument("--date", default=dt.date.today().isoformat()) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--spec") ap.add_argument("--publish-at", help="HH:MM UTC — ждать до этого времени перед публикацией") ap.add_argument("--platforms", default=",".join(ALL_PLATFORMS)) a = ap.parse_args() os.makedirs(BASE, exist_ok=True) lock = open(os.path.join(BASE, ".lock"), "w") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: log("another run in progress — exit") return 0 st = load_state() day = st["days"].setdefault(a.date, {}) work = os.path.join(BASE, a.date) os.makedirs(work, exist_ok=True) mp4 = os.path.join(work, "video.mp4") try: if day.get("rendered") and os.path.exists(mp4) and os.path.exists(os.path.join(work, "spec.json")): spec = json.load(open(os.path.join(work, "spec.json"))) log("reuse rendered video for", a.date) else: past = [d["topic"] for k, d in sorted(st["days"].items()) if d.get("topic") and k != a.date] spec = json.load(open(a.spec)) if a.spec else write_spec(sources(), past, a.date) json.dump(spec, open(os.path.join(work, "spec.json"), "w"), ensure_ascii=False, indent=2) day["topic"] = spec.get("topic", "") log("topic:", day["topic"]) proj, total = build_project(spec, work, a.date) probs = check(proj) if probs: raise RuntimeError("hyperframes check failed: " + "; ".join(probs[:6])) render(proj, mp4) day.update(rendered=True, duration=round(total, 1)) save_state(st) if a.dry_run: log("dry run — video:", mp4) return 0 if a.publish_at: hh, mm = map(int, a.publish_at.split(":")) now = dt.datetime.now(dt.timezone.utc) target = now.replace(hour=hh, minute=mm, second=0, microsecond=0) if target > now: log("waiting until", target, "UTC") time.sleep((target - now).total_seconds()) res = publish(spec, mp4, a.date, [p for p in a.platforms.split(",") if p], day.setdefault("results", {})) save_state(st) failed = [p for p, r in res.items() if str(r).startswith("ERROR")] notify(f"🎬 Ежедневный ролик {a.date}: «{spec.get('topic')}»\n{links(res)}" + (f"\n⚠️ не вышло: {', '.join(failed)}" if failed else ""), f"daily-video-{a.date}") return 1 if failed else 0 except Exception as e: day["error"] = str(e)[:500] save_state(st) log("FAILED:", traceback.format_exc()) if not a.dry_run: notify(f"⚠️ Ежедневный ролик {a.date} не опубликован: {str(e)[:400]}", f"daily-video-fail-{a.date}") return 2 if __name__ == "__main__": sys.exit(main())