ежедневный ролик: вертикаль 1080×1920, 5 стилей по кругу, субтитры, приватность

- вертикальный формат (Shorts/Reels): YouTube через Buffer принимает только вертикаль;
  безопасные зоны под интерфейс приложений, сегментный прогресс-бар как у сторис;
- 5 тем (gold, terminal, paper, violet, poster): палитра, шрифты, фон-мотив,
  манера появления/ухода и своя музыка (MusicGen); тема дня — наименее давно использованная;
- новые сцены chat и terminal (команды сверяются с источниками), ≥4 разных типа,
  не повторять структуру вчерашнего ролика;
- озвучка по предложениям → точные субтитры и появление элементов на фразах;
- приватность: без цитат реальных переписок и имён людей (проверка в валидаторе).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-18 08:46:10 +00:00
parent 1745e6c1c8
commit a08cd482f5
20 changed files with 453 additions and 245 deletions

View file

@ -37,8 +37,10 @@ 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")
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"
@ -89,27 +91,30 @@ def sources():
SCHEMA_HELP = """
Return ONLY a JSON object:
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' (≤22 chars)",
"series": "short uppercase tag for the HUD, e.g. 'DAILY · SECURITY' (≤20 chars)",
"title": "video title (≤80 chars)",
"youtube_title": "≤90 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":"≤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).
{"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
@ -121,6 +126,10 @@ Hard rules:
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".
"""
@ -155,8 +164,17 @@ def parse_json(txt):
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}
# люди из 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):
@ -172,8 +190,10 @@ def validate(spec, src_text, past):
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)")
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:
@ -181,27 +201,43 @@ def validate(spec, src_text, past):
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)
"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, 30, 75), ("steps", 3, 4, 20, 55)):
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", [])) <= 4):
errs.append(f"scene {i + 1}: compare needs 2–4 rows on each side, equal counts")
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) > 40:
if len(row) > 32:
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 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:
@ -217,14 +253,18 @@ def validate(spec, src_text, past):
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):
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):
@ -244,54 +284,91 @@ def write_spec(src, past, date):
# ───────────────────────── озвучка и сборка ─────────────────────────
def tts(text, out_wav, workdir):
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,"
"loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav])
"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())
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]
return dur, sents
def bgm_bed(total, out_wav):
n = math.ceil(total / 26) + 1
ins = sum((["-i", BGM_SRC] for _ in range(n)), [])
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.8,afade=t=out:st={total - 4:.2f}:d=4,loudnorm=I=-16:TP=-1.5")
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 build_project(spec, work, date):
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(os.path.join(ASSETS, "fonts"), os.path.join(proj, "fonts"))
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, starts = tts(s["narration"], wav, work)
lead, tail = 0.5, (2.6 if i == len(spec["scenes"]) - 1 else 0.8)
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), "phrase_starts": [round(vo + x, 2) for x in starts]})
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")
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"))
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, spec.get("series", "DAILY")))
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
@ -343,7 +420,7 @@ def buffer_post(service, text, url, spec):
"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))}}
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 } } }",
@ -417,6 +494,7 @@ def main():
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)
@ -437,11 +515,15 @@ def main():
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)
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"])
proj, total = build_project(spec, work, a.date)
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]))

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
video/daily/music/gold.wav Normal file

Binary file not shown.

