<?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: Keny Alba</title>
    <description>The latest articles on DEV Community by Keny Alba (@kenyalba).</description>
    <link>https://dev.to/kenyalba</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%2F4094286%2F9e075295-9e67-4c34-a5e7-d99f3a9e3613.png</url>
      <title>DEV Community: Keny Alba</title>
      <link>https://dev.to/kenyalba</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenyalba"/>
    <language>en</language>
    <item>
      <title>Seedance 2.5 Goes 4K for 20 Studios: Verified Prices, Draft Mode Math, and Who Can Actually Use It</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:34:28 +0000</pubDate>
      <link>https://dev.to/kenyalba/seedance-25-goes-4k-for-20-studios-verified-prices-draft-mode-math-and-who-can-actually-use-it-3744</link>
      <guid>https://dev.to/kenyalba/seedance-25-goes-4k-for-20-studios-verified-prices-draft-mode-math-and-who-can-actually-use-it-3744</guid>
      <description>&lt;p&gt;ByteDance just put a 4K label on its best video model, and the headlines wrote themselves. The more interesting story is quieter: who is actually allowed to use that 4K mode (almost nobody: around 20 studios, according to a single press report, relayed but not confirmed by ByteDance), what the public model really costs per generated second (we recalculated it from the official formula), and a billing mechanism called Draft mode that changes how studios iterate, without making any single video cheaper.&lt;/p&gt;

&lt;p&gt;Everything below comes from official ByteDance, Volcano Engine and BytePlus documentation, cross-checked with dated press. We did not test the 4K model ourselves: access is enterprise-only, and we will not pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What just shipped: Seedance 2.5 4K in enterprise trial
&lt;/h2&gt;

&lt;p&gt;On October 23, 2026, Volcano Engine, ByteDance's cloud arm, officially presented Seedance 2.5 4K as the strongest model of its 2.5 generation, with extra training on facial expressions, micro-movements and fine image detail (&lt;a href="https://www.yicai.com/news/103406482.html" rel="noopener noreferrer"&gt;Yicai, October 23&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The day before, Chinese tech outlet Leiphone reported that the first enterprise trial batch is deliberately tiny: around 20 studios and directors worldwide, tied to real big-screen productions (&lt;a href="https://www.leiphone.com/category/industrynews/mqvYMpYMxjNyoBAJ.html" rel="noopener noreferrer"&gt;Leiphone, October 22&lt;/a&gt;). According to that report, 20 productions are underway, the first one, an S-tier satellite TV IP, could air as early as November, and a major global film IP is involved. One testing studio told Leiphone its costs came in "lower than expected", thanks to a 480p-first workflow. Keep in mind this "20 studios" figure traces back to that single report, even if other outlets (&lt;a href="https://www.tmtpost.com/nictation/8177365.html" rel="noopener noreferrer"&gt;TMTPost&lt;/a&gt;, &lt;a href="https://www.bjnews.com.cn/detail/1792642976129728.html" rel="noopener noreferrer"&gt;Beijing News&lt;/a&gt;) relayed the trial the same day.&lt;/p&gt;

&lt;p&gt;Two things are certain. Access is invitation-only, with wider availability described as possible later, not promised. And no 4K price for Seedance 2.5 has been published anywhere.&lt;/p&gt;

&lt;p&gt;One fact almost every headline missed: native 4K output is already on sale to the public, on the previous generation. Seedance 2.0 has a listed 4K tier on both Ark in China (25.27 CNY, about 3.8 USD, per 5-second clip) and BytePlus internationally (3.89 USD per 5-second clip, capped at 15 requests per minute and 1 concurrent job). What is new this week is not 4K itself. It is native 4K on the new 2.5 model, the one that generates audio and video jointly, runs up to 30 seconds and accepts up to 50 reference assets, with film productions in its sights, not 4K in the abstract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model actually does
&lt;/h2&gt;

&lt;p&gt;Seedance 2.5, the base model that went generally available in August, is described by ByteDance's Seed team as a "next-generation audio-video joint generation model" (&lt;a href="https://seed.bytedance.com/en/seedance2_5" rel="noopener noreferrer"&gt;seed.bytedance.com&lt;/a&gt;). Joint is the keyword: the model generates sound together with the picture, not as an afterthought.&lt;/p&gt;

&lt;p&gt;From the official pages, verified on October 25-26:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Length:&lt;/strong&gt; up to 30 seconds in a single generation, with the option to extend twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;References:&lt;/strong&gt; up to 50 reference assets in one run, 30 images, 10 videos and 10 audio files, according to the &lt;a href="https://ai.byteplus.com/en/activity/seedance2-5" rel="noopener noreferrer"&gt;BytePlus model page&lt;/a&gt;. Seed says the model reads a reference video's intention, framing and cinematic language, "beyond motion transfer into creative interpretation".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing:&lt;/strong&gt; localized in-video editing, white-model (clay-style mannequin) control, green-screen editing, and what Seed calls professional camera movement and blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Languages:&lt;/strong&gt; native support for 10+ languages (BytePlus).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public output specs:&lt;/strong&gt; 4 to 30 seconds, 480p, 720p or 1080p, at 24 FPS, MP4 or MOV (BytePlus).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the one to remember: publicly, Seedance 2.5 tops out at 1080p on both the Chinese and international platforms. The 4K tier exists only inside the enterprise trial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draft mode: the innovation that saves money, not the one that impresses
&lt;/h2&gt;

&lt;p&gt;Buried in Volcano Engine's Ark pricing documentation is a mechanism called Draft mode, and it explains the trial studio's "lower than expected" comment better than any benchmark. Its exact wording, translated from Chinese:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Seedance 2.5's Draft mode consists of two tasks, each billed separately"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Source: &lt;a href="https://docs.volcengine.com/docs/ark/model-pricing?lang=zh" rel="noopener noreferrer"&gt;Volcano Engine's Ark pricing documentation&lt;/a&gt;, in Chinese, verified on October 27, 2026.&lt;/p&gt;

&lt;p&gt;A Draft generation is billed as two separate tasks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Step 1:&lt;/strong&gt; a draft video, billed at 480p rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2:&lt;/strong&gt; the final video at the target resolution. The draft's own tokens do not count toward step 2, and the per-token rate is set by whether step 1 included an input video.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We verified the official token formula against Ark's own price examples, and it checks out to the cent: tokens = duration in seconds x width x height x FPS / 1024, at 24 FPS. A 5-second 1080p clip is 5 x 1920 x 1080 x 24 / 1024 = 243,000 tokens, which at 77 CNY per million tokens gives 18.71 CNY, exactly Ark's listed example.&lt;/p&gt;

&lt;p&gt;Now the interesting comparison, for a 10-second 1080p shot without input video:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct generation:&lt;/strong&gt; 486,000 tokens = 37.4 CNY (about 5.6 USD, indicative conversion at the October 27 rate of roughly 0.15 USD per CNY).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft path:&lt;/strong&gt; step 1 at 480p costs 96,075 tokens = 6.7 CNY (about 1 USD), then step 2 costs the same 37.4 CNY as a direct run. Total: 44.1 CNY (about 6.6 USD).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read that again, because it is the opposite of what "draft mode saves money" usually means. One finished video through the draft path costs more than one direct generation. What gets about 5.6 times cheaper is each iteration: 6.7 CNY per 480p attempt instead of 37.4 CNY per 1080p attempt. If you reject five drafts before approving one, that is six draft attempts plus the final render: 6 x 6.7 + 37.4 = 77.6 CNY (about 11.6 USD), instead of six direct 1080p tries at 6 x 37.4 = 224.4 CNY (about 33.7 USD). The draft is a cheap rehearsal, not a discount.&lt;/p&gt;

