#!/usr/bin/env python3 """Карусель из spec.json → hyperframes-композиция 1080×1350 → PNG-слайды (+ анимированный mp4). python3 video/carousels/make.py video/carousels/ # собрать + снапшоты python3 video/carousels/make.py video/carousels/ --render # + mp4 spec.json: {"palette": "carbon"|"bone", "handle": "@alexshkor", "slides": [...]} Типы слайдов (у каждого опционально "kicker"): cover photo, title, byline quote text, sub, visual: "chart-down"|"network"|"ring"|null, chips[] (для ring) phototop photo, text, sub photoside photo, text, sub, dots: true|false stat value, label, sub list title, items[] (3–4) formula parts[] (2–4 слова формулы), sub final photo, text, cta В text/title/sub фрагмент *так* выделяется акцентом. photo — путь относительно папки карусели. """ import html import json import os import re import shutil import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.abspath(os.path.join(HERE, "..", "..")) FONTS = os.path.join(REPO, "video", "daily", "fonts") ASSETS = os.path.join(REPO, "video", "simbiot-enterprise-en") BRAND = os.path.join(REPO, "brand", "shkor-labs") HF = ["npx", "--yes", "hyperframes@0.8.168"] SLIDE = 3.5 PALETTES = { "carbon": dict(bg="#050506", bg2="#111113", ink="#ece6da", dim="#a8a297", acc="#c6ff4d", accink="#050506", line="rgba(236,230,218,0.14)", em="color:var(--acc);", fade="5,5,6", logo="shkor-labs-logo-dark-bg.svg"), "bone": dict(bg="#ece6da", bg2="#e2dbcd", ink="#0b0b0c", dim="#55504a", acc="#0b0b0c", accink="#c6ff4d", line="rgba(11,11,12,0.14)", em="background:#c6ff4d; color:#0b0b0c; padding:0 0.12em; box-decoration-break:clone; -webkit-box-decoration-break:clone;", fade="236,230,218", logo="shkor-labs-logo-light.svg"), } def esc(s): return html.escape(str(s), quote=False) def rich(s): parts = re.split(r"\*([^*]+)\*", str(s)) out = "".join(f"{esc(p)}" if i % 2 else esc(p) for i, p in enumerate(parts)) for tag in ("b", "/b", "span", "/span"): # разрешённая разметка в kicker/byline out = out.replace(f"<{tag}>", f"<{tag}>") return out CSS = """ @font-face { font-family: "Fraunces"; font-weight: 400; src: url("fonts/fraunces-400-latin.woff2") format("woff2"); } @font-face { font-family: "Fraunces"; font-weight: 700; src: url("fonts/fraunces-700-latin.woff2") format("woff2"); } @font-face { font-family: "DM Sans"; font-weight: 400; src: url("fonts/dm-sans-400-latin.woff2") format("woff2"); } @font-face { font-family: "DM Sans"; font-weight: 700; src: url("fonts/dm-sans-700-latin.woff2") format("woff2"); } @font-face { font-family: "JetBrains Mono"; font-weight: 400; src: url("fonts/jetbrains-mono-400-latin.woff2") format("woff2"); } @font-face { font-family: "JetBrains Mono"; font-weight: 700; src: url("fonts/jetbrains-mono-700-latin.woff2") format("woff2"); } * { margin:0; padding:0; box-sizing:border-box; } html, body { width:1080px; height:1350px; overflow:hidden; background:var(--bg); } body { color:var(--ink); font-family:"DM Sans", sans-serif; } #root { position:relative; width:100%; height:100%; overflow:hidden; } .clip { position:absolute; inset:0; } .mono { font-family:"JetBrains Mono", monospace; } em { font-style:normal; } #bg { background:radial-gradient(900px 1100px at 70% 20%, var(--bg2) 0%, var(--bg) 70%); } #bg-grid { position:absolute; inset:0; background-image:linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px); background-size:90px 90px; opacity:0.35; } .slide { overflow:hidden; } .top { position:absolute; left:64px; right:64px; top:56px; display:flex; justify-content:space-between; align-items:center; z-index:5; } .mark { width:56px; height:56px; display:block; } .count { font-size:22px; letter-spacing:0.18em; color:var(--dim); background:rgba(var(--fade),0.75); padding:10px 16px; border-radius:20px; } .count b { color:var(--ink); } .foot { position:absolute; left:64px; right:64px; bottom:54px; display:flex; justify-content:space-between; align-items:center; font-size:22px; letter-spacing:0.14em; color:var(--dim); text-transform:uppercase; z-index:5; } .swipe { display:flex; align-items:center; gap:14px; color:var(--ink); font-weight:700; } .kick { font-size:24px; letter-spacing:0.26em; color:var(--dim); text-transform:uppercase; margin-bottom:26px; } .kick b { color:var(--ink); } .body { position:absolute; left:64px; right:64px; z-index:3; } .q { font-family:"Fraunces", serif; font-weight:700; letter-spacing:-0.02em; line-height:1.05; } .sub { font-size:34px; line-height:1.4; color:var(--dim); margin-top:34px; } .photo { position:absolute; overflow:hidden; } .photo img { display:block; width:100%; height:100%; object-fit:cover; } .fadeb { position:absolute; left:0; right:0; } /* cover */ .cover .photo { inset:0; } .cover .fadeb { inset:0; background:linear-gradient(180deg, rgba(var(--fade),0) 0%, rgba(var(--fade),0.1) 30%, rgba(var(--fade),0.9) 52%, rgb(var(--fade)) 66%); } .cover .body { bottom:150px; } .cover .q { font-size:100px; } .byline { margin-top:30px; font-size:30px; } .byline span { color:var(--dim); } /* quote */ .quote .body { top:210px; } .quote .q { font-size:94px; } .vis { position:absolute; left:64px; right:64px; bottom:140px; height:420px; } /* phototop */ .phototop .photo { left:0; top:0; width:1080px; height:640px; } .phototop .fadeb { top:380px; height:262px; background:linear-gradient(180deg, rgba(var(--fade),0), rgb(var(--fade))); } .phototop .body { top:650px; } .phototop .q { font-size:76px; } /* photoside */ .photoside .photo { right:0; top:0; width:520px; height:1350px; } .photoside .fadeb { right:0; top:0; width:520px; height:1350px; background:linear-gradient(90deg, rgb(var(--fade)) 0%, rgba(var(--fade),0.25) 45%, rgba(var(--fade),0) 100%); left:auto; } .photoside .body { top:250px; right:480px; } .photoside .q { font-size:80px; } .dots { display:flex; gap:8px; margin-top:40px; } .dots i { display:block; width:46px; height:46px; border-radius:50%; } .dots .h { background:var(--ink); } .dots .a { border:4px solid var(--acc); } /* stat */ .stat .body { top:330px; } .big { font-family:"Fraunces", serif; font-weight:700; font-size:300px; line-height:0.95; letter-spacing:-0.04em; white-space:nowrap; } .big-l { font-size:44px; font-weight:700; margin-top:24px; line-height:1.25; } /* list */ .list .body { top:210px; } .list .q { font-size:76px; } .items { margin-top:50px; display:flex; flex-direction:column; gap:22px; } .item { display:flex; gap:28px; align-items:baseline; padding:26px 30px; border:2px solid var(--line); border-radius:22px; background:rgba(var(--fade),0.5); } .item b { font-family:"JetBrains Mono", monospace; font-size:30px; color:var(--dim); flex:0 0 52px; } .item span { font-size:38px; line-height:1.3; } /* formula */ .formula .body { top:300px; } .parts { display:flex; flex-direction:column; gap:6px; } .part { font-family:"Fraunces", serif; font-weight:700; font-size:120px; line-height:1.02; letter-spacing:-0.02em; } .op { font-family:"JetBrains Mono", monospace; font-size:60px; color:var(--dim); margin-left:6px; } /* final */ .final .photo { left:64px; top:170px; width:300px; height:300px; border-radius:50%; border:6px solid var(--acc); } .final .body { top:530px; } .final .q { font-size:74px; } .cta { margin-top:48px; display:flex; align-items:center; gap:26px; } .btn { padding:22px 40px; border-radius:40px; background:var(--acc); color:var(--accink); font-size:34px; font-weight:700; } .handle { font-size:32px; } .logo { position:absolute; left:64px; bottom:110px; width:380px; } """ def visual(kind, chips): if kind == "chart-down": return ('' '') if kind == "chart-up": return ('' '') if kind == "network": pts = [(120, 210), (360, 80), (360, 340), (600, 210), (840, 80), (840, 340)] lines = [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (1, 4)] l = "".join(f'' for a, b in lines) c = "".join(f'' for i, (x, y) in enumerate(pts)) return f'{l}{c}' if kind == "ring" and chips: import math n = len(chips) out = ['
'] for i, ch in enumerate(chips): ang = -math.pi / 2 + 2 * math.pi * i / n x, y = 476 + 270 * math.cos(ang), 260 + 230 * math.sin(ang) out.append(f'
{esc(ch)}
') out.append("
") return "".join(out) return "" def build(spec, d): pal = PALETTES[spec.get("palette", "carbon")] n = len(spec["slides"]) total = n * SLIDE mark = "shkor-labs-icon-lime.svg" sec, js = [], [] def IN(sel, at, frm="{ opacity: 0, y: 36 }", dur=0.6): js.append(f'tl.fromTo("{sel}", {frm}, {{ opacity: 1, x: 0, y: 0, scale: 1, duration: {dur}, ease: "power3.out" }}, {at:.2f});') for i, s in enumerate(spec["slides"]): a, sid, t = i * SLIDE, f"s{i + 1}", s["type"] top = (f'
{"" if t == "final" else f""}' f'
{i + 1:02d} / {n:02d}
') if t == "final": top = top.replace('class="top"', 'class="top" style="justify-content:flex-end"') kick = f'
{rich(s["kicker"])}
' if s.get("kicker") else "" photo = f'
' if s.get("photo") else "" inner = "" if t == "cover": fs = 100 if len(s["title"]) <= 40 else 84 inner = (f'{photo}
{top}
{kick}
{rich(s["title"])}
' f'
' f'
cil.byswipe →
') js.append(f'tl.fromTo("#{sid}-p", {{ scale: 1.12 }}, {{ scale: 1, duration: {SLIDE - 0.1}, ease: "power2.out" }}, {a:.2f});') IN(f"#{sid}-q", a + 0.4); IN(f"#{sid}-b", a + 1.0, "{ opacity: 0, y: 16 }", 0.5) elif t == "quote": inner = (f'{top}
{kick}
{rich(s["text"])}
' f'
{rich(s.get("sub", ""))}
{visual(s.get("visual"), s.get("chips"))}') IN(f"#{sid}-q", a + 0.2); IN(f"#{sid}-s", a + 0.9, "{ opacity: 0, y: 16 }", 0.5) if s.get("visual") in ("chart-down", "chart-up"): js.append(f'document.querySelectorAll("#{sid} .draw").forEach(p => {{ const L = p.getTotalLength(); p.style.strokeDasharray = L; p.style.strokeDashoffset = L; }});') js.append(f'tl.to("#{sid} .draw", {{ strokeDashoffset: 0, duration: 1.4, ease: "power2.inOut" }}, {a + 0.6:.2f});') elif s.get("visual") == "network": js.append(f'tl.fromTo("#{sid} .nc", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.3, stagger: 0.1 }}, {a + 0.6:.2f});') js.append(f'tl.fromTo("#{sid} .nl", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.3, stagger: 0.08 }}, {a + 1.0:.2f});') elif s.get("visual") == "ring": js.append(f'tl.fromTo("#{sid} .chip", {{ opacity: 0, scale: 0.6 }}, {{ opacity: 1, scale: 1, duration: 0.4, stagger: 0.1, ease: "back.out(2)" }}, {a + 0.6:.2f});') elif t in ("phototop", "photoside"): dots = "" if s.get("dots"): dots = '
' % sid inner = (f'{photo}
{top}
{kick}
{rich(s["text"])}
' f'{dots}
{rich(s.get("sub", ""))}
') if t == "phototop": js.append(f'tl.fromTo("#{sid}-p", {{ scale: 1.1 }}, {{ scale: 1, duration: {SLIDE - 0.1}, ease: "power2.out" }}, {a:.2f});') else: IN(f"#{sid}-p", a, "{ opacity: 0, x: 80 }", 0.8) IN(f"#{sid}-q", a + 0.3); IN(f"#{sid}-s", a + 1.0, "{ opacity: 0, y: 16 }", 0.5) if dots: js.append(f'tl.fromTo("#{sid}-d i", {{ opacity: 0, scale: 0.4 }}, {{ opacity: 1, scale: 1, duration: 0.3, stagger: 0.1, ease: "back.out(2)" }}, {a + 0.8:.2f});') elif t == "stat": size = min(300, int(900 / max(1, 0.62 * len(str(s["value"]))))) inner = (f'{top}
{kick}
{rich(s["value"])}
' f'
{rich(s["label"])}
{rich(s.get("sub", ""))}
') IN(f"#{sid}-v", a + 0.2, "{ opacity: 0, scale: 0.7 }", 0.7); IN(f"#{sid}-l", a + 0.7); IN(f"#{sid}-s", a + 1.1, "{ opacity: 0, y: 16 }", 0.5) elif t == "list": items = "".join(f'
{j + 1:02d}{rich(it)}
' for j, it in enumerate(s["items"])) inner = f'{top}
{kick}
{rich(s["title"])}
{items}
' IN(f"#{sid}-q", a + 0.2) for j in range(len(s["items"])): IN(f"#{sid}-i{j}", a + 0.7 + 0.25 * j, "{ opacity: 0, x: -40 }", 0.45) elif t == "formula": parts = [] for j, p in enumerate(s["parts"]): if j: parts.append(f'
×
') parts.append(f'
{rich(p)}
') inner = f'{top}
{kick}
{"".join(parts)}
{rich(s.get("sub", ""))}
' for j in range(len(s["parts"])): IN(f"#{sid}-p{j}", a + 0.2 + 0.35 * j, "{ opacity: 0, x: -50 }", 0.5) IN(f"#{sid}-s", a + 0.4 + 0.35 * len(s["parts"]), "{ opacity: 0, y: 16 }", 0.5) elif t == "final": inner = (f'{photo}{top}
{rich(s["text"])}
' f'
{esc(s.get("cta", "Start at cil.by"))}
' f'
{esc(spec.get("handle", "@alexshkor"))}
' f'') IN(f"#{sid}-p", a + 0.1, "{ opacity: 0, scale: 0.8 }", 0.6); IN(f"#{sid}-q", a + 0.4) IN(f"#{sid}-c", a + 1.1, "{ opacity: 0, y: 16 }", 0.5); IN(f"#{sid}-logo", a + 1.5, "{ opacity: 0 }", 0.6) else: raise ValueError(t) if s.get("kicker"): js.append(f'tl.fromTo("#{sid}-k", {{ opacity: 0 }}, {{ opacity: 1, duration: 0.4 }}, {a + 0.1:.2f});') sec.append(f'
{inner}
') root = ";".join(f"--{k}:{v}" for k, v in pal.items() if k not in ("em", "logo")) return f""" {esc(spec.get("title", "Carousel"))}
{"".join(sec)}
""" def main(): d = os.path.abspath(sys.argv[1]) spec = json.load(open(os.path.join(d, "spec.json"))) for sub in ("fonts",): if not os.path.exists(os.path.join(d, sub)): shutil.copytree(FONTS, os.path.join(d, sub)) os.makedirs(os.path.join(d, "img"), exist_ok=True) for f in ("shkor-labs-icon-lime.svg", "shkor-labs-logo-dark-bg.svg", "shkor-labs-logo-light.svg"): shutil.copy(os.path.join(BRAND, f), os.path.join(d, "img", f)) for f in ("gsap.min.js", "hyperframes.json", "meta.json", "package.json"): if not os.path.exists(os.path.join(d, f)): shutil.copy(os.path.join(ASSETS, f), d) open(os.path.join(d, "index.html"), "w").write(build(spec, d)) n = len(spec["slides"]) at = ",".join(f"{(i + 1) * SLIDE - 0.2:.2f}" for i in range(n)) out = os.path.join(d, "slides") shutil.rmtree(out, ignore_errors=True) subprocess.run(HF + ["snapshot", "-o", out, "--at", at, "--no-end", "--describe", "false"], cwd=d, check=True, stdin=subprocess.DEVNULL, capture_output=True) frames = sorted(f for f in os.listdir(out) if f.startswith("frame-")) for i, f in enumerate(frames): os.rename(os.path.join(out, f), os.path.join(out, f"slide-{i + 1:02d}.png")) if "--render" in sys.argv: subprocess.run(HF + ["render", "-o", os.path.join(d, "animated.mp4")], cwd=d, check=True, stdin=subprocess.DEVNULL, capture_output=True) print(out) if __name__ == "__main__": main()