<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AI Meme Video Notes]]></title><description><![CDATA[AI Meme Video Notes]]></description><link>https://lpgit.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:16:15 GMT</lastBuildDate><atom:link href="https://lpgit.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How Do You Show a Vertical Bama Bound Clip on a TV, Projector or YouTube Without Black Bars? A Blurred-Background 16:9 Frame in ffmpeg]]></title><description><![CDATA[Checked November 12, 2026. I build bamabound.app, a studio that turns one photo of one person into a vertical 9:16 clip of them holding a red I'M BAMA…]]></description><link>https://lpgit.hashnode.dev/how-do-you-show-a-vertical-bama-bound-clip-on-a-tv-projector-or-youtube-without-black-bars-a-blurred-background-16-9-frame-in-ffmpeg</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-show-a-vertical-bama-bound-clip-on-a-tv-projector-or-youtube-without-black-bars-a-blurred-background-16-9-frame-in-ffmpeg</guid><category><![CDATA[FFmpeg]]></category><category><![CDATA[video]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[Bash]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 12 Nov 2026 07:10:20 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 12, 2026. I build <a href="https://bamabound.app/">bamabound.app</a>, a studio that turns one photo of one person into a vertical 9:16 clip of them holding a red I'M BAMA BOUND banner. Everything below was run on a synthetic test clip (a red box over a green field), not on a real person.</em></p>
<p>The clip comes out vertical, in a 9:16 frame of about eight seconds (<a href="https://bamabound.app/faq/">FAQ</a>). That's right for a phone. Then someone asks:</p>
<blockquote>
<p>"How do I play my Bama Bound video on the TV at the grad party / in a class slideshow / as a normal YouTube video without two huge black bars?"</p>
</blockquote>
<p>The usual fix is the one TV news uses for viewer phone footage: a blurred, zoomed copy of the same clip fills the sides, and the sharp original sits in the middle at full height. Here is a small script for it, plus one gotcha that matters for this meme in particular.</p>
<h2>The script</h2>
<pre><code class="language-bash">#!/usr/bin/env bash
# Put a vertical 9:16 clip inside a 16:9 frame for YouTube, a TV or a projector:
# a blurred, darkened, zoomed copy fills the sides, the sharp original sits in
# the middle at full height. Audio is copied untouched.
#   ./landscape.sh in.mp4 out.mp4 [1920x1080] [blur]
set -euo pipefail
in=$1; out=$2; size=${3:-1920x1080}; blur=${4:-20}
W=${size%x*}; H=${size#*x}
ffmpeg -v error -y -i "$in" -filter_complex "\
[0:v]split[a][b];\
[a]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},\
gblur=sigma=${blur},eq=brightness=-0.12:saturation=0.8[bg];\
[b]scale=-2:${H}[fg];\
[bg][fg]overlay=(W-w)/2:0,setsar=1,format=yuv420p[v]" \
  -map "[v]" -map 0:a? -c:v libx264 -preset veryfast -crf 20 -c:a copy -movflags +faststart "$out"
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,sample_aspect_ratio,display_aspect_ratio -of csv=p=0 "$out"
</code></pre>
<p>What each piece does:</p>
<ul>
<li><code>split</code> makes two copies of the decoded frames.</li>
<li><strong>Background:</strong> <code>scale ... force_original_aspect_ratio=increase</code> grows the vertical frame until it <em>covers</em> 1920x1080, <code>crop</code> cuts it to exactly that, <code>gblur</code> blurs it, and <code>eq</code> darkens and desaturates it a bit so it doesn't compete with the real clip.</li>
<li><strong>Foreground:</strong> <code>scale=-2:H</code> fits the original to the full height and keeps its shape. <code>-2</code> keeps the width even, which H.264 requires.</li>
<li><code>overlay=(W-w)/2:0</code> centres it.</li>
<li><code>setsar=1</code> stops some players from stretching the result.</li>
<li><code>-c:a copy</code> leaves the original soundtrack alone, and <code>0:a?</code> means a clip without audio doesn't make it fail.</li>
</ul>
<h2>Run it</h2>
<pre><code class="language-bash">./landscape.sh bama.mp4 bama-16x9.mp4              # 1920,1080,1:1,16:9
./landscape.sh bama.mp4 bama-720p.mp4 1280x720     # 1280,720,1:1,16:9
</code></pre>
<p>On my 720x1280 test clip both outputs came out with square pixels, a 16:9 display aspect and exactly the same 8.000 s duration as the input.</p>
<h2>The gotcha: a giant blurry second banner</h2>
<p>Zooming the vertical frame to cover 16:9 makes it about 3.2 times bigger than the sharp copy in the middle, so the <strong>red banner shows up again behind the real one as a huge blurred red block</strong>. In my test frame you can still read part of the letters at blur 20. On a meme whose whole point is the text on the sign, that looks like a doubled sign and pulls the eye away from the face.</p>
<p>Three fixes, cheapest first:</p>
<ol>
<li><strong>Blur harder.</strong> Pass a fourth argument: <code>./landscape.sh in.mp4 out.mp4 1920x1080 45</code>. At high sigma the banner turns into a red glow and the text is gone.</li>
<li><strong>Darken more.</strong> Change <code>brightness=-0.12</code> to <code>-0.3</code>.</li>
<li><strong>Drop the blur, use a flat colour.</strong> Replace the background branch with <code>color=c=0x1a1a1a:s=${W}x${H}</code> and <code>overlay</code> onto it. It's boring, but nothing distracts.</li>
</ol>
<p>For a TV across a room, option 1 looks best. For a projected slideshow with text slides before and after, option 3 matches better.</p>
<h2>What resolution to start from</h2>
<p>The side areas are blurred anyway, so they don't need detail. What matters is the centre strip. In a 1080-tall frame that strip is 608 px wide. A 720p vertical clip is 720 px wide, so it's already a bit bigger than the strip and gets scaled down. For a 1080p TV, a 720p source is enough. For a 4K TV the strip is 1216 px wide, so a 1080p or 4k source avoids upscaling.</p>
<p>Prices on the live <a href="https://bamabound.app/pricing/">pricing page</a> today: 480p $7.98, 720p $9.98, 1080p $16.98, 4k $24.98 per generation, tax added at checkout, no subscription.</p>
<h2>Before you play it in a room full of people</h2>
<p>It's a meme clip, not an admission letter. Only use a photo of someone who agreed to it (<a href="https://bamabound.app/acceptable-use/">acceptable use</a>), and if the audience includes people who might take it literally, say so on a slide before it plays.</p>
]]></content:encoded></item><item><title><![CDATA[Will This Photo Work for My Tiny Desk AI Seat? A Pillow-Only Preflight for Size, Blur, Exposure and EXIF Rotation]]></title><description><![CDATA[A face-swap video generator can only work with the face it can see. When someone asks "why does my Tiny Desk AI clip look like a stranger?", the cause is…]]></description><link>https://lpgit.hashnode.dev/will-this-photo-work-for-my-tiny-desk-ai-seat-a-pillow-only-preflight-for-size-blur-exposure-and-exif-rotation</link><guid isPermaLink="true">https://lpgit.hashnode.dev/will-this-photo-work-for-my-tiny-desk-ai-seat-a-pillow-only-preflight-for-size-blur-exposure-and-exif-rotation</guid><category><![CDATA[Python]]></category><category><![CDATA[image processing]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 12 Nov 2026 04:07:26 GMT</pubDate><content:encoded><![CDATA[<p>A face-swap video generator can only work with the face it can see. When someone asks <em>"why does my Tiny Desk AI clip look like a stranger?"</em>, the cause is usually upstream of the model: a 300-pixel crop from a group chat, a photo taken in a dim bar, or a phone picture whose EXIF says "rotate 90°" and gets read sideways.</p>
<p>On <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a> each photo goes into one seat (lead singer, sax, keyboard, back left, back right), and the studio's <a href="https://tinydeskoutstanding.video/blog/photo-tips">photo tips</a> ask for one front-facing photo per selected role. Since every seat you fill is part of the same quote ($9.98 at 480p up to $39.98 at 4K on the live price table today), it is worth thirty seconds of checking before you press generate. Here is a preflight script that needs only Pillow and numpy.</p>
<h2>What it checks</h2>
<table>
<thead>
<tr>
<th>Check</th>
<th>Why it matters</th>
<th>Threshold</th>
</tr>
</thead>
<tbody><tr>
<td>Short side in pixels</td>
<td>The face is a fraction of the frame; a small photo means a tiny face</td>
<td>512 px</td>
</tr>
<tr>
<td>Sharpness (variance of the Laplacian)</td>
<td>Motion blur and heavy compression erase the features identity depends on</td>
<td>60 on a 512-px-wide grey copy</td>
</tr>
<tr>
<td>Mean luminance</td>
<td>Dark photos lose eye and mouth detail; blown-out ones lose skin texture</td>
<td>60 to 200</td>
</tr>
<tr>
<td>Clipped pixels</td>
<td>Pure black/white patches are detail that is gone for good</td>
<td>5 %</td>
</tr>
<tr>
<td>EXIF orientation</td>
<td>Some pipelines ignore it and see your face sideways</td>
<td>warn only</td>
</tr>
</tbody></table>
<p>The thresholds are starting points I tuned on my own test photos, not values published by the site. Adjust them to your camera.</p>
<h2>The script</h2>
<pre><code class="language-python">"""Preflight a face photo before assigning it to a seat. Pillow + numpy only.

usage: python preflight.py photo.jpg [more.jpg ...]
"""
import sys
import numpy as np
from PIL import Image, ImageOps

MIN_SHORT_SIDE = 512      # below this the face is a few hundred pixels at best
BLUR_THRESHOLD = 60.0     # variance of the Laplacian on a 512px-wide grey copy
DARK, BRIGHT = 60, 200    # mean luminance bounds (0-255)
CLIP_LIMIT = 0.05         # max share of pixels pinned at 0 or 255

def laplacian_var(grey):
    g = grey.astype(np.float32)
    lap = (-4 * g[1:-1, 1:-1] + g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:])
    return float(lap.var())

def check(path):
    raw = Image.open(path)
    rotated = raw.getexif().get(0x0112, 1) not in (1, None)
    img = ImageOps.exif_transpose(raw).convert("RGB")
    w, h = img.size
    small = img.resize((512, max(1, round(512 * h / w))))
    grey = np.asarray(small.convert("L"))
    problems = []
    if min(w, h) &lt; MIN_SHORT_SIDE:
        problems.append(f"small: {w}x{h}, short side under {MIN_SHORT_SIDE}px")
    sharp = laplacian_var(grey)
    if sharp &lt; BLUR_THRESHOLD:
        problems.append(f"soft/blurry: sharpness {sharp:.0f} &lt; {BLUR_THRESHOLD:.0f}")
    mean = grey.mean()
    if mean &lt; DARK:
        problems.append(f"dark: mean luminance {mean:.0f}")
    elif mean &gt; BRIGHT:
        problems.append(f"washed out: mean luminance {mean:.0f}")
    clipped = float(((grey &lt;= 2) | (grey &gt;= 253)).mean())
    if clipped &gt; CLIP_LIMIT:
        problems.append(f"clipped: {clipped:.0%} of pixels pure black/white")
    note = " (EXIF says rotate; preview it upright)" if rotated else ""
    return problems, f"{w}x{h} sharp={sharp:.0f} mean={mean:.0f}{note}"

if __name__ == "__main__":
    bad = 0
    for p in sys.argv[1:]:
        problems, info = check(p)
        print(("FIX " if problems else "OK  ") + p + "  " + info)
        for msg in problems:
            print("    - " + msg)
        bad += bool(problems)
    sys.exit(1 if bad else 0)
</code></pre>
<p>A few implementation notes:</p>
<ul>
<li><strong><code>ImageOps.exif_transpose</code> first.</strong> Every measurement runs on the upright image, and the script tells you if the file depends on the EXIF tag. If you then re-save it with Pillow, the pixels are upright and the tag is gone, which removes the ambiguity for whatever reads it next.</li>
<li><strong>The Laplacian is five array slices.</strong> No OpenCV needed: the 4-neighbour kernel is the centre times -4 plus its four neighbours. The variance of that response is a cheap, well-known focus measure.</li>
<li><strong>Measure on a fixed width.</strong> Sharpness scores aren't comparable across resolutions, so everything is resized to 512 px wide first.</li>
</ul>
<h2>Trying it on synthetic photos</h2>
<pre><code class="language-python">"""Make four synthetic test photos: good, blurry, dark, tiny."""
import numpy as np
from PIL import Image, ImageFilter
rng = np.random.default_rng(7)
base = (rng.integers(0, 255, (1200, 900, 3))).astype(np.uint8)
img = Image.fromarray(base).filter(ImageFilter.GaussianBlur(1.2))
img = Image.blend(img, Image.new("RGB", img.size, (128, 110, 100)), 0.5)
img.save("good.jpg", quality=92)
img.filter(ImageFilter.GaussianBlur(6)).save("blurry.jpg", quality=92)
Image.eval(img, lambda v: v // 4).save("dark.jpg", quality=92)
img.resize((300, 400)).save("tiny.jpg", quality=92)
</code></pre>
<pre><code class="language-text">$ python make_test_photos.py &amp;&amp; python preflight.py good.jpg blurry.jpg dark.jpg tiny.jpg
OK  good.jpg  900x1200 sharp=101 mean=120
FIX blurry.jpg  900x1200 sharp=2 mean=120
    - soft/blurry: sharpness 2 &lt; 60
FIX dark.jpg  900x1200 sharp=9 mean=30
    - soft/blurry: sharpness 9 &lt; 60
    - dark: mean luminance 30
FIX tiny.jpg  300x400 sharp=27 mean=120
    - small: 300x400, short side under 512px
    - soft/blurry: sharpness 27 &lt; 60
</code></pre>
<p>Two results are worth reading closely:</p>
<ul>
<li><strong>The dark photo also fails sharpness.</strong> Dividing every pixel by four squashes the local contrast, so the Laplacian response drops by roughly 16× in variance. That's not a bug: a dark photo really does carry less usable edge detail. Fix the exposure first and re-run before deciding it is blurry.</li>
<li><strong>The tiny photo fails sharpness after upscaling.</strong> Blowing a 300-px image up to 512 px interpolates new pixels, which are smooth by construction. Small and soft travel together, which is why cropping a face out of a group shot so often disappoints.</li>
</ul>
<h2>Running it per seat</h2>
<p>The exit code is 1 if any file needs fixing, so you can gate a batch:</p>
<pre><code class="language-bash">python preflight.py lead.jpg sax.jpg keys.jpg back_left.jpg back_right.jpg &amp;&amp; echo "ready for Whole Band"
</code></pre>
<p>If only one of five fails, you don't have to wait for a better photo of everyone. An empty seat keeps the original performer, and the price is the same whether you fill one seat or five, so leave the weak seat empty and fill it in a later run.</p>
<h2>What a preflight can't catch</h2>
<p>It can't tell you the face is turned 45° away, hidden by sunglasses or a hand, or that it is someone who didn't agree to be in the clip. The site's <a href="https://tinydeskoutstanding.video/terms">terms</a> put the permission part on you: you need permission to use the faces you upload. For the scene choice itself (Just Me, Back Row or Whole Band) see the <a href="https://tinydeskoutstanding.video/blog/choose-your-seat">seat guide</a>.</p>
<p><em>Independent studio for the "Outstanding" Tiny Desk parody; not affiliated with NPR.</em></p>
]]></content:encoded></item><item><title><![CDATA[How Do You Slow Down the "State?" Moment in a Hit the Road Dude Clip Without a Chipmunk or Demon Voice? Segment Slow-Mo With ffmpeg]]></title><description><![CDATA[The best second of a Hit the Road Dude clip is the driver's puzzled "State?", the beat in the State Property pickup-window meme right after the visitor…]]></description><link>https://lpgit.hashnode.dev/how-do-you-slow-down-the-state-moment-in-a-hit-the-road-dude-clip-without-a-chipmunk-or-demon-voice-segment-slow-mo-with-ffmpeg</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-slow-down-the-state-moment-in-a-hit-the-road-dude-clip-without-a-chipmunk-or-demon-voice-segment-slow-mo-with-ffmpeg</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 12 Nov 2026 01:20:30 GMT</pubDate><content:encoded><![CDATA[<p>The best second of a Hit the Road Dude clip is the driver's puzzled "State?", the beat in the State Property pickup-window meme right after the visitor calls the spot state property. A common edit is to slow just that moment down for emphasis and leave the rest at normal speed. Ask an assistant how and you usually get one of two answers that don't work: slow the whole video (the audio drifts out of sync), or slow the audio by changing its sample rate (the voice drops an octave). This post does it properly: one segment slowed, pitch kept, everything else untouched, and measured.</p>
<p>Context: <a href="https://hittheroaddude.app/">hittheroaddude.app</a>, which I build, makes the clip as 15 seconds, vertical 9:16, at 480p or 720p with a newly generated voice (<a href="https://hittheroaddude.app/faq/">FAQ</a>). The scripts work on any MP4. I tested them on a synthetic clip with a steady 440 Hz tone, because a tone makes a pitch change easy to see in numbers.</p>
<h2>The two wrong answers, measured</h2>
<p>Test clip: 15 seconds, 720×1280, 30 fps, a 440 Hz sine as the sound.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# 15 s, 720x1280, 30 fps test clip whose sound is a steady 440 Hz tone,
# so any pitch change after a slow-down is easy to measure.
set -euo pipefail
ffmpeg -v error -y -f lavfi -i "testsrc2=s=720x1280:r=30:d=15" -f lavfi -i "sine=f=440:d=15:sample_rate=48000" \
  -c:v libx264 -preset veryfast -crf 20 -pix_fmt yuv420p -c:a aac -b:a 160k -shortest clip.mp4
echo "wrote clip.mp4"
</code></pre>
<p><strong>Wrong answer 1: slow only the video.</strong> <code>ffmpeg -i clip.mp4 -vf setpts=2*PTS out.mp4</code> gives a file whose video lasts 29.93 s and whose audio still lasts 15.02 s. The second half plays in silence and the voice comes long before the mouth.</p>
<p><strong>Wrong answer 2: slow the audio by resampling.</strong> <code>asetrate=24000,aresample=48000</code> does stretch the audio to the right length, but it plays the samples at half speed. My pitch checker (below) reads <strong>222.7 Hz</strong> for the whole file instead of 440: one octave down, the "demon voice". Speeding up the same way gives the chipmunk.</p>
<p>What you want is a <strong>time stretch</strong>: longer, same pitch. ffmpeg has two filters for it. <code>atempo</code> is built in everywhere. <code>rubberband</code> sounds smoother on voices but only exists if your ffmpeg was built with librubberband (<code>ffmpeg -filters | grep rubberband</code>).</p>
<h2>The segment recipe</h2>
<p>Cut the clip into three parts, slow the middle part's video with <code>setpts</code> and its audio with <code>atempo</code>, and join them with the <code>concat</code> filter in a single re-encode:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Slow down (or speed up) one part of a clip, keep the rest at normal speed,
and keep the voice at its normal pitch.

usage: slowmo_segment.py in.mp4 out.mp4 --start 6.2 --end 7.4 [--speed 0.5] [--rubberband]
  --speed 0.5   half speed (the segment lasts twice as long); 0.25 = quarter speed
  --rubberband  use the rubberband filter for the audio (smoother on voices, if your ffmpeg has it)
"""
import argparse, json, subprocess

def duration(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", path],
                         capture_output=True, text=True, check=True).stdout
    return float(json.loads(out)["format"]["duration"])

def atempo_chain(speed):
    # atempo accepts 0.5..100 per instance, so chain it for slower speeds (0.25 = 0.5 * 0.5)
    parts = []
    while speed &lt; 0.5:
        parts.append("atempo=0.5"); speed /= 0.5
    parts.append(f"atempo={speed:.6f}")
    return ",".join(parts)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("src"); ap.add_argument("dst")
    ap.add_argument("--start", type=float, required=True); ap.add_argument("--end", type=float, required=True)
    ap.add_argument("--speed", type=float, default=0.5); ap.add_argument("--rubberband", action="store_true")
    a = ap.parse_args()
    D = duration(a.src)
    assert 0 &lt;= a.start &lt; a.end &lt;= D, f"segment must lie inside 0..{D:.2f}s"
    assert 0.1 &lt;= a.speed &lt;= 4, "speed between 0.1 and 4"
    s, e, k = a.start, a.end, a.speed
    slow_a = f"rubberband=tempo={k}" if a.rubberband else atempo_chain(k)
    fc = (
        f"[0:v]split=3[v0][v1][v2];[0:a]asplit=3[a0][a1][a2];"
        f"[v0]trim=0:{s},setpts=PTS-STARTPTS[va];"
        f"[v1]trim={s}:{e},setpts=(PTS-STARTPTS)/{k}[vb];"
        f"[v2]trim={e},setpts=PTS-STARTPTS[vc];"
        f"[a0]atrim=0:{s},asetpts=PTS-STARTPTS[aa];"
        f"[a1]atrim={s}:{e},asetpts=PTS-STARTPTS,{slow_a}[ab];"
        f"[a2]atrim={e},asetpts=PTS-STARTPTS[ac];"
        f"[va][aa][vb][ab][vc][ac]concat=n=3:v=1:a=1[v][a]")
    cmd = ["ffmpeg", "-v", "error", "-y", "-i", a.src, "-filter_complex", fc, "-map", "[v]", "-map", "[a]",
           "-r", "30", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
           "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", a.dst]
    print("running:", " ".join(cmd)); subprocess.run(cmd, check=True)
    want = D + (e - s) * (1 / k - 1)
    got = duration(a.dst)
    print(f"{a.dst}: {got:.2f}s (expected {want:.2f}s: {D:.2f}s + {e - s:.2f}s segment x {1 / k:g})")

if __name__ == "__main__":
    main()
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><strong><code>setpts=(PTS-STARTPTS)/0.5</code></strong> doubles the duration of the middle part's frames. The <code>-r 30</code> on output keeps a constant 30 fps file. ffmpeg duplicates frames inside the slowed part, which looks like a classic step slow-mo. If you want smooth interpolated motion, add <code>minterpolate=fps=30</code> after the <code>setpts</code> and expect it to be slow to render and sometimes warp faces.</li>
<li><strong><code>atempo</code> only accepts 0.5 to 100 per instance</strong>, so quarter speed is two filters, <code>atempo=0.5,atempo=0.5</code>. The script chains them for you.</li>
<li><strong>Everything goes through one <code>filter_complex</code>.</strong> Cutting three files with <code>-c copy</code> and concatenating them would cut on keyframes, not at your timestamps, and the joins would click.</li>
</ul>
<h2>Running it</h2>
<pre><code class="language-text">$ ./make_test_clip.sh
wrote clip.mp4
$ python3 slowmo_segment.py clip.mp4 slow.mp4 --start 6 --end 8 --speed 0.5
running: ffmpeg -v error -y -i clip.mp4 -filter_complex [0:v]split=3[v0][v1][v2];... -movflags +faststart slow.mp4
slow.mp4: 17.02s (expected 17.00s: 15.00s + 2.00s segment x 2)
$ python3 slowmo_segment.py clip.mp4 slow25.mp4 --start 6 --end 7 --speed 0.25
...
slow25.mp4: 18.02s (expected 18.00s: 15.00s + 1.00s segment x 4)
</code></pre>
<p>The 20 ms over is AAC padding. The video stream reads 16.97 s and the audio 17.02 s, so they end together within a frame or two.</p>
<h2>Checking the pitch</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Print the strongest frequency in short windows of a clip's audio.

usage: pitch_check.py clip.mp4 [--every 1.0]
A steady test tone should read the same before, inside and after the slowed part.
Needs numpy and ffmpeg.
"""
import argparse, subprocess
import numpy as np

ap = argparse.ArgumentParser(); ap.add_argument("clip"); ap.add_argument("--every", type=float, default=1.0)
a = ap.parse_args()
SR = 48000
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", a.clip, "-ac", "1", "-ar", str(SR), "-f", "f32le", "-"],
                     capture_output=True, check=True).stdout
x = np.frombuffer(raw, np.float32)
win = 8192                                   # ~0.17 s, 5.9 Hz per FFT bin
t = 0.0
while (t * SR) + win &lt;= len(x):
    seg = x[int(t * SR):int(t * SR) + win] * np.hanning(win)
    spec = np.abs(np.fft.rfft(seg))
    peak = spec.argmax() * SR / win
    print(f"{t:6.2f}s  {peak:7.1f} Hz")
    t += a.every
print(f"audio length {len(x) / SR:.2f}s")
</code></pre>
<p>It takes the strongest frequency in a 0.17 s window every second. On the slowed file, the slowed part is 6.0 s to 10.0 s:</p>
<pre><code class="language-text">$ python3 pitch_check.py slow.mp4
  0.00s    439.5 Hz
  ...
  6.00s    439.5 Hz
  7.00s    439.5 Hz
  8.00s    439.5 Hz
  9.00s    439.5 Hz
 10.00s    439.5 Hz
  ...
 16.00s    439.5 Hz
audio length 17.02s
</code></pre>
<p>439.5 Hz everywhere: the FFT bins here are 5.9 Hz wide, so that is 440 Hz before, during and after the slowed part. The resampled version read 222.7 Hz.</p>
<h2>Finding the right timestamps</h2>
<p>The script needs <code>--start</code> and <code>--end</code> around the "State?" line. Scrub to it in any player and note the time, or play the moment slowly with <code>ffplay -ss 5 clip.mp4</code>. Leave a few tenths of a second either side so the slow-down starts just before the word, not halfway through it. Half speed over about one second is plenty. Longer than that and the joke loses its timing.</p>
<p>If you plan to cut the clip again afterwards, keep the output: it is re-encoded with fresh timestamps, so further edits behave.</p>
<h2>Tips for real voices</h2>
<ul>
<li>On speech, <code>atempo</code> at 0.5 can sound a little phasey. Try <code>--rubberband</code> if your build has it, and compare.</li>
<li>Don't slow below 0.25 on voice. It turns to mush with either filter.</li>
<li>If you're also adding music, slow the clip first and mix the music afterwards, so the music isn't stretched too.</li>
<li>Watch the result through before you post. The site's <a href="https://hittheroaddude.app/guides/check-before-posting/">check-before-posting guide</a> covers the face, the lines and labelling it as a generated remake.</li>
</ul>
<p>Prices for reference, checked November 12, 2026 on the <a href="https://hittheroaddude.app/pricing/">pricing page</a>: one clip is $9.99 at 480p or $12.99 at 720p, paid once. Credit packs for signed-in accounts are $27.99 for 30 credits, $71.99 for 100 and $129.99 for 200.</p>
]]></content:encoded></item><item><title><![CDATA[Can You Turn a Malfoid AI Portrait Into String Art? A Greedy Thread Solver in NumPy, and Why the Hair Survives but the Face Doesn't]]></title><description><![CDATA[String art is the craft where one long thread wraps around a ring of nails and, after a few hundred to a few thousand passes, a face appears. It keeps…]]></description><link>https://lpgit.hashnode.dev/can-you-turn-a-malfoid-ai-portrait-into-string-art-a-greedy-thread-solver-in-numpy-and-why-the-hair-survives-but-the-face-doesn-t</link><guid isPermaLink="true">https://lpgit.hashnode.dev/can-you-turn-a-malfoid-ai-portrait-into-string-art-a-greedy-thread-solver-in-numpy-and-why-the-hair-survives-but-the-face-doesn-t</guid><category><![CDATA[Python]]></category><category><![CDATA[numpy]]></category><category><![CDATA[generative art]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 11 Nov 2026 10:20:02 GMT</pubDate><content:encoded><![CDATA[<p>String art is the craft where one long thread wraps around a ring of nails and, after a few hundred to a few thousand passes, a face appears. It keeps coming up as a question about Malfoid portraits: <em>can I turn mine into string art for a wall?</em> The answer is yes, with a catch I didn't expect until I measured it. In circular string art, <strong>the platinum hair survives and the face mostly doesn't</strong>, and which colour of thread you pick matters more than how many nails you use.</p>
<p>This post is a greedy string-art solver in about 120 lines of NumPy, tests that prove it does what it says, and a small sweep over crop, thread colour and pin count with the numbers.</p>
<p>The portraits I have in mind come from <a href="https://malfoidai.com/">malfoidai.com</a>, which I build: one still 3:4 Malfoid (Female Malfoy / Fem Draco) picture at 1K from one photo. For testing I use a synthetic, shape-only portrait (dark corridor, platinum hair, an oval face, green-and-silver tie). It contains no real face and no AI output, so I can publish every number.</p>
<h2>How the greedy solver works</h2>
<ol>
<li>Put <code>N</code> pins evenly on a circle. Pin 0 is at the top, numbered clockwise.</li>
<li>Turn the cropped portrait into a <strong>want map</strong>: how much thread each pixel wants. For black thread on a white board that's <code>1 − brightness</code>. For white thread on a black board it's <code>brightness</code>.</li>
<li>Start at pin 0. For every pin you could go to next, average the <em>remaining</em> want along that chord. Take the best one, subtract a fixed thread weight along it, move there, repeat.</li>
<li>Stop when no chord has positive remaining want, or when you reach the line limit.</li>
</ol>
<p>Two rules keep the result buildable: skip pins closer than 10 positions (short chords hug the rim and waste thread), and never go straight back to the pin you just came from.</p>
<p>The trick that makes it fast is to precompute every chord's pixel indices once. For each pin, the solver concatenates the chords to all reachable pins into one flat array and scores them with a single <code>np.add.reduceat</code>. With 200 pins it runs in under a second on my box.</p>
<h2>The code</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Greedy string art from a portrait: pins on a circle, one continuous thread.

    python string_art.py portrait.png out/ [--pins 200] [--lines 2500]
        [--thread dark|light] [--diameter-cm 40] [--box x,y,w,h]

dark  = black thread on a white board (thread draws the dark areas)
light = white thread on a black board (thread draws the light areas,
        e.g. platinum hair)
Writes sequence.txt (pin numbers, 0 = top, clockwise), board.svg,
preview.png, and prints thread length and a similarity score.
"""
import argparse, math, os
import numpy as np
from PIL import Image

R = 300  # working resolution (diameter in px)

def load_target(path, box, thread):
    img = Image.open(path).convert("L")
    if box:
        x, y, w, h = box
    else:                                    # default: square on the upper 60% (the head)
        w = h = min(img.width, int(img.height * 0.6))
        x, y = (img.width - w) // 2, int(img.height * 0.1)
    sq = img.crop((x, y, x + w, y + h)).resize((R, R), Image.LANCZOS)
    a = np.asarray(sq, dtype=np.float32) / 255
    want = 1 - a if thread == "dark" else a  # how much thread each pixel wants
    yy, xx = np.mgrid[:R, :R]
    inside = (xx - R / 2 + .5) ** 2 + (yy - R / 2 + .5) ** 2 &lt;= (R / 2 - 1) ** 2
    return want * inside, inside

def pins_xy(n):
    t = np.arange(n) * 2 * np.pi / n
    r = R / 2 - 1
    return np.stack([R / 2 + r * np.sin(t), R / 2 - r * np.cos(t)], 1)

def line_px(p, q):
    n = int(math.hypot(*(q - p))) + 1
    xs = np.linspace(p[0], q[0], n).round().astype(int).clip(0, R - 1)
    ys = np.linspace(p[1], q[1], n).round().astype(int).clip(0, R - 1)
    return np.unique(ys * R + xs)

def build_index(P, min_gap):
    """For each pin: flat pixel indices of all its lines + segment starts."""
    n = len(P)
    cache = {}
    per_pin = []
    for i in range(n):
        targets, segs = [], []
        for j in range(n):
            if min(abs(i - j), n - abs(i - j)) &lt; min_gap:
                continue                     # skip near-neighbour pins (short, useless chords)
            k = (min(i, j), max(i, j))
            if k not in cache:
                cache[k] = line_px(P[k[0]], P[k[1]])
            targets.append(j); segs.append(cache[k])
        starts = np.cumsum([0] + [len(s) for s in segs[:-1]])
        lens = np.array([len(s) for s in segs], dtype=np.float32)
        per_pin.append((np.array(targets), np.concatenate(segs), starts, lens))
    return per_pin, cache

def solve(want, P, n_lines, weight=0.18, min_gap=10):
    per_pin, cache = build_index(P, min_gap)
    res = want.ravel().copy()
    seq, cur, last = [0], 0, -1
    for _ in range(n_lines):
        tg, flat, starts, lens = per_pin[cur]
        score = np.add.reduceat(res[flat], starts) / lens
        if last &gt;= 0:
            score[tg == last] = -1           # don't go straight back
        b = int(np.argmax(score))
        if score[b] &lt;= 0:
            break
        nxt = int(tg[b])
        px = cache[(min(cur, nxt), max(cur, nxt))]
        res[px] -= weight
        seq.append(nxt); last, cur = cur, nxt
    return seq, cache

def render(seq, cache, weight=0.18):
    acc = np.zeros(R * R, dtype=np.float32)
    for a, b in zip(seq, seq[1:]):
        acc[cache[(min(a, b), max(a, b))]] += weight
    return np.minimum(acc, 1).reshape(R, R)

def similarity(a, b, inside):
    x, y = a[inside], b[inside]
    return float(np.corrcoef(x, y)[0, 1])

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("src"); ap.add_argument("out")
    ap.add_argument("--pins", type=int, default=200)
    ap.add_argument("--lines", type=int, default=2500)
    ap.add_argument("--thread", choices=["dark", "light"], default="dark")
    ap.add_argument("--diameter-cm", type=float, default=40)
    ap.add_argument("--box")
    a = ap.parse_args()
    os.makedirs(a.out, exist_ok=True)
    box = tuple(map(int, a.box.split(","))) if a.box else None
    want, inside = load_target(a.src, box, a.thread)
    P = pins_xy(a.pins)
    seq, cache = solve(want, P, a.lines)
    drawn = render(seq, cache)
    cm_per_px = a.diameter_cm / (R - 2)
    length_m = sum(math.hypot(*(P[q] - P[p])) for p, q in zip(seq, seq[1:])) * cm_per_px / 100
    open(os.path.join(a.out, "sequence.txt"), "w").write("\n".join(map(str, seq)) + "\n")
    shade = (1 - drawn) if a.thread == "dark" else drawn
    Image.fromarray((np.where(inside, shade, 0.5) * 255).astype(np.uint8)).save(os.path.join(a.out, "preview.png"))
    s = a.diameter_cm * 10 / (R - 2)       # svg in mm
    pts = " ".join(f"{P[i][0] * s:.1f},{P[i][1] * s:.1f}" for i in seq)
    col, bgc = ("black", "white") if a.thread == "dark" else ("white", "black")
    open(os.path.join(a.out, "board.svg"), "w").write(
        f'&lt;svg xmlns="http://www.w3.org/2000/svg" width="{R * s:.0f}mm" height="{R * s:.0f}mm" '
        f'viewBox="0 0 {R * s:.0f} {R * s:.0f}"&gt;&lt;rect width="100%" height="100%" fill="{bgc}"/&gt;'
        f'&lt;polyline points="{pts}" fill="none" stroke="{col}" stroke-width="0.25" stroke-opacity="0.6"/&gt;&lt;/svg&gt;\n')
    print(f"thread={a.thread} pins={a.pins} lines={len(seq) - 1} "
          f"length={length_m:.0f} m on a {a.diameter_cm:.0f} cm board, "
          f"similarity r={similarity(drawn, want, inside):.3f}")

if __name__ == "__main__":
    main()
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">pip install pillow numpy
python string_art.py portrait.png out_dark --thread dark
python string_art.py portrait.png out_light --thread light
</code></pre>
<p>You get <code>sequence.txt</code> (the order to wrap the pins), <code>board.svg</code> (a millimetre-scaled plan you can print as a template, or hand to a plotter), and <code>preview.png</code>.</p>
<h2>Tests, because "looks about right" isn't a test</h2>
<p>String art is easy to get subtly wrong. A path can jump, go straight back, or score chords off by one pin. These four checks run in about a second:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Sanity tests for string_art.py:  python test_string_art.py"""
import numpy as np, string_art as sa

P = sa.pins_xy(120)
yy, xx = np.mgrid[:sa.R, :sa.R]
inside = (xx - sa.R / 2 + .5) ** 2 + (yy - sa.R / 2 + .5) ** 2 &lt;= (sa.R / 2 - 1) ** 2

# 1. a target that is exactly one chord (pin 0 -&gt; pin 60): solver must draw it first
want = np.zeros((sa.R, sa.R), np.float32).ravel()
want[sa.line_px(P[0], P[60])] = 1
seq, cache = sa.solve(want.reshape(sa.R, sa.R), P, 1)
assert seq == [0, 60], seq

# 2. continuity: consecutive pins differ and respect the minimum gap
want = np.random.default_rng(1).random((sa.R, sa.R)).astype(np.float32) * inside
seq, cache = sa.solve(want, P, 300, min_gap=10)
gaps = [min(abs(a - b), 120 - abs(a - b)) for a, b in zip(seq, seq[1:])]
assert min(gaps) &gt;= 10, min(gaps)
assert all(a != c for a, c in zip(seq, seq[2:])), "went straight back"

# 3. an empty target draws nothing
seq, _ = sa.solve(np.zeros((sa.R, sa.R), np.float32), P, 50)
assert seq == [0], seq

# 4. greedy beats random lines of the same count on a disc-with-ring target
ring = ((((xx - 150) ** 2 + (yy - 150) ** 2) &gt; 110 ** 2) &amp; inside).astype(np.float32)
seq, cache = sa.solve(ring, P, 400)
r_greedy = sa.similarity(sa.render(seq, cache), ring, inside)
rnd = np.random.default_rng(0)
rs = [0] + list(rnd.integers(0, 120, len(seq) - 1))
for a, b in zip(rs, rs[1:]):
    k = (min(a, b), max(a, b))
    if a != b and k not in cache:
        cache[k] = sa.line_px(P[k[0]], P[k[1]])
rs = [x for i, x in enumerate(rs) if i == 0 or x != rs[i - 1]]
r_random = sa.similarity(sa.render(rs, cache), ring, inside)
assert r_greedy &gt; r_random + 0.2, (r_greedy, r_random)
print(f"4 tests passed (greedy r={r_greedy:.3f} vs random r={r_random:.3f})")
</code></pre>
<pre><code class="language-text">4 tests passed (greedy r=0.767 vs random r=0.145)
</code></pre>
<p>Test 4 matters most. On a ring target, the greedy path reaches a correlation of 0.767 with the target, against 0.145 for the same number of random chords. If a refactor breaks the scoring, that gap collapses.</p>
<h2>What survives: the sweep</h2>
<p>I ran the solver on the test portrait with two crops (a <em>loose</em> 614 px square over the whole head and a <em>tight</em> 340 px square on the face), both thread colours, and 150, 200 or 300 pins. <code>r</code> is the correlation between the simulated threads and the want map over the whole disc. <code>face_r</code> is the same thing measured only inside the face's bounding box:</p>
<pre><code class="language-text">loose dark  pins=150 lines=833 r=0.781 face_r=0.204
loose dark  pins=200 lines=860 r=0.804 face_r=0.194
loose dark  pins=300 lines=894 r=0.822 face_r=0.168
loose light pins=150 lines=970 r=0.601 face_r=0.119
loose light pins=200 lines=986 r=0.629 face_r=0.132
loose light pins=300 lines=996 r=0.657 face_r=0.130
tight dark  pins=150 lines=374 r=0.249 face_r=0.192
tight dark  pins=200 lines=388 r=0.251 face_r=0.196
tight dark  pins=300 lines=403 r=0.264 face_r=0.199
tight light pins=150 lines=1375 r=0.174 face_r=0.153
tight light pins=200 lines=1392 r=0.193 face_r=0.151
tight light pins=300 lines=1426 r=0.215 face_r=0.154
</code></pre>
<p>Three things jump out.</p>
<p><strong>1. Black thread on the loose crop wins clearly</strong> (r = 0.78–0.82 against 0.60–0.66 for white thread). I expected the opposite: Malfoid is <em>about</em> the pale hair, so white thread should draw it. But look at the geometry. Every chord crosses the circle, and the hair is a big pale oval sitting in the middle. White thread aimed at the hair also lays light across the dark corridor on both sides. Black thread aimed at the dark sides can use chords that stay near the rim and never touch the hair. In circular string art, <strong>a central subject is easiest to draw as negative space.</strong> The hair is the part you <em>don't</em> wrap.</p>
<p><strong>2. The face doesn't come through.</strong> <code>face_r</code> stays around 0.12–0.20 in every setting. The eyes and mouth are small dark marks inside a mid-tone face. Any chord that darkens an eye also darkens the skin on both sides of it, and the solver can't afford that. Real string-art portraits work around this by picking photos with strong, large shadows. A softly lit portrait in an even style won't give it any.</p>
<p><strong>3. Cropping tighter makes it worse, not better.</strong> The tight crop drops <code>r</code> to about 0.25 without improving <code>face_r</code>. Filling the circle with face just fills it with mid-tone skin, which has almost nothing for the thread to work with. More pins help a little everywhere (r 0.78 → 0.82 for black thread, loose crop), but much less than the thread choice does.</p>
<p>With 200 pins and black thread, the solver stopped on its own after <strong>860 lines</strong>. On a 40 cm board that's about <strong>210 m</strong> of thread; the white-thread version used 986 lines and about 351 m. Those totals depend on the thread weight (0.18 per pass in the simulation). Thinner or more transparent thread means a lower weight, more lines and more metres.</p>
<h2>So should you make one?</h2>
<p>If you want something recognisable as <em>Malfoid</em> across a room, yes. The silhouette (a long pale oval of hair framed by darkness) is exactly what string art does well, and it's also the strongest cue for the character. If you want a portrait of <em>you</em> in which friends can pick out your eyes, string art is the wrong medium for a soft, even portrait. Line art or a halftone keeps far more of the face.</p>
<p>Practical notes if you build it:</p>
<ul>
<li><strong>Print <code>board.svg</code> at 100%</strong> and tape it to the board. The pin positions are the polyline vertices, and 0 is at the top.</li>
<li><strong>Count before you buy thread.</strong> The script prints the length; add 10–15% for the knots and the wraps around each pin.</li>
<li><strong>Use your own portrait, or a friend's with their OK.</strong> A wall piece is about as public as posting it. If it hangs anywhere people can see, a small "AI fan art" card next to it is honest; Malfoid is an unofficial fan character.</li>
</ul>
<p>If you're starting from nothing, a portrait from malfoidai.com is $4.99 paid once, or 2 credits from a pack. The <a href="https://malfoidai.com/pricing/">pricing page</a> lists 30 credits for $13.99, 100 for $35.99 and 200 for $59.99, re-checked on 2026-11-11 at 18:00 CST (UTC+8). One 1K portrait is plenty. The solver works on a 300 px disc.</p>
<p><em>Tested on Python 3.13 with Pillow 13.0 and NumPy 2.5. Thread rendering is simulated (additive darkness, clipped); I haven't wrapped a physical board for this post.</em></p>
]]></content:encoded></item><item><title><![CDATA[How Do You Show the Photo You Uploaded Above Your Bama Bound AI Clip in One Vertical Video? A Labeled ffmpeg vstack That Never Stretches Either Side]]></title><description><![CDATA[Checked November 11, 2026. I build bamabound.app, which turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner…]]></description><link>https://lpgit.hashnode.dev/how-do-you-show-the-photo-you-uploaded-above-your-bama-bound-ai-clip-in-one-vertical-video-a-labeled-ffmpeg-vstack-that-never-stretches-either-side</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-show-the-photo-you-uploaded-above-your-bama-bound-ai-clip-in-one-vertical-video-a-labeled-ffmpeg-vstack-that-never-stretches-either-side</guid><category><![CDATA[FFmpeg]]></category><category><![CDATA[Bash]]></category><category><![CDATA[video]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 11 Nov 2026 07:06:56 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 11, 2026. I build <a href="https://bamabound.app/">bamabound.app</a>, which turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner. Test inputs here are synthetic: a checkerboard "photo" and a red box swaying over a field.</em></p>
<p>The most-copied way to post an AI meme clip is the reveal: <em>"the photo I gave it"</em> on top, <em>"what it made"</em> underneath, playing together in one 9:16 video. It answers the first question in the comments ("is that really you?") before anyone asks it.</p>
<p>Doing it by hand in a phone editor usually goes wrong in one of three ways: the photo gets stretched to fill the box, the labels sit on top of the face, or the whole thing exports at the photo's size instead of a proper 9:16 frame. Here's a short script that gets all three right and that I tested at two sizes.</p>
<h2>What it produces</h2>
<ul>
<li>A <strong>720x1280</strong> (or any width you pass, 16:9 rule) vertical MP4.</li>
<li><strong>Top half:</strong> your photo, scaled to fit and letterboxed with black. Never stretched, never cropped.</li>
<li><strong>Bottom half:</strong> the clip, scaled to fit the same way.</li>
<li><strong>Labels</strong> ("MY PHOTO", "AI CLIP") at the bottom of each half on a semi-transparent box, so they read over any background.</li>
<li>The <strong>clip's audio</strong> is kept and the video lasts exactly as long as the clip.</li>
</ul>
<h2>The script</h2>
<p><code>before_after.sh</code> (bash, ffmpeg/ffprobe):</p>
<pre><code class="language-bash">#!/usr/bin/env bash
#   ./before_after.sh photo.jpg clip.mp4 out.mp4 [WIDTH]   (default 720 -&gt; 720x1280)
set -euo pipefail
photo=$1; clip=$2; out=${3:-before_after.mp4}; W=${4:-720}
W=$(( W / 2 * 2 )); H=$(( W * 16 / 9 / 2 * 2 )); HALF=$(( H / 2 / 2 * 2 ))
FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
FS=$(( W / 20 ))
fit="scale=${W}:${HALF}:force_original_aspect_ratio=decrease,pad=${W}:${HALF}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
label() { echo "drawtext=fontfile=${FONT}:text='$1':fontcolor=white:fontsize=${FS}:box=1:boxcolor=black@0.55:boxborderw=$(( FS / 3 )):x=(w-text_w)/2:y=h-text_h-${FS}"; }
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$clip")
fps=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "$clip")
audio=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$clip")
amap=(); [ -n "$audio" ] &amp;&amp; amap=(-map 1:a -c:a aac -b:a 128k)
ffmpeg -v error -y -loop 1 -framerate "$fps" -t "$dur" -i "$photo" -i "$clip" \
  -filter_complex "[0:v]${fit},$(label 'MY PHOTO')[top];[1:v]${fit},$(label 'AI CLIP')[bot];[top][bot]vstack=inputs=2,format=yuv420p[v]" \
  -map "[v]" "${amap[@]}" -c:v libx264 -crf 20 -preset veryfast -r "$fps" -t "$dur" -movflags +faststart "$out"
