naruto-gesture-detection detects a hand sign in the browser and plays a Naruto shadow clone effect over the webcam. The effect comes from naruto-shadow-clone-jutsu by Nasha Wanich. The same code works on Windows and Android. On my iPad Air (5th generation), iPadOS 27.0.1, Safari, the clones never showed up.

The cause is WebKit bug 221542, open since 2021: Apple’s WebKit ignores globalCompositeOperation when the drawImage source is a video. The fix swaps the order of two draw calls.

I found the symptom, added a diagnostics panel with AI help, noticed that the test button and the real trigger behaved differently, directed the work and tested on the iPad. Claude Opus 5.5 in Claude Code traced the code, read the WebKit and MediaPipe source, ran the probes, and wrote the emulation, the fix and the test.

The first half of this post is the bug. The second half is about AI, because the way this bug got solved has bothered me more than the bug did.

The symptom

On the iPad:

  • Training works and the confidence score reaches 100%.
  • The status says the clone sequence is active and the badge icon glows yellow.
  • The blue hand-landmark dots disappear.
  • No clones and no smoke appear. The camera view looks normal.
  • The “Test clone effect” button in the Diagnostics panel does show smoke and blue person figures.

Why iPad-only bugs are expensive

When something works on Windows and Android and fails only on iPad, you know it is a platform-specific bug, but you cannot debug it the usual way from a Windows PC. To inspect a page on an iPad, Web Inspector has to run from Safari on a connected Mac. I could not do that from my Windows PC, so I had no console or element view for the page on the iPad. Playwright’s WebKit on Windows cannot show this bug either, for the reason given further down. The failure also produced no error: the app’s diagnostics panel records uncaught errors and rejected promises, and all 193 entries in the log in the next section are at info level. So my evidence was what I could see on the iPad and what the app logged. iPad-only bugs are expensive to solve, because they usually end in low-level rendering engine behaviour that you did not write.

Debugging it the traditional way takes a lot of background:

  • You need to read browser engine code, or at least know where to look. Here that means WebKit’s canvas code.
  • You need to know that iPad browsers share an engine. Safari, Chrome, Firefox and Brave on iPad normally all run on WebKit, because App Review guideline 2.5.6 tells browsers to use it. If you miss this, the same failure in a second browser could convince you the engine is not the problem.
  • You need the right search words. Claude’s search, further down, contains drawImage, globalCompositeOperation and source-in. You only know those words after you have narrowed down the cause, and from my experience Google will not return a WebKit bug report for a description of what you see on screen.

The engine rule has regional exceptions. Apple lets browsers in the EU use another engine on iPadOS 18 and later, and its rules for Japan allow it on iOS 26.2 and later but name only iOS for distribution. Open Web Advocacy said in January 2026 that no browser vendor had shipped a competing engine, and I found no report of a public release since.

The log shows no errors

A diagnostics log from Safari (ipad-diagnostics-2026-10-08.json) shows this on a real trigger:

clone readiness {"assetsReady":true,"segmentationEnabled":true,"maskReady":true,"active":false}
clone started {"testMode":false}
canvas render-state {"stage":"clone-effect"}
canvas first-clones-rendered {"width":640,"height":480,"testMode":false}
canvas clone-render-skipped {"reason":"expired","elapsed":4624}

Assets loaded, the mask existed, the render ran for its 4.6 seconds, and nothing logged an error. So the draw calls ran, and their output could not be seen.

The log’s user agent says Macintosh ... Version/26.5 Safari. Safari on iPad sends a Mac user agent by default, so the log alone cannot tell an iPad from a Mac. The log dates from minutes after my diagnostics commit, on an older OS than the later 27.0.1 test.

Test button vs real trigger

The test button works and the real trigger does not, even though they share the drawing code. I noticed that on the iPad and told Claude.

Claude traced the difference to the person layer, which goes on top of everything so the real person stands in front of the clones. The real trigger builds it from the segmentation mask and the camera video. The test button paints its own see-through figure. So the button passed because it skipped the broken step, which is a bit annoying given I added it to debug this.

The smoke clue

Smoke images do not use the mask at all, yet they vanished too. The person layer is the only thing drawn after the smoke. So the working theory became that the person layer is an opaque full frame on iOS.

An opaque copy of the live video, drawn over the clones, sits exactly on top of the real video and looks like nothing happened. That also explains the missing blue dots: the effect really had started, and landmarks are not drawn during it.

