<?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[Jacob Yang's AI Tools Blog]]></title><description><![CDATA[Jacob Yang's AI Tools Blog]]></description><link>https://jacobyang-ai.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/696de0211665e90c765924ed/554781a5-ee6a-4b19-b5f2-a8735e0f7093.png</url><title>Jacob Yang&apos;s AI Tools Blog</title><link>https://jacobyang-ai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 15:24:50 GMT</lastBuildDate><atom:link href="https://jacobyang-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Twitch Clip Maker in Practice: VOD Download, Highlight Heuristics, 9:16 Export]]></title><description><![CDATA[A hands-on twitch clip maker workflow: download your own Twitch VOD with yt-dlp, test audio-peak vs chat-spike heuristics, then export 9:16 with ffmpeg.]]></description><link>https://jacobyang-ai.hashnode.dev/twitch-clip-maker-in-practice-vod-download-highlight-heuristics-9-16-export</link><guid isPermaLink="true">https://jacobyang-ai.hashnode.dev/twitch-clip-maker-in-practice-vod-download-highlight-heuristics-9-16-export</guid><category><![CDATA[Tutorial]]></category><category><![CDATA[Python]]></category><category><![CDATA[FFmpeg]]></category><category><![CDATA[Twitch]]></category><dc:creator><![CDATA[Jacob Yang]]></dc:creator><pubDate>Sat, 07 Nov 2026 07:12:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/749307b7-1451-4381-8ab3-36ed9b8ec329.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Twitch Clip Maker in Practice: VOD Download, Highlight Heuristics, 9:16 Export</h1>
<p><img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/749307b7-1451-4381-8ab3-36ed9b8ec329.png" alt="Streamer at a desk reviewing a paused widescreen game VOD on a monitor while a phone shows the same moment cropped vertically" /></p>
<p>A Twitch stream is a long file with a few minutes you would actually post. The boring pipeline underneath every <a href="https://fragcut.io/tools/twitch-clip-maker?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=twitch-clip-maker">twitch clip maker</a> is the same: get the VOD, guess where the moments are, then export a vertical file.</p>
<p>I work on FragCut. The product details I mention later are the current published plan facts (first match analyzed free, pay to render clips, Creator and Pro pricing, 14-day refund, payments through Paddle). Everything measured below is from a local run on this machine: Python 3.13.5 and FFmpeg 7.1.5. I am not going to invent detection accuracy numbers, clip counts, or processing times for the hosted product.</p>
<p>This post builds the three steps so you can see what they cost in code, and where a hosted tool is simply doing the same jobs for you.</p>
<h2>1. Download your own VOD, not someone else's weekend</h2>
<p>Twitch keeps past broadcasts as video on demand for a limited time; the details live in Twitch's own <a href="https://help.twitch.tv/s/article/video-on-demand?language=en_US">Video on Demand</a> help article. Highlights and uploads follow different rules. If the VOD has expired, no clipper can invent it.</p>
<p>For a file you have the right to download — typically your own broadcast — <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a> is the straightforward CLI. The project's usage pattern is:</p>
<pre><code class="language-bash">yt-dlp -F "https://www.twitch.tv/videos/VIDEO_ID"
yt-dlp -o "vod.%(ext)s" "https://www.twitch.tv/videos/VIDEO_ID"
</code></pre>
<p><code>-F</code> lists the formats the site is offering right now. I do that first because VOD availability and muxes change. Then I download with an output template so I do not inherit a 90-character title as a filename.</p>
<p>I am not going to run yt-dlp against a live Twitch URL in this article. That would mean copying someone else's stream or republishing a large copyrighted file I do not need. Use it on recordings you are allowed to keep. If you already have the file from your own encoder or console, skip this step and go to heuristics.</p>
<p>Two practical checks after the download:</p>
<ul>
<li>Duration matches what you remember streaming. A format that resolved to an unexpected audio-only or highlight-only file will waste every later step.</li>
<li>You have either a single muxed file or a video-plus-audio pair you can join. Highlight code is easier when the timeline is one media file.</li>
</ul>
<h2>2. Two highlight heuristics, measured on purpose</h2>
<p>Scrubbing a three-hour VOD by hand works until you do it every night. Automated "this might be a clip" suggestions usually come from one of two cheap signals:</p>
<ol>
<li><strong>Audio energy.</strong> A kill, a win, a yell, or a sudden crowd of game sound raises the loudness.</li>
<li><strong>Chat rate.</strong> Viewers type more when something happens.</li>
</ol>
<p>Neither signal knows what an ace is. Both can still point you at timestamps worth opening. The honest way to test them is not a marketing clip. It is synthetic data with known answers.</p>
<h3>Audio peaks</h3>
<p>I wrote an 8-second mono WAV (16 kHz, 16-bit) with three 440 Hz beeps at 1.5 s, 4.0 s, and 6.5 s, each 180 milliseconds long, on an otherwise silent track. Then I computed RMS energy in 100 ms windows and kept local maxima at least 0.6 s apart whose energy was at least two standard deviations above the mean.</p>
<pre><code class="language-python">def local_peaks(series, z=2.0, min_gap_s=0.6):
    values = [v for _, v in series]
    mean = sum(values) / len(values)
    var = sum((v - mean) ** 2 for v in values) / len(values)
    std = var ** 0.5
    threshold = mean + z * std
    candidates = [(t, v) for t, v in series if v &gt;= threshold]
    kept = []
    for t, v in sorted(candidates, key=lambda x: -x[1]):
        if any(abs(t - kt) &lt; min_gap_s for kt, _ in kept):
            continue
        kept.append((t, v))
    return sorted(kept)
