simbiot-harness/video/daily/daily.py
Simbiot Harness 1fd4659568 ежедневный ролик: сценарий LLM → озвучка Kokoro → hyperframes → соцсети
video/daily/daily.py — оркестратор (cron 06:25 UTC, публикация 07:00 UTC = 10:00 Минск):
факты только из README/SOUL/коммитов за 4 дня (числа сверяются с источниками),
темы не повторяются (state.json в SIMBIOT_HOME/daily-video), hyperframes check
с ошибками/вылезающим текстом — без публикации, сбой — уведомление владельцу.
Площадки: X/Telegram/LinkedIn (coop/social/publish.py) + FB/IG/YouTube (Buffer).
Модели: 9router ocz/claude-sonnet-5 → OpenRouter claude-sonnet-5.5/opus-5.5.
video/daily/template.py — 7 типов сцен в стиле enterprise-ролика.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:36:20 +00:00

477 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())