simbiot-harness/video/daily/template.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

291 lines
18 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.

"""Сценарий (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))