</code></pre>
<h2>The four lines that matter</h2>
<p><strong>1. <code>force_original_aspect_ratio=decrease</code> + <code>pad</code>.</strong> Scale until the image <em>fits inside</em> the half, then pad the rest with black. Swap <code>decrease</code> for <code>increase</code> + <code>crop</code> if you'd rather fill the half and lose the edges, but for a reveal you usually want the whole photo visible.</p>
<p><strong>2. Even numbers everywhere.</strong> <code>W / 2 * 2</code> and friends. x264 with <code>yuv420p</code> needs even width and height. A 1080-wide output gives 1920 tall and two 960-tall halves. A 721 input width would otherwise fail with "width not divisible by 2".</p>
<p><strong>3. <code>-loop 1 -framerate "$fps" -t "$dur"</code> on the photo.</strong> A still image is one frame. Looping it at the clip's own frame rate for the clip's duration means <code>vstack</code> gets two inputs with matching timing, and the output ends when the clip does.</p>
<p><strong>4. Audio only if it exists.</strong> <code>ffprobe -select_streams a</code> returns nothing for a silent file. The <code>amap</code> array keeps the command valid in both cases. Bama Bound clips keep their original soundtrack (<a href="https://bamabound.app/faq/">FAQ</a>), but your edited copy might not.</p>
<h2>Tested</h2>
<pre><code>$ ./before_after.sh sharp.jpg clip.mp4 ba.mp4
ba.mp4: 720x1280
$ ./before_after.sh rotated.jpg clip.mp4 ba2.mp4 1080
ba2.mp4: 1080x1920
</code></pre>
<p><code>ba.mp4</code> has a video and an audio stream and lasts 8.000 s, the same as the input clip. I checked a frame at 3 s: photo letterboxed on top, clip on the bottom, both labels legible, nothing stretched.</p>
<h2>Gotchas I hit</h2>
<ul>
<li><strong>Phone photos and the EXIF rotate flag.</strong> Many phone JPEGs store the pixels sideways plus an EXIF "rotate me" flag. The ffmpeg 7.1 I tested with honours that flag: my <code>rotated.jpg</code> test file (upright pixels, flag set to "rotate 90°") came out turned on its side, exactly as a photo viewer would show it. Older ffmpeg builds and some editors ignore the flag, so the same photo can look different in different tools. If the top half is sideways, re-save the photo upright with a photo editor (that bakes the rotation into the pixels and clears the flag) and run the script again.</li>
<li><strong>Labels on the face.</strong> The labels sit at the bottom of each half. In the clip, that's the grass below the banner. If your photo is a tight face crop, move the photo label to the top with <code>y=${FS}</code>.</li>
<li><strong>The clip has its own black bars.</strong> If the clip is already 9:16 and the half is wider than tall, you'll see side bars in the bottom half. That's expected with <code>decrease</code>. For a tighter look, use a 2:1 split instead of halves (top third photo, bottom two-thirds clip) by changing <code>HALF</code>.</li>
</ul>
<h2>Before you post it</h2>
<p>Showing the source photo next to the result is the most honest version of this meme, because it makes clear the clip is generated. Two rules still apply:</p>
<ul>
<li><strong>Only use a photo you have permission to use.</strong> The studio checks final text only and doesn't review your photo or the video (<a href="https://bamabound.app/faq/">FAQ</a>). The <a href="https://bamabound.app/acceptable-use/">acceptable-use page</a> puts that on you.</li>
<li><strong>It's still a joke, not an admission.</strong> "Applied to Bama as a joke" is the bit. Don't caption a real student's photo as if it were a real acceptance.</li>
</ul>
<p>If you're making the clip itself first: on the live <a href="https://bamabound.app/pricing/">pricing page</a> today, one generation is $7.98 at 480p, $9.98 at 720p, $16.98 at 1080p and $24.98 at 4k, tax extra. For a split-screen like this, each half is only 640 px tall at 720x1280, so 480p or 720p source is plenty.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Burn Captions Into a Vertical Tiny Desk AI Clip So App Buttons Don't Cover Them? (ffmpeg + libass, Tested)]]></title><description><![CDATA[Checked November 11, 2026 (12:00 CST, UTC+8). I build tinydeskoutstanding.video, an independent studio that makes a 15-second vertical parody of the…]]></description><link>https://lpgit.hashnode.dev/how-do-you-burn-captions-into-a-vertical-tiny-desk-ai-clip-so-app-buttons-don-t-cover-them-ffmpeg-libass-tested</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-burn-captions-into-a-vertical-tiny-desk-ai-clip-so-app-buttons-don-t-cover-them-ffmpeg-libass-tested</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 11 Nov 2026 04:06:38 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 11, 2026 (12:00 CST, UTC+8). I build <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a>, an independent studio that makes a 15-second vertical parody of the "Outstanding" Tiny Desk meme from one to five photos. The script here was written for this post and tested on a synthetic 720x1280 clip.</em></p>
<p>People share these clips in feeds where most viewers watch muted. A caption like "Our team, live at the tiny desk" in the first seconds and a big "OUTSTANDING!" at the payoff makes the joke land without sound. The usual problem: you burn subtitles in with ffmpeg, upload, and the bottom line disappears behind the like/comment/share column and the app's own caption bar.</p>
<p>This post covers the one detail that trips most people up (libass sizing units), and a short script that keeps text in a safe band.</p>
<h2>Step 1: write the SRT</h2>
<p>Two cues are enough for a 15-second clip:</p>
<pre><code class="language-text">1
00:00:00,500 --&gt; 00:00:03,000
Our team, live at the tiny desk

2
00:00:11,000 --&gt; 00:00:14,500
OUTSTANDING!
</code></pre>
<p>Keep each cue to one short line. On a 9:16 frame, about 25–30 characters at a readable size is the practical limit before it wraps.</p>
<h2>Step 2: understand libass units (the part that bites)</h2>
<p>ffmpeg's <code>subtitles</code> filter renders through libass. An SRT file has no canvas size of its own, so libass lays it out on a default script canvas that is <strong>288 units tall</strong>, then scales to the video. That means:</p>
<ul>
<li><code>Fontsize=11</code> is not 11 pixels; it's 11/288 of the frame height. On a 1280-px-tall clip that's roughly 49 px.</li>
<li><code>MarginV</code> is in the same units. If you compute "18% of 1280 = 230" and pass <code>MarginV=230</code>, the text flies off the top of the frame.</li>
</ul>
<p>I made exactly that mistake in my first draft of this script. The fix is to think in the 288-unit space: 18% of 288 is about 52.</p>
<h2>Step 3: the script</h2>
<pre><code class="language-bash">#!/usr/bin/env bash
# usage: burn_captions.sh in.mp4 captions.srt out.mp4
# libass renders SRT on a 288-unit-tall canvas, so Fontsize and MarginV are in
# those units whatever the clip's real height: MarginV=52 is ~18% of the frame.
set -euo pipefail
in="$1"; srt="$2"; out="$3"
STYLE="FontName=DejaVu Sans,Fontsize=11,Bold=1,PrimaryColour=&amp;H00FFFFFF,OutlineColour=&amp;H00000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=52,MarginL=12,MarginR=12"
ffmpeg -v error -y -i "$in" \
  -vf "subtitles=${srt}:force_style='${STYLE}'" \
  -c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p -c:a copy -movflags +faststart "$out"
</code></pre>
<p>What each style key does:</p>
<ul>
<li><code>Alignment=2</code>: bottom centre (numpad layout: 1–3 bottom, 4–6 middle, 7–9 top).</li>
<li><code>MarginV=52</code>: lifts the baseline about 18% up from the bottom edge, above where vertical-video apps stack their buttons and caption text.</li>
<li><code>BorderStyle=1,Outline=1.5</code>: a black outline instead of a box, so it reads on both the bright stage lights and the dark background.</li>
<li><code>MarginL/MarginR=12</code>: keeps long lines off the right-side button column.</li>
<li><code>-c:a copy</code>: the audio is untouched; only video is re-encoded (burned-in text has to be).</li>
</ul>
<h2>Step 4: check it</h2>
<pre><code class="language-text">$ ./burn_captions.sh test.mp4 captions.srt cap.mp4
$ ffprobe -v error -show_entries format=duration:stream=codec_name,width,height -of csv cap.mp4
stream,h264,720,1280
stream,aac
format,15.000000
$ ffmpeg -v error -ss 12 -i cap.mp4 -frames:v 1 -vf scale=360:-2 f12.png
</code></pre>
<p>The frame grab at 12 s showed "OUTSTANDING!" centred, sitting a little above the bottom fifth of the frame, with the full width clear of the edges. Duration and resolution matched the input, and the audio stream was copied.</p>
<h2>Why not soft subtitles?</h2>
<p>You can mux an SRT as a soft track (<code>-c:s mov_text</code>), but most social apps drop it on upload. Burning in is the only way to be sure the words survive.</p>
<h2>When the captions should be the whole joke</h2>
<p>This meme is a natural fit for captions because the setup ("live at the tiny desk") and the punchline ("Outstanding!") are both short. A few that work:</p>
<ul>
<li>a team intro over the first two seconds ("Q4 planning, live at the tiny desk"),</li>
<li>a name card for the lead seat,</li>
<li>the payoff word itself, timed to the close-up.</li>
</ul>
<p>The studio's clips are 9:16 at 480p, 720p, 1080p or 4k (prices on the <a href="https://tinydeskoutstanding.video/pricing/">pricing page</a>: $9.98, $12.98, $24.98 and $39.98 as of today). Because the libass units scale with frame height, the same style string gives the same relative size at every resolution, so this script doesn't need changing between them.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Make a Reaction Video With a Hit the Road Dude Clip? Two Videos in One 9:16 File, Both Audio Tracks Mixed, With ffmpeg]]></title><description><![CDATA[A common way to share a Hit the Road Dude clip, the State Property meme where a driver at a pickup window repeats "State?" and sends a visitor off, is as a…]]></description><link>https://lpgit.hashnode.dev/how-do-you-make-a-reaction-video-with-a-hit-the-road-dude-clip-two-videos-in-one-9-16-file-both-audio-tracks-mixed-with-ffmpeg</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-make-a-reaction-video-with-a-hit-the-road-dude-clip-two-videos-in-one-9-16-file-both-audio-tracks-mixed-with-ffmpeg</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 11 Nov 2026 01:16:06 GMT</pubDate><content:encoded><![CDATA[<p>A common way to share a Hit the Road Dude clip, the State Property meme where a driver at a pickup window repeats "State?" and sends a visitor off, is as a reaction: the clip on top, someone's face watching it underneath. Phone apps do this with a "duet" button, but only inside their own app, and only for videos already on that platform. If you have the clip as a file, ffmpeg can build the same layout in one command, and you control the crop, the sync and the audio balance.</p>
<p>I build <a href="https://hittheroaddude.app/">hittheroaddude.app</a>, an independent studio that turns one photo of an adult or a pet into the 15-second vertical pickup-window clip with a newly generated voice. The script below works on any vertical clip and any reaction video. I tested it on synthetic inputs (colour bars and two test tones), so the outputs below show that the plumbing works, not how a real reaction looks.</p>
<h2>The layout</h2>
<p>The output is 720x1280 (9:16), split into two 720x640 halves:</p>
<ul>
<li><strong>Top:</strong> the whole vertical clip, scaled to fit the half's height, so nothing of the pickup window is cut off. The empty sides are filled with a blurred, zoomed copy of the same clip, which looks better than black bars.</li>
<li><strong>Bottom:</strong> the reaction, scaled to cover the half and centre-cropped. A webcam or phone recording usually has the face in the middle, so cropping the sides is safe.</li>
</ul>
<p>Audio is both tracks mixed, with the reaction quieter by default so the "State?" line stays on top.</p>
<p>Why not crop the meme clip to fill the top half too? Because a 9:16 clip squeezed into a 9:8 box loses most of its height, and the driver's face and the pointing at the end sit at different heights in the frame. Fitting it whole is the safe default.</p>
<h2>Test inputs</h2>
<p>A 15-second vertical stand-in for the clip with a 440 Hz tone, and a 20-second 16:9 stand-in for a webcam reaction with a 660 Hz tone. Two different tones let us check later that both made it into the mix.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Stand-ins: a 15 s 9:16 "meme clip" with a 440 Hz tone, and a 20 s 16:9 "webcam reaction" with a 660 Hz tone.
set -euo pipefail
ffmpeg -v error -y -f lavfi -i "testsrc2=size=720x1280:rate=30:duration=15" -f lavfi -i "sine=frequency=440:duration=15" \
  -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest clip.mp4
ffmpeg -v error -y -f lavfi -i "smptebars=size=1280x720:rate=30:duration=20" -f lavfi -i "sine=frequency=660:duration=20" \
  -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest reaction.mp4
echo "wrote clip.mp4 reaction.mp4"
</code></pre>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Stack a vertical meme clip over a reaction video into one 9:16 file, and mix both audio tracks.

usage: python3 react_stack.py clip.mp4 reaction.mp4 out.mp4 [--offset 2.0] [--clip-gain 1.0] [--react-gain 0.6]
  --offset      seconds to skip at the start of the reaction (line your reaction up with the clip)
