<?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: Convertilo</title>
    <description>The latest articles on DEV Community by Convertilo (@convertilo).</description>
    <link>https://dev.to/convertilo</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%2F3941096%2Fe9bb21dc-8d70-4a28-beba-c9e1dfe3fb61.png</url>
      <title>DEV Community: Convertilo</title>
      <link>https://dev.to/convertilo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/convertilo"/>
    <language>en</language>
    <item>
      <title>In-browser machine translation: I measured 30 phrases, found 30% wrong in meaning, and changed the engine</title>
      <dc:creator>Convertilo</dc:creator>
      <pubDate>Wed, 30 Sep 2026 22:30:05 +0000</pubDate>
      <link>https://dev.to/convertilo/in-browser-machine-translation-i-measured-30-phrases-found-30-wrong-in-meaning-and-changed-the-1146</link>
      <guid>https://dev.to/convertilo/in-browser-machine-translation-i-measured-30-phrases-found-30-wrong-in-meaning-and-changed-the-1146</guid>
      <description>&lt;p&gt;Our free translator ran a small machine-translation model directly in the browser (a Bergamot / Opus-MT style model compiled to WebAssembly). The appeal is privacy: the text never leaves the device. The downside is quality. I finally stopped guessing and measured it on the live site with 30 phrases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;30 phrases in both directions between English and Russian, grouped by type. I marked each result as correct, correct with small flaws, or wrong in meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 43% correct, 26% with small flaws, 30% with a changed meaning.&lt;/strong&gt; Speed was fine: the first translation takes about 11 s (model download and load), after that about 1.7 s per phrase.&lt;/p&gt;

&lt;p&gt;What worked and what did not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connected prose and business text: good, in both directions.&lt;/li&gt;
&lt;li&gt;Technical text: it invented words ("driver" became "водитель" (a vehicle driver) instead of a software driver).&lt;/li&gt;
&lt;li&gt;Idioms and casual speech: &lt;strong&gt;5 out of 5 failed&lt;/strong&gt;. "I'll hit you up" came out as "I will hit you", which reads as a threat.&lt;/li&gt;
&lt;li&gt;Ambiguity: "the bank refused the loan" was inverted.&lt;/li&gt;
&lt;li&gt;A legal sentence, "shall not disclose ... to any third party", came out with the direction of the prohibition reversed. For a translator that advertises use on contracts, that was the one that worried me most.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is simple: it fails exactly on what a random visitor tries first, an idiom or a casual phrase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying LLMs as a replacement
&lt;/h2&gt;

&lt;p&gt;I ran the same 30 phrases through 8 models over an API (240 requests). The whole benchmark cost about $0.013. The hard set was 17 phrases where the in-browser model had failed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Hard phrases right (of 17)&lt;/th&gt;
&lt;th&gt;Median latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 26B (instruction-tuned)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1.46 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1.06 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 3.3 70B&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0.92 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4 Flash&lt;/td&gt;
&lt;td&gt;13 (+2 empty)&lt;/td&gt;
&lt;td&gt;2.85 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 30B A3B&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;1.96 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things surprised me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The cheapest model was the worst.&lt;/strong&gt; Qwen3 30B had the lowest price and the lowest score. It translated "Klöße" as cutlets and "Vorspeisen" as first courses. "Newer and bigger" is not the same as "better for this task"; I had bet on Qwen before measuring and was wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning models are a trap for translation.&lt;/strong&gt; One of them returned empty text for all 30 phrases, because it spent the whole token budget on thinking and never reached the answer. Raising the limit just means paying for the most expensive tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I shipped the winner. The cost came out to roughly $0.16 per million source characters, and it fixed the threatening "hit you" case and the inverted legal sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off I had to accept
&lt;/h2&gt;

&lt;p&gt;Moving translation to a server model means the text now leaves the device, which was the original selling point. The client falls back to the local model if the server call fails. If privacy is your main constraint, the small local model is still the right tool. If quality on everyday text matters more, it is not good enough.&lt;/p&gt;

