<?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: Gabriele Pieretti</title>
    <description>The latest articles on DEV Community by Gabriele Pieretti (@gabbrowick).</description>
    <link>https://dev.to/gabbrowick</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%2F4081509%2F97ad5a9c-8094-4644-b924-bfeb72c87649.jpg</url>
      <title>DEV Community: Gabriele Pieretti</title>
      <link>https://dev.to/gabbrowick</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabbrowick"/>
    <language>en</language>
    <item>
      <title>Sensitive Data in Your Logs: What Laravel 13.27's Binding Masking Fixes (and What It Doesn't)</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:22:46 +0000</pubDate>
      <link>https://dev.to/gabbrowick/sensitive-data-in-your-logs-what-laravel-1327s-binding-masking-fixes-and-what-it-doesnt-5fbb</link>
      <guid>https://dev.to/gabbrowick/sensitive-data-in-your-logs-what-laravel-1327s-binding-masking-fixes-and-what-it-doesnt-5fbb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-sensitive-data-in-logs/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a query fails, Laravel builds a &lt;code&gt;QueryException&lt;/code&gt; whose message contains the SQL with every bound value interpolated into it. That's great while you're debugging. In production it means every failed insert copies customer data — an email, a name, maybe a national ID — into your logs, into the &lt;code&gt;failed_jobs&lt;/code&gt; table, and into your APM traces. Laravel 13.27, released on August 26, finally ships a switch to turn that interpolation off. It's worth being precise about what it closes, and what it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: exceptions get written down
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;QueryException&lt;/code&gt; message is designed to be read by a developer mid-debug: &lt;code&gt;insert into users (email) values (?)&lt;/code&gt; tells you nothing about which row broke, the version with the value tells you immediately. The trouble is that the message doesn't stay on that developer's screen. It's a string attached to an exception, and exceptions get recorded everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in the log file or channel you configured in &lt;code&gt;logging.php&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;in the &lt;code&gt;exception&lt;/code&gt; column of &lt;code&gt;failed_jobs&lt;/code&gt;, because the failed-job provider casts the exception to a string and inserts it as-is;&lt;/li&gt;
&lt;li&gt;in the spans of your APM or OpenTelemetry agent, if you run one;&lt;/li&gt;
&lt;li&gt;in whatever error-tracking service your exception reporting ships data to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those places becomes an undeclared copy of the data you were writing to the database — with different retention, different access rules, and almost never accounted for when someone fills in a data-processing register. Across eleven years of building Laravel back-office systems, I've watched application logs get read by far more people than ever had database access. That's normal; logs exist to be read. The question is what ends up in them without anyone having decided it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.27 does
&lt;/h2&gt;

&lt;p&gt;Version 13.27 introduces the per-connection option &lt;code&gt;mask_bindings_in_exception_messages&lt;/code&gt;. The key ships in the framework's own &lt;code&gt;config/database.php&lt;/code&gt; on all five default connections, so even an application that never published that file can enable it with a single environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_MASK_BINDINGS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the flag on, the exception message keeps its placeholders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1062&lt;/span&gt;
&lt;span class="n"&gt;Duplicate&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="s1"&gt;'ada@example.com'&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="s1"&gt;'users_email_unique'&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;`users`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`email`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`name`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`national_id`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the detail: the duplicate value still appears in the first line, because that part of the message comes from the database driver, not from Laravel. The flag masks the framework's interpolation — it doesn't rewrite what MySQL says. That's still a lot: the full insert with three columns of personal data is gone. But it's not a complete scrub of the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'd turn it on almost everywhere
&lt;/h2&gt;

&lt;p&gt;The cost of the flag is that when an exception fires in production you no longer see the values at a glance: you have to recover the bindings some other way — logging them separately to a channel with short retention and tight access, or reproducing the case in development. That's real friction. But the right question isn't "does this make debugging harder?", it's "who reads my logs, and how long do they stick around?". If the answer includes a third-party service, an APM, or months of retention, then binding interpolation is a category of personal-data processing you never declared to anyone — including yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model is bigger than one flag
&lt;/h2&gt;

&lt;p&gt;This release matters to me because it touches a principle I built &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt; around — it's my SaaS for hair salons: &lt;strong&gt;sensitive data has to be protected not just where you think you store it, but in every place your infrastructure re-copies it without asking&lt;/strong&gt;. Logs, queues, traces, log backups, snapshots: every component that serializes state to help you debug is a potential shadow archive.&lt;/p&gt;

