simbiot-harness/video/shkor-labs-site/build.py

127 lines
7.1 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
"""Ролик 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' <audio id="bgm" src="assets/bgm/bed.wav" data-start="0" data-duration="{total}" data-track-index="3" data-volume="0.14"></audio>']
for i, tm in enumerate(timing):
audio.append(f' <audio id="vo{i + 1}" src="assets/vo/v{i + 1}.wav" data-start="{tm["vo"]}" '
f'data-duration="{tm["vo_dur"]}" data-track-index="4" data-volume="1"></audio>')
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}")