&lt;p&gt;You can compare for yourself: the &lt;a href="https://convertilo.io/translate/text/" rel="noopener noreferrer"&gt;browser translator&lt;/a&gt; and the &lt;a href="https://convertilo.ru/translate/en-ru/" rel="noopener noreferrer"&gt;English to Russian page&lt;/a&gt;. What phrases break your favourite translator?&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
    <item>
      <title>TensorFlow.js in the browser: why one new tensor shape cost 8-17 seconds, and how I cut a 40 s freeze</title>
      <dc:creator>Convertilo</dc:creator>
      <pubDate>Wed, 30 Sep 2026 22:17:52 +0000</pubDate>
      <link>https://dev.to/convertilo/tensorflowjs-in-the-browser-why-one-new-tensor-shape-cost-8-17-seconds-and-how-i-cut-a-40-s-4ho3</link>
      <guid>https://dev.to/convertilo/tensorflowjs-in-the-browser-why-one-new-tensor-shape-cost-8-17-seconds-and-how-i-cut-a-40-s-4ho3</guid>
      <description>&lt;p&gt;I run a photo enhancer that does super-resolution fully in the browser: TensorFlow.js on WebGL, nothing is uploaded. The first version used the &lt;code&gt;upscaler&lt;/code&gt; library (UpscalerJS). On a 1.2 megapixel photo it took 60-110 seconds and froze the page for about 40 seconds. A user told me "it does not accept any of my photos", which was partly a bad 0.6 MP limit on my side and partly the freeze. Here is what I found while fixing it. Most of it applies to any browser-side ML on WebGL.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Every new tensor shape recompiles the shaders
&lt;/h2&gt;

&lt;p&gt;tfjs compiles WebGL programs per operation &lt;em&gt;and per tensor shape&lt;/em&gt;. On my machine (an M2 Max) one new shape cost 8-17 seconds of shader compilation. The killer was the edge tiles: I cut the image into tiles, and the last row and column had a different size, so the model was compiled again for them.&lt;/p&gt;

&lt;p&gt;What fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make every tile exactly the same shape. Pad the image once to a whole number of tiles instead of handling a smaller remainder.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;WEBGL_USE_SHAPES_UNIFORMS=true&lt;/code&gt; so shapes are passed as uniforms and programs can be reused across shapes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Compilation is synchronous and blocks the page
&lt;/h2&gt;

&lt;p&gt;Even with the same shape, the first compile blocks the main thread. tfjs 4.11 has a way to compile ahead of time without running the model: use &lt;code&gt;ENGINE_COMPILE_ONLY&lt;/code&gt;, then &lt;code&gt;backend.checkCompileCompletionAsync()&lt;/code&gt; and &lt;code&gt;getUniformLocations()&lt;/code&gt;. That moves the wait out of a frozen page and lets you show a progress state.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. im2col convolutions can eat your video memory
&lt;/h2&gt;

&lt;p&gt;The default convolution path uses im2col. For a 5x5 kernel with 64 channels on a 280x280 tile it needed about 500 MB of GPU memory in my case. Setting &lt;code&gt;WEBGL_CONV_IM2COL=false&lt;/code&gt; gave the same speed with a peak of roughly 100-200 MB. That is the difference between working on a phone and crashing the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Smaller things that mattered
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;tfjs keeps freed textures around. Set &lt;code&gt;WEBGL_DELETE_TEXTURE_THRESHOLD&lt;/code&gt; so the pool does not grow without limit.&lt;/li&gt;
&lt;li&gt;Read each result tile with &lt;code&gt;await tf.browser.toPixels(...)&lt;/code&gt; and draw it on a canvas straight away. Do not stitch tensors together and do not go through base64.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;upscaler.js&lt;/code&gt; never gives control back to the browser without &lt;code&gt;awaitNextFrame&lt;/code&gt;, and with it it gets even slower. I dropped the wrapper and load the model directly with &lt;code&gt;tf.loadLayersModel&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Numbers after the fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 MP photo&lt;/td&gt;
&lt;td&gt;4-7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 MP photo (downscaled to 4 MP input, 16 MP output)&lt;/td&gt;
&lt;td&gt;16-24 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 16 MP output is the ceiling because of the canvas size limit on iOS. I have not tested low-end devices yet, so treat these numbers as "a recent laptop".&lt;/p&gt;