Output: 720x1280, 30 fps, as long as the clip. Top half: the whole clip, fitted, on a blurred fill.
Bottom half: the reaction, centre-cropped to fill. Needs ffmpeg on PATH.
"""
import argparse, subprocess

W, H = 720, 1280
HALF = H // 2

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("clip"); ap.add_argument("reaction"); ap.add_argument("out")
    ap.add_argument("--offset", type=float, default=0.0)
    ap.add_argument("--clip-gain", type=float, default=1.0)
    ap.add_argument("--react-gain", type=float, default=0.6)
    a = ap.parse_args()
    graph = (
        # top: the full vertical clip fitted into 720x640 over a blurred, zoomed copy of itself
        f"[0:v]split[c1][c2];"
        f"[c1]scale={W}:{HALF}:force_original_aspect_ratio=increase,crop={W}:{HALF},boxblur=20:2[bg];"
        f"[c2]scale=-2:{HALF}[fg];"
        f"[bg][fg]overlay=(W-w)/2:0,setsar=1,fps=30[top];"
        # bottom: the reaction cropped to fill 720x640 (faces are usually centred)
        f"[1:v]trim=start={a.offset},setpts=PTS-STARTPTS,"
        f"scale={W}:{HALF}:force_original_aspect_ratio=increase,crop={W}:{HALF},setsar=1,fps=30[bot];"
        f"[top][bot]vstack=inputs=2,format=yuv420p[v];"
        # audio: both tracks, reaction quieter, cut to the clip's length
        f"[0:a]volume={a.clip_gain}[ca];"
        f"[1:a]atrim=start={a.offset},asetpts=PTS-STARTPTS,volume={a.react_gain}[ra];"
        f"[ca][ra]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95[a]"
    )
    cmd = ["ffmpeg", "-v", "error", "-y", "-i", a.clip, "-i", a.reaction, "-filter_complex", graph,
           "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-crf", "20", "-preset", "medium",
           "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", "-shortest", a.out]
    subprocess.run(cmd, check=True)
    print("wrote", a.out)

if __name__ == "__main__":
    main()
</code></pre>
<p>What the filter graph does, line by line:</p>
<ol>
<li><code>split</code> makes two copies of the clip: one becomes the blurred background, one the sharp foreground.</li>
<li><code>scale=...:force_original_aspect_ratio=increase,crop=720:640</code> makes the background cover the box; <code>boxblur</code> softens it.</li>
<li><code>scale=-2:640</code> fits the foreground to the box height, keeping the width even, which H.264 needs.</li>
<li><code>overlay=(W-w)/2:0</code> centres the foreground.</li>
<li>The reaction gets <code>trim</code> and <code>setpts</code> so <code>--offset</code> can skip its first seconds, then the same cover-and-crop.</li>
<li><code>vstack</code> puts the halves together. Both sides are first set to the same width, 30 fps and square pixels, so the halves match and stay in step.</li>
<li><code>amix ... duration=first:normalize=0</code> mixes the audio and stops at the end of the clip. <code>normalize=0</code> keeps your gains as you set them instead of halving everything; the <code>alimiter</code> after it stops the sum from clipping.</li>
</ol>
<h2>Run it and check it</h2>
<pre><code class="language-text">$ ./make_test_inputs.sh
wrote clip.mp4 reaction.mp4
$ python3 react_stack.py clip.mp4 reaction.mp4 stacked.mp4 --offset 2
wrote stacked.mp4
$ ffprobe -v error -show_entries stream=codec_type,width,height,r_frame_rate:format=duration -of compact stacked.mp4
stream|codec_type=video|width=720|height=1280|r_frame_rate=30/1
stream|codec_type=audio|r_frame_rate=0/0
format|duration=15.000000
</code></pre>
<p>The output is the clip's length (15 s), not the reaction's (20 s), at 720x1280 and 30 fps.</p>
<p>To confirm that both audio tracks are in the mix, measure the level at the two test frequencies:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Confirm both voices made it into the mix: measure the level at two test frequencies.

usage: python3 verify_mix.py out.mp4 440 660
Needs ffmpeg on PATH and numpy.
"""
import subprocess, sys
import numpy as np

path, freqs = sys.argv[1], [float(f) for f in sys.argv[2:]]
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", "16000", "-f", "f32le", "-"],
                     capture_output=True, check=True).stdout
x = np.frombuffer(raw, dtype="&lt;f4")
spec = np.abs(np.fft.rfft(x * np.hanning(len(x))))
hz = np.fft.rfftfreq(len(x), 1 / 16000)
ref = spec.max()
for f in freqs:
    band = (hz &gt; f - 5) &amp; (hz &lt; f + 5)
    print(f"{f:6.0f} Hz: {20 * np.log10(spec[band].max() / ref):6.1f} dB relative to the loudest bin")
print(f"duration {len(x) / 16000:.2f}s")
</code></pre>
<pre><code class="language-text">$ python3 verify_mix.py stacked.mp4 440 660
   440 Hz:    0.0 dB relative to the loudest bin
   660 Hz:   -4.5 dB relative to the loudest bin