&lt;p&gt;In Miraviso, the notes a salon keeps about its clients can contain allergies and scalp conditions, and there I took the conclusion all the way: those notes are encrypted with a key derived on the device, and the server stores envelopes it cannot open. I wrote about that design &lt;a href="https://gabrielepieretti.dev/blog-en/sealed-envelopes-sensitive-data-saas/" rel="noopener noreferrer"&gt;in a dedicated post&lt;/a&gt;. The property that's relevant here is that the envelope design makes the logging problem &lt;em&gt;structurally&lt;/em&gt; impossible for those fields: if the plaintext never exists server-side, no exception, however verbose, can interpolate it into a message. There's no flag to remember to enable, no misconfigured APM agent that can betray you.&lt;/p&gt;

&lt;p&gt;I'm not arguing every field deserves that treatment — the same segmentation I apply there applies here. Most data can live in plaintext on the server, and for that data Laravel 13.27's flag is the right move: cheap, immediate, reversible. A small set of fields is delicate enough that leaking them should be impossible rather than unlikely. The test for telling them apart never changes: does the server have a legitimate need to read this value? If not, make sure it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do today
&lt;/h2&gt;

&lt;p&gt;If you run a Laravel application in production that handles personal data, the list is short. Upgrade to 13.27 and set &lt;code&gt;DB_MASK_BINDINGS&lt;/code&gt; at least in production. Then open &lt;code&gt;failed_jobs&lt;/code&gt; and look at the &lt;code&gt;exception&lt;/code&gt; column of your old records: the flag protects the future, it doesn't clean up the past, and those rows have whatever retention you gave them — often forever. Finally, ask which other components of your stack cast exceptions to strings and keep them around. It's a less entertaining exercise than &lt;a href="https://gabrielepieretti.dev/games/nonogram/" rel="noopener noreferrer"&gt;writing nonogram solvers&lt;/a&gt;, but it makes your next audit a great deal shorter.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>privacy</category>
      <category>php</category>
    </item>
    <item>
      <title>Vue's Vapor Mode Is Almost Here. Writing Browser Games With No Framework Taught Me Why It Matters</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:28:49 +0000</pubDate>
      <link>https://dev.to/gabbrowick/vues-vapor-mode-is-almost-here-writing-browser-games-with-no-framework-taught-me-why-it-matters-5gak</link>
      <guid>https://dev.to/gabbrowick/vues-vapor-mode-is-almost-here-writing-browser-games-with-no-framework-taught-me-why-it-matters-5gak</guid>
      <description>&lt;p&gt;Vue 3.6 entered release-candidate stage in July, and with it Vapor Mode is finally feature-complete: single-file components that compile straight to direct DOM operations, skipping the virtual DOM entirely. A stable release is expected this autumn.&lt;/p&gt;

&lt;p&gt;I use Vue daily in client work, so I care about this for the usual reasons - smaller bundles, faster updates. But I have a second, stranger reason to care: for the past while I've been hand-writing logic games for my personal site - a nonogram, a minesweeper variant, numberlink, mastermind, a 3D Rubik's cube with a built-in solver, a speedcubing timer. Each game is one page, no framework, no heavy libraries. Just state and direct DOM manipulation. And that experience is basically a preview of what Vapor Mode does - except I've been the compiler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you learn when there's no virtual DOM to hide behind&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Take the nonogram. The board is a grid of cells; a click toggles one cell and possibly updates a couple of row/column clue indicators. In a naive framework mental model, the "view" is a function of the whole board state, and something diffs its way to the minimal change. Writing it by hand, you can't afford that mental model. You think in deltas: this click changes this cell's class and these two clue elements, nothing else. You write exactly those three DOM operations and you're done.&lt;/p&gt;

&lt;p&gt;The minesweeper was the same lesson at higher stakes. Mine is a no-guess variant - every generated grid is verified by a solver so you're never forced into a 50/50. When a flood-fill reveals eighty cells at once, you feel every unnecessary DOM touch. So you batch: compute the full set of cells to reveal in pure JS, then apply the class changes in one pass. State first, DOM second, and only the DOM that actually changed.&lt;/p&gt;

&lt;p&gt;The 3D Rubik's cube pushed this furthest. Animating layer turns and running solvers (layer-by-layer, CFOP, optimal) means the interesting work is entirely in the state model - the DOM/render layer is a thin projection of it. Once you structure things that way, "rendering" stops being the hard part of the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this says about Vapor Mode&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three takeaways from doing manually what Vapor now does at compile time:&lt;/p&gt;

&lt;p&gt;First, the virtual DOM was never primarily a performance feature. It's a developer experience feature: it lets you write &lt;code&gt;view = f(state)&lt;/code&gt; and not think about deltas. The diffing is the price you pay for that mental model, not a speed trick. Vapor's bet is that a compiler can keep the declarative authoring model and emit the delta-based code I was writing by hand - the best of both.&lt;/p&gt;