</code></pre>
<p>On this run the file produced 80 windows. The detector returned peak times at <strong>1.5 s, 4.0 s, and 6.5 s</strong> — exactly the beep onsets — each matched within 150 ms. Peak RMS was 11123.8 on all three tones, which is what you want from identical beeps.</p>
<p>That is the best case: silence, then a clean event. Real VODs are messier. Music beds, constant game loudness, and a streamer who talks through every round will raise the baseline so a shout is no longer two standard deviations above anything. A per-stream threshold, or a comparison against a rolling median instead of the global mean, is the usual next patch. I left the global mean in this article because I can show it recover known timestamps without pretending it is production-ready on a Valorant VOD.</p>
<img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/29abc635-f194-499f-b712-cfbcd1fc2dd6.png" alt="Editor looking at a computer timeline where an audio waveform shows a few tall peaks against quieter stretches" style="display:block;margin:0 auto" />

<p>If you use this on a real file, export a few seconds on either side of each peak. The interesting part of a clip is often the setup, not the peak frame itself.</p>
<h3>Chat spikes</h3>
<p>Chat is a different sensor. It lags the play (people type after they react) and it piles up for reasons that have nothing to do with a highlight: a raid, a bot command, a giveaway. It is still useful, especially when the audio is already loud.</p>
<p>I built a 30-second series with a baseline of 2 messages per second and three crowded seconds at t = 8, 17, and 24, each with 18 messages. Second 9 has a hangover of 7 messages, which is how real chat decays after a spike.</p>
<pre><code class="language-python">def chat_spikes(counts, z=2.0):
    mean = sum(counts) / len(counts)
    var = sum((c - mean) ** 2 for c in counts) / len(counts)
    std = var ** 0.5
    threshold = mean + z * std
    return [(i, c) for i, c in enumerate(counts) if c &gt;= threshold]
</code></pre>
<p>On this run the mean was <strong>3.77</strong> messages per second. The function flagged <strong>(8, 18), (17, 18), and (24, 18)</strong>. Second 9, with 7 messages, stayed under the line.</p>
<p>That is the behavior I want: clear spikes pass, the decay after a spike does not become a second clip. If your chat log is stored as timestamps rather than per-second buckets, histogram it first. Many downloaders and chat archives already give you a timestamp per message; <code>collections.Counter(int(t) for t in times)</code> is enough to feed this function.</p>
<h3>How I would combine them</h3>
<table>
<thead>
<tr>
<th>Signal</th>
<th>What it catches well</th>
<th>How it lies</th>
</tr>
</thead>
<tbody><tr>
<td>Audio RMS peaks</td>
<td>Sudden shouts, stingers, explosions against a quieter bed</td>
<td>Music beds and constant gunfire flatten the contrast</td>
</tr>
<tr>
<td>Chat-rate spikes</td>
<td>Moments viewers agree were worth typing about</td>
<td>Raids, bots, and delayed reactions shift the timestamp</td>
</tr>
<tr>
<td>Both, near each other</td>
<td>A loud moment that also moved chat</td>
<td>You still have to watch the candidates</td>
</tr>
</tbody></table>
<p>A simple merge: take audio peaks and chat spikes independently, then keep a window if either signal fires and promote a window if both fire within a second or two. I did not invent a precision number for that merge, because I did not run it on a real VOD in this sitting. The table is the claim; the synthetic recoveries are the evidence I do have.</p>
<p>The full script is in <code>code/highlight_heuristics.py</code> next to this draft. It writes the WAV, prints the matched beep times, and prints the chat spikes.</p>
<h2>3. Export 9:16 without guessing the crop filter</h2>
<p>Most VODs arrive as 16:9. Shorts, Reels, and TikTok want 9:16. The mechanical version of that conversion is: crop a vertical window out of the widescreen frame, then scale to the size you need.</p>
<p>I generated a 3-second 1920×1080 color test pattern at 30 fps with FFmpeg's <code>testsrc2</code>, then ran:</p>
<pre><code class="language-bash">ffmpeg -i src_16x9.mp4 \
  -vf "crop=ih*9/16:ih,scale=1080:1920" \
  -c:v libx264 -pix_fmt yuv420p \
  out_9x16.mp4