&lt;p&gt;If you want to try the result, the &lt;a href="https://convertilo.ru/images/enhance/" rel="noopener noreferrer"&gt;photo enhancer&lt;/a&gt; is live (the interface is in Russian, but the upload and result are self-explanatory). Has anyone else measured shader compile time on other GPUs?&lt;/p&gt;

</description>
      <category>tensorflow</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Implementing the Destiny Matrix in TypeScript: reducing a birth date to Major Arcana</title>
      <dc:creator>Convertilo</dc:creator>
      <pubDate>Wed, 30 Sep 2026 20:22:42 +0000</pubDate>
      <link>https://dev.to/convertilo/implementing-the-destiny-matrix-in-typescript-reducing-a-birth-date-to-major-arcana-38mi</link>
      <guid>https://dev.to/convertilo/implementing-the-destiny-matrix-in-typescript-reducing-a-birth-date-to-major-arcana-38mi</guid>
      <description>&lt;p&gt;The "Destiny Matrix" is a numerology-style system that went viral on social media: you enter a birth date and get a chart of numbers from 1 to 22, each one mapped to a Major Arcana card. To be clear up front, it is a tradition and entertainment, not science. But the calculation itself is a nice small algorithm problem, and most online explanations are vague about it. Here is the exact logic I use in a free calculator, in TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: reduce any number to 1..22
&lt;/h2&gt;

&lt;p&gt;Everything is built on one helper. Keep summing the digits until the value is 22 or less. The only special case is zero, which becomes 22 (the last arcana, the Fool).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v&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;abs&lt;/span&gt;&lt;span class="p"&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;trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&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;abs&lt;/span&gt;&lt;span class="p"&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;trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that a value like 19 or 22 is left alone (it is already valid), while 29 becomes 11 and 36 becomes 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the four main points
&lt;/h2&gt;

&lt;p&gt;Take the day, month and year of birth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;left point &lt;code&gt;a&lt;/code&gt; = &lt;code&gt;reduceArcana(day)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;top point &lt;code&gt;b&lt;/code&gt; = &lt;code&gt;reduceArcana(month)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;right point &lt;code&gt;c&lt;/code&gt; = &lt;code&gt;reduceArcana(sumDigits(year))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bottom point &lt;code&gt;d&lt;/code&gt; = &lt;code&gt;reduceArcana(a + b + c)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The year is handled in two steps: first the digit sum (1990 gives 19), then the reduction to 1..22.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: center and corners
&lt;/h2&gt;