duration 15.00s
</code></pre>
<p>The reaction's tone sits 4.5 dB below the clip's, which is what a gain of 0.6 should give (20·log10(0.6) ≈ −4.4 dB). With real voices the frequency trick doesn't apply; just listen with headphones and on a phone speaker.</p>
<h2>Syncing the reaction</h2>
<p>If you filmed yourself reacting while the clip played, the reaction starts a few seconds before the clip does. Find the offset by ear (the moment your recording hears "State?" versus the clip's own "State?"), then pass it as <code>--offset</code>. If you want to be exact, cross-correlating the two audio tracks finds the offset automatically, but for a reaction video a tenth of a second doesn't matter.</p>
<h2>Practical notes</h2>
<ul>
<li><strong>Use your own reaction, or one you have permission for.</strong> The same goes for the clip: a remake of yourself, of an adult who agreed, or of your pet.</li>
<li><strong>Turn the reaction down further</strong> (<code>--react-gain 0.4</code>) if you talk over the punchline. The clip's pause is the joke.</li>
<li><strong>Label it as an AI remake</strong> in the caption. The site's <a href="https://hittheroaddude.app/guides/check-before-posting/">posting check</a> asks you not to present a remake as the original or as a real confrontation.</li>
<li><strong>Side by side instead?</strong> Swap <code>vstack</code> for <code>hstack</code> and use 640x1280 halves, which gives a 1280x1280 square.</li>
</ul>
<h2>If you need the clip</h2>
<p>On hittheroaddude.app you upload one clear photo, choose 480p or 720p and pay; there is no prompt. As of November 11, 2026 the <a href="https://hittheroaddude.app/pricing/">pricing page</a> lists $9.99 per clip at 480p and $12.99 at 720p. Signed-in accounts can also buy credit packs ($27.99 for 30 credits, $71.99 for 100, $129.99 for 200; a clip spends 6 or 14). Each clip is 15 seconds, 9:16, with a new voice rather than the original recording.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Turn a Malfoid AI Portrait Into a Jigsaw Puzzle? Shared-Edge Pieces, a Cut-Once SVG, and the Crossing Test That Caught My Bug]]></title><description><![CDATA[November 10, 2026 Short answer: pick a grid whose pieces come out square (a 3:4 portrait gives 3×4 = 12, 6×8 = 48 or 9×12 = 108 pieces), generate every…]]></description><link>https://lpgit.hashnode.dev/how-do-you-turn-a-malfoid-ai-portrait-into-a-jigsaw-puzzle-shared-edge-pieces-a-cut-once-svg-and-the-crossing-test-that-caught-my-bug</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-turn-a-malfoid-ai-portrait-into-a-jigsaw-puzzle-shared-edge-pieces-a-cut-once-svg-and-the-crossing-test-that-caught-my-bug</guid><category><![CDATA[Python]]></category><category><![CDATA[numpy]]></category><category><![CDATA[image processing]]></category><category><![CDATA[SVG]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Tue, 10 Nov 2026 10:18:39 GMT</pubDate><content:encoded><![CDATA[<p><em>November 10, 2026</em></p>
<p><strong>Short answer:</strong> pick a grid whose pieces come out square (a 3:4 portrait gives 3×4 = 12, 6×8 = 48 or 9×12 = 108 pieces), generate every interior edge <strong>once</strong> with a random tab direction, and build each piece from the four edges around it, so neighbours share exactly the same curve. Export the edges, not the pieces, to SVG so a cutting machine cuts each line once (3,457 mm of cutting instead of 6,214 mm for a 48-piece, 150 × 200 mm puzzle in my test). Then test that edges never cross. An area check alone isn't enough: my first version had crossing edges in 20 out of 20 random layouts and still passed it.</p>
<p>I run <a href="https://malfoidai.com/">malfoidai.com</a>, an independent, unofficial fan-art studio that makes one <strong>still 3:4 portrait at 1K</strong> in the Malfoid look (Female Malfoy / Fem Draco: platinum hair, white shirt, green striped tie, dark robe). You make it from one photo of an adult, or use the preset with no photo. It isn't affiliated with the books, films, author or studios. A small printed puzzle is a nice thing to do with a still portrait, and it's a tidy little geometry problem, so here's a tested version in NumPy and Pillow.</p>
<p>The script writes three things:</p>
<ul>
<li><code>cuts.svg</code>: cut lines in millimetres, for a cutting machine, or to print on top of the picture.</li>
<li><code>sheet.png</code>: the portrait with the cut lines drawn on it, as a preview or a "print and cut with scissors" sheet.</li>
<li><code>pieces/*.png</code> (optional): each piece as a transparent PNG, if you want a digital puzzle.</li>
</ul>
<h2>Pick a grid that suits 3:4</h2>
<p>Square pieces look right and are easier to handle. For a 3:4 picture that means columns : rows = 3 : 4. <code>choose_grid</code> scores every grid by how close it is to the requested count and how square the pieces are, and for 12, 48 and 108 it lands on 3×4, 6×8 and 9×12 exactly. Ask for 24 and you get 4×6, whose pieces are a little wider than tall. That's fine, just not square.</p>
<p>Then the print size. The studio doesn't publish the exact pixel size of a 1K download; check yours (<code>Image.open(p).size</code>). My test image is 768×1024. Its <a href="https://malfoidai.com/guides/check-before-posting/">check-before-posting guide</a> says the portrait is "not meant for large prints", which matters here:</p>
<table>
<thead>
<tr>
<th>Pieces</th>
<th>Grid</th>
<th>Printed size</th>
<th>Piece size</th>
<th>Source px per inch (768 px wide)</th>
</tr>
</thead>
<tbody><tr>
<td>12</td>
<td>3×4</td>
<td>100 × 133 mm</td>
<td>33 mm</td>
<td>195</td>
</tr>
<tr>
<td>48</td>
<td>6×8</td>
<td>150 × 200 mm</td>
<td>25 mm</td>
<td>130</td>
</tr>
<tr>
<td>108</td>
<td>9×12</td>
<td>180 × 240 mm</td>
<td>20 mm</td>
<td>108</td>
</tr>
</tbody></table>
<p>Those numbers are straight from the script's report. At 108 ppi a picture is visibly soft up close. For a puzzle on a table I'd stay around the 48-piece, 150 mm size, and print a test sheet before buying puzzle blanks.</p>
<h2>One edge, two pieces</h2>
<p>The whole trick is to never draw a piece outline by itself. An interior edge is a polyline from one grid corner to the next: straight for the first and last bits, a neck, a round head, then back. Its tab points to one side or the other (<code>sign = ±1</code>), at random. The piece above uses the edge left to right as its bottom; the piece below uses the <em>same array reversed</em> as its top. Border edges are straight lines.</p>
<p>Because both pieces read the same coordinates, there's no gap and no overlap along a shared edge, even with random jitter. That's the property the tests check.</p>
<p>The knob is four cubic Bézier segments in "edge coordinates": <code>u</code> along the edge (0 to 1) and <code>v</code> sideways as a fraction of the edge length. Each edge then gets a small random shift along the edge and a random size between 0.9 and 1.1, so the pieces don't all look stamped from one cutter.</p>
<h2>The script</h2>
<pre><code class="language-bash">pip install numpy pillow
python jigsaw.py portrait.png out/ --pieces 48 --width-mm 150 --piece-pngs
</code></pre>
<pre><code class="language-python">#!/usr/bin/env python3
"""Cut a portrait into a printable jigsaw: cut-line SVG, preview sheet, piece PNGs.

Every interior edge is generated once and shared by the two pieces it
separates, so the pieces tile the picture exactly. The SVG has one path per
edge (not per piece), so a cutting machine never cuts the same line twice.

    pip install numpy pillow
    python jigsaw.py portrait.png out/ [--pieces 48] [--seed 7] [--width-mm 150]
        [--keep-band 0.33,0.45] [--piece-pngs]
"""
import argparse, json, math, os, random
import numpy as np
from PIL import Image, ImageDraw

# One knob as cubic Bezier segments in edge coordinates: u along the edge
# (0..1), v sideways (fraction of edge length). Shape after the common
# "classic" jigsaw curve: neck at u~0.45-0.55, head bulging to v~0.2.
KNOB = [((0.00, 0.00), (0.20, 0.00), (0.42, -0.02), (0.40, 0.06)),
        ((0.40, 0.06), (0.38, 0.14), (0.36, 0.22), (0.50, 0.22)),
        ((0.50, 0.22), (0.64, 0.22), (0.62, 0.14), (0.60, 0.06)),
        ((0.60, 0.06), (0.58, -0.02), (0.80, 0.00), (1.00, 0.00))]


def bezier(p0, p1, p2, p3, n=12):
    t = np.linspace(0, 1, n)[:, None]
    p0, p1, p2, p3 = map(np.array, (p0, p1, p2, p3))
    return (1 - t) ** 3 * p0 + 3 * (1 - t) ** 2 * t * p1 + 3 * (1 - t) * t ** 2 * p2 + t ** 3 * p3


def edge(a, b, sign, rnd):
    """Polyline from corner a to corner b; sign 0 = straight border edge."""
    a, b = np.array(a, float), np.array(b, float)
    if sign == 0:
        return np.array([a, b])
    shift, size = rnd.uniform(-0.06, 0.06), rnd.uniform(0.9, 1.1)
    pts = []
    for seg in KNOB:
        q = bezier(*seg)
        pts.append(q if not pts else q[1:])
    uv = np.vstack(pts)
    # Move/scale the knob, but fade the change out towards both corners
    # (weight sin^2): scaling the whole u axis pushes the first points past
    # the corner and into the neighbouring edge.
    u = uv[:, 0]; wgt = np.sin(np.pi * u) ** 2
    uv[:, 0] = u + (shift + (u - 0.5) * (size - 1)) * wgt
    uv[:, 1] *= size * sign
    d = b - a; n = np.array([-d[1], d[0]])
    return a + uv[:, :1] * d + uv[:, 1:] * n


def choose_grid(w, h, target):
    best = None
    for rows in range(1, 4 * int(math.sqrt(target)) + 3):
        for cols in range(1, 4 * int(math.sqrt(target)) + 3):
            count_err = abs(cols * rows - target) / target
            shape_err = abs(math.log((w / cols) / (h / rows)))
            score = count_err + 0.5 * shape_err
            if best is None or score &lt; best[0]:
                best = (score, cols, rows)
    return best[1], best[2]


def row_lines(h, rows, keep_band=None):
    ys = [h * r / rows for r in range(rows + 1)]
    if keep_band:
        y0, y1 = keep_band[0] * h, keep_band[1] * h
        for i in range(1, rows):
            if y0 &lt; ys[i] &lt; y1:                          # move the cut out of the band
                ys[i] = y0 if ys[i] - y0 &lt; y1 - ys[i] else y1
        assert all(b - a &gt; h / rows * 0.5 for a, b in zip(ys, ys[1:])), "band too wide for this grid"
    return ys


def build(w, h, cols, rows, seed=7, keep_band=None):
    rnd = random.Random(seed)
    xs = [w * c / cols for c in range(cols + 1)]
    ys = row_lines(h, rows, keep_band)
    H, V = {}, {}   # H[r, c]: horizontal edge on line r between columns c..c+1
    for r in range(rows + 1):
        for c in range(cols):
            s = 0 if r in (0, rows) else rnd.choice((-1, 1))
            H[r, c] = edge((xs[c], ys[r]), (xs[c + 1], ys[r]), s, rnd)
    for c in range(cols + 1):
        for r in range(rows):
            s = 0 if c in (0, cols) else rnd.choice((-1, 1))
            V[r, c] = edge((xs[c], ys[r]), (xs[c], ys[r + 1]), s, rnd)
    pieces = {}
    for r in range(rows):
        for c in range(cols):
            top, right = H[r, c], V[r, c + 1]
            bottom, left = H[r + 1, c][::-1], V[r, c][::-1]
            pieces[r, c] = np.vstack([top[:-1], right[:-1], bottom[:-1], left[:-1]])
    return pieces, H, V, xs, ys


def area(poly):
    x, y = poly[:, 0], poly[:, 1]
    return 0.5 * (np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))


def _cross(a, b, c):
    return (b[..., 0] - a[..., 0]) * (c[..., 1] - a[..., 1]) - (b[..., 1] - a[..., 1]) * (c[..., 0] - a[..., 0])


def crossings(p, q):
    """Count proper crossings between segments of polylines p and q."""
    a, b = p[:-1, None], p[1:, None]; c, d = q[None, :-1], q[None, 1:]
    d1, d2 = _cross(c, d, a), _cross(c, d, b); d3, d4 = _cross(a, b, c), _cross(a, b, d)
    return int(((d1 * d2 &lt; -1e-9) &amp; (d3 * d4 &lt; -1e-9)).sum())


def self_crossings(p):
    n = 0
    for i in range(len(p) - 1):
        n += crossings(p[i:i + 2], p[i + 2:]) if i + 2 &lt; len(p) else 0
    return n


def check(pieces, H, V, rows, cols, w, h):
    """Tiling and geometry checks; returns a dict (all zeros/True = good)."""
    total = sum(abs(area(p)) for p in pieces.values())
    bad_pairs = 0
    for r in range(rows):
        for c in range(cols):
            es = [H[r, c], V[r, c + 1], H[r + 1, c], V[r, c]]
            for i in range(4):
                for j in range(i + 1, 4):
                    bad_pairs += crossings(es[i], es[j])
    simple = sum(self_crossings(np.vstack([p, p[:1]])) for p in pieces.values())
    return {"area_error": abs(total - w * h) / (w * h), "edge_crossings": bad_pairs,
            "self_crossings": simple}


def length(p):
    return float(np.linalg.norm(np.diff(p, axis=0), axis=1).sum())


def svg(H, V, w, h, mm_per_px):
    paths = []
    for e in list(H.values()) + list(V.values()):
        d = "M" + " L".join(f"{x * mm_per_px:.3f},{y * mm_per_px:.3f}" for x, y in e)
        paths.append(f'&lt;path d="{d}"/&gt;')
    W, Hh = w * mm_per_px, h * mm_per_px
    return (f'&lt;svg xmlns="http://www.w3.org/2000/svg" width="{W:.2f}mm" height="{Hh:.2f}mm" '
            f'viewBox="0 0 {W:.3f} {Hh:.3f}"&gt;&lt;g fill="none" stroke="#e00" stroke-width="0.1"&gt;'
            + "".join(paths) + "&lt;/g&gt;&lt;/svg&gt;\n")


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("image"); ap.add_argument("out")
    ap.add_argument("--pieces", type=int, default=48); ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--width-mm", type=float, default=150.0)
    ap.add_argument("--keep-band", default=None, help="e.g. 0.33,0.45: no horizontal cut in this height band")
    ap.add_argument("--piece-pngs", action="store_true")
    a = ap.parse_args()
    img = Image.open(a.image).convert("RGB"); w, h = img.size
    band = tuple(map(float, a.keep_band.split(","))) if a.keep_band else None
    cols, rows = choose_grid(w, h, a.pieces)
    pieces, H, V, xs, ys = build(w, h, cols, rows, a.seed, band)
    res = check(pieces, H, V, rows, cols, w, h)
    assert res["area_error"] &lt; 1e-9 and res["edge_crossings"] == 0 and res["self_crossings"] == 0, res
    os.makedirs(a.out, exist_ok=True)
    mm = a.width_mm / w
    with open(f"{a.out}/cuts.svg", "w") as f:
        f.write(svg(H, V, w, h, mm))
    sheet = img.copy(); d = ImageDraw.Draw(sheet)
    for e in list(H.values()) + list(V.values()):
        d.line([tuple(p) for p in e], fill=(255, 255, 255), width=max(1, w // 400))
    sheet.save(f"{a.out}/sheet.png")
    if a.piece_pngs:
        os.makedirs(f"{a.out}/pieces", exist_ok=True)
        rgba = img.convert("RGBA")
        for (r, c), p in pieces.items():
            x0, y0 = np.floor(p.min(0)).astype(int); x1, y1 = np.ceil(p.max(0)).astype(int)
            m = Image.new("L", (x1 - x0, y1 - y0), 0)
            ImageDraw.Draw(m).polygon([tuple(q - (x0, y0)) for q in p], fill=255)
            tile = rgba.crop((x0, y0, x1, y1)); tile.putalpha(m)
            tile.save(f"{a.out}/pieces/r{r:02d}c{c:02d}.png")
    edge_len = sum(length(e) for e in list(H.values()) + list(V.values())) * mm
    piece_len = sum(length(np.vstack([p, p[:1]])) for p in pieces.values()) * mm
    report = {"grid": f"{cols}x{rows}", "pieces": cols * rows,
              "piece_mm": f"{a.width_mm / cols:.1f} x " + "-".join(sorted({f"{(b - t) * mm:.1f}" for t, b in zip(ys, ys[1:])}, key=float)),
              "print_mm": f"{a.width_mm:.0f} x {h * mm:.0f}", "source_ppi": round(w / (a.width_mm / 25.4)),
              "row_lines_px": [round(y) for y in ys], "cut_mm_per_edge": round(edge_len),
              "cut_mm_per_piece_outline": round(piece_len), **res}
    json.dump(report, open(f"{a.out}/report.json", "w"), indent=1)
    print(json.dumps(report))


if __name__ == "__main__":
    main()
</code></pre>
<h2>The bug: scaling the whole edge</h2>
<p>My first version jittered the knob like this:</p>
<pre><code class="language-python">inner = (uv[:, 0] &gt; 0) &amp; (uv[:, 0] &lt; 1)          # keep corners fixed
uv[inner, 0] = 0.5 + (uv[inner, 0] - 0.5) * size + shift
</code></pre>
<p>It looks harmless: scale everything around the middle, move it a little, leave the two corner points alone. But the <em>second</em> point of the curve sits very close to <code>u = 0</code>, and with <code>size = 1.1</code> and <code>shift = -0.06</code> it lands at about <code>u = -0.05</code>. The edge goes backwards past its own corner by a few pixels and into the neighbouring edge. On a printed sheet you'd see a tiny loop at some corners, and a cutting machine would cut a sliver out of the piece.</p>
<p>Here's what the checks reported, run over 20 random seeds of a 48-piece layout:</p>
<pre><code class="language-text">old    seeds with crossings 20/20   total crossings 433   max area error 7.3e-14
fixed  seeds with crossings  0/20   total crossings   0   max area error 5.1e-14
</code></pre>
<p>The area check passed in both cases. The sum of the piece areas equalled the picture's area to 14 decimal places even when edges crossed, because when a polyline loops back on itself the shoelace formula counts the little loop with the opposite sign and it cancels. The test file has a four-point "bow tie" that shows this directly: area 0, one self-crossing.</p>
<p>The fix is in the script above: apply the shift and scale with a weight <code>sin²(πu)</code>, which is 0 at both corners and 1 in the middle. With these jitter limits the remapping <code>u → u'</code> is strictly increasing on [0, 1] (I checked it numerically over the whole shift and size range), and it leaves <code>u = 0</code> and <code>u = 1</code> where they are, so no point can be pushed past a corner.</p>
<h2>How the crossing test works</h2>
<p><code>crossings(p, q)</code> compares every segment of polyline <code>p</code> with every segment of <code>q</code> using the usual orientation test (four cross products), and counts only <em>proper</em> crossings, so two edges that share a corner point don't count. For each grid cell, the check compares its four edges pairwise. That's enough because a tab only reaches into the cells on either side of its edge, and every edge that can poke into a cell is one of that cell's own four edges. It's O(segments²) per pair; for all 48 pieces the whole check took about 0.17 seconds.</p>
<h2>Don't cut through the eyes</h2>
<p>A grid doesn't know there's a face in the picture. On my test portrait the 6×8 grid puts a horizontal cut at y = 384 px, and the eyes are drawn at y = 380–398. So every eye is split between two pieces, and the face is the part people most want to see in one piece.</p>
<p><code>--keep-band 0.33,0.45</code> says "no horizontal cut between 33% and 45% of the height" and moves the offending line to the nearest edge of the band. I measured how many pieces each eye touches, using the known eye positions in the test image (no face detector):</p>
<pre><code class="language-text">  12 3x4 band=None: pieces per eye [1, 1], horizontal cuts at y 370-410: none
  12 3x4 band=(0.33, 0.45): pieces per eye [1, 1], horizontal cuts at y 370-410: none
  48 6x8 band=None: pieces per eye [2, 2], horizontal cuts at y 370-410: [384]
  48 6x8 band=(0.33, 0.45): pieces per eye [1, 1], horizontal cuts at y 370-410: none
 108 9x12 band=None: pieces per eye [2, 2], horizontal cuts at y 370-410: none
 108 9x12 band=(0.33, 0.45): pieces per eye [2, 2], horizontal cuts at y 370-410: none
</code></pre>
<p>(24 pieces, not shown, gives [1, 1] either way.) Two honest limits: the band moves only <em>horizontal</em> cuts, and at 108 pieces it's the vertical cuts at x = 341 and 427 px that split the eyes, so the band doesn't help there. And moving a line makes the rows on either side uneven: with the band, one row of the 48-piece puzzle is 82 px tall and the next is 174 px, so those pieces are no longer square. The report says so: <code>"piece_mm": "25.0 x 16.0-25.0-34.0"</code> (16 mm and 34 mm rows at 150 mm wide). For a real portrait, open <code>sheet.png</code> and look before you print; the band values for your picture are where <em>its</em> eyes are.</p>
<h2>Cut once</h2>
<p>A naive exporter writes 48 closed outlines. Every interior edge then appears twice, and a vinyl or paper cutter goes over it twice, which takes longer and frays card. The report shows the difference for the 48-piece, 150 mm puzzle:</p>
<pre><code class="language-text">"cut_mm_per_edge": 3457, "cut_mm_per_piece_outline": 6214
</code></pre>
<p>That's 1.8 times the cutting for the outline version. The SVG has one <code>&lt;path&gt;</code> per edge, 110 paths for a 6×8 grid (54 horizontal, 56 vertical), with sizes in mm so the cutter software scales it right. If your machine needs closed shapes, cut the outline of the whole puzzle as a separate pass.</p>
<h2>Tests</h2>
<pre><code class="language-text">$ python test_jigsaw.py
test_area_sum_alone_misses_crossings ... ok
test_crossing_is_detected ... ok
test_grid_is_square_ish_for_3x4 ... ok
test_keep_band_moves_cut ... ok
test_many_seeds_tile_without_crossings ... ok
test_pixels_covered_once ... ok
test_shared_edge_is_identical ... ok
test_svg_one_path_per_edge ... ok
Ran 8 tests in 3.644s
OK
</code></pre>
<p><code>test_pixels_covered_once</code> rasterises every piece and adds the masks. On the full 768×1024 test image, 98.54% of pixels belong to exactly one piece, 1.45% to two (the cut lines themselves, which both neighbours draw), and <strong>zero</strong> belong to none. A maximum of 4 happens only where four pieces meet at a grid corner. The two central tests are the ones that would have caught my bug on day one: many seeds without crossings, and a deliberately broken edge that the crossing counter must flag.</p>
<pre><code class="language-python">    def test_many_seeds_tile_without_crossings(self):
        for seed in range(20):
            p, H, V, *_ = J.build(768, 1024, 6, 8, seed)
            r = J.check(p, H, V, 8, 6, 768, 1024)
            self.assertLess(r["area_error"], 1e-9, seed)
            self.assertEqual((r["edge_crossings"], r["self_crossings"]), (0, 0), seed)
</code></pre>
<p>The full run (48 pieces with PNGs) takes about half a second. The 48 transparent piece PNGs come to 392 KB, and <code>cuts.svg</code> is 60 KB.</p>
<h2>Printing it</h2>
<ul>
<li>Print <code>sheet.png</code> (or the portrait plus <code>cuts.svg</code> as an overlay) at exactly the width you passed to <code>--width-mm</code>, or the cut lines won't match the picture.</li>
<li>Mount on card <em>before</em> cutting. Pieces under about 20 mm are fiddly by hand; that's another reason to stay at 48 pieces for a 1K picture.</li>
<li>Before you print a face, check it: the studio's <a href="https://malfoidai.com/guides/check-before-posting/">guide</a> suggests looking at the face, hands, hair edges, tie and any background text or crests. A stray symbol is easy to crop out now and annoying on 48 bits of card.</li>
</ul>
<h2>Whose face goes in the box</h2>
<p>A puzzle is a gift, and a gift with someone's face on it needs their yes. The <a href="https://malfoidai.com/acceptable-use/">acceptable-use page</a> allows a photo of yourself or of an adult who agreed, and never a child or a celebrity. The <a href="https://malfoidai.com/guides/photo-requirements/">photo guide</a> explains which photo works (one adult, face visible, even light, JPEG/PNG/WebP up to 10 MB). If the puzzle is for a fan night rather than for a person, the no-photo preset makes a fictional adult in the same look (<a href="https://malfoidai.com/guides/which-preset/">which preset?</a>). Either way it's fan art, so say so if you post the result.</p>
<h2>What the portrait costs</h2>
<p>On the <a href="https://malfoidai.com/pricing/">pricing page</a> as of November 10, 2026: <strong>$4.99</strong> for one portrait, paid once with no account, from your photo or without one. Signed-in accounts can buy one-time packs: <strong>$13.99 for 30 credits, $35.99 for 100, $59.99 for 200</strong>. A portrait spends 2 credits, packs don't renew, and there's no subscription. No watermark is added, so there's no corner logo to land on a piece.</p>
<p><em>Code: MIT. The test portrait is a synthetic drawing; no real face or generated result is used.</em></p>
]]></content:encoded></item><item><title><![CDATA[Is the Sound Out of Sync After You Edited Your Bama Bound Clip? Measure the Drift Against the Original in Python (Tested)]]></title><description><![CDATA[Checked November 10, 2026 (15:00 CST, UTC+8). I build bamabound.app, an independent studio that turns one photo of one person into a vertical clip of them…]]></description><link>https://lpgit.hashnode.dev/is-the-sound-out-of-sync-after-you-edited-your-bama-bound-clip-measure-the-drift-against-the-original-in-python-tested</link><guid isPermaLink="true">https://lpgit.hashnode.dev/is-the-sound-out-of-sync-after-you-edited-your-bama-bound-clip-measure-the-drift-against-the-original-in-python-tested</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Tue, 10 Nov 2026 07:33:28 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 10, 2026 (15:00 CST, UTC+8). I build <a href="https://bamabound.app/">bamabound.app</a>, an independent studio that turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner. The clip keeps its original soundtrack (<a href="https://bamabound.app/faq/">FAQ</a>). Everything below was tested on synthetic clips, not real people.</em></p>
<p>You download your Bama Bound clip, drop it into an editor, add a caption card in front ("applied to bama as a joke…"), export, and something feels off. The sound seems to hit a beat early or late. Was it the editor, the export, or are you just tired?</p>
<p>You can answer that with numbers, because <strong>you still have the original file</strong>. The original is a perfect reference. Wherever it ends up inside the edit, its picture and its sound should have moved by the <strong>same</strong> amount. If they moved by different amounts, the difference is the sync error your edit introduced.</p>
<h2>The idea in one paragraph</h2>
<p>Find where the original sits inside the edited file twice, independently. First <strong>by sound</strong>: cross-correlate the loudness onsets (every rise in level, 100 times a second). Then <strong>by picture</strong>: cross-correlate how much each frame changes from the previous one. An intro card shifts both by, say, 1.500 s, which is fine. If the sound comes out at +1.700 s and the picture at +1.500 s, the audio is now <strong>200 ms late</strong>.</p>
<p>How much is too much? ITU-R BT.1359 gives detectability thresholds of about <strong>45 ms with audio early</strong> and <strong>125 ms with audio late</strong>. People are more forgiving of late sound because that's how sound behaves in the real world. The script uses that window for its pass/fail.</p>
<h2>Why both signals need care</h2>
<p>Two details took me a couple of failed test runs to get right, and they'll bite any home-made version.</p>
<p><strong>1. A silent intro looks like a huge "onset."</strong> If your edit starts with silence, the level jumps from −120 dB to the soundtrack, and that one jump can outweigh every real beat. The script floors the level at −50 dBFS before taking differences. Then a silent head is just "quiet," not an event.</p>
<p><strong>2. Different frame rates break naive motion matching.</strong> My first version resampled both videos to 100 fps with ffmpeg's <code>fps</code> filter. That duplicates frames, so a 30 fps original turns into a spike train with 30 spikes a second, and a 24 fps phone export turns into 24. Those two trains don't correlate, and the measurement fell apart: a "weak match" and a garbage 2.85 s shift. The fix is to measure frame change <strong>at each file's own frame rate</strong>, as change per second, and interpolate onto a common 100 Hz grid.</p>
<p>A third one was more subtle. When scoring each candidate shift, I first divided the correlation by the overlap length. That quietly favours shifts where only a sliver overlaps, and it picked a nonsense −2.4 s for a clip that had simply been delayed 200 ms. Dividing by the <strong>full</strong> length of the original makes partial overlaps score lower, which is what you want.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Did editing put the sound out of sync? Compare an edited clip with the original.

  python3 av_offset.py original.mp4 edited.mp4 [--max-shift 5] [--json]

It finds where the original sits inside the edited file twice, independently:
  * by sound  - cross-correlating loudness-onset envelopes (100 per second)
  * by motion - cross-correlating frame-difference energy per second, measured at
                each file's own frame rate and interpolated onto the same 100 Hz grid
If the edit kept sync, both shifts are equal (an intro card moves both by the
same amount). The difference is the sync error the edit introduced.
Exit 0 = within the detectability window (audio up to 45 ms early or 125 ms late,
ITU-R BT.1359), 1 = outside it, 2 = could not measure (no sound or no motion).
Needs ffmpeg on PATH and NumPy.
"""
import argparse, json, subprocess, sys
import numpy as np

RATE = 100  # envelope samples per second, both signals

def audio_env(path):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", "8000",
                        "-f", "f32le", "-"], capture_output=True)
    if p.returncode: return None          # no audio stream (muted export)
    raw = p.stdout
    x = np.frombuffer(raw, np.float32)
    hop = 8000 // RATE
    n = len(x) // hop
    if n &lt; 10: return None
    rms = np.sqrt((x[: n * hop].reshape(n, hop) ** 2).mean(1) + 1e-12)
    db = np.maximum(20 * np.log10(rms), -50.0)  # a silent intro shouldn't look like a huge onset
    return np.maximum(np.diff(db, prepend=db[0]), 0)  # onsets: rises in level only

def frame_rate(path):
    r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                        "stream=avg_frame_rate", "-of", "csv=p=0", path],
                       check=True, capture_output=True, text=True).stdout.strip()
    n, d = r.split("/")
    return float(n) / float(d)

def motion_env(path):
    w, h = 64, 112  # tiny greyscale frames are plenty for "how much changed"
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-an", "-fps_mode", "passthrough",
                          "-vf", f"scale={w}:{h},format=gray", "-f", "rawvideo", "-"],
                         check=True, capture_output=True).stdout
    f = np.frombuffer(raw, np.uint8).reshape(-1, h, w).astype(np.float32)
    if len(f) &lt; 10: return None
    fps = frame_rate(path)
    rate = np.abs(np.diff(f, axis=0)).mean((1, 2)) * fps   # change per second, any frame rate
    t_mid = (np.arange(1, len(f)) - 0.5) / fps
    grid = np.arange(0, len(f) / fps, 1 / RATE)
    return np.interp(grid, t_mid, rate)

def best_shift(ref, sig, max_shift):
    """Lag (in samples, sub-sample) at which `ref` best matches inside `sig`."""
    ref = (ref - ref.mean()) / (ref.std() + 1e-9)
    sig = (sig - sig.mean()) / (sig.std() + 1e-9)
    m = int(max_shift * RATE)
    lags = np.arange(-m, m + 1)
    score = np.full(len(lags), -np.inf)
    for i, L in enumerate(lags):
        a0, b0 = max(0, -L), max(0, L)          # ref[a0:], sig[b0:]
        n = min(len(ref) - a0, len(sig) - b0)
        if n &lt; len(ref) // 2: continue          # need at least half the original to overlap
        score[i] = float(np.dot(ref[a0:a0 + n], sig[b0:b0 + n]) / len(ref))  # partial overlap scores less
    k = int(np.argmax(score))
    off = 0.0
    if 0 &lt; k &lt; len(lags) - 1 and np.isfinite(score[k - 1]) and np.isfinite(score[k + 1]):
        y0, y1, y2 = score[k - 1], score[k], score[k + 1]
        den = y0 - 2 * y1 + y2
        off = 0.5 * (y0 - y2) / den if den else 0.0   # parabolic peak refinement
    return (lags[k] + off) / RATE, float(score[k])

def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("original"); p.add_argument("edited")
    p.add_argument("--max-shift", type=float, default=5.0, help="seconds to search (default 5)")
    p.add_argument("--json", action="store_true")
    a = p.parse_args()
    ae0, ae1 = audio_env(a.original), audio_env(a.edited)
    me0, me1 = motion_env(a.original), motion_env(a.edited)
    if ae0 is None or ae1 is None or me0 is None or me1 is None:
        print("cannot measure: a file has no usable audio or video", file=sys.stderr); return 2
    sa, qa = best_shift(ae0, ae1, a.max_shift)
    sv, qv = best_shift(me0, me1, a.max_shift)
    err_ms = (sa - sv) * 1000          # &gt; 0: sound now arrives later than the picture
    r = {"audio_shift_s": round(sa, 3), "video_shift_s": round(sv, 3),
         "sync_error_ms": round(err_ms), "audio_match": round(qa, 2), "video_match": round(qv, 2)}
    if min(qa, qv) &lt; 0.3:
        r["verdict"] = "weak match: edit changed too much to compare"; code = 2
    elif -45 &lt;= err_ms &lt;= 125:
        r["verdict"] = "in sync"; code = 0
    else:
        r["verdict"] = f"audio {'late' if err_ms &gt; 0 else 'early'} by {abs(err_ms):.0f} ms"; code = 1
    if a.json: print(json.dumps(r))
    else:
        print(f"original starts {sv:+.3f} s into the edit (picture), {sa:+.3f} s (sound)")
        print(f"sync error {err_ms:+.0f} ms -&gt; {r['verdict']}  [match {qv:.2f} video, {qa:.2f} audio]")
    return code

if __name__ == "__main__":
    sys.exit(main())
</code></pre>
<p>Output for a clip whose sound I pushed 200 ms late:</p>
<pre><code class="language-text">$ python3 av_offset.py orig.mp4 edits/late200.mp4
original starts -0.000 s into the edit (picture), +0.200 s (sound)
sync error +200 ms -&gt; audio late by 200 ms  [match 1.00 video, 0.84 audio]
</code></pre>
<p>The match scores are correlation peaks (1.0 = identical shape). Below 0.3, the script refuses to give a verdict. If you replaced the music, or covered most of the frame with a sticker, there's nothing left to compare, and a confident wrong number is worse than none.</p>
<h2>Testing it: edits with known errors</h2>
<p>To trust a measuring tool, you have to feed it cases where you already know the answer. This script makes seven edits of one clip:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Make edited copies of a clip with KNOWN sync errors, to test av_offset.py.
#   ./make_sync_edits.sh original.mp4 outdir
set -euo pipefail
src=$1; out=${2:-edits}; mkdir -p "$out"
enc=(-c:v libx264 -preset veryfast -crf 20 -pix_fmt yuv420p -c:a aac -b:a 128k)
q() { ffmpeg -v error -y "$@"; }
# 1. plain re-encode: 0 ms
q -i "$src" "${enc[@]}" "$out/reencode.mp4"
# 2. 1.5 s intro (first frame held, silence): both shift 1.5 s, still 0 ms
q -i "$src" -vf "tpad=start_duration=1.5:start_mode=clone" -af "adelay=1500:all=1" "${enc[@]}" "$out/intro.mp4"
# 3. sound pushed 200 ms late
q -i "$src" -af "adelay=200:all=1,atrim=duration=8" "${enc[@]}" "$out/late200.mp4"
# 4. sound pulled 80 ms early
q -i "$src" -af "atrim=start=0.08,asetpts=PTS-STARTPTS,apad=pad_dur=0.08" "${enc[@]}" "$out/early80.mp4"
# 5. phone-style export: 720x1280, 24 fps, 0.5 s trimmed off the start, sound 90 ms late
q -ss 0.5 -i "$src" -vf "scale=720:1280,fps=24" -af "adelay=90:all=1" "${enc[@]}" "$out/export24_late90.mp4"
# 6. sound 40 ms late (inside the window, should pass)
q -i "$src" -af "adelay=40:all=1" "${enc[@]}" "$out/late40.mp4"
# 7. sound removed: nothing to compare
q -i "$src" -an -c:v libx264 -preset veryfast -crf 20 "$out/muted.mp4"
echo "wrote $(ls "$out" | wc -l) edits to $out"
</code></pre>
<p>The test clip is a red banner swaying over a grainy field, with short beeps at irregular times. The irregular part matters: beeps every 2.0 s would make shifts of ±2 s look equally good.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Synthetic stand-in for a vertical banner clip (no people, no real footage):
# gradient sky, noisy green field, a red banner with white text that sways
# left and right, and a soundtrack of short 880 Hz beeps at irregular times
# over a quiet 220 Hz hum.
#   ./make_field_test_clip.sh out.mp4 [WxH] [fps] [seconds]
#   env NOISE=0..40 sets the field grain (default 18)
set -euo pipefail
out=${1:-field_test.mp4}; size=${2:-480x854}; fps=${3:-30}; dur=${4:-8}
W=${size%x*}; H=${size#*x}
FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
BW=$((W*3/4)); BH=$((H/6)); BY=$((H*2/5))
BX="(${W}-${BW})/2+${W}/10*sin(2*PI*t/3.1)*cos(2*PI*t/7.3)"
SKY=$((H*2/5))
ffmpeg -v error -y \
  -f lavfi -i "gradients=s=${W}x${SKY}:c0=0x2F7FD0:c1=0xBFE3FF:x0=0:y0=0:x1=0:y1=${SKY}:r=${fps}:d=${dur}:speed=0" \
  -f lavfi -i "color=c=0x3A8A3A:s=${W}x${H}:r=${fps}:d=${dur}" \
  -f lavfi -i "color=c=0xC8102E:s=${BW}x${BH}:r=${fps}:d=${dur}" \
  -f lavfi -i "aevalsrc='0.08*sin(2*PI*220*t)+0.6*sin(2*PI*880*t)*(between(t,0.7,0.82)+between(t,2.1,2.22)+between(t,2.6,2.72)+between(t,4.9,5.02)+between(t,6.3,6.42))':s=48000:d=${dur}" \
  -filter_complex "[1:v]noise=alls=${NOISE:-18}:allf=t,format=yuv420p[field];\
[field][0:v]overlay=0:0[bg];\
[2:v]drawtext=fontfile=${FONT}:text='TEST BANNER':fontcolor=white:fontsize=$((BH/4)):x=(w-text_w)/2:y=(h-text_h)/2[ban];\
[bg][ban]overlay=x='${BX}':y=${BY},format=yuv420p[v]" \
  -map "[v]" -map 3:a -c:v libx264 -preset veryfast -crf 20 -r "$fps" -c:a aac -b:a 128k -t "$dur" "$out"
echo "wrote $out (${W}x${H} @ ${fps} fps, ${dur} s)"
</code></pre>
<p>And a small test runner:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Run av_offset.py on edits with known errors and check each answer.
   python3 test_av_offset.py original.mp4 edits/"""
import json, subprocess, sys, os
here = os.path.dirname(os.path.abspath(__file__))
orig, d = sys.argv[1], sys.argv[2]
cases = {  # file: (expected sync error ms, expected exit code)
    "reencode.mp4": (0, 0), "intro.mp4": (0, 0), "late200.mp4": (200, 1),
    "early80.mp4": (-80, 1), "export24_late90.mp4": (90, 0), "late40.mp4": (40, 0), "muted.mp4": (None, 2)}
TOL = 15  # ms
bad = 0
for f, (want, code) in cases.items():
    p = subprocess.run([sys.executable, f"{here}/av_offset.py", orig, f"{d}/{f}", "--json"],
                       capture_output=True, text=True)
    if want is None:  # unmeasurable on purpose
        ok = p.returncode == code; bad += not ok
        print(f"{'PASS' if ok else 'FAIL'} {f:&lt;22} want exit 2, got exit {p.returncode}: {p.stderr.strip()}")
        continue
    r = json.loads(p.stdout)
    ok = abs(r["sync_error_ms"] - want) &lt;= TOL and p.returncode == code
    bad += not ok
    print(f"{'PASS' if ok else 'FAIL'} {f:&lt;22} want {want:+4d} ms got {r['sync_error_ms']:+4d} ms "
          f"(video {r['video_shift_s']:+.3f} s, audio {r['audio_shift_s']:+.3f} s) exit {p.returncode} {r['verdict']}")
print("all passed" if not bad else f"{bad} failed"); sys.exit(1 if bad else 0)
</code></pre>
<p>Results on my box (ffmpeg 7.1, NumPy 2.5), for a 30 fps original and then a 24 fps 720×1280 original:</p>
<pre><code class="language-text">$ python3 test_av_offset.py orig.mp4 edits
PASS reencode.mp4           want   +0 ms got   +0 ms (video -0.000 s, audio -0.000 s) exit 0 in sync
PASS intro.mp4              want   +0 ms got   +0 ms (video +1.500 s, audio +1.500 s) exit 0 in sync
PASS late200.mp4            want +200 ms got +200 ms (video -0.000 s, audio +0.200 s) exit 1 audio late by 200 ms
PASS early80.mp4            want  -80 ms got  -80 ms (video -0.000 s, audio -0.080 s) exit 1 audio early by 80 ms
PASS export24_late90.mp4    want  +90 ms got  +95 ms (video -0.505 s, audio -0.410 s) exit 0 in sync
PASS late40.mp4             want  +40 ms got  +40 ms (video -0.000 s, audio +0.040 s) exit 0 in sync
PASS muted.mp4              want exit 2, got exit 2: cannot measure: a file has no usable audio or video
all passed

$ python3 test_av_offset.py o2.mp4 e2        # 24 fps original
...
PASS export24_late90.mp4    want  +90 ms got  +90 ms (video -0.500 s, audio -0.410 s) exit 0 in sync
all passed
</code></pre>
<p>The 24 fps export from a 30 fps original reads +95 ms instead of +90 ms. The picture can only be located to within a frame of the new rate (about 42 ms at 24 fps), and the interpolation gets most of the way there. That's well inside the window that matters.</p>
<h2>What to do with the number</h2>
<ul>
<li><strong>0 to ±20 ms:</strong> you're fine. That's below what anyone notices.</li>
<li><strong>Audio late, 40–125 ms:</strong> usually fine for a meme. If it bothers you, shift the audio track earlier by that amount in your editor.</li>
<li><strong>Audio early by more than 45 ms, or late by more than 125 ms:</strong> fix it. In ffmpeg, a late track gets <code>-af "atrim=start=0.200,asetpts=PTS-STARTPTS"</code> (pull 200 ms earlier). An early track gets <code>-af "adelay=80:all=1"</code>. Then run the check again on the fixed file.</li>
<li><strong>"weak match":</strong> you changed too much to compare automatically. Check by eye and ear at a moment with a clear hit.</li>
</ul>
<h2>Where drift actually comes from</h2>
<p>In my tests the usual suspects were the ones you'd expect. <strong>Trimming audio and video separately</strong> in an editor, with one snapped to frames and the other not. <strong>Frame-rate conversion</strong> on export (30 → 24 or 25). And <strong>phone apps that add an intro</strong> and pad the audio slightly differently from the video. A plain re-encode didn't move anything.</p>
<p>If the <em>original</em> download already sounds wrong to you, compare it against itself in this tool: there's nothing to measure. A clip that looks or sounds different from what you hoped isn't by itself a failed order. A paid clip that never arrived goes to refund review (<a href="https://bamabound.app/help/">help</a>).</p>
<p><em>Related: what the clip includes and what it costs (<a href="https://bamabound.app/pricing/">pricing</a>: $7.98 at 480p, $9.98 at 720p, $16.98 at 1080p, $24.98 at 4k per generation, tax added at checkout), and how to pick the photo (<a href="https://bamabound.app/guides/">guides</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[How Do You Cut Just the "Outstanding" Payoff From a Tiny Desk AI Clip for a Story or a Reply? Find It by Loudness and Motion (Python + ffmpeg, Tested)]]></title><description><![CDATA[Checked November 10, 2026 (12:00 CST, UTC+8). I build tinydeskoutstanding.video, an independent studio for the "Outstanding" Tiny Desk parody. The script…]]></description><link>https://lpgit.hashnode.dev/how-do-you-cut-just-the-outstanding-payoff-from-a-tiny-desk-ai-clip-for-a-story-or-a-reply-find-it-by-loudness-and-motion-python-ffmpeg-tested</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-cut-just-the-outstanding-payoff-from-a-tiny-desk-ai-clip-for-a-story-or-a-reply-find-it-by-loudness-and-motion-python-ffmpeg-tested</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Tue, 10 Nov 2026 04:28:36 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 10, 2026 (12:00 CST, UTC+8). I build <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a>, an independent studio for the "Outstanding" Tiny Desk parody. The script below was written for this post and tested on synthetic clips with a known "payoff" stretch, not on real footage.</em></p>
<p>A full Tiny Desk AI clip is 15 seconds. That's right for a post, and too long for a lot of other places: a reply in a thread, a 5-second story sticker, a reaction in a group chat where everyone has already seen the whole thing. What people want to resend is the <strong>payoff</strong>: the laugh and the sax lift, the bit that made them reply "outstanding".</p>
<p>You could scrub through and cut it by hand. If you're making several (a set of friends' versions, say), it's nicer to let a script find the busiest few seconds and cut them cleanly. This post does that with two signals you can measure with ffmpeg and NumPy: <strong>how loud</strong> the clip is and <strong>how much the picture moves</strong>.</p>
<h2>What the clip looks like</h2>
<p>The scene is fixed. The studio's <a href="https://tinydeskoutstanding.video/guides/check-before-posting/">check-before-posting guide</a> describes it in three parts (the band shot, the laughing close-up and the sax lift) and adds: "The ending matters more than it seems." Every clip is "15-second vertical video, 9:16, at 480p, 720p, 1080p, or 4k, with sound", and the sound is newly generated (<a href="https://tinydeskoutstanding.video/faq/">FAQ</a>).</p>
<p>Because the sound is generated for each clip, I don't hard-code timestamps. Two runs of the same scene don't have to peak at the same tenth of a second. The script measures the clip it's given.</p>
<h2>Two signals, one score</h2>
<p><strong>Loudness.</strong> Decode the audio to mono 16 kHz floats, split it into 0.1-second steps and take the RMS of each step, in dB. Using dB matters: on a linear scale a quiet passage is "almost zero" and a loud one dominates; in dB the shape of the whole clip shows.</p>
<p><strong>Motion.</strong> Decode the video at 10 frames per second, scaled down to 64×112 grey pixels, and take the mean absolute difference between each frame and the one before. Tiny frames are enough: you're measuring "how much changed", not what changed, and they decode fast.</p>
<p>Both curves are scaled to 0–1 using their 5th and 95th percentiles (so one spike doesn't flatten everything else) and averaged. Then a running sum over a window of <code>--length</code> seconds gives a score for every possible start time. Highest wins.</p>
<p><strong>Ties go late.</strong> In this meme the joke lands at the end. If two windows score within 2% of each other, the script takes the later one. Without that rule, a 5-second window around a 3-second payoff can start anywhere in a 2-second range, and noise decides. With it, the cut ends on the payoff's tail instead of starting on dead air.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Find the 'payoff' of a short clip (the loudest, busiest stretch) and cut it out.

Loudness: RMS of the decoded sound in 0.1 s steps.
Motion:   mean absolute difference between consecutive small grey frames (10 fps).
Both are scaled to 0..1 (5th..95th percentile), averaged, and the window of
--length seconds with the highest total wins. Ties go to the later window,
because the joke in this meme lands at the end ("ties" = within 2% of the best,
so a tenth of a second of noise doesn't decide it).

usage: python3 payoff.py clip.mp4 [--length 5] [--out payoff.mp4] [--weight-audio 0.5]
"""
import argparse, json, subprocess
import numpy as np

STEP = 0.1  # seconds per analysis step

def duration(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                          "-of", "csv=p=0", path], capture_output=True, text=True, check=True)
    return float(out.stdout)

def loudness(path, n):
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", "16000",
                          "-f", "f32le", "-"], capture_output=True, check=True).stdout
    x = np.frombuffer(raw, np.float32)
    hop = int(16000 * STEP)
    r = np.zeros(n)
    for i in range(n):
        seg = x[i * hop:(i + 1) * hop]
        r[i] = np.sqrt(np.mean(seg ** 2)) if seg.size else 0.0
    return 20 * np.log10(r + 1e-5)          # dB, so a quiet tone isn't "zero"

def motion(path, n):
    w, h = 64, 112
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-an", "-vf",
                          f"fps={1/STEP:g},scale={w}:{h},format=gray", "-f", "rawvideo", "-"],
                         capture_output=True, check=True).stdout
    f = np.frombuffer(raw, np.uint8).reshape(-1, h, w).astype(np.float32)
    d = np.zeros(n)
    m = np.abs(np.diff(f, axis=0)).mean(axis=(1, 2))
    d[1:1 + min(n - 1, m.size)] = m[: n - 1]
    return d

def norm(v):
    lo, hi = np.percentile(v, 5), np.percentile(v, 95)
    return np.clip((v - lo) / (hi - lo), 0, 1) if hi &gt; lo else np.zeros_like(v)

def find(path, length, wa):
    dur = duration(path)
    n = int(dur / STEP)
    s = wa * norm(loudness(path, n)) + (1 - wa) * norm(motion(path, n))
    k = int(round(length / STEP))
    if k &gt;= n:
        return 0.0, dur, s
    sums = np.convolve(s, np.ones(k), "valid")           # sums[i] = window starting at step i
    best = np.flatnonzero(sums &gt;= 0.98 * sums.max())[-1]  # latest near-best window
    return best * STEP, min(length, dur - best * STEP), s

def cut(path, out, start, length, fade=0.15):
    af = f"afade=t=in:d={fade},afade=t=out:st={max(0, length - fade):.3f}:d={fade}"
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{start:.3f}", "-i", path,
                    "-t", f"{length:.3f}", "-af", af, "-c:v", "libx264", "-crf", "18",
                    "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
                    "-movflags", "+faststart", out], check=True)

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("clip"); ap.add_argument("--length", type=float, default=5.0)
    ap.add_argument("--out"); ap.add_argument("--weight-audio", type=float, default=0.5)
    a = ap.parse_args()
    start, length, s = find(a.clip, a.length, a.weight_audio)
    print(json.dumps({"start": round(start, 2), "end": round(start + length, 2),
                      "length": round(length, 2),
                      "busiest_second": round(float(np.argmax(s)) * STEP, 1)}))
    if a.out:
        cut(a.clip, a.out, start, length)
</code></pre>
<p>Three choices worth explaining:</p>
<ul>
<li><strong>The cut is re-encoded</strong>, not stream-copied. A copy (<code>-c copy</code>) can only start on a keyframe, so a "5-second" cut may start a second early. A re-encode at CRF 18 starts on the exact frame.</li>
<li><strong>Short audio fades</strong> (0.15 s) on both ends, so the cut doesn't click.</li>
<li><strong><code>-ss</code> before <code>-i</code></strong> with re-encoding is frame-accurate in current ffmpeg and much faster than decoding from the start.</li>
</ul>
<h2>Testing it on clips where I know the answer</h2>
<p>Real clips don't come with a label that says "the payoff is here", so the test builds synthetic ones that do: a still picture with a quiet tone, except one stretch with a moving test pattern and a loud tone.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Synthetic 15 s vertical clip: still picture and a quiet tone, except one
# "payoff" stretch with motion and a loud tone. No real footage.
#   bash make_payoff_test_clip.sh out.mp4 PAYOFF_START PAYOFF_END   (whole seconds)
set -euo pipefail
out=${1:-test.mp4}; a=${2:-10}; b=${3:-13}
ffmpeg -v error -y \
  -f lavfi -i "color=c=0x334455:size=480x854:rate=30:duration=$a" \
  -f lavfi -i "testsrc2=size=480x854:rate=30:duration=$((b - a))" \
  -f lavfi -i "color=c=0x334455:size=480x854:rate=30:duration=$((15 - b))" \
  -f lavfi -i "aevalsrc='sin(2*PI*440*t)*(0.03+0.6*between(t,$a,$b))':s=48000:d=15" \
  -filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0,format=yuv420p[v]" \
  -map "[v]" -map 3:a -c:v libx264 -c:a aac -shortest "$out"
echo "made $out (payoff $a-$b s)"
</code></pre>
<p>The test checks two things for several payoff positions and window lengths: the chosen window <strong>contains</strong> the payoff when the window is longer than the payoff (or sits <strong>inside</strong> it when shorter), and the cut file is the requested length.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Self-test: build synthetic clips with a known payoff stretch and check that
payoff.py picks a window that contains it, and that the cut has the right length.
usage: python3 test_payoff.py   (run next to payoff.py and make_payoff_test_clip.sh)"""
import os, subprocess, sys, tempfile
from payoff import find, cut, duration

HERE = os.path.dirname(os.path.abspath(__file__))
cases = [(10, 13, 5), (10, 13, 3), (3, 6, 4), (6, 9, 6), (12, 14, 2)]
fails = 0
with tempfile.TemporaryDirectory() as tmp:
    for a, b, length in cases:
        clip = f"{tmp}/c_{a}_{b}.mp4"
        subprocess.run(["bash", f"{HERE}/make_payoff_test_clip.sh", clip, str(a), str(b)],
                       check=True, stdout=subprocess.DEVNULL)
        start, got_len, _ = find(clip, length, 0.5)
        end = start + got_len
        need = (a, b) if length &gt;= b - a else None
        ok = (start &lt;= a + 0.15 and end &gt;= b - 0.15) if need else (start &gt;= a - 0.15 and end &lt;= b + 0.15)
        cut(clip, f"{tmp}/cut.mp4", start, got_len)
        d = duration(f"{tmp}/cut.mp4")
        ok = ok and abs(d - got_len) &lt; 0.1
        fails += not ok
        print(f"payoff {a}-{b}s, length {length}s -&gt; window {start:.1f}-{end:.1f}s, cut {d:.2f}s  "
              f"{'ok' if ok else 'FAIL'}")
print("PASS" if not fails else f"FAIL ({fails})")
sys.exit(1 if fails else 0)
</code></pre>
<pre><code>$ python3 test_payoff.py
payoff 10-13s, length 5s -&gt; window 10.0-15.0s, cut 5.00s  ok
payoff 10-13s, length 3s -&gt; window 10.1-13.1s, cut 3.00s  ok
payoff 3-6s, length 4s -&gt; window 3.0-7.0s, cut 4.00s  ok
payoff 6-9s, length 6s -&gt; window 6.0-12.0s, cut 6.00s  ok
payoff 12-14s, length 2s -&gt; window 12.0-14.0s, cut 2.00s  ok
PASS
</code></pre>
<p>The first run of this test taught me the tie rule. Without the 2% tolerance, the 10–13 s case picked 8.7–13.7 s: technically correct, but it opened on 1.3 seconds of nothing. With it, the window starts on the payoff.</p>
<h2>Using it</h2>
<pre><code class="language-bash">python3 payoff.py tinydesk.mp4 --length 5 --out payoff.mp4
# {"start": ..., "end": ..., "length": 5.0, "busiest_second": ...}
</code></pre>
<p>Watch the result once before sending it. The score knows "loud and busy"; it doesn't know funny. If the busiest stretch on your clip is the band shot rather than the close-up, nudge <code>--weight-audio</code> (1.0 = loudness only, 0.0 = motion only) or pick the window by hand from <code>busiest_second</code>.</p>
<h2>Posting the short version</h2>
<p>A 5-second cut loses the setup, so it works best as a reply to the full clip, or with the full clip one tap away. The same rules apply as for the whole thing: the people in it agreed to be in it, and the caption says it's an AI parody, not NPR footage (<a href="https://tinydeskoutstanding.video/guides/check-before-posting/">check-before-posting</a>).</p>
<p>If you're starting from scratch, the studio at <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a> takes one to five photos. The price is the same for one face or five: $9.98 at 480p, $12.98 at 720p, $24.98 at 1080p or $39.98 at 4k as of today (<a href="https://tinydeskoutstanding.video/pricing/">pricing</a>). For cutting a 5-second payoff for a phone, 480p is plenty.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Embed a Hit the Road Dude Clip on Your Own Page? Poster Image, Muted Autoplay and Lazy Loading That Behave on iPhone]]></title><description><![CDATA[You made a Hit the Road Dude remake (the State Property meme: a driver at a pickup window repeats "State?", says it is private property and sends someone…]]></description><link>https://lpgit.hashnode.dev/how-do-you-embed-a-hit-the-road-dude-clip-on-your-own-page-poster-image-muted-autoplay-and-lazy-loading-that-behave-on-iphone</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-embed-a-hit-the-road-dude-clip-on-your-own-page-poster-image-muted-autoplay-and-lazy-loading-that-behave-on-iphone</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Tue, 10 Nov 2026 01:14:40 GMT</pubDate><content:encoded><![CDATA[<p>You made a Hit the Road Dude remake (the State Property meme: a driver at a pickup window repeats "State?", says it is private property and sends someone off) and you want it on your own page, blog or group site instead of a platform that recompresses it. Here is the HTML I would use, and the browser rules that decide whether it plays.</p>
<p>The clip I have in mind is the kind <a href="https://hittheroaddude.app/">hittheroaddude.app</a> sells: 15 seconds, vertical 9:16, 480p or 720p, with a newly generated voice. The site takes one photo of an adult or a pet and no prompt. Everything below applies to any short vertical MP4 you have the right to publish. I have not tested this snippet on every browser, only on the standards-based behaviour described, so test on your own phone.</p>
<h2>The minimum that works</h2>
<pre><code class="language-html">&lt;video controls playsinline preload="metadata"
       poster="state-poster.jpg" width="360" height="640"&gt;
  &lt;source src="state-remake.mp4" type="video/mp4"&gt;
  Your browser can't play this video.
  &lt;a href="state-remake.mp4"&gt;Download the clip (MP4)&lt;/a&gt;.
&lt;/video&gt;
</code></pre>
<p>What each attribute does:</p>
<ul>
<li><strong><code>controls</code></strong> shows play, seek and volume. Because this clip has a voice and the joke is the "State?" pause, you want people to be able to hear it.</li>
<li><strong><code>playsinline</code></strong> stops iPhone from opening the clip fullscreen the moment someone presses play. Without it, a vertical clip can jump out of your layout.</li>
<li><strong><code>preload="metadata"</code></strong> loads only the duration and the first frame, not the whole file, so a page with several clips doesn't download them all up front.</li>
<li><strong><code>poster</code></strong> is the image shown before play. Pick a good one: choose a sharp, well-lit frame, ideally the "State?" beat.</li>
<li><strong><code>width</code> and <code>height</code></strong> reserve space, so the page doesn't jump when the video loads. For 9:16 use matching numbers like 360 by 640.</li>
<li>The fallback link is for browsers and feed readers that strip video.</li>
</ul>
<h2>Why muted autoplay is a trap for this clip</h2>
<p>Browsers generally only autoplay video that is muted, and iPhone also needs <code>playsinline</code>. Autoplay is tempting for a clip like this, but the sound is the joke. A muted autoplay shows a driver moving his mouth in silence. If you want a looping preview, make it a separate short silent preview and link to the full clip:</p>
<pre><code class="language-html">&lt;video autoplay muted loop playsinline preload="metadata"
       poster="state-poster.jpg" width="180" height="320"&gt;
  &lt;source src="state-preview.mp4" type="video/mp4"&gt;
&lt;/video&gt;
</code></pre>
<p>And make the preview a deliberate cut, not the original with the sound stripped. Say clearly on the page that the full version has sound.</p>
<h2>Lazy loading: don't start downloading until it's near the screen</h2>
<p><code>loading="lazy"</code> works on images and iframes but not on <code>&lt;video&gt;</code>. A small IntersectionObserver does the job. Keep the real source in <code>data-src</code> and attach it when the video is about to scroll into view:</p>
<pre><code class="language-html">&lt;video class="lazy-clip" controls playsinline preload="none"
       poster="state-poster.jpg" width="360" height="640"
       data-src="state-remake.mp4"&gt;&lt;/video&gt;

&lt;script&gt;
const io = new IntersectionObserver((entries) =&gt; {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const v = e.target;
    v.src = v.dataset.src;     // starts loading metadata now
    v.preload = "metadata";
    io.unobserve(v);
  }
}, { rootMargin: "300px" });  // start 300 px before it is visible
document.querySelectorAll("video.lazy-clip").forEach((v) =&gt; io.observe(v));
&lt;/script&gt;
</code></pre>
<p>If JavaScript is off, the poster still shows but the clip can't play, so add a link underneath as a fallback.</p>
<h2>Make the file stream from the first byte</h2>
<p>If the MP4 has its index (the <code>moov</code> atom) at the end, the browser may have to download a lot before it can play. Move it to the front without re-encoding:</p>
<pre><code class="language-bash">ffmpeg -i state-remake.mp4 -c copy -movflags +faststart state-remake.web.mp4
</code></pre>
<p>I also check the codecs before publishing: H.264 video, <code>yuv420p</code> pixel format and AAC audio play almost everywhere. You can verify that with ffprobe.</p>
<h2>Accessibility and honesty</h2>
<ul>
<li>Add a text transcript below the video: the visitor says it's state property, the driver repeats "State?", corrects it to private property and says "hit the road, dude."</li>
<li>Add a caption track with <code>&lt;track kind="captions" src="state.vtt" srclang="en" label="English" default&gt;</code> if you have a WebVTT file.</li>
<li>State on the page that it is an AI-generated remake with a new voice and not the original recording.</li>
</ul>
<h2>Where this fits</h2>
<p>If you just want to make a clip, <a href="https://hittheroaddude.app/">the generator</a> is the quick route. Its pages list the current price per clip, and as of November 10, 2026 the <a href="https://hittheroaddude.app/pricing/">pricing page</a> shows $9.99 at 480p and $12.99 at 720p, with the total on the button before you pay. The <a href="https://hittheroaddude.app/create/">create page</a> is where the upload happens, and the <a href="https://hittheroaddude.app/faq/">FAQ</a> covers formats: JPEG, PNG or WebP photos up to 10 MB.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Turn a Malfoid AI Portrait Into a Colouring Page? XDoG Line Art in OpenCV, and Why Grey-Only Edge Detection Loses the Face]]></title><description><![CDATA[Short answer: smooth the picture without blurring its edges, run an XDoG (extended difference-of-Gaussians) line filter, throw away specks, thicken the…]]></description><link>https://lpgit.hashnode.dev/how-do-you-turn-a-malfoid-ai-portrait-into-a-colouring-page-xdog-line-art-in-opencv-and-why-grey-only-edge-detection-loses-the-face</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-turn-a-malfoid-ai-portrait-into-a-colouring-page-xdog-line-art-in-opencv-and-why-grey-only-edge-detection-loses-the-face</guid><category><![CDATA[Python]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[opencv]]></category><category><![CDATA[Computer Vision]]></category><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Mon, 09 Nov 2026 10:42:16 GMT</pubDate><content:encoded><![CDATA[<p><strong>Short answer:</strong> smooth the picture without blurring its edges, run an XDoG (extended difference-of-Gaussians) line filter, throw away specks, thicken the lines, and lay the result on an A4 or Letter page at 300 dpi. The one non-obvious step: run the filter on the <strong>colour channels too</strong>, not just on grey. In a Malfoid portrait, pale skin sits right next to platinum hair, and the two have nearly the same brightness. My first grey-only version drew the hair and the tie and simply forgot the face. The test below measures that: with colour edges, 40% of a thin ring around the face gets inked; grey only, 0%.</p>
<p>I build <a href="https://malfoidai.com/">malfoidai.com</a>, a small studio that makes still 3:4 Malfoid (Female Malfoy / Fem Draco) fan portraits. People keep asking whether they can print one as a colouring page, for a party table, a fan meetup, or a kid's version of an older sibling's profile picture. You can, with about 100 lines of Python.</p>
<h2>The pipeline</h2>
<ol>
<li><strong>CIELAB split.</strong> Lightness (L) carries most edges. The two colour channels (a: green–red, b: blue–yellow) carry the edges that brightness misses: skin vs. pale hair, green tie vs. grey stripe.</li>
<li><strong>Bilateral filter, twice.</strong> Flattens skin texture, fabric grain and JPEG noise while keeping strong edges. Without it, XDoG turns every pore and compression block into a dot.</li>
<li><strong>DoG with a soft threshold.</strong> Blur at two scales, subtract, ink the darker side of every edge stronger than <code>tau</code>. <code>--detail</code> moves <code>tau</code>: low = only big shapes, high = individual strands.</li>
<li><strong>Despeck.</strong> Remove connected ink blobs under <code>--min-speck</code> pixels.</li>
<li><strong>Thicken and place.</strong> Dilate to a 2-pixel line, scale to fit the page with a half-inch margin, add a small credit line.</li>
</ol>
<h2>The bug worth knowing about</h2>
<p>The textbook XDoG formula is <code>(1 + p) * G1 - p * G2</code> compared against a threshold near 0. That includes the image value itself, so in a bright area the result stays positive and <strong>nothing gets inked unless the edge is very strong</strong>. Pale-on-pale edges vanish. Thresholding <code>G1 - G2</code> alone fixes it, because then only the change matters, not how bright the area is.</p>
<p>That fix helps, but it isn't enough for this picture. The face edge in my test card is a step of about 29 levels in L, which gives a DoG peak of about 0.007, under the threshold at the default detail. In the a-channel the same edge is a jump from "slightly pink" to "neutral", and after a 4x contrast stretch it's easily caught. Hence the colour pass.</p>
<h2>Code</h2>
<pre><code class="language-bash">pip install opencv-python-headless numpy
</code></pre>
<pre><code class="language-python">#!/usr/bin/env python3
"""Turn a portrait into a printable colouring page (clean black lines on white).

Pipeline: CIELAB split -&gt; edge-preserving smoothing -&gt; XDoG (extended difference of
Gaussians) line extraction -&gt; remove specks -&gt; thicken -&gt; place on an A4 or
Letter page at 300 dpi with a margin and a small fan-art credit line.

    pip install opencv-python-headless numpy
    python coloring_page.py portrait.png page.png [--paper a4|letter]
        [--detail 0.5] [--min-speck 40] [--line 2] [--grey-only] [--label "AI fan art ..."]

--detail goes from 0 (only big shapes) to 1 (every hair strand).
The script prints ink coverage: a page that is more than ~12% black is
usually too busy to colour; turn --detail down.
"""
import argparse, sys
import cv2
import numpy as np

PAPER = {"a4": (2480, 3508), "letter": (2550, 3300)}   # pixels at 300 dpi


def xdog(gray, detail):
    """Difference-of-Gaussians lines with a soft (tanh) threshold.

    Ink goes on the darker side of every edge whose DoG response is
    stronger than tau. Using the raw image value in the threshold (the
    textbook (1+p)*G1 - p*G2 form with eps near 0) only inks dark lines,
    which is why pale-on-pale edges vanish; thresholding G1 - G2 does not
    care how bright the area is.
    """
    g = gray.astype(np.float32) / 255.0
    sigma = 1.2
    a = cv2.GaussianBlur(g, (0, 0), sigma)
    b = cv2.GaussianBlur(g, (0, 0), sigma * 2.5)
    tau = 0.004 + 0.016 * (1.0 - detail)   # detail 1 -&gt; faint edges count too
    soft = 0.5 + 0.5 * np.tanh(200.0 * ((a - b) + tau))   # ~0 = ink
    return (soft &lt; 0.5).astype(np.uint8)


def despeck(ink, min_area):
    n, lab, stats, _ = cv2.connectedComponentsWithStats(ink, connectivity=8)
    keep = np.zeros(n, bool)
    keep[1:] = stats[1:, cv2.CC_STAT_AREA] &gt;= min_area
    return keep[lab].astype(np.uint8)


def smooth(ch):
    for _ in range(2):                    # flatten skin/fabric texture, keep edges
        ch = cv2.bilateralFilter(ch, 9, 40, 7)
    return ch


def lines(img, detail=0.5, min_speck=40, width=2, colour_edges=True):
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    ink = xdog(smooth(lab[:, :, 0]), detail)           # lightness edges
    if colour_edges:
        # Pale skin next to platinum hair has almost the same lightness, so a
        # grey-only pipeline loses the face outline. Run the same filter on the
        # two colour channels (contrast-stretched) and keep either result.
        for c in (1, 2):
            ch = lab[:, :, c].astype(np.float32)
            ch = np.clip((ch - 128) * 4 + 128, 0, 255).astype(np.uint8)
            ink |= xdog(smooth(ch), detail)
    ink = despeck(ink, min_speck)
    if width &gt; 1:
        ink = cv2.dilate(ink, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (width, width)))
    return ink


def page(ink, paper, label):
    pw, ph = PAPER[paper]
    margin = 150                          # 0.5 inch
    h, w = ink.shape
    s = min((pw - 2 * margin) / w, (ph - 2 * margin - 80) / h)
    art = cv2.resize(ink * 255, (int(w * s), int(h * s)), interpolation=cv2.INTER_LINEAR)
    art = (art &gt; 127).astype(np.uint8)
    canvas = np.full((ph, pw), 255, np.uint8)
    x0, y0 = (pw - art.shape[1]) // 2, margin
    canvas[y0:y0 + art.shape[0], x0:x0 + art.shape[1]][art &gt; 0] = 0
    if label:
        cv2.putText(canvas, label, (margin, ph - margin + 20), cv2.FONT_HERSHEY_SIMPLEX,
                    1.0, 0, 2, cv2.LINE_AA)
    return canvas, s


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("src"); ap.add_argument("out")
    ap.add_argument("--paper", choices=PAPER, default="a4")
    ap.add_argument("--detail", type=float, default=0.5)
    ap.add_argument("--min-speck", type=int, default=40)
    ap.add_argument("--line", type=int, default=2)
    ap.add_argument("--grey-only", action="store_true", help="skip colour edges (to see what they add)")
    ap.add_argument("--label", default="AI fan art (Malfoid) - unofficial, not a real photo")
    a = ap.parse_args()
    img = cv2.imread(a.src, cv2.IMREAD_COLOR)
    if img is None:
        sys.exit(f"cannot read {a.src}")
    ink = lines(img, a.detail, a.min_speck, a.line, not a.grey_only)
    canvas, s = page(ink, a.paper, a.label)
    cv2.imwrite(a.out, canvas, [cv2.IMWRITE_PNG_COMPRESSION, 9])
    cov = 100.0 * ink.mean()
    n = cv2.connectedComponents(ink)[0] - 1
    print(f"{a.out}: {a.paper} 300 dpi, art scaled x{s:.2f}, ink {cov:.1f}%, {n} line pieces"
          + ("  &lt;- busy, try a lower --detail" if cov &gt; 12 else ""))


if __name__ == "__main__":
    main()
</code></pre>
<h2>A test picture without anyone's face</h2>
<p>All numbers below come from a synthetic card drawn from shapes (no real face, no AI output). <code>--texture</code> adds hair strands:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Draw a synthetic 3:4 'Malfoid-style' test portrait (no real face, no AI).

Shapes only: dark corridor, candle glow, platinum hair, an oval face,
white shirt, green-and-silver striped tie, dark robe. --texture adds thin
hair strands and fabric grain, so resampling and line-art scripts have some
fine detail to chew on. No real face, no AI, nobody's photo.

    python make_test_portrait.py out.png [--seed 1] [--size 768x1024] [--texture]
"""
import argparse
import random
from PIL import Image, ImageDraw, ImageFilter


def face(d, s, fw, skin):
    d.ellipse([int((264 - fw) * s), int(250 * s), int((504 + fw) * s), int(560 * s)], fill=skin)  # face
    for ex in (330, 438):                                                  # eyes
        d.ellipse([int((ex - 16) * s), int(380 * s), int((ex + 16) * s), int(398 * s)], fill=(250, 250, 250))
        d.ellipse([int((ex - 7) * s), int(382 * s), int((ex + 7) * s), int(396 * s)], fill=(110, 130, 120))
    d.arc([int(340 * s), int(450 * s), int(428 * s), int(500 * s)], 20, 160, fill=(150, 80, 80), width=max(2, int(4 * s)))


def portrait(w=768, h=1024, seed=1, texture=False):
    rnd = random.Random(seed)
    img = Image.new("RGB", (w, h))
    px = img.load()
    for y in range(h):                      # corridor: dark warm gradient
        for x in range(w):
            v = 18 + int(30 * (1 - abs(x - w / 2) / (w / 2)) * (1 - y / h))
            px[x, y] = (v + 10, v + 4, v)
    glow = Image.new("RGB", (w, h), (0, 0, 0))
    g = ImageDraw.Draw(glow)
    for _ in range(4):                      # candle glows
        cx, cy, r = rnd.randint(40, w - 40), rnd.randint(60, h // 2), rnd.randint(25, 60)
        g.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 150, 40))
    glow = glow.filter(ImageFilter.GaussianBlur(28))
    img = Image.blend(img, Image.composite(glow, img, glow.convert("L")), 0.6)
    d = ImageDraw.Draw(img)
    s = w / 768
    skin, fw = (222, 182, 156), 0                # seed 1 = the default sitter
    if seed != 1:                                # other seeds: another sitter
        skin = tuple(max(0, c - rnd.randint(0, 70)) for c in skin)
        fw = rnd.randint(-30, 30)
    d.polygon([(int(60 * s), h), (int(130 * s), int(640 * s)), (int(638 * s), int(640 * s)),
               (int(708 * s), h)], fill=(22, 24, 30))                      # robe
    d.polygon([(int(300 * s), int(640 * s)), (int(468 * s), int(640 * s)),
               (int(430 * s), h), (int(338 * s), h)], fill=(240, 240, 236))  # shirt
    tie = [(int(364 * s), int(660 * s)), (int(404 * s), int(660 * s)),
           (int(420 * s), h), (int(348 * s), h)]
    d.polygon(tie, fill=(26, 92, 52))                                      # tie green
    for i in range(6):                                                     # silver stripes
        y0 = int((690 + i * 55) * s)
        d.polygon([(int(340 * s), y0), (int(430 * s), y0 - int(30 * s)),
                   (int(430 * s), y0 - int(14 * s)), (int(340 * s), y0 + int(16 * s))],
                  fill=(176, 180, 184))
    d.polygon(tie, outline=(26, 92, 52))
    d.ellipse([int(170 * s), int(150 * s), int(598 * s), int(760 * s)], fill=(226, 222, 206))  # hair
    face(d, s, fw, skin)
    if texture:
        tr = random.Random(seed * 7919)
        for _ in range(260):                                               # hair strands
            x0 = tr.uniform(180, 588) * s
            side = -1 if x0 &lt; 384 * s else 1
            pts = [(x0, 160 * s)]
            for k in range(1, 9):
                pts.append((x0 + side * k * tr.uniform(1, 5) * s, (160 + k * 70) * s))
            c = tr.randint(170, 250)
            d.line([(int(a), int(b)) for a, b in pts], fill=(c, c - 4, c - 22), width=1)
        face(d, s, fw, skin)                                               # face back on top
        px = img.load()
        for _ in range(int(w * h * 0.05)):                                 # fabric grain on the robe
            x, y = tr.randrange(w), tr.randrange(int(700 * s), h)
            r, g, b = px[x, y]
            if r &lt; 40 and g &lt; 40:
                v = tr.randint(-12, 12)
                px[x, y] = (max(0, r + v), max(0, g + v), max(0, b + v))
    return img


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("out")
    ap.add_argument("--seed", type=int, default=1)
    ap.add_argument("--size", default="768x1024")
    ap.add_argument("--texture", action="store_true")
    a = ap.parse_args()
    w, h = map(int, a.size.lower().split("x"))
    portrait(w, h, a.seed, a.texture).save(a.out)
    print(f"wrote {a.out} {w}x{h}")
</code></pre>
<h2>A test that catches the "forgot the face" bug</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Checks for coloring_page.py on the synthetic test portrait (no real face).

1. The face outline (pale skin against platinum hair) gets inked with colour
   edges on, measured as the share of a thin ring around the face ellipse.
2. Ink coverage stays under 12% at the default detail.
3. No ink specks smaller than --min-speck survive.
"""
import cv2, numpy as np
from make_test_portrait import portrait
import coloring_page as cp

img = cv2.cvtColor(np.array(portrait(768, 1024, 1, texture=True)), cv2.COLOR_RGB2BGR)
ring = np.zeros(img.shape[:2], np.uint8)
cv2.ellipse(ring, (384, 405), (120, 155), 0, 0, 360, 1, 9)   # face is 264..504 x 250..560
ring = ring &gt; 0


def outline_share(ink):
    return ink[ring].mean()


with_c = cp.lines(img, 0.5, 40, 2, True)
grey = cp.lines(img, 0.5, 40, 2, False)
print(f"face outline inked: colour edges {outline_share(with_c):.0%}, grey only {outline_share(grey):.0%}")
assert outline_share(with_c) &gt; 0.25, "face outline missing"  # ring is 9 px, line ~3 px
cov = with_c.mean()
print(f"ink coverage {cov:.1%}")
assert cov &lt; 0.12
n, lab, st, _ = cv2.connectedComponentsWithStats(cp.despeck(cp.xdog(cp.smooth(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)), 0.5), 40))
assert n == 1 or st[1:, cv2.CC_STAT_AREA].min() &gt;= 40
print("ok")
</code></pre>
<p>Output:</p>
<pre><code class="language-text">face outline inked: colour edges 40%, grey only 0%
ink coverage 8.1%
ok
</code></pre>
<p>(The ring is 9 px wide and the line about 3 px, so 40% means the outline is there all the way round.)</p>
<h2>Choosing the detail level</h2>
<pre><code class="language-bash">python make_test_portrait.py tex.png --texture
for d in 0.2 0.5 0.8; do python coloring_page.py tex.png page-$d.png --detail $d; done
python coloring_page.py tex.png grey.png --grey-only
</code></pre>
<pre><code class="language-text">page-0.2.png: a4 300 dpi, art scaled x2.84, ink 7.0%, 31 line pieces
page-0.5.png: a4 300 dpi, art scaled x2.84, ink 8.1%, 29 line pieces
page-0.8.png: a4 300 dpi, art scaled x2.84, ink 10.6%, 63 line pieces
grey.png: a4 300 dpi, art scaled x2.84, ink 6.7%, 41 line pieces
</code></pre>
<p>What I saw on the pages:</p>
<ul>
<li><strong>0.2:</strong> outer hair shape, eyes, mouth, tie stripes. The face outline is only partly there. Good for small children.</li>
<li><strong>0.5 (default):</strong> full face outline, hair edge with some strands, clean tie stripes. The one I'd print.</li>
<li><strong>0.8:</strong> strands everywhere and the robe edge appears. More to colour, but busy. Ink coverage creeps toward the 12% warning.</li>
<li><strong>Grey-only at 0.5:</strong> no face outline at all. Same picture, same settings, minus the colour channels.</li>
</ul>
<p>Ink coverage is a decent proxy for "too busy": above ~12% black, a page starts to look like a sketch rather than something to colour. Line pieces count separate strokes; a sudden jump (31 → 63) usually means texture is breaking into fragments.</p>
<h2>Things that go wrong with real portraits</h2>
<ul>
<li><strong>Background clutter.</strong> A candlelit corridor has columns and arches. At high detail those become lines too. Lower <code>--detail</code>, or crop to head and shoulders first.</li>
<li><strong>Hair as a solid black mass.</strong> That happens when strands are dense and dark-on-light. Raise <code>--min-speck</code> and lower <code>--detail</code>.</li>
<li><strong>Thin lines disappearing in print.</strong> Use <code>--line 3</code> for inkjet prints on plain paper.</li>
<li><strong>Face looks "drawn on" with heavy eyes.</strong> That's the colour pass catching makeup or catchlights. Fine for colouring; if you don't like it, crop the eyes from a lower-detail page.</li>
</ul>
<h2>Print and label</h2>
<p>At 300 dpi an A4 page is 2480 × 3508 px; a 1K portrait gets scaled about 2.8x to fill it. That's fine here: we print lines, not photo detail, and the lines are drawn after scaling, so they stay sharp. The script adds a small line at the bottom, "AI fan art (Malfoid) - unofficial, not a real photo." Keep it: Malfoid is unofficial fan art, not affiliated with the books, films, author or studios, and a portrait made from a photo isn't a verified likeness of anyone.</p>
<h2>Getting a portrait to start from</h2>
<p>On <a href="https://malfoidai.com/">malfoidai.com</a> a still 3:4 portrait is <strong>$4.99</strong> paid once (no account), from one photo of an adult who agreed, or <a href="https://malfoidai.com/guides/which-preset/">without a photo</a> (a fictional adult, which is the easier choice for a page you'll hand out). Signed-in accounts can buy one-time packs instead, <strong>$13.99 / 30 credits, $35.99 / 100, $59.99 / 200</strong>, at 2 credits a portrait. No subscription. Prices from the <a href="https://malfoidai.com/pricing/">pricing page</a>, checked November 9, 2026. The studio doesn't add a watermark, so there's no logo to trace around.</p>
<p><em>Code: MIT. All images in this post are synthetic.</em></p>
]]></content:encoded></item><item><title><![CDATA[480p or 720p for a Hit the Road Dude AI Clip? Measuring What a Phone Screen Actually Shows With ffmpeg SSIM and PSNR]]></title><description><![CDATA["Is 720p worth it?" is one of the most common questions about AI meme generators. It comes up for the Hit the Road Dude clip too, the State Property meme…]]></description><link>https://lpgit.hashnode.dev/480p-or-720p-for-a-hit-the-road-dude-ai-clip-measuring-what-a-phone-screen-actually-shows-with-ffmpeg-ssim-and-psnr</link><guid isPermaLink="true">https://lpgit.hashnode.dev/480p-or-720p-for-a-hit-the-road-dude-ai-clip-measuring-what-a-phone-screen-actually-shows-with-ffmpeg-ssim-and-psnr</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Mon, 09 Nov 2026 07:38:52 GMT</pubDate><content:encoded><![CDATA[<p>"Is 720p worth it?" is one of the most common questions about AI meme generators. It comes up for the Hit the Road Dude clip too, the State Property meme where a driver at a pickup window repeats "State?" and sends someone off. My studio, <a href="https://hittheroaddude.app/">hittheroaddude.app</a>, sells this clip at exactly two resolutions. So I wanted a better answer than "720p is sharper", which is what my own <a href="https://hittheroaddude.app/guides/480p-or-720p/">480p or 720p guide</a> says.</p>
<p>The honest framing: nobody watches a 480p vertical clip at 480 pixels wide. A phone scales it up to fill a screen around 1080 pixels wide, so the real question is how much detail survives that upscale. ffmpeg can measure that with two standard metrics.</p>
<h2>The facts this is about</h2>
<p>From the live <a href="https://hittheroaddude.app/pricing/">pricing page</a>, checked 2026-11-09:</p>
<table>
<thead>
<tr>
<th></th>
<th>480p</th>
<th>720p</th>
</tr>
</thead>
<tbody><tr>
<td>Single payment</td>
<td>$9.99</td>
<td>$12.99</td>
</tr>
<tr>
<td>Credits per clip</td>
<td>6</td>
<td>14</td>
</tr>
<tr>
<td>Length, framing, voice</td>
<td>same</td>
<td>same</td>
</tr>
</tbody></table>
<p>Both are "the same 15-second vertical clip with a new voice; 720p is sharper", and "There is no higher resolution than 720p." So the only variable is pixel count, and that's exactly what we can test.</p>
<h2>The experiment</h2>
<ol>
<li>Make a detailed <strong>1080×1920 reference</strong> clip. This plays the role of what the scene would look like if nothing were lost.</li>
<li>Downscale it to <strong>480×854</strong> and <strong>720×1280</strong> and encode both with the same normal-quality x264 settings (CRF 23).</li>
<li>Upscale each back to <strong>1080×1920</strong>, as a phone would, and compare it with the reference using <strong>SSIM</strong> (structural similarity; 1.0 = identical) and <strong>PSNR</strong> (peak signal-to-noise ratio in dB; higher = closer).</li>
<li>Do it for two kinds of content: <strong>fine detail</strong> (a zooming Mandelbrot set, lots of edges) and <strong>soft content</strong> (smooth moving gradients). Both get burned-in "State?" text, because small text is where low resolution shows first.</li>
</ol>
<p>No real footage and no generated clips are used, so this measures the resolutions themselves, not any model.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# What does 720p buy you over 480p once a phone scales both to full screen?
# For two synthetic vertical sources (fine detail and soft gradients) this
# makes 480p and 720p versions, scales each back up to a 1080x1920 "phone
# screen", and scores them with SSIM and PSNR against the full-res source.
# Needs ffmpeg with libx264. Usage: ./res_compare.sh   (DUR=6 by default)
set -euo pipefail
DUR=${DUR:-6}
mkdir -p rc &amp;&amp; cd rc
declare -A SRC=(
  [fine]="mandelbrot=s=1080x1920:rate=30"
  [soft]="gradients=s=1080x1920:rate=30:speed=0.02"
)
score() {  # $1 = test clip, $2 = reference; prints "SSIM PSNR"
  local s p
  s=$(ffmpeg -nostats -i "$1" -i "$2" -lavfi "[0:v]scale=1080:1920:flags=bicubic[a];[a][1:v]ssim" \
      -f null - 2&gt;&amp;1 | sed -nE 's/.*SSIM.* All:([0-9.]+).*/\1/p')
  p=$(ffmpeg -nostats -i "$1" -i "$2" -lavfi "[0:v]scale=1080:1920:flags=bicubic[a];[a][1:v]psnr" \
      -f null - 2&gt;&amp;1 | sed -nE 's/.*PSNR y:([0-9.]+).*/\1/p')
  echo "$s $p"
}
printf "%-6s %-9s %-7s %-7s %s\n" source size SSIM PSNR_Y "bytes/s"
for name in fine soft; do
  ffmpeg -v error -y -f lavfi -i "${SRC[$name]}" -t "$DUR" \
    -vf "drawtext=text='State?':fontsize=40:fontcolor=white:borderw=2:x=40+20*t:y=1700,format=yuv420p" \
    -c:v libx264 -crf 10 -preset veryfast "ref_${name}.mp4"
  for H in 854 1280; do
    W=$(( H * 9 / 16 / 2 * 2 ))                  # 480x854, 720x1280
    out="${name}_${W}x${H}.mp4"
    ffmpeg -v error -y -i "ref_${name}.mp4" -vf "scale=${W}:${H}:flags=lanczos" \
      -c:v libx264 -crf 23 -preset veryfast "$out"
    read -r ssim psnr &lt; &lt;(score "$out" "ref_${name}.mp4")
    printf "%-6s %-9s %-7.4f %-7.2f %d\n" "$name" "${W}x${H}" "$ssim" "$psnr" \
      $(( $(stat -c %s "$out") / DUR ))
  done