</code></pre>
<p><code>crop=ih*9/16:ih</code> keeps the full height and a 9:16-wide slice from the center. <code>scale=1080:1920</code> turns that slice into a 1080×1920 file. <a href="https://ffmpeg.org/ffmpeg.html">FFmpeg</a> is doing the work; the filter graph is the entire creative decision if you accept a center crop.</p>
<p><code>ffprobe</code> on the two files from this run:</p>
<table>
<thead>
<tr>
<th>File</th>
<th align="right">Width</th>
<th align="right">Height</th>
<th align="right">Duration</th>
<th>Frame rate</th>
</tr>
</thead>
<tbody><tr>
<td><code>src_16x9.mp4</code></td>
<td align="right">1920</td>
<td align="right">1080</td>
<td align="right">3.000 s</td>
<td>30/1</td>
</tr>
<tr>
<td><code>out_9x16.mp4</code></td>
<td align="right">1080</td>
<td align="right">1920</td>
<td align="right">3.000 s</td>
<td>30/1</td>
</tr>
</tbody></table>
<p>Center crop is honest and often wrong. FPS gameplay keeps the crosshair in the middle, so a center 9:16 window is a reasonable default. A talking-head cam in the top-left, or an action that happens on the far side of a wide map, will get shaved off. The next upgrade is a crop origin you choose per clip (<code>crop=w:h:x:y</code>) or a face/body tracker. I am not going to fake a tracker demo here. If the subject is not in the middle, do not pretend a default crop is "vertical-ready."</p>
<p>For a real VOD you would first cut the highlight window (<code>-ss</code> / <code>-to</code> or a stream copy into a temporary file), then crop. Doing crop-and-scale on the entire three-hour file just to post one moment is how you discover how large intermediate files can get.</p>
<h2>When I would run this myself, and when I would not</h2>
<p>I would run this pipeline when I already have the VOD locally, I care about seeing the heuristic fire on timestamps I can audit, and I want a specific crop. It is also a good way to learn why automated clippers disagree with your memory: your favorite moment may have been quiet, or chat may have exploded two seconds late.</p>
<p>I would not run it on a night when I just want candidates from a match I played and I do not want to babysit FFmpeg. That is the hosted-tool case.</p>
<p>FragCut's <a href="https://fragcut.io/tools/twitch-clip-maker?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=twitch-clip-maker">twitch clip maker</a> is the product I work on for that case. Under the current plans, the first match is analyzed free; you pay when you render clips. Creator is $19.99 a month or $199 a year and includes 30 matches a month. Pro is $49.99 a month or $499 a year with no match cap. Refunds are available for 14 days, and payment goes through Paddle.</p>
<p>I am not going to describe hidden model internals or quote processing-time claims here. If you use the hosted path, you are trading the three local steps above for a web workflow. If you use the local path, you keep every file and every timestamp in view. Both are valid. The bad version is downloading a VOD you do not have rights to, or posting a 16:9 kill without noticing that the vertical crop removed the actual play.</p>
<p>The code, the synthetic WAV, and the two test videos from this run sit in the <code>code/</code> folder beside the draft. Re-run <code>highlight_heuristics.py</code> if you change the beep times; the printout should keep matching them within 150 ms. Re-run the FFmpeg pair if you change the filter, and read <code>ffprobe</code> before you trust the filename.</p>
]]></content:encoded></item><item><title><![CDATA[MediaPipe Face Mesh Ratios: Where 7-Class Face-Shape Rules Break at the Boundaries]]></title><description><![CDATA[Run MediaPipe Face Landmarker (478 points) on a synthetic plaster bust, compute length/width, forehead, jaw and jaw-angle ratios; see where rules flip]]></description><link>https://jacobyang-ai.hashnode.dev/mediapipe-face-mesh-ratios-where-7-class-face-shape-rules-break-at-the-boundaries</link><guid isPermaLink="true">https://jacobyang-ai.hashnode.dev/mediapipe-face-mesh-ratios-where-7-class-face-shape-rules-break-at-the-boundaries</guid><category><![CDATA[Python]]></category><category><![CDATA[mediapipe]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Jacob Yang]]></dc:creator><pubDate>Thu, 05 Nov 2026 04:29:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/ed7bcf9e-c099-47b7-856e-06cdeeebe3f8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/8b9e7b7d-b4f4-4d59-a2b1-2ad0be143d97.png" alt="Close-up of a real face with a cyan-and-violet MediaPipe-style landmark mesh overlay and length/width measurement lines" style="display:block;margin:0 auto" />