&lt;p&gt;Second, the discipline that no-framework code forces on you - keep state pure, treat the DOM as a projection - is exactly the discipline that makes components fast in any framework. If your component's state is tangled with its DOM, no rendering strategy saves you.&lt;/p&gt;

&lt;p&gt;Third, there's a real ceiling on how much this matters. My games are the favorable case for direct DOM updates: high-frequency, localized mutations on large grids. A typical CRUD form re-renders so rarely that VDOM overhead is noise. If your Vue app is mostly forms and tables, Vapor will shrink your bundle more than it speeds up your interactions - still worth it, but know which benefit you're buying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would I still hand-write the games after Vapor lands?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes - but for a different reason than performance. Keeping each game dependency-free means the pages will still work untouched in ten years, and writing the update logic myself is half the fun. But for product work, Vapor is the right direction: it takes the rendering strategy that low-level code teaches you and makes it the default output of the declarative code you'd write anyway.&lt;/p&gt;

&lt;p&gt;One caveat: 3.6 is still RC as I write this. Feature-complete is not stable - I'm not shipping it to client projects until the stable release, expected this autumn.&lt;/p&gt;

&lt;p&gt;The games are all playable at &lt;a href="https://gabrielepieretti.dev/games/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt; if you want to see the hand-rolled version of "vapor mode" in action.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The GPU that says yes and does nothing: debugging real-time hair segmentation on mid-range Android</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:14:55 +0000</pubDate>
      <link>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</link>
      <guid>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</guid>
      <description>&lt;p&gt;We build &lt;a href="https://miraviso.it/prova-colore-live" rel="noopener noreferrer"&gt;a virtual mirror for hair salons&lt;/a&gt;: a tablet&lt;br&gt;
camera feed where the customer's hair changes colour in real time while they move their head.&lt;br&gt;
Two constraints shaped every decision.&lt;/p&gt;

&lt;p&gt;The first is privacy: no frame ever leaves the device. Everything runs locally — the model,&lt;br&gt;
the fonts, the runtime — with no CDN dependency, because a CDN request would both break&lt;br&gt;
offline use and leak the salon's IP to a third party.&lt;/p&gt;

&lt;p&gt;The second is hardware: salons don't buy flagship tablets. We had to work on whatever&lt;br&gt;
mid-range Android is on the counter.&lt;/p&gt;

&lt;p&gt;This is what we learned making it fast enough. All numbers were measured on real devices with&lt;br&gt;
diagnostics built into the engine — none of them are estimates.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that looked like slowness
&lt;/h2&gt;

&lt;p&gt;Our starting point was bad in a confusing way. A Pixel 8 Pro ran at 9 fps — poor, but working.&lt;br&gt;
A Samsung A56 (Exynos 1580, Mali GPU) showed &lt;em&gt;no colour at all&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The cause took a while to find, and it's the most useful thing in this article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe's &lt;code&gt;ImageSegmenter&lt;/code&gt; with the GPU delegate was created without throwing, ran in 5 ms,&lt;br&gt;
and returned zero hair pixels.&lt;/strong&gt; Both the confidence mask and the category mask came back&lt;br&gt;
empty. Every time.&lt;/p&gt;

&lt;p&gt;Our fallback to CPU only triggered on exceptions. There were no exceptions. So it never&lt;br&gt;
triggered, and the app simply looked slow instead of broken.&lt;/p&gt;

&lt;p&gt;A clean comparison, same scene minutes apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Delegate&lt;/th&gt;
&lt;th&gt;Mask coverage&lt;/th&gt;
&lt;th&gt;Time per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;1.5–2.1 %&lt;/td&gt;
&lt;td&gt;483 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the trap in that table. The GPU path is 96× faster precisely &lt;em&gt;because it isn't doing&lt;br&gt;
anything&lt;/em&gt;. If you benchmark by timing alone, the broken path wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson: don't assume that a delegate which constructs successfully actually works.&lt;/strong&gt;&lt;br&gt;
Check the output, not the exit code. We now measure mask coverage and surface it in the&lt;br&gt;
diagnostics.&lt;/p&gt;
&lt;h3&gt;
  
  
  Detecting it at runtime
&lt;/h3&gt;