done
</code></pre>
<p>A few choices worth explaining:</p>
<ul>
<li><strong><code>-crf 10</code> for the reference</strong> keeps encoding loss in the reference negligible, so the scores reflect the downscale, not the reference encode.</li>
<li><strong>Lanczos down, bicubic up.</strong> Encoders and editors usually downscale with good filters, while phone players upscale with something cheap and fast. Bicubic is a fair stand-in.</li>
<li><strong>SSIM and PSNR run as separate passes.</strong> One filter graph with both is possible, but parsing two metrics from one stderr stream gets fragile with progress output mixed in. <code>-nostats</code> plus one metric per pass keeps the <code>sed</code> simple.</li>
</ul>
<h2>Results</h2>
<p>On my box (ffmpeg 7.1, libx264, 6-second clips):</p>
<pre><code class="language-text">source size      SSIM    PSNR_Y  bytes/s
fine   480x854   0.9385  30.04   140669
fine   720x1280  0.9584  31.46   388348
soft   480x854   0.9990  42.65   7387
soft   720x1280  0.9995  47.99   10633
</code></pre>
<p>How to read it:</p>
<ul>
<li><strong>On detailed content, 720p is measurably closer to the reference.</strong> SSIM goes from 0.94 to 0.96 and PSNR improves by about 1.4 dB. That's the kind of gap you can see on fine texture and small text when you look for it.</li>
<li><strong>On soft content, both are nearly perfect.</strong> Smooth gradients have little detail to lose, so the upscaled 480p is already almost identical.</li>
<li><strong>720p costs about 2.8× the bitrate on the detailed clip</strong> at the same CRF. That doesn't change your price, but it does change the file size you send to friends.</li>
</ul>
<p>These are synthetic sources, so read the numbers as the shape of the trade-off, not a promise about any particular clip. A real pickup-window shot sits between the two. A face, hair, grass and the edge of a truck window are more detailed than a gradient and less busy than a fractal.</p>
<h2>See it instead of trusting a number</h2>
<p>SSIM is an average, and your eyes go to specific places, like the face and the "State?" moment. This one-liner puts a 2× zoom of the same region from each version side by side:</p>
<pre><code class="language-bash">ffmpeg -ss 3 -i rc/fine_480x854.mp4 -ss 3 -i rc/fine_720x1280.mp4 -filter_complex \
 "[0:v]scale=1080:1920:flags=bicubic,crop=360:360:0:1560,scale=720:720:flags=neighbor[a];\
  [1:v]scale=1080:1920:flags=bicubic,crop=360:360:0:1560,scale=720:720:flags=neighbor[b];\
  [a][b]hstack" -frames:v 1 zoom_compare.png
