ежедневный ролик: сценарий 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>
This commit is contained in:
parent
45cda710db
commit
1fd4659568
2 changed files with 768 additions and 0 deletions
477
video/daily/daily.py
Normal file
477
video/daily/daily.py
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
#!/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())
|
||||||
291
video/daily/template.py
Normal file
291
video/daily/template.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""Сценарий (spec) + тайминги озвучки → index.html композиции hyperframes.
|
||||||
|
|
||||||
|
Визуальный язык — как у ролика «Simbiot Harness for enterprise» (тёмный фон,
|
||||||
|
золотой акцент, Unbounded/Inter/JetBrains Mono, HUD с главами и прогрессом).
|
||||||
|
|
||||||
|
Типы сцен (spec["scenes"][i]["type"]):
|
||||||
|
hook line1, strike (слово из line1, зачёркивается), line2
|
||||||
|
points kicker, headline, items[{title, desc}] (2–4)
|
||||||
|
flow kicker, headline, steps[{title, desc}] (3–4)
|
||||||
|
stat kicker, headline, value, caption
|
||||||
|
compare kicker, headline, left_title, left[], right_title, right[] (2–4)
|
||||||
|
statement kicker, text, sub
|
||||||
|
outro line1, line2, cta
|
||||||
|
В headline/text/line2 можно выделить фрагмент золотом: *так*.
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
FONTS_CSS = "\n".join(
|
||||||
|
f'@font-face {{ font-family: "{fam}"; font-weight: {w}; src: url("fonts/{file}-{w}-{sub}.woff2") format("woff2"); unicode-range: {rng}; }}'
|
||||||
|
for fam, file, weights in (("Unbounded", "unbounded", (400, 700)), ("Inter", "inter", (400, 600)),
|
||||||
|
("JetBrains Mono", "jetbrains-mono", (400, 700)))
|
||||||
|
for w in weights
|
||||||
|
for sub, rng in (("latin", "U+0000-00FF, U+2000-206F, U+2190-21FF, U+2200-22FF"), ("cyrillic", "U+0400-04FF"))
|
||||||
|
)
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
:root { --bg:#070a10; --ink:#f1f3f7; --dim:#a9b1be; --gold:#f5c46b; --mint:#5cffb0; --red:#ff5d73; --line:rgba(255,255,255,0.08); --card:rgba(16,21,31,0.9); }
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
html, body { width:1920px; height:1080px; overflow:hidden; background:var(--bg); }
|
||||||
|
body { color:var(--ink); font-family:"Inter", sans-serif; }
|
||||||
|
#root { position:relative; width:100%; height:100%; overflow:hidden; }
|
||||||
|
.clip { position:absolute; inset:0; }
|
||||||
|
.mono { font-family:"JetBrains Mono", monospace; }
|
||||||
|
.gold { color:var(--gold); }
|
||||||
|
#bg { background:radial-gradient(1400px 900px at 50% 45%, #111827 0%, var(--bg) 70%); }
|
||||||
|
#bg-lines { position:absolute; inset:-300px; background-image:repeating-linear-gradient(115deg, rgba(245,196,107,0.045) 0 1px, transparent 1px 64px); mask-image:radial-gradient(ellipse at center, #000 25%, transparent 75%); }
|
||||||
|
#bg-glow { position:absolute; width:1100px; height:1100px; border-radius:50%; left:410px; top:-700px; background:var(--gold); filter:blur(180px); opacity:0.1; }
|
||||||
|
.hud-row { position:absolute; left:64px; right:64px; display:flex; justify-content:space-between; align-items:center; font-size:18px; letter-spacing:0.14em; color:var(--dim); text-transform:uppercase; }
|
||||||
|
#hud-top { top:44px; } #hud-bot { bottom:52px; }
|
||||||
|
#hud-top b { color:var(--gold); font-weight:700; }
|
||||||
|
.chap { display:flex; gap:34px; }
|
||||||
|
.chap span { display:block; color:#858e9c; }
|
||||||
|
#hud-prog { position:absolute; left:64px; right:64px; bottom:36px; height:3px; background:rgba(255,255,255,0.06); }
|
||||||
|
#hud-prog-fill { width:100%; height:3px; background:var(--gold); transform-origin:left center; }
|
||||||
|
.scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:130px 150px; }
|
||||||
|
.in { display:flex; flex-direction:column; align-items:center; width:100%; }
|
||||||
|
.kicker { font-size:22px; letter-spacing:0.32em; color:var(--gold); text-transform:uppercase; margin-bottom:24px; text-align:center; }
|
||||||
|
.h2 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:64px; letter-spacing:-0.02em; line-height:1.1; text-align:center; max-width:1560px; }
|
||||||
|
/* hook */
|
||||||
|
.hook-l1 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:72px; letter-spacing:-0.02em; text-align:center; max-width:1560px; line-height:1.12; }
|
||||||
|
.hook-l1 .strike-w { position:relative; display:inline-block; }
|
||||||
|
.hook-l1 .strike { position:absolute; left:-8px; right:-8px; top:54%; height:10px; border-radius:5px; background:var(--red); transform-origin:left center; }
|
||||||
|
.hook-l2 { margin-top:46px; font-family:"Unbounded", sans-serif; font-weight:700; font-size:72px; letter-spacing:-0.02em; text-align:center; color:var(--gold); max-width:1560px; line-height:1.12; }
|
||||||
|
.brand { margin-top:60px; font-size:26px; letter-spacing:0.3em; color:var(--dim); }
|
||||||
|
.brand b { color:var(--ink); }
|
||||||
|
/* points */
|
||||||
|
.list { margin-top:56px; width:1320px; display:flex; flex-direction:column; gap:22px; }
|
||||||
|
.chk { display:flex; align-items:center; gap:30px; min-height:108px; padding:18px 36px; border-radius:20px; background:var(--card); border:1px solid var(--line); }
|
||||||
|
.box { position:relative; flex:0 0 56px; width:56px; height:56px; border-radius:14px; border:2px solid var(--gold); }
|
||||||
|
.box svg { position:absolute; inset:6px; width:40px; height:40px; }
|
||||||
|
.chk-t { font-size:33px; font-weight:600; }
|
||||||
|
.chk-t small { display:block; font-size:23px; color:var(--dim); font-weight:400; margin-top:6px; }
|
||||||
|
/* flow */
|
||||||
|
.flow { margin-top:70px; display:flex; align-items:stretch; gap:0; }
|
||||||
|
.step { width:360px; padding:30px 30px; border-radius:22px; background:var(--card); border:1px solid rgba(245,196,107,0.3); }
|
||||||
|
.step-n { font-size:20px; color:var(--gold); letter-spacing:0.2em; }
|
||||||
|
.step-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:30px; margin-top:14px; line-height:1.15; }
|
||||||
|
.step-d { font-size:22px; color:var(--dim); margin-top:12px; line-height:1.35; }
|
||||||
|
.arrow { width:70px; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.arrow svg { width:46px; height:46px; display:block; }
|
||||||
|
/* stat */
|
||||||
|
.stat { display:flex; align-items:center; gap:90px; margin-top:40px; }
|
||||||
|
.stat-v { font-family:"Unbounded", sans-serif; font-weight:700; font-size:220px; line-height:1; color:var(--gold); white-space:nowrap; }
|
||||||
|
.stat-c { width:640px; font-size:36px; line-height:1.35; border-left:4px solid var(--gold); padding-left:30px; }
|
||||||
|
/* compare */
|
||||||
|
.cmp { margin-top:60px; display:flex; gap:40px; }
|
||||||
|
.col { width:640px; padding:34px 38px; border-radius:24px; background:var(--card); border:1px solid var(--line); }
|
||||||
|
.col.us { border-color:var(--gold); background:rgba(245,196,107,0.07); }
|
||||||
|
.col-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:32px; margin-bottom:22px; }
|
||||||
|
.col.them .col-t { color:var(--dim); }
|
||||||
|
.col.us .col-t { color:var(--gold); }
|
||||||
|
.row { display:flex; gap:16px; align-items:baseline; font-size:27px; line-height:1.3; margin-top:16px; }
|
||||||
|
.row i { display:block; flex:0 0 14px; width:14px; height:14px; border-radius:4px; }
|
||||||
|
.col.them .row { color:var(--dim); }
|
||||||
|
.col.them .row i { background:#5a6270; }
|
||||||
|
.col.us .row i { background:var(--gold); }
|
||||||
|
/* statement */
|
||||||
|
.big { font-family:"Unbounded", sans-serif; font-weight:700; font-size:82px; letter-spacing:-0.02em; line-height:1.12; text-align:center; max-width:1600px; }
|
||||||
|
.big-sub { margin-top:40px; font-size:32px; color:var(--dim); text-align:center; max-width:1300px; line-height:1.4; }
|
||||||
|
.rule { width:760px; height:2px; background:var(--line); margin:56px 0 44px; }
|
||||||
|
/* outro */
|
||||||
|
.o1 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:80px; letter-spacing:-0.02em; text-align:center; max-width:1600px; }
|
||||||
|
.o2 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:80px; letter-spacing:-0.02em; text-align:center; color:var(--gold); margin-top:10px; max-width:1600px; }
|
||||||
|
.logo { font-family:"Unbounded", sans-serif; font-weight:700; font-size:54px; }
|
||||||
|
.logo span { color:var(--gold); }
|
||||||
|
.cta { margin-top:26px; font-size:32px; color:var(--dim); }
|
||||||
|
.cta b { color:var(--ink); }
|
||||||
|
"""
|
||||||
|
|
||||||
|
TICK_SVG = ('<svg viewBox="0 0 40 40"><path class="tick" d="M8 21 L17 30 L33 11" stroke="#f5c46b" stroke-width="5" '
|
||||||
|
'fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>')
|
||||||
|
ARROW_SVG = ('<svg viewBox="0 0 46 46"><path d="M6 23 H38 M26 11 L38 23 L26 35" stroke="#f5c46b" stroke-width="4" '
|
||||||
|
'fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>')
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s):
|
||||||
|
return html.escape(str(s), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def rich(s):
|
||||||
|
"""*фрагмент* → золотой span; остальное экранируется."""
|
||||||
|
parts = re.split(r"\*([^*]+)\*", str(s))
|
||||||
|
return "".join(f'<span class="gold">{esc(p)}</span>' if i % 2 else esc(p) for i, p in enumerate(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def plain(s):
|
||||||
|
return str(s).replace("*", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cues(n, sc):
|
||||||
|
"""n моментов появления элементов внутри сцены: по началам фраз диктора."""
|
||||||
|
starts = [t for t in sc["phrase_starts"] if t > sc["vo"] + 0.6]
|
||||||
|
if len(starts) >= n:
|
||||||
|
# берём последние n фраз (первая фраза обычно — заголовок)
|
||||||
|
return starts[-n:] if len(starts) > n + 1 else starts[:n]
|
||||||
|
span_a, span_b = sc["vo"] + 1.2, sc["vo"] + max(2.0, sc["vo_dur"] * 0.8)
|
||||||
|
return [span_a + (span_b - span_a) * i / max(1, n - 1) for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def build(spec, timing, series="DAILY"):
|
||||||
|
"""timing: [{start, dur, vo, vo_dur, phrase_starts[]}] по сценам; total — общая длина."""
|
||||||
|
scenes = spec["scenes"]
|
||||||
|
total = timing[-1]["start"] + timing[-1]["dur"]
|
||||||
|
body, js = [], []
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
def pop(sel, at, frm='{ opacity: 0, y: 30 }', d=0.7):
|
||||||
|
js.append(f'tl.fromTo("{sel}", {frm}, {{ opacity: 1, x: 0, y: 0, duration: {d}, ease: "power3.out" }}, {at:.2f});')
|
||||||
|
|
||||||
|
for i, (s, t) in enumerate(zip(scenes, timing)):
|
||||||
|
sid = f"s{i + 1}"
|
||||||
|
a, vo = t["start"], t["vo"]
|
||||||
|
out_at = a + t["dur"] - 0.5
|
||||||
|
typ = s["type"]
|
||||||
|
kick = f'<div class="kicker mono" id="{sid}-k">{esc(s.get("kicker", ""))}</div>' if s.get("kicker") else ""
|
||||||
|
head = f'<div class="h2" id="{sid}-h">{rich(s.get("headline", ""))}</div>' if s.get("headline") else ""
|
||||||
|
if typ not in ("hook", "outro") and s.get("kicker"):
|
||||||
|
chapters.append((plain(s["kicker"]).split("·")[-1].strip()[:14], a, a + t["dur"]))
|
||||||
|
inner = ""
|
||||||
|
if typ == "hook":
|
||||||
|
l1 = esc(s["line1"])
|
||||||
|
w = s.get("strike")
|
||||||
|
if w and w in s["line1"]:
|
||||||
|
l1 = esc(s["line1"]).replace(esc(w), f'<span class="strike-w">{esc(w)}<span class="strike" id="{sid}-strike"></span></span>', 1)
|
||||||
|
inner = (f'<div class="hook-l1" id="{sid}-l1">{l1}</div><div class="hook-l2" id="{sid}-l2">{rich(s["line2"])}</div>'
|
||||||
|
f'<div class="brand mono" id="{sid}-b"><b>SIMBIOT HARNESS</b> · {esc(series)}</div>')
|
||||||
|
c = _cues(2, t)
|
||||||
|
pop(f"#{sid}-l1", a + 0.3)
|
||||||
|
if w and w in s["line1"]:
|
||||||
|
js.append(f'tl.fromTo("#{sid}-strike", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.45, ease: "power2.inOut" }}, {max(a + 1.2, c[0] - 0.6):.2f});')
|
||||||
|
js.append(f'tl.to("#{sid}-l1", {{ opacity: 0.45, duration: 0.5 }}, {max(a + 1.8, c[0] - 0.1):.2f});')
|
||||||
|
pop(f"#{sid}-l2", max(a + 1.6, c[0]))
|
||||||
|
js.append(f'tl.fromTo("#{sid}-b", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {min(out_at - 1.2, c[-1] + 0.4):.2f});')
|
||||||
|
elif typ == "points":
|
||||||
|
items = s["items"]
|
||||||
|
rows = "".join(f'<div class="chk" id="{sid}-i{j}"><div class="box" id="{sid}-b{j}">{TICK_SVG}</div>'
|
||||||
|
f'<div class="chk-t">{esc(it["title"])}<small>{esc(it.get("desc", ""))}</small></div></div>'
|
||||||
|
for j, it in enumerate(items))
|
||||||
|
inner = f'{kick}{head}<div class="list">{rows}</div>'
|
||||||
|
for j, at in enumerate(_cues(len(items), t)):
|
||||||
|
pop(f"#{sid}-i{j}", at, '{ opacity: 0, x: -50 }', 0.5)
|
||||||
|
js.append(f'tl.to("#{sid}-i{j} .tick", {{ strokeDashoffset: 0, duration: 0.35, ease: "power2.out" }}, {at + 0.35:.2f});')
|
||||||
|
js.append(f'tl.to("#{sid}-b{j}", {{ backgroundColor: "rgba(245,196,107,0.15)", duration: 0.3 }}, {at + 0.35:.2f});')
|
||||||
|
elif typ == "flow":
|
||||||
|
parts = []
|
||||||
|
for j, st in enumerate(s["steps"]):
|
||||||
|
if j:
|
||||||
|
parts.append(f'<div class="arrow" id="{sid}-a{j}">{ARROW_SVG}</div>')
|
||||||
|
parts.append(f'<div class="step" id="{sid}-i{j}"><div class="step-n mono">{j + 1:02d}</div>'
|
||||||
|
f'<div class="step-t">{esc(st["title"])}</div><div class="step-d">{esc(st.get("desc", ""))}</div></div>')
|
||||||
|
inner = f'{kick}{head}<div class="flow">{"".join(parts)}</div>'
|
||||||
|
for j, at in enumerate(_cues(len(s["steps"]), t)):
|
||||||
|
if j:
|
||||||
|
pop(f"#{sid}-a{j}", at - 0.15, '{ opacity: 0, x: -20 }', 0.35)
|
||||||
|
pop(f"#{sid}-i{j}", at, '{ opacity: 0, y: 40 }', 0.55)
|
||||||
|
elif typ == "stat":
|
||||||
|
inner = (f'{kick}{head}<div class="stat"><div class="stat-v" id="{sid}-v">{esc(s["value"])}</div>'
|
||||||
|
f'<div class="stat-c" id="{sid}-c">{rich(s.get("caption", ""))}</div></div>')
|
||||||
|
c = _cues(2, t)
|
||||||
|
js.append(f'tl.fromTo("#{sid}-v", {{ opacity: 0, scale: 0.6 }}, {{ opacity: 1, scale: 1, duration: 0.8, ease: "back.out(1.6)" }}, {c[0]:.2f});')
|
||||||
|
pop(f"#{sid}-c", c[1], '{ opacity: 0, x: 40 }', 0.6)
|
||||||
|
elif typ == "compare":
|
||||||
|
def col(cls, title, rows, tag):
|
||||||
|
return (f'<div class="col {cls}" id="{sid}-{tag}"><div class="col-t">{esc(title)}</div>'
|
||||||
|
+ "".join(f'<div class="row"><i></i><span>{esc(r)}</span></div>' for r in rows) + '</div>')
|
||||||
|
inner = (f'{kick}{head}<div class="cmp">{col("them", s["left_title"], s["left"], "l")}'
|
||||||
|
f'{col("us", s["right_title"], s["right"], "r")}</div>')
|
||||||
|
c = _cues(2, t)
|
||||||
|
pop(f"#{sid}-l", c[0], '{ opacity: 0, x: -50 }', 0.6)
|
||||||
|
pop(f"#{sid}-r", c[1], '{ opacity: 0, x: 50 }', 0.6)
|
||||||
|
js.append(f'tl.fromTo("#{sid}-r", {{ boxShadow: "0 0 0px rgba(245,196,107,0)" }}, {{ boxShadow: "0 0 60px rgba(245,196,107,0.35)", duration: 0.6, yoyo: true, repeat: 3, ease: "sine.inOut" }}, {c[1] + 0.6:.2f});')
|
||||||
|
elif typ == "statement":
|
||||||
|
inner = (f'{kick}<div class="big" id="{sid}-t">{rich(s["text"])}</div><div class="rule" id="{sid}-r"></div>'
|
||||||
|
f'<div class="big-sub" id="{sid}-s">{rich(s.get("sub", ""))}</div>')
|
||||||
|
c = _cues(2, t)
|
||||||
|
pop(f"#{sid}-t", a + 0.3, '{ opacity: 0, y: 40 }', 0.8)
|
||||||
|
js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.8, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});')
|
||||||
|
pop(f"#{sid}-s", c[1])
|
||||||
|
elif typ == "outro":
|
||||||
|
inner = (f'<div class="o1" id="{sid}-o1">{esc(s["line1"])}</div><div class="o2" id="{sid}-o2">{rich(s["line2"])}</div>'
|
||||||
|
f'<div class="rule" id="{sid}-r"></div><div class="logo" id="{sid}-logo">SIMBIOT <span>HARNESS</span></div>'
|
||||||
|
f'<div class="cta" id="{sid}-cta">{rich(s.get("cta", "Let’s talk about a pilot · *cil.by*")).replace("<span class=\"gold\">", "<b>").replace("</span>", "</b>")}</div>')
|
||||||
|
c = _cues(3, t)
|
||||||
|
pop(f"#{sid}-o1", a + 0.3)
|
||||||
|
pop(f"#{sid}-o2", c[0])
|
||||||
|
js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.8, ease: "power3.inOut" }}, {c[1] - 0.4:.2f});')
|
||||||
|
js.append(f'tl.fromTo("#{sid}-logo", {{ opacity: 0, scale: 0.94 }}, {{ opacity: 1, scale: 1, duration: 0.7, ease: "power3.out" }}, {c[1]:.2f});')
|
||||||
|
js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.7 }}, {c[2]:.2f});')
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown scene type {typ}")
|
||||||
|
|
||||||
|
if typ not in ("hook", "outro", "statement"):
|
||||||
|
if s.get("kicker"):
|
||||||
|
pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5)
|
||||||
|
if s.get("headline"):
|
||||||
|
pop(f"#{sid}-h", a + 0.2)
|
||||||
|
elif typ == "statement" and s.get("kicker"):
|
||||||
|
pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5)
|
||||||
|
if i < len(scenes) - 1:
|
||||||
|
js.append(f'tl.to("#{sid}-in", {{ opacity: 0, duration: 0.45, ease: "power2.in" }}, {out_at:.2f});')
|
||||||
|
body.append(f'<section id="{sid}" class="clip scene" data-start="{a:.2f}" data-duration="{t["dur"]:.2f}" data-track-index="1">'
|
||||||
|
f'<div class="in" id="{sid}-in">{inner}</div></section>')
|
||||||
|
|
||||||
|
audio = [f'<audio id="bgm" src="assets/bgm/bed.wav" data-start="0" data-duration="{total:.2f}" data-track-index="3" data-volume="0.16"></audio>']
|
||||||
|
for i, t in enumerate(timing):
|
||||||
|
audio.append(f'<audio id="vo{i + 1}" src="assets/vo/v{i + 1}.wav" data-start="{t["vo"]:.2f}" data-duration="{t["vo_dur"]:.2f}" data-track-index="4" data-volume="1"></audio>')
|
||||||
|
|
||||||
|
chap_html = "".join(f'<span id="c{j}">{esc(c[0])}</span>' for j, c in enumerate(chapters))
|
||||||
|
hud_from = timing[1]["start"] if len(timing) > 1 else 0
|
||||||
|
hud_js = [
|
||||||
|
f'tl.fromTo("#hud-top, #hud-bot, #hud-prog", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.8 }}, {hud_from:.2f});',
|
||||||
|
f'tl.fromTo("#hud-prog-fill", {{ scaleX: 0 }}, {{ scaleX: 1, duration: {total - hud_from:.2f}, ease: "none" }}, {hud_from:.2f});',
|
||||||
|
]
|
||||||
|
for j, (_, ca, cb) in enumerate(chapters):
|
||||||
|
hud_js.append(f'tl.to("#c{j}", {{ color: "#f5c46b", duration: 0.3 }}, {ca:.2f});')
|
||||||
|
hud_js.append(f'tl.to("#c{j}", {{ color: "#858e9c", duration: 0.3 }}, {cb:.2f});')
|
||||||
|
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=1920, height=1080" />
|
||||||
|
<title>{esc(plain(spec.get("title", "Simbiot Harness")))}</title>
|
||||||
|
<script src="./gsap.min.js"></script>
|
||||||
|
<style>
|
||||||
|
{FONTS_CSS}
|
||||||
|
{CSS}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root" data-composition-id="main" data-start="0" data-duration="{total:.2f}" data-width="1920" data-height="1080">
|
||||||
|
<div id="bg" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="0"><div id="bg-lines"></div><div id="bg-glow"></div></div>
|
||||||
|
{"".join(body)}
|
||||||
|
{"".join(audio)}
|
||||||
|
<div id="hud" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="2">
|
||||||
|
<div class="hud-row mono" id="hud-top"><div><b>SIMBIOT</b> · {esc(series)}</div><div>{esc(spec.get("date_label", ""))}</div></div>
|
||||||
|
<div class="hud-row mono" id="hud-bot"><div class="chap">{chap_html}</div><div>cil.by</div></div>
|
||||||
|
<div id="hud-prog"><div id="hud-prog-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const tl = gsap.timeline({{ paused: true }});
|
||||||
|
tl.fromTo("#bg-lines", {{ x: 0 }}, {{ x: 256, duration: {total:.2f}, ease: "none" }}, 0);
|
||||||
|
tl.fromTo("#bg-glow", {{ x: 0, y: 0 }}, {{ x: -380, y: 180, duration: {total:.2f}, ease: "sine.inOut" }}, 0);
|
||||||
|
document.querySelectorAll(".tick").forEach((p) => {{ const L = p.getTotalLength(); p.style.strokeDasharray = L; p.style.strokeDashoffset = L; }});
|
||||||
|
{chr(10).join(" " + l for l in hud_js + js).strip()}
|
||||||
|
window.__timelines = window.__timelines || {{}};
|
||||||
|
window.__timelines["main"] = tl;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
spec = json.load(open(sys.argv[1]))
|
||||||
|
timing = json.load(open(sys.argv[2]))
|
||||||
|
print(build(spec, timing))
|
||||||
Loading…
Reference in a new issue