#!/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}")