</code></pre>
<p>In my run, the 480p text edges were visibly softer and slightly smeared, while the 720p text stayed crisp. That matches the scores. Change <code>crop=w:h:x:y</code> to frame the face in a real clip.</p>
<h2>So which should you pick?</h2>
<p>The way I'd decide it:</p>
<ul>
<li><strong>Pick 480p</strong> if the clip will be watched once in a chat thread, as a reply or in a group chat, or re-shared through apps that recompress video anyway. The difference mostly disappears after another round of compression.</li>
<li><strong>Pick 720p</strong> if you'll post it where people watch full screen and the face is the joke, or if you plan to edit it, crop it or add captions. Editing throws away pixels, so it helps to start with more.</li>
<li>Either way, the clip is 15 seconds, vertical 9:16, with sound and a newly generated voice. Resolution doesn't change the timing of the "State?" pause, which is what makes the joke land.</li>
</ul>
<p>If you buy credits, the gap is wider than the cash prices suggest. A 720p clip spends 14 credits against 6 for 480p, and the guide notes that "100 credits cover 16 clips at 480p or 7 at 720p." Packs need an account, are one-time purchases and don't renew.</p>
<p>When you're ready, the <a href="https://hittheroaddude.app/create/">create page</a> defaults to 480p and shows the total on the button before you pay. After it's delivered, run through the <a href="https://hittheroaddude.app/guides/check-before-posting/">pre-posting check</a> whichever resolution you chose.</p>
]]></content:encoded></item><item><title><![CDATA[Where Do You Put a Reaction-Cam Bubble on a Bama Bound Clip Without Covering the Banner? Picking the Emptiest Corner With NumPy and ffmpeg]]></title><description><![CDATA[Checked November 9, 2026 (15:00 CST, UTC+8). I build bamabound.app, an independent generator that makes a vertical clip of one person holding a red I'M…]]></description><link>https://lpgit.hashnode.dev/where-do-you-put-a-reaction-cam-bubble-on-a-bama-bound-clip-without-covering-the-banner-picking-the-emptiest-corner-with-numpy-and-ffmpeg</link><guid isPermaLink="true">https://lpgit.hashnode.dev/where-do-you-put-a-reaction-cam-bubble-on-a-bama-bound-clip-without-covering-the-banner-picking-the-emptiest-corner-with-numpy-and-ffmpeg</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Mon, 09 Nov 2026 07:22:43 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 9, 2026 (15:00 CST, UTC+8). I build <a href="https://bamabound.app/">bamabound.app</a>, an independent generator that makes a vertical clip of one person holding a red I'M BAMA BOUND banner from one photo. The scripts here were written for this post and tested on synthetic clips (a red box on a green field, plus ffmpeg's <code>testsrc2</code> as the "reaction"), with no real faces.</em></p>
<p>A common way to post a Bama Bound clip is as a <strong>reaction</strong>: the generated clip fills the screen, and a small round bubble in a corner shows a real person's face watching it, often a parent who has just learned their kid is "Bama bound." The bubble has one job: don't cover the banner. The red sign reading I'M BAMA BOUND <em>is</em> the joke (<a href="https://bamabound.app/faq/">what the clip shows</a>).</p>
<p>Editors make you drag the bubble around by hand, and you check it on one frame. But the banner moves. The person lifts it, the camera drifts, and a corner that's empty at second 1 can be half covered at second 6. This post automates the choice:</p>
<ol>
<li>Find the banner in every sampled frame.</li>
<li>For each of the four corners, measure the <strong>worst</strong> frame's overlap.</li>
<li>Put the bubble where the worst case is smallest, and keep it out of the bands where TikTok/Reels/Shorts draw their buttons.</li>
</ol>
<h2>The idea in one picture</h2>
<pre><code class="language-text"> ┌───────────────┐  ← top 10%: app header, keep clear
 │ [tl]     [tr] │
 │   ███████     │  ← banner (red mask), tracked per frame
 │   ███████     │
 │               │
 │ [bl]     [br] │
 │               │  ← bottom 20%: captions/buttons, keep clear
 └───────────────┘
 score(corner) = max over frames of (red pixels inside the corner box / box area)
</code></pre>
<p>Using the maximum instead of the average is deliberate. A bubble that covers the banner for one second is a bad bubble. The average would hide that.</p>
<h2>Detecting "banner red" cheaply</h2>
<p>You don't need a segmentation model to find a saturated red banner on grass and sky. A per-pixel rule on 120-px-wide frames at 2 fps is enough:</p>
<pre><code class="language-python">(r &gt; 120) &amp; (r - g &gt; 60) &amp; (r - b &gt; 60)
</code></pre>
<p>This catches crimson through orange-red. It ignores skin, which is reddish but not that far from green and blue. If your clip has a red shirt as well, the shirt counts as "banner", and that's fine: you don't want to cover the person either.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Put a round reaction-cam bubble on a vertical banner clip, in the corner the banner uses least.

1. Sample the main clip at 2 fps (downscaled), build a red-banner mask per frame.
2. For each of the four candidate bubble boxes (kept clear of the top/bottom app-UI bands)
   take the WORST frame's banner coverage; pick the corner with the lowest worst case.
3. Overlay the reaction clip as a circle (alpha drawn with geq), keep the main clip's audio
   and mix the reaction audio under it if it has one.
Usage: bubble.py main.mp4 reaction.mp4 out.mp4 [--size 0.34] [--corner auto|tl|tr|bl|br]
Needs ffmpeg/ffprobe and numpy.
"""
import argparse, json, subprocess, sys
import numpy as np

UI_TOP, UI_BOTTOM, MARGIN = 0.10, 0.20, 0.04   # fractions of height/width kept clear

def probe(path):
    j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_entries",
        "stream=codec_type,width,height", "-of", "json", path],
        capture_output=True, text=True, check=True).stdout)
    v = next(s for s in j["streams"] if s["codec_type"] == "video")
    return v["width"], v["height"], any(s["codec_type"] == "audio" for s in j["streams"])

def red_masks(path, w, h, sw=120):
    sh = int(round(h * sw / w / 2)) * 2
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vf", f"fps=2,scale={sw}:{sh}",
                          "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
    f = np.frombuffer(raw, np.uint8).reshape(-1, sh, sw, 3).astype(np.int16)
    r, g, b = f[..., 0], f[..., 1], f[..., 2]
    return (r &gt; 120) &amp; (r - g &gt; 60) &amp; (r - b &gt; 60)          # strong red, any shade crimson..orange-red

def boxes(w, h, d):
    mx, top, bot = int(MARGIN * w), int(UI_TOP * h), int(h - UI_BOTTOM * h - d)
    return {"tl": (mx, top), "tr": (w - mx - d, top), "bl": (mx, bot), "br": (w - mx - d, bot)}

def coverage(masks, w, h, x, y, d):
    sh, sw = masks.shape[1:]
    x0, y0 = int(x * sw / w), int(y * sh / h)
    x1, y1 = int((x + d) * sw / w), int((y + d) * sh / h)
    return float(masks[:, y0:y1, x0:x1].mean(axis=(1, 2)).max())

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("main"); ap.add_argument("reaction"); ap.add_argument("out")
    ap.add_argument("--size", type=float, default=0.34, help="bubble diameter as share of width")
    ap.add_argument("--corner", default="auto", choices=["auto", "tl", "tr", "bl", "br"])
    a = ap.parse_args()
    w, h, main_audio = probe(a.main)
    _, _, react_audio = probe(a.reaction)
    d = int(a.size * w) // 2 * 2
    cands = boxes(w, h, d)
    masks = red_masks(a.main, w, h)
    scores = {k: coverage(masks, w, h, x, y, d) for k, (x, y) in cands.items()}
    corner = min(scores, key=lambda k: (scores[k], ["br", "bl", "tr", "tl"].index(k))) \
        if a.corner == "auto" else a.corner
    x, y = cands[corner]
    r = d / 2
    fc = (f"[1:v]scale={d}:{d}:force_original_aspect_ratio=increase,crop={d}:{d},format=yuva420p,"
          f"geq=lum='p(X,Y)':cb='p(X,Y)':cr='p(X,Y)':"
          f"a='if(lte(hypot(X-{r},Y-{r}),{r - 1}),255,0)'[b];"
          f"[0:v][b]overlay=x={x}:y={y}:shortest=0:eof_action=pass,format=yuv420p[v]")
    maps = ["-map", "[v]"]
    if main_audio and react_audio:
        fc += ";[0:a][1:a]amix=inputs=2:duration=first:weights='1 0.6'[a]"; maps += ["-map", "[a]"]
    elif main_audio:
        maps += ["-map", "0:a"]
    elif react_audio:
        maps += ["-map", "1:a"]
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", a.main, "-i", a.reaction, "-filter_complex", fc,
                    *maps, "-c:v", "libx264", "-crf", "18", "-preset", "veryfast", "-c:a", "aac",
                    "-t", str(float(json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                        "format=duration", "-of", "json", a.main], capture_output=True, text=True).stdout)
                        ["format"]["duration"])),
                    "-movflags", "+faststart", a.out], check=True)
    print(json.dumps({"corner": corner, "x": x, "y": y, "diameter": d,
                      "banner_coverage_worst_frame": {k: round(v, 3) for k, v in scores.items()}}))

if __name__ == "__main__":
    sys.exit(main())
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><strong>The circle is drawn with <code>geq</code> on the alpha plane</strong>: <code>a='if(lte(hypot(X-r,Y-r),r-1),255,0)'</code>. That avoids a separate mask PNG and works at any size. The <code>r-1</code> keeps the edge from showing a hard ring of the square crop.</li>
<li><strong><code>force_original_aspect_ratio=increase,crop=d:d</code></strong> fills the circle from a 4:3 or 16:9 webcam recording without stretching faces.</li>
<li><strong><code>eof_action=pass</code></strong> means a reaction shorter than the clip just disappears instead of freezing on its last frame. The output is cut to the main clip's length (<code>-t</code>), because the clip is the content. Bama Bound clips are about eight seconds (<a href="https://bamabound.app/faq/">FAQ</a>), and reactions are usually longer.</li>
<li><strong>Audio</strong>: the main clip keeps its soundtrack. The original sound stays on these clips, and it's part of the meme. The reaction's audio is mixed under it at 0.6 weight with <code>amix</code>. If you'd rather not include gasps, pass a silent reaction.</li>
<li><strong>Tie-break</strong>: when several corners are empty, it prefers bottom-right, then bottom-left. Faces are usually near the top of the frame in this meme, and viewers expect the reaction bubble low.</li>
</ul>
<h2>Checking the output</h2>
<p>"The banner wasn't covered" should be a test, not a feeling. The checker compares the source and the output at three timestamps. Banner pixels must be unchanged (mean absolute difference &lt; 6, which allows for re-encoding), and the middle of the bubble must clearly differ (&gt; 20).</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Check a bubble overlay: banner pixels unchanged, bubble present where bubble.py said.
Usage: check_bubble.py main.mp4 out.mp4 '&lt;json line printed by bubble.py&gt;'"""
import json, subprocess, sys
import numpy as np

def grab(path, t):
    j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
        "stream=width,height", "-of", "json", path], capture_output=True, text=True).stdout)["streams"][0]
    raw = subprocess.run(["ffmpeg", "-v", "error", "-ss", str(t), "-i", path, "-frames:v", "1",
        "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
    return np.frombuffer(raw, np.uint8).reshape(j["height"], j["width"], 3).astype(np.int16)

main, out, info = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
ok = True
for t in (1.0, 4.0, 7.0):
    a, b = grab(main, t), grab(out, t)
    red = (a[..., 0] &gt; 120) &amp; (a[..., 0] - a[..., 1] &gt; 60) &amp; (a[..., 0] - a[..., 2] &gt; 60)
    banner_change = float(np.abs(a - b)[red].mean()) if red.any() else 0.0
    x, y, d = info["x"], info["y"], info["diameter"]
    c = (slice(y + d // 4, y + 3 * d // 4), slice(x + d // 4, x + 3 * d // 4))
    bubble_change = float(np.abs(a[c] - b[c]).mean())
    good = banner_change &lt; 6 and bubble_change &gt; 20
    ok &amp;= good
    print(f"t={t:.1f}s banner mean abs diff {banner_change:5.1f} (want &lt;6)  "
          f"bubble-centre diff {bubble_change:5.1f} (want &gt;20)  {'OK' if good else 'FAIL'}")
print("PASS" if ok else "FAIL"); sys.exit(0 if ok else 1)
</code></pre>
<h2>Tested on three layouts</h2>
<p>I made three synthetic clips with the same generator script, moving the banner using <code>BX</code>/<code>BY</code>:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Synthetic stand-in for a vertical banner clip: sky, green field, a red banner with
# white text, a 440 Hz tone. No people, no real footage.
#   ./make_banner_test_clip.sh out.mp4 [steady|flicker|drift]
#   env BX, BY move the banner (default 60, 330)
#   flicker = banner turns orange at 4.0-4.2 s; drift = whole frame hue rotates 2 deg/s
set -euo pipefail
out=${1:-banner_test.mp4}; mode=${2:-steady}
FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
banner="drawbox=x=${BX:-60}:y=${BY:-330}:w=360:h=150:color=0xC8102E:t=fill"
[ "$mode" = flicker ] &amp;&amp; banner="$banner,drawbox=x=${BX:-60}:y=${BY:-330}:w=360:h=150:color=0xF07820:t=fill:enable='between(t,4,4.2)'"
post=""; [ "$mode" = drift ] &amp;&amp; post=",hue=h=2*t"
ffmpeg -v error -y -f lavfi -i "color=c=0x3A8A3A:s=480x854:r=30:d=8" \
  -f lavfi -i "sine=frequency=440:duration=8:sample_rate=48000" \
  -filter_complex "[0:v]drawbox=x=0:y=0:w=iw:h=300:color=0x87CEEB:t=fill,${banner},drawtext=fontfile=${FONT}:text='TEST BANNER':fontcolor=white:fontsize=40:x=${BX:-60}+(360-text_w)/2:y=${BY:-330}+55${post},format=yuv420p[v]" \
  -map "[v]" -map 1:a -c:v libx264 -preset veryfast -crf 20 -c:a aac -b:a 128k -shortest "$out"
echo "wrote $out ($mode)"
</code></pre>
<pre><code class="language-text">$ ffmpeg -f lavfi -i testsrc2=s=640x480:r=30:d=8 -f lavfi -i sine=frequency=660:duration=8 -c:v libx264 -c:a aac -shortest reaction.mp4
$ ./make_banner_test_clip.sh steady.mp4                 # banner in the middle
$ BY=600 BX=120 ./make_banner_test_clip.sh low.mp4      # banner low, right of centre
$ BY=90  BX=120 ./make_banner_test_clip.sh high.mp4     # banner high

steady -&gt; corner br  coverage tl 0.000 tr 0.000 bl 0.000 br 0.000   PASS (banner diff 0.3, bubble diff ~121)
low    -&gt; corner tr  coverage tl 0.000 tr 0.000 bl 0.172 br 0.432   PASS
high   -&gt; corner br  coverage tl 0.323 tr 0.838 bl 0.000 br 0.000   PASS
</code></pre>
<p>The "low" case shows why automating this is worth it. Bottom-right, the corner most people would drag the bubble to by habit, would have covered 43% of its own area with banner.</p>
<h2>Practical notes</h2>
<ul>
<li><strong>Size</strong>: <code>--size 0.34</code> (a third of the width) reads well on phones. Above 0.4 the bubble competes with the main clip.</li>
<li><strong>Force a corner</strong> with <code>--corner tl</code> if you want a consistent layout across a series. The coverage numbers are still printed, so you'll know if it costs you.</li>
<li><strong>Resolution</strong>: the bubble is re-encoded with the main clip, so start from the best version you have. If you plan to crop or zoom later, a higher-resolution source helps. Today's single-generation prices on the <a href="https://bamabound.app/pricing/">pricing page</a> are $7.98 (480p), $9.98 (720p), $16.98 (1080p) and $24.98 (4k), with tax added at checkout.</li>
<li><strong>Consent</strong>: the reaction is a second real person on camera. Ask them, the same way the site asks you to have permission for the photo you upload (<a href="https://bamabound.app/acceptable-use/">acceptable use</a>). And keep it obvious that it's a meme. The clip is not an admission letter, and the University of Alabama's real orientation program called Bama Bound is a separate thing (<a href="https://bamabound.app/about/">about</a>).</li>
</ul>
<p>The same corner-scoring trick works for any overlay that must avoid a known object: a logo, a scoreboard, a caption. Swap the red rule for a mask that fits your object.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Make a Boomerang Loop of the Laughing Close-Up in a Tiny Desk AI Clip? Frame-Exact Forward/Reverse With ffmpeg (Tested)]]></title><description><![CDATA[Checked November 9, 2026 (12:00 CST, UTC+8). I build tinydeskoutstanding.video, an independent studio for the "Outstanding" Tiny Desk parody. The script…]]></description><link>https://lpgit.hashnode.dev/how-do-you-make-a-boomerang-loop-of-the-laughing-close-up-in-a-tiny-desk-ai-clip-frame-exact-forward-reverse-with-ffmpeg-tested</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-make-a-boomerang-loop-of-the-laughing-close-up-in-a-tiny-desk-ai-clip-frame-exact-forward-reverse-with-ffmpeg-tested</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Mon, 09 Nov 2026 04:36:56 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 9, 2026 (12:00 CST, UTC+8). I build <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a>, an independent studio for the "Outstanding" Tiny Desk parody. The script below was written for this post and tested on ffmpeg's synthetic <code>testsrc2</code> footage; every number in the output section comes from that run.</em></p>
<p>The "Outstanding" Tiny Desk parody has three beats: the band behind the red keyboard, a <strong>laughing close-up</strong>, and a sax lift at the end (<a href="https://tinydeskoutstanding.video/guides/check-before-posting/">how the clip runs</a>). The close-up is the part people screenshot and reply with. A still loses the laugh, though, and a GIF loses the sound.</p>
<p>A <strong>boomerang</strong> (forward, then backward, on repeat) of one second and a half of that close-up works as a reaction clip in a chat. Making one well is fiddlier than it looks, and the fiddly part is one frame.</p>
<h2>The bug everyone ships first</h2>
<p>The obvious graph is: cut the segment, <code>split</code> it, <code>reverse</code> one copy, <code>concat</code> them, loop. Play it and you'll feel a tiny stall at each turnaround. That's because the last frame of the forward half and the first frame of the reversed half are <strong>the same frame</strong>, and so are the end of the reversed half and the start of the next forward half. Each turn shows one frame twice, a 33 ms freeze at 30 fps, twice per cycle.</p>
<p>The fix is to drop both end frames from the reversed half. For a segment of N frames, one cycle is:</p>
<pre><code>forward:  0, 1, 2, ..., N-1
backward: N-2, ..., 2, 1
</code></pre>
<p>That's 2N−2 frames per cycle. Then the script adds one final frame 0, so a player that loops the whole file also turns smoothly.</p>
<h2>Sound: none, or reversed</h2>
<p>A reaction loop usually goes out <strong>silent</strong>, which is the default. If you want the laugh in it, <code>--audio reverse</code> plays the segment's audio forward and then backward, using the same rule as the picture: the reversed half skips one frame's worth of samples at each end. Otherwise the audio cycle is 2N frames long while the video cycle is 2N−2, and the two drift apart by two frames (67 ms at 30 fps) every cycle. My first version did exactly that. With the rule applied, the loop length is the same in samples and frames, and <code>aloop</code> repeats it.</p>
<p>The sound in these clips is newly generated by the studio, not the Gap Band recording or NPR audio (<a href="https://tinydeskoutstanding.video/faq/">FAQ</a>), so a reversed laugh is just a reversed generated laugh.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Cut a back-and-forth "boomerang" loop out of one moment of a clip.

usage: python3 boomerang.py clip.mp4 START SECONDS [--cycles 3] [--audio none|reverse] [-o out.mp4]

Forward, then backward, repeated. The turnaround frames are not doubled:
each cycle is frames 0..N-1 forward, then N-2..1 backward, so the motion
never "sticks" for a frame at either end and the cycles join cleanly.
"""
import argparse, json, shutil, subprocess, sys
from fractions import Fraction

def probe(path):
    j = json.loads(subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
         "stream=avg_frame_rate:format=duration", "-of", "json", path],
        capture_output=True, text=True, check=True).stdout)
    return Fraction(j["streams"][0]["avg_frame_rate"]), float(j["format"]["duration"])

def plan(n, cycles):
    """Source-frame index for every output frame (also used by the checker)."""
    if n &lt; 3:
        raise ValueError("segment needs at least 3 frames")
    one = list(range(n)) + list(range(n - 2, 0, -1))
    return one * cycles + [0]          # end on frame 0 so a player loop is seamless too

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("clip"); ap.add_argument("start", type=float); ap.add_argument("seconds", type=float)
    ap.add_argument("--cycles", type=int, default=3)
    ap.add_argument("--audio", choices=["none", "reverse"], default="none")
    ap.add_argument("-o", "--out", default="boomerang.mp4")
    a = ap.parse_args()
    if not shutil.which("ffmpeg"):
        sys.exit("ffmpeg not found")
    fps, dur = probe(a.clip)
    if a.start &lt; 0 or a.start + a.seconds &gt; dur + 1e-6:
        sys.exit(f"segment {a.start}+{a.seconds}s is outside the clip ({dur:.2f}s)")
    n = int(round(a.seconds * fps))
    order = plan(n, a.cycles)
    # Forward segment as exactly n frames; reversed copy drops both end frames.
    s = f"{a.start:.6f}"
    g = [f"[0:v]trim=start={s},setpts=PTS-STARTPTS,trim=end_frame={n},split=2[f][r]",
         f"[r]reverse,trim=start_frame=1:end_frame={n-1},setpts=PTS-STARTPTS[b]"]
    pairs = "".join("[f%d][b%d]" % (i, i) for i in range(a.cycles))
    g.append("[f]split=%d%s" % (a.cycles, "".join("[f%d]" % i for i in range(a.cycles))))
    g.append("[b]split=%d%s" % (a.cycles, "".join("[b%d]" % i for i in range(a.cycles))))
    g.append("[0:v]trim=start=%s,setpts=PTS-STARTPTS,trim=end_frame=1[z]" % s)
    g.append(f"{pairs}[z]concat=n={2*a.cycles+1}:v=1:a=0,setpts=N/({fps.numerator}/{fps.denominator})/TB[v]")
    maps = ["-map", "[v]"]
    if a.audio == "reverse":
        sr = int(json.loads(subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate",
             "-of", "json", a.clip], capture_output=True, text=True, check=True).stdout)["streams"][0]["sample_rate"])
        f1 = float(1 / fps)                       # one frame of audio
        cyc = round(sr * (2 * n - 2) / fps)       # samples per video cycle
        g.append(f"[0:a]atrim=start={s}:duration={float(n / fps):.6f},asetpts=PTS-STARTPTS,asplit=2[af][ar]")
        # Same rule as the picture: the reversed half skips one frame's worth at each end.
        g.append(f"[ar]areverse,atrim=start={f1:.6f}:duration={float((n - 2) / fps):.6f},asetpts=PTS-STARTPTS[ab]")
        g.append(f"[af][ab]concat=n=2:v=0:a=1,aloop=loop={a.cycles}:size={cyc},"
                 f"atrim=duration={float(len(order) / fps):.6f}[a]")
        maps += ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"]
    else:
        maps += ["-an"]
    cmd = ["ffmpeg", "-v", "error", "-y", "-i", a.clip, "-filter_complex", ";".join(g), *maps,
           "-r", f"{fps.numerator}/{fps.denominator}", "-c:v", "libx264", "-crf", "18",
           "-pix_fmt", "yuv420p", "-movflags", "+faststart", a.out]
    subprocess.run(cmd, check=True)
    print(f"wrote {a.out}: {len(order)} frames ({len(order)/fps:.2f}s at {float(fps):g} fps), "
          f"segment {n} frames x {a.cycles} cycles, audio={a.audio}")

if __name__ == "__main__":
    main()
</code></pre>
<p>Notes:</p>
<ul>
<li><code>trim=start=...,setpts=PTS-STARTPTS,trim=end_frame=N</code> cuts <strong>exactly N frames</strong>. Cutting by duration alone can give you N or N+1 depending on timestamps.</li>
<li><code>reverse</code> buffers the whole segment in memory. At 4k, 1.5 s is about 45 frames of 3840×2160, roughly 560 MB of raw 8-bit YUV 4:2:0. Keep boomerang segments short.</li>
<li><code>setpts=N/(fps)/TB</code> rewrites timestamps after <code>concat</code> so the output is constant frame rate with no gaps.</li>
</ul>
<h2>Proving it's frame-exact</h2>
<p>Watching for a one-frame stall by eye isn't a test. The checker decodes tiny grey thumbnails of the source segment and of the output, finds the <strong>nearest source frame for every output frame</strong>, and compares that sequence with the plan from <code>boomerang.plan()</code>. It needs every source frame to look different, which is true for real footage and for <code>testsrc2</code> (it has a running frame counter).</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Match every frame of a boomerang back to the source frame it came from.

usage: python3 check_boomerang.py clip.mp4 START SECONDS CYCLES boomerang.mp4
Decodes tiny grey thumbnails, finds the nearest source frame for each output
frame and compares the sequence with boomerang.plan(). Needs a source whose
frames all differ (true for real footage and for ffmpeg's testsrc2).
"""
import os, subprocess, sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from boomerang import plan, probe

def thumbs(path, vf):
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vf", vf + ",scale=48:86,format=gray",
                          "-fps_mode", "passthrough", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout
    return np.frombuffer(raw, np.uint8).reshape(-1, 86, 48).astype(np.float32)

clip, start, secs, cycles, out = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), int(sys.argv[4]), sys.argv[5]
fps, _ = probe(clip)
n = int(round(secs * fps))
src = thumbs(clip, f"trim=start={start},setpts=PTS-STARTPTS,trim=end_frame={n}")
got = thumbs(out, "null")
idx = [int(np.argmin(((src - f) ** 2).mean(axis=(1, 2)))) for f in got]
want = plan(n, cycles)
bad = [i for i, (g, w) in enumerate(zip(idx, want)) if g != w]
print(f"source frames {len(src)}, output frames {len(got)} (planned {len(want)}), mismatches {len(bad)}")
if bad:
    print("first mismatches:", [(i, idx[i], want[i]) for i in bad[:5]])
ok = len(src) == n and len(got) == len(want) and not bad
print("PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
</code></pre>
<p>One trap I hit while writing it: piping <code>-f rawvideo</code> out of a filter chain that starts mid-clip <strong>silently dropped frames</strong> (38 instead of 45) because the raw muxer assumed a different output rate. <code>-fps_mode passthrough</code> fixes it. Without that flag the checker blamed the boomerang for a bug that was in the checker.</p>
<h2>Running it</h2>
<pre><code>$ ffmpeg -v error -f lavfi -i "testsrc2=size=480x854:rate=30:duration=15" \
    -f lavfi -i "sine=frequency=330:duration=15" \
    -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest clip.mp4
$ python3 boomerang.py clip.mp4 6.2 1.5 --cycles 3 -o b1.mp4
wrote b1.mp4: 265 frames (8.83s at 30 fps), segment 45 frames x 3 cycles, audio=none
$ python3 check_boomerang.py clip.mp4 6.2 1.5 3 b1.mp4
source frames 45, output frames 265 (planned 265), mismatches 0
PASS
$ python3 boomerang.py clip.mp4 6.2 1.5 --cycles 2 --audio reverse -o b2.mp4
wrote b2.mp4: 177 frames (5.90s at 30 fps), segment 45 frames x 2 cycles, audio=reverse
$ python3 check_boomerang.py clip.mp4 6.2 1.5 2 b2.mp4
source frames 45, output frames 177 (planned 177), mismatches 0
PASS
</code></pre>
<p>265 = 3 × (2 × 45 − 2) + 1. Video and audio in <code>b2.mp4</code> are both 5.900 s. To check the audio loop period, I ran it on a rising chirp (where every moment sounds different) and correlated consecutive cycles of the decoded audio at a period of 2N−2 frames: 0.99997 and 0.999999. The cycles repeat at the video's period.</p>
<h2>Finding the start time</h2>
<p>Scrub your own clip and note when the close-up starts; don't trust anyone's timestamp, including mine. The studio follows one saved scene, but every run "comes out a little different" (<a href="https://tinydeskoutstanding.video/guides/480p-or-720p/">480p, 720p, 1080p, or 4k guide</a>), so the exact second varies. A 1 to 2 second segment is the sweet spot: shorter feels jittery, longer stops reading as a loop.</p>
<h2>Which resolution to start from</h2>
<p>The boomerang can only be as sharp as the clip. For a chat reaction, 480p is enough; the site makes the same point that messaging apps compress video again. If you plan to crop into the close-up, a bigger source helps. Today a clip is $9.98 at 480p, $12.98 at 720p, $24.98 at 1080p or $39.98 at 4k, with no subscription (<a href="https://tinydeskoutstanding.video/pricing/">pricing</a>). There's no upscale button; a sharper version is a new generation.</p>
<h2>Posting it</h2>
<p>If the face in the close-up is a friend's, ask before you turn their laugh into a reaction clip that will outlive the joke. The site only takes photos of "one adult who agreed, or one pet" per spot (<a href="https://tinydeskoutstanding.video/guides/band-and-guest-slots/">band and guest slots</a>), and a boomerang that keeps circulating deserves the same yes.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Burn a Soft Caption Onto a Bama Bound Clip Without Covering the Red Banner?]]></title><description><![CDATA[Written November 5, 2026 (15:00 CST, UTC+8) by the builder of bamabound.app. No paid generation was used. The burn-in below was tested on a synthetic…]]></description><link>https://lpgit.hashnode.dev/how-do-you-burn-a-soft-caption-onto-a-bama-bound-clip-without-covering-the-red-banner</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-burn-a-soft-caption-onto-a-bama-bound-clip-without-covering-the-red-banner</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 05 Nov 2026 07:31:12 GMT</pubDate><content:encoded><![CDATA[<p><em>Written November 5, 2026 (15:00 CST, UTC+8) by the builder of <a href="https://bamabound.app/">bamabound.app</a>. No paid generation was used. The burn-in below was tested on a synthetic 720×1280 clip with a drawn red banner where the real preset keeps the words I'M BAMA BOUND.</em></p>
<p>You already have the vertical clip. You want a soft on-screen line — "decision day (joke)", a friend's name, a date — and you do <strong>not</strong> want white text sitting on the red banner. Phone UIs also eat the bottom and top edges. So the caption has to live in a <strong>top safe zone</strong> that ends before the banner starts.</p>
<h2>Safe-zone math for 9:16</h2>
<p>Treat the frame height as <code>H</code>. Keep a top margin of about <code>0.06H</code>, and treat everything from roughly <code>0.42H</code> downward as banner keep-out (the banner on this meme sits in the lower-middle of the field). The caption's <code>y</code> is just the top margin, and we assert <code>y</code> is still above the keep-out line.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Compute a drawtext Y that sits in the top safe zone of a 9:16 frame
without covering a red banner that typically occupies the lower-middle."""
from __future__ import annotations
import argparse, json

def caption_y(height: int, *, margin_frac: float = 0.06, banner_top_frac: float = 0.42) -&gt; dict:
    """Return y for ffmpeg drawtext (top-left origin).

    Banner on the Bama Bound preset usually starts around mid-frame.
    We keep the caption entirely above banner_top_frac * height, with a
    top margin of margin_frac * height.
    """
    if height &lt; 100:
        raise ValueError("height too small")
    top = int(round(height * margin_frac))
    max_bottom = int(round(height * banner_top_frac)) - 8
    # Prefer a fixed band near the top; clamp so the whole line fits.
    y = top
    return {
        "height": height,
        "y": y,
        "top_margin_px": top,
        "banner_keepout_from_y": max_bottom,
        "safe": y &lt; max_bottom,
        "drawtext_y_expr": str(y),
    }

def main() -&gt; None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--height", type=int, required=True)
    ap.add_argument("--margin-frac", type=float, default=0.06)
    ap.add_argument("--banner-top-frac", type=float, default=0.42)
    args = ap.parse_args()
    print(json.dumps(caption_y(args.height, margin_frac=args.margin_frac,
                                banner_top_frac=args.banner_top_frac), indent=2))

if __name__ == "__main__":
    main()
</code></pre>
<p>On a 1280-tall frame that prints:</p>
<pre><code class="language-json">{
  "height": 1280,
  "y": 77,
  "top_margin_px": 77,
  "banner_keepout_from_y": 530,
  "safe": true,
  "drawtext_y_expr": "77"
}
</code></pre>
<p>So <code>drawtext</code> gets <code>y=77</code>, centred horizontally, with a soft semi-transparent box rather than a hard outline that fights the red.</p>
<h2>The ffmpeg filter</h2>
<pre><code class="language-bash">#!/usr/bin/env bash
# Soft white caption in the top safe zone of a vertical clip.
set -euo pipefail
IN="${1:?input mp4}"
OUT="${2:?output mp4}"
TEXT="${3:-Decision day (joke)}"
H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$IN")
Y=$(python3 "$(dirname "$0")/safezone.py" --height "$H" | python3 -c "import sys,json; print(json.load(sys.stdin)['y'])")
# Soft look: semi-transparent box, shadow, no outline crushing the frame.
ffmpeg -y -hide_banner -loglevel error -i "$IN" \
  -vf "drawtext=text='${TEXT}':fontsize=36:fontcolor=white@0.92:borderw=0:shadowx=2:shadowy=2:shadowcolor=black@0.55:box=1:boxcolor=black@0.35:boxborderw=12:x=(w-text_w)/2:y=${Y}" \
  -c:v libx264 -pix_fmt yuv420p -c:a copy "$OUT"
echo "burned y=${Y} height=${H} -&gt; $OUT"
</code></pre>
<p>Choices that matter for a soft look:</p>
<ul>
<li><code>fontcolor=white@0.92</code> and <code>boxcolor=black@0.35</code> — readable without a hard sticker.</li>
<li><code>shadowx/y</code> instead of a thick <code>borderw</code> — less crunch on the banner if you mis-place it.</li>
<li>Audio is copied (<code>-c:a copy</code>). The studio keeps the original soundtrack (<a href="https://bamabound.app/faq/">FAQ</a>); this filter does not touch it.</li>
</ul>
<h2>Tested today</h2>
<pre><code class="language-text">$ python3 safezone.py --height 1280
# y=77, keep-out from 530, safe=true
$ bash burn_caption.sh with_banner.mp4 captioned.mp4 "Decision day (joke)"
burned y=77 height=1280 -&gt; captioned.mp4
$ ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 captioned.mp4
720,1280
</code></pre>
<p>The caption sits in the top band; the red rectangle that stands in for the banner (drawn around <code>y=560</code>) is untouched.</p>
<h2>Product reminder</h2>
<p>bamabound.app does not take a prompt. You upload one JPEG/PNG/WebP ≤10 MB of one person with the face uncovered, pick a resolution if you want something other than 480p, and pay (<a href="https://bamabound.app/">create flow</a>, <a href="https://bamabound.app/pricing/">pricing</a>). Today's singles: <strong>$7.98 / 798 credits (480p), $9.98 / 998 (720p), $16.98 / 1923 (1080p), $24.98 / 3921 (4k)</strong>. Burn-in is a local post-step for posting, not part of checkout.</p>
]]></content:encoded></item><item><title><![CDATA[A Friend Changed Their Mind After Our Tiny Desk AI Clip Was Made. How Do I Blur Just Their Spot? (Keyframed ffmpeg + Python, Tested)]]></title><description><![CDATA[Checked November 5, 2026 (12:00 CST, UTC+8). I build tinydeskoutstanding.video, an independent studio that turns one to five photos into a 15-second…]]></description><link>https://lpgit.hashnode.dev/a-friend-changed-their-mind-after-our-tiny-desk-ai-clip-was-made-how-do-i-blur-just-their-spot-keyframed-ffmpeg-python-tested</link><guid isPermaLink="true">https://lpgit.hashnode.dev/a-friend-changed-their-mind-after-our-tiny-desk-ai-clip-was-made-how-do-i-blur-just-their-spot-keyframed-ffmpeg-python-tested</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 05 Nov 2026 04:17:58 GMT</pubDate><content:encoded><![CDATA[<p><em>Checked November 5, 2026 (12:00 CST, UTC+8). I build <a href="https://tinydeskoutstanding.video/">tinydeskoutstanding.video</a>, an independent studio that turns one to five photos into a 15-second vertical parody of the "Outstanding" Tiny Desk meme. The script below was written for this post and tested on a synthetic clip with a moving test pattern, not on anyone's face.</em></p>
<p>Here's a situation the "group clip" version of this meme creates. Five friends agree, five photos go in, the clip comes out great, and then one of them says: <em>"Actually, can you take me out before you post it?"</em></p>
<p>The right answer to "can I still post it with them in it?" is no. The site's rule is that every person in the clip is you or an adult who agreed (<a href="https://tinydeskoutstanding.video/acceptable-use/">acceptable use</a>), and a yes that's been taken back isn't a yes anymore. You have two options:</p>
<ol>
<li><strong>Make a new clip</strong> without that photo. Their spot goes back to the original performer, because "empty spots keep the original performers" (<a href="https://tinydeskoutstanding.video/faq/">FAQ</a>). That's a new paid run (as of today $9.98 at 480p, up to $39.98 at 4k, <a href="https://tinydeskoutstanding.video/pricing/">pricing</a>), and it won't be frame-for-frame the same clip.</li>
<li><strong>Blur their spot</strong> in the clip you already have, if everyone else is happy with that.</li>
</ol>
<p>This post is about option 2, done properly: a blur box that <strong>follows the person</strong> through the shots, <strong>jumps</strong> at camera cuts instead of sliding across the frame, and <strong>switches off</strong> in shots where that spot isn't on screen.</p>
<h2>Why a single static box doesn't work</h2>
<p>The clip isn't one locked-off camera. It runs as a band shot, a laughing close-up of the lead, and a sax lift at the end (<a href="https://tinydeskoutstanding.video/blog/desk-concert-parody/">how the parody runs</a>). A spot in the back row is in one place in the band shot and may not be in the close-up at all. One fixed box either misses them or blurs half the frame.</p>
<p>What you want is a handful of <strong>keyframes</strong>:</p>
<ul>
<li><code>t</code>, <code>x</code>, <code>y</code>: where the centre of their spot is, as fractions of the frame (so the same file works at 480p, 720p, 1080p or 4k),</li>
<li><code>cut: true</code>: the camera cut here, so jump, don't glide,</li>
<li><code>off: true</code>: from here until the next key, the spot isn't on screen; don't blur anything.</li>
</ul>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Blur one spot of a finished clip, following it with keyframed boxes.

  python3 blur_spot.py in.mp4 boxes.json out.mp4

boxes.json:
  {"size": [0.30, 0.22],                      # box w,h as a fraction of the frame
   "keys": [{"t": 0.0, "x": 0.10, "y": 0.40},  # box centre, fractions of the frame
            {"t": 4.0, "x": 0.18, "y": 0.42},
            {"t": 6.2, "x": 0.50, "y": 0.50, "cut": true},   # camera cut: jump, don't glide
            {"t": 9.0, "off": true}]}                         # spot not in this shot

Between two keys the box glides linearly; a key with "cut" jumps there;
from an "off" key until the next key nothing is blurred. The audio is copied.
"""
import json, subprocess, sys

def probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
                          "-show_entries", "stream=width,height", "-of", "json", path],
                         check=True, capture_output=True, text=True).stdout
    s = json.loads(out)["streams"][0]
    return s["width"], s["height"]

