whatfirst-small / demo /compose.py
optiquant's picture
updates to speed up computation of ranked list
a56a725
Raw
History Blame Contribute Delete
15 kB
"""Compose the captured whatfirst-small screenshots into a narrated demo video.
Pipeline, mirroring the what-first.com demo but with the voiceover baked in:
1. synthesize each scene's narration (vo.py / edge-tts) and measure it,
2. set each scene's on-screen duration from its narration length,
3. lay the voice clips onto a track aligned to the cut and mix the jazz bed
(music.py) under it,
4. render a clean Ken-Burns push-in per scene with a bold caption above the
app, crossfade the scenes, and mux the mixed audio.
Output: demo/out/whatfirst-small-demo.mp4 (+ -poster.jpg)
"""
import os
import subprocess
from functools import lru_cache
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import imageio_ffmpeg
import vo
HERE = os.path.dirname(os.path.abspath(__file__))
SHOTS = os.path.join(HERE, "shots")
AUDIO = os.path.join(HERE, "audio")
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
W, H = 1920, 1080
OUT_W, OUT_H = 1280, 720
FPS = 24
FADE = int(0.6 * FPS)
LOGO_FADE = int(0.9 * FPS)
SR = vo.SR
MUSIC_GAIN = 0.16
VO_LEAD = 0.45 # narration starts this long after the scene begins
VO_TAIL = 0.9 # breath held after narration before the next scene
MIN_SECS = {"title": 3.2, "app": 5.0, "card": 3.6, "end": 3.8}
# ── Type & colour ────────────────────────────────────────────────────────────
_ROBOTO = os.path.join(HERE, "fonts", "Roboto-VF.ttf")
_WGHT = {"r": 400, "m": 500, "sb": 600, "b": 700}
@lru_cache(maxsize=None)
def font(weight, size):
f = ImageFont.truetype(_ROBOTO, size)
try:
f.set_variation_by_axes([_WGHT[weight], 100])
except Exception:
pass
return f
INDIGO = (79, 70, 229); BLUE = (37, 99, 235); SLATE = (30, 41, 59)
INK = (17, 24, 39); INKSOFT = (55, 65, 81); MUTED = (90, 100, 115)
ACCENTS = {
"indigo": (79, 70, 229), "blue": (37, 99, 235), "gold": (217, 119, 6),
"green": (21, 128, 61), "violet": (109, 40, 217), "slate": (51, 65, 85),
"teal": (13, 118, 110),
}
VW, VH = 1480, 832
VX, VY = (W - VW) // 2, 214
VBOX = (VX, VY, VX + VW, VY + VH)
VRAD = 22
_shot_cache = {}
def load_shot(name):
if name not in _shot_cache:
_shot_cache[name] = Image.open(os.path.join(SHOTS, f"{name}.png")).convert("RGB")
return _shot_cache[name]
def make_bg():
top = np.array([250, 251, 255], dtype=np.float32)
bot = np.array([232, 235, 248], dtype=np.float32)
ramp = np.linspace(0, 1, H, dtype=np.float32)[:, None, None]
grad = np.repeat((top * (1 - ramp) + bot * ramp).astype(np.uint8), W, axis=1)
img = Image.fromarray(grad, "RGB")
glow = Image.new("L", (W, H), 0)
ImageDraw.Draw(glow).ellipse([-560, -620, 1060, 560], fill=90)
glow = glow.filter(ImageFilter.GaussianBlur(240))
layer = Image.new("RGB", (W, H), INDIGO)
return Image.composite(layer, img, glow.point(lambda v: int(v * 0.13)))
BG = make_bg()
BG_ARR = np.asarray(BG.convert("RGB"))
def soft_shadow(box, radius, blur, alpha, dy):
x0, y0, x1, y1 = box
shp = Image.new("L", (W, H), 0)
ImageDraw.Draw(shp).rounded_rectangle([x0, y0 + dy, x1, y1 + dy], radius=radius, fill=alpha)
return shp.filter(ImageFilter.GaussianBlur(blur))
def centered(draw, cx, y, text, f, fill, tracking=0.0):
if not tracking:
draw.text((cx - draw.textlength(text, font=f) / 2, y), text, font=f, fill=fill)
return
widths = [draw.textlength(ch, font=f) for ch in text]
total = sum(widths) + tracking * (len(text) - 1)
x = cx - total / 2
for ch, wd in zip(text, widths):
draw.text((x, y), ch, font=f, fill=fill)
x += wd + tracking
def wrap(draw, text, f, max_w):
words, lines, cur = text.split(), [], ""
for w in words:
trial = (cur + " " + w).strip()
if draw.textlength(trial, font=f) <= max_w or not cur:
cur = trial
else:
lines.append(cur); cur = w
if cur:
lines.append(cur)
return lines
def draw_wordmark(draw, cx, cy, size):
f = font("sb", size)
parts = [("whatfirst", INDIGO), (" Β· small", SLATE)]
widths = [draw.textlength(t, font=f) for t, _ in parts]
x = cx - sum(widths) / 2
for (t, col), wpx in zip(parts, widths):
draw.text((x, cy), t, font=f, fill=col)
x += wpx
def _box_mask():
m = Image.new("L", (VW, VH), 0)
ImageDraw.Draw(m).rounded_rectangle([0, 0, VW - 1, VH - 1], radius=VRAD, fill=255)
return m
BOX_MASK = _box_mask()
def scene_static(chip_col, chip_label, title, body):
frame = BG.copy()
d = ImageDraw.Draw(frame)
cx = W // 2
y = 44
if chip_label:
fchip = font("b", 21)
tw = d.textlength(chip_label.upper(), font=fchip)
col = ACCENTS[chip_col]
d.rounded_rectangle([cx - tw / 2 - 17, y, cx + tw / 2 + 17, y + 37], radius=9, fill=col)
d.text((cx - tw / 2, y + 7), chip_label.upper(), font=fchip, fill=(255, 255, 255))
centered(d, cx, 88, title, font("b", 52), INK, tracking=-1.0)
fb = font("m", 30)
for line in wrap(d, body, fb, 1560)[:1]:
centered(d, cx, 158, line, fb, INKSOFT)
frame.paste((0, 0, 0), (0, 0, W, H), soft_shadow(VBOX, VRAD, 40, 60, 24))
d.rounded_rectangle(VBOX, radius=VRAD, fill=(255, 255, 255))
d.rounded_rectangle(VBOX, radius=VRAD, outline=(228, 231, 237), width=2)
return frame
def kenburns(shot_img, focal, z):
iw, ih = shot_img.size
base = max(VW / iw, VH / ih)
scale = base * z
rw, rh = VW / scale, VH / scale
fx, fy = focal
cxp = min(max(fx * iw, rw / 2), iw - rw / 2)
cyp = min(max(fy * ih, rh / 2), ih - rh / 2)
box = (cxp - rw / 2, cyp - rh / 2, cxp + rw / 2, cyp + rh / 2)
return shot_img.resize((VW, VH), Image.LANCZOS, box=box)
def ease(p):
return 0.5 - 0.5 * np.cos(np.pi * p)
def build_title(line, sub):
frame = BG.copy()
d = ImageDraw.Draw(frame)
draw_wordmark(d, W // 2, H // 2 - 170, 132)
if sub:
centered(d, W // 2, H // 2 - 16, sub, font("m", 34), SLATE)
fb = font("m", 37)
for i, ln in enumerate(wrap(d, line, fb, 1420)[:3]):
centered(d, W // 2, H // 2 + 64 + i * 50, ln, fb, INK, tracking=-0.6)
return np.asarray(frame.convert("RGB"))
def build_card(chip_col, chip_label, title, subtitle):
frame = BG.copy()
d = ImageDraw.Draw(frame)
cx = W // 2
fchip = font("b", 25)
tw = d.textlength(chip_label.upper(), font=fchip)
col = ACCENTS[chip_col]
d.rounded_rectangle([cx - tw / 2 - 23, H // 2 - 158, cx + tw / 2 + 23, H // 2 - 110], radius=11, fill=col)
d.text((cx - tw / 2, H // 2 - 150), chip_label.upper(), font=fchip, fill=(255, 255, 255))
centered(d, cx, H // 2 - 66, title, font("b", 76), INK, tracking=-1.6)
centered(d, cx, H // 2 + 52, subtitle, font("m", 33), MUTED)
return np.asarray(frame.convert("RGB"))
def build_end():
frame = BG.copy()
d = ImageDraw.Draw(frame)
draw_wordmark(d, W // 2, H // 2 - 120, 120)
centered(d, W // 2, H // 2 + 40, "Dump everything. Do the first thing.", font("m", 35), SLATE)
centered(d, W // 2, H // 2 + 112, "hf.co/spaces/build-small-hackathon/whatfirst-small",
font("sb", 28), INDIGO)
return np.asarray(frame.convert("RGB"))
# ── The cut ──────────────────────────────────────────────────────────────────
def app(shot, chip_col, chip_label, title, body, vo_text, focal=(0.5, 0.42), z=(1.05, 1.18)):
return dict(kind="app", shot=shot, chip_col=chip_col, chip_label=chip_label,
title=title, body=body, vo=vo_text, focal=focal, z=z)
SCENES = [
dict(kind="title", vo=(
"Meet whatfirst, small. Dump everything on your mind β€” and it tells you "
"the one thing to do first."),
line="Dump your mind. Get back what to do first.", sub="the small, offline edition"),
app("02_filled", "indigo", "Capture", "Catch it however it arrives",
"Type a messy brain-dump β€” or snap a photo of your list.",
"Start with the mess. Type a brain-dump, or photograph a pile of sticky notes. "
"A small vision model β€” three billion parameters, running locally on llama-c-p-p β€” "
"reads every line and turns it into a structured task.",
focal=(0.17, 0.40), z=(1.05, 1.18)),
app("03_ranked", "blue", "The answer", "What should I do first?",
"One ranked list β€” and the single thing to start now.",
"Then a transparent engine ranks them. Impact, urgency, how ready you are, a nudge "
"for quick wins β€” and out comes one ranked list, with the single thing to do first, "
"right at the top.",
focal=(0.60, 0.30), z=(1.05, 1.19)),
app("06_formula", "slate", "No black box", "The math is one plain page",
"Two scores compete β€” and every number is shown.",
"And it's no black box. Two scores compete β€” do it now, or de-risk it first β€” and "
"the whole formula is right there, in plain English.",
focal=(0.32, 0.86), z=(1.05, 1.18)),
app("04_correct", "violet", "You decide", "The model proposes β€” you decide",
"Disagree with a score? Grab a slider and correct it.",
"The model proposes, but you decide. Think it got one wrong? Grab a slider and "
"correct any score yourself.",
focal=(0.30, 0.88), z=(1.05, 1.18)),
app("05_reranked", "green", "Live", "The list re-ranks instantly",
"Change a number and the whole order updates live.",
"Change a number, and the whole list re-ranks the instant you let go β€” that task "
"just climbed from the bottom of the pile.",
focal=(0.56, 0.34), z=(1.05, 1.19)),
dict(kind="card", vo=(
"And all of it runs offline. A three-billion-parameter model on llama-c-p-p β€” no "
"cloud, no API keys. Your to-do list never leaves the laptop."),
chip_col="indigo", chip_label="Backyard AI",
title="3B model Β· llama.cpp Β· offline", subtitle="No cloud. No API keys. Runs on a laptop."),
dict(kind="end", vo=(
"That's whatfirst, small. Dump everything β€” and just do the first thing.")),
]
# ── Audio: synth narration, time the scenes, mix voice under music ───────────
def build_audio():
durs = []
for i, s in enumerate(SCENES):
path = os.path.join(AUDIO, f"vo_{i:02d}.mp3")
d = vo.synth(s["vo"], path)
s["_vo_path"] = path
secs = max(MIN_SECS[s["kind"]], VO_LEAD + d + VO_TAIL)
s["_secs"] = secs
durs.append((i, d, secs))
print(f" scene {i} ({s['kind']}): vo {d:.2f}s -> scene {secs:.2f}s")
total = sum(s["_secs"] for s in SCENES) + 2 * LOGO_FADE / FPS
n = int(total * SR) + SR
track = np.zeros((n, 2), np.float32)
t = LOGO_FADE / FPS
for s in SCENES:
clip = vo.decode_stereo(s["_vo_path"])
start = int((t + VO_LEAD) * SR)
end = min(n, start + clip.shape[0])
track[start:end] += clip[: end - start]
t += s["_secs"]
# Music bed: tile to length, duck under the voice.
bed = vo.decode_stereo(os.path.join(AUDIO, "bed.wav"))
if bed.shape[0] < n:
reps = int(np.ceil(n / bed.shape[0]))
bed = np.tile(bed, (reps, 1))
bed = bed[:n] * MUSIC_GAIN
mix = track + bed
peak = float(np.max(np.abs(mix))) or 1.0
if peak > 0.97:
mix *= 0.97 / peak
fin = int(1.2 * SR)
fout = int(2.0 * SR)
mix[:fin] *= np.linspace(0, 1, fin)[:, None]
mix[-fout:] *= np.linspace(1, 0, fout)[:, None]
import wave
mix_path = os.path.join(AUDIO, "mix.wav")
pcm = (np.clip(mix, -1, 1) * 32767).astype(np.int16)
with wave.open(mix_path, "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(pcm.tobytes())
return mix_path, total
# ── Video: render frames, mux the mixed audio ────────────────────────────────
def frame_for(s):
if s["kind"] == "app":
return np.asarray(scene_static(s["chip_col"], s["chip_label"], s["title"], s["body"]).convert("RGB"))
if s["kind"] == "title":
return build_title(s["line"], s["sub"])
if s["kind"] == "card":
return build_card(s["chip_col"], s["chip_label"], s["title"], s["subtitle"])
return build_end()
def render():
mix_path, total = build_audio()
statics = [frame_for(s) for s in SCENES]
shots = [load_shot(s["shot"]) if s["kind"] == "app" else None for s in SCENES]
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
out_path = os.path.join(OUT, "whatfirst-small-demo.mp4")
cmd = [
ffmpeg, "-y",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
"-i", mix_path,
"-vf", f"scale={OUT_W}:{OUT_H}:flags=lanczos",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", "-preset", "medium",
"-c:a", "aac", "-b:a", "160k",
"-map", "0:v", "-map", "1:a", "-t", f"{total:.2f}",
"-movflags", "+faststart", out_path,
]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
write = proc.stdin.write
def frame_at(idx, f, n):
s = SCENES[idx]
if s["kind"] != "app":
return statics[idx]
z = s["z"][0] + (s["z"][1] - s["z"][0]) * ease(f / max(1, n - 1))
crop = kenburns(shots[idx], s["focal"], z)
base = Image.fromarray(statics[idx])
base.paste(crop, (VX, VY), BOX_MASK)
return np.asarray(base)
first = frame_at(0, 0, 1)
for f in range(LOGO_FADE):
a = (f + 1) / LOGO_FADE
write((BG_ARR * (1 - a) + first * a).astype(np.uint8))
prev_last = first
for idx, s in enumerate(SCENES):
n = int(s["_secs"] * FPS)
frame = statics[idx]
for f in range(n):
frame = frame_at(idx, f, n)
if f < FADE and idx > 0:
a = f / FADE
frame = (prev_last * (1 - a) + frame * a).astype(np.uint8)
write(frame)
prev_last = frame_at(idx, n - 1, n)
for f in range(LOGO_FADE):
a = (f + 1) / LOGO_FADE
write((prev_last * (1 - a) + BG_ARR * a).astype(np.uint8))
proc.stdin.close()
proc.wait()
# Poster: the ranked-list scene, mid push-in.
pidx = next((i for i, s in enumerate(SCENES) if s.get("shot") == "03_ranked"), 1)
pf = frame_at(pidx, int(SCENES[pidx]["_secs"] * FPS) // 2, int(SCENES[pidx]["_secs"] * FPS))
poster = Image.fromarray(pf).resize((OUT_W, OUT_H), Image.LANCZOS)
poster.convert("RGB").save(out_path.replace(".mp4", "-poster.jpg"), "JPEG", quality=86)
print(f"done -> {out_path} (~{total:.0f}s)")
if __name__ == "__main__":
render()