diff --git a/assets/promo-video-828/PR_SUBMISSION.md b/assets/promo-video-828/PR_SUBMISSION.md new file mode 100644 index 000000000..7d9025458 --- /dev/null +++ b/assets/promo-video-828/PR_SUBMISSION.md @@ -0,0 +1,31 @@ +# PR Title + +feat: add 30-second SolFoundry promo video + +# PR Body + +Closes #828 + +## Summary + +- Adds a 30-second 1080p MP4 promo video for SolFoundry. +- Covers the required value proposition: post bounty, fund escrow, AI review, and earn FNDRY. +- Includes SolFoundry branding, social-ready thumbnail, storyboard contact sheet, and an original generated synth music bed. +- Includes a reproducible Python renderer so the video can be updated later. + +## Files + +- `assets/promo-video-828/final/solfoundry-promo-30s.mp4` +- `assets/promo-video-828/final/thumbnail.png` +- `assets/promo-video-828/storyboard/contact-sheet.png` +- `assets/promo-video-828/source/` + +## Verification + +- Rendered with `python assets/promo-video-828/source/render_video.py` +- Validated MP4 output as 1920x1080, approximately 30 seconds +- Ran `git diff --check` + +## Reward Wallet + +FNDRY/Solana wallet: `3BAGU3K8mn9avJJC3okFaT2BSeeaxHdvhMZWpWkYrYuW` diff --git a/assets/promo-video-828/README.md b/assets/promo-video-828/README.md new file mode 100644 index 000000000..65fc00c8d --- /dev/null +++ b/assets/promo-video-828/README.md @@ -0,0 +1,25 @@ +# SolFoundry 30-Second Promo Video + +This package contains a reproducible 1080p promo video for bounty #828. + +## Deliverables + +- `final/solfoundry-promo-30s.mp4` - 30-second 1920x1080 MP4 with audio +- `final/thumbnail.png` - social preview thumbnail +- `storyboard/contact-sheet.png` - six-frame storyboard overview +- `source/` - Python renderer, visual spec, palette, and original audio generator + +## Message Flow + +1. SolFoundry turns ideas into public bounties. +2. Sponsors fund escrow. +3. AI-assisted review checks submissions. +4. Contributors earn FNDRY for useful work. + +The music bed is generated from original synth tones in `source/audio.py`. + +## Re-render + +```bash +python assets/promo-video-828/source/render_video.py +``` diff --git a/assets/promo-video-828/final/original-synth-bed.wav b/assets/promo-video-828/final/original-synth-bed.wav new file mode 100644 index 000000000..67a72230c Binary files /dev/null and b/assets/promo-video-828/final/original-synth-bed.wav differ diff --git a/assets/promo-video-828/final/solfoundry-promo-30s.mp4 b/assets/promo-video-828/final/solfoundry-promo-30s.mp4 new file mode 100644 index 000000000..82163629c Binary files /dev/null and b/assets/promo-video-828/final/solfoundry-promo-30s.mp4 differ diff --git a/assets/promo-video-828/final/thumbnail.png b/assets/promo-video-828/final/thumbnail.png new file mode 100644 index 000000000..c606b4047 Binary files /dev/null and b/assets/promo-video-828/final/thumbnail.png differ diff --git a/assets/promo-video-828/source/audio.py b/assets/promo-video-828/source/audio.py new file mode 100644 index 000000000..bed77859d --- /dev/null +++ b/assets/promo-video-828/source/audio.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import math +import wave +from pathlib import Path + +import numpy as np + + +SAMPLE_RATE = 44_100 + + +def _sine(freq: float, t: np.ndarray) -> np.ndarray: + return np.sin(2 * math.pi * freq * t) + + +def _envelope(length: int, attack: float = 0.04, release: float = 0.12) -> np.ndarray: + env = np.ones(length, dtype=np.float32) + attack_n = max(1, int(length * attack)) + release_n = max(1, int(length * release)) + env[:attack_n] = np.linspace(0, 1, attack_n) + env[-release_n:] = np.linspace(1, 0, release_n) + return env + + +def render_audio(output_path: Path, duration: float = 30.0) -> None: + """Create an original soft synth bed for the promo video.""" + total = int(SAMPLE_RATE * duration) + timeline = np.arange(total, dtype=np.float32) / SAMPLE_RATE + audio = np.zeros(total, dtype=np.float32) + + chords = [ + (0.0, 6.0, [130.81, 196.00, 261.63, 392.00]), + (6.0, 12.0, [146.83, 220.00, 293.66, 440.00]), + (12.0, 18.0, [164.81, 246.94, 329.63, 493.88]), + (18.0, 24.0, [196.00, 293.66, 392.00, 587.33]), + (24.0, 30.0, [174.61, 261.63, 349.23, 523.25]), + ] + + for start, end, freqs in chords: + start_i = int(start * SAMPLE_RATE) + end_i = int(end * SAMPLE_RATE) + local_t = timeline[start_i:end_i] - start + chord = np.zeros(end_i - start_i, dtype=np.float32) + for index, freq in enumerate(freqs): + chord += _sine(freq, local_t) * (0.16 / (index + 1)) + chord += _sine(freq * 2.0, local_t) * (0.025 / (index + 1)) + chord *= _envelope(len(chord), attack=0.08, release=0.18) + audio[start_i:end_i] += chord + + beat_every = int(SAMPLE_RATE * 0.75) + for beat in range(0, total, beat_every): + length = int(SAMPLE_RATE * 0.09) + end = min(total, beat + length) + local_t = np.arange(end - beat, dtype=np.float32) / SAMPLE_RATE + click = _sine(880, local_t) * np.exp(-local_t * 34) + audio[beat:end] += click.astype(np.float32) * 0.055 + + shimmer = (_sine(1760, timeline) + _sine(2349.32, timeline)) * 0.012 + shimmer *= 0.5 + 0.5 * np.sin(2 * math.pi * timeline / 8) + audio += shimmer.astype(np.float32) + + fade = np.ones(total, dtype=np.float32) + fade[: SAMPLE_RATE] = np.linspace(0, 1, SAMPLE_RATE) + fade[-SAMPLE_RATE:] = np.linspace(1, 0, SAMPLE_RATE) + audio *= fade + audio = np.tanh(audio * 1.5) * 0.78 + + output_path.parent.mkdir(parents=True, exist_ok=True) + pcm = np.int16(audio * 32767) + with wave.open(str(output_path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(SAMPLE_RATE) + wav.writeframes(pcm.tobytes()) diff --git a/assets/promo-video-828/source/draw_utils.py b/assets/promo-video-828/source/draw_utils.py new file mode 100644 index 000000000..017bcdced --- /dev/null +++ b/assets/promo-video-828/source/draw_utils.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import math +from functools import lru_cache +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from palette import COLORS, hex_to_rgb, mix, with_alpha + + +WIDTH = 1920 +HEIGHT = 1080 + + +def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float: + return max(low, min(high, value)) + + +def ease(value: float) -> float: + value = clamp(value) + return value * value * (3 - 2 * value) + + +def lerp(a: float, b: float, amount: float) -> float: + return a + (b - a) * amount + + +def fade_window(t: float, start: float, end: float, edge: float = 0.45) -> float: + fade_in = clamp((t - start) / edge) + fade_out = clamp((end - t) / edge) + return min(ease(fade_in), ease(fade_out)) + + +@lru_cache(maxsize=64) +def font(size: int, bold: bool = False, mono: bool = False) -> ImageFont.FreeTypeFont: + if mono: + candidates = [ + Path("C:/Windows/Fonts/consolab.ttf" if bold else "C:/Windows/Fonts/consola.ttf"), + Path("C:/Windows/Fonts/courbd.ttf" if bold else "C:/Windows/Fonts/cour.ttf"), + ] + else: + candidates = [ + Path("C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf"), + Path("C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf"), + ] + for candidate in candidates: + if candidate.exists(): + return ImageFont.truetype(str(candidate), size) + return ImageFont.load_default() + + +def text_box(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.ImageFont) -> tuple[int, int]: + box = draw.textbbox((0, 0), text, font=face) + return box[2] - box[0], box[3] - box[1] + + +def centered_text( + draw: ImageDraw.ImageDraw, + y: int, + text: str, + face: ImageFont.ImageFont, + fill: tuple[int, int, int, int] | str, + x_center: int = WIDTH // 2, +) -> None: + tw, th = text_box(draw, text, face) + draw.text((x_center - tw / 2, y - th / 2), text, font=face, fill=fill) + + +def build_background() -> Image.Image: + y = np.linspace(0, 1, HEIGHT, dtype=np.float32)[:, None] + x = np.linspace(0, 1, WIDTH, dtype=np.float32)[None, :] + top = np.array(hex_to_rgb("#071015"), dtype=np.float32) + bottom = np.array(hex_to_rgb("#10262A"), dtype=np.float32) + base = top * (1 - y) + bottom * y + + glows = [ + (0.16, 0.14, "#9945FF", 0.35, 0.18), + (0.86, 0.20, "#14F195", 0.26, 0.22), + (0.58, 0.88, "#6EE7FF", 0.20, 0.20), + ] + rgb = np.broadcast_to(base[:, None, :], (HEIGHT, WIDTH, 3)).copy() + for cx, cy, color, strength, radius in glows: + dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 + mask = np.clip(1 - dist / radius, 0, 1) ** 2 + rgb += np.array(hex_to_rgb(color), dtype=np.float32) * mask[..., None] * strength + + noise = ((np.sin(x * 90) + np.cos(y * 70)) * 2.0)[..., None] + rgb = np.clip(rgb + noise, 0, 255).astype(np.uint8) + bg = Image.fromarray(rgb, "RGB").convert("RGBA") + draw = ImageDraw.Draw(bg, "RGBA") + + for gx in range(-200, WIDTH + 200, 120): + draw.line([(gx, 0), (gx + 420, HEIGHT)], fill=(255, 255, 255, 9), width=1) + for gy in range(120, HEIGHT, 120): + draw.line([(0, gy), (WIDTH, gy)], fill=(255, 255, 255, 6), width=1) + return bg + + +def draw_logo(draw: ImageDraw.ImageDraw, x: int, y: int, scale: float = 1.0, alpha: int = 255) -> None: + g = with_alpha(COLORS["green"], alpha) + v = with_alpha(COLORS["violet"], alpha) + white = with_alpha(COLORS["white"], alpha) + s = scale + draw.rounded_rectangle([x, y, x + 82 * s, y + 82 * s], radius=int(18 * s), fill=(10, 16, 22, alpha), outline=(110, 231, 255, 45)) + draw.polygon( + [ + (x + 18 * s, y + 58 * s), + (x + 28 * s, y + 38 * s), + (x + 57 * s, y + 38 * s), + (x + 66 * s, y + 58 * s), + ], + fill=v, + ) + draw.rounded_rectangle([x + 23 * s, y + 31 * s, x + 62 * s, y + 42 * s], radius=int(3 * s), fill=g) + draw.polygon([(x + 23 * s, y + 36 * s), (x + 9 * s, y + 34 * s), (x + 11 * s, y + 40 * s), (x + 23 * s, y + 42 * s)], fill=v) + draw.rounded_rectangle([x + 40 * s, y + 13 * s, x + 47 * s, y + 35 * s], radius=int(2 * s), fill=g) + draw.rounded_rectangle([x + 32 * s, y + 8 * s, x + 55 * s, y + 20 * s], radius=int(4 * s), fill=v) + for sx, sy, color, size in [(59, 27, g, 4), (65, 21, with_alpha(COLORS["gold"], alpha), 3), (69, 32, g, 2)]: + draw.ellipse([x + (sx - size) * s, y + (sy - size) * s, x + (sx + size) * s, y + (sy + size) * s], fill=color) + draw.text((x + 102 * s, y + 6 * s), "SolFoundry", font=font(int(44 * s), bold=True, mono=True), fill=white) + draw.text((x + 104 * s, y + 55 * s), "THE AI FACTORY THAT BUILDS ITSELF", font=font(int(15 * s), mono=True), fill=with_alpha(COLORS["muted"], alpha)) + + +def draw_pill(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, color: str, alpha: int = 255) -> None: + x, y = xy + face = font(30, bold=True) + tw, th = text_box(draw, text, face) + draw.rounded_rectangle([x, y, x + tw + 42, y + th + 28], radius=24, fill=(*hex_to_rgb(color), int(alpha * 0.13)), outline=(*hex_to_rgb(color), int(alpha * 0.55)), width=2) + draw.text((x + 21, y + 13), text, font=face, fill=with_alpha(color, alpha)) + + +def draw_panel(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], alpha: int = 225) -> None: + draw.rounded_rectangle(box, radius=34, fill=(13, 24, 31, alpha), outline=(255, 255, 255, 34), width=2) + + +def draw_bottom_progress(draw: ImageDraw.ImageDraw, t: float, duration: float) -> None: + x0, y0, x1, y1 = 420, 996, 1500, 1011 + draw.rounded_rectangle([x0, y0, x1, y1], radius=8, fill=(255, 255, 255, 22)) + progress = clamp(t / duration) + fill_w = int((x1 - x0) * progress) + draw.rounded_rectangle([x0, y0, x0 + fill_w, y1], radius=8, fill=hex_to_rgb(COLORS["green"]) + (210,)) + labels = ["POST", "ESCROW", "REVIEW", "EARN"] + for i, label in enumerate(labels): + lx = x0 + int((x1 - x0) * (i + 0.5) / 4) + fill = with_alpha(COLORS["white"], 190 if progress > i / 4 else 80) + centered_text(draw, 1042, label, font(18, bold=True, mono=True), fill, x_center=lx) + + +def draw_coin(draw: ImageDraw.ImageDraw, cx: int, cy: int, radius: int, alpha: int = 255) -> None: + draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=with_alpha(COLORS["gold"], alpha), outline=(255, 255, 255, int(alpha * 0.45)), width=3) + centered_text(draw, cy, "F", font(max(18, int(radius * 0.95)), bold=True, mono=True), (12, 19, 25, alpha), x_center=cx) + + +def draw_arrow(draw: ImageDraw.ImageDraw, start: tuple[int, int], end: tuple[int, int], color: str, alpha: int = 180) -> None: + sx, sy = start + ex, ey = end + draw.line([start, end], fill=with_alpha(color, alpha), width=6) + angle = math.atan2(ey - sy, ex - sx) + size = 18 + left = (ex - math.cos(angle - 0.55) * size, ey - math.sin(angle - 0.55) * size) + right = (ex - math.cos(angle + 0.55) * size, ey - math.sin(angle + 0.55) * size) + draw.polygon([end, left, right], fill=with_alpha(color, alpha)) + + +def wrap_text(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.ImageFont, max_width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + current: list[str] = [] + for word in words: + trial = " ".join(current + [word]) + if text_box(draw, trial, face)[0] <= max_width or not current: + current.append(word) + else: + lines.append(" ".join(current)) + current = [word] + if current: + lines.append(" ".join(current)) + return lines + + +def draw_multiline(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, face: ImageFont.ImageFont, fill, max_width: int, line_gap: int = 12) -> None: + x, y = xy + for line in wrap_text(draw, text, face, max_width): + draw.text((x, y), line, font=face, fill=fill) + y += text_box(draw, line, face)[1] + line_gap + + +def draw_scene_title(draw: ImageDraw.ImageDraw, headline: str, subline: str, alpha: int) -> None: + centered_text(draw, 176, headline, font(74, bold=True), with_alpha(COLORS["white"], alpha)) + centered_text(draw, 244, subline, font(34), with_alpha(COLORS["muted"], int(alpha * 0.9))) + + +def draw_code_lines(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], labels: list[str], alpha: int) -> None: + x0, y0, x1, _ = box + y = y0 + 48 + for index, label in enumerate(labels): + color = [COLORS["green"], COLORS["cyan"], COLORS["violet"], COLORS["gold"]][index % 4] + draw.rounded_rectangle([x0 + 42, y, x0 + 90, y + 14], radius=7, fill=with_alpha(color, int(alpha * 0.75))) + draw.rounded_rectangle([x0 + 108, y - 5, x1 - 48 - index * 32, y + 19], radius=10, fill=(255, 255, 255, int(alpha * 0.11))) + draw.text((x0 + 44, y + 26), label, font=font(20, mono=True), fill=with_alpha(COLORS["muted"], int(alpha * 0.85))) + y += 86 + + +def draw_check(draw: ImageDraw.ImageDraw, cx: int, cy: int, radius: int, alpha: int) -> None: + draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=with_alpha(COLORS["green"], int(alpha * 0.16)), outline=with_alpha(COLORS["green"], alpha), width=4) + draw.line([(cx - radius * 0.38, cy), (cx - radius * 0.08, cy + radius * 0.30), (cx + radius * 0.45, cy - radius * 0.36)], fill=with_alpha(COLORS["green"], alpha), width=max(4, radius // 7)) + + +def draw_network(draw: ImageDraw.ImageDraw, center: tuple[int, int], pulse: float, alpha: int) -> None: + cx, cy = center + points = [] + for i in range(9): + angle = i * math.tau / 9 + pulse * 0.35 + radius = 120 + (i % 3) * 54 + points.append((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius)) + for i, p1 in enumerate(points): + for j, p2 in enumerate(points): + if i < j and (i + j) % 3 == 0: + draw.line([p1, p2], fill=with_alpha(COLORS["cyan"], int(alpha * 0.20)), width=2) + for index, (px, py) in enumerate(points): + color = [COLORS["green"], COLORS["violet"], COLORS["cyan"]][index % 3] + r = 17 + int(4 * math.sin(pulse * 2 + index)) + draw.ellipse([px - r, py - r, px + r, py + r], fill=with_alpha(color, int(alpha * 0.85)), outline=(255, 255, 255, int(alpha * 0.5)), width=2) diff --git a/assets/promo-video-828/source/palette.py b/assets/promo-video-828/source/palette.py new file mode 100644 index 000000000..568a8a3e3 --- /dev/null +++ b/assets/promo-video-828/source/palette.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +COLORS = { + "ink": "#071015", + "panel": "#0E1820", + "panel_2": "#14222C", + "muted": "#8EA3AE", + "white": "#F4FFF9", + "green": "#14F195", + "violet": "#9945FF", + "cyan": "#6EE7FF", + "gold": "#FFD166", + "pink": "#FF4FD8", + "red": "#FF6B6B", +} + + +def hex_to_rgb(value: str) -> tuple[int, int, int]: + value = value.lstrip("#") + return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4)) + + +def with_alpha(value: str, alpha: int) -> tuple[int, int, int, int]: + r, g, b = hex_to_rgb(value) + return r, g, b, alpha + + +def mix(a: str, b: str, amount: float) -> tuple[int, int, int]: + ar, ag, ab = hex_to_rgb(a) + br, bg, bb = hex_to_rgb(b) + amount = max(0.0, min(1.0, amount)) + return ( + round(ar + (br - ar) * amount), + round(ag + (bg - ag) * amount), + round(ab + (bb - ab) * amount), + ) diff --git a/assets/promo-video-828/source/promo_spec.json b/assets/promo-video-828/source/promo_spec.json new file mode 100644 index 000000000..2bd0ba145 --- /dev/null +++ b/assets/promo-video-828/source/promo_spec.json @@ -0,0 +1,45 @@ +{ + "duration_seconds": 30, + "fps": 24, + "width": 1920, + "height": 1080, + "tagline": "The AI factory that builds itself", + "scenes": [ + { + "start": 0, + "end": 4, + "headline": "SolFoundry", + "subline": "Autonomous bounty-to-build workflows on Solana" + }, + { + "start": 4, + "end": 9, + "headline": "Post a bounty", + "subline": "Turn ideas into funded, builder-ready work" + }, + { + "start": 9, + "end": 14, + "headline": "Fund escrow", + "subline": "Transparent rewards keep the workflow moving" + }, + { + "start": 14, + "end": 20, + "headline": "AI reviews submissions", + "subline": "Automated checks help surface the strongest work" + }, + { + "start": 20, + "end": 26, + "headline": "Earn FNDRY", + "subline": "Contributors get rewarded for useful output" + }, + { + "start": 26, + "end": 30, + "headline": "Forge. Review. Earn.", + "subline": "github.com/SolFoundry/solfoundry" + } + ] +} diff --git a/assets/promo-video-828/source/render_video.py b/assets/promo-video-828/source/render_video.py new file mode 100644 index 000000000..3b6331699 --- /dev/null +++ b/assets/promo-video-828/source/render_video.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import imageio_ffmpeg +import numpy as np +from PIL import Image, ImageDraw + +from audio import render_audio +from draw_utils import ( + HEIGHT, + WIDTH, + build_background, + centered_text, + clamp, + draw_arrow, + draw_bottom_progress, + draw_check, + draw_code_lines, + draw_coin, + draw_logo, + draw_multiline, + draw_network, + draw_panel, + draw_pill, + draw_scene_title, + ease, + fade_window, + font, + lerp, +) +from palette import COLORS, hex_to_rgb, with_alpha + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "source" +FINAL = ROOT / "final" +STORYBOARD = ROOT / "storyboard" +VIDEO_PATH = FINAL / "solfoundry-promo-30s.mp4" +AUDIO_PATH = FINAL / "original-synth-bed.wav" +THUMBNAIL_PATH = FINAL / "thumbnail.png" +CONTACT_SHEET_PATH = STORYBOARD / "contact-sheet.png" + + +def load_spec() -> dict: + with (SOURCE / "promo_spec.json").open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def scene_for_time(spec: dict, t: float) -> dict: + for scene in spec["scenes"]: + if scene["start"] <= t < scene["end"]: + return scene + return spec["scenes"][-1] + + +def draw_intro(draw: ImageDraw.ImageDraw, t: float, alpha: int) -> None: + draw_logo(draw, 625, 330, scale=1.65, alpha=alpha) + centered_text(draw, 640, "Bounties become shipped software", font(46, bold=True), with_alpha(COLORS["white"], alpha)) + centered_text(draw, 702, "Post. Fund. Review. Earn.", font(34), with_alpha(COLORS["muted"], int(alpha * 0.9))) + for index in range(18): + angle = index * math.tau / 18 + t * 0.85 + radius = 225 + (index % 4) * 24 + cx = 960 + math.cos(angle) * radius + cy = 488 + math.sin(angle) * radius * 0.48 + color = [COLORS["green"], COLORS["violet"], COLORS["cyan"], COLORS["gold"]][index % 4] + size = 4 + index % 4 + draw.ellipse([cx - size, cy - size, cx + size, cy + size], fill=with_alpha(color, int(alpha * 0.8))) + + +def draw_bounty(draw: ImageDraw.ImageDraw, t: float, scene: dict, alpha: int) -> None: + p = ease((t - scene["start"]) / (scene["end"] - scene["start"])) + card_x = int(220 + (1 - p) * -120) + draw_panel(draw, (card_x, 348, card_x + 660, 760), alpha=int(alpha * 0.92)) + draw_pill(draw, (card_x + 44, 396), "#835 BOUNTY", COLORS["violet"], alpha) + draw.text((card_x + 48, 484), "Build a sticker pack", font=font(56, bold=True), fill=with_alpha(COLORS["white"], alpha)) + draw_multiline( + draw, + (card_x + 52, 566), + "Clear task, public reward, and a simple path for contributors to submit work.", + font(30), + with_alpha(COLORS["muted"], int(alpha * 0.95)), + 520, + line_gap=13, + ) + draw_code_lines(draw, (card_x, 620, card_x + 660, 750), ["scope.md", "assets/", "PR_SUBMISSION.md"], alpha) + + right_x = int(1050 + (1 - p) * 140) + draw.rounded_rectangle([right_x, 410, right_x + 560, 690], radius=32, fill=(255, 255, 255, int(alpha * 0.07)), outline=with_alpha(COLORS["green"], int(alpha * 0.55)), width=3) + centered_text(draw, 505, "Idea", font(42, bold=True), with_alpha(COLORS["white"], alpha), x_center=right_x + 140) + draw_arrow(draw, (right_x + 240, 550), (right_x + 350, 550), COLORS["green"], int(alpha * 0.82)) + centered_text(draw, 505, "Open bounty", font(42, bold=True), with_alpha(COLORS["white"], alpha), x_center=right_x + 420) + draw.text((right_x + 58, 610), "The work becomes concrete.", font=font(28), fill=with_alpha(COLORS["muted"], int(alpha * 0.95))) + + +def draw_escrow(draw: ImageDraw.ImageDraw, t: float, scene: dict, alpha: int) -> None: + p = clamp((t - scene["start"]) / (scene["end"] - scene["start"])) + sponsor = (430, 570) + escrow = (960, 570) + builder = (1490, 570) + for cx, cy, label, color in [ + (*sponsor, "Sponsor", COLORS["violet"]), + (*escrow, "Escrow", COLORS["green"]), + (*builder, "Builder", COLORS["cyan"]), + ]: + draw.ellipse([cx - 98, cy - 98, cx + 98, cy + 98], fill=with_alpha(color, int(alpha * 0.13)), outline=with_alpha(color, alpha), width=5) + centered_text(draw, cy + 142, label, font(34, bold=True), with_alpha(COLORS["white"], alpha), x_center=cx) + draw_arrow(draw, (550, 570), (820, 570), COLORS["green"], int(alpha * 0.75)) + draw_arrow(draw, (1100, 570), (1370, 570), COLORS["cyan"], int(alpha * 0.75)) + for i in range(5): + phase = (p * 1.55 + i * 0.18) % 1.0 + if phase < 0.58: + x = int(lerp(560, 820, phase / 0.58)) + else: + x = int(lerp(1100, 1370, (phase - 0.58) / 0.42)) + y = 570 + int(math.sin(t * 2.4 + i) * 22) + draw_coin(draw, x, y, 36, int(alpha * (0.45 + phase * 0.45))) + draw.rounded_rectangle([840, 448, 1080, 692], radius=42, fill=with_alpha(COLORS["green"], int(alpha * 0.12)), outline=with_alpha(COLORS["green"], alpha), width=4) + draw.polygon([(960, 492), (1035, 525), (1014, 646), (960, 684), (906, 646), (885, 525)], fill=with_alpha(COLORS["green"], int(alpha * 0.22)), outline=with_alpha(COLORS["green"], alpha)) + centered_text(draw, 590, "FNDRY", font(42, bold=True, mono=True), with_alpha(COLORS["white"], alpha)) + + +def draw_review(draw: ImageDraw.ImageDraw, t: float, scene: dict, alpha: int) -> None: + p = ease((t - scene["start"]) / (scene["end"] - scene["start"])) + draw_panel(draw, (185, 358, 770, 778), alpha=int(alpha * 0.88)) + draw.text((242, 420), "Submission", font=font(44, bold=True), fill=with_alpha(COLORS["white"], alpha)) + draw_code_lines(draw, (205, 482, 745, 740), ["tests pass", "assets included", "scope matched"], alpha) + draw_network(draw, (1220, 565), t, alpha) + draw_check(draw, 1450, 700, 74, int(alpha * p)) + draw.text((1040, 782), "AI-assisted review checks quality signals", font=font(31), fill=with_alpha(COLORS["muted"], int(alpha * 0.95))) + + +def draw_earn(draw: ImageDraw.ImageDraw, t: float, scene: dict, alpha: int) -> None: + p = ease((t - scene["start"]) / (scene["end"] - scene["start"])) + draw_panel(draw, (330, 342, 890, 790), alpha=int(alpha * 0.9)) + draw.ellipse([410, 430, 540, 560], fill=with_alpha(COLORS["cyan"], int(alpha * 0.25)), outline=with_alpha(COLORS["cyan"], alpha), width=4) + draw.rounded_rectangle([385, 590, 565, 714], radius=48, fill=with_alpha(COLORS["cyan"], int(alpha * 0.18)), outline=with_alpha(COLORS["cyan"], int(alpha * 0.65)), width=3) + draw.text((610, 440), "Contributor", font=font(42, bold=True), fill=with_alpha(COLORS["white"], alpha)) + draw.text((610, 504), "Merged work", font=font(32), fill=with_alpha(COLORS["muted"], int(alpha * 0.95))) + draw_check(draw, 784, 646, 56, alpha) + + for i in range(12): + local = (p + i * 0.083) % 1.0 + x = int(1040 + math.sin(local * math.tau + i) * 250) + y = int(600 - local * 270 + math.cos(local * math.tau) * 38) + draw_coin(draw, x, y, 34 + (i % 3) * 5, int(alpha * (0.28 + local * 0.64))) + draw.rounded_rectangle([1060, 648, 1540, 778], radius=34, fill=with_alpha(COLORS["gold"], int(alpha * 0.13)), outline=with_alpha(COLORS["gold"], int(alpha * 0.8)), width=3) + centered_text(draw, 706, "FNDRY reward", font(54, bold=True), with_alpha(COLORS["white"], alpha), x_center=1300) + + +def draw_close(draw: ImageDraw.ImageDraw, t: float, scene: dict, alpha: int) -> None: + steps = [ + ("Post bounty", COLORS["violet"]), + ("Fund escrow", COLORS["green"]), + ("AI review", COLORS["cyan"]), + ("Earn FNDRY", COLORS["gold"]), + ] + x = 260 + y = 470 + for index, (label, color) in enumerate(steps): + box = (x + index * 365, y, x + index * 365 + 290, y + 154) + draw.rounded_rectangle(box, radius=30, fill=with_alpha(color, int(alpha * 0.13)), outline=with_alpha(color, int(alpha * 0.74)), width=3) + centered_text(draw, y + 78, label, font(31, bold=True), with_alpha(COLORS["white"], alpha), x_center=box[0] + 145) + if index < len(steps) - 1: + draw_arrow(draw, (box[2] + 24, y + 78), (box[2] + 84, y + 78), color, int(alpha * 0.82)) + draw_logo(draw, 708, 706, scale=1.05, alpha=alpha) + centered_text(draw, 894, "Build in public. Reward useful work.", font(38), with_alpha(COLORS["muted"], int(alpha * 0.95))) + + +def render_frame(t: float, spec: dict, background: Image.Image) -> Image.Image: + image = background.copy() + draw = ImageDraw.Draw(image, "RGBA") + scene = scene_for_time(spec, t) + alpha = int(255 * fade_window(t, scene["start"], scene["end"])) + + draw_logo(draw, 78, 64, scale=0.74, alpha=210) + draw_bottom_progress(draw, t, spec["duration_seconds"]) + draw_scene_title(draw, scene["headline"], scene["subline"], alpha) + + scene_index = spec["scenes"].index(scene) + if scene_index == 0: + draw_intro(draw, t, alpha) + elif scene_index == 1: + draw_bounty(draw, t, scene, alpha) + elif scene_index == 2: + draw_escrow(draw, t, scene, alpha) + elif scene_index == 3: + draw_review(draw, t, scene, alpha) + elif scene_index == 4: + draw_earn(draw, t, scene, alpha) + else: + draw_close(draw, t, scene, alpha) + + return image.convert("RGB") + + +def render_contact_sheet(spec: dict, background: Image.Image) -> None: + times = [1.5, 5.7, 10.7, 16.7, 22.5, 28.0] + thumbs = [] + for t in times: + frame = render_frame(t, spec, background) + thumbs.append(frame.resize((480, 270), Image.Resampling.LANCZOS)) + + sheet = Image.new("RGB", (1500, 720), hex_to_rgb(COLORS["ink"])) + draw = ImageDraw.Draw(sheet) + draw.text((30, 24), "SolFoundry 30-second promo storyboard", font=font(32, bold=True), fill=hex_to_rgb(COLORS["white"])) + for index, thumb in enumerate(thumbs): + x = 30 + (index % 3) * 490 + y = 116 + (index // 3) * 300 + sheet.paste(thumb, (x, y)) + draw.text((x, y - 35), f"{times[index]:04.1f}s", font=font(25, bold=True, mono=True), fill=hex_to_rgb(COLORS["green"])) + CONTACT_SHEET_PATH.parent.mkdir(parents=True, exist_ok=True) + sheet.save(CONTACT_SHEET_PATH) + + +def render_video(spec: dict, background: Image.Image) -> None: + FINAL.mkdir(parents=True, exist_ok=True) + render_audio(AUDIO_PATH, duration=spec["duration_seconds"]) + + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + cmd = [ + ffmpeg, + "-y", + "-f", + "rawvideo", + "-vcodec", + "rawvideo", + "-s", + f"{WIDTH}x{HEIGHT}", + "-pix_fmt", + "rgb24", + "-r", + str(spec["fps"]), + "-i", + "-", + "-i", + str(AUDIO_PATH), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "24", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "128k", + "-shortest", + str(VIDEO_PATH), + ] + + process = subprocess.Popen(cmd, stdin=subprocess.PIPE) + total_frames = int(spec["duration_seconds"] * spec["fps"]) + for index in range(total_frames): + t = index / spec["fps"] + frame = render_frame(t, spec, background) + if index == int(28.0 * spec["fps"]): + frame.save(THUMBNAIL_PATH) + process.stdin.write(np.asarray(frame, dtype=np.uint8).tobytes()) + if index % spec["fps"] == 0: + print(f"rendered {index // spec['fps']:02d}s / {spec['duration_seconds']}s", flush=True) + process.stdin.close() + code = process.wait() + if code != 0: + raise RuntimeError(f"ffmpeg exited with {code}") + + +def main() -> int: + spec = load_spec() + if spec["width"] != WIDTH or spec["height"] != HEIGHT: + raise ValueError("Spec dimensions must match renderer dimensions.") + background = build_background() + render_contact_sheet(spec, background) + render_video(spec, background) + print(f"wrote {VIDEO_PATH}") + print(f"wrote {THUMBNAIL_PATH}") + print(f"wrote {CONTACT_SHEET_PATH}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/promo-video-828/storyboard/contact-sheet.png b/assets/promo-video-828/storyboard/contact-sheet.png new file mode 100644 index 000000000..d44873948 Binary files /dev/null and b/assets/promo-video-828/storyboard/contact-sheet.png differ