def piecewise(keys, axis, scale):
    """Build an ffmpeg expression in t for the box centre along one axis."""
    pts = [k for k in keys if not k.get("off")]
    expr = f"{pts[-1][axis] * scale:.2f}"            # after the last key: hold
    for i in range(len(pts) - 2, -1, -1):
        a, b = pts[i], pts[i + 1]
        va, vb = a[axis] * scale, b[axis] * scale
        if b.get("cut"):
            seg = f"{va:.2f}"                         # hold, then jump at b.t
        else:
            seg = f"({va:.2f}+({vb - va:.2f})*(t-{a['t']})/{b['t'] - a['t']})"
        expr = f"if(lt(t,{b['t']}),{seg},{expr})"
    return f"if(lt(t,{pts[0]['t']}),{pts[0][axis] * scale:.2f},{expr})"

def visible(keys):
    """enable= expression: on everywhere except [off key, next key)."""
    offs = []
    for i, k in enumerate(keys):
        if k.get("off"):
            end = keys[i + 1]["t"] if i + 1 &lt; len(keys) else 1e9
            offs.append(f"between(t,{k['t']},{end - 0.0001})")
    return "1" if not offs else f"not({'+'.join(offs)})"

def build_filter(spec, W, H):
    keys = sorted(spec["keys"], key=lambda k: k["t"])
    if not any(not k.get("off") for k in keys):
        raise ValueError("need at least one key with x and y")
    bw = int(spec["size"][0] * W) // 2 * 2
    bh = int(spec["size"][1] * H) // 2 * 2
    cx, cy = piecewise(keys, "x", W), piecewise(keys, "y", H)
    # left/top edge, kept inside the frame
    x = f"max(0,min({W - bw},{cx}-{bw // 2}))"
    y = f"max(0,min({H - bh},{cy}-{bh // 2}))"
    radius = max(4, min(bw, bh) // 6)
    return (f"[0:v]split[base][src];"
            f"[src]crop=w={bw}:h={bh}:x='{x}':y='{y}',boxblur={radius}:2[blur];"
            f"[base][blur]overlay=x='{x}':y='{y}':enable='{visible(keys)}'[v]")

def main(src, spec_path, dst):
    with open(spec_path, encoding="utf-8") as f:
        spec = json.load(f)
    W, H = probe(src)
    fc = build_filter(spec, W, H)
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-filter_complex", fc,
                    "-map", "[v]", "-map", "0:a?", "-c:v", "libx264", "-crf", "18",
                    "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", dst],
                   check=True)
    print("wrote", dst)

if __name__ == "__main__":
    if len(sys.argv) != 4:
        sys.exit(__doc__)
    main(*sys.argv[1:])
</code></pre>
<p>How the ffmpeg part works:</p>
<ul>
<li><code>split</code> makes two copies of the video. One is the untouched background.</li>
<li><code>crop</code> cuts the box out of the other copy. Its <code>x</code> and <code>y</code> are <strong>expressions in <code>t</code></strong>, re-evaluated every frame, so the box moves. (Crop's width and height are fixed when the filter starts, which is why the box keeps one size and the keyframes only move its centre.)</li>
<li><code>boxblur</code> blurs the cut-out. The radius scales with the box so a 4k clip gets as much blur as a 480p one.</li>
<li><code>overlay</code> puts it back at the same <code>x</code>/<code>y</code>, with <code>enable=</code> switching the overlay off during <code>off</code> stretches.</li>
<li>The audio is copied untouched. If the person also <em>says</em> something in the clip, a blur doesn't remove that. Make a new clip instead.</li>
</ul>
<p><code>piecewise()</code> turns the keys into nested <code>if(lt(t,…),…)</code> expressions. A glide segment is a linear interpolation between two keys; a segment that ends at a <code>cut</code> key just holds the old position, so at the cut the box jumps.</p>
<h2>Testing it without anyone's face</h2>
<p>A test clip with a sharp test pattern standing in for "the spot". It drifts right for six seconds, a "camera cut" moves it to the centre, and from 9 s it's gone (another shot):</p>
<pre><code class="language-bash">#!/bin/sh
# 15 s 720x1280 test clip: a sharp patch that drifts right for 6 s, then a
# "camera cut" puts it in the centre, and from 9 s it is gone (another shot).
ffmpeg -v error -y \
  -f lavfi -i color=c=0x335577:s=720x1280:r=30:d=15 \
  -f lavfi -i testsrc2=s=200x260:r=30:d=15 \
  -f lavfi -i sine=f=440:d=15 \
  -filter_complex "[0][1]overlay=x='if(lt(t,6),72+15*t,260)':y='if(lt(t,6),512,510)':enable='lt(t,9)'[v]" \
  -map "[v]" -map 2:a -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a aac -shortest test.mp4
</code></pre>
<p>The keyframes for that clip, worked out from the overlay positions (patch centre = top-left + half its size):</p>
<pre><code class="language-json">{"size": [0.34, 0.24],
 "keys": [{"t": 0.0, "x": 0.2389, "y": 0.5016},
          {"t": 6.0, "x": 0.3639, "y": 0.5016},
          {"t": 6.0, "x": 0.5, "y": 0.5, "cut": true},
          {"t": 9.0, "off": true}]}
</code></pre>
<p>Then a checker that measures sharpness (variance of a Laplacian) inside the patch in the original and the output, at times on both sides of the cut:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Compare sharpness inside a region of two clips at given times (gray, NumPy)."""
import subprocess, sys
import numpy as np

def frame(path, t, W=720, H=1280):
    raw = subprocess.run(["ffmpeg", "-v", "error", "-ss", str(t), "-i", path, "-frames:v", "1",
                          "-f", "rawvideo", "-pix_fmt", "gray", "-"],
                         check=True, capture_output=True).stdout
    return np.frombuffer(raw, np.uint8).reshape(H, W).astype(float)

def sharpness(img):
    lap = (img[1:-1, :-2] + img[1:-1, 2:] + img[:-2, 1:-1] + img[2:, 1:-1] - 4 * img[1:-1, 1:-1])
    return lap.var()

def patch_box(t):  # where make_test_clip.sh draws the 200x260 patch
    x, y = (72 + 15 * t, 512) if t &lt; 6 else (260, 510)
    return int(x), int(y), 200, 260

if __name__ == "__main__":
    orig, blurred = sys.argv[1], sys.argv[2]
    for t in (0.5, 3.0, 5.5, 6.5, 8.5, 10.0):
        x, y, w, h = patch_box(t)
        a = frame(orig, t)[y:y + h, x:x + w]
        b = frame(blurred, t)[y:y + h, x:x + w]
        ratio = sharpness(b) / max(sharpness(a), 1e-9)
        print(f"t={t:&gt;4}s  sharpness orig {sharpness(a):8.1f}  out {sharpness(b):8.1f}  "
              f"ratio {ratio:.3f}  mean abs diff {np.abs(a - b).mean():.2f}")
</code></pre>
<pre><code>$ sh make_test_clip.sh &amp;&amp; python3 blur_spot.py test.mp4 boxes.json out.mp4
wrote out.mp4
$ python3 check_blur.py test.mp4 out.mp4
t= 0.5s  sharpness orig   1827.6  out      1.9  ratio 0.001  mean abs diff 47.86
t= 3.0s  sharpness orig   1923.0  out      2.0  ratio 0.001  mean abs diff 48.63
t= 5.5s  sharpness orig   1774.5  out      2.0  ratio 0.001  mean abs diff 48.11
t= 6.5s  sharpness orig   1813.6  out      2.0  ratio 0.001  mean abs diff 47.92
t= 8.5s  sharpness orig   1806.1  out      2.0  ratio 0.001  mean abs diff 47.87
t=10.0s  sharpness orig      0.0  out      0.0  ratio 0.000  mean abs diff 0.00
</code></pre>
<p>Before the cut, after the cut and just before the <code>off</code> key, the patch keeps about 0.1% of its detail. At 10 s, in the "other shot", the output is identical to the input (mean difference 0.00), so nothing outside the spot's screen time was touched. <code>ffprobe</code> reports 15.000 s for both streams of the output; the length didn't change.</p>
<h2>Finding the keyframes on a real clip</h2>
<p>You need maybe five to eight keys for 15 seconds:</p>
<ol>
<li>Step through the clip in a player that shows the time (mpv shows it with <code>o</code>; VLC's <code>E</code> steps one frame).</li>
<li>At each camera cut, note the time and add a <code>cut: true</code> key with the spot's new centre.</li>
<li>Inside a long shot, add a key wherever the person has clearly moved.</li>
<li>Where the spot isn't visible (often the close-up, if it's not the lead), add <code>off: true</code>.</li>
<li>Render, then <strong>watch the output frame by frame around every cut</strong>. A box that's one frame late at a cut shows the face for 1/30 s, and people do pause videos.</li>
</ol>
<p>Make the box generous. A face that leans out of a tight box for a few frames is exactly the leak you're trying to prevent.</p>
<h2>Blur is not the same as removed</h2>
<p>Be honest with the friend about what this does:</p>
<ul>
<li>A heavy box blur on a face at this size isn't practically reversible, but <strong>hair, clothes and the spot itself</strong> may still say who it was to people who know the group.</li>
<li>If they want to be <strong>gone</strong>, not blurred, the clean route is a new clip with their spot left empty.</li>
<li>Delete the old unblurred file from the shared folder too. Blurring a copy doesn't do anything about the original sitting in the group chat.</li>
</ul>
<p>Whatever you post, keep the label: the site asks that the result is shared as an AI parody, not a real concert (<a href="https://tinydeskoutstanding.video/acceptable-use/">acceptable use</a>). It isn't NPR footage, and the sound is newly generated, not the Gap Band recording (<a href="https://tinydeskoutstanding.video/faq/">FAQ</a>).</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Turn the "State?" Moment Into a Telegram Video Sticker? WebM VP9 Under 256 KB With ffmpeg and Python]]></title><description><![CDATA[November 5, 2026. I run hittheroaddude.app, a paid studio that remakes the Hit the Road Dude pickup-window clip from one photo. Everything here was tested…]]></description><link>https://lpgit.hashnode.dev/how-do-you-turn-the-state-moment-into-a-telegram-video-sticker-webm-vp9-under-256-kb-with-ffmpeg-and-python</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-turn-the-state-moment-into-a-telegram-video-sticker-webm-vp9-under-256-kb-with-ffmpeg-and-python</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Thu, 05 Nov 2026 01:35:25 GMT</pubDate><content:encoded><![CDATA[<p><em>November 5, 2026. I run <a href="https://hittheroaddude.app/">hittheroaddude.app</a>, a paid studio that remakes the Hit the Road Dude pickup-window clip from one photo. Everything here was tested on a synthetic 720×1280 clip; no generated clips are shown or claimed.</em></p>
<p>The best two seconds of the Hit the Road Dude clip are the pause: the driver hears "state property", stops, and repeats a single puzzled <strong>"State?"</strong>. The site's write-up of the exchange calls that repeat "the turn. Without it, the clip is just someone waving a visitor along" (<a href="https://hittheroaddude.app/blog/window-exchange/">the pickup window exchange</a>).</p>
<p>Two seconds of a confused face is exactly what a sticker is for. Telegram supports <strong>video stickers</strong>, and it has a strict spec. This post turns a moment from a 15-second vertical MP4 into a sticker that passes the spec on the first upload, with a script that checks its own output.</p>
<h2>The rules</h2>
<p>From Telegram's own <a href="https://core.telegram.org/stickers">sticker documentation</a>, video stickers must be:</p>
<ul>
<li>WebM, encoded with <strong>VP9</strong></li>
<li>one side <strong>exactly 512 px</strong>, the other side 512 px or less</li>
<li><strong>3 seconds</strong> or shorter</li>
<li>up to <strong>30 FPS</strong></li>
<li><strong>no audio stream</strong></li>
<li><strong>256 KB</strong> or smaller</li>
<li>and it "should be looped for optimal user experience"</li>
</ul>
<p>For a vertical 9:16 clip, "one side exactly 512" means height 512 and width 288. The size cap is the one that bites: 256 KB over 3 seconds is about 680 kbit/s, and VP9 needs a bit of care to land under it.</p>
<p>Note the sticker has no sound, so "State?" becomes a facial expression, not a word. That's fine. Most people know the line.</p>
<h2>Pick the moment</h2>
<p>Find where the pause starts. In my test clip the "State?" line is at about 4.5 s, so I start at <strong>4.3 s</strong> and take <strong>2.8 s</strong>. Starting a little before the word catches the freeze, which is the funny part. Ending a bit short of 3.0 s leaves a margin under the limit.</p>
<p>If you want it to loop cleanly, look for a start and end where the face is in roughly the same position. The pause is good for this, because the driver is nearly still.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Cut a &lt;=3 s moment from a vertical clip into a Telegram video sticker.

Telegram's rules for video stickers (core.telegram.org/stickers): WebM with VP9,
one side exactly 512 px (the other 512 or less), at most 3 seconds, up to 30 FPS,
no audio stream, at most 256 KB. This script cuts the window, scales the long
side to 512, drops audio, and lowers the bitrate until the file fits.

usage: make_sticker.py CLIP.mp4 START OUT.webm [--len 2.8] [--fps 30]
"""
import argparse, json, os, subprocess, sys

LIMIT = 256 * 1024

def encode(src, start, length, fps, kbps, out):
    vf = (f"fps={fps},scale='if(gt(iw,ih),512,-2)':'if(gt(iw,ih),-2,512)':flags=lanczos,"
          "format=yuv420p")
    base = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start), "-t", str(length),
            "-i", src, "-an", "-vf", vf, "-c:v", "libvpx-vp9", "-b:v", f"{kbps}k",
            "-maxrate", f"{int(kbps * 1.2)}k", "-bufsize", f"{kbps * 2}k",
            "-row-mt", "1", "-deadline", "good", "-cpu-used", "2"]
    log = out + ".2pass"
    subprocess.run(base + ["-pass", "1", "-passlogfile", log, "-f", "webm", os.devnull], check=True)
    subprocess.run(base + ["-pass", "2", "-passlogfile", log, out], check=True)
    for f in os.listdir(os.path.dirname(os.path.abspath(out))):
        if f.startswith(os.path.basename(log)):
            os.remove(os.path.join(os.path.dirname(os.path.abspath(out)), f))
    return os.path.getsize(out)

def check(out):
    j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_entries",
        "format=duration,size:stream=codec_type,codec_name,width,height,avg_frame_rate",
        "-of", "json", out], capture_output=True, text=True, check=True).stdout)
    v = [s for s in j["streams"] if s["codec_type"] == "video"][0]
    num, den = map(int, v["avg_frame_rate"].split("/"))
    problems = []
    if v["codec_name"] != "vp9": problems.append("codec is not vp9")
    if max(v["width"], v["height"]) != 512: problems.append("no side is exactly 512 px")
    if min(v["width"], v["height"]) &gt; 512: problems.append("a side is over 512 px")
    if float(j["format"]["duration"]) &gt; 3.0: problems.append("longer than 3 s")
    if num / den &gt; 30: problems.append("over 30 FPS")
    if any(s["codec_type"] == "audio" for s in j["streams"]): problems.append("has audio")
    if int(j["format"]["size"]) &gt; LIMIT: problems.append("over 256 KB")
    print(f"{out}: {v['width']}x{v['height']} {v['codec_name']} {num/den:.0f}fps "
          f"{float(j['format']['duration']):.2f}s {int(j['format']['size'])/1024:.0f} KB")
    return problems

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("clip"); ap.add_argument("start", type=float); ap.add_argument("out")
    ap.add_argument("--len", type=float, default=2.8)
    ap.add_argument("--fps", type=int, default=30)
    a = ap.parse_args()
    if a.len &gt; 3.0 or a.fps &gt; 30:
        sys.exit("Telegram allows at most 3 s and 30 FPS")
    # target bitrate from the size budget, with ~8% headroom for the container
    kbps = int(LIMIT * 8 / 1000 / a.len * 0.92)
    for _ in range(6):
        size = encode(a.clip, a.start, a.len, a.fps, kbps, a.out)
        print(f"  {kbps} kbps -&gt; {size/1024:.0f} KB")
        if size &lt;= LIMIT: break
        kbps = int(kbps * LIMIT / size * 0.9)
    problems = check(a.out)
    print("OK for Telegram" if not problems else "NOT OK: " + "; ".join(problems))
    sys.exit(1 if problems else 0)

if __name__ == "__main__":
    main()