<p>Face-shape apps usually hide the boring part: four ratios, a handful of thresholds, and a long list of faces that sit <em>between</em> two labels.</p>
<p>I wanted the boring part in the open. This post:</p>
<ol>
<li>Runs <strong>MediaPipe Face Landmarker</strong> (478 landmarks — the current Tasks API successor to classic Face Mesh) on a <strong>synthetic plaster-bust render</strong>, not a photo of a person.</li>
<li>Computes the same four ratio families product face-shape tools talk about: length ÷ width, forehead ÷ cheeks, jaw ÷ cheeks, jaw angle.</li>
<li>Morphs that mesh and records <strong>where a transparent 7-class rule set flips</strong> — especially the oval ↔ oblong boundary and the square ↔ triangle confusion.</li>
</ol>
<p>Every number below was measured in this run. The classifier in the repo is a <strong>demo rule set for the article</strong>, not Face Shape Haircut AI's production model. Disclosure: I work on that product; the live <a href="https://faceshapehaircut.com/face-shape-detector">face shape detector</a> is what we ship. This post is about the measurement geometry, and where brittle rules lie.</p>
<h2>Environment</h2>
<pre><code class="language-text">Python 3.13
mediapipe 1.2.2
opencv-python-headless 5.x
numpy, matplotlib
Face Landmarker model: face_landmarker.task (float16, Google storage)
</code></pre>
<p>Classic <code>mp.solutions.face_mesh</code> is gone in this MediaPipe build. The Tasks API is what you get now.</p>
<h2>Why the input image is synthetic on purpose</h2>
<p>I need a detector input that:</p>
<ul>
<li>Is obviously <strong>not a photograph of a real person</strong> (so the article can show mesh diagrams without a face photo).</li>
<li>Still has enough "face-like" structure that Face Landmarker returns a full 478-point mesh.</li>
</ul>
<p><code>make_synthetic_bust.py</code> draws a matte oval head with soft shading, dark eye sockets, a nose bridge shadow, and a mouth line. No skin texture, no identity.</p>
<pre><code class="language-python"># make_synthetic_bust.py (trimmed) — full file is in /code
import cv2, numpy as np
H, W = 800, 480
img = np.zeros((H, W, 3), np.uint8)
cx, cy = W // 2, H // 2 - 40
yy, xx = np.mgrid[0:H, 0:W]
head = ((xx - cx) / 125) ** 2 + ((yy - cy) / 210) ** 2 &lt;= 1
# ... plaster fill, soft light, abstract eye sockets, mouth line ...
cv2.imwrite("synthetic_plaster_bust.png", cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
</code></pre>
<p>MediaPipe still returned <strong>478 landmarks</strong> on that image. That surprised me the first time. It also means every downstream ratio is grounded in a real detector output, not hand-drawn points.</p>
<h2>Measuring the four ratios</h2>
<p>I used the public Face Mesh topology indices:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Index</th>
</tr>
</thead>
<tbody><tr>
<td>Forehead top (hairline center)</td>
<td>10</td>
</tr>
<tr>
<td>Chin</td>
<td>152</td>
</tr>
<tr>
<td>Left / right cheek (width)</td>
<td>234 / 454</td>
</tr>
<tr>
<td>Left / right forehead</td>
<td>54 / 284</td>
</tr>
<tr>
<td>Left / right jaw</td>
<td>172 / 397</td>
</tr>
<tr>
<td>Mid-jaw (angle helpers)</td>
<td>58 / 288</td>
</tr>
</tbody></table>
<pre><code class="language-python">def ratios_from_landmarks(lm, w, h):
    top, chin = xy(lm, 10, w, h), xy(lm, 152, w, h)
    cl, cr = xy(lm, 234, w, h), xy(lm, 454, w, h)
    fl, fr = xy(lm, 54, w, h), xy(lm, 284, w, h)
    jl, jr = xy(lm, 172, w, h), xy(lm, 397, w, h)
    length = np.linalg.norm(chin - top)
    cheek_w = np.linalg.norm(cr - cl)
    forehead_w = np.linalg.norm(fr - fl)
    jaw_w = np.linalg.norm(jr - jl)
    # jaw angle = mean of angles at jaw points (cheek→jaw→chin)
    ...
    return length/cheek_w, forehead_w/cheek_w, jaw_w/cheek_w, jaw_angle_deg
</code></pre>
<p>On the synthetic bust, Face Landmarker gave:</p>
<table>
<thead>
<tr>
<th>Ratio</th>
<th>Measured value</th>
</tr>
</thead>
<tbody><tr>
<td>length ÷ width</td>
<td><strong>1.2301</strong></td>
</tr>
<tr>
<td>forehead ÷ cheek</td>
<td><strong>0.8093</strong></td>
</tr>
<tr>
<td>jaw ÷ cheek</td>
<td><strong>0.8176</strong></td>
</tr>
<tr>
<td>jaw angle</td>
<td><strong>135.27°</strong></td>
</tr>
</tbody></table>
<p>Under the demo rules (below), that lands on <strong>round</strong>. Visually the bust is a short-ish oval head — fair enough. The interesting part is what happens when we stretch it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/21386e55-a688-487b-822f-5cf3b83a7368.png" alt="478 MediaPipe landmarks plotted on a dark canvas with length and cheek-width measurement lines highlighted" style="display:block;margin:0 auto" />

<h2>A transparent 7-class rule set (for probing, not shipping)</h2>
<p>Production classifiers are usually messy ensembles. For this article I want something you can argue with:</p>
<ul>
<li><strong>Oblong</strong> if length/width ≳ 1.45 and the top-to-bottom widths are similar.</li>
<li><strong>Oval</strong> if length/width sits roughly 1.30–1.45 with balanced widths.</li>
<li><strong>Round</strong> if length/width is short (&lt; ~1.28) with full cheeks.</li>
<li><strong>Square</strong> if the jaw is wide and the angle is sharper.</li>
<li><strong>Heart</strong> if the forehead is wide and the jaw is narrow.</li>
<li><strong>Diamond</strong> if forehead <em>and</em> jaw are narrow relative to the cheeks.</li>
<li><strong>Triangle</strong> if the jaw is clearly wider than the forehead.</li>
</ul>
<p>When two scores are close, the code records a <strong>leaning</strong> label. That matches how "between two shapes" shows up in real faces.</p>
<p>Again: this is <strong>not</strong> the production <a href="https://faceshapehaircut.com/face-shape-detector">face shape detector</a>. It's a ruler for finding brittle edges.</p>
<h2>Experiment 1: stretch only the length</h2>
<p>I took the detected 478 points and scaled every landmark's vertical distance from the mid-face by a factor <code>s</code> from 0.90 to 1.80, leaving horizontal structure alone. Forehead÷cheek and jaw÷cheek stayed at <strong>0.809 / 0.818</strong> the whole way. Only length ÷ width moved.</p>
<p>Measured transitions:</p>
<table>
<thead>
<tr>
<th>length scale</th>
<th>length ÷ width</th>
<th>demo class</th>
</tr>
</thead>
<tbody><tr>
<td>1.00 (raw detect)</td>
<td>1.230</td>
<td>round</td>
</tr>
<tr>
<td>1.06</td>
<td>1.304</td>
<td>diamond leaning oval</td>
</tr>
<tr>
<td>1.12</td>
<td>1.378</td>
<td>diamond leaning oval</td>
</tr>
<tr>
<td>1.16</td>
<td>1.427</td>
<td>diamond leaning oblong</td>
</tr>
<tr>
<td><strong>1.18</strong></td>
<td><strong>1.452</strong></td>
<td><strong>oblong</strong></td>
</tr>
<tr>
<td>1.22</td>
<td>1.501</td>
<td>oblong</td>
</tr>
</tbody></table>
<p>The oval ↔ oblong gate in these rules is <strong>1.45</strong>. The flip happened at scale <strong>1.18</strong>, where length ÷ width crossed from <strong>1.4269 → 1.4515</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/3e887b78-fbbf-4a19-90bd-7ae1b57f0273.png" alt="Line chart of length-over-width versus vertical morph scale, with an oblong gate at 1.45 and shaded class regions" style="display:block;margin:0 auto" />

<h3>The misclassification that matters</h3>
<p>Between scale 1.06 and 1.16, the demo rules did <strong>not</strong> say oval. They said <strong>diamond leaning oval/oblong</strong>.</p>
<p>Why? Because the synthetic bust's forehead and jaw were already narrow relative to the cheeks (0.81). When you only add length, you enter the oval/oblong length band <em>while still looking "narrow top and bottom"</em> to a diamond rule. The label flips through diamond as an artifact of a length-only morph.</p>
<p>That's the first boundary bug: <strong>a length threshold and a width-ratio rule can fight each other.</strong> A face that is simply "a bit longer than average" with slightly narrow forehead/jaw gets called diamond until length finally overwhelms everything else.</p>
<h2>Experiment 2: directed morphs toward each class</h2>
<p>I also pushed the same mesh toward each of the seven labels with combined length and width-band morphs. Measured results:</p>
<table>
<thead>
<tr>
<th>Morph intent</th>
<th>L/W</th>
<th>forehead÷cheek</th>
<th>jaw÷cheek</th>
<th>angle°</th>
<th>demo class</th>
</tr>
</thead>
<tbody><tr>
<td>toward oblong</td>
<td>1.907</td>
<td>0.809</td>
<td>0.818</td>
<td>144.3</td>
<td>oblong</td>
</tr>
<tr>
<td>toward round</td>
<td>1.002</td>
<td>0.749</td>
<td>0.757</td>
<td>139.2</td>
<td>round</td>
</tr>
<tr>
<td>toward square</td>
<td>1.292</td>
<td>0.809</td>
<td>0.997</td>
<td>128.4</td>
<td>square leaning triangle</td>
</tr>
<tr>
<td>toward heart</td>
<td>1.378</td>
<td>0.955</td>
<td>0.638</td>
<td>155.7</td>
<td>heart</td>
</tr>
<tr>
<td>toward diamond</td>
<td>1.402</td>
<td>0.647</td>
<td>0.621</td>
<td>157.6</td>
<td>diamond leaning oval</td>
</tr>
<tr>
<td>toward triangle</td>
<td>1.329</td>
<td>0.664</td>
<td>0.997</td>
<td>119.0</td>
<td><strong>square leaning triangle</strong></td>
</tr>
<tr>
<td>toward oval</td>
<td>1.378</td>
<td>0.809</td>
<td>0.818</td>
<td>137.4</td>
<td><strong>diamond leaning oval</strong></td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/22a6c628-f53e-4bb4-8478-48c36f1879d7.png" alt="Bar chart of length-over-width for each directed morph with predicted class labels on each bar" style="display:block;margin:0 auto" />

<p>Two failures are useful:</p>
<ol>
<li><code>toward_oval</code> <strong>→ diamond leaning oval.</strong> I only lengthened the mesh into the oval band. I didn't widen the forehead/jaw toward balance. The diamond rule still fired. "Make it oval" is not a single-axis edit.</li>
<li><code>toward_triangle</code> <strong>→ square leaning triangle.</strong> Wide jaw + narrow forehead should be triangle, but a sharp jaw angle also scores square. On this morph the square score won, with triangle as the lean. Square ↔ triangle is a real boundary; a single winner label is dishonest here.</li>
</ol>
<p>Heart and diamond morphs that also moved length into the 1.37–1.40 band behaved. Oblong and round were easy once length was extreme enough. The fragile middle is the point.</p>
<h2>Running it yourself</h2>
<pre><code class="language-bash">python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install mediapipe opencv-python-headless numpy matplotlib
# download face_landmarker.task into code/models/
python make_synthetic_bust.py
python face_ratios.py
</code></pre>
<p><code>face_ratios.py</code> writes <code>results.json</code>, the mesh plot, and both charts. Full source is next to this post under <code>code/</code>.</p>
<h2>What I would not conclude</h2>
<ul>
<li>These thresholds are <strong>not</strong> universal anthropometry. They're a probe.</li>
<li>A synthetic bust is easier than a real selfie: no glasses, no hair covering landmarks, no expression.</li>
<li>MediaPipe's landmark indices are stable; <strong>your</strong> ratio definitions (which point counts as "forehead width"?) will move the numbers. Document the indices.</li>
<li>Production systems that show a "leaning" second shape are doing the user a favor. A hard seven-way argmax is where support tickets come from.</li>
</ul>
<h2>Why this matters for a face shape detector</h2>
<p>If you only ship a single label, boundary faces get a confident wrong answer. If you ship the four ratios next to the label — length ÷ width, forehead, jaw, jaw angle — the user can see <em>why</em> the model hesitated.</p>
<p>That's the product stance I care about: measure first, label second, and admit when two shapes are close. The live tool is the <a href="https://faceshapehaircut.com/face-shape-detector">face shape detector</a>; this post is the geometry underneath, run on a synthetic mesh so the numbers stay reproducible.</p>
<p>If you re-run the sweep on a real selfie (your own), I'd be curious where your oval ↔ oblong flip lands, and whether diamond shows up as a false bridge the way it did here.</p>
]]></content:encoded></item><item><title><![CDATA[Instagram Reel Transcript to Ad Structure: Hook, CTA and Pace in 40 Lines of Python]]></title><description><![CDATA[Transcribing is the easy half. Finding where the hook ends and the ask begins is the useful one.]]></description><link>https://jacobyang-ai.hashnode.dev/instagram-reel-transcript-to-ad-structure-hook-cta-and-pace-in-40-lines-of-python</link><guid isPermaLink="true">https://jacobyang-ai.hashnode.dev/instagram-reel-transcript-to-ad-structure-hook-cta-and-pace-in-40-lines-of-python</guid><category><![CDATA[Python]]></category><category><![CDATA[openai whisper]]></category><category><![CDATA[marketing]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[Jacob Yang]]></dc:creator><pubDate>Sun, 01 Nov 2026 05:02:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/5eda47fc-4518-411f-b90e-a87da236d93a.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A transcript of an Instagram Reel is a block of text, and a block of text isn't what a marketer actually wants. They want to know what was said in the first three seconds, where the ask starts, and how fast the person was talking. Those three numbers are what separate a Reel that holds people from one that doesn't, and none of them can be read off a plain transcript without scrolling back and forth through the video.</p>
<p>So this post goes one step past "speech to text." It takes Reel video files you already have, transcribes them with word-level timestamps, and pulls out the hook, the call to action and the speaking pace. I tested it on three short ad-style clips with three Whisper model sizes. The smallest model made a mistake I wouldn't have caught by skimming, and the largest one broke my first version of the code.</p>
<h2>Where the video files come from</h2>
<p>I'm not covering downloading Reels here. Instagram doesn't offer a public download API, scraping it means logging in with an account, and that conflicts with its terms. The files this script expects are ones you're entitled to have: your own Reels, UGC videos creators deliver to your brand before posting, or clips from a client's account that they handed over.</p>
<p>For reproducible tests I made three 16-second vertical clips myself. I wrote three short ad scripts (a water bottle, sunscreen tips, running shoes), voiced them with the open-source Piper TTS engine, and wrapped the audio in a 1080Ã—1920 video with ffmpeg:</p>
<pre><code class="language-bash">echo "$SCRIPT" | piper -m en_US-lessac-medium.onnx -f bottle.wav
ffmpeg -f lavfi -i color=c=0x223344:s=1080x1920:r=30 -i bottle.wav \
  -shortest -c:v libx264 -tune stillimage -c:a aac -b:a 128k reel_bottle.mp4
