ежедневный ролик: вертикаль 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) os.environ.setdefault("SIMBIOT_HOME", HOME)
BASE = os.path.join(HOME, "daily-video") BASE = os.path.join(HOME, "daily-video")
STATE = os.path.join(BASE, "state.json") STATE = os.path.join(BASE, "state.json")
ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # шрифты, gsap DAILY = os.path.dirname(os.path.abspath(__file__))
BGM_SRC = os.path.join(REPO, "video", "simbiot-enterprise", "assets", "bgm", "track.wav") 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"] HF = ["npx", "--yes", "hyperframes@0.8.168"]
KOKORO_PY = "/opt/kokoro-venv/bin/python" KOKORO_PY = "/opt/kokoro-venv/bin/python"
VOICE, SPEED = "am_michael", "1.1" VOICE, SPEED = "am_michael", "1.1"
@ -89,27 +91,30 @@ def sources():
SCHEMA_HELP = """ 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", "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)", "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'", "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", "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... ] "scenes": [ ...5 to 7 scenes... ]
} }
Scene types (every scene also has "narration": what the voice says during the scene): 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":"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":"≤48 chars, may contain *gold*","items":[{"title":"≤30 chars","desc":"≤75 chars"}] (2–4 items),"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":"≤48 chars","steps":[{"title":"≤20 chars","desc":"≤55 chars"}] (3–4 steps),"narration":"…"} {"type":"flow","kicker":"…","headline":"≤40 chars","steps":[{"title":"≤24 chars","desc":"≤50 chars"}] (3–4),"narration":"…"}
{"type":"stat","kicker":"…","headline":"≤48 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤90 chars","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":"≤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":"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":"≤70 chars, may contain *gold*","sub":"≤110 chars","narration":"…"} {"type":"statement","kicker":"…","text":"≤60 chars, may contain *accent*","sub":"≤100 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":"…"} {"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":"…"}
Structure: first scene "hook", last scene "outro", 3–5 middle scenes of varied types. Kickers numbered "01 · Cost", "02 · …". {"type":"terminal","kicker":"…","headline":"≤40 chars","lines":["$ a real command from the sources (≤44 chars)","output line (≤44 chars)"] (2–5),"narration":"…"}
Narration: English, natural spoken style, 15–45 words per scene, 150–200 words total. Each sentence of a scene's {"type":"outro","line1":"≤34 chars","line2":"≤34 chars, may contain *accent*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"}
narration should introduce the next on-screen item in order (items appear on sentence starts). 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 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. 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. - 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". - 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)) return json.loads(m.group(0))
LIMITS = {"line1": 60, "line2": 60, "headline": 48, "title": 30, "desc": 75, "value": 6, "caption": 90, # люди из README/SOUL/коммитов — в публичный ролик их имена и переписки не попадают
"text": 70, "sub": 110, "left_title": 22, "right_title": 22, "cta": 60, "kicker": 28} 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): def validate(spec, src_text, past):
@ -172,8 +190,10 @@ def validate(spec, src_text, past):
for i, s in enumerate(sc): for i, s in enumerate(sc):
n = len(str(s.get("narration", "")).split()) n = len(str(s.get("narration", "")).split())
words += n words += n
if not 10 <= n <= 50: if not 6 <= n <= 34:
errs.append(f"scene {i + 1}: narration {n} words (need 15–45)") 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(): for k, lim in LIMITS.items():
v = s.get(k) v = s.get(k)
if isinstance(v, str) and len(v.replace("*", "")) > lim: if isinstance(v, str) and len(v.replace("*", "")) > lim:
@ -181,27 +201,43 @@ def validate(spec, src_text, past):
t = s.get("type") t = s.get("type")
need = {"hook": ["line1", "line2"], "points": ["headline", "items"], "flow": ["headline", "steps"], need = {"hook": ["line1", "line2"], "points": ["headline", "items"], "flow": ["headline", "steps"],
"stat": ["headline", "value", "caption"], "compare": ["headline", "left", "right", "left_title", "right_title"], "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: if need is None:
errs.append(f"scene {i + 1}: unknown type {t}") errs.append(f"scene {i + 1}: unknown type {t}")
continue continue
for k in need: for k in need:
if not s.get(k): if not s.get(k):
errs.append(f"scene {i + 1}: missing {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 t in ("points", "flow") and key in s:
if not lo <= len(s[key]) <= hi: if not lo <= len(s[key]) <= hi:
errs.append(f"scene {i + 1}: {key} count {len(s[key])} (need {lo}–{hi})") errs.append(f"scene {i + 1}: {key} count {len(s[key])} (need {lo}–{hi})")
for it in s[key]: for it in s[key]:
if len(it.get("title", "")) > tl or len(it.get("desc", "")) > dl: if len(it.get("title", "")) > tl or len(it.get("desc", "")) > dl:
errs.append(f"scene {i + 1}: {key} entry too long: {it}") 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): 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–4 rows on each side, equal counts") 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", []): 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}") errs.append(f"scene {i + 1}: compare row too long: {row!r}")
if not 130 <= words <= 220: if t == "chat":
errs.append(f"total narration {words} words (need 150–200)") 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: if len(spec.get("x_text", "")) > 270:
errs.append("x_text over 270 chars") errs.append("x_text over 270 chars")
if not 300 <= len(spec.get("post_text", "")) <= 1000: if not 300 <= len(spec.get("post_text", "")) <= 1000:
@ -217,14 +253,18 @@ def validate(spec, src_text, past):
continue # номера глав/пунктов, «42» — имя кооператива continue # номера глав/пунктов, «42» — имя кооператива
if n.lower() not in norm_src: if n.lower() not in norm_src:
errs.append(f"number {num!r} not found in sources — remove it or use a sourced one") 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}: if spec.get("topic", "").strip().lower() in {p.lower() for p in past}:
errs.append("topic repeats a past topic") errs.append("topic repeats a past topic")
return errs 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"] 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)"]) + 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}") 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}] msgs = [{"role": "system", "content": RULES}, {"role": "user", "content": user}]
for attempt in range(4): 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") 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(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", 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," "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]) "-ar", "48000", "-ac", "1", out_wav])
os.remove(raw) 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()) 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", "-"], return dur, sents
capture_output=True, text=True).stderr
ends = [float(x) for x in re.findall(r"silence_end: ([0-9.]+)", det)]
return dur, [0.0] + [e for e in ends if e < dur - 0.3]
def bgm_bed(total, out_wav): def bgm_bed(total, out_wav, theme):
n = math.ceil(total / 26) + 1 src = os.path.join(MUSIC, f"{theme}.wav")
ins = sum((["-i", BGM_SRC] for _ in range(n)), []) 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]" chain, prev = [], "[0]"
for k in range(1, n): for k in range(1, n):
lab = f"[x{k}]" lab = f"[x{k}]"
chain.append(f"{prev}[{k}]acrossfade=d=4:c1=tri:c2=tri{lab}") chain.append(f"{prev}[{k}]acrossfade=d=4:c1=tri:c2=tri{lab}")
prev = 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]) 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") proj = os.path.join(work, "project")
shutil.rmtree(proj, ignore_errors=True) shutil.rmtree(proj, ignore_errors=True)
os.makedirs(os.path.join(proj, "assets", "vo")) os.makedirs(os.path.join(proj, "assets", "vo"))
os.makedirs(os.path.join(proj, "assets", "bgm")) 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"): for f in ("gsap.min.js", "hyperframes.json", "meta.json", "package.json"):
shutil.copy(os.path.join(ASSETS, f), proj) shutil.copy(os.path.join(ASSETS, f), proj)
timing, t = [], 0.0 timing, t = [], 0.0
for i, s in enumerate(spec["scenes"]): for i, s in enumerate(spec["scenes"]):
wav = os.path.join(proj, "assets", "vo", f"v{i + 1}.wav") wav = os.path.join(proj, "assets", "vo", f"v{i + 1}.wav")
dur, starts = tts(s["narration"], wav, work) dur, sents = tts(s["narration"], wav, work)
lead, tail = 0.5, (2.6 if i == len(spec["scenes"]) - 1 else 0.8) lead, tail = 0.45, (2.4 if i == len(spec["scenes"]) - 1 else 0.6)
vo = t + lead vo = t + lead
timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), "vo_dur": round(dur, 2),
"vo_dur": round(dur, 2), "phrase_starts": [round(vo + x, 2) for x in starts]}) "sents": [{"t0": round(vo + x["t0"], 2), "t1": round(vo + x["t1"], 2), "text": x["text"]} for x in sents]})
t += lead + dur + tail 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"] 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() 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) json.dump(timing, open(os.path.join(work, "timing.json"), "w"), indent=1)
return proj, total return proj, total
@ -343,7 +420,7 @@ def buffer_post(service, text, url, spec):
"notifySubscribers": True}}}[service] "notifySubscribers": True}}}[service]
video = {"url": url, "metadata": {"title": spec["title"][:100]}} video = {"url": url, "metadata": {"title": spec["title"][:100]}}
if service == "instagram": 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", inp = {"channelId": ch["id"], "text": text, "assets": [{"video": video}], "mode": "shareNow",
"schedulingType": "automatic", "needsApproval": False, "metadata": meta, "source": "simbiot-daily"} "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 } } }", 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("--spec")
ap.add_argument("--publish-at", help="HH:MM UTC — ждать до этого времени перед публикацией") ap.add_argument("--publish-at", help="HH:MM UTC — ждать до этого времени перед публикацией")
ap.add_argument("--platforms", default=",".join(ALL_PLATFORMS)) ap.add_argument("--platforms", default=",".join(ALL_PLATFORMS))
ap.add_argument("--theme", choices=list(template.THEMES))
a = ap.parse_args() a = ap.parse_args()
os.makedirs(BASE, exist_ok=True) os.makedirs(BASE, exist_ok=True)
@ -437,11 +515,15 @@ def main():
log("reuse rendered video for", a.date) log("reuse rendered video for", a.date)
else: else:
past = [d["topic"] for k, d in sorted(st["days"].items()) if d.get("topic") and k != a.date] 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) json.dump(spec, open(os.path.join(work, "spec.json"), "w"), ensure_ascii=False, indent=2)
day["topic"] = spec.get("topic", "") day["topic"] = spec.get("topic", "")
log("topic:", day["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) probs = check(proj)
if probs: if probs:
raise RuntimeError("hyperframes check failed: " + "; ".join(probs[:6])) 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» (тёмный фон, Темы (THEMES) чередуются по дням: своя палитра, шрифты, фон-мотив, манера появления
золотой акцент, Unbounded/Inter/JetBrains Mono, HUD с главами и прогрессом). и ухода элементов, стиль субтитров и музыка. Безопасные зоны Shorts/Reels: контент
y 230–1280, субтитры y 1340–1600, правые 120 px и низ под интерфейс приложений не заняты текстом.
Типы сцен (spec["scenes"][i]["type"]): Типы сцен (spec["scenes"][i]["type"]):
hook line1, strike (слово из line1, зачёркивается), line2 hook line1, strike (слово из line1), line2
points kicker, headline, items[{title, desc}] (2–4) points kicker, headline, items[{title, desc}] (2–4)
flow kicker, headline, steps[{title, desc}] (3–4) flow kicker, headline, steps[{title, desc}] (3–4)
stat kicker, headline, value, caption 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 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 outro line1, line2, cta
В headline/text/line2 можно выделить фрагмент золотом: *так*. В headline/text/line2/caption/sub можно выделить фрагмент акцентом: *так*.
""" """
import html import html
import json import json
import re import re
FONTS_CSS = "\n".join( W, H = 1080, 1920
f'@font-face {{ font-family: "{fam}"; font-weight: {w}; src: url("fonts/{file}-{w}-{sub}.woff2") format("woff2"); unicode-range: {rng}; }}'
for fam, file, weights in (("Unbounded", "unbounded", (400, 700)), ("Inter", "inter", (400, 600)),
("JetBrains Mono", "jetbrains-mono", (400, 700)))
for w in weights
for sub, rng in (("latin", "U+0000-00FF, U+2000-206F, U+2190-21FF, U+2200-22FF"), ("cyrillic", "U+0400-04FF"))
)
CSS = """ FONT_FILES = { # семейство → [(файл, вес)]
: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); } "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; } * { margin:0; padding:0; box-sizing:border-box; }
html, body { width:1920px; height:1080px; overflow:hidden; background:var(--bg); } html, body { width:1080px; height:1920px; overflow:hidden; background:var(--bg); }
body { color:var(--ink); font-family:"Inter", sans-serif; } body { color:var(--ink); font-family:var(--body), sans-serif; }
#root { position:relative; width:100%; height:100%; overflow:hidden; } #root { position:relative; width:100%; height:100%; overflow:hidden; }
.clip { position:absolute; inset:0; } .clip { position:absolute; inset:0; }
.mono { font-family:"JetBrains Mono", monospace; } .mono { font-family:var(--mono), monospace; }
.gold { color:var(--gold); } .acc { color:var(--accent); }
#bg { background:radial-gradient(1400px 900px at 50% 45%, #111827 0%, var(--bg) 70%); } .disp { font-family:var(--display), sans-serif; font-weight:var(--dw); text-transform:var(--up); }
#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%); } #hud-segs { position:absolute; left:60px; right:60px; top:72px; display:flex; gap:10px; }
#bg-glow { position:absolute; width:1100px; height:1100px; border-radius:50%; left:410px; top:-700px; background:var(--gold); filter:blur(180px); opacity:0.1; } .seg { flex:1; height:6px; border-radius:3px; background:var(--line); overflow:hidden; }
.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; } .seg i { display:block; width:100%; height:6px; background:var(--accent); transform-origin:left center; }
#hud-top { top:44px; } #hud-bot { bottom:52px; } #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-top b { color:var(--gold); font-weight:700; } #hud-row b { color:var(--accent); }
.chap { display:flex; gap:34px; } .scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:230px 80px 640px; }
.chap span { display:block; color:#858e9c; } .in { display:flex; flex-direction:column; align-items:flex-start; width:920px; }
#hud-prog { position:absolute; left:64px; right:64px; bottom:36px; height:3px; background:rgba(255,255,255,0.06); } .in.center { align-items:center; text-align:center; }
#hud-prog-fill { width:100%; height:3px; background:var(--gold); transform-origin:left center; } .kicker { font-size:30px; letter-spacing:0.28em; color:var(--accent); text-transform:uppercase; margin-bottom:26px; }
.scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:130px 150px; } .h2 { font-size:92px; letter-spacing:-0.015em; line-height:1.06; }
.in { display:flex; flex-direction:column; align-items:center; width:100%; } .card { background:var(--card); border:var(--cborder); color:var(--cink); border-radius:var(--radius); }
.kicker { font-size:22px; letter-spacing:0.32em; color:var(--gold); text-transform:uppercase; margin-bottom:24px; text-align:center; } .card small, .card .d { color:var(--cdim); }
.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 / outro / statement */
/* hook */ .l1 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; }
.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; } .l1 .sw { position:relative; display:inline-block; }
.hook-l1 .strike-w { 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; }
.hook-l1 .strike { position:absolute; left:-8px; right:-8px; top:54%; height:10px; border-radius:5px; background:var(--red); transform-origin:left center; } .l2 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; color:var(--accent); margin-top:44px; }
.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:70px; font-size:30px; letter-spacing:0.3em; color:var(--dim); }
.brand { margin-top:60px; font-size:26px; letter-spacing:0.3em; color:var(--dim); }
.brand b { color:var(--ink); } .brand b { color:var(--ink); }
/* points */ .big { font-size:118px; line-height:1.05; letter-spacing:-0.02em; }
.list { margin-top:56px; width:1320px; display:flex; flex-direction:column; gap:22px; } .sub { margin-top:40px; font-size:44px; line-height:1.35; color:var(--dim); }
.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); } .rule { width:100%; height:3px; background:var(--line); margin:54px 0 44px; transform-origin:left center; }
.box { position:relative; flex:0 0 56px; width:56px; height:56px; border-radius:14px; border:2px solid var(--gold); } .logo { font-size:70px; } .logo span { color:var(--accent); }
.box svg { position:absolute; inset:6px; width:40px; height:40px; } .cta { margin-top:24px; font-size:42px; color:var(--dim); } .cta b { color:var(--ink); }
.chk-t { font-size:33px; font-weight:600; } /* list-like */
.chk-t small { display:block; font-size:23px; color:var(--dim); font-weight:400; margin-top:6px; } .list { margin-top:60px; width:100%; display:flex; flex-direction:column; gap:26px; }
/* flow */ .it { display:flex; align-items:center; gap:32px; padding:36px 38px; }
.flow { margin-top:70px; display:flex; align-items:stretch; gap:0; } .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)); }
.step { width:360px; padding:30px 30px; border-radius:22px; background:var(--card); border:1px solid rgba(245,196,107,0.3); } .it-t { font-size:50px; font-weight:700; line-height:1.15; }
.step-n { font-size:20px; color:var(--gold); letter-spacing:0.2em; } .it-t small { display:block; font-size:34px; font-weight:400; margin-top:8px; line-height:1.3; }
.step-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:30px; margin-top:14px; line-height:1.15; } .down { width:100%; height:44px; display:flex; justify-content:center; }
.step-d { font-size:22px; color:var(--dim); margin-top:12px; line-height:1.35; } .down svg { width:44px; height:44px; display:block; }
.arrow { width:70px; display:flex; align-items:center; justify-content:center; }
.arrow svg { width:46px; height:46px; display:block; }
/* stat */ /* stat */
.stat { display:flex; align-items:center; gap:90px; margin-top:40px; } .stat-v { line-height:0.95; color:var(--accent); margin-top:40px; letter-spacing:-0.03em; white-space:nowrap; }
.stat-v { font-family:"Unbounded", sans-serif; font-weight:700; font-size:220px; line-height:1; color:var(--gold); white-space:nowrap; } .stat-c { margin-top:44px; font-size:52px; line-height:1.3; border-left:6px solid var(--accent); padding-left:30px; }
.stat-c { width:640px; font-size:36px; line-height:1.35; border-left:4px solid var(--gold); padding-left:30px; }
/* compare */ /* compare */
.cmp { margin-top:60px; display:flex; gap:40px; } .cmp { margin-top:46px; width:100%; display:flex; flex-direction:column; gap:26px; }
.col { width:640px; padding:34px 38px; border-radius:24px; background:var(--card); border:1px solid var(--line); } .col { padding:38px 40px; }
.col.us { border-color:var(--gold); background:rgba(245,196,107,0.07); } .col.us { background:var(--us); border:3px solid var(--accent); }
.col-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:32px; margin-bottom:22px; } .col-t { font-size:44px; font-weight:700; margin-bottom:8px; }
.col.them .col-t { color:var(--dim); } .col.them .col-t { color:var(--cdim); }
.col.us .col-t { color:var(--gold); } .col.us .col-t { color:var(--accent); }
.row { display:flex; gap:16px; align-items:baseline; font-size:27px; line-height:1.3; margin-top:16px; } .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 14px; width:14px; height:14px; border-radius:4px; } .row i { display:block; flex:0 0 16px; width:16px; height:16px; border-radius:5px; background:var(--cdim); }
.col.them .row { color:var(--dim); } .col.us .row i { background:var(--accent); }
.col.them .row i { background:#5a6270; } .col.them .row { color:var(--cdim); }
.col.us .row i { background:var(--gold); } /* chat */
/* statement */ .chat { margin-top:56px; width:100%; display:flex; flex-direction:column; gap:28px; }
.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; } .msg { max-width:840px; padding:30px 36px; font-size:42px; line-height:1.35; border-radius:30px; }
.big-sub { margin-top:40px; font-size:32px; color:var(--dim); text-align:center; max-width:1300px; line-height:1.4; } .msg.user { align-self:flex-end; background:var(--accent); color:var(--uink, var(--bg)); border-bottom-right-radius:8px; }
.rule { width:760px; height:2px; background:var(--line); margin:56px 0 44px; } .msg.agent { align-self:flex-start; border-bottom-left-radius:8px; }
/* outro */ .msg.system { align-self:center; font-size:30px; color:var(--dim); background:transparent; border:2px dashed var(--line); border-radius:14px; }
.o1 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:80px; letter-spacing:-0.02em; text-align:center; max-width:1600px; } .who { display:block; font-size:24px; letter-spacing:0.14em; text-transform:uppercase; margin-bottom:8px; opacity:0.75; }
.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; } /* terminal */
.logo { font-family:"Unbounded", sans-serif; font-weight:700; font-size:54px; } .term { margin-top:46px; width:100%; overflow:hidden; border-radius:22px; background:#05080c; border:2px solid var(--line); }
.logo span { color:var(--gold); } .term-bar { height:54px; display:flex; align-items:center; gap:12px; padding:0 24px; background:rgba(255,255,255,0.05); }
.cta { margin-top:26px; font-size:32px; color:var(--dim); } .term-bar i { display:block; width:16px; height:16px; border-radius:50%; }
.cta b { color:var(--ink); } .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" ' 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>')
ARROW_SVG = ('<svg viewBox="0 0 46 46"><path d="M6 23 H38 M26 11 L38 23 L26 35" stroke="#f5c46b" stroke-width="4" '
'fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>') 'fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>')
@ -110,182 +188,230 @@ def esc(s):
def rich(s): def rich(s):
"""*фрагмент* → золотой span; остальное экранируется."""
parts = re.split(r"\*([^*]+)\*", str(s)) 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): def plain(s):
return str(s).replace("*", "") 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): def _cues(n, sc):
"""n моментов появления элементов внутри сцены: по началам фраз диктора.""" """n моментов появления элементов: начала предложений диктора (первое — обычно заголовок)."""
starts = [t for t in sc["phrase_starts"] if t > sc["vo"] + 0.6] starts = [s["t0"] for s in sc["sents"]]
if len(starts) >= n: if len(starts) > n:
# берём последние n фраз (первая фраза обычно — заголовок) return starts[1:n + 1] if len(starts) - 1 >= n else starts[-n:]
return starts[-n:] if len(starts) > n + 1 else starts[:n] if len(starts) == n:
span_a, span_b = sc["vo"] + 1.2, sc["vo"] + max(2.0, sc["vo_dur"] * 0.8) return [max(sc["start"] + 0.9, x) for x in starts]
return [span_a + (span_b - span_a) * i / max(1, n - 1) for i in range(n)] 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"): def captions(timing, prefix=""):
"""timing: [{start, dur, vo, vo_dur, phrase_starts[]}] по сценам; total — общая длина.""" """Фразы диктора → блоки субтитров (длинные делятся пополам по времени ∝ символам)."""
scenes = spec["scenes"] 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"] total = timing[-1]["start"] + timing[-1]["dur"]
enter_from, enter_to, enter_d, enter_ease = ENTER[th["enter"]]
body, js = [], [] body, js = [], []
chapters = []
def pop(sel, at, frm='{ opacity: 0, y: 30 }', d=0.7): def E(sel, at, kind=None, d=None):
js.append(f'tl.fromTo("{sel}", {frm}, {{ opacity: 1, x: 0, y: 0, duration: {d}, ease: "power3.out" }}, {at:.2f});') 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)): for i, (s, t) in enumerate(zip(spec["scenes"], timing)):
sid = f"s{i + 1}" sid, a = f"s{i + 1}", t["start"]
a, vo = t["start"], t["vo"]
out_at = a + t["dur"] - 0.5 out_at = a + t["dur"] - 0.5
typ = s["type"] typ = s["type"]
kick = f'<div class="kicker mono" id="{sid}-k">{esc(s.get("kicker", ""))}</div>' if s.get("kicker") else "" kick = f'<div class="kicker mono" id="{sid}-k">{esc(s["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 "" head = f'<div class="h2 disp" id="{sid}-h">{rich(s["headline"])}</div>' if s.get("headline") else ""
if typ not in ("hook", "outro") and s.get("kicker"): center = typ in ("hook", "outro", "statement")
chapters.append((plain(s["kicker"]).split("·")[-1].strip()[:14], a, a + t["dur"]))
inner = ""
if typ == "hook": if typ == "hook":
l1 = esc(s["line1"]) l1 = esc(s["line1"])
w = s.get("strike") w = s.get("strike")
if w and w in s["line1"]: has_strike = bool(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) if has_strike:
inner = (f'<div class="hook-l1" id="{sid}-l1">{l1}</div><div class="hook-l2" id="{sid}-l2">{rich(s["line2"])}</div>' 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>') f'<div class="brand mono" id="{sid}-b"><b>SIMBIOT HARNESS</b> · {esc(series)}</div>')
c = _cues(2, t) c = _cues(2, t)
pop(f"#{sid}-l1", a + 0.3) E(f"#{sid}-l1", a + 0.25)
if w and w in s["line1"]: if has_strike:
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.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.5 }}, {max(a + 1.8, c[0] - 0.1):.2f});') js.append(f'tl.to("#{sid}-l1", {{ opacity: 0.45, duration: 0.4 }}, {max(a + 1.4, c[0] - 0.1):.2f});')
pop(f"#{sid}-l2", max(a + 1.6, c[0])) 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.2, c[-1] + 0.4):.2f});') 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 == "points": elif typ in ("points", "flow"):
items = s["items"] key = "items" if typ == "points" else "steps"
rows = "".join(f'<div class="chk" id="{sid}-i{j}"><div class="box" id="{sid}-b{j}">{TICK_SVG}</div>'
f'<div class="chk-t">{esc(it["title"])}<small>{esc(it.get("desc", ""))}</small></div></div>'
for j, it in enumerate(items))
inner = f'{kick}{head}<div class="list">{rows}</div>'
for j, at in enumerate(_cues(len(items), t)):
pop(f"#{sid}-i{j}", at, '{ opacity: 0, x: -50 }', 0.5)
js.append(f'tl.to("#{sid}-i{j} .tick", {{ strokeDashoffset: 0, duration: 0.35, ease: "power2.out" }}, {at + 0.35:.2f});')
js.append(f'tl.to("#{sid}-b{j}", {{ backgroundColor: "rgba(245,196,107,0.15)", duration: 0.3 }}, {at + 0.35:.2f});')
elif typ == "flow":
parts = [] parts = []
for j, st in enumerate(s["steps"]): for j, it in enumerate(s[key]):
if j: if typ == "flow" and j:
parts.append(f'<div class="arrow" id="{sid}-a{j}">{ARROW_SVG}</div>') parts.append(f'<div class="down acc" id="{sid}-a{j}">{DOWN_SVG}</div>')
parts.append(f'<div class="step" id="{sid}-i{j}"><div class="step-n mono">{j + 1:02d}</div>' parts.append(f'<div class="it card" id="{sid}-i{j}"><div class="num mono">{j + 1}</div>'
f'<div class="step-t">{esc(st["title"])}</div><div class="step-d">{esc(st.get("desc", ""))}</div></div>') f'<div class="it-t">{esc(it["title"])}<small>{esc(it.get("desc", ""))}</small></div></div>')
inner = f'{kick}{head}<div class="flow">{"".join(parts)}</div>' gap = ' style="gap:14px"' if typ == "flow" else ""
for j, at in enumerate(_cues(len(s["steps"]), t)): inner = f'{kick}{head}<div class="list"{gap}>{"".join(parts)}</div>'
if j: for j, at in enumerate(_cues(len(s[key]), t)):
pop(f"#{sid}-a{j}", at - 0.15, '{ opacity: 0, x: -20 }', 0.35) if typ == "flow" and j:
pop(f"#{sid}-i{j}", at, '{ opacity: 0, y: 40 }', 0.55) 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": elif typ == "stat":
inner = (f'{kick}{head}<div class="stat"><div class="stat-v" id="{sid}-v">{esc(s["value"])}</div>' val = str(s["value"])
f'<div class="stat-c" id="{sid}-c">{rich(s.get("caption", ""))}</div></div>') 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) 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});') 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});')
pop(f"#{sid}-c", c[1], '{ opacity: 0, x: 40 }', 0.6) E(f"#{sid}-c", c[1])
elif typ == "compare": elif typ == "compare":
def col(cls, title, rows, tag): def col(cls, title, rows, tag):
return (f'<div class="col {cls}" id="{sid}-{tag}"><div class="col-t">{esc(title)}</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>') + "".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")}' inner = f'{kick}{head}<div class="cmp">{col("them", s["left_title"], s["left"], "l")}{col("us", s["right_title"], s["right"], "r")}</div>'
f'{col("us", s["right_title"], s["right"], "r")}</div>')
c = _cues(2, t) c = _cues(2, t)
pop(f"#{sid}-l", c[0], '{ opacity: 0, x: -50 }', 0.6) E(f"#{sid}-l", c[0])
pop(f"#{sid}-r", c[1], '{ opacity: 0, x: 50 }', 0.6) E(f"#{sid}-r", c[1])
js.append(f'tl.fromTo("#{sid}-r", {{ boxShadow: "0 0 0px rgba(245,196,107,0)" }}, {{ boxShadow: "0 0 60px rgba(245,196,107,0.35)", duration: 0.6, yoyo: true, repeat: 3, ease: "sine.inOut" }}, {c[1] + 0.6:.2f});')
elif typ == "statement": elif typ == "statement":
inner = (f'{kick}<div class="big" id="{sid}-t">{rich(s["text"])}</div><div class="rule" id="{sid}-r"></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="big-sub" id="{sid}-s">{rich(s.get("sub", ""))}</div>') f'<div class="sub" id="{sid}-s">{rich(s.get("sub", ""))}</div>')
c = _cues(2, t) c = _cues(2, t)
pop(f"#{sid}-t", a + 0.3, '{ opacity: 0, y: 40 }', 0.8) E(f"#{sid}-t", a + 0.3)
js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.8, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});') js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});')
pop(f"#{sid}-s", c[1]) 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": 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>' cta = rich(s.get("cta", "Let’s talk about a pilot · *cil.by*")).replace('<span class="acc">', "<b>").replace("</span>", "</b>")
f'<div class="rule" id="{sid}-r"></div><div class="logo" id="{sid}-logo">SIMBIOT <span>HARNESS</span></div>' 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="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>') 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) c = _cues(3, t)
pop(f"#{sid}-o1", a + 0.3) E(f"#{sid}-o1", a + 0.25)
pop(f"#{sid}-o2", c[0]) E(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}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, 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});') E(f"#{sid}-logo", c[1], "pop")
js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.7 }}, {c[2]:.2f});') js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {c[2]:.2f});')
else: else:
raise ValueError(f"unknown scene type {typ}") raise ValueError(f"unknown scene type {typ}")
if typ not in ("hook", "outro", "statement"): if typ not in ("hook", "outro"):
if s.get("kicker"): 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"): if s.get("headline"):
pop(f"#{sid}-h", a + 0.2) E(f"#{sid}-h", a + 0.2)
elif typ == "statement" and s.get("kicker"): if i < len(spec["scenes"]) - 1:
pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5) js.append(f'tl.to("#{sid}-in", {EXIT[th["exit"]]}, {out_at:.2f});')
if i < len(scenes) - 1: cls = "in center" if center else "in"
js.append(f'tl.to("#{sid}-in", {{ opacity: 0, duration: 0.45, ease: "power2.in" }}, {out_at:.2f});')
body.append(f'<section id="{sid}" class="clip scene" data-start="{a:.2f}" data-duration="{t["dur"]:.2f}" data-track-index="1">' 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>'] 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): 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>') 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)) disp_fam, disp_w = th["display"]
hud_from = timing[1]["start"] if len(timing) > 1 else 0 root_vars = "".join(f"--{k}:{val};" for k, val in v.items())
hud_js = [ root_vars += (f'--display:"{disp_fam}";--dw:{disp_w};--body:"{th["body"]}";--mono:"{th["mono"]}";'
f'tl.fromTo("#hud-top, #hud-bot, #hud-prog", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.8 }}, {hud_from:.2f});', f'--up:{"uppercase" if th["upper"] else "none"};--cborder:{th.get("card_border", "1px solid " + v["line"])};'
f'tl.fromTo("#hud-prog-fill", {{ scaleX: 0 }}, {{ scaleX: 1, duration: {total - hud_from:.2f}, ease: "none" }}, {hud_from:.2f});', f'--radius:{"0px" if theme_name in ("paper", "poster") else "24px"};')
] extra_css = f""":root {{ {root_vars} }}
for j, (_, ca, cb) in enumerate(chapters): #bg {{ {th["bg_css"]} }}
hud_js.append(f'tl.to("#c{j}", {{ color: "#f5c46b", duration: 0.3 }}, {ca:.2f});') .card {{ {th.get("card_extra", "")} }}
hud_js.append(f'tl.to("#c{j}", {{ color: "#858e9c", duration: 0.3 }}, {cb:.2f});') #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> return f"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>{esc(plain(spec.get("title", "Simbiot Harness")))}</title>
<script src="./gsap.min.js"></script> <script src="./gsap.min.js"></script>
<style> <style>
{FONTS_CSS} {font_css()}
{CSS} {BASE_CSS}
{extra_css}
</style> </style>
</head> </head>
<body> <body>
<div id="root" data-composition-id="main" data-start="0" data-duration="{total:.2f}" data-width="1920" data-height="1080"> <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"><div id="bg-lines"></div><div id="bg-glow"></div></div> <div id="bg" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="0">{th["motif"]}</div>
{"".join(body)} {"".join(body)}
<div id="caps" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="5">{cap_html}</div>
{"".join(audio)} {"".join(audio)}
<div id="hud" class="clip" data-start="0" data-duration="{total:.2f}" data-track-index="2"> <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 id="hud-segs">{segs}</div>
<div class="hud-row mono" id="hud-bot"><div class="chap">{chap_html}</div><div>cil.by</div></div> <div id="hud-row" class="mono"><div><b>SIMBIOT</b> · {esc(series)}</div><div>{esc(spec.get("date_label", ""))}</div></div>
<div id="hud-prog"><div id="hud-prog-fill"></div></div>
</div> </div>
</div> </div>
<script> <script>
const T = {total:.2f};
const tl = gsap.timeline({{ paused: true }}); const tl = gsap.timeline({{ paused: true }});
tl.fromTo("#bg-lines", {{ x: 0 }}, {{ x: 256, duration: {total:.2f}, ease: "none" }}, 0); {th["motif_js"]}
tl.fromTo("#bg-glow", {{ x: 0, y: 0 }}, {{ x: -380, y: 180, duration: {total:.2f}, ease: "sine.inOut" }}, 0); {chr(10).join(" " + l for l in js).strip()}
document.querySelectorAll(".tick").forEach((p) => {{ const L = p.getTotalLength(); p.style.strokeDasharray = L; p.style.strokeDashoffset = L; }});
{chr(10).join(" " + l for l in hud_js + js).strip()}
window.__timelines = window.__timelines || {{}}; window.__timelines = window.__timelines || {{}};
window.__timelines["main"] = tl; window.__timelines["main"] = tl;
</script> </script>
</body> </body>
</html> </html>
""" """
if __name__ == "__main__":
import sys
spec = json.load(open(sys.argv[1]))
timing = json.load(open(sys.argv[2]))
print(build(spec, timing))