diff --git a/video/daily/daily.py b/video/daily/daily.py
index 34d5f73..e95e77f 100644
--- a/video/daily/daily.py
+++ b/video/daily/daily.py
@@ -37,8 +37,10 @@ HOME = os.environ.get("SIMBIOT_HOME", "/var/lib/simbiot-harness")
os.environ.setdefault("SIMBIOT_HOME", HOME)
BASE = os.path.join(HOME, "daily-video")
STATE = os.path.join(BASE, "state.json")
-ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # шрифты, gsap
-BGM_SRC = os.path.join(REPO, "video", "simbiot-enterprise", "assets", "bgm", "track.wav")
+DAILY = os.path.dirname(os.path.abspath(__file__))
+ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") # gsap, hyperframes.json
+FONTS = os.path.join(DAILY, "fonts")
+MUSIC = os.path.join(DAILY, "music") # <тема>.wav; нет файла — gold.wav
HF = ["npx", "--yes", "hyperframes@0.8.168"]
KOKORO_PY = "/opt/kokoro-venv/bin/python"
VOICE, SPEED = "am_michael", "1.1"
@@ -89,27 +91,30 @@ def sources():
SCHEMA_HELP = """
-Return ONLY a JSON object:
+The video is VERTICAL (1080×1920, YouTube Shorts / Reels), ~60–75 seconds. Return ONLY a JSON object:
{
"topic": "short English topic label (≤60 chars), must differ from past topics",
- "series": "short uppercase tag for the HUD, e.g. 'DAILY · SECURITY' (≤22 chars)",
+ "series": "short uppercase tag for the HUD, e.g. 'DAILY · SECURITY' (≤20 chars)",
"title": "video title (≤80 chars)",
- "youtube_title": "≤90 chars",
+ "youtube_title": "≤90 chars, ends with #Shorts",
"x_text": "post for X, ≤260 chars incl. hashtags and 'cil.by'",
"post_text": "post for LinkedIn/Telegram/Facebook/Instagram, 450–950 chars, short paragraphs, a few emoji ok, ends with a call to action mentioning cil.by and 3–5 hashtags",
"scenes": [ ...5 to 7 scenes... ]
}
Scene types (every scene also has "narration": what the voice says during the scene):
- {"type":"hook","line1":"≤60 chars","strike":"one word from line1 to strike through (optional)","line2":"≤60 chars, may contain *gold*","narration":"…"}
- {"type":"points","kicker":"NN · Label","headline":"≤48 chars, may contain *gold*","items":[{"title":"≤30 chars","desc":"≤75 chars"}] (2–4 items),"narration":"…"}
- {"type":"flow","kicker":"…","headline":"≤48 chars","steps":[{"title":"≤20 chars","desc":"≤55 chars"}] (3–4 steps),"narration":"…"}
- {"type":"stat","kicker":"…","headline":"≤48 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤90 chars","narration":"…"}
- {"type":"compare","kicker":"…","headline":"≤48 chars","left_title":"≤22 chars (typical AI bot / status quo)","left":["≤40 chars", …2–4],"right_title":"Simbiot Harness","right":["≤40 chars", …same count],"narration":"…"}
- {"type":"statement","kicker":"…","text":"≤70 chars, may contain *gold*","sub":"≤110 chars","narration":"…"}
- {"type":"outro","line1":"≤40 chars","line2":"≤40 chars, may contain *gold*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"}
-Structure: first scene "hook", last scene "outro", 3–5 middle scenes of varied types. Kickers numbered "01 · Cost", "02 · …".
-Narration: English, natural spoken style, 15–45 words per scene, 150–200 words total. Each sentence of a scene's
-narration should introduce the next on-screen item in order (items appear on sentence starts).
+ {"type":"hook","line1":"≤44 chars","strike":"optional: one word from line1 that the video REJECTS (e.g. chatbot in Not another chatbot); crossing it out must not invert the meaning — omit if unsure","line2":"≤44 chars, may contain *accent*","narration":"…"}
+ {"type":"points","kicker":"NN · Label","headline":"≤40 chars, may contain *accent*","items":[{"title":"≤26 chars","desc":"≤60 chars"}] (2–4),"narration":"…"}
+ {"type":"flow","kicker":"…","headline":"≤40 chars","steps":[{"title":"≤24 chars","desc":"≤50 chars"}] (3–4),"narration":"…"}
+ {"type":"stat","kicker":"…","headline":"≤40 chars","value":"≤6 chars, a number that literally appears in the sources","caption":"≤80 chars","narration":"…"}
+ {"type":"compare","kicker":"…","headline":"≤40 chars","left_title":"≤22 chars (typical AI bot / status quo)","left":["≤32 chars", 2–3],"right_title":"Simbiot Harness","right":["≤32 chars", same count],"narration":"…"}
+ {"type":"statement","kicker":"…","text":"≤60 chars, may contain *accent*","sub":"≤100 chars","narration":"…"}
+ {"type":"chat","kicker":"…","headline":"≤40 chars","messages":[{"from":"user|agent|system","text":"≤90 chars"}] (2–4) — an illustrative exchange that shows REAL documented behaviour,"narration":"…"}
+ {"type":"terminal","kicker":"…","headline":"≤40 chars","lines":["$ a real command from the sources (≤44 chars)","output line (≤44 chars)"] (2–5),"narration":"…"}
+ {"type":"outro","line1":"≤34 chars","line2":"≤34 chars, may contain *accent*","cta":"e.g. Let’s talk about a pilot · *cil.by*","narration":"…"}
+Structure: first "hook", last "outro", 3–5 middle scenes. Use at least 4 DIFFERENT scene types overall and do not copy
+yesterday's sequence of types. Kickers numbered "01 · Label".
+Narration: English, punchy spoken style, short sentences, 8–30 words per scene, 110–150 words total. Each sentence of
+a scene introduces the next on-screen element in order (elements appear on sentence starts); captions show each sentence.
"""
RULES = """You write a daily ~80-second explainer video + social posts about Simbiot Harness (an AI agent built by
@@ -121,6 +126,10 @@ Hard rules:
otherwise pick an important capability not covered by PAST TOPICS. Never repeat a past topic.
- Translate internal Russian terms into plain business English. No internal task ids (t-…, e-…), no file paths.
- Tone: confident, concrete, no hype words like "revolutionary".
+- PRIVACY: never quote, paraphrase or reconstruct real private chat messages or internal conversations from the
+ sources, and never name individual people (owner, board members, members, partners, candidates). Refer to roles only:
+ "the owner", "a board member", "a client". Chat scenes must be generic illustrations, not real exchanges.
+- In narration write the domain as "cil dot by" (for the voice); in on-screen text and posts write "cil.by".
"""
@@ -155,8 +164,17 @@ def parse_json(txt):
return json.loads(m.group(0))
-LIMITS = {"line1": 60, "line2": 60, "headline": 48, "title": 30, "desc": 75, "value": 6, "caption": 90,
- "text": 70, "sub": 110, "left_title": 22, "right_title": 22, "cta": 60, "kicker": 28}
+# люди из README/SOUL/коммитов — в публичный ролик их имена и переписки не попадают
+PRIVATE_NAMES = ["Alex", "Alexander", "Shkor", "Vlad", "Vladislav", "Yura", "Yuri", "Gennady", "Nadezhda", "Yulia",
+ "Stas", "Bugaev", "Алекс", "Влад", "Юра", "Геннадий"]
+
+LIMITS = {"line1": 44, "line2": 44, "headline": 40, "value": 6, "caption": 80, "text": 60, "sub": 100,
+ "left_title": 22, "right_title": 22, "cta": 50, "kicker": 24}
+OUTRO_LIMIT = 34
+
+
+def plain_(x):
+ return str(x).replace("*", "")
def validate(spec, src_text, past):
@@ -172,8 +190,10 @@ def validate(spec, src_text, past):
for i, s in enumerate(sc):
n = len(str(s.get("narration", "")).split())
words += n
- if not 10 <= n <= 50:
- errs.append(f"scene {i + 1}: narration {n} words (need 15–45)")
+ if not 6 <= n <= 34:
+ errs.append(f"scene {i + 1}: narration {n} words (need 8–30)")
+ if s.get("type") == "outro" and max(len(plain_(s.get("line1", ""))), len(plain_(s.get("line2", "")))) > OUTRO_LIMIT:
+ errs.append(f"scene {i + 1}: outro lines must be ≤{OUTRO_LIMIT} chars")
for k, lim in LIMITS.items():
v = s.get(k)
if isinstance(v, str) and len(v.replace("*", "")) > lim:
@@ -181,27 +201,43 @@ def validate(spec, src_text, past):
t = s.get("type")
need = {"hook": ["line1", "line2"], "points": ["headline", "items"], "flow": ["headline", "steps"],
"stat": ["headline", "value", "caption"], "compare": ["headline", "left", "right", "left_title", "right_title"],
- "statement": ["text"], "outro": ["line1", "line2"]}.get(t)
+ "statement": ["text"], "outro": ["line1", "line2"], "chat": ["headline", "messages"],
+ "terminal": ["headline", "lines"]}.get(t)
if need is None:
errs.append(f"scene {i + 1}: unknown type {t}")
continue
for k in need:
if not s.get(k):
errs.append(f"scene {i + 1}: missing {k}")
- for key, lo, hi, tl, dl in (("items", 2, 4, 30, 75), ("steps", 3, 4, 20, 55)):
+ for key, lo, hi, tl, dl in (("items", 2, 4, 26, 60), ("steps", 3, 4, 24, 50)):
if t in ("points", "flow") and key in s:
if not lo <= len(s[key]) <= hi:
errs.append(f"scene {i + 1}: {key} count {len(s[key])} (need {lo}–{hi})")
for it in s[key]:
if len(it.get("title", "")) > tl or len(it.get("desc", "")) > dl:
errs.append(f"scene {i + 1}: {key} entry too long: {it}")
- if t == "compare" and (len(s.get("left", [])) != len(s.get("right", [])) or not 2 <= len(s.get("left", [])) <= 4):
- errs.append(f"scene {i + 1}: compare needs 2–4 rows on each side, equal counts")
+ if t == "compare" and (len(s.get("left", [])) != len(s.get("right", [])) or not 2 <= len(s.get("left", [])) <= 3):
+ errs.append(f"scene {i + 1}: compare needs 2–3 rows on each side, equal counts")
for row in s.get("left", []) + s.get("right", []):
- if len(row) > 40:
+ if len(row) > 32:
errs.append(f"scene {i + 1}: compare row too long: {row!r}")
- if not 130 <= words <= 220:
- errs.append(f"total narration {words} words (need 150–200)")
+ if t == "chat":
+ ms = s.get("messages") or []
+ if not 2 <= len(ms) <= 4 or any(m.get("from") not in ("user", "agent", "system") or len(m.get("text", "")) > 90 for m in ms):
+ errs.append(f"scene {i + 1}: chat needs 2–4 messages, from user|agent|system, text ≤90")
+ if t == "terminal":
+ ls = s.get("lines") or []
+ if not 2 <= len(ls) <= 5 or any(len(x) > 46 for x in ls):
+ errs.append(f"scene {i + 1}: terminal needs 2–5 lines ≤44 chars")
+ for x in ls:
+ if x.startswith("$ "):
+ cmd = " ".join(x[2:].split()[:2])
+ if cmd.lower() not in src_text.lower():
+ errs.append(f"scene {i + 1}: command {cmd!r} not found in sources — use a real one")
+ if not 100 <= words <= 165:
+ errs.append(f"total narration {words} words (need 110–150)")
+ if len({x.get("type") for x in sc}) < 4:
+ errs.append("use at least 4 different scene types")
if len(spec.get("x_text", "")) > 270:
errs.append("x_text over 270 chars")
if not 300 <= len(spec.get("post_text", "")) <= 1000:
@@ -217,14 +253,18 @@ def validate(spec, src_text, past):
continue # номера глав/пунктов, «42» — имя кооператива
if n.lower() not in norm_src:
errs.append(f"number {num!r} not found in sources — remove it or use a sourced one")
+ for name in PRIVATE_NAMES:
+ if re.search(rf"\b{name}\b", blob, re.I):
+ errs.append(f"private person name {name!r} found — refer to roles, never quote real chats")
if spec.get("topic", "").strip().lower() in {p.lower() for p in past}:
errs.append("topic repeats a past topic")
return errs
-def write_spec(src, past, date):
+def write_spec(src, past, date, yesterday_types=None):
src_text = src["readme"] + src["soul"] + src["commits"]
user = (f"TODAY: {date}\n\nPAST TOPICS (do not repeat):\n- " + "\n- ".join(past[-40:] or ["(none)"]) +
+ f"\n\nYESTERDAY'S SCENE TYPES (vary from this): {yesterday_types or 'none'}"
f"\n\nRECENT COMMITS (last 4 days):\n{src['commits']}\n\nSOUL:\n{src['soul']}\n\nREADME:\n{src['readme']}\n\n{SCHEMA_HELP}")
msgs = [{"role": "system", "content": RULES}, {"role": "user", "content": user}]
for attempt in range(4):
@@ -244,54 +284,91 @@ def write_spec(src, past, date):
# ───────────────────────── озвучка и сборка ─────────────────────────
-def tts(text, out_wav, workdir):
+def _tts_one(text, out_wav, workdir):
raw = os.path.join(workdir, "raw.wav")
sh(HF + ["tts", text, "-v", VOICE, "-s", SPEED, "-o", raw], timeout=600, env={"HYPERFRAMES_PYTHON": KOKORO_PY})
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-i", raw, "-af",
- "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse,"
- "loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav])
+ "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse",
+ "-ar", "48000", "-ac", "1", out_wav])
os.remove(raw)
+ return float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
+
+
+def sentences(text):
+ parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", text.strip()) if p.strip()]
+ return parts or [text.strip()]
+
+
+def tts(text, out_wav, workdir, gap=0.22):
+ """Озвучка по предложениям: точные начала фраз для появления элементов и субтитров."""
+ sents, files, t = [], [], 0.0
+ for k, sent in enumerate(sentences(text)):
+ f = os.path.join(workdir, f"sent{k}.wav")
+ d = _tts_one(sent, f, workdir)
+ sents.append({"t0": round(t, 2), "t1": round(t + d, 2), "text": sent})
+ files.append(f)
+ t += d + gap
+ sil = os.path.join(workdir, "gap.wav")
+ sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-t", str(gap), sil])
+ lst = os.path.join(workdir, "concat.txt")
+ with open(lst, "w") as fh:
+ for k, f in enumerate(files):
+ fh.write(f"file '{f}'\n" + (f"file '{sil}'\n" if k < len(files) - 1 else ""))
+ sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", lst,
+ "-af", "loudnorm=I=-16:TP=-1.5", "-ar", "48000", out_wav])
+ for f in files + [sil, lst]:
+ os.remove(f)
dur = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out_wav]).strip())
- det = subprocess.run(["ffmpeg", "-nostdin", "-i", out_wav, "-af", "silencedetect=n=-38dB:d=0.18", "-f", "null", "-"],
- capture_output=True, text=True).stderr
- ends = [float(x) for x in re.findall(r"silence_end: ([0-9.]+)", det)]
- return dur, [0.0] + [e for e in ends if e < dur - 0.3]
+ return dur, sents
-def bgm_bed(total, out_wav):
- n = math.ceil(total / 26) + 1
- ins = sum((["-i", BGM_SRC] for _ in range(n)), [])
+def bgm_bed(total, out_wav, theme):
+ src = os.path.join(MUSIC, f"{theme}.wav")
+ if not os.path.exists(src):
+ src = os.path.join(MUSIC, "gold.wav")
+ seg = float(sh(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", src]).strip())
+ n = math.ceil(total / max(8, seg - 4)) + 1
+ ins = sum((["-i", src] for _ in range(n)), [])
chain, prev = [], "[0]"
for k in range(1, n):
lab = f"[x{k}]"
chain.append(f"{prev}[{k}]acrossfade=d=4:c1=tri:c2=tri{lab}")
prev = lab
- chain.append(f"{prev}atrim=0:{total:.2f},afade=t=in:d=0.8,afade=t=out:st={total - 4:.2f}:d=4,loudnorm=I=-16:TP=-1.5")
+ chain.append(f"{prev}atrim=0:{total:.2f},afade=t=in:d=0.6,afade=t=out:st={total - 3:.2f}:d=3,loudnorm=I=-16:TP=-1.5")
sh(["ffmpeg", "-nostdin", "-y", "-loglevel", "error", *ins, "-filter_complex", ";".join(chain), "-ar", "48000", out_wav])
-def build_project(spec, work, date):
+def pick_theme(st, date):
+ """Наименее давно использованная тема (ротация 5 стилей)."""
+ last = {name: "" for name in template.THEMES}
+ for d, info in sorted(st["days"].items()):
+ if d != date and info.get("theme") in last:
+ last[info["theme"]] = d
+ return min(last, key=lambda name: (last[name], list(template.THEMES).index(name)))
+
+
+def build_project(spec, work, date, theme):
proj = os.path.join(work, "project")
shutil.rmtree(proj, ignore_errors=True)
os.makedirs(os.path.join(proj, "assets", "vo"))
os.makedirs(os.path.join(proj, "assets", "bgm"))
- shutil.copytree(os.path.join(ASSETS, "fonts"), os.path.join(proj, "fonts"))
+ shutil.copytree(FONTS, os.path.join(proj, "fonts"))
for f in ("gsap.min.js", "hyperframes.json", "meta.json", "package.json"):
shutil.copy(os.path.join(ASSETS, f), proj)
timing, t = [], 0.0
for i, s in enumerate(spec["scenes"]):
wav = os.path.join(proj, "assets", "vo", f"v{i + 1}.wav")
- dur, starts = tts(s["narration"], wav, work)
- lead, tail = 0.5, (2.6 if i == len(spec["scenes"]) - 1 else 0.8)
+ dur, sents = tts(s["narration"], wav, work)
+ lead, tail = 0.45, (2.4 if i == len(spec["scenes"]) - 1 else 0.6)
vo = t + lead
- timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2),
- "vo_dur": round(dur, 2), "phrase_starts": [round(vo + x, 2) for x in starts]})
+ timing.append({"start": round(t, 2), "dur": round(lead + dur + tail, 2), "vo": round(vo, 2), "vo_dur": round(dur, 2),
+ "sents": [{"t0": round(vo + x["t0"], 2), "t1": round(vo + x["t1"], 2), "text": x["text"]} for x in sents]})
t += lead + dur + tail
- log(f"scene {i + 1} {s['type']}: vo {dur:.1f}s")
+ log(f"scene {i + 1} {s['type']}: vo {dur:.1f}s, {len(sents)} sentences")
total = timing[-1]["start"] + timing[-1]["dur"]
- bgm_bed(total, os.path.join(proj, "assets", "bgm", "bed.wav"))
+ bgm_bed(total, os.path.join(proj, "assets", "bgm", "bed.wav"), theme)
spec["date_label"] = dt.date.fromisoformat(date).strftime("%d %b %Y").upper()
- open(os.path.join(proj, "index.html"), "w").write(template.build(spec, timing, spec.get("series", "DAILY")))
+ open(os.path.join(proj, "index.html"), "w").write(template.build(spec, timing, theme, spec.get("series", "DAILY")))
json.dump(timing, open(os.path.join(work, "timing.json"), "w"), indent=1)
return proj, total
@@ -343,7 +420,7 @@ def buffer_post(service, text, url, spec):
"notifySubscribers": True}}}[service]
video = {"url": url, "metadata": {"title": spec["title"][:100]}}
if service == "instagram":
- video = {"url": url, "metadata": {"thumbnailOffset": int(spec.get("_thumb_ms", 3000))}}
+ video = {"url": url, "metadata": {"thumbnailOffset": int(spec.get("_thumb_ms", 2500))}}
inp = {"channelId": ch["id"], "text": text, "assets": [{"video": video}], "mode": "shareNow",
"schedulingType": "automatic", "needsApproval": False, "metadata": meta, "source": "simbiot-daily"}
r = gql("mutation($input: CreatePostInput!){ createPost(input:$input){ __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }",
@@ -417,6 +494,7 @@ def main():
ap.add_argument("--spec")
ap.add_argument("--publish-at", help="HH:MM UTC — ждать до этого времени перед публикацией")
ap.add_argument("--platforms", default=",".join(ALL_PLATFORMS))
+ ap.add_argument("--theme", choices=list(template.THEMES))
a = ap.parse_args()
os.makedirs(BASE, exist_ok=True)
@@ -437,11 +515,15 @@ def main():
log("reuse rendered video for", a.date)
else:
past = [d["topic"] for k, d in sorted(st["days"].items()) if d.get("topic") and k != a.date]
- spec = json.load(open(a.spec)) if a.spec else write_spec(sources(), past, a.date)
+ prev = [d for k, d in sorted(st["days"].items()) if k < a.date and d.get("types")]
+ theme = a.theme or pick_theme(st, a.date)
+ spec = json.load(open(a.spec)) if a.spec else write_spec(sources(), past, a.date, prev[-1]["types"] if prev else None)
json.dump(spec, open(os.path.join(work, "spec.json"), "w"), ensure_ascii=False, indent=2)
day["topic"] = spec.get("topic", "")
log("topic:", day["topic"])
- proj, total = build_project(spec, work, a.date)
+ day.update(theme=theme, types=[x["type"] for x in spec["scenes"]])
+ log("theme:", theme)
+ proj, total = build_project(spec, work, a.date, theme)
probs = check(proj)
if probs:
raise RuntimeError("hyperframes check failed: " + "; ".join(probs[:6]))
diff --git a/video/daily/fonts/anton-400-latin.woff2 b/video/daily/fonts/anton-400-latin.woff2
new file mode 100644
index 0000000..69d48b9
Binary files /dev/null and b/video/daily/fonts/anton-400-latin.woff2 differ
diff --git a/video/daily/fonts/dm-sans-400-latin.woff2 b/video/daily/fonts/dm-sans-400-latin.woff2
new file mode 100644
index 0000000..0b8bc55
Binary files /dev/null and b/video/daily/fonts/dm-sans-400-latin.woff2 differ
diff --git a/video/daily/fonts/dm-sans-700-latin.woff2 b/video/daily/fonts/dm-sans-700-latin.woff2
new file mode 100644
index 0000000..26edc56
Binary files /dev/null and b/video/daily/fonts/dm-sans-700-latin.woff2 differ
diff --git a/video/daily/fonts/fraunces-400-latin.woff2 b/video/daily/fonts/fraunces-400-latin.woff2
new file mode 100644
index 0000000..83cbd8f
Binary files /dev/null and b/video/daily/fonts/fraunces-400-latin.woff2 differ
diff --git a/video/daily/fonts/fraunces-700-latin.woff2 b/video/daily/fonts/fraunces-700-latin.woff2
new file mode 100644
index 0000000..71bbace
Binary files /dev/null and b/video/daily/fonts/fraunces-700-latin.woff2 differ
diff --git a/video/daily/fonts/inter-400-latin.woff2 b/video/daily/fonts/inter-400-latin.woff2
new file mode 100644
index 0000000..f15b025
Binary files /dev/null and b/video/daily/fonts/inter-400-latin.woff2 differ
diff --git a/video/daily/fonts/inter-600-latin.woff2 b/video/daily/fonts/inter-600-latin.woff2
new file mode 100644
index 0000000..d189794
Binary files /dev/null and b/video/daily/fonts/inter-600-latin.woff2 differ
diff --git a/video/daily/fonts/jetbrains-mono-400-latin.woff2 b/video/daily/fonts/jetbrains-mono-400-latin.woff2
new file mode 100644
index 0000000..5858873
Binary files /dev/null and b/video/daily/fonts/jetbrains-mono-400-latin.woff2 differ
diff --git a/video/daily/fonts/jetbrains-mono-700-latin.woff2 b/video/daily/fonts/jetbrains-mono-700-latin.woff2
new file mode 100644
index 0000000..3a4e333
Binary files /dev/null and b/video/daily/fonts/jetbrains-mono-700-latin.woff2 differ
diff --git a/video/daily/fonts/space-grotesk-500-latin.woff2 b/video/daily/fonts/space-grotesk-500-latin.woff2
new file mode 100644
index 0000000..0db251f
Binary files /dev/null and b/video/daily/fonts/space-grotesk-500-latin.woff2 differ
diff --git a/video/daily/fonts/space-grotesk-700-latin.woff2 b/video/daily/fonts/space-grotesk-700-latin.woff2
new file mode 100644
index 0000000..44604a0
Binary files /dev/null and b/video/daily/fonts/space-grotesk-700-latin.woff2 differ
diff --git a/video/daily/fonts/unbounded-400-latin.woff2 b/video/daily/fonts/unbounded-400-latin.woff2
new file mode 100644
index 0000000..a8c140d
Binary files /dev/null and b/video/daily/fonts/unbounded-400-latin.woff2 differ
diff --git a/video/daily/fonts/unbounded-700-latin.woff2 b/video/daily/fonts/unbounded-700-latin.woff2
new file mode 100644
index 0000000..662dce4
Binary files /dev/null and b/video/daily/fonts/unbounded-700-latin.woff2 differ
diff --git a/video/daily/music/gold.wav b/video/daily/music/gold.wav
new file mode 100644
index 0000000..096e6b7
Binary files /dev/null and b/video/daily/music/gold.wav differ
diff --git a/video/daily/music/paper.wav b/video/daily/music/paper.wav
new file mode 100644
index 0000000..2e0ff71
Binary files /dev/null and b/video/daily/music/paper.wav differ
diff --git a/video/daily/music/poster.wav b/video/daily/music/poster.wav
new file mode 100644
index 0000000..75b44dd
Binary files /dev/null and b/video/daily/music/poster.wav differ
diff --git a/video/daily/music/terminal.wav b/video/daily/music/terminal.wav
new file mode 100644
index 0000000..c7cf0b8
Binary files /dev/null and b/video/daily/music/terminal.wav differ
diff --git a/video/daily/music/violet.wav b/video/daily/music/violet.wav
new file mode 100644
index 0000000..1661a4a
Binary files /dev/null and b/video/daily/music/violet.wav differ
diff --git a/video/daily/template.py b/video/daily/template.py
index 0cafd2b..7817587 100644
--- a/video/daily/template.py
+++ b/video/daily/template.py
@@ -1,108 +1,186 @@
-"""Сценарий (spec) + тайминги озвучки → index.html композиции hyperframes.
+"""Сценарий (spec) + тайминги озвучки → вертикальная (1080×1920) композиция hyperframes.
-Визуальный язык — как у ролика «Simbiot Harness for enterprise» (тёмный фон,
-золотой акцент, Unbounded/Inter/JetBrains Mono, HUD с главами и прогрессом).
+Темы (THEMES) чередуются по дням: своя палитра, шрифты, фон-мотив, манера появления
+и ухода элементов, стиль субтитров и музыка. Безопасные зоны Shorts/Reels: контент
+y 230–1280, субтитры y 1340–1600, правые 120 px и низ под интерфейс приложений не заняты текстом.
Типы сцен (spec["scenes"][i]["type"]):
- hook line1, strike (слово из line1, зачёркивается), line2
+ hook line1, strike (слово из line1), line2
points kicker, headline, items[{title, desc}] (2–4)
flow kicker, headline, steps[{title, desc}] (3–4)
stat kicker, headline, value, caption
- compare kicker, headline, left_title, left[], right_title, right[] (2–4)
+ compare kicker, headline, left_title, left[], right_title, right[] (2–3)
statement kicker, text, sub
+ chat kicker, headline, messages[{from: user|agent|system, text}] (2–4)
+ terminal kicker, headline, lines[] (2–5; команды начинаются с "$ ")
outro line1, line2, cta
-В headline/text/line2 можно выделить фрагмент золотом: *так*.
+В headline/text/line2/caption/sub можно выделить фрагмент акцентом: *так*.
"""
import html
import json
import re
-FONTS_CSS = "\n".join(
- f'@font-face {{ font-family: "{fam}"; font-weight: {w}; src: url("fonts/{file}-{w}-{sub}.woff2") format("woff2"); unicode-range: {rng}; }}'
- for fam, file, weights in (("Unbounded", "unbounded", (400, 700)), ("Inter", "inter", (400, 600)),
- ("JetBrains Mono", "jetbrains-mono", (400, 700)))
- for w in weights
- for sub, rng in (("latin", "U+0000-00FF, U+2000-206F, U+2190-21FF, U+2200-22FF"), ("cyrillic", "U+0400-04FF"))
-)
+W, H = 1080, 1920
-CSS = """
-:root { --bg:#070a10; --ink:#f1f3f7; --dim:#a9b1be; --gold:#f5c46b; --mint:#5cffb0; --red:#ff5d73; --line:rgba(255,255,255,0.08); --card:rgba(16,21,31,0.9); }
+FONT_FILES = { # семейство → [(файл, вес)]
+ "Unbounded": [("unbounded", 400), ("unbounded", 700)],
+ "Inter": [("inter", 400), ("inter", 600)],
+ "JetBrains Mono": [("jetbrains-mono", 400), ("jetbrains-mono", 700)],
+ "Fraunces": [("fraunces", 400), ("fraunces", 700)],
+ "Space Grotesk": [("space-grotesk", 500), ("space-grotesk", 700)],
+ "Anton": [("anton", 400)],
+ "DM Sans": [("dm-sans", 400), ("dm-sans", 700)],
+}
+
+THEMES = {
+ "gold": dict(
+ vars=dict(bg="#070a10", ink="#f1f3f7", dim="#a9b1be", accent="#f5c46b", card="rgba(16,21,31,0.92)",
+ cink="#f1f3f7", cdim="#a9b1be", line="rgba(255,255,255,0.1)", strike="#ff5d73", us="rgba(245,196,107,0.08)"),
+ display=("Unbounded", 700), body="Inter", mono="JetBrains Mono", upper=False, enter="rise", exit="fade",
+ bg_css="background:radial-gradient(1200px 1400px at 50% 30%, #121a2b 0%, #070a10 70%);",
+ motif='
',
+ 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='
',
+ 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='42
',
+ 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='
',
+ 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='
',
+ motif_css=""".m-stripes { position:absolute; inset:-500px; background-image:repeating-linear-gradient(-30deg, rgba(18,13,10,0.07) 0 70px, transparent 70px 180px); }""",
+ motif_js='tl.fromTo(".m-stripes", { x: 0 }, { x: 360, duration: T, ease: "none" }, 0);',
+ cap_css="background:#120d0a; color:#ffffff; padding:6px 22px; text-transform:uppercase; letter-spacing:0.02em;",
+ ),
+}
+
+ENTER = { # (from, to-extra, duration, ease)
+ "rise": ('{ opacity: 0, y: 46 }', 'y: 0', 0.7, "power3.out"),
+ "slide": ('{ opacity: 0, x: -90 }', 'x: 0', 0.6, "power3.out"),
+ "pop": ('{ opacity: 0, scale: 0.84 }', 'scale: 1', 0.6, "back.out(1.7)"),
+ "wipe": ('{ opacity: 0, clipPath: "inset(0% 100% 0% 0%)" }', 'clipPath: "inset(0% 0% 0% 0%)"', 0.6, "power2.inOut"),
+ "slam": ('{ opacity: 0, scale: 1.28 }', 'scale: 1', 0.35, "power4.out"),
+}
+EXIT = {
+ "fade": '{ opacity: 0, duration: 0.45, ease: "power2.in" }',
+ "slideout": '{ opacity: 0, x: -140, duration: 0.45, ease: "power2.in" }',
+ "scale": '{ opacity: 0, scale: 0.92, duration: 0.45, ease: "power2.in" }',
+ "glitch": '{ opacity: 0, x: 18, duration: 0.3, ease: "steps(3)" }',
+ "cut": '{ opacity: 0, duration: 0.12, ease: "none" }',
+}
+
+BASE_CSS = """
* { margin:0; padding:0; box-sizing:border-box; }
-html, body { width:1920px; height:1080px; overflow:hidden; background:var(--bg); }
-body { color:var(--ink); font-family:"Inter", sans-serif; }
+html, body { width:1080px; height:1920px; overflow:hidden; background:var(--bg); }
+body { color:var(--ink); font-family:var(--body), sans-serif; }
#root { position:relative; width:100%; height:100%; overflow:hidden; }
.clip { position:absolute; inset:0; }
-.mono { font-family:"JetBrains Mono", monospace; }
-.gold { color:var(--gold); }
-#bg { background:radial-gradient(1400px 900px at 50% 45%, #111827 0%, var(--bg) 70%); }
-#bg-lines { position:absolute; inset:-300px; background-image:repeating-linear-gradient(115deg, rgba(245,196,107,0.045) 0 1px, transparent 1px 64px); mask-image:radial-gradient(ellipse at center, #000 25%, transparent 75%); }
-#bg-glow { position:absolute; width:1100px; height:1100px; border-radius:50%; left:410px; top:-700px; background:var(--gold); filter:blur(180px); opacity:0.1; }
-.hud-row { position:absolute; left:64px; right:64px; display:flex; justify-content:space-between; align-items:center; font-size:18px; letter-spacing:0.14em; color:var(--dim); text-transform:uppercase; }
-#hud-top { top:44px; } #hud-bot { bottom:52px; }
-#hud-top b { color:var(--gold); font-weight:700; }
-.chap { display:flex; gap:34px; }
-.chap span { display:block; color:#858e9c; }
-#hud-prog { position:absolute; left:64px; right:64px; bottom:36px; height:3px; background:rgba(255,255,255,0.06); }
-#hud-prog-fill { width:100%; height:3px; background:var(--gold); transform-origin:left center; }
-.scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:130px 150px; }
-.in { display:flex; flex-direction:column; align-items:center; width:100%; }
-.kicker { font-size:22px; letter-spacing:0.32em; color:var(--gold); text-transform:uppercase; margin-bottom:24px; text-align:center; }
-.h2 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:64px; letter-spacing:-0.02em; line-height:1.1; text-align:center; max-width:1560px; }
-/* hook */
-.hook-l1 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:72px; letter-spacing:-0.02em; text-align:center; max-width:1560px; line-height:1.12; }
-.hook-l1 .strike-w { position:relative; display:inline-block; }
-.hook-l1 .strike { position:absolute; left:-8px; right:-8px; top:54%; height:10px; border-radius:5px; background:var(--red); transform-origin:left center; }
-.hook-l2 { margin-top:46px; font-family:"Unbounded", sans-serif; font-weight:700; font-size:72px; letter-spacing:-0.02em; text-align:center; color:var(--gold); max-width:1560px; line-height:1.12; }
-.brand { margin-top:60px; font-size:26px; letter-spacing:0.3em; color:var(--dim); }
+.mono { font-family:var(--mono), monospace; }
+.acc { color:var(--accent); }
+.disp { font-family:var(--display), sans-serif; font-weight:var(--dw); text-transform:var(--up); }
+#hud-segs { position:absolute; left:60px; right:60px; top:72px; display:flex; gap:10px; }
+.seg { flex:1; height:6px; border-radius:3px; background:var(--line); overflow:hidden; }
+.seg i { display:block; width:100%; height:6px; background:var(--accent); transform-origin:left center; }
+#hud-row { position:absolute; left:60px; right:60px; top:100px; display:flex; justify-content:space-between; font-size:22px; letter-spacing:0.16em; color:var(--dim); text-transform:uppercase; }
+#hud-row b { color:var(--accent); }
+.scene { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:230px 80px 640px; }
+.in { display:flex; flex-direction:column; align-items:flex-start; width:920px; }
+.in.center { align-items:center; text-align:center; }
+.kicker { font-size:30px; letter-spacing:0.28em; color:var(--accent); text-transform:uppercase; margin-bottom:26px; }
+.h2 { font-size:92px; letter-spacing:-0.015em; line-height:1.06; }
+.card { background:var(--card); border:var(--cborder); color:var(--cink); border-radius:var(--radius); }
+.card small, .card .d { color:var(--cdim); }
+/* hook / outro / statement */
+.l1 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; }
+.l1 .sw { position:relative; display:inline-block; }
+.l1 .strike { position:absolute; left:-6px; right:-6px; top:52%; height:12px; border-radius:6px; background:var(--strike); transform-origin:left center; }
+.l2 { font-size:112px; line-height:1.04; letter-spacing:-0.02em; color:var(--accent); margin-top:44px; }
+.brand { margin-top:70px; font-size:30px; letter-spacing:0.3em; color:var(--dim); }
.brand b { color:var(--ink); }
-/* points */
-.list { margin-top:56px; width:1320px; display:flex; flex-direction:column; gap:22px; }
-.chk { display:flex; align-items:center; gap:30px; min-height:108px; padding:18px 36px; border-radius:20px; background:var(--card); border:1px solid var(--line); }
-.box { position:relative; flex:0 0 56px; width:56px; height:56px; border-radius:14px; border:2px solid var(--gold); }
-.box svg { position:absolute; inset:6px; width:40px; height:40px; }
-.chk-t { font-size:33px; font-weight:600; }
-.chk-t small { display:block; font-size:23px; color:var(--dim); font-weight:400; margin-top:6px; }
-/* flow */
-.flow { margin-top:70px; display:flex; align-items:stretch; gap:0; }
-.step { width:360px; padding:30px 30px; border-radius:22px; background:var(--card); border:1px solid rgba(245,196,107,0.3); }
-.step-n { font-size:20px; color:var(--gold); letter-spacing:0.2em; }
-.step-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:30px; margin-top:14px; line-height:1.15; }
-.step-d { font-size:22px; color:var(--dim); margin-top:12px; line-height:1.35; }
-.arrow { width:70px; display:flex; align-items:center; justify-content:center; }
-.arrow svg { width:46px; height:46px; display:block; }
+.big { font-size:118px; line-height:1.05; letter-spacing:-0.02em; }
+.sub { margin-top:40px; font-size:44px; line-height:1.35; color:var(--dim); }
+.rule { width:100%; height:3px; background:var(--line); margin:54px 0 44px; transform-origin:left center; }
+.logo { font-size:70px; } .logo span { color:var(--accent); }
+.cta { margin-top:24px; font-size:42px; color:var(--dim); } .cta b { color:var(--ink); }
+/* list-like */
+.list { margin-top:60px; width:100%; display:flex; flex-direction:column; gap:26px; }
+.it { display:flex; align-items:center; gap:32px; padding:36px 38px; }
+.num { flex:0 0 76px; height:76px; border-radius:18px; display:flex; align-items:center; justify-content:center; font-size:36px; font-weight:700; background:var(--accent); color:var(--uink, var(--bg)); }
+.it-t { font-size:50px; font-weight:700; line-height:1.15; }
+.it-t small { display:block; font-size:34px; font-weight:400; margin-top:8px; line-height:1.3; }
+.down { width:100%; height:44px; display:flex; justify-content:center; }
+.down svg { width:44px; height:44px; display:block; }
/* stat */
-.stat { display:flex; align-items:center; gap:90px; margin-top:40px; }
-.stat-v { font-family:"Unbounded", sans-serif; font-weight:700; font-size:220px; line-height:1; color:var(--gold); white-space:nowrap; }
-.stat-c { width:640px; font-size:36px; line-height:1.35; border-left:4px solid var(--gold); padding-left:30px; }
+.stat-v { line-height:0.95; color:var(--accent); margin-top:40px; letter-spacing:-0.03em; white-space:nowrap; }
+.stat-c { margin-top:44px; font-size:52px; line-height:1.3; border-left:6px solid var(--accent); padding-left:30px; }
/* compare */
-.cmp { margin-top:60px; display:flex; gap:40px; }
-.col { width:640px; padding:34px 38px; border-radius:24px; background:var(--card); border:1px solid var(--line); }
-.col.us { border-color:var(--gold); background:rgba(245,196,107,0.07); }
-.col-t { font-family:"Unbounded", sans-serif; font-weight:700; font-size:32px; margin-bottom:22px; }
-.col.them .col-t { color:var(--dim); }
-.col.us .col-t { color:var(--gold); }
-.row { display:flex; gap:16px; align-items:baseline; font-size:27px; line-height:1.3; margin-top:16px; }
-.row i { display:block; flex:0 0 14px; width:14px; height:14px; border-radius:4px; }
-.col.them .row { color:var(--dim); }
-.col.them .row i { background:#5a6270; }
-.col.us .row i { background:var(--gold); }
-/* statement */
-.big { font-family:"Unbounded", sans-serif; font-weight:700; font-size:82px; letter-spacing:-0.02em; line-height:1.12; text-align:center; max-width:1600px; }
-.big-sub { margin-top:40px; font-size:32px; color:var(--dim); text-align:center; max-width:1300px; line-height:1.4; }
-.rule { width:760px; height:2px; background:var(--line); margin:56px 0 44px; }
-/* outro */
-.o1 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:80px; letter-spacing:-0.02em; text-align:center; max-width:1600px; }
-.o2 { font-family:"Unbounded", sans-serif; font-weight:700; font-size:80px; letter-spacing:-0.02em; text-align:center; color:var(--gold); margin-top:10px; max-width:1600px; }
-.logo { font-family:"Unbounded", sans-serif; font-weight:700; font-size:54px; }
-.logo span { color:var(--gold); }
-.cta { margin-top:26px; font-size:32px; color:var(--dim); }
-.cta b { color:var(--ink); }
+.cmp { margin-top:46px; width:100%; display:flex; flex-direction:column; gap:26px; }
+.col { padding:38px 40px; }
+.col.us { background:var(--us); border:3px solid var(--accent); }
+.col-t { font-size:44px; font-weight:700; margin-bottom:8px; }
+.col.them .col-t { color:var(--cdim); }
+.col.us .col-t { color:var(--accent); }
+.row { display:flex; gap:20px; align-items:baseline; font-size:40px; line-height:1.3; margin-top:18px; }
+.row i { display:block; flex:0 0 16px; width:16px; height:16px; border-radius:5px; background:var(--cdim); }
+.col.us .row i { background:var(--accent); }
+.col.them .row { color:var(--cdim); }
+/* chat */
+.chat { margin-top:56px; width:100%; display:flex; flex-direction:column; gap:28px; }
+.msg { max-width:840px; padding:30px 36px; font-size:42px; line-height:1.35; border-radius:30px; }
+.msg.user { align-self:flex-end; background:var(--accent); color:var(--uink, var(--bg)); border-bottom-right-radius:8px; }
+.msg.agent { align-self:flex-start; border-bottom-left-radius:8px; }
+.msg.system { align-self:center; font-size:30px; color:var(--dim); background:transparent; border:2px dashed var(--line); border-radius:14px; }
+.who { display:block; font-size:24px; letter-spacing:0.14em; text-transform:uppercase; margin-bottom:8px; opacity:0.75; }
+/* terminal */
+.term { margin-top:46px; width:100%; overflow:hidden; border-radius:22px; background:#05080c; border:2px solid var(--line); }
+.term-bar { height:54px; display:flex; align-items:center; gap:12px; padding:0 24px; background:rgba(255,255,255,0.05); }
+.term-bar i { display:block; width:16px; height:16px; border-radius:50%; }
+.term-body { padding:38px 36px; display:flex; flex-direction:column; gap:22px; }
+.tl { font-size:36px; line-height:1.35; color:#9fb0a8; white-space:pre-wrap; word-break:break-word; }
+.tl.cmd { color:#e8fff3; } .tl.cmd b { color:#5cffb0; }
+/* captions */
+#caps .cap { position:absolute; left:80px; top:1360px; width:900px; font-size:50px; font-weight:700; line-height:1.62; opacity:0; }
"""
-TICK_SVG = (' ')
-ARROW_SVG = (' ')
def esc(s):
@@ -110,182 +188,230 @@ def esc(s):
def rich(s):
- """*фрагмент* → золотой span; остальное экранируется."""
parts = re.split(r"\*([^*]+)\*", str(s))
- return "".join(f'{esc(p)} ' if i % 2 else esc(p) for i, p in enumerate(parts))
+ return "".join(f'{esc(p)} ' if i % 2 else esc(p) for i, p in enumerate(parts))
def plain(s):
return str(s).replace("*", "")
+def font_css():
+ out = []
+ for fam, files in FONT_FILES.items():
+ for f, w in files:
+ out.append(f'@font-face {{ font-family: "{fam}"; font-weight: {w}; src: url("fonts/{f}-{w}-latin.woff2") format("woff2"); }}')
+ return "\n".join(out)
+
+
def _cues(n, sc):
- """n моментов появления элементов внутри сцены: по началам фраз диктора."""
- starts = [t for t in sc["phrase_starts"] if t > sc["vo"] + 0.6]
- if len(starts) >= n:
- # берём последние n фраз (первая фраза обычно — заголовок)
- return starts[-n:] if len(starts) > n + 1 else starts[:n]
- span_a, span_b = sc["vo"] + 1.2, sc["vo"] + max(2.0, sc["vo_dur"] * 0.8)
- return [span_a + (span_b - span_a) * i / max(1, n - 1) for i in range(n)]
+ """n моментов появления элементов: начала предложений диктора (первое — обычно заголовок)."""
+ starts = [s["t0"] for s in sc["sents"]]
+ if len(starts) > n:
+ return starts[1:n + 1] if len(starts) - 1 >= n else starts[-n:]
+ if len(starts) == n:
+ return [max(sc["start"] + 0.9, x) for x in starts]
+ a, b = sc["vo"] + 0.9, sc["vo"] + max(1.6, sc["vo_dur"] * 0.75)
+ return [a + (b - a) * i / max(1, n - 1) for i in range(n)]
-def build(spec, timing, series="DAILY"):
- """timing: [{start, dur, vo, vo_dur, phrase_starts[]}] по сценам; total — общая длина."""
- scenes = spec["scenes"]
+def captions(timing, prefix=""):
+ """Фразы диктора → блоки субтитров (длинные делятся пополам по времени ∝ символам)."""
+ caps = []
+ for sc in timing:
+ for s in sc["sents"]:
+ text = re.sub(r"\bc-?i-?l dot b-?y\b", "cil.by", s["text"].strip(), flags=re.I)
+ if len(text) <= 95:
+ caps.append((s["t0"], s["t1"], text))
+ continue
+ words = text.split()
+ half = len(text) / 2
+ acc, cut = 0, len(words) // 2
+ for k, w in enumerate(words):
+ acc += len(w) + 1
+ if acc >= half:
+ cut = k + 1
+ break
+ a, b = " ".join(words[:cut]), " ".join(words[cut:])
+ tm = s["t0"] + (s["t1"] - s["t0"]) * len(a) / max(1, len(text))
+ caps += [(s["t0"], tm, a), (tm, s["t1"], b)]
+ return [(t0, t1, prefix + txt) for t0, t1, txt in caps]
+
+
+def build(spec, timing, theme_name="gold", series="DAILY"):
+ th = THEMES[theme_name]
+ v = th["vars"]
total = timing[-1]["start"] + timing[-1]["dur"]
+ enter_from, enter_to, enter_d, enter_ease = ENTER[th["enter"]]
body, js = [], []
- chapters = []
- def pop(sel, at, frm='{ opacity: 0, y: 30 }', d=0.7):
- js.append(f'tl.fromTo("{sel}", {frm}, {{ opacity: 1, x: 0, y: 0, duration: {d}, ease: "power3.out" }}, {at:.2f});')
+ def E(sel, at, kind=None, d=None):
+ f, to, dd, ease = ENTER[kind] if kind else (enter_from, enter_to, enter_d, enter_ease)
+ js.append(f'tl.fromTo("{sel}", {f}, {{ opacity: 1, {to}, duration: {d or dd}, ease: "{ease}" }}, {at:.2f});')
- for i, (s, t) in enumerate(zip(scenes, timing)):
- sid = f"s{i + 1}"
- a, vo = t["start"], t["vo"]
+ for i, (s, t) in enumerate(zip(spec["scenes"], timing)):
+ sid, a = f"s{i + 1}", t["start"]
out_at = a + t["dur"] - 0.5
typ = s["type"]
- kick = f'{esc(s.get("kicker", ""))}
' if s.get("kicker") else ""
- head = f'{rich(s.get("headline", ""))}
' if s.get("headline") else ""
- if typ not in ("hook", "outro") and s.get("kicker"):
- chapters.append((plain(s["kicker"]).split("·")[-1].strip()[:14], a, a + t["dur"]))
- inner = ""
+ kick = f'{esc(s["kicker"])}
' if s.get("kicker") else ""
+ head = f'{rich(s["headline"])}
' if s.get("headline") else ""
+ center = typ in ("hook", "outro", "statement")
if typ == "hook":
l1 = esc(s["line1"])
w = s.get("strike")
- if w and w in s["line1"]:
- l1 = esc(s["line1"]).replace(esc(w), f'{esc(w)} ', 1)
- inner = (f'{l1}
{rich(s["line2"])}
'
+ has_strike = bool(w and w in s["line1"])
+ if has_strike:
+ l1 = l1.replace(esc(w), f'{esc(w)} ', 1)
+ inner = (f'{l1}
{rich(s["line2"])}
'
f'SIMBIOT HARNESS · {esc(series)}
')
c = _cues(2, t)
- pop(f"#{sid}-l1", a + 0.3)
- if w and w in s["line1"]:
- js.append(f'tl.fromTo("#{sid}-strike", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.45, ease: "power2.inOut" }}, {max(a + 1.2, c[0] - 0.6):.2f});')
- js.append(f'tl.to("#{sid}-l1", {{ opacity: 0.45, duration: 0.5 }}, {max(a + 1.8, c[0] - 0.1):.2f});')
- pop(f"#{sid}-l2", max(a + 1.6, c[0]))
- js.append(f'tl.fromTo("#{sid}-b", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {min(out_at - 1.2, c[-1] + 0.4):.2f});')
- elif typ == "points":
- items = s["items"]
- rows = "".join(f'{TICK_SVG}
'
- f'
{esc(it["title"])}{esc(it.get("desc", ""))}
'
- for j, it in enumerate(items))
- inner = f'{kick}{head}{rows}
'
- for j, at in enumerate(_cues(len(items), t)):
- pop(f"#{sid}-i{j}", at, '{ opacity: 0, x: -50 }', 0.5)
- js.append(f'tl.to("#{sid}-i{j} .tick", {{ strokeDashoffset: 0, duration: 0.35, ease: "power2.out" }}, {at + 0.35:.2f});')
- js.append(f'tl.to("#{sid}-b{j}", {{ backgroundColor: "rgba(245,196,107,0.15)", duration: 0.3 }}, {at + 0.35:.2f});')
- elif typ == "flow":
+ E(f"#{sid}-l1", a + 0.25)
+ if has_strike:
+ js.append(f'tl.fromTo("#{sid}-st", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.4, ease: "power2.inOut" }}, {max(a + 1.0, c[0] - 0.55):.2f});')
+ js.append(f'tl.to("#{sid}-l1", {{ opacity: 0.45, duration: 0.4 }}, {max(a + 1.4, c[0] - 0.1):.2f});')
+ E(f"#{sid}-l2", max(a + 1.3, c[0]))
+ js.append(f'tl.fromTo("#{sid}-b", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {min(out_at - 1.0, c[-1] + 0.5):.2f});')
+ elif typ in ("points", "flow"):
+ key = "items" if typ == "points" else "steps"
parts = []
- for j, st in enumerate(s["steps"]):
- if j:
- parts.append(f'{ARROW_SVG}
')
- parts.append(f'{j + 1:02d}
'
- f'
{esc(st["title"])}
{esc(st.get("desc", ""))}
')
- inner = f'{kick}{head}{"".join(parts)}
'
- for j, at in enumerate(_cues(len(s["steps"]), t)):
- if j:
- pop(f"#{sid}-a{j}", at - 0.15, '{ opacity: 0, x: -20 }', 0.35)
- pop(f"#{sid}-i{j}", at, '{ opacity: 0, y: 40 }', 0.55)
+ for j, it in enumerate(s[key]):
+ if typ == "flow" and j:
+ parts.append(f'{DOWN_SVG}
')
+ parts.append(f'{j + 1}
'
+ f'
{esc(it["title"])}{esc(it.get("desc", ""))}
')
+ gap = ' style="gap:14px"' if typ == "flow" else ""
+ inner = f'{kick}{head}{"".join(parts)}
'
+ for j, at in enumerate(_cues(len(s[key]), t)):
+ if typ == "flow" and j:
+ js.append(f'tl.fromTo("#{sid}-a{j}", {{ opacity: 0, y: -14 }}, {{ opacity: 1, y: 0, duration: 0.3 }}, {at - 0.15:.2f});')
+ E(f"#{sid}-i{j}", at)
elif typ == "stat":
- inner = (f'{kick}{head}{esc(s["value"])}
'
- f'
{rich(s.get("caption", ""))}
')
+ val = str(s["value"])
+ size = int(min(380, 900 / max(1, 0.66 * len(val))))
+ inner = (f'{kick}{head}{esc(val)}
'
+ f'{rich(s.get("caption", ""))}
')
c = _cues(2, t)
- js.append(f'tl.fromTo("#{sid}-v", {{ opacity: 0, scale: 0.6 }}, {{ opacity: 1, scale: 1, duration: 0.8, ease: "back.out(1.6)" }}, {c[0]:.2f});')
- pop(f"#{sid}-c", c[1], '{ opacity: 0, x: 40 }', 0.6)
+ js.append(f'tl.fromTo("#{sid}-v", {{ opacity: 0, scale: 0.6 }}, {{ opacity: 1, scale: 1, duration: 0.7, ease: "back.out(1.6)" }}, {c[0]:.2f});')
+ E(f"#{sid}-c", c[1])
elif typ == "compare":
def col(cls, title, rows, tag):
- return (f'{esc(title)}
'
- + "".join(f'
{esc(r)}
' for r in rows) + '
')
- inner = (f'{kick}{head}{col("them", s["left_title"], s["left"], "l")}'
- f'{col("us", s["right_title"], s["right"], "r")}
')
+ return (f'{esc(title)}
'
+ + "".join(f'
{esc(r)}
' for r in rows) + "
")
+ inner = f'{kick}{head}{col("them", s["left_title"], s["left"], "l")}{col("us", s["right_title"], s["right"], "r")}
'
c = _cues(2, t)
- pop(f"#{sid}-l", c[0], '{ opacity: 0, x: -50 }', 0.6)
- pop(f"#{sid}-r", c[1], '{ opacity: 0, x: 50 }', 0.6)
- js.append(f'tl.fromTo("#{sid}-r", {{ boxShadow: "0 0 0px rgba(245,196,107,0)" }}, {{ boxShadow: "0 0 60px rgba(245,196,107,0.35)", duration: 0.6, yoyo: true, repeat: 3, ease: "sine.inOut" }}, {c[1] + 0.6:.2f});')
+ E(f"#{sid}-l", c[0])
+ E(f"#{sid}-r", c[1])
elif typ == "statement":
- inner = (f'{kick}{rich(s["text"])}
'
- f'{rich(s.get("sub", ""))}
')
+ inner = (f'{kick}{rich(s["text"])}
'
+ f'{rich(s.get("sub", ""))}
')
c = _cues(2, t)
- pop(f"#{sid}-t", a + 0.3, '{ opacity: 0, y: 40 }', 0.8)
- js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.8, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});')
- pop(f"#{sid}-s", c[1])
+ E(f"#{sid}-t", a + 0.3)
+ js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, ease: "power3.inOut" }}, {c[1] - 0.3:.2f});')
+ E(f"#{sid}-s", c[1])
+ elif typ == "chat":
+ msgs = "".join(
+ f''
+ + (f'{"Simbiot" if m["from"] == "agent" else "Client"} ' if m["from"] != "system" else "")
+ + f'{esc(m["text"])}
' for j, m in enumerate(s["messages"]))
+ inner = f'{kick}{head}{msgs}
'
+ 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'$ {esc(ln[2:])}
')
+ else:
+ rows.append(f'{esc(ln)}
')
+ inner = (f'{kick}{head} '
+ f'
'
+ f'
{"".join(rows)}
')
+ for j, at in enumerate(_cues(len(s["lines"]), t)):
+ E(f"#{sid}-t{j}", at, "wipe")
elif typ == "outro":
- inner = (f'{esc(s["line1"])}
{rich(s["line2"])}
'
- f'
SIMBIOT HARNESS
'
- f'{rich(s.get("cta", "Let’s talk about a pilot · *cil.by*")).replace("", "").replace(" ", "")}
')
+ cta = rich(s.get("cta", "Let’s talk about a pilot · *cil.by*")).replace('', "").replace(" ", "")
+ inner = (f'{esc(s["line1"])}
{rich(s["line2"])}
'
+ f'
SIMBIOT HARNESS
'
+ f'{cta}
')
c = _cues(3, t)
- pop(f"#{sid}-o1", a + 0.3)
- pop(f"#{sid}-o2", c[0])
- js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.8, ease: "power3.inOut" }}, {c[1] - 0.4:.2f});')
- js.append(f'tl.fromTo("#{sid}-logo", {{ opacity: 0, scale: 0.94 }}, {{ opacity: 1, scale: 1, duration: 0.7, ease: "power3.out" }}, {c[1]:.2f});')
- js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.7 }}, {c[2]:.2f});')
+ E(f"#{sid}-o1", a + 0.25)
+ E(f"#{sid}-o2", c[0])
+ js.append(f'tl.fromTo("#{sid}-r", {{ scaleX: 0 }}, {{ scaleX: 1, duration: 0.7, ease: "power3.inOut" }}, {c[1] - 0.4:.2f});')
+ E(f"#{sid}-logo", c[1], "pop")
+ js.append(f'tl.fromTo("#{sid}-cta", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.6 }}, {c[2]:.2f});')
else:
raise ValueError(f"unknown scene type {typ}")
- if typ not in ("hook", "outro", "statement"):
+ if typ not in ("hook", "outro"):
if s.get("kicker"):
- pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5)
+ js.append(f'tl.fromTo("#{sid}-k", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.4 }}, {a + 0.1:.2f});')
if s.get("headline"):
- pop(f"#{sid}-h", a + 0.2)
- elif typ == "statement" and s.get("kicker"):
- pop(f"#{sid}-k", a + 0.1, '{ opacity: 0, y: 16 }', 0.5)
- if i < len(scenes) - 1:
- js.append(f'tl.to("#{sid}-in", {{ opacity: 0, duration: 0.45, ease: "power2.in" }}, {out_at:.2f});')
+ E(f"#{sid}-h", a + 0.2)
+ if i < len(spec["scenes"]) - 1:
+ js.append(f'tl.to("#{sid}-in", {EXIT[th["exit"]]}, {out_at:.2f});')
+ cls = "in center" if center else "in"
body.append(f'')
+ f'{inner}
')
+
+ # субтитры
+ caps = captions(timing, th.get("cap_prefix", ""))
+ cap_html = "".join(f'{esc(txt)}
' 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'
' 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' ']
for i, t in enumerate(timing):
audio.append(f' ')
- chap_html = "".join(f'{esc(c[0])} ' for j, c in enumerate(chapters))
- hud_from = timing[1]["start"] if len(timing) > 1 else 0
- hud_js = [
- f'tl.fromTo("#hud-top, #hud-bot, #hud-prog", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.8 }}, {hud_from:.2f});',
- f'tl.fromTo("#hud-prog-fill", {{ scaleX: 0 }}, {{ scaleX: 1, duration: {total - hud_from:.2f}, ease: "none" }}, {hud_from:.2f});',
- ]
- for j, (_, ca, cb) in enumerate(chapters):
- hud_js.append(f'tl.to("#c{j}", {{ color: "#f5c46b", duration: 0.3 }}, {ca:.2f});')
- hud_js.append(f'tl.to("#c{j}", {{ color: "#858e9c", duration: 0.3 }}, {cb:.2f});')
+ disp_fam, disp_w = th["display"]
+ root_vars = "".join(f"--{k}:{val};" for k, val in v.items())
+ root_vars += (f'--display:"{disp_fam}";--dw:{disp_w};--body:"{th["body"]}";--mono:"{th["mono"]}";'
+ f'--up:{"uppercase" if th["upper"] else "none"};--cborder:{th.get("card_border", "1px solid " + v["line"])};'
+ f'--radius:{"0px" if theme_name in ("paper", "poster") else "24px"};')
+ extra_css = f""":root {{ {root_vars} }}
+#bg {{ {th["bg_css"]} }}
+.card {{ {th.get("card_extra", "")} }}
+#caps .cap .cap-in {{ display:inline; box-decoration-break:clone; -webkit-box-decoration-break:clone; {th["cap_css"]} }}
+{th["motif_css"]}"""
return f"""
-
+
{esc(plain(spec.get("title", "Simbiot Harness")))}
-
-
+
+
{th["motif"]}
{"".join(body)}
+
{cap_html}
{"".join(audio)}
-
SIMBIOT · {esc(series)}
{esc(spec.get("date_label", ""))}
-
-
+
{segs}
+
SIMBIOT · {esc(series)}
{esc(spec.get("date_label", ""))}
"""
-
-
-if __name__ == "__main__":
- import sys
- spec = json.load(open(sys.argv[1]))
- timing = json.load(open(sys.argv[2]))
- print(build(spec, timing))