</code></pre>
<p>Synthetic voices are cleaner than a creator talking over music, so treat the accuracy here as a best case. The point of the clips is that I know exactly what was said in each one, which makes it possible to grade the output.</p>
<h2>Transcribing with word timestamps</h2>
<p>I used <a href="https://github.com/SYSTRAN/faster-whisper">faster-whisper</a>, a CTranslate2 reimplementation of Whisper that runs well on CPU with int8 weights. The one setting that matters here is <code>word_timestamps=True</code>. Segment-level timestamps tell you roughly when a sentence was spoken; word-level ones let you say "the ask starts at 12.5 seconds."</p>
<pre><code class="language-python">import json, re, subprocess, sys, time
import numpy as np
from faster_whisper import WhisperModel

CTA = re.compile(r"\b(comment|save this|send this|link in (my )?bio|follow for|"
                 r"tap the link|dm me|share this)\b", re.I)
HOOK_SECONDS = 3.0

def load_audio(video):
    """ffmpeg -&gt; 16 kHz mono float32 in memory; no temp files, no PyAV."""
    raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", video, "-vn",
                          "-ac", "1", "-ar", "16000", "-f", "f32le", "-"],
                         capture_output=True, check=True).stdout
    return np.frombuffer(raw, np.float32)

def transcribe(model, video):
    segments, info = model.transcribe(load_audio(video), word_timestamps=True,
                                      vad_filter=True, beam_size=5)
    words = [w for s in segments for w in s.words]
    return words, info