The old code:

ctx.drawImage(mask, 0, 0, width, height);
ctx.globalCompositeOperation = "source-in";
ctx.drawImage(video, 0, 0, width, height);

For that to come out opaque, either the mask is opaque or Safari ignores source-in for the video.

The wrong theory: MediaPipe

The mask was the first suspect. MediaPipe’s bundle has an iOS-only branch: useCpuInference defaults to true when navigator.platform is an iPad or iPhone, or when the user agent contains Mac and the device has touch. It looked like the answer, since it is a real difference between iPad and everything else.

So Claude measured it. A probe read actual mask pixels with the branch off and on, in Chromium and in real WebKit on Windows, including a spoofed iPad platform and a Mac user agent with touch. Every variant gave a correct mask: corner alpha 0 and similar coverage. The theory was plausible and wrong, and measuring it took minutes.

The WebKit source

That left the video. (The links in this section point at WebKit commit 74fe159, which was main when I checked them.)

In CanvasRenderingContext2DBase.cpp, the Apple-only (USE(CG)) branch of drawImage for video calls:

c->drawNativeImage(*image, normalizedDstRect, normalizedSrcRect);

No paint options are passed, so drawNativeImage gets its empty default. The default in RasterDrawingOptions.h is CompositeOperator::SourceOver, and GraphicsContextCG.cpp applies the options’ mode. The ImageBitmap path does pass state().globalComposite.

Camera video needed its own check, because it comes from a MediaStream player. MediaPlayerPrivateMediaStreamAVFObjC has no nativeImageForCurrentTime, so it gets the base class’s null. drawImage then falls through to paintCurrentFrameInContext, and the player’s version also calls drawNativeImage with default options. Both routes end at SourceOver.

The search and the bug report

Once the suspects were clear, Claude ran this search:

Safari canvas drawImage video ignores globalCompositeOperation source-in WebKit bug

The top result was WebKit bug 221542, “globalCompositeOperation has not effect on drawing a video to a canvas” (the typo is in the title). It dates from February 2021 and its status is still NEW. A patch got review approval but never landed, and a commenter reproduced the bug on Safari 16.1 in October 2022.

Reproducing it without a Mac

Nothing in this work ran Apple’s renderer, so this section is an emulation of the bug. Playwright’s WebKit on Windows does not compile the USE(CG) code, so it cannot show the bug. Linux WebKit in Docker has the same limit, so Claude skipped Docker. Running macOS in a container was not an option.

So Claude patched Chromium to behave the way the source says Safari behaves. The patch wraps drawImage and forces source-over whenever the source is a video:

CanvasRenderingContext2D.prototype.drawImage = function (...args) {
  if (!(args[0] instanceof HTMLVideoElement)) return drawImage.apply(this, args);
  const operation = this.globalCompositeOperation;
  this.globalCompositeOperation = "source-over";
  drawImage.apply(this, args);
  this.globalCompositeOperation = operation;
};

The real CloneEffect then ran with a real MediaPipe mask and real smoke images. The camera was a drawn test figure, so the mask is rough.

Render Opaque canvas Result
Normal browser, old code 28.1% Clones and smoke
Safari behavior, old code 100% Plain video, same as the iPad
Safari behavior, fixed code 28.1% Clones and smoke

Emulated Safari, old drawing order: plain frame only, no clones or smoke.

Chromium patched to act like Safari, old drawing order: only the plain frame. Both images come from the emulation with the drawn test figure, not from a real iPad, which is why they look rough.

Emulated Safari, fixed drawing order: clones and smoke behind the drawn figure.

Same patch, fixed drawing order: clones and smoke behind the figure.

The fix

Never draw the video under a composite mode. The fix puts the video down normally and masks it afterwards with destination-in. The mask is a bitmap, which WebKit handles correctly:

ctx.drawImage(video, 0, 0, width, height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0, width, height);

The pixels should come out the same in any browser: video multiplied by mask alpha, either way.

The fix also logs personCoverage, the opaque share of the person layer, since “no errors” told me nothing here.

The regression test

tests/e2e/clone-compositing.spec.ts keeps the patch in the repo, so anyone with Playwright can run it on any OS:

bun run build && bunx playwright test clone-compositing

Claude wrote it before the fix. On the old drawing order it failed: background alpha expected 0, received 255. It passes on the new order.

