<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Zephyr’s Tech Lab]]></title><description><![CDATA[Zephyr’s Tech Lab]]></description><link>https://zephyrs-tech-lab.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 14 Nov 2026 19:22:48 GMT</lastBuildDate><atom:link href="https://zephyrs-tech-lab.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How we fit English into a speech bubble drawn for vertical Japanese]]></title><description><![CDATA[In one of our sample manga pages, the Japanese line 約束、覚えてたんだ sits in two slim vertical columns. Translate it as “So you remembered our promise” and you…]]></description><link>https://zephyrs-tech-lab.hashnode.dev/how-we-fit-english-into-a-speech-bubble-drawn-for-vertical-japanese</link><guid isPermaLink="true">https://zephyrs-tech-lab.hashnode.dev/how-we-fit-english-into-a-speech-bubble-drawn-for-vertical-japanese</guid><category><![CDATA[Python]]></category><dc:creator><![CDATA[Zephyrwr6k]]></dc:creator><pubDate>Sat, 14 Nov 2026 11:06:34 GMT</pubDate><content:encoded><![CDATA[<p>In one of our sample manga pages, the Japanese line 約束、覚えてたんだ sits in two slim vertical columns. Translate it as “So you remembered our promise” and you get a horizontal line with a ten-letter word in the middle. In <a href="https://ai-manga-translator.net/">AI Manga Translator</a>, using the old text box as the boundary squeezed the English into a strip of space much narrower than the actual speech bubble.</p>
<p><img src="https://ai-manga-translator.net/blog/typesetting-narrow-bubble.webp" alt="The same speech bubble in Japanese, with English fitted to the original text column, and with English fitted to the bubble" /></p>
<h2>Measure the space around the text</h2>
<p>Once the original lettering is erased, we use <a href="https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html">OpenCV connected components</a> to look for a light, closed region around the detected text. An area touching the page edge is rejected. This keeps white space between panels from being mistaken for the inside of a bubble.</p>
<p>An oval is usually narrower at the top and bottom. For each line, we find the horizontal span common to all pixel rows that line will occupy and let the lettering use <strong>84%</strong> of it. If a line spans rows whose common width is 100 pixels, its text budget is 84 pixels. A middle line with 140 pixels available gets about 118 pixels. Those figures illustrate the layout rule.</p>
<h2>Choose breaks after measuring words</h2>
<p>We measure text with <a href="https://pillow.readthedocs.io/en/stable/reference/ImageFont.html">Pillow’s font metrics</a> and compare possible breaks for a given number of lines. If two breaks fit, the renderer favours the one that leaves less uneven space across the available widths. That gives the wide middle of a bubble room for more text and helps avoid a short word stranded on the final line.</p>
<p><img src="https://ai-manga-translator.net/blog/typesetting-balance.webp" alt="A greedy line break leaves one word by itself. Balanced breaks use the width of each line" /></p>
<p>There are still cases where the bubble-aware layout steps aside. A sound effect over artwork may have no closed region to measure. If detection finds several separate text regions in the same bubble, we keep the upstream placement for those regions. Both are useful cases to include in a lettering review alongside a clean oval bubble.</p>
<p>If you are testing a manga translation pipeline, keep the translated sentence and font fixed and compare only the wrapping. On a narrow bubble, check the smallest line, the space around the outline, and the placement of the longest word. The <a href="https://ai-manga-translator.net/blog/manga-typesetting">full typesetting walkthrough</a> shows the intermediate layouts using the same example.</p>
<p><em>AI was used to draft and localize this article from the project’s implementation and published examples.</em></p>
]]></content:encoded></item><item><title><![CDATA[The face got smaller when I fitted a portrait to 16:9]]></title><description><![CDATA[A character preset for LiveFaceSwap looked fine in the picker. It was a full-height portrait, so I could see the outfit as well as the face. But after…]]></description><link>https://zephyrs-tech-lab.hashnode.dev/the-face-got-smaller-when-i-fitted-a-portrait-to-16-9</link><guid isPermaLink="true">https://zephyrs-tech-lab.hashnode.dev/the-face-got-smaller-when-i-fitted-a-portrait-to-16-9</guid><category><![CDATA[canvas]]></category><dc:creator><![CDATA[Zephyrwr6k]]></dc:creator><pubDate>Sat, 14 Nov 2026 07:39:25 GMT</pubDate><content:encoded><![CDATA[<p>A character preset for <a href="https://livefaceswap.co/">LiveFaceSwap</a> looked fine in the picker. It was a full-height portrait, so I could see the outfit as well as the face. But after fitting the whole portrait into the landscape canvas sent to the model, the character’s face occupied much less of the frame than it did in the webcam composition. Making the canvas wider had given the portrait more white space on either side.</p>
<p>I kept the full image for the picker and gave selected Face Swap presets a second rectangle: the part of the image that should reach the model. Here’s what changes when that rectangle is applied before the resize.</p>
<h2>The 16:9 canvas says little about the face size</h2>
<p>One of our portrait assets is 384 × 576 pixels. The upload preparation uses a 1280 × 720 canvas. With a proportional fit, the height is the limiting dimension, so the image fills 480 pixels of the canvas width. That leaves 400 pixels of padding on each side.</p>
<p>The first Face Swap preset takes the top 72% of that image. The retained source height is about 415 pixels. When fitted on the same canvas, that rectangle is about 667 pixels wide. Any feature fully inside the retained area, including the face, is drawn about <strong>1.39× as wide</strong> as it was in the whole-image fit. This figure comes from the resize geometry. It measures the image sent for conditioning. Model quality would need a separate output test.</p>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4532083/56049177-d0f7-418c-9610-138922c226a0.png" alt="Full portrait and top 72% fitted to the same 16:9 canvas" style="display:block;margin:0 auto" />

