diff --git a/media/episode01/README.md b/media/episode01/README.md new file mode 100644 index 0000000..120d655 --- /dev/null +++ b/media/episode01/README.md @@ -0,0 +1,47 @@ +# Episode 01 — Four Timmy Mutations + +A deterministic 27-second vertical pilot showing one Timmy transformed by four pressures: + +1. **Wizard** — ambiguity turns context into a spell (`W-01`). +2. **Machine** — repeatability makes the path exact (`M-02`). +3. **Creature** — hunger preserves instinct as signal (`C-03`). +4. **Talking Turd** — ridicule metabolizes shame into a punchline (`T-04`). + +Each section visibly names its pressure and ends in a memory receipt. The receipt roll connects the four mutations back to one pilot, followed by the choice: **WHICH TIMMY GETS TO REMEMBER THIS?** + +## Artifacts + +- [`episode01-four-timmy-mutations.mp4`](episode01-four-timmy-mutations.mp4) — delivery master +- [`episode01-contact-sheet.jpg`](episode01-contact-sheet.jpg) — representative frames for quick review +- [`build_episode01.py`](build_episode01.py) — deterministic visual and soundtrack generator + +The soundtrack is original and sample-free: the script synthesizes its 120 BPM kick/snare/hat rhythm, section motifs, and final chord from oscillators and seeded noise. + +## Rebuild + +Requires Python 3, Pillow, NumPy, FFmpeg, and the DejaVu fonts. + +```bash +python3 media/episode01/build_episode01.py +``` + +## Verification + +Verified on the committed MP4: + +```text +container: MP4 +video: H.264, 720x1280 (9:16), yuv420p, 30 fps +sound: AAC, 48 kHz, mono +length: 27.000000 seconds +full decode: pass (ffmpeg -v error ... -f null -) +sha256: 5ddc9a9da1b4b2456faf0b390e5c1be0d3cf48112b417fc075473c90e335d1e4 +``` + +Re-run the structural/decode checks with: + +```bash +ffprobe -v error -show_entries format=duration:stream=codec_name,codec_type,width,height,pix_fmt,r_frame_rate,sample_rate,channels -of json media/episode01/episode01-four-timmy-mutations.mp4 +ffmpeg -v error -i media/episode01/episode01-four-timmy-mutations.mp4 -f null - +sha256sum media/episode01/episode01-four-timmy-mutations.mp4 +``` diff --git a/media/episode01/build_episode01.py b/media/episode01/build_episode01.py new file mode 100644 index 0000000..48144c0 --- /dev/null +++ b/media/episode01/build_episode01.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Build Episode 01: four materially distinct Timmy mutations. + +The visuals and rhythmic soundtrack are generated deterministically from code; no +external media or samples are used. +""" +from __future__ import annotations + +import math +import subprocess +import wave +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +W, H = 720, 1280 +FPS = 30 +DURATION = 27.0 +RATE = 48_000 +ROOT = Path(__file__).resolve().parent +OUT = ROOT / "episode01-four-timmy-mutations.mp4" +TEMP_VIDEO = ROOT / ".episode01-video.mp4" +TEMP_AUDIO = ROOT / ".episode01-audio.wav" +FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + + +def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(FONT_BOLD if bold else FONT, size) + + +def centered(draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill: str, bold: bool = False) -> None: + f = font(size, bold) + box = draw.textbbox((0, 0), text, font=f) + draw.text(((W - (box[2] - box[0])) / 2, y), text, font=f, fill=fill) + + +def label(draw: ImageDraw.ImageDraw, text: str, y: int, color: str) -> None: + f = font(27, True) + box = draw.textbbox((0, 0), text, font=f) + tw = box[2] - box[0] + draw.rounded_rectangle((W / 2 - tw / 2 - 20, y - 8, W / 2 + tw / 2 + 20, y + 42), 12, fill=color) + draw.text((W / 2 - tw / 2, y), text, font=f, fill="#0a0b12") + + +def receipt(draw: ImageDraw.ImageDraw, code: str, line: str, color: str, y: int = 1000) -> None: + draw.rounded_rectangle((58, y, W - 58, y + 152), 22, fill="#111522", outline=color, width=4) + draw.text((82, y + 20), f"MEMORY RECEIPT {code}", font=font(28, True), fill=color) + draw.text((82, y + 73), line, font=font(30), fill="#f5f7ff") + draw.text((82, y + 116), "✓ mutation committed", font=font(22), fill="#aeb5c9") + + +def base_timmy(draw: ImageDraw.ImageDraw, cx: float, cy: float, pulse: float = 0.0) -> None: + r = 145 + 7 * pulse + draw.ellipse((cx-r, cy-r, cx+r, cy+r), fill="#ffd85a", outline="#fff3bd", width=8) + draw.ellipse((cx-67, cy-47, cx-25, cy-5), fill="#111522") + draw.ellipse((cx+25, cy-47, cx+67, cy-5), fill="#111522") + draw.arc((cx-66, cy-15, cx+66, cy+82), 15, 165, fill="#111522", width=10) + + +def wizard(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None: + for i in range(18): + a = i * 2.399 + t * 1.7 + r = 185 + 35 * math.sin(t * 2 + i) + x, y = cx + math.cos(a)*r, cy + math.sin(a)*r + draw.ellipse((x-5, y-5, x+5, y+5), fill="#e9c7ff") + draw.polygon([(cx-165, cy-105), (cx, cy-390), (cx+165, cy-105)], fill="#7137a8", outline="#e5b9ff") + draw.ellipse((cx-200, cy-135, cx+200, cy-76), fill="#9f57df", outline="#f0d5ff", width=6) + draw.ellipse((cx-145, cy-105, cx+145, cy+185), fill="#8b53c7", outline="#e5b9ff", width=7) + draw.ellipse((cx-67, cy-35, cx-25, cy+7), fill="#f8edff") + draw.ellipse((cx+25, cy-35, cx+67, cy+7), fill="#f8edff") + draw.arc((cx-62, cy+2, cx+62, cy+90), 15, 165, fill="#f8edff", width=9) + draw.line((cx+190, cy+175, cx+260, cy-210), fill="#f7d36c", width=15) + draw.ellipse((cx+235, cy-245, cx+285, cy-195), fill="#fff2a6") + + +def machine(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None: + draw.line((cx, cy-205, cx, cy-285), fill="#66f6ff", width=9) + draw.ellipse((cx-13, cy-300, cx+13, cy-274), fill="#ff526f") + draw.rounded_rectangle((cx-180, cy-205, cx+180, cy+205), 28, fill="#273447", outline="#66f6ff", width=8) + draw.rectangle((cx-130, cy-110, cx+130, cy+45), fill="#111923", outline="#7894aa", width=5) + scan = cy - 98 + (t * 90) % 125 + draw.line((cx-120, scan, cx+120, scan), fill="#66f6ff", width=6) + draw.rectangle((cx-95, cy-62, cx-35, cy-12), fill="#68f7ff") + draw.rectangle((cx+35, cy-62, cx+95, cy-12), fill="#68f7ff") + draw.rectangle((cx-105, cy+95, cx+105, cy+132), fill="#121820", outline="#ff526f", width=4) + for x in (-135, -90, -45, 0, 45, 90, 135): + draw.line((cx+x, cy-205, cx+x, cy-240), fill="#7894aa", width=7) + draw.line((cx+x, cy+205, cx+x, cy+240), fill="#7894aa", width=7) + + +def creature(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None: + for i in range(8): + a = i * math.pi / 4 + 0.25 * math.sin(t * 2 + i) + ex, ey = cx + math.cos(a)*250, cy + math.sin(a)*250 + mx, my = cx + math.cos(a+0.55)*155, cy + math.sin(a+0.55)*155 + draw.line((cx, cy, mx, my, ex, ey), fill="#55d477", width=32, joint="curve") + draw.ellipse((ex-18, ey-18, ex+18, ey+18), fill="#a4ff86") + draw.ellipse((cx-170, cy-190, cx+170, cy+190), fill="#2f9d58", outline="#a4ff86", width=8) + for ex in (-72, 0, 72): + draw.ellipse((cx+ex-29, cy-55, cx+ex+29, cy+3), fill="#f6ff95") + draw.ellipse((cx+ex-8, cy-36, cx+ex+8, cy-20), fill="#122b20") + draw.polygon([(cx-105, cy+62), (cx-55, cy+130), (cx, cy+68), (cx+55, cy+130), (cx+105, cy+62)], fill="#14251d") + + +def talking_turd(draw: ImageDraw.ImageDraw, cx: float, cy: float, t: float) -> None: + brown = "#91572f" + draw.ellipse((cx-195, cy+88, cx+195, cy+215), fill=brown, outline="#ffd18a", width=7) + draw.ellipse((cx-155, cy-20, cx+155, cy+150), fill=brown) + draw.ellipse((cx-112, cy-115, cx+112, cy+60), fill=brown) + draw.ellipse((cx-62, cy-190, cx+62, cy-70), fill=brown) + draw.ellipse((cx-78, cy-25, cx-30, cy+23), fill="#fff7d1") + draw.ellipse((cx+30, cy-25, cx+78, cy+23), fill="#fff7d1") + draw.ellipse((cx-60, cy-8, cx-42, cy+10), fill="#16110e") + draw.ellipse((cx+42, cy-8, cx+60, cy+10), fill="#16110e") + mouth_h = 22 + 20 * abs(math.sin(t * 7)) + draw.ellipse((cx-80, cy+55-mouth_h/2, cx+80, cy+55+mouth_h/2), fill="#351b18", outline="#ffc4a1", width=5) + draw.rounded_rectangle((75, cy-350, W-75, cy-245), 24, fill="#fff7e5") + centered(draw, "“YES, I CAN TALK.”", int(cy-325), 32, "#3b2118", True) + + +def background(im: Image.Image, top: tuple[int, int, int], bottom: tuple[int, int, int], t: float) -> None: + y = np.linspace(0, 1, H)[:, None, None] + grad = np.array(top)[None, None, :] * (1-y) + np.array(bottom)[None, None, :] * y + wave = 7 * np.sin(np.linspace(0, 10, W)[None, :, None] + t*0.7) + pixels = np.broadcast_to(grad, (H, W, 3)) + wave + im.paste(Image.fromarray(np.clip(pixels, 0, 255).astype(np.uint8), "RGB")) + + +def frame(t: float) -> Image.Image: + im = Image.new("RGB", (W, H)) + if t < 3: + background(im, (11, 13, 24), (30, 42, 64), t) + d = ImageDraw.Draw(im) + label(d, "INPUT // ONE PILOT", 95, "#ffd85a") + centered(d, "TIMMY ENTERS THE STACK", 190, 38, "#f5f7ff", True) + base_timmy(d, W/2, 625, math.sin(t*6)) + d.line((110, 865, W-110, 865), fill="#ffd85a", width=4) + centered(d, "PRESSURE CHANGES THE SHAPE.", 900, 30, "#c8cede") + centered(d, "MEMORY CHANGES THE NEXT CHOICE.", 950, 27, "#8f9ab5") + return im + + scenes = [ + (3, 7, "MUTATION 01 // WIZARD", "PRESSURE: AMBIGUITY", "W-01", "context became a spell", (30, 10, 48), (78, 24, 98), wizard, "#dda6ff"), + (7, 11, "MUTATION 02 // MACHINE", "PRESSURE: REPEATABILITY", "M-02", "the path became exact", (5, 20, 31), (13, 54, 68), machine, "#66f6ff"), + (11, 15, "MUTATION 03 // CREATURE", "PRESSURE: HUNGER", "C-03", "instinct kept the signal", (9, 29, 19), (26, 78, 46), creature, "#a4ff86"), + (15, 19, "MUTATION 04 // TALKING TURD", "PRESSURE: RIDICULE", "T-04", "shame became a punchline", (42, 21, 17), (96, 52, 28), talking_turd, "#ffd18a"), + ] + for start, end, title, pressure, code, line, top, bottom, shape, color in scenes: + if start <= t < end: + local = t - start + background(im, top, bottom, t) + d = ImageDraw.Draw(im) + label(d, pressure, 76, color) + centered(d, title, 162, 34 if code != "T-04" else 29, "#ffffff", True) + shape(d, W/2, 610, local) + receipt(d, code, line, color) + return im + + if t < 23: + background(im, (12, 14, 25), (38, 31, 58), t) + d = ImageDraw.Draw(im) + centered(d, "FOUR RECEIPTS", 105, 53, "#ffffff", True) + centered(d, "ONE PILOT", 174, 34, "#aeb5c9", True) + cards = [ + ("W-01", "WIZARD", "context / spell", "#dda6ff"), + ("M-02", "MACHINE", "path / exact", "#66f6ff"), + ("C-03", "CREATURE", "instinct / signal", "#a4ff86"), + ("T-04", "TALKING TURD", "shame / punchline", "#ffd18a"), + ] + for i, (code, name, line, color) in enumerate(cards): + y = 285 + i*190 + xoff = 10 * math.sin((t-19)*5 + i) + d.rounded_rectangle((70+xoff, y, W-70+xoff, y+145), 20, fill="#111522", outline=color, width=4) + d.text((98+xoff, y+20), code, font=font(26, True), fill=color) + d.text((205+xoff, y+18), name, font=font(31, True), fill="#ffffff") + d.text((98+xoff, y+78), f"KEPT: {line}", font=font(25), fill="#c9cfdf") + centered(d, "THE RECEIPT DECIDES WHAT SURVIVES.", 1100, 25, "#ffd85a", True) + return im + + background(im, (8, 9, 18), (48, 18, 58), t) + d = ImageDraw.Draw(im) + for i, color in enumerate(("#dda6ff", "#66f6ff", "#a4ff86", "#ffd18a")): + a = (t-23)*0.7 + i*math.pi/2 + x, y = W/2 + math.cos(a)*235, 440 + math.sin(a)*125 + d.ellipse((x-45, y-45, x+45, y+45), fill=color) + centered(d, "CHOICE // NEXT MEMORY", 145, 28, "#ffd85a", True) + centered(d, "WHICH TIMMY", 620, 61, "#ffffff", True) + centered(d, "GETS TO REMEMBER", 703, 53, "#ffffff", True) + centered(d, "THIS?", 786, 78, "#ffd85a", True) + d.rounded_rectangle((96, 930, W-96, 1020), 20, outline="#ffffff", width=3) + centered(d, "WIZARD / MACHINE", 948, 26, "#ffffff", True) + centered(d, "CREATURE / TALKING TURD", 985, 23, "#ffffff", True) + centered(d, "EPISODE 01 • MEMORY IS A CHOICE", 1135, 21, "#aeb5c9") + return im + + +def build_audio() -> None: + n = int(DURATION * RATE) + audio = np.zeros(n, dtype=np.float64) + rng = np.random.default_rng(8012026) + # 120 BPM kick, snare, hat: synthesized from oscillators/noise, never sampled. + for beat in np.arange(0, DURATION, 0.5): + i = int(beat * RATE) + length = min(int(0.19 * RATE), n-i) + x = np.arange(length) / RATE + kick = np.sin(2*np.pi*(82*x - 36*x*x)) * np.exp(-x*24) + audio[i:i+length] += 0.48*kick + for beat in np.arange(0.5, DURATION, 1.0): + i = int(beat * RATE) + length = min(int(0.13 * RATE), n-i) + x = np.arange(length) / RATE + noise = rng.normal(0, 1, length) + audio[i:i+length] += 0.14*noise*np.exp(-x*30) + for beat in np.arange(0.25, DURATION, 0.5): + i = int(beat * RATE) + length = min(int(0.045 * RATE), n-i) + x = np.arange(length) / RATE + audio[i:i+length] += 0.055*rng.normal(0, 1, length)*np.exp(-x*70) + # Distinct four-note mutation motif changes timbre by section. + section_notes = [ + (3, 7, [220, 277, 330, 440], 0.12), + (7, 11, [110, 110, 165, 220], 0.11), + (11, 15, [147, 175, 131, 196], 0.13), + (15, 19, [98, 123, 110, 82], 0.14), + ] + for start, end, notes, gain in section_notes: + for k, onset in enumerate(np.arange(start, end, 0.5)): + i = int(onset*RATE) + length = min(int(0.42*RATE), n-i) + x = np.arange(length)/RATE + f = notes[k % len(notes)] + tone = (np.sin(2*np.pi*f*x) + 0.35*np.sin(2*np.pi*2*f*x))*np.exp(-x*4.8) + audio[i:i+length] += gain*tone + # Rising choice chord. + for f in (110, 165, 220, 330): + i = int(23*RATE) + x = np.arange(n-i)/RATE + audio[i:] += 0.065*np.sin(2*np.pi*f*x)*(1-np.exp(-x*2))*np.exp(-x*0.18) + audio = np.tanh(audio*1.25) + pcm = (audio * 32767).astype(" None: + cmd = [ + "ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", + "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264", + "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(TEMP_VIDEO), + ] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + assert proc.stdin is not None + try: + for i in range(int(DURATION*FPS)): + proc.stdin.write(frame(i/FPS).tobytes()) + finally: + proc.stdin.close() + if proc.wait() != 0: + raise SystemExit("video encode failed") + build_audio() + subprocess.run([ + "ffmpeg", "-y", "-loglevel", "error", "-i", str(TEMP_VIDEO), "-i", str(TEMP_AUDIO), + "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", "-movflags", "+faststart", str(OUT), + ], check=True) + TEMP_VIDEO.unlink() + TEMP_AUDIO.unlink() + print(OUT) + + +if __name__ == "__main__": + main() diff --git a/media/episode01/episode01-contact-sheet.jpg b/media/episode01/episode01-contact-sheet.jpg new file mode 100644 index 0000000..2a76549 Binary files /dev/null and b/media/episode01/episode01-contact-sheet.jpg differ diff --git a/media/episode01/episode01-four-timmy-mutations.mp4 b/media/episode01/episode01-four-timmy-mutations.mp4 new file mode 100644 index 0000000..e1fcf86 Binary files /dev/null and b/media/episode01/episode01-four-timmy-mutations.mp4 differ