&lt;p&gt;The center is the sum of all four main points, and the diagonal corners are sums of neighbours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&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;corners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;topLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;topRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;bottomRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;bottomLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reduceArcana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;a&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;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;For 14 March 1990:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Point&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;left&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;1+9+9+0 = 19&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bottom&lt;/td&gt;
&lt;td&gt;14+3+19 = 36 -&amp;gt; 9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;center&lt;/td&gt;
&lt;td&gt;14+3+19+9 = 45 -&amp;gt; 9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-left&lt;/td&gt;
&lt;td&gt;14+3 = 17&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-right&lt;/td&gt;
&lt;td&gt;3+19 = 22&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bottom-right&lt;/td&gt;
&lt;td&gt;19+9 = 28 -&amp;gt; 10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bottom-left&lt;/td&gt;
&lt;td&gt;9+14 = 23 -&amp;gt; 5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Edge cases worth a unit test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;29.11.2000&lt;/code&gt;: day 29 reduces to 11, month 11 stays 11, year 2000 gives digit sum 2. Bottom is 6 and center is 3.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01.01.2000&lt;/code&gt;: all small numbers, bottom 4, center 8.&lt;/li&gt;
&lt;li&gt;A sum that lands exactly on 22 must stay 22, not become 4. This is the usual bug when people reduce with &lt;code&gt;% 9&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never reduce with modulo 9. It turns 18 into 0 and breaks the 10-22 range that the system needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you just want to see the chart drawn for a date, the &lt;a href="https://convertilo.io/calculators/destiny-matrix/" rel="noopener noreferrer"&gt;destiny matrix calculator&lt;/a&gt; uses exactly the functions above and runs fully in the browser, so the birth date never leaves your device.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>ffmpeg.wasm: "memory access out of bounds" is usually not about memory (and 4 more slideshow gotchas)</title>
      <dc:creator>Convertilo</dc:creator>
      <pubDate>Wed, 30 Sep 2026 20:20:19 +0000</pubDate>
      <link>https://dev.to/convertilo/ffmpegwasm-memory-access-out-of-bounds-is-usually-not-about-memory-and-4-more-slideshow-16kj</link>
      <guid>https://dev.to/convertilo/ffmpegwasm-memory-access-out-of-bounds-is-usually-not-about-memory-and-4-more-slideshow-16kj</guid>
      <description>&lt;p&gt;I spent three iterations getting a browser-only "photos to video" tool working on top of ffmpeg.wasm. Everything runs client-side, nothing is uploaded. Most of the time went into five things that the docs and error messages don't tell you. Sharing them in case they save you an evening.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it is a free &lt;a href="https://convertilo.io/video/slideshow/" rel="noopener noreferrer"&gt;photo slideshow maker&lt;/a&gt; that builds the MP4 in your tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;memory access out of bounds&lt;/code&gt; is just how an abort looks from outside
&lt;/h2&gt;

&lt;p&gt;When the ffmpeg core crashes, the error that reaches JavaScript is &lt;code&gt;RuntimeError: memory access out of bounds&lt;/code&gt;. It looks like an out-of-memory problem. In my case it never was. Any abort inside the core is reported this way.&lt;/p&gt;

&lt;p&gt;The real reason is only in the engine's own log. Attach a log handler before you run anything:&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="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;log&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="nx"&gt;message&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[ffmpeg]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that, you are guessing. With it, the next two items took minutes instead of hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The &lt;code&gt;image2&lt;/code&gt; muxer needs &lt;code&gt;-update 1&lt;/code&gt; for a single image
&lt;/h2&gt;

&lt;p&gt;If you write one frame to disk (for example to extract a poster), &lt;code&gt;image2&lt;/code&gt; treats the output name as a sequence pattern and fails. Add &lt;code&gt;-update 1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 1 &lt;span class="nt"&gt;-update&lt;/span&gt; 1 poster.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Prepare frames on a canvas, not in ffmpeg
&lt;/h2&gt;

&lt;p&gt;My first version pushed every photo through ffmpeg for scaling and letterboxing. After fixing 1 and 2 the core still crashed while writing images. Moving frame preparation to canvas turned out to be better on every axis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one ffmpeg command instead of N+1;&lt;/li&gt;
&lt;li&gt;noticeably faster;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createImageBitmap(file, { imageOrientation: 'from-image' })&lt;/code&gt; applies the EXIF rotation for you, so phone photos are not sideways;&lt;/li&gt;
&lt;li&gt;every frame has the same pixel format, so the concat step needs one codec only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The "repeat the last file" trick can hurt
&lt;/h2&gt;