<h2>Crop first, then fit</h2>
<p>A preset stores <code>[x0, y0, x1, y1]</code> as fractions of the source dimensions, so the same rectangle works when a preset is re-exported at a different resolution with the same composition. The browser converts the fractions to pixels, fits the rectangle proportionally, and centers it on an opaque canvas. The nine-argument form of <a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage"><code>drawImage()</code></a> takes a source rectangle and a destination rectangle in one call.</p>
<pre><code class="language-js">const sx = x0 * bitmap.width;
const sy = y0 * bitmap.height;
const sw = (x1 - x0) * bitmap.width;
const sh = (y1 - y0) * bitmap.height;
const scale = Math.min(1280 / sw, 720 / sh);
const w = sw * scale;
const h = sh * scale;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 1280, 720);
ctx.drawImage(bitmap, sx, sy, sw, sh, (1280 - w) / 2, (720 - h) / 2, w, h);
</code></pre>
<h2>Keep the cues the feature needs</h2>
<p>A head-only crop can remove clothing that helps define a character. Keeping the neckline and shoulders gives Face Swap more context, while Try-On keeps the whole image so the garment stays visible. Uploaded Face Swap references are also fitted whole, which makes the crop you choose before upload important. Presets can use their stored frame or the full source. The appropriate frame depends on what you expect the output to preserve.</p>
<p>To reproduce the geometry, use the same portrait twice. Fit the whole source first, then fit its top 72% to the same canvas. Compare the width of a facial landmark in the two prepared images. For a live test, hold the webcam position and lighting fixed, then alternate the two references. Look at the face and clothing separately. If you’re picking your own image, here are the <a href="https://livefaceswap.co/blog/best-reference-photo-for-face-swap">reference-photo checks I use</a> before opening the camera.</p>
<p><em>AI assistance was used to draft and edit this article from the implementation, preset asset, and public documentation.</em></p>
]]></content:encoded></item><item><title><![CDATA[When a user closes the tab, who settles the live AI session?]]></title><description><![CDATA[A worked example of reserving credits, settling from usage, and recovering an abandoned live browser session.]]></description><link>https://zephyrs-tech-lab.hashnode.dev/when-a-user-closes-the-tab-who-settles-the-live-ai-session</link><guid isPermaLink="true">https://zephyrs-tech-lab.hashnode.dev/when-a-user-closes-the-tab-who-settles-the-live-ai-session</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Zephyrwr6k]]></dc:creator><pubDate>Fri, 13 Nov 2026 14:15:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af6bcb66f8563d7a013fff8/eb654b4b-e9bd-4ad2-b030-4486d4874491.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A user starts a live camera session, runs it for half a minute, and closes the tab. When they come back, the balance can still show the credits set aside at the start. The browser is gone, but the server still has a session to close and an amount to reconcile.</p>
<p>I ran into this boundary while building <a href="https://livefaceswap.co/?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=live_session_settlement_en">LiveFaceSwap</a>. A camera preview can stop before the billing record catches up. I wanted the server to settle from the usage recorded for that session and return the rest of the reservation.</p>
<h2>Set a ceiling before opening the stream</h2>
<p>At the start, the server reserves up to the amount the user can spend on a single session. It writes the ledger debit and the new session together, with a balance check on the debit. The reservation also sets the budget for the live connection. LiveFaceSwap currently meters at 0.6 credits per second, with a 900-second session cap.</p>
<p>Here is an example. With 60 credits reserved, the budget covers 100 seconds. If the service reports 32 seconds used, the final charge is 20 credits. That's 19.2 rounded up to a whole credit. The remaining 40 go back to the balance. The numbers illustrate the calculation and are independent of any real account.</p>
<h2>Give the browser a fast path</h2>
<p>The normal <strong>Stop</strong> button closes the live connection and sends an authenticated request to end the session. The server closes the metered connection, reads its usage, and returns the charge and updated balance when settlement succeeds.</p>
<p>Closing the tab is a different path through the same endpoint. The studio listens for <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event"><code>pagehide</code></a> and attempts a small POST using <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request/keepalive"><code>fetch</code> with <code>keepalive</code></a>. The browser can keep that request alive after the page unloads. A mobile OS can also kill the browser without delivering the event, so the server has to handle a session it never gets asked to end.</p>
<h2>Give the server a way to catch missed requests</h2>
<p>A <a href="https://developers.cloudflare.com/workers/configuration/cron-triggers/">scheduled Cloudflare Worker</a> looks for sessions past their deadline and calls the same settlement logic. The current schedule runs every five minutes. That interval describes how often the sweep starts. An abandoned session must first become eligible for cleanup, and settlement can take longer if usage lookup fails.</p>
<p>The server calculates the charge from the metering service's reported usage. It rounds to a whole credit and caps the charge at the reservation. When two requests try to settle the same session, only the one that changes it from active to settled issues the refund. The ledger also uses a unique refund key for the session. Those checks address repeated requests. The state update and refund write are separate operations, so recovery from a failure between them deserves its own test.</p>
<h2>Test the calculation and the delivery paths separately</h2>
<p>The local unit tests passed for rounding, the reservation cap, and invalid inputs. To check the full flow in a test account, I'd compare the session record and ledger after three cases:</p>
<ol>
<li>Click <strong>Stop</strong> and confirm that the returned balance matches the charge.</li>
<li>Close the tab with the end request blocked, then check that the sweep settles the session after its deadline.</li>
<li>Send the end request twice and confirm there's only one refund in the ledger.</li>
</ol>
<p>The next time I build a metered browser feature, I'll start with those three cases. The client needs a fast way to say it is done. The server needs enough information to finish the accounting on its own.</p>
<p><em>AI assistance was used to draft and edit this article from the implementation and public documentation.</em></p>
]]></content:encoded></item><item><title><![CDATA[My AI video worked in the browser. Getting it into OBS was a different problem.]]></title><description><![CDATA[I’m building LiveFaceSwap, a web studio that takes a webcam feed and a reference image, then shows the transformed video next to the original. You can see…]]></description><link>https://zephyrs-tech-lab.hashnode.dev/my-ai-video-worked-in-the-browser-getting-it-into-obs-was-a-different-problem</link><guid isPermaLink="true">https://zephyrs-tech-lab.hashnode.dev/my-ai-video-worked-in-the-browser-getting-it-into-obs-was-a-different-problem</guid><dc:creator><![CDATA[Zephyrwr6k]]></dc:creator><pubDate>Fri, 13 Nov 2026 07:37:31 GMT</pubDate><content:encoded><![CDATA[<p>I’m building <a href="https://livefaceswap.co/?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=obs_camera_build_log">LiveFaceSwap</a>, a web studio that takes a webcam feed and a reference image, then shows the transformed video next to the original. You can see the result in the browser as you move. Open the camera menu in another app, though, and you’ll see your physical webcam. The transformed video is still sitting inside the browser.</p>
<p>That gap turned into a separate engineering problem. The model had already done its job. I still needed to get the right frames to the software people use to record, stream, or join a call.</p>
<h2>OBS gave me a useful first version</h2>
<p>For recording or streaming, OBS can capture a window. Point it at the browser studio, crop the scene to the processed output, and select your microphone in OBS. That gives someone a way to use the result without installing a separate LiveFaceSwap desktop app. If they need the composed scene in a call, OBS Virtual Camera can expose that scene as a camera source.</p>
<p>I kept that route because it uses the studio that’s already running. It also gives people control over the rest of their scene. The tradeoff is that you’re capturing part of a window. Change the layout or resize the window and you may need to adjust the crop. If you want the output to show up as a named camera that another app can pick directly, you need an operating system integration.</p>
<h2>One studio, with a second place for the frames to go</h2>
<p>I kept the web studio inside an Electron shell. It uses the same live processing and account as the website. The extra piece is a small bridge that only exists inside the desktop app. During a live session, that bridge reads frames from the processed output and passes them to a native helper. A Windows Media Foundation camera source can then present those frames to another app as <strong>LiveFaceSwap Camera</strong>.</p>
<p>The split is useful for debugging. The studio decides what the video looks like. The bridge moves those pixels. The Windows camera source makes them available as a device. If the preview looks fine but OBS can’t find the camera, I can start with registration and device selection instead of changing the model or its prompts.</p>
<p>There’s a simple way to catch one common setup mistake. Put an obviously different effect on the processed feed, then compare the two panes in the studio with the OBS preview. If OBS matches the raw pane, check which camera source you selected before spending time on AI settings. The microphone stays a separate input, so you can check it independently.</p>
<h2>A small bridge can still move a lot of data</h2>
<p>The current bridge targets 1280 × 720 at 30 frames per second. At four bytes per pixel, that’s roughly <strong>111 MB per second of raw pixel data</strong> at the target cadence, before copies or other overhead. That’s the nominal size of the local handoff, not an internet speed requirement or a measured frame rate.</p>
<p>That calculation shaped a choice in the implementation. The renderer allows one frame submission at a time. If a submission is still outstanding when the next tick comes around, it skips that tick. The native pipe also tells the sender when to wait for it to drain. In live video, an old frame loses value quickly. A queue that faithfully delivers every frame can make a camera feel further and further behind.</p>
<p>There’s also a difference between a helper process starting and the helper being ready to accept frames. The controller waits for an explicit ready message before sending video. I can test that handshake and the binary frame format with a fake child process. Those tests catch errors at the boundary between JavaScript and native code. They don’t tell me whether a particular Windows machine will list the camera in OBS.</p>
<h2>What I can verify separately</h2>
<p>I think about the check in three steps now:</p>
<ol>
<li>Does the original webcam preview move?</li>
<li>Does the processed preview move independently, with the effect applied?</li>
<li>Does the receiving app show that processed feed, with the expected microphone selected?</li>
</ol>
<p>Each step narrows down where to look. A black browser preview can point to permissions or a busy webcam. A moving original preview with no processed video sends me to the live session. A healthy processed preview with no camera in the receiving app sends me to the capture or device path.</p>
<p>The browser capture route is documented, and the native route is available as an <strong>unsigned preview for Windows 11 22H2 or later on x64</strong>. I’m treating device discovery and live output in each receiving app as separate compatibility checks. That’s one of the more useful lessons from this build: I can make the demo work without having tested the place someone actually wants to use it.</p>
<p>OBS documents both the <a href="https://obsproject.com/kb/window-capture-sources">window capture source</a> and <a href="https://obsproject.com/kb/virtual-camera-guide">OBS Virtual Camera</a>. For the project-specific steps, see the <a href="https://livefaceswap.co/guides/livefaceswap-obs?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=obs_camera_build_log">LiveFaceSwap OBS setup guide</a>.</p>
<p>If you’ve shipped a virtual camera, what check caught the most real-world bugs for you: clean-install device discovery, repeated start/stop, or letting the video run long enough to see whether it falls behind?</p>
<p><em>Disclosure: This article was drafted by an AI agent from the project's source code and development notes. The project owner reviewed the draft and approved publication.</em></p>
]]></content:encoded></item></channel></rss>