BIN
video/daily/music/paper.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,107 +1,185 @@
"""Сценарий (spec) + тайминги озвучки → index.html композиции hyperframes.
"""Сценарий (spec) + тайминги озвучки → вертикальная (1080×1920) композиция hyperframes.
Визуальный язык — как у ролика «Simbiot Harness for enterprise» (тёмный фон,
золотой акцент, Unbounded/Inter/JetBrains Mono, HUD с главами и прогрессом).
Темы (THEMES) чередуются по дням: своя палитра, шрифты, фон-мотив, манера появления
и ухода элементов, стиль субтитров и музыка. Безопасные зоны Shorts/Reels: контент
y 230–1280, субтитры y 1340–1600, правые 120 px и низ под интерфейс приложений не заняты текстом.
Типы сцен (spec["scenes"][i]["type"]):
hook line1, strike (слово из line1, зачёркивается), line2
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)
compare kicker, headline, left_title, left[], right_title, right[] (2–3)
statement kicker, text, sub
chat kicker, headline, messages[{from: user|agent|system, text}] (2–4)
terminal kicker, headline, lines[] (2–5; команды начинаются с "$ ")
outro line1, line2, cta
В headline/text/line2 можно выделить фрагмент золотом: *так*.
В headline/text/line2/caption/sub можно выделить фрагмент акцентом: *так*.
"""
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"))
)
W, H = 1080, 1920
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); }
FONT_FILES = { # семейство → [(файл, вес)]
"Unbounded": [("unbounded", 400), ("unbounded", 700)],
"Inter": [("inter", 400), ("inter", 600)],
"JetBrains Mono": [("jetbrains-mono", 400), ("jetbrains-mono", 700)],
"Fraunces": [("fraunces", 400), ("fraunces", 700)],
"Space Grotesk": [("space-grotesk", 500), ("space-grotesk", 700)],
"Anton": [("anton", 400)],
"DM Sans": [("dm-sans", 400), ("dm-sans", 700)],
}
THEMES = {
"gold": dict(
vars=dict(bg="#070a10", ink="#f1f3f7", dim="#a9b1be", accent="#f5c46b", card="rgba(16,21,31,0.92)",
cink="#f1f3f7", cdim="#a9b1be", line="rgba(255,255,255,0.1)", strike="#ff5d73", us="rgba(245,196,107,0.08)"),
display=("Unbounded", 700), body="Inter", mono="JetBrains Mono", upper=False, enter="rise", exit="fade",
bg_css="background:radial-gradient(1200px 1400px at 50% 30%, #121a2b 0%, #070a10 70%);",
motif='<div class="m-lines"></div><div class="m-glow"></div>',
motif_css=""".m-lines { position:absolute; inset:-400px; background-image:repeating-linear-gradient(115deg, rgba(245,196,107,0.05) 0 1px, transparent 1px 64px); }
.m-glow { position:absolute; width:1000px; height:1000px; border-radius:50%; left:40px; top:-560px; background:#f5c46b; filter:blur(170px); opacity:0.12; }""",
motif_js='tl.fromTo(".m-lines", { x: 0 }, { x: 256, duration: T, ease: "none" }, 0); tl.fromTo(".m-glow", { x: 0, y: 0 }, { x: -200, y: 700, duration: T, ease: "sine.inOut" }, 0);',
cap_css="background:rgba(10,14,22,0.88); border-left:6px solid var(--accent); padding:6px 22px; border-radius:10px;",
),
"terminal": dict(
vars=dict(bg="#020604", ink="#d8ffe9", dim="#86b49b", accent="#5cffb0", card="rgba(6,20,13,0.94)",
cink="#d8ffe9", cdim="#86b49b", line="rgba(92,255,176,0.18)", strike="#ff5d73", us="rgba(92,255,176,0.08)"),
display=("JetBrains Mono", 700), body="DM Sans", mono="JetBrains Mono", upper=True, enter="wipe", exit="glitch",
bg_css="background:#020604;",
motif='<div class="m-grid"></div><div class="m-scan"></div>',
motif_css=""".m-grid { position:absolute; inset:-120px; background-image:linear-gradient(rgba(92,255,176,0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(92,255,176,0.07) 1px, transparent 1px); background-size:60px 60px; }
.m-scan { position:absolute; left:0; right:0; top:0; height:220px; background:linear-gradient(180deg, transparent, rgba(92,255,176,0.08), transparent); }""",
motif_js='tl.fromTo(".m-grid", { y: 0 }, { y: 120, duration: T, ease: "none" }, 0); tl.fromTo(".m-scan", { y: -220 }, { y: 1920, duration: 4, ease: "none", repeat: Math.max(0, Math.floor(T / 4) - 1) }, 0);',
cap_css="color:var(--accent); font-family:'JetBrains Mono', monospace; padding:0 6px; text-shadow:0 0 18px rgba(92,255,176,0.35);",
cap_prefix="> ",
),
"paper": dict(
vars=dict(bg="#f2ede3", ink="#16130f", dim="#5a5248", accent="#d23a28", card="#fffaf1",
cink="#16130f", cdim="#5a5248", line="rgba(22,19,15,0.16)", strike="#d23a28", us="#fff1d6"),
display=("Fraunces", 700), body="DM Sans", mono="JetBrains Mono", upper=False, enter="slide", exit="slideout",
bg_css="background:#f2ede3;", card_border="2px solid #16130f",
motif='<div class="m-num">42</div><div class="m-rule r1"></div><div class="m-rule r2"></div>',
motif_css=""".m-num { position:absolute; right:-140px; bottom:120px; font-family:'Fraunces', serif; font-weight:700; font-size:900px; line-height:1; color:transparent; -webkit-text-stroke:3px rgba(22,19,15,0.08); }
.m-rule { position:absolute; left:60px; right:60px; height:2px; background:rgba(22,19,15,0.12); }
.m-rule.r1 { top:180px; } .m-rule.r2 { top:1640px; }""",
motif_js='tl.fromTo(".m-num", { rotation: -6, y: 0 }, { rotation: 6, y: -160, duration: T, ease: "sine.inOut" }, 0);',
cap_css="background:#16130f; color:#f2ede3; padding:6px 22px;",
),
"violet": dict(
vars=dict(bg="#0b0420", ink="#f5f0ff", dim="#c2b6e3", accent="#b69cff", card="rgba(255,255,255,0.08)",
cink="#f5f0ff", cdim="#c2b6e3", line="rgba(255,255,255,0.18)", strike="#ff7ad9", us="rgba(182,156,255,0.14)"),
display=("Space Grotesk", 700), body="Inter", mono="JetBrains Mono", upper=False, enter="pop", exit="scale",
bg_css="background:radial-gradient(1300px 1700px at 50% 20%, #23104f 0%, #0b0420 70%);", card_extra="backdrop-filter:blur(18px);",
motif='<div class="m-blob b1"></div><div class="m-blob b2"></div><div class="m-blob b3"></div>',
motif_css=""".m-blob { position:absolute; width:620px; height:620px; border-radius:50%; filter:blur(120px); opacity:0.42; }
.b1 { background:#7c4dff; left:-160px; top:120px; } .b2 { background:#ff4fb8; right:-200px; top:820px; opacity:0.3; } .b3 { background:#3fa9ff; left:200px; bottom:-200px; opacity:0.28; }""",
motif_js='tl.fromTo(".b1", { x: 0, y: 0 }, { x: 420, y: 520, duration: T, ease: "sine.inOut" }, 0); tl.fromTo(".b2", { x: 0, y: 0 }, { x: -380, y: -300, duration: T, ease: "sine.inOut" }, 0); tl.fromTo(".b3", { x: 0, y: 0 }, { x: 220, y: -640, duration: T, ease: "sine.inOut" }, 0);',
cap_css="background:rgba(255,255,255,0.12); border:1px solid rgba(255,255,255,0.22); padding:6px 22px; border-radius:16px;",
),
"poster": dict(
vars=dict(bg="#ff5a1f", ink="#120d0a", dim="#3a1a0c", accent="#ffffff", card="#120d0a",
cink="#ffffff", cdim="#ffc2a8", line="rgba(18,13,10,0.3)", strike="#120d0a", us="#120d0a", uink="#120d0a"),
display=("Anton", 400), body="DM Sans", mono="JetBrains Mono", upper=True, enter="slam", exit="cut",
bg_css="background:#ff5a1f;",
motif='<div class="m-stripes"></div>',
motif_css=""".m-stripes { position:absolute; inset:-500px; background-image:repeating-linear-gradient(-30deg, rgba(18,13,10,0.07) 0 70px, transparent 70px 180px); }""",
motif_js='tl.fromTo(".m-stripes", { x: 0 }, { x: 360, duration: T, ease: "none" }, 0);',
cap_css="background:#120d0a; color:#ffffff; padding:6px 22px; text-transform:uppercase; letter-spacing:0.02em;",
),
}
ENTER = { # (from, to-extra, duration, ease)
"rise": ('{ opacity: 0, y: 46 }', 'y: 0', 0.7, "power3.out"),
"slide": ('{ opacity: 0, x: -90 }', 'x: 0', 0.6, "power3.out"),
"pop": ('{ opacity: 0, scale: 0.84 }', 'scale: 1', 0.6, "back.out(1.7)"),
"wipe": ('{ opacity: 0, clipPath: "inset(0% 100% 0% 0%)" }', 'clipPath: "inset(0% 0% 0% 0%)"', 0.6, "power2.inOut"),
"slam": ('{ opacity: 0, scale: 1.28 }', 'scale: 1', 0.35, "power4.out"),
}
EXIT = {
"fade": '{ opacity: 0, duration: 0.45, ease: "power2.in" }',
"slideout": '{ opacity: 0, x: -140, duration: 0.45, ease: "power2.in" }',
"scale": '{ opacity: 0, scale: 0.92, duration: 0.45, ease: "power2.in" }',
"glitch": '{ opacity: 0, x: 18, duration: 0.3, ease: "steps(3)" }',
"cut": '{ opacity: 0, duration: 0.12, ease: "none" }',
}
BASE_CSS = """
* { 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; }
html, body { width:1080px; height:1920px; overflow:hidden; background:var(--bg); }
body { color:var(--ink); font-family:var(--body), 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); }
.mono { font-family:var(--mono), monospace; }
.acc { color:var(--accent); }
.disp { font-family:var(--display), sans-serif; font-weight:var(--dw); text-transform:var(--up); }
#hud-segs { position:absolute; left:60px; right:60px; top:72px; display:flex; gap:10px; }
.seg { flex:1; height:6px; border-radius:3px; background:var(--line); overflow:hidden; }
.seg i { display:block; width:100%; height:6px; background:var(--accent); transform-origin:left center; }
#hud-row { position:absolute; left:60px; right:60px; top:100px; display:flex; justify-content:space-between; font-size:22px; letter-spacing:0.16em; color:var(--dim); text-transform:uppercase; }
#hud-row b { color:var(--accent); }
.scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:230px 80px 640px; }
.in { display:flex; flex-direction:column; align-items:flex-start; width:920px; }
.in.center { align-items:center; text-align:center; }
.kicker { font-size:30px; letter-spacing:0.28em; color:var(--accent); text-transform:uppercase; margin-bottom:26px; }
.h2 { font-size:92px; letter-spacing:-0.015em; line-height:1.06; }
.card { background:var(--card); border:var(--cborder); color:var(--cink); border-radius:var(--radius); }
.card small, .card .d { color:var(--cdim); }
/* hook / outro / statement */
.l1 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; }
.l1 .sw { position:relative; display:inline-block; }
.l1 .strike { position:absolute; left:-6px; right:-6px; top:52%; height:12px; border-radius:6px; background:var(--strike); transform-origin:left center; }
.l2 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; color:var(--accent); margin-top:44px; }
.brand { margin-top:70px; font-size:30px; 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; }
.big { font-size:118px; line-height:1.05; letter-spacing:-0.02em; }
.sub { margin-top:40px; font-size:44px; line-height:1.35; color:var(--dim); }
.rule { width:100%; height:3px; background:var(--line); margin:54px 0 44px; transform-origin:left center; }
.logo { font-size:70px; } .logo span { color:var(--accent); }
.cta { margin-top:24px; font-size:42px; color:var(--dim); } .cta b { color:var(--ink); }
/* list-like */
.list { margin-top:60px; width:100%; display:flex; flex-direction:column; gap:26px; }
.it { display:flex; align-items:center; gap:32px; padding:36px 38px; }
.num { flex:0 0 76px; height:76px; border-radius:18px; display:flex; align-items:center; justify-content:center; font-size:36px; font-weight:700; background:var(--accent); color:var(--uink, var(--bg)); }
.it-t { font-size:50px; font-weight:700; line-height:1.15; }
.it-t small { display:block; font-size:34px; font-weight:400; margin-top:8px; line-height:1.3; }
.down { width:100%; height:44px; display:flex; justify-content:center; }
.down svg { width:44px; height:44px; 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; }
.stat-v { line-height:0.95; color:var(--accent); margin-top:40px; letter-spacing:-0.03em; white-space:nowrap; }
.stat-c { margin-top:44px; font-size:52px; line-height:1.3; border-left:6px solid var(--accent); 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); }
.cmp { margin-top:46px; width:100%; display:flex; flex-direction:column; gap:26px; }
.col { padding:38px 40px; }
.col.us { background:var(--us); border:3px solid var(--accent); }
.col-t { font-size:44px; font-weight:700; margin-bottom:8px; }
.col.them .col-t { color:var(--cdim); }
.col.us .col-t { color:var(--accent); }
.row { display:flex; gap:20px; align-items:baseline; font-size:40px; line-height:1.3; margin-top:18px; }
.row i { display:block; flex:0 0 16px; width:16px; height:16px; border-radius:5px; background:var(--cdim); }
.col.us .row i { background:var(--accent); }
.col.them .row { color:var(--cdim); }
/* chat */
.chat { margin-top:56px; width:100%; display:flex; flex-direction:column; gap:28px; }
.msg { max-width:840px; padding:30px 36px; font-size:42px; line-height:1.35; border-radius:30px; }
.msg.user { align-self:flex-end; background:var(--accent); color:var(--uink, var(--bg)); border-bottom-right-radius:8px; }
.msg.agent { align-self:flex-start; border-bottom-left-radius:8px; }
.msg.system { align-self:center; font-size:30px; color:var(--dim); background:transparent; border:2px dashed var(--line); border-radius:14px; }
.who { display:block; font-size:24px; letter-spacing:0.14em; text-transform:uppercase; margin-bottom:8px; opacity:0.75; }
/* terminal */
.term { margin-top:46px; width:100%; overflow:hidden; border-radius:22px; background:#05080c; border:2px solid var(--line); }
.term-bar { height:54px; display:flex; align-items:center; gap:12px; padding:0 24px; background:rgba(255,255,255,0.05); }
.term-bar i { display:block; width:16px; height:16px; border-radius:50%; }
.term-body { padding:38px 36px; display:flex; flex-direction:column; gap:22px; }
.tl { font-size:36px; line-height:1.35; color:#9fb0a8; white-space:pre-wrap; word-break:break-word; }
.tl.cmd { color:#e8fff3; } .tl.cmd b { color:#5cffb0; }
/* captions */
#caps .cap { position:absolute; left:80px; top:1360px; width:900px; font-size:50px; font-weight:700; line-height:1.62; opacity:0; }
"""
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" '
DOWN_SVG = ('<svg viewBox="0 0 34 34"><path d="M17 4 V28 M7 19 L17 29 L27 19" stroke="currentColor" stroke-width="4" '
'fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>')
@ -110,182 +188,230 @@ def esc(s):
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))
return "".join(f'<span class="acc">{esc(p)}</span>' if i % 2 else esc(p) for i, p in enumerate(parts))
def plain(s):
return str(s).replace("*", "")
def font_css():
out = []
for fam, files in FONT_FILES.items():
for f, w in files:
out.append(f'@font-face {{ font-family: "{fam}"; font-weight: {w}; src: url("fonts/{f}-{w}-latin.woff2") format("woff2"); }}')
return "\n".join(out)
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)]
"""n моментов появления элементов: начала предложений диктора (первое — обычно заголовок)."""
starts = [s["t0"] for s in sc["sents"]]
if len(starts) > n:
return starts[1:n + 1] if len(starts) - 1 >= n else starts[-n:]
if len(starts) == n:
return [max(sc["start"] + 0.9, x) for x in starts]
a, b = sc["vo"] + 0.9, sc["vo"] + max(1.6, sc["vo_dur"] * 0.75)
return [a + (b - 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"]
def captions(timing, prefix=""):
"""Фразы диктора → блоки субтитров (длинные делятся пополам по времени ∝ символам)."""
caps = []
for sc in timing:
for s in sc["sents"]:
text = re.sub(r"\bc-?i-?l dot b-?y\b", "cil.by", s["text"].strip(), flags=re.I)
if len(text) <= 95:
caps.append((s["t0"], s["t1"], text))
continue
words = text.split()
half = len(text) / 2
acc, cut = 0, len(words) // 2
for k, w in enumerate(words):
acc += len(w) + 1
if acc >= half:
cut = k + 1
break
a, b = " ".join(words[:cut]), " ".join(words[cut:])
tm = s["t0"] + (s["t1"] - s["t0"]) * len(a) / max(1, len(text))
caps += [(s["t0"], tm, a), (tm, s["t1"], b)]
return [(t0, t1, prefix + txt) for t0, t1, txt in caps]
def build(spec, timing, theme_name="gold", series="DAILY"):
th = THEMES[theme_name]
v = th["vars"]
total = timing[-1]["start"] + timing[-1]["dur"]
enter_from, enter_to, enter_d, enter_ease = ENTER[th["enter"]]
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});')
def E(sel, at, kind=None, d=None):
f, to, dd, ease = ENTER[kind] if kind else (enter_from, enter_to, enter_d, enter_ease)
js.append(f'tl.fromTo("{sel}", {f}, {{ opacity: 1, {to}, duration: {d or dd}, ease: "{ease}" }}, {at:.2f});')
for i, (s, t) in enumerate(zip(scenes, timing)):
sid = f"s{i + 1}"
a, vo = t["start"], t["vo"]
for i, (s, t) in enumerate(zip(spec["scenes"], timing)):
sid, a = f"s{i + 1}", t["start"]
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 = ""
kick = f'<div class="kicker mono" id="{sid}-k">{esc(s["kicker"])}</div>' if s.get("kicker") else ""
head = f'<div class="h2 disp" id="{sid}-h">{rich(s["headline"])}</div>' if s.get("headline") else ""
center = typ in ("hook", "outro", "statement")
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>'
has_strike = bool(w and w in s["line1"])
if has_strike:
l1 = l1.replace(esc(w), f'<span class="sw">{esc(w)}<span class="strike" id="{sid}-st"></span></span>', 1)
inner = (f'<div class="l1 disp" id="{sid}-l1">{l1}</div><div class="l2 disp" 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":
E(f"#{sid}-l1", a + 0.25)
if has_strike:
js.append(f'tl.fromTo("#{sid}-st", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.4, ease: "power2.inOut" }}, {max(a + 1.0, c[0] - 0.55):.2f});')
js.append(f'tl.to("#{sid}-l1", {{ opacity: 0.45, duration: 0.4 }}, {max(a + 1.4, c[0] - 0.1):.2f});')
E(f"#{sid}-l2", max(a + 1.3, c[0]))
js.append(f'tl.fromTo("#{sid}-b", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {min(out_at - 1.0, c[-1] + 0.5):.2f});')
elif typ in ("points", "flow"):
key = "items" if typ == "points" else "steps"
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)
for j, it in enumerate(s[key]):
if typ == "flow" and j:
parts.append(f'<div class="down acc" id="{sid}-a{j}">{DOWN_SVG}</div>')
parts.append(f'<div class="it card" id="{sid}-i{j}"><div class="num mono">{j + 1}</div>'
f'<div class="it-t">{esc(it["title"])}<small>{esc(it.get("desc", ""))}</small></div></div>')
gap = ' style="gap:14px"' if typ == "flow" else ""
inner = f'{kick}{head}<div class="list"{gap}>{"".join(parts)}</div>'
for j, at in enumerate(_cues(len(s[key]), t)):
if typ == "flow" and j:
js.append(f'tl.fromTo("#{sid}-a{j}", {{ opacity: 0, y: -14 }}, {{ opacity: 1, y: 0, duration: 0.3 }}, {at - 0.15:.2f});')
E(f"#{sid}-i{j}", at)
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>')
val = str(s["value"])
size = int(min(380, 900 / max(1, 0.66 * len(val))))
inner = (f'{kick}{head}<div class="stat-v disp" id="{sid}-v" style="font-size:{size}px">{esc(val)}</div>'
f'<div class="stat-c" id="{sid}-c">{rich(s.get("caption", ""))}</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)
js.append(f'tl.fromTo("#{sid}-v", {{ opacity: 0, scale: 0.6 }}, {{ opacity: 1, scale: 1, duration: 0.7, ease: "back.out(1.6)" }}, {c[0]:.2f});')
E(f"#{sid}-c", c[1])
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>')
return (f'<div class="col card {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")}{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});')
E(f"#{sid}-l", c[0])
E(f"#{sid}-r", c[1])
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>')
inner = (f'{kick}<div class="big disp" id="{sid}-t">{rich(s["text"])}</div><div class="rule" id="{sid}-r"></div>'
f'<div class="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])
E(f"#{sid}-t", a + 0.3)
js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});')
E(f"#{sid}-s", c[1])
elif typ == "chat":
msgs = "".join(
f'<div class="msg {m["from"]}{"" if m["from"] == "user" else " card" if m["from"] == "agent" else ""}" id="{sid}-m{j}">'
+ (f'<span class="who mono">{"Simbiot" if m["from"] == "agent" else "Client"}</span>' if m["from"] != "system" else "")
+ f'{esc(m["text"])}</div>' for j, m in enumerate(s["messages"]))
inner = f'{kick}{head}<div class="chat">{msgs}</div>'
for j, at in enumerate(_cues(len(s["messages"]), t)):
E(f"#{sid}-m{j}", at, "pop" if th["enter"] != "slam" else "slam")
elif typ == "terminal":
rows = []
for j, ln in enumerate(s["lines"]):
if ln.startswith("$ "):
rows.append(f'<div class="tl cmd mono" id="{sid}-t{j}"><b>$</b> {esc(ln[2:])}</div>')
else:
rows.append(f'<div class="tl mono" id="{sid}-t{j}">{esc(ln)}</div>')
inner = (f'{kick}{head}<div class="term"><div class="term-bar"><i style="background:#ff5f57"></i>'
f'<i style="background:#febc2e"></i><i style="background:#28c840"></i></div>'
f'<div class="term-body">{"".join(rows)}</div></div>')
for j, at in enumerate(_cues(len(s["lines"]), t)):
E(f"#{sid}-t{j}", at, "wipe")
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>')
cta = rich(s.get("cta", "Let’s talk about a pilot · *cil.by*")).replace('<span class="acc">', "<b>").replace("</span>", "</b>")
inner = (f'<div class="l1 disp" id="{sid}-o1">{esc(s["line1"])}</div><div class="l2 disp" id="{sid}-o2">{rich(s["line2"])}</div>'
f'<div class="rule" id="{sid}-r"></div><div class="logo disp" id="{sid}-logo">SIMBIOT <span>HARNESS</span></div>'
f'<div class="cta" id="{sid}-cta">{cta}</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});')
E(f"#{sid}-o1", a + 0.25)
E(f"#{sid}-o2", c[0])
js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, ease: "power3.inOut" }}, {c[1] - 0.4:.2f});')
E(f"#{sid}-logo", c[1], "pop")
js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {c[2]:.2f});')
else:
raise ValueError(f"unknown scene type {typ}")
if typ not in ("hook", "outro", "statement"):
if typ not in ("hook", "outro"):
if s.get("kicker"):
pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5)
js.append(f'tl.fromTo("#{sid}-k", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.4 }}, {a + 0.1:.2f});')
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});')
E(f"#{sid}-h", a + 0.2)
if i < len(spec["scenes"]) - 1:
js.append(f'tl.to("#{sid}-in", {EXIT[th["exit"]]}, {out_at:.2f});')
cls = "in center" if center else "in"
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>')
f'<div class="{cls}" id="{sid}-in">{inner}</div></section>')
# субтитры
caps = captions(timing, th.get("cap_prefix", ""))
cap_html = "".join(f'<div class="cap" id="cap{k}"><span class="cap-in">{esc(txt)}</span></div>' for k, (_, _, txt) in enumerate(caps))
for k, (t0, t1, _) in enumerate(caps):
js.append(f'tl.fromTo("#cap{k}", {{ opacity: 0, y: 16 }}, {{ opacity: 1, y: 0, duration: 0.18, ease: "power2.out" }}, {t0:.2f});')
js.append(f'tl.to("#cap{k}", {{ opacity: 0, duration: 0.12 }}, {max(t0 + 0.3, t1 - 0.05):.2f});')
# HUD: сегменты по сценам
segs = "".join(f'<div class="seg"><i id="seg{i}"></i></div>' for i in range(len(timing)))
for i, t in enumerate(timing):
js.append(f'tl.fromTo("#seg{i}", {{ scaleX: 0 }}, {{ scaleX: 1, duration: {t["dur"]:.2f}, ease: "none" }}, {t["start"]:.2f});')
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});')
disp_fam, disp_w = th["display"]
root_vars = "".join(f"--{k}:{val};" for k, val in v.items())
root_vars += (f'--display:"{disp_fam}";--dw:{disp_w};--body:"{th["body"]}";--mono:"{th["mono"]}";'
f'--up:{"uppercase" if th["upper"] else "none"};--cborder:{th.get("card_border", "1px solid " + v["line"])};'
f'--radius:{"0px" if theme_name in ("paper", "poster") else "24px"};')
extra_css = f""":root {{ {root_vars} }}
#bg {{ {th["bg_css"]} }}
.card {{ {th.get("card_extra", "")} }}
#caps .cap .cap-in {{ display:inline; box-decoration-break:clone; -webkit-box-decoration-break:clone; {th["cap_css"]} }}
{th["motif_css"]}"""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<meta name="viewport" content="width={W}, height={H}" />
<title>{esc(plain(spec.get("title", "Simbiot Harness")))}</title>
<script src="./gsap.min.js"></script>
<style>
{FONTS_CSS}
{CSS}
{font_css()}
{BASE_CSS}
{extra_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>
<div id="root" data-composition-id="main" data-start="0" data-duration="{total:.2f}" data-width="{W}" data-height="{H}">
<div id="bg" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="0">{th["motif"]}</div>
{"".join(body)}
<div id="caps" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="5">{cap_html}</div>
{"".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 id="hud-segs">{segs}</div>
<div id="hud-row" class="mono"><div><b>SIMBIOT</b> · {esc(series)}</div><div>{esc(spec.get("date_label", ""))}</div></div>
</div>
</div>
<script>
const T = {total:.2f};
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()}
{th["motif_js"]}
{chr(10).join(" " + l for l in 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))