Files
blackroad/bin/br-video-create
Alexa Amundson 78fbe80f2a Initial monorepo — everything BlackRoad in one place
bin/       230 CLI tools (ask-*, br-*, agent-*, roadid, carpool)
scripts/   99 automation scripts
fleet/     Node configs and deployment
workers/   Cloudflare Worker sources (roadpay, road-search, squad webhooks)
roadc/     RoadC programming language
roadnet/   Mesh network (5 APs, WireGuard)
operator/  Memory system scripts
config/    System configs
dotfiles/  Shell configs
docs/      Documentation

BlackRoad OS — Pave Tomorrow.

RoadChain-SHA2048: d1a24f55318d338b
RoadChain-Identity: alexa@sovereign
RoadChain-Full: d1a24f55318d338b24b60bad7be39286379c76ae5470817482100cb0ddbbcb97e147d07ac7243da0a9f0363e4e5c833d612b9c0df3a3cd20802465420278ef74875a5b77f55af6fe42a931b8b635b3d0d0b6bde9abf33dc42eea52bc03c951406d8cbe49f1a3d29b26a94dade05e9477f34a7d4d4c6ec4005c3c2ac54e73a68440c512c8e83fd9b1fe234750b898ef8f4032c23db173961fe225e67a0432b5293a9714f76c5c57ed5fdf35b9fb40fd73c03ebf88b7253c6a0575f5afb6a6b49b3bda310602fb1ef676859962dad2aebbb2875814b30eee0a8ba195e482d4cbc91d8819e7f38f6db53e8063401649c77bb994371473cabfb917fb53e8cbe73d60
2026-03-14 17:08:41 -05:00

888 lines
36 KiB
Python
Executable File

