- вертикальный формат (Shorts/Reels): YouTube через Buffer принимает только вертикаль; безопасные зоны под интерфейс приложений, сегментный прогресс-бар как у сторис; - 5 тем (gold, terminal, paper, violet, poster): палитра, шрифты, фон-мотив, манера появления/ухода и своя музыка (MusicGen); тема дня — наименее давно использованная; - новые сцены chat и terminal (команды сверяются с источниками), ≥4 разных типа, не повторять структуру вчерашнего ролика; - озвучка по предложениям → точные субтитры и появление элементов на фразах; - приватность: без цитат реальных переписок и имён людей (проверка в валидаторе). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
30 KiB
Python
559 lines
30 KiB
Python
#!/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")
|
||
DAILY = os.path.dirname(os.path.abspath(__file__))
|
||
ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # gsap, hyperframes.json
|
||
FONTS = os.path.join(DAILY, "fonts")
|
||
MUSIC = os.path.join(DAILY, "music") # <тема>.wav; нет файла — gold.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 = """
|
||
The video is VERTICAL (1080×1920, YouTube Shorts / Reels), ~60–75 seconds. 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' (≤20 chars)",
|
||
"title": "video title (≤80 chars)",
|
||
"youtube_title": "≤90 chars, ends with #Shorts",
|
||
"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":"≤44 chars","strike":"optional: one word from line1 that the video REJECTS (e.g. chatbot in Not another chatbot); crossing it out must not invert the meaning — omit if unsure","line2":"≤44 chars, may contain *accent*","narration":"…"}
|
||
{"type":"points","kicker":"NN · Label","headline":"≤40 chars, may contain *accent*","items":[{"title":"≤26 chars","desc":"≤60 chars"}] (2–4),"narration":"…"}
|
||
{"type":"flow","kicker":"…","headline":"≤40 chars","steps":[{"title":"≤24 chars","desc":"≤50 chars"}] (3–4),"narration":"…"}
|
||
{"type":"stat","kicker":"…","headline":"≤40 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤80 chars","narration":"…"}
|
||
{"type":"compare","kicker":"…","headline":"≤40 chars","left_title":"≤22 chars (typical AI bot / status quo)","left":["≤32 chars", 2–3],"right_title":"Simbiot Harness","right":["≤32 chars", same count],"narration":"…"}
|
||
{"type":"statement","kicker":"…","text":"≤60 chars, may contain *accent*","sub":"≤100 chars","narration":"…"}
|
||
{"type":"chat","kicker":"…","headline":"≤40 chars","messages":[{"from":"user|agent|system","text":"≤90 chars"}] (2–4) — an illustrative exchange that shows REAL documented behaviour,"narration":"…"}
|
||
{"type":"terminal","kicker":"…","headline":"≤40 chars","lines":["$ a real command from the sources (≤44 chars)","output line (≤44 chars)"] (2–5),"narration":"…"}
|
||
{"type":"outro","line1":"≤34 chars","line2":"≤34 chars, may contain *accent*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"}
|
||
Structure: first "hook", last "outro", 3–5 middle scenes. Use at least 4 DIFFERENT scene types overall and do not copy
|
||
yesterday's sequence of types. Kickers numbered "01 · Label".
|
||
Narration: English, punchy spoken style, short sentences, 8–30 words per scene, 110–150 words total. Each sentence of
|
||
a scene introduces the next on-screen element in order (elements appear on sentence starts); captions show each sentence.
|
||
"""
|
||
|
||
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".
|
||
- PRIVACY: never quote, paraphrase or reconstruct real private chat messages or internal conversations from the
|
||
sources, and never name individual people (owner, board members, members, partners, candidates). Refer to roles only:
|
||
"the owner", "a board member", "a client". Chat scenes must be generic illustrations, not real exchanges.
|
||
- In narration write the domain as "cil dot by" (for the voice); in on-screen text and posts write "cil.by".
|
||
"""
|
||
|
||
|
||
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))
|
||
|
||
|
||
# люди из README/SOUL/коммитов — в публичный ролик их имена и переписки не попадают
|
||
PRIVATE_NAMES = ["Alex", "Alexander", "Shkor", "Vlad", "Vladislav", "Yura", "Yuri", "Gennady", "Nadezhda", "Yulia",
|
||
"Stas", "Bugaev", "Алекс", "Влад", "Юра", "Геннадий"]
|
||
|
||
LIMITS = {"line1": 44, "line2": 44, "headline": 40, "value": 6, "caption": 80, "text": 60, "sub": 100,
|
||
"left_title": 22, "right_title": 22, "cta": 50, "kicker": 24}
|
||
OUTRO_LIMIT = 34
|
||
|
||
|
||
def plain_(x):
|
||
return str(x).replace("*", "")
|
||
|
||
|
||
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 6 <= n <= 34:
|
||
errs.append(f"scene {i + 1}: narration {n} words (need 8–30)")
|
||
if s.get("type") == "outro" and max(len(plain_(s.get("line1", ""))), len(plain_(s.get("line2", "")))) > OUTRO_LIMIT:
|
||
errs.append(f"scene {i + 1}: outro lines must be ≤{OUTRO_LIMIT} chars")
|
||
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"], "chat": ["headline", "messages"],
|
||
"terminal": ["headline", "lines"]}.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, 26, 60), ("steps", 3, 4, 24, 50)):
|
||
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", [])) <= 3):
|
||
errs.append(f"scene {i + 1}: compare needs 2–3 rows on each side, equal counts")
|
||
for row in s.get("left", []) + s.get("right", []):
|
||
if len(row) > 32:
|
||
errs.append(f"scene {i + 1}: compare row too long: {row!r}")
|
||
if t == "chat":
|
||
ms = s.get("messages") or []
|
||
if not 2 <= len(ms) <= 4 or any(m.get("from") not in ("user", "agent", "system") or len(m.get("text", "")) > 90 for m in ms):
|
||
errs.append(f"scene {i + 1}: chat needs 2–4 messages, from user|agent|system, text ≤90")
|
||
if t == "terminal":
|
||
ls = s.get("lines") or []
|
||
if not 2 <= len(ls) <= 5 or any(len(x) > 46 for x in ls):
|
||
errs.append(f"scene {i + 1}: terminal needs 2–5 lines ≤44 chars")
|
||
for x in ls:
|
||
if x.startswith("$ "):
|
||
cmd = " ".join(x[2:].split()[:2])
|
||
if cmd.lower() not in src_text.lower():
|
||
errs.append(f"scene {i + 1}: command {cmd!r} not found in sources — use a real one")
|
||
if not 100 <= words <= 165:
|
||
errs.append(f"total narration {words} words (need 110–150)")
|
||
if len({x.get("type") for x in sc}) < 4:
|
||
errs.append("use at least 4 different scene types")
|
||
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")
|
||
for name in PRIVATE_NAMES:
|
||
if re.search(rf"\b{name}\b", blob, re.I):
|
||
errs.append(f"private person name {name!r} found — refer to roles, never quote real chats")
|
||
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, yesterday_types=None):
|
||
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\nYESTERDAY'S SCENE TYPES (vary from this): {yesterday_types 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_one(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",
|
||
"-ar", "48000", "-ac", "1", out_wav])
|
||
os.remove(raw)
|
||
return float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
|
||
|
||
|
||
def sentences(text):
|
||
parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", text.strip()) if p.strip()]
|
||
return parts or [text.strip()]
|
||
|
||
|
||
def tts(text, out_wav, workdir, gap=0.22):
|
||
"""Озвучка по предложениям: точные начала фраз для появления элементов и субтитров."""
|
||
sents, files, t = [], [], 0.0
|
||
for k, sent in enumerate(sentences(text)):
|
||
f = os.path.join(workdir, f"sent{k}.wav")
|
||
d = _tts_one(sent, f, workdir)
|
||
sents.append({"t0": round(t, 2), "t1": round(t + d, 2), "text": sent})
|
||
files.append(f)
|
||
t += d + gap
|
||
sil = os.path.join(workdir, "gap.wav")
|
||
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-t", str(gap), sil])
|
||
lst = os.path.join(workdir, "concat.txt")
|
||
with open(lst, "w") as fh:
|
||
for k, f in enumerate(files):
|
||
fh.write(f"file '{f}'\n" + (f"file '{sil}'\n" if k < len(files) - 1 else ""))
|
||
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", lst,
|
||
"-af", "loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav])
|
||
for f in files + [sil, lst]:
|
||
os.remove(f)
|
||
dur = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
|
||
return dur, sents
|
||
|
||
|
||
def bgm_bed(total, out_wav, theme):
|
||
src = os.path.join(MUSIC, f"{theme}.wav")
|
||
if not os.path.exists(src):
|
||
src = os.path.join(MUSIC, "gold.wav")
|
||
seg = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", src]).strip())
|
||
n = math.ceil(total / max(8, seg - 4)) + 1
|
||
ins = sum((["-i", 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.6,afade=t=out:st={total - 3:.2f}:d=3,loudnorm=I=-16:TP=-1.5")
|
||
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", *ins, "-filter_complex", ";".join(chain), "-ar", "48000", out_wav])
|
||
|
||
|
||
def pick_theme(st, date):
|
||
"""Наименее давно использованная тема (ротация 5 стилей)."""
|
||
last = {name: "" for name in template.THEMES}
|
||
for d, info in sorted(st["days"].items()):
|
||
if d != date and info.get("theme") in last:
|
||
last[info["theme"]] = d
|
||
return min(last, key=lambda name: (last[name], list(template.THEMES).index(name)))
|
||
|
||
|
||
def build_project(spec, work, date, theme):
|
||
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(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, sents = tts(s["narration"], wav, work)
|
||
lead, tail = 0.45, (2.4 if i == len(spec["scenes"]) - 1 else 0.6)
|
||
vo = t + lead
|
||
timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), "vo_dur": round(dur, 2),
|
||
"sents": [{"t0": round(vo + x["t0"], 2), "t1": round(vo + x["t1"], 2), "text": x["text"]} for x in sents]})
|
||
t += lead + dur + tail
|
||
log(f"scene {i + 1} {s['type']}: vo {dur:.1f}s, {len(sents)} sentences")
|
||
total = timing[-1]["start"] + timing[-1]["dur"]
|
||
bgm_bed(total, os.path.join(proj, "assets", "bgm", "bed.wav"), theme)
|
||
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, theme, 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", 2500))}}
|
||
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))
|
||
ap.add_argument("--theme", choices=list(template.THEMES))
|
||
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]
|
||
prev = [d for k, d in sorted(st["days"].items()) if k < a.date and d.get("types")]
|
||
theme = a.theme or pick_theme(st, a.date)
|
||
spec = json.load(open(a.spec)) if a.spec else write_spec(sources(), past, a.date, prev[-1]["types"] if prev else None)
|
||
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"])
|
||
day.update(theme=theme, types=[x["type"] for x in spec["scenes"]])
|
||
log("theme:", theme)
|
||
proj, total = build_project(spec, work, a.date, theme)
|
||
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())
|