</code></pre>
<p>What it does:</p>
<ol>
<li><strong>Scale.</strong> <code>scale='if(gt(iw,ih),512,-2)':'if(gt(iw,ih),-2,512)'</code> puts 512 on the long side and keeps the aspect ratio for either orientation. <code>-2</code> keeps the short side even, which <code>yuv420p</code> needs.</li>
<li><strong>Drop audio.</strong> <code>-an</code>. Telegram rejects a file with an audio stream, even a silent one.</li>
<li><strong>Pick a bitrate from the budget.</strong> 256 KB × 8 / length, minus 8% for the WebM container and keyframes.</li>
<li><strong>Two-pass VP9.</strong> Pass one measures the content, pass two spends the bits. Two-pass rate control is the standard way to land close to a target file size.</li>
<li><strong>Retry smaller</strong> if the file is still over 256 KB, scaling the bitrate down by how much it missed.</li>
<li><strong>Check the result</strong> with ffprobe against every rule, and exit non-zero if any fails.</li>
</ol>
<h2>Run it</h2>
<pre><code class="language-bash">python3 make_sticker.py clip.mp4 4.3 state.webm
#   689 kbps -&gt; 237 KB
# state.webm: 288x512 vp9 30fps 2.80s 237 KB
# OK for Telegram
</code></pre>
<p>One pass of the loop was enough. Ask for more than Telegram allows and it refuses before encoding:</p>
<pre><code class="language-bash">python3 make_sticker.py clip.mp4 4.3 bad.webm --len 4
# Telegram allows at most 3 s and 30 FPS
</code></pre>
<p>My test input is <code>testsrc2</code>, which is much busier than a face at a truck window (moving gradients, a counter, scrolling patterns). A real remake has more static background, so it should compress at least as well. If yours comes out soft, try <code>--fps 24</code> before cutting the length: fewer frames leave more bits for each one.</p>
<h2>Uploading it</h2>
<p>Telegram's flow goes through the official <code>@Stickers</code> bot (or the Stickers mini app): create a new video sticker set, send the <code>.webm</code> when the bot asks for the sticker, then pick an emoji for it. 🤨 is the obvious one. If the bot rejects the file, run the script's check again on exactly the file you sent.</p>
<h2>Before you share the set</h2>
<p>A sticker pack is more public than a clip in a group chat. Anyone you send a sticker to can add the whole set. So the usual rules matter more here, not less:</p>
<ul>
<li><strong>Whose face is it?</strong> The studio only accepts "yourself, an adult who agreed, or your pet" (<a href="https://hittheroaddude.app/acceptable-use/">acceptable use</a>), and the same page says "Permission to edit material does not automatically include permission to publish it." A sticker pack is publishing. Ask again.</li>
<li><strong>It's a remake.</strong> Don't name the set after the original person or present it as the original clip. "Do not present it as the original recording" (<a href="https://hittheroaddude.app/guides/check-before-posting/">check before posting</a>).</li>
<li><strong>Pets are the easy case.</strong> A dog doing the "State?" face is probably the best sticker in this post. The <a href="https://hittheroaddude.app/guides/hit-the-road-dude-dog/">dog version guide</a> covers the photo.</li>
</ul>
<h2>What you start from</h2>
<p>The input is any 15-second vertical MP4. If it comes from my studio, it's 480p ($9.99) or 720p ($12.99), and both are 15 seconds, 9:16, with sound (<a href="https://hittheroaddude.app/pricing/">pricing</a>). For a 288×512 sticker, 480p is already more than enough resolution. 720p doesn't buy you anything visible at sticker size.</p>
<h2>Recap</h2>
<ul>
<li>Video sticker = WebM VP9, long side exactly 512, ≤3 s, ≤30 FPS, no audio, ≤256 KB.</li>
<li>Start a little before "State?" and take ~2.8 s of the pause.</li>
<li>Two-pass VP9 at a bitrate derived from the size budget, retry smaller if needed.</li>
<li>Let the script check the output. Then send it to <code>@Stickers</code>.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Does the Green-and-Silver Malfoid Look Still Work for Colour-Blind Viewers? Simulating Protan, Deutan and Tritan Vision in Python]]></title><description><![CDATA[About 1 in 12 men and 1 in 200 women of Northern European ancestry have some form of red-green colour-vision deficiency. When you post a Malfoid (Female…]]></description><link>https://lpgit.hashnode.dev/does-the-green-and-silver-malfoid-look-still-work-for-colour-blind-viewers-simulating-protan-deutan-and-tritan-vision-in-python</link><guid isPermaLink="true">https://lpgit.hashnode.dev/does-the-green-and-silver-malfoid-look-still-work-for-colour-blind-viewers-simulating-protan-deutan-and-tritan-vision-in-python</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 04 Nov 2026 10:22:01 GMT</pubDate><content:encoded><![CDATA[<p>About 1 in 12 men and 1 in 200 women of Northern European ancestry have some form of red-green colour-vision deficiency. When you post a Malfoid (Female Malfoy / Fem Draco) portrait, part of what makes it read as <em>that</em> character is the palette: platinum hair, a green tie with silver stripes, a dark robe. So it's a fair question, and one people do ask: <strong>does the look still read for someone who can't see green the way you do?</strong></p>
<p>You can measure that instead of guessing. This post builds a small checker: sample a few colours from a picture, simulate three kinds of colour-vision deficiency, and print how far apart the colours stay. It also writes a four-panel preview. Pillow and NumPy only, tested on the box on 2026-11-04.</p>
<p>(I build <a href="https://malfoidai.com/">malfoidai.com</a>, an unofficial studio for still Malfoid portraits. The test image here is a synthetic drawing, not a generated portrait.)</p>
<h2>How the simulation works</h2>
<p>The <a href="https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html">Machado, Oliveira &amp; Fernandes (2009)</a> model gives a 3×3 matrix per deficiency and severity. You apply it in <strong>linear</strong> RGB (undo the sRGB gamma first, re-apply it after). At severity 1.0 the three matrices approximate:</p>
<ul>
<li><strong>protanopia</strong>: no working L (long-wavelength, "red") cones</li>
<li><strong>deuteranopia</strong>: no working M ("green") cones, the most common type</li>
<li><strong>tritanopia</strong>: no working S ("blue") cones, rare</li>
</ul>
<p>Then convert both colours of each pair to CIELAB and take the distance: <strong>ΔE</strong>, CIE76 here (plain Euclidean distance in Lab). As a rough guide, ΔE under 10 is hard to tell apart at a glance and under 5 is nearly the same colour. CIEDE2000 would be more accurate for small differences. For "can you tell the tie from the robe", CIE76 is enough.</p>
<h2>The script</h2>
<pre><code class="language-python">#!/usr/bin/env python3
"""Does a colour scheme survive colour-vision deficiency? (Pillow + NumPy)

    python cvd_check.py portrait.png --sample tie_green=376,820,16,16 \
        --sample tie_silver=380,900,8,6 --sample hair=230,600,30,30 \
        --sample robe=180,900,40,40 [--preview cvd.png]

Each --sample is NAME=x,y,w,h (a small box of one colour). The script
averages each box, simulates protanopia, deuteranopia and tritanopia with
the Machado, Oliveira &amp; Fernandes (2009) matrices at severity 1.0 (applied
in linear RGB), and prints the CIE76 colour difference (Delta E in CIELAB)
between every pair of samples for normal vision and each simulation.
Rough reading: &lt; 10 hard to tell apart at a glance, &lt; 5 nearly the same.
"""
import argparse
import itertools
import numpy as np
from PIL import Image

MACHADO = {
    "protan": [[0.152286, 1.052583, -0.204868],
               [0.114503, 0.786281, 0.099216],
               [-0.003882, -0.048116, 1.051998]],
    "deutan": [[0.367322, 0.860646, -0.227968],
               [0.280085, 0.672501, 0.047413],
               [-0.011820, 0.042940, 0.968881]],
    "tritan": [[1.255528, -0.076749, -0.178779],
               [-0.078411, 0.930809, 0.147602],
               [0.004733, 0.691367, 0.303900]],
}


def to_linear(c):
    c = np.asarray(c, dtype=np.float64) / 255
    return np.where(c &lt;= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)


def to_srgb(l):
    l = np.clip(l, 0, 1)
    return np.where(l &lt;= 0.0031308, l * 12.92, 1.055 * l ** (1 / 2.4) - 0.055) * 255


def lab(rgb):
    l = to_linear(rgb)
    m = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]])
    xyz = l @ m.T / np.array([0.95047, 1.0, 1.08883])
    f = np.where(xyz &gt; (6 / 29) ** 3, np.cbrt(xyz), xyz / (3 * (6 / 29) ** 2) + 4 / 29)
    return np.stack([116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]),
                     200 * (f[..., 1] - f[..., 2])], axis=-1)


def simulate(rgb, kind):
    return to_srgb(to_linear(rgb) @ np.array(MACHADO[kind]).T)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("image")
    ap.add_argument("--sample", action="append", required=True, help="NAME=x,y,w,h")
    ap.add_argument("--preview")
    a = ap.parse_args()
    img = Image.open(a.image).convert("RGB")
    arr = np.asarray(img, dtype=np.float64)
    samples = {}
    for s in a.sample:
        name, box = s.split("=")
        x, y, w, h = map(int, box.split(","))
        samples[name] = arr[y:y + h, x:x + w].reshape(-1, 3).mean(axis=0)
    views = {"normal": samples}
    for k in MACHADO:
        views[k] = {n: simulate(c, k) for n, c in samples.items()}
    print("sample colours (sRGB):")
    for n, c in samples.items():
        print(f"  {n:12} #{''.join(f'{int(round(v)):02x}' for v in c)}")
    print(f"\n{'pair':26}" + "".join(f"{v:&gt;8}" for v in views))
    for p, q in itertools.combinations(samples, 2):
        row = [float(np.linalg.norm(lab(views[v][p]) - lab(views[v][q]))) for v in views]
        flag = "  &lt;- check" if min(row) &lt; 10 else ""
        print(f"{p + ' / ' + q:26}" + "".join(f"{d:8.1f}" for d in row) + flag)
    if a.preview:
        tiles = [img] + [Image.fromarray(simulate(arr, k).round().astype(np.uint8)) for k in MACHADO]
        w, h = img.size
        sheet = Image.new("RGB", (w * 4, h))
        for i, t in enumerate(tiles):
            sheet.paste(t, (i * w, 0))
        sheet.resize((w * 2, h // 2)).save(a.preview)
        print(f"\npreview: {a.preview} (normal | protan | deutan | tritan)")


if __name__ == "__main__":
    main()
</code></pre>
<h2>Running it on a Malfoid-style test image</h2>
<p>The test image is 768×1024 and drawn with shapes (helper script at the end). I sampled six flat boxes: tie green, tie stripe silver, hair, robe, background, skin.</p>
<pre><code class="language-bash">python cvd_check.py test.png \
  --sample tie_green=372,815,8,8 --sample tie_silver=382,898,8,5 \
  --sample hair=220,580,30,30 --sample robe=170,880,40,40 \
  --sample background=20,20,30,30 --sample skin=370,300,20,20 \
  --preview cvd.png
</code></pre>
<p>Output:</p>
<pre><code class="language-text">sample colours (sRGB):
  tie_green    #1a5c34
  tie_silver   #b0b4b8
  hair         #e2dece
  robe         #16181e
  background   #1e1814
  skin         #deb69c

pair                        normal  protan  deutan  tritan
tie_green / tie_silver        53.0    44.6    43.5    45.5
tie_green / hair              62.3    54.0    55.8    61.4
tie_green / robe              46.3    37.7    31.7    34.8
tie_green / background        43.7    32.8    26.4    39.4
tie_green / skin              59.5    39.7    45.2    64.4
tie_silver / hair             18.7    18.3    19.1    16.4
tie_silver / robe             64.9    64.8    64.9    64.8
tie_silver / background       64.6    65.1    64.2    64.7
tie_silver / skin             24.3    19.2    23.3    25.9
hair / robe                   81.1    80.6    81.5    80.2
hair / background             79.7    79.7    79.6    79.5
hair / skin                   19.6    15.5    15.7    22.6
robe / background              8.4     7.8     8.8     7.3  &lt;- check
robe / skin                   73.1    69.9    74.1    73.0
background / skin             70.2    67.9    70.8    70.3

preview: cvd.png (normal | protan | deutan | tritan)
</code></pre>
<h2>What the numbers say</h2>
<p><strong>The look survives.</strong> The cues that matter most are carried by <em>lightness</em>, not hue: bright platinum hair against a dark robe (ΔE <del>80 in every view), and silver stripes on a dark green tie (</del>44–53). Lightness differences don't depend on the L/M/S cone balance, so they hold up under every simulation.</p>
<p><strong>The green is the part that weakens.</strong> Tie green vs robe drops from 46.3 to <strong>31.7</strong> for deuteranopia. Tie green vs background drops from 43.7 to <strong>26.4</strong>. Still distinguishable, but the green becomes a dark olive-grey to those viewers. It reads as "a darker tie", not as "green". The <em>silver stripes</em> keep the tie identifiable.</p>
<p><strong>Robe vs background is the real low-contrast pair, for everyone</strong> (7.3–8.8, flagged in all four columns). That's a lightness problem, not a colour-vision one: near-black on near-black. It's why a dark robe can melt into a dark corridor in a small thumbnail.</p>
<h2>Why green and silver beats red and green here</h2>
<p>For comparison I ran a swatch of the same tie green next to a red (<code>#8c2828</code>) and the silver:</p>
<pre><code class="language-text">pair                        normal  protan  deutan  tritan
green / red                   73.3    11.2    14.0    84.6
green / silver                53.0    44.6    43.5    45.5
red / silver                  64.9    50.2    48.8    74.7
</code></pre>
<p>Green vs red goes from 73 to <strong>11–14</strong> for protan/deutan, nearly the same colour. Green vs silver stays above 43. A palette built on a dark colour plus a light neutral is much more robust than two saturated hues of similar lightness. That's a useful rule if you're designing a fan-art palette, a theme or a UI, not just for this one look.</p>
<h2>Practical takeaways for posting</h2>
<ul>
<li>Run it on <strong>your own</strong> portrait. Sample flat areas (zoom in and pick 6–10 px boxes inside a stripe, not across an edge). Generated images have gradients, so averages over larger boxes are more stable.</li>
<li>If your crop for a profile picture keeps only the face and hair, you've lost the tie, and with it the clearest palette cue. Keep the tie in frame when you can.</li>
<li>Don't rely on colour words in the caption either. "Green-and-silver" means less to some readers. A plain caption such as "Malfoid fan portrait (AI fan art)" carries the meaning without the colour.</li>
<li>Alt text should describe the look: "AI fan portrait of a woman with long platinum hair, white shirt, striped tie and dark robe in a candlelit corridor".</li>
</ul>
<h2>Limits</h2>
<ul>
<li>The simulation approximates dichromacy at full severity. Most colour-blind people are anomalous trichromats with milder shifts, so these numbers are a worst case.</li>
<li>ΔE thresholds depend on size and context: thin stripes in a small thumbnail are harder to tell apart than big patches.</li>
<li>The matrices assume a standard sRGB display.</li>
</ul>
<h2>Where the palette comes from</h2>
<p>The portrait look is fixed by the preset on malfoidai.com: long platinum hair, white shirt, green striped tie, dark robe, in a candlelit academy corridor (<a href="https://malfoidai.com/guides/which-preset/">which preset</a>, <a href="https://malfoidai.com/templates/">scene notes</a>). As of Nov 4, 2026 a portrait is $4.99 paid once or 2 credits from a pack (<a href="https://malfoidai.com/pricing/">pricing</a>). It's unofficial fan art, so label it that way when you post (<a href="https://malfoidai.com/guides/check-before-posting/">check before posting</a>).</p>
<h3>Helper: the synthetic test portrait</h3>
<pre><code class="language-python">#!/usr/bin/env python3
"""Draw a synthetic 3:4 'Malfoid-style' test portrait (no real face, no AI).

Shapes only: dark corridor, candle glow, platinum hair, an oval face,
white shirt, green-and-silver striped tie, dark robe. Good enough to test
colour, print and hashing scripts without using anyone's photo.

    python make_test_portrait.py out.png [--seed 1] [--size 768x1024]
"""
import argparse
import random
from PIL import Image, ImageDraw, ImageFilter


def portrait(w=768, h=1024, seed=1):
    rnd = random.Random(seed)
    img = Image.new("RGB", (w, h))
    px = img.load()
    for y in range(h):                      # corridor: dark warm gradient
        for x in range(w):
            v = 18 + int(30 * (1 - abs(x - w / 2) / (w / 2)) * (1 - y / h))
            px[x, y] = (v + 10, v + 4, v)
    glow = Image.new("RGB", (w, h), (0, 0, 0))
    g = ImageDraw.Draw(glow)
    for _ in range(4):                      # candle glows
        cx, cy, r = rnd.randint(40, w - 40), rnd.randint(60, h // 2), rnd.randint(25, 60)
        g.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 150, 40))
    glow = glow.filter(ImageFilter.GaussianBlur(28))
    img = Image.blend(img, Image.composite(glow, img, glow.convert("L")), 0.6)
    d = ImageDraw.Draw(img)
    s = w / 768
    skin, fw = (222, 182, 156), 0                # seed 1 = the default sitter
    if seed != 1:                                # other seeds: another sitter
        skin = tuple(max(0, c - rnd.randint(0, 70)) for c in skin)
        fw = rnd.randint(-30, 30)
    d.polygon([(int(60 * s), h), (int(130 * s), int(640 * s)), (int(638 * s), int(640 * s)),
               (int(708 * s), h)], fill=(22, 24, 30))                      # robe
    d.polygon([(int(300 * s), int(640 * s)), (int(468 * s), int(640 * s)),
               (int(430 * s), h), (int(338 * s), h)], fill=(240, 240, 236))  # shirt
    tie = [(int(364 * s), int(660 * s)), (int(404 * s), int(660 * s)),
           (int(420 * s), h), (int(348 * s), h)]
    d.polygon(tie, fill=(26, 92, 52))                                      # tie green
    for i in range(6):                                                     # silver stripes
        y0 = int((690 + i * 55) * s)
        d.polygon([(int(340 * s), y0), (int(430 * s), y0 - int(30 * s)),
                   (int(430 * s), y0 - int(14 * s)), (int(340 * s), y0 + int(16 * s))],
                  fill=(176, 180, 184))
    d.polygon(tie, outline=(26, 92, 52))
    d.ellipse([int(170 * s), int(150 * s), int(598 * s), int(760 * s)], fill=(226, 222, 206))  # hair
    d.ellipse([int((264 - fw) * s), int(250 * s), int((504 + fw) * s), int(560 * s)], fill=skin)  # face
    for ex in (330, 438):                                                  # eyes
        d.ellipse([int((ex - 16) * s), int(380 * s), int((ex + 16) * s), int(398 * s)], fill=(250, 250, 250))
        d.ellipse([int((ex - 7) * s), int(382 * s), int((ex + 7) * s), int(396 * s)], fill=(110, 130, 120))
    d.arc([int(340 * s), int(450 * s), int(428 * s), int(500 * s)], 20, 160, fill=(150, 80, 80), width=max(2, int(4 * s)))
    return img


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("out")
    ap.add_argument("--seed", type=int, default=1)
    ap.add_argument("--size", default="768x1024")
    a = ap.parse_args()
    w, h = map(int, a.size.lower().split("x"))
    portrait(w, h, a.seed).save(a.out)
    print(f"wrote {a.out} {w}x{h}")
</code></pre>
]]></content:encoded></item><item><title><![CDATA[How Do You Get a Sharp Still Photo From a Bama Bound Video? Picking the Sharpest Frame With a Laplacian Focus Score (Python + ffmpeg)]]></title><description><![CDATA[November 4, 2026 (CST, UTC+8). I build bamabound.app, which turns one photo into an 8-second vertical clip of that person holding a red banner that reads…]]></description><link>https://lpgit.hashnode.dev/how-do-you-get-a-sharp-still-photo-from-a-bama-bound-video-picking-the-sharpest-frame-with-a-laplacian-focus-score-python-ffmpeg</link><guid isPermaLink="true">https://lpgit.hashnode.dev/how-do-you-get-a-sharp-still-photo-from-a-bama-bound-video-picking-the-sharpest-frame-with-a-laplacian-focus-score-python-ffmpeg</guid><dc:creator><![CDATA[Peiguan]]></dc:creator><pubDate>Wed, 04 Nov 2026 07:19:26 GMT</pubDate><content:encoded><![CDATA[<p><em>November 4, 2026 (CST, UTC+8). I build <a href="https://bamabound.app/">bamabound.app</a>, which turns one photo into an 8-second vertical clip of that person holding a red banner that reads I'M BAMA BOUND. Everything below was run on a synthetic test clip, so no customer media was involved, and the script works on any video file.</em></p>
<p>A lot of people want a <strong>still</strong> of their Bama Bound moment, not just the clip: a profile picture, a group-chat image, slide 2 of a carousel, or something to print. The obvious move is to pause the video and screenshot it. That usually gives you a bad frame. The banner is being lifted, the person is mid-cheer, and roughly every other frame has some motion blur. A screenshot is also captured at your screen's size and then compressed again by your phone.</p>
<p>The better way is to let a script look at <strong>every frame</strong>, score how sharp each one is, and export the winners at the clip's full resolution as lossless PNGs.</p>
<h2>Measuring sharpness: variance of the Laplacian</h2>
<p>The Laplacian is a tiny filter that responds to edges: for each pixel it adds the four neighbours and subtracts four times the centre. On a crisp frame, letters, hair and fabric produce strong positive and negative responses. On a blurred frame those responses flatten out. So the <strong>variance</strong> of the Laplacian over the frame is a one-number focus score: high means sharp, low means soft. It's a standard focus measure because it's cheap and works without a reference image.</p>
<p>It's only meaningful <strong>relative to other frames of the same clip</strong>. A frame full of grass will score differently from a frame of a face no matter how sharp they are, which is why the script also prints each pick's score as a multiple of the clip's median.</p>
<h2>The script</h2>
<p>It decodes the clip once as a small grey copy (360 pixels wide is plenty to rank focus), scores every frame with NumPy, picks the top N while keeping picks at least <code>--gap</code> seconds apart (otherwise you get three near-identical neighbours), and then asks ffmpeg for exactly those frame numbers <strong>at full size</strong>.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""best_still.py: save the sharpest frame(s) of a video as full-resolution PNG.

Scores every frame by the variance of a Laplacian (a standard focus measure:
motion blur and soft focus remove edges, so the variance drops), then extracts
the winners at full size with ffmpeg.

Usage: python3 best_still.py clip.mp4 [--top 3] [--gap 0.5] [--out stills]
"""
import argparse, json, os, subprocess
import numpy as np

SCORE_W = 360  # score on a small grey copy: fast, and enough to rank focus


def probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
                          "-show_entries", "stream=width,height,r_frame_rate",
                          "-of", "json", path], capture_output=True, text=True,
                         check=True).stdout
    s = json.loads(out)["streams"][0]
    num, den = s["r_frame_rate"].split("/")
    return s["width"], s["height"], float(num) / float(den)


def frames_gray(path, w, h):
    sh = round(h * SCORE_W / w / 2) * 2
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf",
                          f"scale={SCORE_W}:{sh}:flags=area,format=gray",
                          "-f", "rawvideo", "-"], stdout=subprocess.PIPE)
    size = SCORE_W * sh
    while True:
        buf = p.stdout.read(size)
        if len(buf) &lt; size:
            break
        yield np.frombuffer(buf, np.uint8).reshape(sh, SCORE_W).astype(np.float32)
    p.wait()


def laplacian_var(img):
    lap = (img[:-2, 1:-1] + img[2:, 1:-1] + img[1:-1, :-2] + img[1:-1, 2:]
           - 4 * img[1:-1, 1:-1])
    return float(lap.var())


def pick(scores, fps, top, gap):
    chosen = []
    for i in sorted(range(len(scores)), key=lambda i: -scores[i]):
        if all(abs(i - j) / fps &gt;= gap for j in chosen):
            chosen.append(i)
        if len(chosen) == top:
            break
    return chosen


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("clip")
    ap.add_argument("--top", type=int, default=3)
    ap.add_argument("--gap", type=float, default=0.5, help="min seconds between picks")
    ap.add_argument("--out", default="stills")
    a = ap.parse_args()
    w, h, fps = probe(a.clip)
    scores = [laplacian_var(f) for f in frames_gray(a.clip, w, h)]
    if not scores:
        raise SystemExit("no frames decoded")
    med = float(np.median(scores))
    os.makedirs(a.out, exist_ok=True)
    print(f"{len(scores)} frames at {fps:.3f} fps, median sharpness {med:.1f}")
    for rank, i in enumerate(pick(scores, fps, a.top, a.gap), 1):
        dst = os.path.join(a.out, f"still_{rank}_t{i / fps:05.2f}s.png")
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", a.clip, "-vf",
                        f"select=eq(n\\,{i})", "-frames:v", "1", dst], check=True)
        print(f"#{rank} frame {i} t={i / fps:.2f}s sharpness {scores[i]:.1f} "
              f"({scores[i] / med:.1f}x median) -&gt; {dst}")


if __name__ == "__main__":
    main()
</code></pre>
<p>Two details that matter:</p>
<ul>
<li><strong><code>select=eq(n\,{i})</code></strong> picks the frame by its decoded index, so the PNG is the exact frame that was scored. Seeking with <code>-ss</code> to a timestamp can land one frame off.</li>
<li><strong>PNG, not JPEG.</strong> The extracted still is lossless. Convert to JPEG later if a site insists, and do it once.</li>
</ul>
<h2>Testing it on a clip where I know the answer</h2>
<p>To check the ranking, I made an 8-second, 720×1280, 30 fps clip whose focus changes every two seconds: heavy blur, lighter blur, <strong>sharp from 4 to 6 seconds</strong>, then blur again.</p>
<pre><code class="language-bash">#!/bin/sh
# 8 s, 720x1280, 30 fps test clip whose focus changes every 2 s: blurry, softer, sharp, blurry.
set -e
ffmpeg -v error -y -f lavfi -i "testsrc2=size=720x1280:rate=30:duration=8" -filter_complex \
 "[0:v]split=4[a][b][c][d];\
  [a]trim=0:2,gblur=sigma=6,setpts=PTS-STARTPTS[a2];\
  [b]trim=2:4,gblur=sigma=3,setpts=PTS-STARTPTS[b2];\
  [c]trim=4:6,gblur=sigma=0.5,setpts=PTS-STARTPTS[c2];\
  [d]trim=6:8,gblur=sigma=4,setpts=PTS-STARTPTS[d2];\
  [a2][b2][c2][d2]concat=n=4:v=1:a=0,format=yuv420p[v]" \
 -map "[v]" -c:v libx264 -crf 18 focus_test.mp4
</code></pre>
<p>Output of <code>python3 best_still.py focus_test.mp4</code>:</p>
<pre><code class="language-text">240 frames at 30.000 fps, median sharpness 16.8
#1 frame 120 t=4.00s sharpness 473.7 (28.3x median) -&gt; stills/still_1_t04.00s.png
#2 frame 137 t=4.57s sharpness 465.2 (27.7x median) -&gt; stills/still_2_t04.57s.png
#3 frame 162 t=5.40s sharpness 462.3 (27.6x median) -&gt; stills/still_3_t05.40s.png
</code></pre>
<p>All three picks are inside the sharp 4–6 s window, spaced at least half a second apart, and each PNG is the full 720×1280. The whole run took about 1.3 seconds on my box.</p>
<p>One thing I got wrong on the first try: I read the frame rate from ffprobe's <code>avg_frame_rate</code>, which is frames divided by duration. On this concatenated clip it came out as 30.126, so the timestamps in the filenames were slightly off (3.98 s instead of 4.00 s). <code>r_frame_rate</code>, the stream's nominal rate, gives 30. For a variable-frame-rate file both are approximations, and that's another reason the export selects by frame number, not by time.</p>
<h2>Using it on your clip</h2>
<pre><code class="language-sh">pip install numpy
python3 best_still.py my_bama_bound.mp4 --top 5 --gap 0.4
</code></pre>
<p>Then look at the five PNGs yourself. The score finds <em>sharp</em> frames. It doesn't know which one has the best expression, whether the banner text is fully visible, or whether someone's eyes are closed. Ranking first and choosing by eye is much faster than scrubbing through 240 frames.</p>
<p>Some practical notes for this particular meme:</p>
<ul>
<li><strong>The banner is the joke.</strong> Prefer a frame where I'M BAMA BOUND is fully readable. A sharp frame with the banner half-turned isn't the one.</li>
<li><strong>Resolution sets the ceiling.</strong> The still can't be bigger than the clip. bamabound.app makes the clip at 480p by default, with 720p, 1080p and 4k as paid options ($7.99, $9.99, $14.99 and $24.99 per clip on the <a href="https://bamabound.app/pricing/">pricing page</a> today). If you mainly want a still for a phone screen, 480p is usually enough. If you want to print it or crop in, a higher tier gives the frame more pixels.</li>
<li><strong>Say it's generated</strong> when it could be mistaken for a real photo. The <a href="https://bamabound.app/acceptable-use/">posting guidance</a> is short: use a photo you have permission to use, and don't present the result as a real acceptance. A still loses the "it's a video meme" context faster than the clip does.</li>
</ul>
<h2>Why not just use a "best thumbnail" feature?</h2>
<p>Video thumbnail pickers in editors and platforms usually optimise for "representative" frames, meaning colourful, centred and not too dark. That isn't the same as sharpest. For a still you're going to look at closely, sharpness matters more, and you want control over the spacing between picks.</p>
<h2>Limits</h2>
<ul>
<li>The Laplacian also rises with <strong>noise and compression blockiness</strong>. On a heavily compressed re-upload, a blocky frame can outscore a clean one. Run it on the original download.</li>
<li>Scoring at 360 pixels wide hides very fine focus differences. For 4K files, <code>SCORE_W = 720</code> ranks a little more precisely, at about four times the cost.</li>
<li>It needs Python 3.8+, NumPy and ffmpeg/ffprobe on PATH.</li>
</ul>
<p>If you don't have a clip yet, the <a href="https://bamabound.app/faq/">FAQ</a> covers what the generator takes (one photo of one adult, JPEG/PNG/WebP up to 10 MB) and what you get back.</p>
]]></content:encoded></item></channel></rss>