#!/usr/bin/env python3
"""
br-video-create — BlackRoad Animated Video Generator
Usage:
br-video-create <config.json> [output.html]
br-video-create --interactive
br-video-create --example
Reads a JSON config defining scenes and generates a complete
animated HTML video following BRAND-LOCK design rules.
"""
import json
import sys
import os
import textwrap
from pathlib import Path
from datetime import datetime
# ── BlackRoad Design Tokens ─────────────────────────────────────────────
GRADIENT = "linear-gradient(90deg,#FF6B2B,#FF2255,#CC00AA,#8844FF,#4488FF,#00D4FF)"
FONTS_URL = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400&display=swap"
EXAMPLE_CONFIG = {
"title": "My Awesome Video",
"aspect": "16:9",
"background": "particles",
"scenes": [
{
"type": "title",
"pretitle": "INTRODUCING",
"title": "Something Amazing",
"subtitle": "Built from scratch.",
"duration": 4000
},
{
"type": "quote",
"text": "The best way to predict the future is to build it.",
"attribution": "— ALAN KAY",
"duration": 5000
},
{
"type": "stats",
"heading": "BY THE NUMBERS",
"items": [
{"value": "10K", "label": "Users"},
{"value": "99.9%", "label": "Uptime"},
{"value": "50ms", "label": "Latency"},
{"value": "24/7", "label": "Support"}
],
"duration": 5000
},
{
"type": "bullets",
"heading": "Why Us",
"items": [
"Zero cloud dependency",
"Open source everything",
"Built on sovereign hardware"
],
"duration": 5000
},
{
"type": "terminal",
"title": "demo@localhost — zsh",
"lines": [
{"type": "input", "text": "curl api.example.com/status"},
{"type": "success", "text": ' {"status": "operational", "uptime": "99.99%"}'},
{"type": "input", "text": "deploy --production"},
{"type": "info", "text": " Deployed to 3 regions in 2.4s"}
],
"duration": 7000
},
{
"type": "cta",
"title": "Get Started",
"subtitle": "example.com",
"duration": 4000
}
]
}
# ── Scene Types ──────────────────────────────────────────────────────────
def scene_title(s, idx):
pretitle = s.get("pretitle", "")
title = s.get("title", "Title")
subtitle = s.get("subtitle", "")
gradient_title = s.get("gradient", True)
title_class = ' class="grad"' if gradient_title else ''
pretitle_html = f'<div class="mono" style="font-size:13px;letter-spacing:.3em;color:rgba(255,255,255,.2);margin-bottom:16px">{esc(pretitle)}</div>' if pretitle else ''
subtitle_html = f'<div style="font-size:20px;color:rgba(255,255,255,.3);margin-top:16px;font-weight:300">{esc(subtitle)}</div>' if subtitle else ''
return f'''<div class="scene" id="s{idx}">
{pretitle_html}
<div style="font-size:64px;font-weight:700;text-align:center;line-height:1.15"{title_class}>{esc(title)}</div>
<div class="grad-line" style="width:150px;margin-top:24px"></div>
{subtitle_html}
</div>'''
def scene_subtitle(s, idx):
lines = s.get("lines", [s.get("text", "")])
size = s.get("size", 28)
html_lines = "<br>".join(esc(l) for l in lines)
return f'''<div class="scene" id="s{idx}">
<div style="text-align:center;max-width:1000px">
<div style="font-size:{size}px;color:rgba(255,255,255,.3);line-height:2">{html_lines}</div>
<div class="grad-line" style="width:200px;margin:32px auto"></div>
</div>
</div>'''
def scene_bigtext(s, idx):
text = s.get("text", "")
gradient = s.get("gradient", False)
size = s.get("size", 64)
cls = ' class="grad"' if gradient else ''
return f'''<div class="scene" id="s{idx}">
<div style="font-size:{size}px;font-weight:700;text-align:center;line-height:1.15"{cls}>{esc(text)}</div>
</div>'''
def scene_quote(s, idx):
text = s.get("text", "")
attr = s.get("attribution", "")
attr_html = f'<div class="quote-attr">{esc(attr)}</div>' if attr else ''
return f'''<div class="scene" id="s{idx}">
<div class="quote">"{esc(text)}"</div>
{attr_html}
</div>'''
def scene_stats(s, idx):
heading = s.get("heading", "")
items = s.get("items", [])
heading_html = f'<div class="mono" style="font-size:14px;letter-spacing:.2em;color:rgba(255,255,255,.2);margin-bottom:40px">{esc(heading)}</div>' if heading else ''
cards = []
for i, item in enumerate(items):
delay = i * 0.2
cards.append(f'''<div class="stat-card" style="transition-delay:{delay}s">
<div class="stat-value grad">{esc(str(item["value"]))}</div>
<div class="stat-label">{esc(item["label"])}</div>
</div>''')
return f'''<div class="scene" id="s{idx}" data-anim="stats">
{heading_html}
<div class="stats-grid">
{"".join(cards)}
</div>
</div>'''
def scene_bullets(s, idx):
heading = s.get("heading", "")
items = s.get("items", [])
icon = s.get("icon", "")
heading_html = f'<div style="font-size:36px;font-weight:600;margin-bottom:40px;text-align:center">{esc(heading)}</div>' if heading else ''
bullets = []
for i, item in enumerate(items):
delay = i * 0.3
bullets.append(f'<div class="bullet-item" style="transition-delay:{delay}s"><span class="bullet-icon grad">{esc(icon)}</span> {esc(item)}</div>')
return f'''<div class="scene" id="s{idx}" data-anim="bullets">
{heading_html}
<div class="bullet-list">
{"".join(bullets)}
</div>
</div>'''
def scene_cards(s, idx):
heading = s.get("heading", "")
items = s.get("items", [])
heading_html = f'<div class="mono" style="font-size:14px;letter-spacing:.2em;color:rgba(255,255,255,.2);margin-bottom:40px">{esc(heading)}</div>' if heading else ''
cards = []
for i, item in enumerate(items):
delay = i * 0.2
icon = item.get("icon", "")
name = item.get("name", "")
desc = item.get("description", "")
status = item.get("status", "")
status_cls = "on" if status.lower() in ("online", "active", "ok", "yes") else "off" if status else ""
status_html = f'<div class="card-status {status_cls}">{esc(status)}</div>' if status else ''
icon_html = f'<div class="card-icon">{icon}</div>' if icon else ''
cards.append(f'''<div class="info-card" style="transition-delay:{delay}s">
{icon_html}
<div class="card-body"><div class="card-name">{esc(name)}</div><div class="card-desc">{esc(desc)}</div></div>
{status_html}
</div>''')
return f'''<div class="scene" id="s{idx}" data-anim="cards">
{heading_html}
<div class="cards-list">
{"".join(cards)}
</div>
</div>'''
def scene_timeline(s, idx):
heading = s.get("heading", "THE TIMELINE")
items = s.get("items", [])
heading_html = f'<div class="mono" style="font-size:14px;letter-spacing:.2em;color:rgba(255,255,255,.2);margin-bottom:40px">{esc(heading)}</div>'
entries = []
for i, item in enumerate(items):
delay = i * 0.2
date = item.get("date", "")
event = item.get("event", "")
detail = item.get("detail", "")
detail_html = f'<div class="tl-detail">{esc(detail)}</div>' if detail else ''
entries.append(f'''<div class="tl-item" style="transition-delay:{delay}s">
<div class="tl-date">{esc(date)}</div>
<div class="tl-event">{esc(event)}</div>
{detail_html}
</div>''')
return f'''<div class="scene" id="s{idx}" data-anim="timeline">
{heading_html}
<div class="timeline">
{"".join(entries)}
</div>
</div>'''
def scene_terminal(s, idx):
title = s.get("title", "terminal")
lines = s.get("lines", [])
term_id = f"term{idx}"
lines_json = json.dumps(lines)
return f'''<div class="scene" id="s{idx}" data-anim="terminal" data-term="{term_id}" data-lines='{lines_json}'>
<div class="term-window">
<div class="term-bar">
<div class="term-dot r"></div><div class="term-dot y"></div><div class="term-dot g"></div>
<div class="term-title">{esc(title)}</div>
</div>
<div class="term-body" id="{term_id}"></div>
</div>
</div>'''
def scene_comparison(s, idx):
heading = s.get("heading", "")
left_title = s.get("left_title", "Before")
right_title = s.get("right_title", "After")
left = s.get("left", [])
right = s.get("right", [])
heading_html = f'<div style="font-size:36px;font-weight:600;margin-bottom:40px;text-align:center">{esc(heading)}</div>' if heading else ''
left_items = "".join(f'<div class="cmp-item bad" style="transition-delay:{i*0.15}s"><span class="cmp-x">✗</span> {esc(x)}</div>' for i, x in enumerate(left))
right_items = "".join(f'<div class="cmp-item good" style="transition-delay:{i*0.15+0.3}s"><span class="cmp-check">✓</span> {esc(x)}</div>' for i, x in enumerate(right))
return f'''<div class="scene" id="s{idx}" data-anim="comparison">
{heading_html}
<div class="compare">
<div class="compare-col">
<div class="compare-head" style="color:rgba(255,255,255,.3)">{esc(left_title)}</div>
{left_items}
</div>
<div class="compare-divider"></div>
<div class="compare-col">
<div class="compare-head grad">{esc(right_title)}</div>
{right_items}
</div>
</div>
</div>'''
def scene_code(s, idx):
code = s.get("code", "")
lang = s.get("language", "")
caption = s.get("caption", "")
caption_html = f'<div class="mono" style="font-size:13px;color:rgba(255,255,255,.2);margin-top:16px;letter-spacing:.1em">{esc(caption)}</div>' if caption else ''
return f'''<div class="scene" id="s{idx}">
<div class="code-block">
<div class="code-lang">{esc(lang)}</div>
<pre class="code-pre">{esc(code)}</pre>
</div>
{caption_html}
</div>'''
def scene_split(s, idx):
"""Left text, right visual (big number, icon, or gradient text)"""
left_lines = s.get("left", [])
right_text = s.get("right", "")
right_gradient = s.get("right_gradient", True)
left_html = "".join(f'<div style="font-size:24px;color:rgba(255,255,255,.4);line-height:2">{esc(l)}</div>' for l in left_lines)
right_cls = ' class="grad"' if right_gradient else ''
return f'''<div class="scene" id="s{idx}">
<div class="split-layout">
<div class="split-left">{left_html}</div>
<div class="split-right"><div style="font-size:72px;font-weight:700"{right_cls}>{esc(right_text)}</div></div>
</div>
</div>'''
def scene_flow(s, idx):
"""Horizontal pipeline / data flow"""
stages = s.get("stages", [])
heading = s.get("heading", "")
heading_html = f'<div style="font-size:36px;font-weight:600;margin-bottom:48px;text-align:center">{esc(heading)}</div>' if heading else ''
stage_html = []
for i, st in enumerate(stages):
delay = i * 0.3
icon = st.get("icon", "")
label = st.get("label", "")
stage_html.append(f'<div class="flow-stage" style="transition-delay:{delay}s"><div class="flow-icon">{icon}</div><div class="flow-label">{esc(label)}</div></div>')
if i < len(stages) - 1:
stage_html.append('<div class="flow-arrow">→</div>')
return f'''<div class="scene" id="s{idx}" data-anim="flow">
{heading_html}
<div class="flow-row">
{"".join(stage_html)}
</div>
</div>'''
def scene_metrics(s, idx):
"""Grid of metric tiles"""
items = s.get("items", [])
heading = s.get("heading", "")
heading_html = f'<div class="mono" style="font-size:14px;letter-spacing:.2em;color:rgba(255,255,255,.2);margin-bottom:40px">{esc(heading)}</div>' if heading else ''
tiles = []
for i, item in enumerate(items):
delay = i * 0.12
tiles.append(f'''<div class="metric-tile" style="transition-delay:{delay}s">
<div class="mv">{esc(str(item.get("value","")))}</div>
<div class="ml">{esc(item.get("label",""))}</div>
</div>''')
return f'''<div class="scene" id="s{idx}" data-anim="metrics">
{heading_html}
<div class="metrics-grid">
{"".join(tiles)}
</div>
</div>'''
def scene_image(s, idx):
"""Full-screen image or centered image with caption"""
src = s.get("src", "")
caption = s.get("caption", "")
full = s.get("fullscreen", False)
caption_html = f'<div style="font-size:18px;color:rgba(255,255,255,.3);margin-top:24px">{esc(caption)}</div>' if caption else ''
if full:
return f'''<div class="scene" id="s{idx}">
<img src="{esc(src)}" style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0">
<div style="position:absolute;bottom:80px;left:80px;z-index:2">{caption_html}</div>
</div>'''
else:
return f'''<div class="scene" id="s{idx}">
<img src="{esc(src)}" style="max-width:80%;max-height:70%;border-radius:12px;border:1px solid rgba(255,255,255,.08)">
{caption_html}
</div>'''
def scene_logos(s, idx):
"""Row of logos or brand names"""
items = s.get("items", [])
heading = s.get("heading", "")
heading_html = f'<div style="font-size:24px;color:rgba(255,255,255,.3);margin-bottom:40px">{esc(heading)}</div>' if heading else ''
logos = []
for i, item in enumerate(items):
delay = i * 0.15
if isinstance(item, dict):
text = item.get("name", "")
icon = item.get("icon", "")
logos.append(f'<div class="logo-item" style="transition-delay:{delay}s">{icon}<div>{esc(text)}</div></div>')
else:
logos.append(f'<div class="logo-item" style="transition-delay:{delay}s"><div>{esc(str(item))}</div></div>')
return f'''<div class="scene" id="s{idx}" data-anim="logos">
{heading_html}
<div class="logos-row">
{"".join(logos)}
</div>
</div>'''
def scene_cta(s, idx):
title = s.get("title", "")
subtitle = s.get("subtitle", "")
gradient_title = s.get("gradient", True)
links = s.get("links", [])
title_cls = ' class="grad"' if gradient_title else ''
subtitle_html = f'<div class="mono" style="font-size:16px;color:rgba(255,255,255,.2);margin-top:20px;letter-spacing:.2em">{esc(subtitle)}</div>' if subtitle else ''
links_html = ""
if links:
link_items = "".join(f'<div class="mono" style="font-size:13px;color:rgba(255,255,255,.2)">{esc(l)}</div>' for l in links)
links_html = f'<div style="display:flex;gap:48px;margin-top:48px">{link_items}</div>'
return f'''<div class="scene" id="s{idx}">
<div class="grad-line" style="width:200px;margin-bottom:32px"></div>
<div style="font-size:48px;font-weight:700"{title_cls}>{esc(title)}</div>
{subtitle_html}
<div class="grad-line" style="width:200px;margin-top:32px"></div>
{links_html}
</div>'''
def scene_countdown(s, idx):
"""Animated number with label"""
target = s.get("target", 100)
label = s.get("label", "")
prefix = s.get("prefix", "")
suffix = s.get("suffix", "")
return f'''<div class="scene" id="s{idx}" data-anim="counter" data-target="{target}" data-prefix="{esc(prefix)}" data-suffix="{esc(suffix)}">
<div style="text-align:center">
<div class="counter-value grad" id="counter{idx}">{esc(prefix)}0{esc(suffix)}</div>
<div style="font-size:28px;color:rgba(255,255,255,.4);margin-top:16px">{esc(label)}</div>
</div>
</div>'''
def scene_reveal(s, idx):
"""Word-by-word reveal of a statement"""
words = s.get("text", "").split()
gradient_words = s.get("gradient_words", [])
word_spans = []
for i, w in enumerate(words):
delay = i * 0.15
cls = "reveal-word grad" if w.strip(".,!?") in gradient_words else "reveal-word"
word_spans.append(f'<span class="{cls}" style="transition-delay:{delay}s">{esc(w)}</span>')
return f'''<div class="scene" id="s{idx}" data-anim="reveal">
<div class="reveal-text">
{" ".join(word_spans)}
</div>
</div>'''
SCENE_RENDERERS = {
"title": scene_title,
"subtitle": scene_subtitle,
"bigtext": scene_bigtext,
"quote": scene_quote,
"stats": scene_stats,
"bullets": scene_bullets,
"cards": scene_cards,
"timeline": scene_timeline,
"terminal": scene_terminal,
"comparison": scene_comparison,
"code": scene_code,
"split": scene_split,
"flow": scene_flow,
"metrics": scene_metrics,
"image": scene_image,
"logos": scene_logos,
"cta": scene_cta,
"countdown": scene_countdown,
"reveal": scene_reveal,
}
def esc(text):
"""Escape HTML entities (minimal — preserve intentional HTML in content)"""
if not text:
return ""
return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def bg_particles():
return '''<canvas id="bgCanvas"></canvas>
<script>
(function(){
const c=document.getElementById('bgCanvas'),x=c.getContext('2d');
c.width=parseInt(document.body.style.width||1920);c.height=parseInt(document.body.style.height||1080);
const N=50,ps=Array.from({length:N},()=>({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-.5)*.6,vy:(Math.random()-.5)*.6,r:Math.random()*2+.5}));
const g=x.createLinearGradient(0,0,c.width,0);
g.addColorStop(0,'#FF6B2B');g.addColorStop(.2,'#FF2255');g.addColorStop(.4,'#CC00AA');g.addColorStop(.6,'#8844FF');g.addColorStop(.8,'#4488FF');g.addColorStop(1,'#00D4FF');
function d(){x.clearRect(0,0,c.width,c.height);
ps.forEach(p=>{p.x+=p.vx;p.y+=p.vy;if(p.x<0||p.x>c.width)p.vx*=-1;if(p.y<0||p.y>c.height)p.vy*=-1;x.fillStyle='rgba(255,255,255,.5)';x.beginPath();x.arc(p.x,p.y,p.r,0,Math.PI*2);x.fill()});
x.strokeStyle=g;x.lineWidth=.5;x.globalAlpha=.12;
for(let i=0;i<N;i++)for(let j=i+1;j<N;j++){const dx=ps[i].x-ps[j].x,dy=ps[i].y-ps[j].y;if(dx*dx+dy*dy<40000){x.beginPath();x.moveTo(ps[i].x,ps[i].y);x.lineTo(ps[j].x,ps[j].y);x.stroke()}}
x.globalAlpha=1;requestAnimationFrame(d)}d()})();
</script>'''
def bg_mesh():
return '''<canvas id="bgCanvas"></canvas>
<script>
(function(){
const c=document.getElementById('bgCanvas'),x=c.getContext('2d');
c.width=parseInt(document.body.style.width||1920);c.height=parseInt(document.body.style.height||1080);
const N=60,ps=Array.from({length:N},()=>({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-.5)*.8,vy:(Math.random()-.5)*.8,r:Math.random()*2+1}));
const g=x.createLinearGradient(0,0,c.width,0);
g.addColorStop(0,'#FF6B2B');g.addColorStop(.2,'#FF2255');g.addColorStop(.4,'#CC00AA');g.addColorStop(.6,'#8844FF');g.addColorStop(.8,'#4488FF');g.addColorStop(1,'#00D4FF');
function d(){x.clearRect(0,0,c.width,c.height);
ps.forEach(p=>{p.x+=p.vx;p.y+=p.vy;if(p.x<0||p.x>c.width)p.vx*=-1;if(p.y<0||p.y>c.height)p.vy*=-1;x.fillStyle='#fff';x.beginPath();x.arc(p.x,p.y,p.r,0,Math.PI*2);x.fill()});
x.strokeStyle=g;x.lineWidth=.5;x.globalAlpha=.15;
for(let i=0;i<N;i++)for(let j=i+1;j<N;j++){const dx=ps[i].x-ps[j].x,dy=ps[i].y-ps[j].y;if(dx*dx+dy*dy<40000){x.beginPath();x.moveTo(ps[i].x,ps[i].y);x.lineTo(ps[j].x,ps[j].y);x.stroke()}}
x.globalAlpha=1;requestAnimationFrame(d)}d()})();
</script>'''
def bg_grid():
return '''<canvas id="bgCanvas"></canvas>
<script>
(function(){
const c=document.getElementById('bgCanvas'),x=c.getContext('2d');
c.width=parseInt(document.body.style.width||1920);c.height=parseInt(document.body.style.height||1080);
x.strokeStyle='rgba(255,255,255,.03)';x.lineWidth=1;
const s=60;
for(let i=0;i<=c.width;i+=s){x.beginPath();x.moveTo(i,0);x.lineTo(i,c.height);x.stroke()}
for(let j=0;j<=c.height;j+=s){x.beginPath();x.moveTo(0,j);x.lineTo(c.width,j);x.stroke()}
// Pulse a random intersection
let px=0,py=0;
function pulse(){
x.clearRect(px-20,py-20,40,40);
x.strokeStyle='rgba(255,255,255,.03)';
[px,py]=[Math.floor(Math.random()*(c.width/s))*s, Math.floor(Math.random()*(c.height/s))*s];
x.fillStyle='rgba(255,255,255,.08)';x.beginPath();x.arc(px,py,3,0,Math.PI*2);x.fill();
setTimeout(pulse,800+Math.random()*2000)}
pulse()})();
</script>'''
def bg_none():
return ''
BG_RENDERERS = {
"particles": bg_particles,
"mesh": bg_mesh,
"grid": bg_grid,
"none": bg_none,
"": bg_none,
}
def generate(config):
title = config.get("title", "BlackRoad Video")
aspect = config.get("aspect", "16:9")
bg_type = config.get("background", "none")
cinematic = config.get("cinematic_bars", False)
scenes = config.get("scenes", [])
if aspect == "9:16":
w, h = 1080, 1920
else:
w, h = 1920, 1080
# Render scenes
scene_htmls = []
durations = []
for i, s in enumerate(scenes):
stype = s.get("type", "title")
renderer = SCENE_RENDERERS.get(stype)
if not renderer:
print(f"Warning: Unknown scene type '{stype}', skipping", file=sys.stderr)
continue
scene_htmls.append(renderer(s, i + 1))
durations.append(s.get("duration", 4000))
# Background
bg_html = BG_RENDERERS.get(bg_type, bg_none)()
# Cinematic bars
cinema_html = ""
if cinematic:
cinema_html = '<div class="cinema-top" id="ctop"></div><div class="cinema-bot" id="cbot"></div>'
durations_js = json.dumps(durations)
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width={w},height={h}">
<title>{esc(title)}</title>
<link href="{FONTS_URL}" rel="stylesheet">
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
html,body{{width:{w}px;height:{h}px;overflow:hidden;background:#000;color:#fff;font-family:'Space Grotesk',sans-serif}}
/* Scene system */
.scene{{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:{80 if aspect=="9:16" else 60}px;opacity:0;transition:opacity .8s;z-index:1}}
.scene.active{{opacity:1}}
.mono{{font-family:'JetBrains Mono',monospace}}
.grad{{background:{GRADIENT};-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}}
.grad-line{{height:3px;border-radius:2px;background:{GRADIENT}}}
/* Canvas background */
#bgCanvas{{position:fixed;inset:0;z-index:0;opacity:.4}}
/* Cinematic bars */
.cinema-top,.cinema-bot{{position:fixed;left:0;right:0;background:#000;z-index:100;transition:height 1.5s ease}}
.cinema-top{{top:0;height:80px}}.cinema-bot{{bottom:0;height:80px}}
.cinema-top.open,.cinema-bot.open{{height:0}}
/* Quote */
.quote{{font-size:{32 if aspect=="9:16" else 36}px;font-weight:300;line-height:1.6;max-width:{800 if aspect=="9:16" else 900}px;text-align:center;font-style:italic;color:rgba(255,255,255,.7)}}
.quote-attr{{font-family:'JetBrains Mono',monospace;font-size:14px;color:rgba(255,255,255,.3);margin-top:24px;letter-spacing:.1em}}
/* Stats */
.stats-grid{{display:flex;gap:{32 if aspect=="9:16" else 48}px;flex-wrap:wrap;justify-content:center}}
.stat-card{{text-align:center;opacity:0;transform:translateY(20px);transition:all .5s}}
.stat-card.visible{{opacity:1;transform:translateY(0)}}
.stat-value{{font-size:{56 if aspect=="9:16" else 64}px;font-weight:700}}
.stat-label{{font-family:'JetBrains Mono',monospace;font-size:13px;color:rgba(255,255,255,.3);margin-top:8px;letter-spacing:.15em;text-transform:uppercase}}
/* Bullets */
.bullet-list{{max-width:800px;width:100%}}
.bullet-item{{font-size:{22 if aspect=="9:16" else 26}px;padding:16px 0;opacity:0;transform:translateX(-20px);transition:all .5s;color:rgba(255,255,255,.6)}}
.bullet-item.visible{{opacity:1;transform:translateX(0)}}
.bullet-icon{{margin-right:16px;font-weight:700}}
/* Cards */
.cards-list{{width:100%;max-width:{900 if aspect=="9:16" else 1200}px;display:flex;flex-direction:column;gap:16px}}
.info-card{{display:flex;align-items:center;gap:20px;padding:24px 28px;border:1px solid rgba(255,255,255,.08);border-radius:12px;opacity:0;transform:translateY(20px);transition:all .5s}}
.info-card.visible{{opacity:1;transform:translateY(0)}}
.card-icon{{font-size:32px;width:52px;height:52px;display:flex;align-items:center;justify-content:center;border-radius:10px;border:1px solid rgba(255,255,255,.08)}}
.card-body{{flex:1}}.card-name{{font-size:20px;font-weight:600}}.card-desc{{font-size:14px;color:rgba(255,255,255,.3);margin-top:4px;font-family:'JetBrains Mono',monospace}}
.card-status{{font-size:13px;font-family:'JetBrains Mono',monospace}}.card-status.on{{color:#22c55e}}.card-status.off{{color:#ef4444}}
/* Timeline */
.timeline{{width:{800 if aspect=="9:16" else 1100}px;position:relative;padding-left:40px;border-left:1px solid rgba(255,255,255,.08)}}
.tl-item{{padding:16px 0 16px 36px;position:relative;opacity:0;transform:translateX(-20px);transition:all .5s}}
.tl-item.visible{{opacity:1;transform:translateX(0)}}
.tl-item::before{{content:'';position:absolute;left:-5px;top:24px;width:9px;height:9px;border-radius:50%;background:{GRADIENT}}}
.tl-date{{font-family:'JetBrains Mono',monospace;font-size:12px;color:rgba(255,255,255,.3);letter-spacing:.1em}}
.tl-event{{font-size:20px;font-weight:500;margin-top:4px}}
.tl-detail{{font-size:14px;color:rgba(255,255,255,.3);margin-top:4px}}
/* Terminal */
.term-window{{width:{900 if aspect=="9:16" else 1400}px;background:#0a0a0a;border:1px solid rgba(255,255,255,.1);border-radius:12px;overflow:hidden}}
.term-bar{{height:40px;background:rgba(255,255,255,.03);border-bottom:1px solid rgba(255,255,255,.06);display:flex;align-items:center;padding:0 16px;gap:8px}}
.term-dot{{width:12px;height:12px;border-radius:50%}}.term-dot.r{{background:#ff5f57}}.term-dot.y{{background:#febc2e}}.term-dot.g{{background:#28c840}}
.term-title{{flex:1;text-align:center;font-family:'JetBrains Mono',monospace;font-size:12px;color:rgba(255,255,255,.3)}}
.term-body{{padding:24px 28px;font-family:'JetBrains Mono',monospace;font-size:{14 if aspect=="9:16" else 16}px;line-height:1.9;min-height:300px}}
.term-body .prompt{{color:#8844FF}}.term-body .cmd{{color:#fff}}.term-body .output{{color:rgba(255,255,255,.5)}}
.term-body .success{{color:#22c55e}}.term-body .error{{color:#ef4444}}.term-body .info{{color:#4488FF}}
.term-body .pink{{color:#FF2255}}.term-body .dim{{color:rgba(255,255,255,.2)}}
.tline{{opacity:0;transform:translateY(5px);transition:all .3s}}.tline.show{{opacity:1;transform:translateY(0)}}
/* Comparison */
.compare{{display:flex;gap:48px;align-items:flex-start;max-width:1100px}}
.compare-col{{flex:1}}.compare-head{{font-size:20px;font-weight:600;margin-bottom:20px;text-align:center}}
.compare-divider{{width:1px;background:rgba(255,255,255,.08);align-self:stretch}}
.cmp-item{{padding:12px 0;font-size:18px;opacity:0;transform:translateX(-10px);transition:all .4s}}
.cmp-item.visible{{opacity:1;transform:translateX(0)}}
.cmp-x{{color:#ef4444;margin-right:12px}}.cmp-check{{color:#22c55e;margin-right:12px}}
.cmp-item.bad{{color:rgba(255,255,255,.3);text-decoration:line-through}}
/* Code block */
.code-block{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:32px 36px;max-width:1000px;width:100%}}
.code-lang{{font-family:'JetBrains Mono',monospace;font-size:11px;color:rgba(255,255,255,.2);margin-bottom:16px;letter-spacing:.1em;text-transform:uppercase}}
.code-pre{{font-family:'JetBrains Mono',monospace;font-size:16px;line-height:1.8;color:rgba(255,255,255,.7);white-space:pre-wrap}}
/* Split */
.split-layout{{display:flex;align-items:center;gap:80px;width:100%;max-width:1400px}}
.split-left,.split-right{{flex:1}}
.split-right{{text-align:center}}
/* Flow */
.flow-row{{display:flex;align-items:center;gap:24px;justify-content:center;flex-wrap:wrap}}
.flow-stage{{text-align:center;opacity:0;transform:scale(.8);transition:all .5s}}.flow-stage.visible{{opacity:1;transform:scale(1)}}
.flow-icon{{font-size:40px;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border:1px solid rgba(255,255,255,.08);border-radius:16px;margin-bottom:12px}}
.flow-label{{font-size:14px;color:rgba(255,255,255,.5);font-family:'JetBrains Mono',monospace}}
.flow-arrow{{font-size:28px;color:rgba(255,255,255,.15);margin:0 8px}}
/* Metrics grid */
.metrics-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;max-width:1000px;width:100%}}
.metric-tile{{border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:24px;text-align:center;opacity:0;transform:translateY(15px);transition:all .4s}}
.metric-tile.visible{{opacity:1;transform:translateY(0)}}
.mv{{font-size:28px;font-weight:700}}.ml{{font-family:'JetBrains Mono',monospace;font-size:11px;color:rgba(255,255,255,.3);margin-top:8px;letter-spacing:.1em;text-transform:uppercase}}
/* Logos */
.logos-row{{display:flex;gap:48px;align-items:center;justify-content:center;flex-wrap:wrap}}
.logo-item{{text-align:center;font-size:18px;font-weight:500;opacity:0;transform:translateY(10px);transition:all .4s;color:rgba(255,255,255,.4)}}
.logo-item.visible{{opacity:1;transform:translateY(0)}}
/* Counter */
.counter-value{{font-size:120px;font-weight:700}}
/* Reveal */
.reveal-text{{font-size:{40 if aspect=="9:16" else 52}px;font-weight:700;text-align:center;line-height:1.4;max-width:1100px}}
.reveal-word{{display:inline-block;opacity:0;transform:translateY(15px);transition:all .4s}}.reveal-word.visible{{opacity:1;transform:translateY(0)}}
</style>
</head>
<body>
{cinema_html}
{bg_html}
{chr(10).join(scene_htmls)}
<script>
// Terminal typing engine
function typeTerminal(id, lines, charDelay) {{
charDelay = charDelay || 30;
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '';
let totalDelay = 0;
lines.forEach(function(line) {{
const div = document.createElement('div');
div.className = 'tline';
if (line.type === 'input') {{
totalDelay += 200;
const ps = '<span class="prompt">$</span> ';
div.innerHTML = ps;
el.appendChild(div);
setTimeout(function(){{ div.classList.add('show') }}, totalDelay);
const chars = line.text.split('');
chars.forEach(function(ch, ci) {{
totalDelay += charDelay;
setTimeout(function() {{
div.innerHTML = ps + '<span class="cmd">' + line.text.slice(0, ci + 1) + '</span><span class="dim">_</span>';
}}, totalDelay);
}});
totalDelay += 400;
setTimeout(function() {{
div.innerHTML = ps + '<span class="cmd">' + line.text + '</span>';
}}, totalDelay);
}} else {{
totalDelay += line.delay || 80;
div.innerHTML = '<span class="' + (line.type || 'output') + '">' + line.text + '</span>';
el.appendChild(div);
setTimeout(function(){{ div.classList.add('show') }}, totalDelay);
}}
}});
}}
// Scene controller
const scenes = document.querySelectorAll('.scene');
const durations = {durations_js};
let cur = 0;
function animateScene(el) {{
const anim = el.dataset.anim;
if (!anim) return;
if (anim === 'stats') el.querySelectorAll('.stat-card').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*200) }});
if (anim === 'bullets') el.querySelectorAll('.bullet-item').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*300) }});
if (anim === 'cards') el.querySelectorAll('.info-card').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*200) }});
if (anim === 'timeline') el.querySelectorAll('.tl-item').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*300) }});
if (anim === 'comparison') el.querySelectorAll('.cmp-item').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*150) }});
if (anim === 'flow') el.querySelectorAll('.flow-stage').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*300) }});
if (anim === 'metrics') el.querySelectorAll('.metric-tile').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*120) }});
if (anim === 'logos') el.querySelectorAll('.logo-item').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*150) }});
if (anim === 'reveal') el.querySelectorAll('.reveal-word').forEach(function(c,j){{ setTimeout(function(){{ c.classList.add('visible') }}, j*150) }});
if (anim === 'terminal') {{
const termId = el.dataset.term;
try {{ var lines = JSON.parse(el.dataset.lines); typeTerminal(termId, lines); }} catch(e){{}}
}}
if (anim === 'counter') {{
const target = parseInt(el.dataset.target) || 100;
const prefix = el.dataset.prefix || '';
const suffix = el.dataset.suffix || '';
const counterEl = el.querySelector('.counter-value');
if (!counterEl) return;
let current = 0;
const step = Math.max(1, Math.floor(target / 60));
const iv = setInterval(function() {{
current = Math.min(current + step, target);
counterEl.textContent = prefix + current.toLocaleString() + suffix;
if (current >= target) clearInterval(iv);
}}, 25);
}}
}}
function show(i) {{
if (i >= scenes.length) return;
scenes.forEach(function(s) {{ s.classList.remove('active') }});
scenes[i].classList.add('active');
animateScene(scenes[i]);
{"// Cinematic bars" if cinematic else ""}
{"if(i===1){document.getElementById('ctop').classList.add('open');document.getElementById('cbot').classList.add('open')}" if cinematic else ""}
{"if(i===scenes.length-1){document.getElementById('ctop').classList.remove('open');document.getElementById('cbot').classList.remove('open')}" if cinematic else ""}
setTimeout(function(){{ show(i + 1) }}, durations[i]);
}}
show(0);
</script>
</body>
</html>'''
def print_usage():
print("""
\033[38;5;205m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
\033[38;5;205m br-video-create — Animated Video Generator\033[0m
\033[38;5;205m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
\033[38;5;82mUsage:\033[0m
br-video-create <config.json> [output.html]
br-video-create --example
br-video-create --types
\033[38;5;135mScene Types:\033[0m
title Hero title with optional pretitle/subtitle
subtitle Multi-line subtitle text
bigtext Single big statement
quote Quoted text with attribution
stats Animated stat counters in a row
bullets Bullet point list with stagger
cards Info cards with icon/name/desc/status
timeline Vertical timeline with dates
terminal Typing terminal with colored output
comparison Side-by-side before/after
code Syntax-highlighted code block
split Left text, right big number/word
flow Horizontal pipeline stages
metrics Grid of metric tiles
image Image with optional caption
logos Row of brand names/icons
cta Call-to-action end card
countdown Animated counting number
reveal Word-by-word text reveal
\033[38;5;135mBackgrounds:\033[0m particles, mesh, grid, none
\033[38;5;135mAspect Ratios:\033[0m 16:9 (1920x1080), 9:16 (1080x1920)
\033[38;5;69mExample:\033[0m
br-video-create --example > my-config.json
br-video-create my-config.json my-video.html
open my-video.html
""")
def print_types():
print(json.dumps(list(SCENE_RENDERERS.keys()), indent=2))
def main():
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print_usage()
sys.exit(0)
if args[0] == "--example":
print(json.dumps(EXAMPLE_CONFIG, indent=2))
sys.exit(0)
if args[0] == "--types":
print_types()
sys.exit(0)
# Read config
config_path = args[0]
if config_path == "-":
config = json.load(sys.stdin)
else:
with open(config_path) as f:
config = json.load(f)
# Generate
html = generate(config)
# Output
if len(args) > 1:
output_path = args[1]
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w") as f:
f.write(html)
print(f"\033[38;5;82m✓ Generated: {output_path}\033[0m")
print(f"\033[38;5;69m Scenes: {len(config.get('scenes',[]))}\033[0m")
print(f"\033[38;5;69m Aspect: {config.get('aspect','16:9')}\033[0m")
print(f"\033[38;5;69m Background: {config.get('background','none')}\033[0m")
else:
print(html)
if __name__ == "__main__":
main()