&lt;p&gt;(One caveat: when your input already contains video, Ark applies a minimum token usage, so very short inputs do not shrink the bill below that floor.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What a generated second really costs
&lt;/h2&gt;

&lt;p&gt;Two official price lists exist, and they are not currency conversions of each other. Treat them as separate markets.&lt;/p&gt;

&lt;p&gt;Volcano Ark, China (doubao-seedance-2.5, per million tokens, online inference):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;No input video&lt;/th&gt;
&lt;th&gt;With input video&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;480p / 720p&lt;/td&gt;
&lt;td&gt;70 CNY (approx. 10.5 USD)&lt;/td&gt;
&lt;td&gt;42 CNY (approx. 6.3 USD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1080p&lt;/td&gt;
&lt;td&gt;77 CNY (approx. 11.6 USD)&lt;/td&gt;
&lt;td&gt;46 CNY (approx. 6.9 USD)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dollar amounts are indicative conversions at the October 27 rate (roughly 0.15 USD per CNY, CNY/USD quote observed on &lt;a href="https://exa.ai/library/markets/forex/CNYUSD" rel="noopener noreferrer"&gt;exa.ai&lt;/a&gt;). Ark bills in CNY; these are not dollar list prices.&lt;/p&gt;

&lt;p&gt;Official worked examples, 5 seconds, 16:9: 3.36 CNY (0.50 USD) at 480p, 7.56 CNY (1.13 USD) at 720p, 18.71 CNY (2.80 USD) at 1080p. A 28% promotion on 1080p ran from August 14 to October 17 and has now ended.&lt;/p&gt;

&lt;p&gt;BytePlus ModelArk, international (Dreamina-Seedance-2.5, per million tokens): 10.7 USD without input video, 6.4 USD with input video. The official example works out to 0.514 USD for a 5-second 480p clip. Seedance 2.0, the previous generation, carries the public native 4K tier at 3.89 USD per 5-second clip.&lt;/p&gt;

&lt;p&gt;What that means in practice at international list prices: a 10-second 1080p shot costs about 5.20 USD in tokens, and a maximum-length 30-second generation about 15.60 USD. A rejected draft at 480p costs about 1.03 USD on the Chinese list, where Draft mode is documented; BytePlus's international docs do not describe Draft mode at the time of writing, so we will not claim it there. Per second, still at list price without input video: about 0.103 USD/s at 480p and 0.52 USD/s at 1080p (16:9, 24 fps).&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can use it today, and who cannot
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The 4K model:&lt;/strong&gt; the trial studios, full stop. If a vendor offers you "Seedance 2.5 4K" access today, be careful: AIFilmHQ noted on October 23 that some resellers sell 1440p and 4K tiers that are upscales of 1080p output, not native generations (&lt;a href="https://aifilmhq.com/entry/seedance-2-5-4k-native-4k-10-bit-enterprise-trial/" rel="noopener noreferrer"&gt;aifilmhq.com&lt;/a&gt;). That outlet also mentions 10-bit color for the 4K tier; we found no primary confirmation of that detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The public 1080p model, international:&lt;/strong&gt; BytePlus ModelArk, ByteDance's international platform, sells Seedance 2.5 with a real entry ticket: activation requires at least 30 USD through one of three routes in the &lt;a href="https://docs.byteplus.com/en/docs/modelark/seedance-model-activation-usage-and-refund" rel="noopener noreferrer"&gt;official activation doc&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activation route&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credits&lt;/td&gt;
&lt;td&gt;30 USD&lt;/td&gt;
&lt;td&gt;prepaid top-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated AI Savings Plan&lt;/td&gt;
&lt;td&gt;30 USD&lt;/td&gt;
&lt;td&gt;30, 60 or 100 USD tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource pack&lt;/td&gt;
&lt;td&gt;30 USD&lt;/td&gt;
&lt;td&gt;valid for 90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is no free quota for this model. On the consumer side, Dreamina (the international name for Jimeng, distributed with CapCut) offers Light and Production plans; those plans are explicitly non-refundable and fall back to pay-as-you-go when exhausted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The public model in China:&lt;/strong&gt; Volcano Ark for API access, Jimeng and Doubao for app usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third parties:&lt;/strong&gt; API resellers like OpenRouter carry Seedance models. The confusion is real enough that a Hacker News thread from August (&lt;a href="https://news.ycombinator.com/item?id=49140823" rel="noopener noreferrer"&gt;news.ycombinator.com/item?id=49140823&lt;/a&gt;) opens with a user, r0fl, asking "Where can you use seedance to make movies? Do you have to sign up to a third party? [...] I just can't figure it out!!" The replies map the maze: Jimeng, Dreamina, Doubao, BytePlus, resellers. One reply notes there is no way to see subscription pricing without creating an account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce the draft-to-final logic on your own footage
&lt;/h2&gt;

&lt;p&gt;You do not need Seedance access to benefit from the draft idea: it is just resolution discipline. We ran the following on October 26 with ffmpeg 4.4.2 on a synthetic 30-second 1080p24 clip (26.7 MB), and the numbers below are the real outputs.&lt;/p&gt;

&lt;p&gt;Extract one reference frame every 5 seconds, at 480p width, for storyboard validation:&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; master-1080p.mp4 &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"fps=1/5,scale=854:-1"&lt;/span&gt; frames/%03d.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: 6 JPEG frames at 854x480, 25 to 39 KB each.&lt;/p&gt;

&lt;p&gt;Render a 480p draft proxy for edit approval:&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; master-1080p.mp4 &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;854:480 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-crf&lt;/span&gt; 28 proxy-480p.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: 895 KB, about 3.4% of the master's size, same duration and frame rate.&lt;/p&gt;

&lt;p&gt;One nuance worth keeping: the file shrank 30-fold, but a generation API does not bill file size, it bills pixels. A 480p frame holds 19.8% of the pixels of a 1080p frame, which is why a 480p draft costs roughly one fifth of the tokens, not one thirtieth. Whether you are validating an edit locally or iterating on a paid model, the habit is the same: approve the cheap version, pay for the expensive one once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this launch says about the video model race
&lt;/h2&gt;

&lt;p&gt;The race of late 2026 is not resolution, it is sustained coherence. Kuaishou launched Kling 4.0 on October 15 (&lt;a href="https://ir.kuaishou.com/news-releases/news-release-details/kling-ai-officially-launches-next-generation-kling-40-raising/" rel="noopener noreferrer"&gt;official press release&lt;/a&gt;) with native 30-second single-pass generations and an Omni Reference system handling up to 15 reference assets and 10 keyframes; API access for enterprise customers is announced as coming soon. ByteDance answers with 30-second joint audio-video generation, reference libraries of 50 assets, and an enterprise 4K tier aimed at theatrical productions, plus industrial uses like robotics simulation (Leiphone).&lt;/p&gt;

&lt;p&gt;Notice where the differentiation happens: billing mechanics, reference control, editing. Draft mode is a procurement feature as much as a creative one. The studios in this trial are not testing whether AI video looks good. They are testing whether it fits a production budget and a post-production pipeline. That is a maturity signal for the whole category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits and what we do not know yet
&lt;/h2&gt;

&lt;p&gt;This article is documentation analysis, not a hands-on review. The 4K model is inaccessible to us, trial results have not been independently verified, and the "20 studios" figure rests on one report. No 4K price or general availability date has been published for Seedance 2.5. The 10-bit color detail comes from a single secondary source. BytePlus's international documentation does not describe Draft mode, so the draft math above is anchored to the Chinese Ark list. Prices and access rules can change at any time; every figure here carries its verification date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Seedance 2.5 4K is real but locked in an enterprise trial of about 20 studios. Public Seedance 2.5 tops out at 1080p; public native 4K already exists on the older Seedance 2.0.&lt;/li&gt;
&lt;li&gt;The model generates audio and video jointly, runs up to 30 seconds (extendable twice), takes up to 50 reference assets, and edits footage locally.&lt;/li&gt;
&lt;li&gt;Draft mode bills a 480p rehearsal plus the final render: each iteration costs about 5.6 times less, not the final video.&lt;/li&gt;
&lt;li&gt;Verified costs: 5 seconds of 1080p = 18.71 CNY (about 2.8 USD) on Ark, 5.20 USD for 10 seconds of 1080p at BytePlus international list prices, 3.89 USD for 5 seconds of native 4K on Seedance 2.0.&lt;/li&gt;
&lt;li&gt;International access starts at 30 USD on BytePlus ModelArk. Beware resellers advertising "4K Seedance 2.5": it may be upscaled 1080p.&lt;/li&gt;
&lt;li&gt;The draft habit is free to copy: iterate at low resolution, render final once. Two ffmpeg commands above get you there on your own footage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Official sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://seed.bytedance.com/en/seedance2_5" rel="noopener noreferrer"&gt;Seedance 2.5 product page (ByteDance Seed)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.volcengine.com/docs/ark/model-pricing?lang=zh" rel="noopener noreferrer"&gt;Volcano Engine Ark pricing, China&lt;/a&gt;: prices and token formula verified on October 27, 2026&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://byteplus.com/en/product/modelark/pricing" rel="noopener noreferrer"&gt;BytePlus ModelArk pricing, international&lt;/a&gt;: verified on October 26, 2026&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.byteplus.com/en/docs/modelark/seedance-model-activation-usage-and-refund" rel="noopener noreferrer"&gt;Seedance activation on BytePlus ModelArk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ai.byteplus.com/en/activity/seedance2-5" rel="noopener noreferrer"&gt;BytePlus Seedance 2.5 page&lt;/a&gt;: public limits (1080p max, 50 assets)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ir.kuaishou.com/news-releases/news-release-details/kling-ai-officially-launches-next-generation-kling-40-raising/" rel="noopener noreferrer"&gt;Kling 4.0 press release (Kuaishou IR, October 16, 2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Press:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.yicai.com/news/103406482.html" rel="noopener noreferrer"&gt;Yicai, October 23, 2026&lt;/a&gt;: enterprise 4K trial announcement&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.leiphone.com/category/industrynews/mqvYMpYMxjNyoBAJ.html" rel="noopener noreferrer"&gt;Leiphone, October 22, 2026&lt;/a&gt;: sole source for the "20 studios" figure&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.tmtpost.com/nictation/8177365.html" rel="noopener noreferrer"&gt;TMTPost, October 22, 2026&lt;/a&gt; and &lt;a href="https://www.bjnews.com.cn/detail/1792642976129728.html" rel="noopener noreferrer"&gt;Beijing News, October 22, 2026&lt;/a&gt;: same-day trial coverage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aifilmhq.com/entry/seedance-2-5-4k-native-4k-10-bit-enterprise-trial/" rel="noopener noreferrer"&gt;AiFilmHQ, October 23, 2026&lt;/a&gt;: secondary source (10-bit color, resellers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://news.ycombinator.com/item?id=49140823" rel="noopener noreferrer"&gt;Hacker News thread&lt;/a&gt;: access-confusion case, cited with attribution&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>aivideo</category>
      <category>performance</category>
      <category>videogeneration</category>
    </item>
    <item>
      <title>Chrome's Prompt API is stable: on-device AI for your website, no API key and no per-token bill</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:59:15 +0000</pubDate>
      <link>https://dev.to/kenyalba/chromes-prompt-api-is-stable-on-device-ai-for-your-website-no-api-key-and-no-per-token-bill-555e</link>
      <guid>https://dev.to/kenyalba/chromes-prompt-api-is-stable-on-device-ai-for-your-website-no-api-key-and-no-per-token-bill-555e</guid>
      <description>&lt;p&gt;Calling a language model from a web page no longer requires an API key, a per-token invoice, or sending user data to a server. Chrome's Prompt API, powered by Gemini Nano, is stable in extensions since Chrome 138 and on the web since Chrome 148: a page in Chrome can ask the browser to summarize, classify or rewrite, locally. For extension developers, this is a category shift: AI becomes a browser capability, on par with storage or notifications.&lt;/p&gt;

&lt;p&gt;It is not magic, though. The model downloads on first use, it can be absent (insufficient hardware, limited storage), and the API surface differs subtly between the web and extensions. Without serious feature detection and a fallback, your "smart" feature becomes a dead button for part of your users.&lt;/p&gt;

&lt;p&gt;This guide builds the complete pattern from the official documentation: detect, create a session, stream the response, handle the model download, and fall back cleanly to a backend when Nano is not there. The code is self-contained, testable in the console, and honest about its limits.&lt;/p&gt;

&lt;p&gt;Contents:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is stable, what is not&lt;/li&gt;
&lt;li&gt;How it works under the hood&lt;/li&gt;
&lt;li&gt;Hands-on: the detect, create, stream pattern&lt;/li&gt;
&lt;li&gt;Web or extension: the differences that matter&lt;/li&gt;
&lt;li&gt;Anti-pattern: calling without checking availability&lt;/li&gt;
&lt;li&gt;Edge cases and failures&lt;/li&gt;
&lt;li&gt;How to measure and verify&lt;/li&gt;
&lt;li&gt;Privacy and architecture questions before you ship&lt;/li&gt;
&lt;li&gt;Designing a reusable, measurable session&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What is stable, what is not
&lt;/h2&gt;

&lt;p&gt;As of October 22, 2026, the official documentation draws this map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Extensions&lt;/th&gt;
&lt;th&gt;Web&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt API (LanguageModel)&lt;/td&gt;
&lt;td&gt;Stable since Chrome 138&lt;/td&gt;
&lt;td&gt;Stable since Chrome 148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summarizer, Translator, Language Detector&lt;/td&gt;
&lt;td&gt;Stable since Chrome 138&lt;/td&gt;
&lt;td&gt;Stable since Chrome 138&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer, Rewriter, Proofreader&lt;/td&gt;
&lt;td&gt;In development&lt;/td&gt;
&lt;td&gt;In development&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampling parameters (&lt;code&gt;samplingMode&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Legacy topK/temperature&lt;/td&gt;
&lt;td&gt;Origin trial only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two clarifications that save debugging hours. First, the current surface is called &lt;code&gt;LanguageModel&lt;/code&gt;: old examples based on &lt;code&gt;window.ai&lt;/code&gt; date from the 2024 previews and throw errors. Second, expired origin trial permissions (&lt;code&gt;aiLanguageModelOriginTrial&lt;/code&gt;) must be removed from extension manifests; the documentation calls this out explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;The model doing the work is Gemini Nano, downloaded once by the browser and shared across origins. Your site does not ship the model, Chrome does, which is why there is no key and no billing. Three practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first call triggers a potentially long download, to be treated as a first-class interface state.&lt;/li&gt;
&lt;li&gt;The exact size varies with browser updates (&lt;code&gt;chrome://on-device-internals&lt;/code&gt; shows the current value), and if available space drops below 10 GB after download, Chrome removes the model.&lt;/li&gt;
&lt;li&gt;Supported languages are limited at this stage: English, Japanese, Spanish, German and French for text inputs and outputs; inputs can also be image or audio, outputs remain text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hands-on: the detect, create, stream pattern
&lt;/h2&gt;

&lt;p&gt;The following block is self-contained: paste it into the console of an HTTPS page on Chrome 148+, or into an extension service worker on Chrome 138+. It illustrates the rhythm the API imposes: the same options to &lt;code&gt;availability()&lt;/code&gt; and &lt;code&gt;create()&lt;/code&gt;, then response streaming.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// prompt-api-pattern.js - paste into the console (Chrome 148+ web, 138+ extension)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;askOnDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. The SAME options go to availability() and create():&lt;/span&gt;
  &lt;span class="c1"&gt;//    an unsupported modality is only detectable this way.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expectedInputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="na"&gt;expectedOutputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LanguageModel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;LanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. create() triggers the download if needed;&lt;/span&gt;
  &lt;span class="c1"&gt;//    plan a visual indicator while it downloads.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;LanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloadprogress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;e&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Model: &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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Streaming: each chunk is a delta (only the new tokens,&lt;/span&gt;
  &lt;span class="c1"&gt;//    the API's current behavior): accumulate them.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;promptStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;onToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;on-device&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askOnDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Summarize in one sentence what the Prompt API is.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nano unavailable here: fall back to your cloud backend.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt; on a compatible machine, the first call shows download progress, then the streamed answer; on an incompatible one, the code prints the fallback path. If &lt;code&gt;availability()&lt;/code&gt; returns &lt;code&gt;downloadable&lt;/code&gt;, &lt;code&gt;create()&lt;/code&gt; starts the download (user activation may be required).&lt;/p&gt;

&lt;p&gt;For typing, the &lt;code&gt;@types/dom-chromium-ai&lt;/code&gt; package provides TypeScript definitions for the Prompt API and the other built-in AI APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web or extension: the differences that matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Web (Chrome 148+)&lt;/th&gt;
&lt;th&gt;Extension (Chrome 138+)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sampling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;samplingMode&lt;/code&gt; via origin trial only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;topK&lt;/code&gt; / &lt;code&gt;temperature&lt;/code&gt; supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download trigger&lt;/td&gt;
&lt;td&gt;User activation required&lt;/td&gt;
&lt;td&gt;Same, but privileged context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use cases&lt;/td&gt;
&lt;td&gt;Page summaries, assisted forms&lt;/td&gt;
&lt;td&gt;Background processing, side panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy trial permission&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;Must be removed from the manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For an extension, the older stability window (138) is a real competitive edge: the server-side AI extension market charges for what Chrome offers here for free, offline, without sending the user's page to a third party.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-pattern: calling without checking availability
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Avoid: session created blind&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;LanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Summarize this page.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// On a machine without Nano: unhandled exception,&lt;/span&gt;
&lt;span class="c1"&gt;// a surprise download mid-user-action,&lt;/span&gt;
&lt;span class="c1"&gt;// and no way out. The "AI" button becomes a trap.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct pattern first tests &lt;code&gt;'LanguageModel' in self&lt;/code&gt;, then &lt;code&gt;availability()&lt;/code&gt; with the exact options, and always keeps a way out. Without that, the most modern feature in the product is also the most fragile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and failures
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Documented behavior&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;availability()&lt;/code&gt; = &lt;code&gt;unavailable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Incompatible hardware or policy&lt;/td&gt;
&lt;td&gt;Cloud fallback, hide the feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;availability()&lt;/code&gt; = &lt;code&gt;downloadable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Model not on device yet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create()&lt;/code&gt; starts the download, inform the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage &amp;lt; 10 GB after download&lt;/td&gt;
&lt;td&gt;Chrome removes the model&lt;/td&gt;
&lt;td&gt;Expect re-download, do not treat as a bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language outside en/ja/es/de/fr&lt;/td&gt;
&lt;td&gt;Not supported at this stage&lt;/td&gt;
&lt;td&gt;Detect language first (Language Detector API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;temperature&lt;/code&gt; on the web&lt;/td&gt;
&lt;td&gt;Not exposed by default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;samplingMode&lt;/code&gt; via origin trial, or leave unset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long session left open&lt;/td&gt;
&lt;td&gt;Memory retained&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session.destroy()&lt;/code&gt; after use, as in the pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON output expected&lt;/td&gt;
&lt;td&gt;No schema constraint on text output&lt;/td&gt;
&lt;td&gt;Validate and retry application-side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to measure and verify
&lt;/h2&gt;

&lt;p&gt;Three quick checks are enough before shipping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;chrome://on-device-internals&lt;/code&gt; confirms the model's presence and current size.&lt;/li&gt;
&lt;li&gt;An offline test (airplane mode) must work once the model is downloaded: that is the whole promise.&lt;/li&gt;
&lt;li&gt;Measure time to first token (&lt;code&gt;performance.now()&lt;/code&gt; before &lt;code&gt;create()&lt;/code&gt; and at the first stream iteration) on an entry-level machine: that latency, not yours on a MacBook Pro, is what your users will live with.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Privacy and architecture questions before you ship
&lt;/h2&gt;

&lt;p&gt;The Prompt API's appeal (a language model built into the browser, no backend bill, no API key) comes with design questions a quick demo never raises. The first is privacy posture. On-device inference means user text does not have to leave the machine, which is a genuine architectural win for sensitive contexts. But "can stay local" is not the same as "stays local": the moment your feature falls back to a server model for unsupported browsers or heavier tasks, your data-flow diagram has two paths, and your privacy notice must describe both. Decide the fallback policy before the first user prompt, not after the first support ticket.&lt;/p&gt;

&lt;p&gt;The second question is capability honesty. A built-in model is a generalist; it will summarize a support ticket and draft a reply, but it is not your fine-tuned domain model and it will not know your product catalog. Features that need your data still need your backend. The healthy pattern is a split brain: the browser model handles user-private, latency-sensitive text work, and your server handles anything that requires your knowledge.&lt;/p&gt;

&lt;p&gt;The third question is durability. Browser APIs evolve; the extension surface reached stability earlier than the web surface. Gate the feature, detect availability at runtime, and degrade gracefully. Your users on other browsers will thank you for a good fallback far more than for a badge saying "Chrome only."&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing a reusable, measurable session
&lt;/h2&gt;

&lt;p&gt;The official documentation provides the two primitives that structure everything else: &lt;code&gt;LanguageModel.availability()&lt;/code&gt; to know whether the model is ready, and &lt;code&gt;LanguageModel.create()&lt;/code&gt; to open a session. The detail that makes the difference in production hides in an easy-to-miss caution: you must pass &lt;code&gt;availability()&lt;/code&gt; exactly the same options as &lt;code&gt;prompt()&lt;/code&gt; or &lt;code&gt;promptStreaming()&lt;/code&gt;, because some models do not support every modality or language. In other words, availability is tested per configuration, not in absolute terms.&lt;/p&gt;

&lt;p&gt;Architecture-wise, three habits pay off. One session per intent rather than one global session: the &lt;code&gt;expectedInputs&lt;/code&gt; and &lt;code&gt;expectedOutputs&lt;/code&gt; parameters (modalities and languages) are declared at creation, and a ticket-summarization session does not have the same shape as a translation session. An explicit download monitor: the official example shows the &lt;code&gt;downloadprogress&lt;/code&gt; event at creation, and an interface that displays model-download progress avoids the "frozen app" feeling on first use. A measured fallback: when &lt;code&gt;availability()&lt;/code&gt; says no, switch to your server path while logging the reason; that log is what will tell you, three months from now, whether the local feature deserves to be expanded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Prompt API is stable: extensions since Chrome 138, web since Chrome 148; it is called &lt;code&gt;LanguageModel&lt;/code&gt;, not &lt;code&gt;window.ai&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Gemini Nano is downloaded and shared by Chrome: no key, no bill, but an initial download to handle and possible removal under 10 GB free.&lt;/li&gt;
&lt;li&gt;The golden rule: same options for &lt;code&gt;availability()&lt;/code&gt; and &lt;code&gt;create()&lt;/code&gt;, and always a cloud fallback for incompatible devices.&lt;/li&gt;
&lt;li&gt;Web and extensions do not share the same surface: fine sampling control remains an extension perk (or an origin trial).&lt;/li&gt;
&lt;li&gt;The decisive argument is privacy: on-device inference keeps prompts and page content on the machine, which simplifies many a conversation with the security team. On unsupported devices, what leaves the machine depends on your fallback implementation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide is maintained on the ScrollFlow blog, where it was first published and corrected: &lt;a href="https://www.getscrollflow.com/blog/chrome-built-in-ai-prompt-api" rel="noopener noreferrer"&gt;Chrome's Prompt API: On-Device AI for Your Websites&lt;/a&gt;. Speaking of Chrome extensions: ours, ScrollFlow, records your web pages as video with automatic scrolling, 100% locally. If on-device AI speaks to you, local processing should too: &lt;a href="https://chromewebstore.google.com/detail/onjdjejlbjddkmglnnogpmgmiepknbkk" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What are you building with on-device AI? Curious to read your use cases in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chrome for Developers - Prompt API&lt;/strong&gt;: official documentation (138/148 stability, &lt;code&gt;LanguageModel&lt;/code&gt;, &lt;code&gt;availability()&lt;/code&gt;, sessions, multimodal, languages). &lt;a href="https://developer.chrome.com/docs/ai/prompt-api" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/ai/prompt-api&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome for Developers - Get started with built-in AI&lt;/strong&gt;: overview of the built-in AI APIs and Gemini Nano. &lt;a href="https://developer.chrome.com/docs/ai/get-started" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/ai/get-started&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome for Developers - Built-in AI APIs&lt;/strong&gt;: status of each API (Summarizer, Translator, Writer, etc.). &lt;a href="https://developer.chrome.com/docs/ai/built-in-apis" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/ai/built-in-apis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MDN - LanguageModel.promptStreaming()&lt;/strong&gt;: streaming behavior and examples (delta chunks, accumulation). &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/LanguageModel/promptStreaming" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/API/LanguageModel/promptStreaming&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm - @types/dom-chromium-ai&lt;/strong&gt;: official TypeScript definitions for the built-in AI APIs. &lt;a href="https://www.npmjs.com/package/@types/dom-chromium-ai" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@types/dom-chromium-ai&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>chrome</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>Auto-scroll is not `window.scrollTo`</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/kenyalba/auto-scroll-is-not-windowscrollto-1fe3</link>
      <guid>https://dev.to/kenyalba/auto-scroll-is-not-windowscrollto-1fe3</guid>
      <description>&lt;h2&gt;
  
  
  The document you measure is not always the document you see
&lt;/h2&gt;

&lt;p&gt;This is the last post in a five-part series about recording a page while it moves. Capture models and clocks do not matter if the page does not move the way you think it does.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window.scrollTo(0, y)&lt;/code&gt; is the demo. Production sites are scroll-snap, Lenis-class smooth engines, overflow-owned layouts, and WebGL scenes that never exposed &lt;code&gt;scrollHeight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;document.documentElement.scrollHeight&lt;/code&gt; is a good default. It is not a contract.&lt;/p&gt;

&lt;p&gt;Marketing pages put the real scroll on an inner &lt;code&gt;div&lt;/code&gt; with &lt;code&gt;overflow: auto&lt;/code&gt;. Animation libraries put a hidden scroller in the tree and map it onto transforms. Canvas scenes consume wheel events and draw a camera. The window’s &lt;code&gt;scrollY&lt;/code&gt; barely changes. Auto-stop based on “near &lt;code&gt;scrollHeight - innerHeight&lt;/code&gt;” fires immediately, or never.&lt;/p&gt;

&lt;p&gt;I detect overflow boxes and drive their &lt;code&gt;scrollTop&lt;/code&gt; as well as the window. That over-fires on pages that have a small scrollable code sample. A short article with a modal or a widget can hijack the “page” height. The safer filter is: only trust a container that covers most of the viewport. I still miss some owners and still grab some widgets. It is a heuristic.&lt;/p&gt;

&lt;p&gt;Wheel events have to look like a user. Libraries check hover before they honor &lt;code&gt;wheel&lt;/code&gt;. Dispatching only on &lt;code&gt;document&lt;/code&gt; is not enough. I target the element under the pointer (or the simulated pointer), bubble a &lt;code&gt;wheel&lt;/code&gt;, and also hit the first canvas if the pointer is on an overlay. Isolated-world &lt;code&gt;scrollTo&lt;/code&gt; never entered that canvas’s event path.&lt;/p&gt;

&lt;p&gt;Smooth-scroll engines keep their own target in a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop. After I jump to the top, they interpolate back to where they were. The first seconds of a capture show the page sliding away from y = 0. The counter is a freeze: keep forcing top until preparation delay ends, then release. At the bottom, stop sending deltas. Footer widgets and WebGL listeners bounce if you keep ticking. Syncing the engine’s internal offset to the real &lt;code&gt;scrollY&lt;/code&gt; is a last poke, not a second scroller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snap is a feature until it is a fight
&lt;/h2&gt;

&lt;p&gt;CSS scroll-snap will fight a linear &lt;code&gt;scrollBy&lt;/code&gt; every frame. The page looks possessed: you ask for two pixels, it jumps a section, you ask again, it jumps back.&lt;/p&gt;

&lt;p&gt;For ordinary auto-scroll I disable &lt;code&gt;scroll-snap-type&lt;/code&gt; on the tree. The recording is a continuous move. That is the point.&lt;/p&gt;

&lt;p&gt;For section-based sites, that disable is the bug. Those pages &lt;strong&gt;are&lt;/strong&gt; the snap. I use a different driver: one synthetic wheel impulse, then silence, then another. Snap stays on. If the wheel does nothing, a deferred viewport-sized &lt;code&gt;scrollBy&lt;/code&gt; runs on a later frame - never in the same frame as the wheel, or you double-apply. If several impulses produce no DOM progress, I stop. A capped impulse count exists so a broken page cannot run forever.&lt;/p&gt;

&lt;p&gt;That second driver is not “smarter linear scroll.” It is a different machine: wait → impulse → wait. Mixing it into the rAF pixel loop is how you get both snap fighting and random jumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Height is a liar in both directions
&lt;/h2&gt;

&lt;p&gt;Lazy load grows &lt;code&gt;scrollHeight&lt;/code&gt; after you “finished.” If stop is already armed, I do not cancel it - pages that keep appending forever never end. If we are still in the settle window, I unlock and continue. A small threshold ignores animation jitter that is not real growth.&lt;/p&gt;

&lt;p&gt;Banners shrink the document. You can lock onto a ghost bottom, then sit there while the real footer is still off-screen. Unlock if we are no longer near the new bottom.&lt;/p&gt;

&lt;p&gt;“Near the bottom” as a percentage fails on long pages. A few pixels of footer is a fraction of a percent and still missing. I use a tight pixel tolerance, then a stagnation filet: if we asked to move and &lt;code&gt;scrollY&lt;/code&gt; did not, the browser may have hit a wall because height was overestimated (sticky chrome). One snap to max, then believe the wall.&lt;/p&gt;

&lt;p&gt;Smart pacing - pause on headings, wait for in-view images to decode - is extra policy on top of this, not a replacement. It still needs a real document height. On a canvas-only scene it has nothing to attach to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind pages: do not auto-stop
&lt;/h2&gt;

&lt;p&gt;If the measurable scroll range is tiny compared to the viewport, I treat the page as blind. Constant speed, wheel events for the WebGL path, &lt;strong&gt;no&lt;/strong&gt; automatic end.&lt;/p&gt;

&lt;p&gt;The user hits Stop. That is not a missing feature. Auto-stop would cut a three-minute WebGL journey at second zero because &lt;code&gt;maxScroll&lt;/code&gt; was 40 pixels.&lt;/p&gt;

&lt;p&gt;Impulse mode uses the same blindness test: no “we reached the bottom,” because there is no bottom to trust. It still stops on no-progress or a max impulse count so a dead page cannot spin.&lt;/p&gt;

&lt;p&gt;Cinema cannot save this. A map needs a height. A 40-pixel document produces a poster of the first screen, then a camera with nowhere to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I will not pretend to drive
&lt;/h2&gt;

&lt;p&gt;Cross-origin iframes are other documents. I do not inject into every frame. If the product lives in an embed, outer-page auto-scroll is theatre.&lt;/p&gt;

&lt;p&gt;Horizontal overflow is a different axis. The driver is vertical.&lt;/p&gt;

&lt;p&gt;A hidden tab can stop painting. Capture goes black. Scroll may still be “working” in a world you cannot see.&lt;/p&gt;

&lt;p&gt;Overlay cleanup that hides &lt;code&gt;fixed&lt;/code&gt; nodes must spare canvases, main landmarks, and near-fullscreen layers. Otherwise you “clean” the WebGL scene you came to record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The series in one line
&lt;/h2&gt;

&lt;p&gt;A screenshot is a scan. Live video is a compositor diary. Cinema is a camera on a poster. Auto-scroll is a negotiation with whoever actually owns the scroll.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt; as that negotiation on the user’s machine. It still loses to iframes, hidden tabs, sideways pages, and scenes that never exposed a scrollbar. Those losses are the spec, not the backlog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;If a site ignored &lt;code&gt;window.scrollTo&lt;/code&gt;, I want that engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I turned this experiment into &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt;, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>programming</category>
      <category>discuss</category>
      <category>tools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A full-page PNG is a scan of viewports, not a screenshot of the document</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/kenyalba/a-full-page-png-is-a-scan-of-viewports-not-a-screenshot-of-the-document-16ia</link>
      <guid>https://dev.to/kenyalba/a-full-page-png-is-a-scan-of-viewports-not-a-screenshot-of-the-document-16ia</guid>
      <description>&lt;h2&gt;
  
  
  Why a single screenshot cannot exist
&lt;/h2&gt;

&lt;p&gt;This is the fourth post in a series about capturing scrolling pages. Live video and Cinema maps get the attention. The still image is where sticky headers and the last slice quietly ruin the file.&lt;/p&gt;

&lt;p&gt;I used to think “full page” meant one screenshot of a tall document. In a browser it means: scroll, wait, capture the visible tab, repeat, stack the bitmaps. Every step can disagree with the last.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;captureVisibleTab&lt;/code&gt; sees the viewport. There is no API that returns the layout tree as one PNG at device resolution. Long pages are a loop.&lt;/p&gt;

&lt;p&gt;Chrome also rate-limits how often you may call that API. The loop is slow on purpose. Racing it produces missing frames or errors, not a faster poster.&lt;/p&gt;

&lt;p&gt;Between two captures the page is allowed to change: lazy images, sticky classes applied by JavaScript, banners collapsing, fonts swapping. A “full page” PNG is a timeline of viewports glued together. If you do not treat it that way, you debug the glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last slice always overlaps - unless you crop
&lt;/h2&gt;

&lt;p&gt;Suppose the page is not an integer number of viewports tall. You scroll by one viewport each time. On the last step, the browser &lt;strong&gt;clamps&lt;/strong&gt; &lt;code&gt;scrollTo&lt;/code&gt;. You cannot place the viewport so that its top sits on “the remaining pixels.” You land on &lt;code&gt;totalHeight - viewportHeight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The last bitmap therefore contains a band you already captured in the previous frame. Stack the images and the footer duplicates. I have seen copyright lines twice and a button that appears to sit on itself.&lt;/p&gt;

&lt;p&gt;The fix is geometric, not aesthetic. Compute the remainder - the true height of the last segment - and crop the last bitmap to that band, in &lt;strong&gt;captured pixels&lt;/strong&gt;, not CSS pixels. &lt;code&gt;captureVisibleTab&lt;/code&gt; returns a device-pixel buffer. Remainder and viewport height are CSS. The crop uses the ratio &lt;code&gt;bitmapHeight / viewportHeight&lt;/code&gt; or you cut the wrong amount on a retina display.&lt;/p&gt;

&lt;p&gt;If the crop fails, I keep the raw last frame. A duplicated band is worse than a failed export, but a failed export is worse than a slightly messy PNG. The crop is non-blocking.&lt;/p&gt;

&lt;p&gt;Frames cannot sit in the service worker as one giant message. Long pages used to die on memory and IPC size. Each viewport is written into the local encoder’s filesystem as it arrives. Assembly is a vertical stack at the end. The worker never holds the whole poster.&lt;/p&gt;

&lt;p&gt;Cinema maps use this same capture family. A map that is too tall for a single raster (browser canvas limits are on the order of tens of thousands of pixels) must be refused &lt;strong&gt;before&lt;/strong&gt; a multi-minute scan. Failure after the loop is the worst moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sticky chrome is a stamp, not a header
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;position: fixed&lt;/code&gt; bar exists in every viewport. If you stack raw captures, you print that bar at every height you visited. The page looks like it has a header every screen, because it did - on screen.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;position: sticky&lt;/code&gt; is sneakier. At the top of the document it may still be in normal flow. Your first pass “find all fixed nodes” sees nothing. After the user (or your loop) scrolls, a script adds a class and the bar becomes fixed. Frame 0 is clean. Frames 1..N are tattooed.&lt;/p&gt;

&lt;p&gt;The procedure that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At the top, mark nodes that are already fixed or sticky.&lt;/li&gt;
&lt;li&gt;Capture frame 0 with the nav in its legitimate place.&lt;/li&gt;
&lt;li&gt;Hide marked nodes with &lt;code&gt;visibility: hidden&lt;/code&gt;, not &lt;code&gt;display: none&lt;/code&gt;. Sticky elements still occupy flow until they stick. &lt;code&gt;display: none&lt;/code&gt; reflows the layout you are measuring. &lt;code&gt;visibility&lt;/code&gt; keeps geometry and stops the pixels.&lt;/li&gt;
&lt;li&gt;After every later scroll, scan again for nodes that &lt;strong&gt;became&lt;/strong&gt; fixed, mark them, wait a paint, then capture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hiding starts after the first frame. The first frame is the only one where a top nav is allowed to exist in the poster.&lt;/p&gt;

&lt;p&gt;Scrollbars must be hidden for the whole scan. A scrollbar that appears and disappears changes the content width by a few pixels. &lt;code&gt;vstack&lt;/code&gt; then misaligns columns. That is independent of any “clean UI” feature.&lt;/p&gt;

&lt;p&gt;CSS transitions and scroll-triggered animation are frozen for the scan. A navbar mid-fade on a random frame is not a feature. Live video wants those animations. A poster does not. Different product, different freeze.&lt;/p&gt;

&lt;p&gt;Lazy images in the current viewport get a short wait, then we continue. An unbounded wait is a hung capture. Black bands in the poster are the failure mode of not waiting at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup has to run even when capture fails
&lt;/h2&gt;

&lt;p&gt;Injected styles, hidden sticky flags, and a forced scroll position must come off in &lt;code&gt;finally&lt;/code&gt;. If the loop throws on frame 12, leaving &lt;code&gt;cursor: none&lt;/code&gt; and frozen animations on the user’s tab is a bug they will remember longer than a missing PNG.&lt;/p&gt;

&lt;p&gt;Window resize is not part of the historical PNG path. It &lt;strong&gt;is&lt;/strong&gt; part of a Cinema map: the page must already be at output width so the bitmap aspect matches the video. A tall phone layout reflows; the map gets longer. That is why the height check exists before the loop, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PNG is for
&lt;/h2&gt;

&lt;p&gt;A stitched PNG is the right artifact when someone needs a layout audit, a mockup, or a still for a ticket. It is the wrong artifact when they needed motion, hover, or a playing video. Cinema is “this PNG, then a camera.” Live is “never this PNG.”&lt;/p&gt;

&lt;p&gt;The header ghost and the doubled footer are not random quality issues. They are the scan model asserting itself. Once I named the last-slice clamp and the delayed &lt;code&gt;fixed&lt;/code&gt; class, the glue became boring - which is what you want from glue.&lt;/p&gt;

&lt;p&gt;The still export in &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt; is that scan, cropped and destamped as far as the page will allow. Some overlays still win. The geometry does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;If a stitched screenshot reprinted a header on you, I want that page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I turned this experiment into &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt;, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>programming</category>
      <category>productivity</category>
      <category>discuss</category>
      <category>performance</category>
    </item>
    <item>
      <title>The page and the encoder are different clocks</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Sat, 29 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/kenyalba/-the-page-and-the-encoder-are-different-clocks-ge2</link>
      <guid>https://dev.to/kenyalba/-the-page-and-the-encoder-are-different-clocks-ge2</guid>
      <description>&lt;h2&gt;
  
  
  Three times, one file
&lt;/h2&gt;

&lt;p&gt;This is the third post in a series about auto-scroll capture. After choosing live versus a frozen map, the live path still has a quieter bug: my scroll loop, the compositor, and the video encoder do not share a clock.&lt;/p&gt;

&lt;p&gt;I spent too long trying to force them into one number. The recordings got worse.&lt;/p&gt;

&lt;p&gt;Auto-scroll in the page is a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop. Each tick I compute a delta from real elapsed time, move the document, and hope the next paint includes that position.&lt;/p&gt;

&lt;p&gt;The compositor produces frames on its own cadence. A 144 Hz display does not paint 144 unique page states into a capture. Tab capture samples what is on screen when it can.&lt;/p&gt;

&lt;p&gt;The encoder then writes a compressed stream. For live WebM that stream is typically &lt;strong&gt;variable frame rate&lt;/strong&gt;: one encoded frame per captured paint, not a metronome. Many players treat VFR as stutter. The file is still a more accurate diary of capture than a resampled clip that invented timestamps.&lt;/p&gt;

&lt;p&gt;If I pretend these are one clock, I optimize the wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I stopped smoothing the scroll delta
&lt;/h2&gt;

&lt;p&gt;The speed presets are defined in “pixels per tick” at a notional 60 Hz. Real ticks are not 60 Hz. Under load - high resolution plus a live encoder - a frame callback can arrive late.&lt;/p&gt;

&lt;p&gt;If I scale the scroll step by a large catch-up factor, the page &lt;strong&gt;teleports&lt;/strong&gt; in a single paint. The encoder, if it captures that paint, stores a jump. The screen may have felt only a hitch. The file looks like a cut.&lt;/p&gt;

&lt;p&gt;If I clamp catch-up too hard, the recording lags the intent of the speed preset. I chose a modest cap: better a slightly slower capture than a hundred-pixel jump in one frame.&lt;/p&gt;

&lt;p&gt;I also tried an exponential moving average on &lt;code&gt;dt&lt;/code&gt;. That made displacement even &lt;strong&gt;per painted frame&lt;/strong&gt; and uneven &lt;strong&gt;in real time&lt;/strong&gt;. Capture samples time, not frame index. A long frame that moves twice as far and stays on screen twice as long is the correct live motion. A smoothed &lt;code&gt;dt&lt;/code&gt; made the page fall behind and then sprint. I removed the smoother.&lt;/p&gt;

&lt;p&gt;Sub-pixel remainder matters here. Rounding every tiny step to an integer pixel creates a staircase that the encoder dutifully records. I carry the fraction and apply whole pixels when I have them. One &lt;code&gt;scrollBy&lt;/code&gt; per paint. Never a catch-up burst of several steps in the same callback - that burst is one filmed jump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I do not constrain capture frame rate
&lt;/h2&gt;

&lt;p&gt;It is tempting to tell &lt;code&gt;getUserMedia&lt;/code&gt; a maximum frame rate so the file matches a UI setting.&lt;/p&gt;

&lt;p&gt;I tried it. On a high-refresh display, a forced period does not land on compositor beats. Chrome waits for the deadline, then takes the next paint. You skip two paints, then three, then two. Scroll advances in uneven steps for the entire take. That is the “page teleporting while I watch a smooth screen” failure, except now it is structural, not a load spike.&lt;/p&gt;

&lt;p&gt;Without a frame-rate cap, capture follows content. Gaps between encoded frames match the compositor more closely. The UI can still offer a cadence for &lt;strong&gt;offline&lt;/strong&gt; render and for transcode. Live capture does not get to fake that cadence at the sensor.&lt;/p&gt;

&lt;p&gt;Software VP8 in the live recorder is CPU-bound. When pixel throughput is too high, the encoder drops input frames irregularly. The compositor is fine. The file is not. The fix that worked was reducing &lt;strong&gt;definition&lt;/strong&gt;, not demanding a higher frame rate from a pipe that cannot carry it. A slightly softer image is easier to forgive than a slideshow labeled as 60 fps.&lt;/p&gt;

&lt;p&gt;Bitrate has to follow real width × height × actual capture rate. Indexing bitrate on a quality label while encoding native retina resolution starves bits per pixel. The picture goes soft and the encoder is still drowning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoding is a fourth clock, on the same machine
&lt;/h2&gt;

&lt;p&gt;Service workers cannot run &lt;code&gt;MediaRecorder&lt;/code&gt;. Capture and transcode live in an offscreen document. FFmpeg is bundled as WebAssembly. No remote encode.&lt;/p&gt;

&lt;p&gt;Default live WebM is the recorder blob, VFR, download immediately. That is the file that matches the take.&lt;/p&gt;

&lt;p&gt;A constant-frame-rate re-encode exists as a playback convenience. It is slower, generational, and can exhaust WASM’s 32-bit heap on large clips. I would rather ship the raw take than crash the export. If a container conversion fails, I keep the original recording under an honest extension (WebM or Matroska, not a lying &lt;code&gt;.mp4&lt;/code&gt; name).&lt;/p&gt;

&lt;p&gt;GIF is a special case of the same memory physics. Building a palette from every source frame at full rate OOMs. I reduce temporal resolution &lt;strong&gt;before&lt;/strong&gt; palette generation. A 4K request is not a 4K GIF if the WASM heap cannot hold it.&lt;/p&gt;

&lt;p&gt;MP4 is fastest when the live recorder already produced H.264 and I can remux without a second encode. Full software x264 in WASM is a different, much slower job. Timeouts that assume “encode always finishes in N seconds” kill healthy long transcodes. An inactivity watchdog (no log, no progress) is the useful one. A ticker that pings “still working” forever hides a deadlock.&lt;/p&gt;

&lt;p&gt;Cinema, from the previous post, is the escape hatch: an offline clock, &lt;code&gt;frame / fps&lt;/code&gt;, hardware or WebCodecs encode, then a remux. It does not fix live VFR. It refuses the live time base.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;Do not make scroll catch up to a late &lt;code&gt;rAF&lt;/code&gt; with a huge step. The encoder will publish the lie.&lt;/p&gt;

&lt;p&gt;Do not force capture frame rate to flatter a settings menu. You will alias the compositor.&lt;/p&gt;

&lt;p&gt;Do not treat “1080p” as a promise about the live file. Treat it as a pixel budget and a bitrate, then tell the truth about VFR.&lt;/p&gt;

&lt;p&gt;Local encode is the product constraint, not an implementation detail. The machine that showed the page is the machine that compresses it. When it cannot, a smaller honest file beats a crashed tab.&lt;/p&gt;

&lt;p&gt;The live export in &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt; is the compositor’s diary. The Cinema path is a score I conduct. Mixing the metaphors in one pipeline is how the stutter got “mysterious.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;If you have seen a smooth screen turn into a stuttering file, I want that case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I turned this experiment into &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt;, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>Live capture vs a frozen map: two honest ways to record a scrolling page</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/kenyalba/live-capture-vs-a-frozen-map-two-honest-ways-to-record-a-scrolling-page-32b6</link>
      <guid>https://dev.to/kenyalba/live-capture-vs-a-frozen-map-two-honest-ways-to-record-a-scrolling-page-32b6</guid>
      <description>&lt;p&gt;This is the second post in a series about auto-scroll capture in a Chrome extension. The first post was the problem. This one is the fork I had to make: film the live tab, or render motion from a still image of the page.&lt;/p&gt;

&lt;p&gt;They look similar in the UI. They are not the same product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same intent, two physics
&lt;/h2&gt;

&lt;p&gt;I wanted a recording that moved down a site without my hand on the wheel. That sentence hides a choice.&lt;/p&gt;

&lt;p&gt;Either the file is a recording of &lt;strong&gt;what the tab painted&lt;/strong&gt;, in real time, while something else drives scroll.&lt;/p&gt;

&lt;p&gt;Or the file is a &lt;strong&gt;reconstruction&lt;/strong&gt;: capture the page once as a tall bitmap, then move a virtual camera across that bitmap on a clock I control.&lt;/p&gt;

&lt;p&gt;Both are local. Nothing is sent to a render farm. They fail in opposite ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live capture: the tab is the source of truth
&lt;/h2&gt;

&lt;p&gt;Live capture uses Chrome’s tab recording path. Auto-scroll runs in the page. The encoder writes whatever the compositor put on screen.&lt;/p&gt;

&lt;p&gt;If a video is playing, it is in the file. If a menu opens on hover, it is in the file. If a WebGL scene reacts to wheel input, that reaction is in the file. I am not inventing those pixels. I am sampling them.&lt;/p&gt;

&lt;p&gt;That honesty has a cost. The encoder is bound to wall-clock time. It cannot pause the universe to finish a heavy frame. When the software video encoder is overloaded, Chrome does not slow the page down. It drops frames. The screen can still look smooth while the file becomes a slideshow.&lt;/p&gt;

&lt;p&gt;Tab capture also has geometry quirks. It does not automatically mean “the quality label the user picked.” It films the tab in screen pixels, then fits that into a constraint box. A box with the wrong aspect ratio letterboxes the content: black bars that you will stitch into a map if you are not careful. I size the box from the tab’s real aspect and I never ask capture to upscale. Extra pixels are interpolation, paid for by the encoder.&lt;/p&gt;

&lt;p&gt;The live scroller and the recorder are only loosely coupled. Prepare the page, start recording, then start scroll - except when I need the load animation, in which case the recorder must start &lt;em&gt;before&lt;/em&gt; the reload. There is no shared frame index. Time in the file is “when the compositor happened to emit a frame,” not “tick N of my scroll loop.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Cinema: the map is the source of truth
&lt;/h2&gt;

&lt;p&gt;Cinema mode does the opposite. It builds a full-page still — the same family of problem as a stitched PNG — then treats that image as a set. A virtual camera has a position, a speed, an easing curve, and a shutter.&lt;/p&gt;

&lt;p&gt;Because the page is a bitmap, I can sample several camera positions inside one output frame and average them. That is real motion blur: the integral of the shutter, not a directional smear guessed from velocity. Timing is &lt;code&gt;frame index / fps&lt;/code&gt;. If I ask for a given cadence, I get that cadence. Dropped live frames are not part of this model.&lt;/p&gt;

&lt;p&gt;The map’s aspect has to be the output’s aspect &lt;strong&gt;before&lt;/strong&gt; capture. Format first, map second. If I resize the window after the map exists, zoom-1 no longer frames the page. The timeline of scroll positions can survive a recapture. The pixels cannot.&lt;/p&gt;

&lt;p&gt;What the map cannot contain, the render cannot show. Hover that was never open. A &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; that was paused or never in view. WebGL that is a live scene, not a screenshot. CSS that animates on a timer rather than on scroll position. Cinema is precise about framing and rhythm. It is silent about anything that needs the page to keep running.&lt;/p&gt;

&lt;p&gt;GIF export stays on the live path. Building a palette from a compositor-style render is a different, heavier job. I did not pretend Cinema could own every container.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I choose
&lt;/h2&gt;

&lt;p&gt;I treat this as a question about &lt;strong&gt;what must be true in the file&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the demo is “this landing page has a looping hero and a scroll-triggered scene,” live is the only honest tool. Cinema would freeze the hero.&lt;/p&gt;

&lt;p&gt;If the demo is “move down this article with a stable speed and a readable blur,” Cinema is the tool. Live will give me variable frame timing and whatever the encoder survived.&lt;/p&gt;

&lt;p&gt;A stitched PNG is the third deliverable: no motion at all. It shares the map pipeline’s sticky-header problem, which is the next post. It does not share Cinema’s camera.&lt;/p&gt;

&lt;p&gt;I do not blend the two in one file. A hybrid that “mostly” uses a map and “sometimes” samples the live tab would lie about both clocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Live still cannot see into a cross-origin iframe. Cinema cannot either: the map is a screenshot of the outer page.&lt;/p&gt;

&lt;p&gt;Live still goes black if the tab is hidden. Cinema’s map capture has the same constraint while the still is being taken, then the render is offline.&lt;/p&gt;

&lt;p&gt;A page with no real scrollbar does not give Cinema a meaningful map height, and it does not give live auto-stop. That is a driving problem, not a compositing problem.&lt;/p&gt;

&lt;p&gt;The useful discipline was naming the fork in the product, not hiding it. “Record the tab” and “render a camera over a poster” are both capture. They are not interchangeable.&lt;/p&gt;

&lt;p&gt;If you ship site demos, the question is which lie you refuse: lost interactivity, or lost timing.&lt;/p&gt;

&lt;p&gt;ScrollFlow keeps both engines on auto-scroll for that reason. I would rather the user pick an honest mode than get a file that looks fine and explains nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;https://bit.ly/try-scrollflow&lt;/a&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>discuss</category>
      <category>showdev</category>
      <category>tools</category>
    </item>
    <item>
      <title>How I built a browser extension that records a full web page while auto-scrolling</title>
      <dc:creator>Keny Alba</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:03:26 +0000</pubDate>
      <link>https://dev.to/kenyalba/how-i-built-a-browser-extension-that-records-a-full-web-page-while-auto-scrolling-2bcj</link>
      <guid>https://dev.to/kenyalba/how-i-built-a-browser-extension-that-records-a-full-web-page-while-auto-scrolling-2bcj</guid>
      <description>&lt;h2&gt;
  
  
  Why a screenshot was not enough
&lt;/h2&gt;

&lt;p&gt;I needed recordings of websites that moved the way a person would scroll them: a pause near a headline, a steady pass through a long article, a stop at the footer.&lt;/p&gt;

&lt;p&gt;A full-page screenshot showed the layout. It did not show motion, hover, lazy-loaded blocks filling in, or the page-load sequence. It was a poster of the site, not a demo of using it.&lt;/p&gt;

&lt;p&gt;A manual screen recording showed those things, and also my hand on the wheel. The speed was uneven. I skipped sections. The cursor wandered. A sticky header covered the title I meant to show.&lt;/p&gt;

&lt;p&gt;That gap is why I built &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt; as a Chrome extension. It drives the scroll itself and records on the user’s machine. It does not record every website correctly. The rest of this post is about why that is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  A webpage is moving target
&lt;/h2&gt;

&lt;p&gt;A webpage is not a PDF. While you travel down it, the document keeps changing.&lt;/p&gt;

&lt;p&gt;Lazy-loaded images and extra sections arrive after you thought you had reached the bottom. A cookie banner or promo bar collapses, and the “bottom” you measured a second ago is no longer there. Sticky and fixed chrome sit in every viewport. If you stitch screenshots, that chrome stamps itself onto every slice.&lt;/p&gt;

&lt;p&gt;Scroll-snap, smooth-scroll libraries, and custom overflow containers often ignore a simple “scroll to Y” command, or they fight it with their own animation loop. Canvas and WebGL scenes may have no real scrollbar at all, so the browser cannot tell you when the story is over.&lt;/p&gt;

&lt;p&gt;Capture films what the tab is actually showing, not what your scroll function believes. If the encoder cannot keep up, frames disappear while the screen still looks smooth. If the tab is in the background, the recording can go black.&lt;/p&gt;

&lt;p&gt;The hard problem is not calling scroll. It is keeping a changing document, a browser capture API, and an encoder honest about what landed in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach: record live, render when needed
&lt;/h2&gt;

&lt;p&gt;I did not send pages to a remote rendering backend. Recording and encoding run on the user’s device. The finished file downloads locally.&lt;/p&gt;

&lt;p&gt;There are two ways to produce motion, and they answer different questions.&lt;/p&gt;

&lt;p&gt;Live capture films the actual tab while auto-scroll runs. Animations, video, hover, and WebGL that are on screen appear in the file because they were really there. Timing is whatever the browser delivered that day. That is the path I use when the page is alive.&lt;/p&gt;

&lt;p&gt;Cinema mode captures the page once as a still full-page map, then flies a virtual camera over that image. Timing is controlled. Motion can look deliberate because the “page” is a bitmap. Hover menus, playing video, and WebGL are not in that bitmap, so they cannot appear. I use this when I need a clean move down a mostly static layout.&lt;/p&gt;

&lt;p&gt;A stitched full-page PNG is still useful when a still is the deliverable. It is a scan of viewports, with sticky chrome handled as a special case, not a screenshot of the whole document object.&lt;/p&gt;

&lt;p&gt;Exports stay on the machine: the live recording, a local conversion when a different container is needed, or a composited file from the map. If a conversion fails, the capture is not thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where browser automation gets difficult
&lt;/h2&gt;

&lt;p&gt;Four cases ate most of the work.&lt;/p&gt;

&lt;p&gt;Lazy-loaded content lies about height. Auto-scroll can hit a fake bottom, notice that the document grew, and continue. It can also unlock if a banner collapsed and the real bottom moved. Infinite feeds and images that arrive late still surprise it. There is no complete answer, only a better guess than stopping on the first pause.&lt;/p&gt;

&lt;p&gt;Sticky and fixed UI reprints itself. For a full-page image I keep the header on the first slice and hide it on the rest, then look again after each step. Many navs are ordinary until you scroll, then they become fixed. Overlay cleanup hides chrome without deleting nodes, and it still guesses wrong on some widgets.&lt;/p&gt;

&lt;p&gt;Scroll-snap and custom scroll engines fight linear motion. Pixel-by-pixel scrolling on a snapped marketing site looks like the page is arguing with you. Ordinary auto-scroll turns snap off. A stepped, impulse-style move leaves snap on, because those sites use it as the section cut. Smooth-scroll libraries remember an old position and restore it after you jump to the top, so the start of a capture has to hold the page there until they settle. Overflow boxes that actually own the scroll need extra handling. Some pages still win.&lt;/p&gt;

&lt;p&gt;Canvas, WebGL, and dynamically rendered scenes often have no usable scrollbar. If the document barely scrolls, I keep moving at a constant speed and I do not auto-stop. The user has to end the recording. That is the honest outcome for a scene that never exposed a height. Cinema mode cannot invent a running canvas from a still map.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;I learned four things I wish I had believed on day one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A screenshot and a recording are different products. Freezing animation and hiding sticky chrome is correct for a poster and wrong for a scroll-driven demo.&lt;/li&gt;
&lt;li&gt;The page, the compositor, and the encoder are three clocks. Forcing them into one number made the video look worse than accepting what the live tab actually produced.&lt;/li&gt;
&lt;li&gt;Local encoding is a feature and a limit. Work stays on the device. Heavy conversions can fail, so a lesser local file is better than a crashed export.&lt;/li&gt;
&lt;li&gt;Browser APIs dominate the design. A background script cannot record media. A side panel does not grant tab access the way a toolbar popup does. Hidden tabs go black. Those constraints decided the architecture more than any UI idea.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What still breaks
&lt;/h2&gt;

&lt;p&gt;Cross-origin iframes are a separate document. If the real page lives in a frame, auto-scroll may move the wrong thing or nothing useful.&lt;/p&gt;

&lt;p&gt;If you switch away from the tab, the capture can go black. There is a warning. There is no recovery.&lt;/p&gt;

&lt;p&gt;Horizontal pages are out of scope. The driver moves on the vertical axis.&lt;/p&gt;

&lt;p&gt;Complex WebGL and custom-scroll scenes with no real scrollbar do not auto-finish. You stop them by hand. Cinema mode will not reconstruct hover, video, or a running canvas from a frozen map.&lt;/p&gt;

&lt;p&gt;I do not claim this works on every website. The difficult pages are why the work exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;If you have fought sticky headers in stitched screenshots, custom scroll fighting a programmatic move, or a recording that does not match what you saw on screen, I want those cases.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I turned this experiment into &lt;a href="https://bit.ly/try-scrollflow" rel="noopener noreferrer"&gt;ScrollFlow&lt;/a&gt;, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>productivity</category>
      <category>discuss</category>
      <category>tools</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
