<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: leviyi</title>
    <description>The latest articles on DEV Community by leviyi (@leviyi).</description>
    <link>https://dev.to/leviyi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4058377%2F6d12556a-1ac2-45bd-9168-f9e501e6bc19.png</url>
      <title>DEV Community: leviyi</title>
      <link>https://dev.to/leviyi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leviyi"/>
    <language>en</language>
    <item>
      <title>Syncing a rhythm game to a YouTube iframe (a metronome on someone else's player)</title>
      <dc:creator>leviyi</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:27:06 +0000</pubDate>
      <link>https://dev.to/leviyi/syncing-a-rhythm-game-to-a-youtube-iframe-a-metronome-on-someone-elses-player-3la0</link>
      <guid>https://dev.to/leviyi/syncing-a-rhythm-game-to-a-youtube-iframe-a-metronome-on-someone-elses-player-3la0</guid>
      <description>&lt;p&gt;I shipped a little browser game where the backing track is a YouTube embed and the gameplay has to land on the beat. Not "close to the beat". On it, within tens of milliseconds, or the whole thing feels broken.&lt;/p&gt;

&lt;p&gt;That sounds simple until you try it. The YouTube IFrame API gives you a video player, not a clock. This post is what broke on the way, and the three small modules that came out of it. The code is all here: &lt;a href="https://github.com/dengyu123456/yt-beat-sync" rel="noopener noreferrer"&gt;yt-beat-sync&lt;/a&gt;. No dependencies, take what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: whose clock do you trust?
&lt;/h2&gt;

&lt;p&gt;My first version ran game logic on &lt;code&gt;performance.now()&lt;/code&gt; and started the YouTube player at the same moment. Two clocks, started together. What could go wrong?&lt;/p&gt;

&lt;p&gt;Quite a lot, it turns out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The player buffers. Your clock keeps running. The beat grid slides half a second off the audio and never comes back.&lt;/li&gt;
&lt;li&gt;The user seeks. Your clock doesn't know.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getCurrentTime()&lt;/code&gt; is coarse. It updates in chunks and freezes while buffering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix felt backwards: the coarse, freezing, low-resolution player clock has to be the source of truth. Not because it's accurate, but because it's honest. When the video buffers, game time should freeze too, because that's what the player is actually hearing. Any extrapolation you layer on top drifts away from the audio and the beats visibly slide.&lt;/p&gt;

&lt;p&gt;So the game reads one clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useExternal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalTime&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;externalTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// player.getCurrentTime()&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intro&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wallStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not started yet, never return an epoch&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wallStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pausedAccum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;intro&lt;/code&gt; offset shifts t=0 to the moment the beat actually drops in the video, so the game logic can think in "seconds since the drop" and ignore however much lead-in the video has.&lt;/p&gt;

&lt;p&gt;And when the embed is blocked entirely (some networks and regions do this), the same clock falls back to wall time and the game keeps running. Degraded, but playable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: autoplay policy is a game-design problem
&lt;/h2&gt;

&lt;p&gt;Browsers won't let you play audio without a user gesture. Fair enough. The nasty part is in the details.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;AudioContext&lt;/code&gt; starts &lt;code&gt;suspended&lt;/code&gt;. You must call &lt;code&gt;resume()&lt;/code&gt; inside a gesture handler. Fine. But creating a YouTube player costs a network round-trip: the API script, the iframe, the video. If you start all of that inside the click handler, the player materializes after the gesture window closes, and mobile Safari and Chrome block the sound.&lt;/p&gt;

&lt;p&gt;The fix is to preload the API long before you need it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// on page load, not in the click handler&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;YTEngine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the play click only has to do &lt;code&gt;new YT.Player(...)&lt;/code&gt; and &lt;code&gt;playVideo()&lt;/code&gt;, which fits inside the gesture window.&lt;/p&gt;

&lt;p&gt;Android has a secret third state. Granting microphone permission (my game uses the mic) can flip the context to &lt;code&gt;'interrupted'&lt;/code&gt;. Every &lt;code&gt;state === 'suspended'&lt;/code&gt; check sails right past it. Your scheduler runs, notes get scheduled, and nothing is heard. Silently. I found this on a live Android test where the beat audio was just gone. Now a &lt;code&gt;wake()&lt;/code&gt; covers both states, hooked to &lt;code&gt;onstatechange&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;wake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 3: setInterval is not a metronome
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playClick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bpm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// do not do this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timers jitter by tens of milliseconds and get clamped to once per second in background tabs. For anything rhythm-critical that's unusable.&lt;/p&gt;

&lt;p&gt;The standard answer is a lookahead scheduler: a short-interval timer (40ms) that schedules every beat falling inside a 150ms window, with sound placed on the AudioContext clock, which is sample-accurate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;wallNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;wallNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// scheduled ahead, sample-accurate&lt;/span&gt;
    &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One wrinkle I didn't expect: the audio clock freezes while the context is suspended. If you drive visuals off the same schedule, the whole metronome starves. So visuals run on wall time. A &lt;code&gt;setTimeout&lt;/code&gt; aligned to each beat fires the CSS pulse whether or not audio is currently allowed. Sound catches up when the context resumes; the grid never stops.&lt;/p&gt;

&lt;p&gt;That separation ended up being the whole architecture: audio on the audio clock, visuals on wall time, game logic on the player's timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/dengyu123456/yt-beat-sync" rel="noopener noreferrer"&gt;github.com/dengyu123456/yt-beat-sync&lt;/a&gt; is a single page: paste a YouTube URL, set the BPM and the intro length, and a ball bounces on the beat. Buffer the video, seek around, background the tab. The grid follows the player, because everything reads the one honest clock.&lt;/p&gt;

&lt;p&gt;Three files, no dependencies: &lt;code&gt;yt-engine.js&lt;/code&gt; (API preload, singleton player with &lt;code&gt;loadVideoById&lt;/code&gt; reuse, a 12s watchdog for dead embeds), &lt;code&gt;beat-clock.js&lt;/code&gt; (the two-source clock), &lt;code&gt;metronome.js&lt;/code&gt; (the lookahead scheduler and kick/hat synths).&lt;/p&gt;

&lt;p&gt;If you're building anything that syncs to embedded media (karaoke, play-along tabs, rhythm games, video-synced quizzes), steal it.&lt;/p&gt;

&lt;p&gt;I use this in production at &lt;a href="https://rhymegame.org" rel="noopener noreferrer"&gt;The Rhyme Game&lt;/a&gt;, a free browser game where you practice freestyle rhymes over real beats. The same clock drives the bouncing ball and the bar-by-bar word drops there. If you try the demo on something weird (SoundCloud embeds, livestreams, 7/8 time), I'd be curious what breaks.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>audio</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I open-sourced a deterministic crochet chart engine — no AI hallucinations, plus an MCP server so agents can use it</title>
      <dc:creator>leviyi</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:43:26 +0000</pubDate>
      <link>https://dev.to/leviyi/i-open-sourced-a-deterministic-crochet-chart-engine-no-ai-hallucinations-plus-an-mcp-server-so-3a0k</link>
      <guid>https://dev.to/leviyi/i-open-sourced-a-deterministic-crochet-chart-engine-no-ai-hallucinations-plus-an-mcp-server-so-3a0k</guid>
      <description>&lt;p&gt;A crochet chart is a grid. Each cell is one stitch, each color is a yarn, and if row 14 says "7 blue, 3 white" then row 14 has exactly ten stitches — not eleven, never "approximately ten."&lt;/p&gt;

&lt;p&gt;That property is exactly why AI image generators are useless at making them. Ask a diffusion model for a "crochet chart of a frog" and you get something that &lt;em&gt;looks&lt;/em&gt; like a chart: wavy grid lines, cells that change size mid-row, a legend with nine colors and a frog made of eleven. Pretty. Unworkable. You can't crochet from a vibe.&lt;/p&gt;

&lt;p&gt;So the engine behind my crochet site is &lt;strong&gt;deterministic image processing&lt;/strong&gt;, and this week I open-sourced it: &lt;a href="https://github.com/dengyu123456/crochet-engine" rel="noopener noreferrer"&gt;&lt;code&gt;crochetpatterngen-engine&lt;/code&gt;&lt;/a&gt; — Python library, CLI, and MCP server. Same input, same chart, same stitch counts, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Feed it a photo, get back a stitch-ready chart PNG plus exact per-color stitch counts that sum exactly to the grid size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;crochet_chart_engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_chart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estimate_yarn&lt;/span&gt;

&lt;span class="n"&gt;png_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dog.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;technique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c2c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# graph | c2c | tapestry | mosaic | filet
&lt;/span&gt;    &lt;span class="n"&gt;grid_w&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# 20..120
&lt;/span&gt;    &lt;span class="n"&gt;n_colors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# 2..16
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# meta: {"grid_w", "grid_h", "colors": [{"hex", "count"}],
#        "total_stitches", "technique", "warning"}
&lt;/span&gt;
&lt;span class="n"&gt;yarn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_yarn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worsted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# per-color yardage, so you know how much yarn to buy before you start
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline is resize → median-cut color quantization → confetti cleanup (isolated single-stitch specks get absorbed into their neighbors, because nobody wants to change yarn for one stitch). Pillow + numpy, no heavy deps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fun bug: C2C blankets come out squashed
&lt;/h2&gt;

&lt;p&gt;The most interesting problem wasn't color — it was geometry. Corner-to-corner (C2C) crochet blocks are physically &lt;strong&gt;wider than tall&lt;/strong&gt;, roughly a 0.7:1 height-to-width ratio. A square grid of C2C blocks does not produce a square piece of fabric.&lt;/p&gt;

&lt;p&gt;Every naive photo-to-chart converter gets this wrong: your dog photo comes out vertically stretched on the actual blanket, like a funhouse mirror. The engine corrects grid dimensions by the block aspect ratio, so the &lt;em&gt;worked piece&lt;/em&gt; keeps the photo's proportions instead of the chart's.&lt;/p&gt;

&lt;p&gt;It's a one-line insight and a week of testing. That's most of applied software, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an MCP server
&lt;/h2&gt;

&lt;p&gt;The same engine also ships as an &lt;a href="https://github.com/dengyu123456/crochet-engine#mcp-server-claude-desktop--friends" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, so AI agents (Claude Desktop, Cursor, whatever comes next) can call it as a tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"crochet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uvx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--from"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crochetpatterngen-engine[mcp]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crochetpatterngen-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tools: &lt;code&gt;photo_to_chart&lt;/code&gt;, &lt;code&gt;render_ascii_design&lt;/code&gt;, &lt;code&gt;estimate_yarn_tool&lt;/code&gt;. The pitch: an agent that can say "here's your chart, 60×44, 8 colors, you'll need about 210 yards of the green" is doing something generative AI structurally can't — because the answer is &lt;em&gt;computed&lt;/em&gt;, not sampled.&lt;/p&gt;

&lt;p&gt;I think "deterministic tools behind an agent frontend" is an underrated pattern right now. Everyone is wrapping LLMs around fuzzy tasks; there's a lot of value in wrapping them around precise ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ASCII design format is my favorite part
&lt;/h2&gt;

&lt;p&gt;Charts can also come from text files — one character per stitch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;palette&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;G=#58A05C, W=#FFFFFF, .=#FAFAF7&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Frog Face&lt;/span&gt;
&lt;span class="na"&gt;technique&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;graph&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="s"&gt;..GGGG..........GGGG....&lt;/span&gt;
&lt;span class="s"&gt;.GGWWGG........GGWWGG...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hand-draw designs in a text editor and git-diff my frogs. There's something deeply satisfying about version-controllable pixel art that becomes a physical object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/dengyu123456/crochet-engine" rel="noopener noreferrer"&gt;dengyu123456/crochet-engine&lt;/a&gt; — MIT, &lt;code&gt;pytest tests/&lt;/code&gt; passes, PRs welcome&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt;: &lt;code&gt;pip install crochetpatterngen-engine&lt;/code&gt; (&lt;code&gt;[mcp]&lt;/code&gt; and &lt;code&gt;[rembg]&lt;/code&gt; extras)&lt;/li&gt;
&lt;li&gt;The hosted version with a free pattern library: &lt;a href="https://crochetpatterngen.com" rel="noopener noreferrer"&gt;crochetpatterngen.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with it — graphgans, a knitting variant, a cross-stitch port — I'd genuinely love to hear about it. And if you've ever tried to crochet from an AI-generated "chart," I owe you a beer and an apology on behalf of the industry.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>mcp</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