Confirmation on the iPad

The emulation comes from reading the source, so a real device still had to confirm it. A LAN dev server with a self-signed certificate came first, and my devices could not open it. So the fix went to a branch, Cloudflare Pages built it with an HTTPS URL, and I tested on the iPad. The clones and smoke appeared. Later iPad logs show personCoverage between 21% and 42% of the frame across four real triggers, where an opaque full frame would read 100%.

From the source, I expect macOS Safari and iPad Chrome and Firefox to be affected too, but I have not tested those.

The cost without AI

Claude reported a likely cause and a fix about 19 minutes after my first message. I confirmed it on my iPad about 45 minutes after that message. I added the diagnostics panel on 8 October to chase this failure, so this bug had stayed unsolved for almost a month.

Without AI, I might not have resolved it at all. Five years ago I would have needed to hire an experienced web developer who knows the Apple ecosystem and enough WebKit to go looking in the engine.

I give the credit for this to Opus 5.5 specifically. It could not run Apple’s renderer, but it still found the bug by reading the source, and it left a test that runs on any OS. It was really inspiring for me to see a model do that.

So the barrier to entry fell for me. I personally did not know how to do this work, and it used to be very expensive, but I could direct it from a Windows PC and check the result on my own iPad. Opus 5.5 also taught me: I asked how it found the bug and it wrote the whole diagnosis down for me.

In April 2025 I wrote that AI replaces work where examples are readily available online, and this bug fits that. WebKit’s source and the bug report had been public for years. I lacked the background to find them and connect them, and Opus 5.5 did that part.

(In September I also wrote that I feel we are over-adopting AI. On this bug AI helped me a lot, and I still need to work out how those two things fit together.)

The waves of AI I have seen

  • GPT-4o felt like a big jump to me because of its image capabilities. By April 2025 I was using it to generate cover pages for reports.
  • o1 could think and reason over complex questions.
  • Deep research connected models to a huge amount of internet data. In that same April post I had almost built a workflow around Gemini’s version. I don’t find it very useful nowadays.
  • Before tool calling, MCP and agent skills, AI was only plain chat.

Voice input is another one. I can dictate a prompt instead of typing it: a speech recognition model turns my voice into text, and the AI follows that text even when it is garbled. In this project “Stryker” came out as “STRYLER”, and Claude still followed it. It does not always work: “Cloudflare” came out as “Cloud”, and Claude copied that word into the first draft of this post.

Years of experience

We still need experienced front-end and back-end developers. But a model can now read WebKit’s source for someone like me, so I am less sure what years of experience are worth. This bothers me a lot, and it comes back every time a new wave arrives.

I ask myself what knowledge stays valuable and what experienced developers should do now, and I don’t have a general answer. I only know what still mattered in this one bug, and what I would ask an AI for next time.

Using AI on a bug like this

None of my part needed WebKit knowledge. Claude had no iPad, so the disappearing blue dots and the test button that worked both came from me using the app on the real device. My first message about this bug was messy, but it worked because it named the device and the OS version and said exactly what appeared and what did not.

I also had evidence before I asked. The diagnostics log showed the render running with no errors, which moved the search from “did it draw?” to “why can’t I see it?”. The panel was worth adding, even though its test button hid the bug.

It helped that I knew my own setup. The LAN dev server failed on my devices, and I knew a pushed branch would get an HTTPS URL from Cloudflare Pages, so I asked for that and it got the fix onto the iPad. The emulation was only a theory from reading source until the clones appeared there.

When an AI gives me a cause, I want to know what it measured. The MediaPipe branch looked like the answer until a probe of real mask pixels ruled it out. I also want links to the source. I don’t understand WebKit’s internals, but I can open a link to drawNativeImage and check that the line says what Claude said, and the next reader or agent can check this post the same way. That is as far as I can go myself, so an AI’s answer still needs a second check from a real person who has the right knowledge.

Asking how it got there is useful too. The diagnosis it wrote for me covers why my test button passed and where the Apple-only code path is.

I ask for tests that follow the real path and measure the output. My diagnostics button passed because it skipped the broken step, and “no errors” did not mean the effect worked. A number like personCoverage shows the problem directly: near 100% means the person layer covers the clones and smoke. I also asked for the Chromium patch and its context to go in the repo, so anyone with Playwright can reproduce this. The test failed on the old drawing order first, and it will fail for anyone who brings that order back.