#!/usr/bin/env python3
"""Ролик Shkor Labs для сайта (shkor.com, секция Film): сценарий → озвучка Kokoro → hyperframes → MP4.
venv/bin/python video/shkor-labs-site/build.py # озвучка (с кэшем) + index.html + check + рендер
venv/bin/python video/shkor-labs-site/build.py --no-render
Голос и обработка — как у ежедневных роликов (video/daily/daily.py: tts по предложениям, am_michael ×1.1,
loudnorm), музыка — подложка из video/daily/music. Длины сцен и появление элементов считаются по
таймингам фраз (template.tpl: at(сцена, фраза, доля)), поэтому правка текста не ломает синхронизацию.
Факты в тексте — только то, что есть на shkor.com (метод, ритмы, доверие, лабы, тарифы).
"""
import argparse
import hashlib
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, "..", ".."))
sys.path.insert(0, os.path.join(REPO, "video", "daily"))
import daily # noqa: E402 (tts, bgm_bed, sh, HF, VOICE, SPEED)
OUT = os.path.join(REPO, "out", "shkor-labs-film-en.mp4")
THEME = "gold" # музыка как у enterprise-EN, который понравился Alex
SCENES = [
"Every business runs on routine that nobody has time for. Shkor Labs builds the autopilot for it.",
"It starts with a mirror. Messages, tasks, calls and payments become one signed stream of events, "
"a living twin of the company. Every change is rehearsed on the twin before it touches a customer.",
"Then the routine goes to an agent that runs on a heartbeat. "
"Every second, minute, hour and day: reflexes, triage, execution and planning.",
"And you can trust it with real work. Every step is signed. "
"Its rights come from verified identity, not from a message. "
"When it isn't sure, it hands the decision to a person, with the full story.",
"We don't sell what we don't run. Shkor Labs is a network of real businesses inside Cooperative 42, "
"each one a founder plus AI agents, testing the autopilot every day.",
"Build it yourself with Autopilot: your own server, an agent, and a workspace with OpenCode and Claude Code, "
"for ninety-nine dollars a month. Or build it with us in the Lab. "
"And builders with traction get paid roles in the studio.",
"Shkor Labs. Autopilot for any business. Start at shkor dot com.",
]
# произношение для синтезатора (текст сценария не меняется): espeak читает «Shkor» как
# «эс-эйч-кор» (ˌɛsˈeɪtʃkˈɔːɹ), а «Schkor» — как надо, одним слогом (ʃkˈɔːɹ)
PRONOUNCE = {r"\bShkor\b": "Schkor", r"\bshkor\b": "schkor"}
# сколько фраз ждёт шаблон в каждой сцене (at(i, k) берёт k-ю фразу)
EXPECT = [2, 3, 2, 4, 2, 3, 3]
def voice(i, text, work):
"""Озвучка сцены с кэшем по тексту/голосу: неизменённые сцены не синтезируются заново."""
for pat, rep in PRONOUNCE.items():
text = re.sub(pat, rep, text)
wav = os.path.join(HERE, "assets", "vo", f"v{i + 1}.wav")
key = hashlib.sha256(f"{daily.VOICE}|{daily.SPEED}|{text}".encode()).hexdigest()[:16]
meta = wav + ".json"
if os.path.exists(wav) and os.path.exists(meta):
m = json.load(open(meta))
if m.get("key") == key:
return m["dur"], m["sents"]
dur, sents = daily.tts(text, wav, work)
json.dump({"key": key, "dur": dur, "sents": sents}, open(meta, "w"))
return dur, sents
def build():
work = os.path.join(HERE, ".work")
os.makedirs(work, exist_ok=True)
os.makedirs(os.path.join(HERE, "assets", "vo"), exist_ok=True)
os.makedirs(os.path.join(HERE, "assets", "bgm"), exist_ok=True)
timing, t = [], 0.0
for i, text in enumerate(SCENES):
dur, sents = voice(i, text, work)
if len(sents) != EXPECT[i]:
raise SystemExit(f"сцена {i + 1}: {len(sents)} фраз, шаблон ждёт {EXPECT[i]} — поправь текст или EXPECT")
lead, tail = 0.45, (2.8 if i == len(SCENES) - 1 else 0.75)
vo = t + lead
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)} for x in sents]})
daily.log(f"scene {i + 1}: vo {dur:.1f}s, {len(sents)} sentences")
t += lead + dur + tail
total = round(t, 2)
daily.bgm_bed(total, os.path.join(HERE, "assets", "bgm", "bed.wav"), THEME)
audio = [f' ']
for i, tm in enumerate(timing):
audio.append(f' ')
html = open(os.path.join(HERE, "template.tpl")).read()
for i, tm in enumerate(timing):
html = html.replace(f"@S{i + 1}_START", str(tm["start"])).replace(f"@S{i + 1}_DUR", str(tm["dur"]))
html = html.replace("@AUDIO", "\n".join(audio)).replace("@TIMING", json.dumps(timing)).replace("@TOTAL", str(total))
open(os.path.join(HERE, "index.html"), "w").write(html)
json.dump({"total": total, "scenes": timing}, open(os.path.join(work, "timing.json"), "w"), indent=1)
daily.log(f"index.html: {total:.1f}s")
return total
def check():
out = subprocess.run(daily.HF + ["check", "--json"], cwd=HERE, capture_output=True, text=True, timeout=900,
stdin=subprocess.DEVNULL).stdout
rep = json.loads(out[out.index("{"):])
summary = {s: {"errors": (rep.get(s) or {}).get("errorCount", 0), "warnings": (rep.get(s) or {}).get("warningCount", 0)}
for s in ("lint", "runtime", "layout", "motion", "contrast")}
findings = []
for s in ("lint", "runtime", "layout", "motion", "contrast"):
for f in (rep.get(s) or {}).get("findings", [])[:12]:
findings.append(f"{s}: {str(f.get('severity') or '')} {str(f.get('code') or f.get('rule') or '')} "
f"{str(f.get('message') or '')[:170]}")
return summary, findings
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--no-render", action="store_true")
args = ap.parse_args()
build()
summary, findings = check()
print(json.dumps(summary))
print("\n".join(findings))
if any(v["errors"] for v in summary.values()):
raise SystemExit("hyperframes check: есть ошибки — рендер не запускаю")
if not args.no_render:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
daily.render(HERE, OUT)
daily.log(f"render: {OUT}")