simbiot-harness/video/carousels/make.py
Simbiot Harness 95b77f2839 карусели: генератор make.py (spec.json → hyperframes → PNG) + 4 карусели и 9 сгенерированных фото Alex
- make.py: 8 типов слайдов (cover/quote/phototop/photoside/stat/list/formula/final),
  палитры carbon и bone в стиле Shkor Labs;
- studio-orchestration, production-lessons — настоящие фото (ч/б);
  principle-zero, govern-agents — фото, сгенерированные Kling (Nano Banana Pro) по референсам;
- тезисы — из 42_Harness_Deck, Shkor_Labs_Deck, Simbiot Whitepaper, COOP_CONTEXT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:03:19 +00:00

294 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Карусель из spec.json → hyperframes-композиция 1080×1350 → PNG-слайды (+ анимированный mp4).
python3 video/carousels/make.py video/carousels/<slug> # собрать + снапшоты
python3 video/carousels/make.py video/carousels/<slug> --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"<em>{esc(p)}</em>" 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"&lt;{tag}&gt;", 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 ('<svg class="vis" viewBox="0 0 952 420"><line x1="0" y1="400" x2="952" y2="400" stroke="var(--line)" stroke-width="2"/>'
'<path class="draw" d="M0 30 C 160 40, 240 130, 360 200 S 600 340, 952 386" fill="none" stroke="var(--acc)" stroke-width="6" stroke-linecap="round"/></svg>')
if kind == "chart-up":
return ('<svg class="vis" viewBox="0 0 952 420"><line x1="0" y1="400" x2="952" y2="400" stroke="var(--line)" stroke-width="2"/>'
'<path class="draw" d="M0 380 C 220 370, 360 330, 520 250 S 800 60, 952 30" fill="none" stroke="var(--acc)" stroke-width="6" stroke-linecap="round"/></svg>')
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'<line class="nl" x1="{pts[a][0]}" y1="{pts[a][1]}" x2="{pts[b][0]}" y2="{pts[b][1]}" stroke="var(--acc)" stroke-opacity="0.6" stroke-width="3"/>' for a, b in lines)
c = "".join(f'<circle class="nc" cx="{x}" cy="{y}" r="32" fill="{"var(--ink)" if i == 0 else "var(--acc)" if i == 3 else "none"}" stroke="var(--acc)" stroke-width="6"/>' for i, (x, y) in enumerate(pts))
return f'<svg class="vis" viewBox="0 0 952 420">{l}{c}</svg>'
if kind == "ring" and chips:
import math
n = len(chips)
out = ['<div class="vis" style="height:520px"><div style="position:absolute;left:206px;top:10px;width:540px;height:500px;border:2px dashed var(--acc);border-radius:50%;opacity:0.6"></div>']
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'<div class="chip" style="position:absolute;left:{x - 95:.0f}px;top:{y - 28:.0f}px;width:190px;height:56px;border-radius:28px;'
f'background:var(--acc);color:var(--accink);font-size:23px;font-weight:700;display:flex;align-items:center;justify-content:center">{esc(ch)}</div>')
out.append("</div>")
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'<div class="top">{"" if t == "final" else f"<img class=mark src=img/{mark} alt=>"}'
f'<div class="count mono"><b>{i + 1:02d}</b> / {n:02d}</div></div>')
if t == "final":
top = top.replace('class="top"', 'class="top" style="justify-content:flex-end"')
kick = f'<div class="kick mono" id="{sid}-k">{rich(s["kicker"])}</div>' if s.get("kicker") else ""
photo = f'<div class="photo" id="{sid}-p"><img src="{esc(s["photo"])}" alt="" style="object-position:{s.get("pos", "50% 25%")}"/></div>' if s.get("photo") else ""
inner = ""
if t == "cover":
fs = 100 if len(s["title"]) <= 40 else 84
inner = (f'{photo}<div class="fadeb"></div>{top}<div class="body">{kick}<div class="q" id="{sid}-q" style="font-size:{fs}px">{rich(s["title"])}</div>'
f'<div class="byline" id="{sid}-b">{rich(s.get("byline", ""))}</div></div>'
f'<div class="foot mono"><span>cil.by</span><span class="swipe">swipe →</span></div>')
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}<div class="body">{kick}<div class="q" id="{sid}-q">{rich(s["text"])}</div>'
f'<div class="sub" id="{sid}-s">{rich(s.get("sub", ""))}</div></div>{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 = '<div class="dots" id="%s-d"><i class="h"></i><i class="h"></i><i class="h"></i><i class="a"></i><i class="a"></i><i class="a"></i></div>' % sid
inner = (f'{photo}<div class="fadeb"></div>{top}<div class="body">{kick}<div class="q" id="{sid}-q">{rich(s["text"])}</div>'
f'{dots}<div class="sub" id="{sid}-s">{rich(s.get("sub", ""))}</div></div>')
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}<div class="body">{kick}<div class="big" id="{sid}-v" style="font-size:{size}px">{rich(s["value"])}</div>'
f'<div class="big-l" id="{sid}-l">{rich(s["label"])}</div><div class="sub" id="{sid}-s">{rich(s.get("sub", ""))}</div></div>')
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'<div class="item" id="{sid}-i{j}"><b>{j + 1:02d}</b><span>{rich(it)}</span></div>' for j, it in enumerate(s["items"]))
inner = f'{top}<div class="body">{kick}<div class="q" id="{sid}-q">{rich(s["title"])}</div><div class="items">{items}</div></div>'
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'<div class="op" id="{sid}-o{j}">×</div>')
parts.append(f'<div class="part" id="{sid}-p{j}">{rich(p)}</div>')
inner = f'{top}<div class="body">{kick}<div class="parts">{"".join(parts)}</div><div class="sub" id="{sid}-s">{rich(s.get("sub", ""))}</div></div>'
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}<div class="body"><div class="q" id="{sid}-q">{rich(s["text"])}</div>'
f'<div class="cta" id="{sid}-c"><div class="btn">{esc(s.get("cta", "Start at cil.by"))}</div>'
f'<div class="handle mono">{esc(spec.get("handle", "@alexshkor"))}</div></div></div>'
f'<img class="logo" id="{sid}-logo" src="img/{pal["logo"]}" alt=""/>')
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'<section id="{sid}" class="clip slide {t}" data-start="{a:.2f}" data-duration="{SLIDE}" data-track-index="1">{inner}</section>')
root = ";".join(f"--{k}:{v}" for k, v in pal.items() if k not in ("em", "logo"))
return f"""<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=1080, height=1350" />
<title>{esc(spec.get("title", "Carousel"))}</title><script src="./gsap.min.js"></script>
<style>{CSS}
:root {{ {root} }}
em {{ {pal["em"]} }}
</style></head><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="{total}" data-width="1080" data-height="1350">
<div id="bg" class="clip" data-start="0" data-duration="{total}" data-track-index="0"><div id="bg-grid"></div></div>
{"".join(sec)}
</div>
<script>
const tl = gsap.timeline({{ paused: true }});
tl.fromTo("#bg-grid", {{ y: 0 }}, {{ y: 90, duration: {total}, ease: "none" }}, 0);
{chr(10).join(js)}
window.__timelines = window.__timelines || {{}};
window.__timelines["main"] = tl;
</script></body></html>
"""
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()