simbiot-harness/video/daily/template.py
Simbiot Harness a08cd482f5 ежедневный ролик: вертикаль 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>
2026-10-18 08:46:10 +00:00

417 lines
26 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) + тайминги озвучки → вертикальная (1080×1920) композиция hyperframes.
Темы (THEMES) чередуются по дням: своя палитра, шрифты, фон-мотив, манера появления
и ухода элементов, стиль субтитров и музыка. Безопасные зоны Shorts/Reels: контент
y 230–1280, субтитры y 1340–1600, правые 120 px и низ под интерфейс приложений не заняты текстом.
Типы сцен (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–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/caption/sub можно выделить фрагмент акцентом: *так*.
"""
import html
import json
import re
W, H = 1080, 1920
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: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: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); }
.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-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: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; }
"""
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>')
def esc(s):
return html.escape(str(s), quote=False)
def rich(s):
parts = re.split(r"\*([^*]+)\*", str(s))
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 = [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 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 = [], []
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(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["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")
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)
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, 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":
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.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 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)
E(f"#{sid}-l", c[0])
E(f"#{sid}-r", c[1])
elif typ == "statement":
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)
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":
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)
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"):
if s.get("kicker"):
js.append(f'tl.fromTo("#{sid}-k", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.4 }}, {a + 0.1:.2f});')
if s.get("headline"):
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="{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>')
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={W}, height={H}" />
<title>{esc(plain(spec.get("title", "Simbiot Harness")))}</title>
<script src="./gsap.min.js"></script>
<style>
{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="{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 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 }});
{th["motif_js"]}
{chr(10).join(" " + l for l in js).strip()}
window.__timelines = window.__timelines || {{}};
window.__timelines["main"] = tl;
</script>
</body>
</html>
"""