&lt;p&gt;The fix is a fallback that rebuilds the engine on CPU. The rule matters more than it looks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After five consecutive empty masks, &lt;strong&gt;having never seen a good one in the whole session&lt;/strong&gt;,&lt;br&gt;
rebuild on CPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The condition is "never seen a mask", not "empty right now". An empty mask right now is&lt;br&gt;
completely normal — it happens whenever nobody is in front of the lens. We had to encode the&lt;br&gt;
difference between &lt;em&gt;broken&lt;/em&gt; and &lt;em&gt;nobody's there&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;On the A56 this took us from 2 fps to 14 fps with colour applied.&lt;/p&gt;
&lt;h3&gt;
  
  
  The field confirmed it, months later
&lt;/h3&gt;

&lt;p&gt;We shipped that fallback on the strength of a coverage number, which is a slightly&lt;br&gt;
uncomfortable place to be: we knew &lt;em&gt;that&lt;/em&gt; the delegate returned nothing, not &lt;em&gt;why&lt;/em&gt;. The answer&lt;br&gt;
arrived from a beta tester, as a photo of their phone screen. MediaPipe was failing a&lt;br&gt;
&lt;code&gt;RET_CHECK&lt;/code&gt; on every single &lt;code&gt;segmentForVideo&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Confidence mask count mismatch, 0 vs 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GPU graph was asking for two masks and receiving zero — at &lt;strong&gt;runtime&lt;/strong&gt;, on every frame,&lt;br&gt;
while construction succeeded cleanly. That is exactly why an exception-based fallback can&lt;br&gt;
never catch it, and it's why a plain "Retry" button wouldn't have helped either: retrying&lt;br&gt;
rebuilds the same broken graph.&lt;/p&gt;

&lt;p&gt;Two cheap safety nets came out of that, both costing nothing per frame. If nothing has been&lt;br&gt;
drawn two seconds after start, fall back from &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;requestAnimationFrame&lt;/code&gt; — on some WebViews the former exists but never fires. And do a&lt;br&gt;
&lt;strong&gt;one-pixel&lt;/strong&gt; &lt;code&gt;readPixels&lt;/code&gt;, &lt;strong&gt;once&lt;/strong&gt;, to find out whether the canvas is coming out black. We&lt;br&gt;
had shipped a version where the error line was written into an element that&lt;br&gt;
&lt;code&gt;display: none&lt;/code&gt; had already hidden, so a broken device showed a black mirror and said nothing&lt;br&gt;
at all. Errors you can't see are errors you can't fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  A diagnostic that costs more than what it measures
&lt;/h3&gt;

&lt;p&gt;While chasing this, we left &lt;code&gt;outputCategoryMask&lt;/code&gt; enabled from an earlier experiment. The&lt;br&gt;
diagnostic itself became a significant part of the frame budget. Worth remembering when your&lt;br&gt;
measurements start shaping the thing you're measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was never the model
&lt;/h2&gt;

&lt;p&gt;With the GPU path honest, we profiled the Pixel 8 Pro's 9 fps. The model inference cost 6 ms.&lt;br&gt;
The other 126 ms were ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;66 ms&lt;/strong&gt; in a JavaScript loop recolouring pixels one at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;two GPU→CPU readbacks&lt;/strong&gt; per frame, one of them costing 123 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole story of most "the ML model is too slow" complaints we've had since: the&lt;br&gt;
model was never the problem. The problem was everything we did around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1 — recolour in a fragment shader (66 ms → 0)
&lt;/h3&gt;

&lt;p&gt;The per-pixel JavaScript loop moved into a fragment shader. This is the obvious one, and it's&lt;br&gt;
free: the pixels are already on the GPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2 — decouple the mask from the draw
&lt;/h3&gt;

&lt;p&gt;Hair does not move at 60 Hz. We were segmenting every frame for no reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;segmentForVideo&lt;/code&gt; is &lt;strong&gt;synchronous&lt;/strong&gt;, and on an Exynos it costs 437 ms of blocked main thread.&lt;br&gt;
Now we segment one frame in every &lt;code&gt;PASSO_SEG&lt;/code&gt;, and draw the video on all of them using the&lt;br&gt;
most recent mask. The step adapts to the device.&lt;/p&gt;

&lt;p&gt;This is the change that fixed the A56, and it's worth stressing: &lt;strong&gt;we expected to need a Web&lt;br&gt;
Worker and we didn't.&lt;/strong&gt; Decoupling cadence was enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3 — share the WebGL context with MediaPipe (123 ms → 0)
&lt;/h3&gt;

&lt;p&gt;This was the big one. We were copying the mask out of MediaPipe and back into our own&lt;br&gt;
pipeline, round-tripping through the CPU.&lt;/p&gt;

