- вертикальный формат (Shorts/Reels): YouTube через Buffer принимает только вертикаль; безопасные зоны под интерфейс приложений, сегментный прогресс-бар как у сторис; - 5 тем (gold, terminal, paper, violet, poster): палитра, шрифты, фон-мотив, манера появления/ухода и своя музыка (MusicGen); тема дня — наименее давно использованная; - новые сцены chat и terminal (команды сверяются с источниками), ≥4 разных типа, не повторять структуру вчерашнего ролика; - озвучка по предложениям → точные субтитры и появление элементов на фразах; - приватность: без цитат реальных переписок и имён людей (проверка в валидаторе). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
26 KiB
Python
417 lines
26 KiB
Python
"""Сценарий (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>
|
||
"""
|