</code></pre>
<p><code>load_audio</code> exists because of an error. faster-whisper decodes files with PyAV, and the PyAV version in my environment rejected an argument faster-whisper passes (<code>metadata_errors</code>), so every call failed before transcribing anything. Piping raw float32 samples out of ffmpeg and passing the NumPy array directly avoids PyAV entirely. It also handles any container ffmpeg can read, which matters when people send you <code>.mov</code> files from iPhones.</p>
<h2>Pulling out the structure</h2>
<p>The regex is a short list of the calls to action Reels actually use. Very few say "buy now"; the common ones are engagement asks like <em>comment a word</em>, <em>save this</em>, <em>send this to someone</em>, or <em>link in bio</em>. You'll want to extend the list for your niche.</p>
<p>My first version split the transcript into sentences at periods and question marks, then flagged any sentence that matched the regex. It worked with the tiny model. With the larger ones, it reported the running-shoes CTA as starting at 6.3 seconds (base.en) and then at 0.0 seconds (small.en), because those models returned the whole clip as one long sentence held together with commas. Whisper's punctuation is a guess, and bigger models don't guess the same way.</p>
<p>The version that works ignores sentences. It builds the text word by word, records which word every character came from, and maps the regex match back to that word's timestamp:</p>
<pre><code class="language-python">def structure(words):
    text, owner = "", []
    for i, w in enumerate(words):
        text += w.word
        owner += [i] * len(w.word)
    hook = "".join(w.word for w in words if w.start &lt; HOOK_SECONDS).strip()
    m = CTA.search(text)
    cta_at = words[owner[m.start()]].start if m else None
    cta = text[m.start():].strip() if m else ""
    dur = words[-1].end - words[0].start
    return {"hook_first_3s": hook, "cta": cta,
            "cta_starts_at": round(cta_at, 1) if m else None,
            "words": len(words), "speech_seconds": round(dur, 1),
            "words_per_minute": round(len(words) / dur * 60),
            "transcript": text.strip()}