&lt;p&gt;A classic recipe against a truncated final frame in the concat demuxer is to list the last file twice. In this build it gave the last photo double time: three frames at 2 s came out as 8 s instead of 6 s. Without the duplicate I measured 5.97 s and the last frame was held fully. Measure your own build before you copy a recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Audio: &lt;code&gt;apad&lt;/code&gt; plus &lt;code&gt;-shortest&lt;/code&gt;, not just &lt;code&gt;-shortest&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you add a music track, plain &lt;code&gt;-shortest&lt;/code&gt; cuts the whole video to the length of the track (a 6 s slideshow with 3 s of music becomes 3 s). Pad the audio with silence and then let &lt;code&gt;-shortest&lt;/code&gt; trim a long track to the video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-af apad -shortest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bonus: remux instead of re-encode when you can
&lt;/h2&gt;

&lt;p&gt;For a format converter the biggest win was skipping the encode. When the source codec fits the target container, &lt;code&gt;-c copy&lt;/code&gt; only rewrites the container. In my measurements a remux took about 0.14 s against 1.41 s for a re-encode on a small clip, and in the deployed &lt;a href="https://convertilo.io/video/convert/" rel="noopener noreferrer"&gt;video format converter&lt;/a&gt; it is roughly 0.6-0.7 s against 2.9-5.6 s. I still re-encode HEVC on purpose, because an MP4 with HEVC does not open everywhere.&lt;/p&gt;

&lt;p&gt;What trips you up most with ffmpeg.wasm? I am curious whether others saw the same "out of bounds" masking.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
      <category>ffmpeg</category>
    </item>
    <item>
      <title>I benchmarked 6 WASM image codecs in the browser. Here is what beats the server.</title>
      <dc:creator>Convertilo</dc:creator>
      <pubDate>Tue, 19 May 2026 23:07:41 +0000</pubDate>
      <link>https://dev.to/convertilo/i-benchmarked-6-wasm-image-codecs-in-the-browser-here-is-what-beats-the-server-1dlb</link>
      <guid>https://dev.to/convertilo/i-benchmarked-6-wasm-image-codecs-in-the-browser-here-is-what-beats-the-server-1dlb</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Modern WebAssembly codecs (MozJPEG, libavif, OxiPNG/imagequant, libwebp, gifsicle, SVGO) now run fast enough in the browser that you can ship a real image-compression tool &lt;strong&gt;without uploading anything to a server&lt;/strong&gt;. I built one (&lt;a href="https://convertilo.io" rel="noopener noreferrer"&gt;convertilo.io&lt;/a&gt;) and benchmarked the codec mix on 100 real-world images. Results below — and a couple of surprises about what &lt;em&gt;doesn't&lt;/em&gt; work.&lt;/p&gt;

&lt;p&gt;Raw data + repo: &lt;a href="https://github.com/convertilo/wasm-image-benchmarks" rel="noopener noreferrer"&gt;github.com/convertilo/wasm-image-benchmarks&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Corpus: 100 mixed real-world images (photos, screenshots, transparent PNGs, animated GIFs, vector icons)&lt;/li&gt;
&lt;li&gt;Quality: 75 (sane default for lossy)&lt;/li&gt;
&lt;li&gt;Environment: Chrome 130, M-series Mac, warm WASM cache&lt;/li&gt;
&lt;li&gt;Median reduction (per-image variance is ~±15%)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Median size reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPEG&lt;/td&gt;
&lt;td&gt;MozJPEG (@jsquash/jpeg)&lt;/td&gt;
&lt;td&gt;lossy q=75&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−53%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;imagequant + @jsquash/png&lt;/td&gt;
&lt;td&gt;lossy palette&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−75%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;OxiPNG (@jsquash/png)&lt;/td&gt;
&lt;td&gt;lossless&lt;/td&gt;
&lt;td&gt;varies*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;libwebp (@jsquash/webp)&lt;/td&gt;
&lt;td&gt;lossy re-encode&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−17%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;libavif (@jsquash/avif)&lt;/td&gt;
&lt;td&gt;lossy q=75&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−59%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GIF (static)&lt;/td&gt;
&lt;td&gt;gifsicle-wasm-browser&lt;/td&gt;
&lt;td&gt;lossy −O3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−75%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GIF (animated)&lt;/td&gt;
&lt;td&gt;gifsicle-wasm-browser&lt;/td&gt;
&lt;td&gt;lossy −O3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−27%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG&lt;/td&gt;
&lt;td&gt;SVGO v4 browser bundle&lt;/td&gt;
&lt;td&gt;lossless multipass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−42%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*OxiPNG depends on whether the input was already optimized — anything from 0% to 60%.&lt;/p&gt;

