|
# /// script |
|
# requires-python = ">=3.11" |
|
# dependencies = ["playwright", "google-cloud-texttospeech"] |
|
# /// |
|
|
|
""" |
|
Render a narrated tour video from a JSON spec. |
|
|
|
Usage: |
|
python render_tour.py '<json_data>' [output.mp4] |
|
|
|
JSON spec: |
|
{ |
|
"title": "Feature Name", |
|
"bullets": ["Point 1", "Point 2"], |
|
"metric": "42%", |
|
"metricLabel": "improvement", |
|
"narration": "Text to speak as voiceover.", |
|
"voice": "en-US-Journey-D" // optional, defaults to en-US-Journey-D |
|
} |
|
|
|
Requires: |
|
- Google Cloud TTS credentials (GOOGLE_APPLICATION_CREDENTIALS) |
|
- ffmpeg + ffprobe on PATH (or set FFMPEG_PATH / FFPROBE_PATH) |
|
- Playwright with Chromium installed |
|
""" |
|
|
|
import asyncio |
|
import json |
|
import os |
|
import subprocess |
|
import sys |
|
from playwright.async_api import async_playwright |
|
|
|
|
|
FFMPEG = os.environ.get("FFMPEG_PATH", "ffmpeg") |
|
FFPROBE = os.environ.get("FFPROBE_PATH", "ffprobe") |
|
|
|
TEMPLATE = """ |
|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<style> |
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
body { |
|
width: 1280px; height: 720px; |
|
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%); |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; |
|
color: #fff; |
|
overflow: hidden; |
|
display: flex; |
|
align-items: center; |
|
} |
|
.container { padding: 60px 80px; width: 100%; } |
|
.badge { |
|
display: inline-block; |
|
background: #22c55e; |
|
color: #000; |
|
padding: 6px 16px; |
|
border-radius: 20px; |
|
font-size: 14px; |
|
font-weight: 700; |
|
text-transform: uppercase; |
|
letter-spacing: 1px; |
|
margin-bottom: 24px; |
|
} |
|
h1 { |
|
font-size: 52px; |
|
font-weight: 800; |
|
line-height: 1.1; |
|
margin-bottom: 40px; |
|
} |
|
.bullet { |
|
font-size: 26px; |
|
color: #b0b0b0; |
|
margin-bottom: 18px; |
|
padding-left: 24px; |
|
position: relative; |
|
} |
|
.bullet::before { |
|
content: '→'; |
|
position: absolute; |
|
left: 0; |
|
color: #22c55e; |
|
} |
|
.metric { |
|
position: absolute; |
|
bottom: 60px; |
|
right: 80px; |
|
text-align: right; |
|
} |
|
.metric .value { |
|
font-size: 56px; |
|
font-weight: 800; |
|
color: #22c55e; |
|
} |
|
.metric .label { |
|
font-size: 16px; |
|
color: #888; |
|
text-transform: uppercase; |
|
letter-spacing: 2px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="badge" id="badge">COMPLETED</div> |
|
<h1 id="title"></h1> |
|
<div id="bullets"></div> |
|
<div class="metric" id="metric"> |
|
<div class="value" id="metricValue"></div> |
|
<div class="label" id="metricLabel"></div> |
|
</div> |
|
</div> |
|
<script> |
|
const data = __DATA__; |
|
document.getElementById('title').textContent = data.title; |
|
document.getElementById('metricValue').textContent = data.metric || ''; |
|
document.getElementById('metricLabel').textContent = data.metricLabel || ''; |
|
const bulletsEl = document.getElementById('bullets'); |
|
data.bullets.forEach(b => { |
|
const div = document.createElement('div'); |
|
div.className = 'bullet'; |
|
div.textContent = b; |
|
bulletsEl.appendChild(div); |
|
}); |
|
</script> |
|
</body> |
|
</html> |
|
""" |
|
|
|
|
|
def generate_narration(text: str, output_path: str, voice_name: str = "en-US-Journey-D") -> float: |
|
"""Generate TTS narration via Google Cloud TTS. Returns duration in seconds.""" |
|
from google.cloud import texttospeech |
|
|
|
client = texttospeech.TextToSpeechClient() |
|
synthesis_input = texttospeech.SynthesisInput(text=text) |
|
voice = texttospeech.VoiceSelectionParams( |
|
language_code="en-US", |
|
name=voice_name, |
|
) |
|
audio_config = texttospeech.AudioConfig( |
|
audio_encoding=texttospeech.AudioEncoding.MP3, |
|
speaking_rate=1.05, |
|
) |
|
response = client.synthesize_speech( |
|
input=synthesis_input, voice=voice, audio_config=audio_config |
|
) |
|
with open(output_path, "wb") as f: |
|
f.write(response.audio_content) |
|
|
|
result = subprocess.run( |
|
[FFPROBE, "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", output_path], |
|
capture_output=True, text=True, |
|
) |
|
return float(result.stdout.strip()) |
|
|
|
|
|
async def render_frames(data: dict, duration_secs: float, fps: int = 30) -> str: |
|
"""Render animated HTML frames via Playwright. Returns frames directory path.""" |
|
total_frames = int(fps * duration_secs) |
|
frames_dir = "/tmp/tour-frames" |
|
os.makedirs(frames_dir, exist_ok=True) |
|
|
|
for f in os.listdir(frames_dir): |
|
os.remove(os.path.join(frames_dir, f)) |
|
|
|
html = TEMPLATE.replace("__DATA__", json.dumps(data)) |
|
html_path = "/tmp/tour-template.html" |
|
with open(html_path, "w") as f: |
|
f.write(html) |
|
|
|
async with async_playwright() as p: |
|
browser = await p.chromium.launch(headless=True) |
|
page = await browser.new_page(viewport={"width": 1280, "height": 720}) |
|
await page.goto(f"file://{html_path}") |
|
await page.wait_for_timeout(200) |
|
|
|
num_bullets = len(data.get("bullets", [])) |
|
|
|
for frame_num in range(total_frames): |
|
t = frame_num / total_frames |
|
|
|
badge_opacity = min(1, max(0, (t - 0.0) * 10)) |
|
title_opacity = min(1, max(0, (t - 0.05) * 8)) |
|
title_offset = max(0, 20 * (1 - min(1, (t - 0.05) * 8))) |
|
metric_opacity = min(1, max(0, (t - 0.7) * 5)) |
|
|
|
bullet_js = "" |
|
for i in range(num_bullets): |
|
bt_start = 0.15 + (i * 0.5 / max(1, num_bullets)) |
|
bt = max(0, (t - bt_start) * 8) |
|
bullet_js += f""" |
|
bullets[{i}].style.opacity = Math.min(1, {bt}); |
|
bullets[{i}].style.transform = 'translateX(' + Math.max(0, 15 * (1 - Math.min(1, {bt}))) + 'px)'; |
|
""" |
|
|
|
await page.evaluate(f""" |
|
document.getElementById('badge').style.opacity = {badge_opacity}; |
|
document.getElementById('title').style.opacity = {title_opacity}; |
|
document.getElementById('title').style.transform = 'translateY({title_offset}px)'; |
|
document.getElementById('metric').style.opacity = {metric_opacity}; |
|
const bullets = document.querySelectorAll('.bullet'); |
|
{bullet_js} |
|
""") |
|
|
|
await page.screenshot(path=f"{frames_dir}/frame_{frame_num:04d}.png") |
|
|
|
await browser.close() |
|
|
|
return frames_dir |
|
|
|
|
|
def stitch_video(frames_dir: str, audio_path: str | None, output_path: str, fps: int = 30): |
|
"""Stitch frames into video, optionally with audio.""" |
|
if audio_path and os.path.exists(audio_path): |
|
cmd = [ |
|
FFMPEG, "-y", |
|
"-framerate", str(fps), |
|
"-i", f"{frames_dir}/frame_%04d.png", |
|
"-i", audio_path, |
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", |
|
"-preset", "fast", "-crf", "23", |
|
"-c:a", "aac", "-b:a", "128k", |
|
"-shortest", |
|
output_path, |
|
] |
|
else: |
|
cmd = [ |
|
FFMPEG, "-y", |
|
"-framerate", str(fps), |
|
"-i", f"{frames_dir}/frame_%04d.png", |
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", |
|
"-preset", "fast", "-crf", "23", |
|
output_path, |
|
] |
|
|
|
subprocess.run(cmd, check=True, capture_output=True) |
|
size = os.path.getsize(output_path) |
|
print(f"Output: {output_path} ({size / 1024:.0f}KB)") |
|
|
|
|
|
async def main(): |
|
data = json.loads(sys.argv[1]) if len(sys.argv) > 1 else { |
|
"title": "Example Tour", |
|
"bullets": [ |
|
"First key point", |
|
"Second key point", |
|
"Third key point", |
|
], |
|
"metric": "100%", |
|
"metricLabel": "complete", |
|
"narration": "This is an example tour video. It demonstrates animated title cards with bullet points and a highlighted metric.", |
|
} |
|
output = sys.argv[2] if len(sys.argv) > 2 else "/tmp/tour-output.mp4" |
|
|
|
audio_path = None |
|
duration = 5.0 |
|
|
|
narration = data.get("narration") |
|
if narration: |
|
print("Generating narration...") |
|
audio_path = "/tmp/tour-narration.mp3" |
|
duration = generate_narration(narration, audio_path, |
|
voice_name=data.get("voice", "en-US-Journey-D")) |
|
print(f"Narration: {duration:.1f}s") |
|
duration += 1.0 |
|
|
|
print(f"Rendering {int(duration * 30)} frames...") |
|
frames_dir = await render_frames(data, duration) |
|
|
|
print("Stitching video...") |
|
stitch_video(frames_dir, audio_path, output) |
|
|
|
print("Done!") |
|
|
|
|
|
if __name__ == "__main__": |
|
asyncio.run(main()) |