</code></pre>
<h2>Results</h2>
<p>All runs on an 8-vCPU cloud machine, CPU only, int8. Processing time is per clip and doesn't include loading the model.</p>
<table>
<thead>
<tr>
<th>Clip</th>
<th>Model</th>
<th>CTA found</th>
<th>CTA starts</th>
<th align="right">Pace</th>
<th align="right">Time</th>
</tr>
</thead>
<tbody><tr>
<td>Bottle</td>
<td>tiny.en</td>
<td>"Comment bottle and I'll send you the link."</td>
<td>13.4 s</td>
<td align="right">216 wpm</td>
<td align="right">0.47 s</td>
</tr>
<tr>
<td>Bottle</td>
<td>small.en</td>
<td>same</td>
<td>13.0 s</td>
<td align="right">217 wpm</td>
<td align="right">1.34 s</td>
</tr>
<tr>
<td>Sunscreen</td>
<td>tiny.en</td>
<td>"save this for your next pharmacy run."</td>
<td>14.3 s</td>
<td align="right">194 wpm</td>
<td align="right">0.44 s</td>
</tr>
<tr>
<td>Sunscreen</td>
<td>small.en</td>
<td>same</td>
<td>14.2 s</td>
<td align="right">192 wpm</td>
<td align="right">1.29 s</td>
</tr>
<tr>
<td>Shoes</td>
<td>tiny.en</td>
<td>"Send this to the friend who keeps quitting running."</td>
<td>12.6 s</td>
<td align="right">225 wpm</td>
<td align="right">0.50 s</td>
</tr>
<tr>
<td>Shoes</td>
<td>small.en</td>
<td>same</td>
<td>12.5 s</td>
<td align="right">229 wpm</td>
<td align="right">1.23 s</td>
</tr>
</tbody></table>
<p>After the fix, all three model sizes find the same CTA in every clip, and their start times agree within half a second. The hooks came out as the full opening line in each case ("Okay, stop scrolling if your water bottle smells weird."), which is a useful check in itself: if your first sentence doesn't fit in three seconds at normal pace, it isn't a hook.</p>
<p>Pace was the number I found most useful. All three scripts sit around 190â€“230 words per minute, well above typical conversation. That's normal for short-form video, and it's also why Reels without captions lose people.</p>
<h2>The mistake that mattered</h2>
<p>tiny.en is the fastest, and on structure it was as good as the others. But in the running-shoes clip, the line "I'm not a doctor, so talk to someone if it's bad" came out as:</p>
<blockquote>
<p>I met a doctor, so talk to someone if it's bad.</p>
</blockquote>
<p>One wrong word flips a disclaimer into a claim. If you're transcribing a creator's video to check it against your brand's health or legal guidelines, that's the exact sentence you needed to get right. base.en and small.en both got it right. My rule after this: use tiny for bulk research where you only want structure, and something bigger whenever the transcript itself will be read for compliance or quoted back to a creator.</p>
<p>The other differences were cosmetic: "Ok" versus "Okay," "4 months" versus "four months," and "1." versus "One," for the numbered list. Normalize numbers before you compare transcripts across models, or you'll count those as errors.</p>
<h2>When you don't have the file</h2>
<p>All of this assumes the video is on your disk. For research on public Reels from other brands, you usually just have a link. That's the case where a hosted tool is easier: UGCfy AI runs a free <a href="https://ugcfy.ai/tools/instagram-transcript-generator?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=instagram-reel-transcript">instagram reel transcript</a> generator that takes a public Reel URL. It reads the captions when the Reel has them and falls back to transcribing the audio when it doesn't. There's no signup, with a small daily cap on the free tier. You can then paste that text into the <code>structure()</code> function above and get the same hook, CTA and pace breakdown.</p>
<h2>What I'd add next</h2>
<ul>
<li>A second pass over the hook that flags the common formats (question, "stop scrolling," a numbered promise like "three things") so you can count which ones a competitor leans on.</li>
<li>Silence detection between hook and body. A long pause right after the hook is usually a cut, and cuts are worth counting.</li>
<li>Running the CTA regex on the on-screen caption text as well, because a lot of Reels put the ask on screen and never say it.</li>
</ul>
<p>The transcript is the raw material. The timestamps are what make it something you can compare across fifty videos.</p>
]]></content:encoded></item><item><title><![CDATA[How AI Is Helping Gaming Content Creators Post More Without Burning Out]]></title><description><![CDATA[Gaming content creation has a burnout problem. The creators who do it well — the ones posting consistently on YouTube, TikTok, and Twitch highlights — are…]]></description><link>https://jacobyang-ai.hashnode.dev/how-ai-is-helping-gaming-content-creators-post-more-without-burning-out</link><guid isPermaLink="true">https://jacobyang-ai.hashnode.dev/how-ai-is-helping-gaming-content-creators-post-more-without-burning-out</guid><dc:creator><![CDATA[Jacob Yang]]></dc:creator><pubDate>Fri, 27 Mar 2026 04:03:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/696de0211665e90c765924ed/fd023c7b-3e3e-4cfa-b4bc-33377692ce8f.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Gaming content creation has a burnout problem. The creators who do it well — the ones posting consistently on YouTube, TikTok, and Twitch highlights — are often spending as much time editing as they are playing. Record a two-hour session, spend four hours cutting it into something watchable, post, repeat. It's not sustainable.</p>
<p>This is where AI clip generation tools are starting to make a real difference.</p>
<h2>The editing bottleneck</h2>
<p>Most gaming content isn't actually that hard to record. The hard part is turning raw footage into something worth watching. You need to identify the moments that matter — the clutch plays, the funny fails, the close calls — cut the dead time between them, pace the edits so the video feels energetic, and add captions and music.</p>
<p>For a two-hour session, this takes two to six hours depending on skill level. For full-time creators, that's just the cost of doing business. For part-time creators with jobs, families, or school, it's often the thing that kills the habit.</p>
<h2>What AI clip generation does</h2>
<p>Tools like <a href="https://fragcut.io">FragCut</a> are designed specifically for this problem. The AI analyzes your footage and automatically identifies the interesting moments — kills, deaths, streaks, comebacks, high-action sequences — then assembles them into a polished highlight reel.</p>
<p>It's not a raw clip dump. The AI handles pacing, trims dead time before and after each moment, and orders clips for impact. Some tools add captions and background music automatically. What a skilled editor would spend an hour producing can be done in minutes.</p>
<h2>Who this is actually for</h2>
<p>The technology has a clear sweet spot.</p>
<p>Part-time creators with full-time jobs are the obvious fit. "I'll post when I have time to edit" becomes "I can post tonight." Streamers who want to repurpose three-hour streams into 90-second TikToks get a workflow that was previously either expensive to outsource or impossible to sustain solo. New creators who aren't sure which moments are worth keeping get a useful starting point.</p>
<p>Full-time professionals who want complete creative control over every cut will still edit manually. AI tools aren't trying to replace them.</p>
<h2>The consistency advantage</h2>
<p>The biggest benefit isn't the time savings on any single video — it's what consistent posting does over time.</p>
<p>Algorithms reward regular uploads. Audiences expect them. But "post consistently" is advice that's easy to give and hard to follow when every video requires four hours of editing. When the time from "finished playing" to "posted clip" drops to twenty minutes, posting three times a week becomes feasible for someone who couldn't manage once a week before.</p>
<p>For creators serious about growing their presence, that's the real value.</p>
<h2>Getting started</h2>
<p>If you've been putting off posting because editing feels like too much work, the barrier to entry with AI tools is low. Most let you start with just an upload.</p>
<p>The time savings become obvious immediately.</p>
]]></content:encoded></item></channel></rss>