&lt;p&gt;MediaPipe accepts a &lt;code&gt;canvas&lt;/code&gt; in its options. Pass it the same canvas you're drawing on, and&lt;br&gt;
you can take the mask with &lt;code&gt;getAsWebGLTexture()&lt;/code&gt; — it never leaves the GPU.&lt;/p&gt;

&lt;p&gt;One catch, learned the hard way: &lt;strong&gt;MediaPipe recycles that texture under your feet.&lt;/strong&gt; Binding&lt;br&gt;
it directly made the colour flicker. We copy it into our own texture with a shader, which is&lt;br&gt;
still enormously cheaper than a CPU round trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4 — drop &lt;code&gt;preserveDrawingBuffer&lt;/code&gt; (5 ms per frame)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;preserveDrawingBuffer: true&lt;/code&gt; costs real time on every single frame. We only needed it to&lt;br&gt;
capture a still of the current look, which is a rare user action.&lt;/p&gt;

&lt;p&gt;We defer the capture to the next draw call, within the same task, and read it there. Verified&lt;br&gt;
on device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe will not start inside a &lt;code&gt;type: "module"&lt;/code&gt; worker.&lt;/strong&gt; It dies with&lt;br&gt;
&lt;code&gt;ModuleFactory not set&lt;/code&gt;, because its WASM loader registers the factory via &lt;code&gt;importScripts()&lt;/code&gt;,&lt;br&gt;
which doesn't exist in module workers. If you want MediaPipe in a worker, you need a classic&lt;br&gt;
worker — or you don't get one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freeze the frame in a texture &lt;em&gt;before&lt;/em&gt; you segment.&lt;/strong&gt; If you segment first and draw after,&lt;br&gt;
the video has already advanced, and the hair lags behind the head permanently. Not&lt;br&gt;
occasionally. Always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the specialised model.&lt;/strong&gt; We were running &lt;code&gt;selfie_multiclass&lt;/code&gt;, which segments six classes&lt;br&gt;
when we needed one. Switching to the dedicated &lt;code&gt;hair_segmenter&lt;/code&gt;: 780 KB instead of 16 MB, and&lt;br&gt;
the official Pixel 6 figures are 58 ms vs 217 ms on CPU, 52 ms vs 71 ms on GPU. Our consumer&lt;br&gt;
APK dropped from 40.7 MB to 25.8 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;132 ms → 17 ms per frame. 9 fps → 15 fps&lt;/strong&gt; on the Pixel 8 Pro, and 2 → 14 on the A56.&lt;/p&gt;

&lt;p&gt;You'll notice 17 ms per frame should mean roughly 58 fps, and we get 15. That's honest and&lt;br&gt;
worth explaining: the remaining ceiling is no longer in our code. It's in how the WebView is&lt;br&gt;
composited inside Flutter, and we haven't opened that front yet.&lt;/p&gt;

&lt;p&gt;We also kept the old CPU 2D path as a fallback for devices without WebGL2. That's not&lt;br&gt;
nostalgia — &lt;strong&gt;a canvas has exactly one context&lt;/strong&gt;. If you request WebGL and it fails, you can't&lt;br&gt;
fall back to 2D on the same canvas. You have to decide before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Four of the five wins here came from removing work we had added ourselves, not from making the&lt;br&gt;
model faster. And the single largest discovery — the silently empty GPU delegate — wasn't a&lt;br&gt;
performance problem at all. It was a correctness bug wearing a performance costume, and it was&lt;br&gt;
invisible until we measured the &lt;em&gt;output&lt;/em&gt; instead of the &lt;em&gt;duration&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building anything similar: instrument mask coverage, not just milliseconds. The&lt;br&gt;
frame that takes 5 ms might be the one doing nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The engine described here ships in &lt;a href="https://miraviso.it/" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, a consultation tool for hair salons: the customer sees the colour and the haircut before the stylist starts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you'd rather look at it than read about it, the same engine is in a free consumer app — &lt;a href="https://miraviso.it/app-colore" rel="noopener noreferrer"&gt;Miraviso Colore&lt;/a&gt; on Google Play. No account, no sign-up, and the camera pipeline runs entirely on the device, which you can check by putting the phone in airplane mode: the mirror keeps working. Tap the "Miraviso" header inside the colour screen and the status line turns into the live diagnostics described above — frame budget broken down by stage, the delegate in use, and the mask coverage number that this whole article is about. Each tap cycles: off → automatic → force GPU → force CPU. If you want to see the empty-mask failure for yourself, force GPU on a mid-range device and watch &lt;code&gt;conf&lt;/code&gt; sit at 0.0 %.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Happy to answer questions about the WebGL side in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>android</category>
    </item>
  </channel>
</rss>
