127 lines
7.1 KiB
Python
127 lines
7.1 KiB
Python
#!/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}")
|