&lt;p&gt;PNG via imagequant and JPEG via MozJPEG hit numbers competitive with &lt;strong&gt;server-side TinyPNG&lt;/strong&gt;. That was the moment I stopped looking for a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shipped this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;@jsquash&lt;/strong&gt; — Sam Sneddon's WASM bindings around mozjpeg, libwebp, libavif, OxiPNG, imagequant. Lazy-loaded per format so the bundle stays tiny: ~250 KB only when the user picks a real file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gifsicle-wasm-browser&lt;/strong&gt; — full gifsicle 1.92 CLI compiled to WASM. Preserves animation frames properly (more on this below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVGO v4&lt;/strong&gt; — works fine in the browser via the bundled &lt;code&gt;svgo/browser&lt;/code&gt; import. No need for a server.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Three things that didn't work (and saved you the time)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  imagequant for WebP/JPEG
&lt;/h3&gt;

&lt;p&gt;imagequant gives PNG the −75% number, so my first instinct was "apply the same quantization to WebP/JPEG before encoding." It actively &lt;strong&gt;hurts&lt;/strong&gt; there: the palette quantization introduces dithering noise that wrecks lossy entropy coding. WebP went from −17% to &lt;strong&gt;−12%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Rule: imagequant for lossless palette PNG only. Don't cross-apply.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebP knob-twisting
&lt;/h3&gt;

&lt;p&gt;I sunk a few hours into &lt;code&gt;method: 4&lt;/code&gt;, &lt;code&gt;pass: 3&lt;/code&gt;, &lt;code&gt;sns_strength: 75&lt;/code&gt;, &lt;code&gt;use_sharp_yuv: 1&lt;/code&gt; for libwebp. Total impact: &amp;lt;1% extra reduction on already-optimized WebPs. If you're re-encoding WebP that's been touched by another tool, you're squeezing a stone.&lt;/p&gt;

&lt;h3&gt;
  
  
  GIF via canvas/ImageData
&lt;/h3&gt;

&lt;p&gt;I tried decoding GIF, processing as ImageData, re-encoding. Animation frame timings die in transit. Just use gifsicle as a File → File pipeline and pass &lt;code&gt;-O3 --lossy=80&lt;/code&gt; directly. Don't reimplement what's already a 30-year-old C tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy-first angle
&lt;/h2&gt;

&lt;p&gt;The reason I cared at all: image compressors historically upload your files. Receipts, IDs, family photos, contracts — your stuff sits on someone's server. With WASM you can run the &lt;strong&gt;same codecs&lt;/strong&gt; (MozJPEG, libavif, OxiPNG) entirely in the user's tab. Nothing leaves the device.&lt;/p&gt;

&lt;p&gt;I learned the hard way that this also requires &lt;strong&gt;not&lt;/strong&gt; loading Yandex.Metrika or Google Analytics before consent, which is a whole other post. The codec part is the easy half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/convertilo/wasm-image-benchmarks
&lt;span class="nb"&gt;cd &lt;/span&gt;wasm-image-benchmarks
&lt;span class="c"&gt;# results.json has the medians I cite above&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;results.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want a live implementation rather than a benchmark, all of these codecs are wired up at &lt;a href="https://convertilo.io" rel="noopener noreferrer"&gt;convertilo.io&lt;/a&gt; — drop a file in and watch network: no upload happens.&lt;/p&gt;




&lt;p&gt;If you've squeezed more out of any of these (especially WebP — I'm convinced there's more there), I'd love the numbers. Drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
