UPNG.js

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

2posts · 30d
Nov 40 posts
  1. Why canvas.toBlob() Doesn't Compress PNGs, and What Actually Works in the Browser I run a small browser-based file converter, and for a while my PNG compressor had a quiet bug: it did not compress anything. The code looked reasonable: canvas.toBlob((blob) => resolve(blob), 'image/png', 0.8); Draw the image to a canvas, export it as PNG with quality 0.8, done. The output was usually the same size as the input, and sometimes bigger. Here is why, and what I replaced it with. The quality argument only applies to lossy formats The quality parameter of toBlob() and toDataURL() is only used for lossy formats such as image/jpeg and image/webp. For image/png the browser ignores it. You get whatever its built-in PNG encoder produces, and you have no control over filters or compression level. So canvas.toBlob(cb, 'image/png', 0.8) is just a re-encode. If the source PNG was already written by a decent encoder, you gain nothing. Where the savings in a PNG actually are A PNG is a pixel grid, a per-row filter, and DEFLATE on top. There are two ways to make the file smaller: 1. Lossless re-encoding. Try better filters and a stronger DEFLATE setting. The pixels stay identical, but savings are usually small, often single-digit to low double-digit percent. 2. Palette quantization. Reduce the image from 32-bit RGBA to an indexed palette of up to 256 colors, keeping the alpha channel. This is lossy, but for screenshots, logos, icons and UI graphics it is usually invisible, and the files get much smaller. Tools like TinyPNG get most of their savings from option 2. Doing it in the browser with UPNG.js and pako UPNG.js is a small PNG encoder/decoder from the Photopea author. It uses pako for DEFLATE. Its encode() takes raw RGBA data and a color count: 0 means lossless, 256 means quantize to a 256-color palette. <script src="/assets/js/pako.min.js"></script> <script src="/assets/js/UPNG.js"></script> function compressPng(file, mode) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data.buffer; const candidates = [UPNG.encode([rgba.slice(0)], canvas.width, canvas.height, 0)]; if (mode === 'smart') { candidates.push(UPNG.encode([rgba.slice(0)], canvas.width, canvas.height, 256)); } const best = candidates.reduce((a, b) => (b.byteLength < a.byteLength ? b : a)); URL.revokeObjectURL(url); // never hand back a file bigger than the original resolve(best.byteLength < file.size ? new Blob([best], { type: 'image/png' }) : file); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Could not read PNG')); }; img.src = url; }); } A few details that matter: - getImageData() returns non-premultiplied RGBA, which is what UPNG expects, so transparency survives. - UPNG.encode() may use the buffer you pass it, so pass rgba.slice(0) when you encode twice. - Comparing the candidates and falling back to the original file means users never get a "compressed" file that is larger. What the numbers look like I tested a 1000x700 synthetic image, a noisy gradient with a transparent strip at the top. This is a hard case, not a typical one: | Mode | Size | |---|---| | Original | 1.50 MB | | Lossless (cnum = 0) | 1.43 MB (−5%) | | 256-color palette | 231 KB (−85%) | The lossless number is the honest result of re-encoding: a few percent. The big win comes from quantization. Your own images will vary, so treat these as an illustration and not a benchmark. When not to quantize Palette reduction can cause banding in smooth gradients and photos. For those, you have better options: - Keep lossless mode available, so users who need pixel-exact output can choose it. - Convert photos to JPEG or WebP. canvas.toBlob(cb, 'image/webp', 0.8) does honor the quality value, and WebP supports transparency. - Reduce dimensions if the image is larger than it will ever be displayed. I ended up with two modes in the UI: "Smart" (tries both and keeps the smaller) and "Lossless". Privacy comes for free Because everything above runs in the browser, the image never leaves the device. No upload, no server to trust, nothing to delete afterwards. That was the reason I built the tool in the first place, and it is a real advantage for screenshots with private data or unreleased designs. If you want to see the result, the compressor is here: oneweeb.com. It is free, with no sign-up. Takeaways - quality is ignored for image/png in toBlob(). - Real PNG savings come from palette quantization, not from re-encoding. - Offer a lossless mode, compare outputs, and never return a bigger file than the input. - Client-side processing keeps user files private. Have you run into other canvas export surprises? I would like to hear about them in the comments.

  2. Zenn · · @stampchan

    1枚のイラストからLINEの動くスタンプ(APNG)をブラウザで作る:動きの関数とループ回数の書き換え LINEの動くスタンプ(アニメーションスタンプ)は、1個ずつAPNG形式の画像にして申請します。この記事では、1枚の静止画に「ぴょんぴょん」「ゆらゆら」などの動きをつけて、LINEの規格に合ったAPNGをブラウザだけで書き出す実装を紹介します。画像はサーバーに送りません。 途中で1つ、はまりやすい点があります。JavaScriptのAPNGエンコーダー(upng-js)でそのまま書き出すと、ループ回数が「0(無限)」になります。LINEはループを1〜4回と決めているので、ここを書き換える必要があります。 LINE側の規格 LINE Creators Marketの制作ガイドラインで決められている内容です(2026年10月22日に確認)。 項目 動くスタンプ アニメーション絵文字 サイズ 最大320×270px(縦か横のどちらかが270px以上) 180×180px 個数 8・16・24個 8〜40個 フレーム数 5〜20 5〜20 再生時間 4秒まで 4秒まで ループ 1〜4回(4秒を超えない範囲) 1〜4回(4秒を超えない範囲) 容量 1個1MB以下 1個300KB以下 ほかに、次の2点がガイドラインに書かれています。 すべてのフレームが同じ画像だと、アニメーションとして動かず、アップロード時にエラーになる ショップでは、APNGの1フレーム目が静止画として表示される 先に、この方法で書き出した結果を載せます。元のAPNGは320×270、12フレーム、1ループ1秒、2回ループ、約680KBです。 ! ZennではAPNGがアニメーションとして表示されないため、同じ12フレームをGIFにして載せています。GIFは表示時間を10ms単位でしか持てないので、1フレーム80msで、くり返し再生になっています。 1枚の静止画から書き出した動くスタンプ(表示用にGIFへ変換) 1. 動きを「t → 変形」の関数にする 動きのテンプレートは、1ループの中の位置 t(0以上1未満)を受け取って、移動・拡大縮小・回転・不透明度を返す関数にしています。どの動きも t = 1 で最初の状態に戻るようにしておくと、ループのつなぎ目で絵が飛びません。 type Transform = { dx: number; dy: number; scaleX: number; scaleY: number; rotate: number; alpha: number; }; function motion( preset: string, t: number, width: number, height: number ): Transform { const base = { dx: 0, dy: 0, scaleX: 1, scaleY: 1, rotate: 0, alpha: 1 }; const wave = Math.sin(2 * Math.PI * t); switch (preset) { case 'bounce': // ぴょんぴょん:1ループで2回跳ねる return { ...base, dy: -Math.abs(Math.sin(2 * Math.PI * t)) * height * 0.1, }; case 'squash': // ぽよん:床でつぶれて伸びる return { ...base, scaleX: 1 + 0.07 * wave, scaleY: 1 - 0.07 * wave }; case 'shake': // ぷるぷる:左右に小さくふるえる return { ...base, dx: Math.sin(2 * Math.PI * 3 * t) * width * 0.025 }; case 'sway': // ゆらゆら:足元を中心に左右に揺れる return { ...base, rotate: ((8 * Math.PI) / 180) * wave }; case 'pulse': // ドキドキ:大きくなって小さくなる return { ...base, scaleX: 1 + 0.08 * wave, scaleY: 1 + 0.08 * wave }; default: // 点滅:薄くなって戻る return { ...base, alpha: 0.35 + 0.65 * (0.5 + 0.5 * Math.cos(2 * Math.PI * t)), }; } } 「ぴょんぴょん」は |sin| を使っているので、1ループで2回跳ねて、t = 0 と t = 0.5 で床に戻ります。12フレームなら、1枚目と7枚目が着地の位置です。 図1:「ぴょんぴょん」の上への移動量(キャンバスの高さ270pxの10%まで) 2. 動きに必要な余白を、先に取っておく キャンバスは規格の上限(動くスタンプは320×270)に固定し、そこに絵を縮小して置きます。このとき、動きごとに必要な余白が違います。跳ねるなら上に、左右にふるえるなら横に余白が要ります。全部の方向に同じだけ余白を取ると、キャラクターが必要以上に小さくなります。 // キャンバスに対する割合 const ROOM = { bounce: { x: 0.02, top: 0.12, bottom: 0.02 }, squash: { x: 0.08, top: 0.06, bottom: 0.02 }, shake: { x: 0.05, top: 0.02, bottom: 0.02 }, sway: { x: 0.09, top: 0.05, bottom: 0.02 }, pulse: { x: 0.07, top: 0.07, bottom: 0.07 }, blink: { x: 0.02, top: 0.02, bottom: 0.02 }, }; 「ぴょんぴょん」は上に12%の余白を取り、跳ねる高さ(10%)がはみ出さないようにしています。 3. 足元を中心にして、フレームを描く 各フレームは canvas に描いて getImageData で取り出します(以下は実際のコードを短くしたものです)。基準点を絵の下の中央(足元)に置くと、「ぽよん」は足が床についたままつぶれ、「ゆらゆら」は足元を軸に揺れます。 for (let i = 0; i < frameCount; i++) { const m = motion(preset, i / frameCount, width, height); const ctx = canvas.getContext('2d', { willReadFrequently: true })!; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height); ctx.globalAlpha = m.alpha; ctx.translate(width / 2 + m.dx, restBottom + m.dy); // 足元へ移動 ctx.rotate(m.rotate); ctx.scale(m.scaleX, m.scaleY); ctx.drawImage(art, -art.width / 2, -art.height); // 足元が原点に来るように frames.push(ctx.getImageData(0, 0, width, height)); } t は i / frameCount なので、最後のフレームは1周の手前で終わり、次のループの1枚目へ自然につながります。 図2:書き出した12フレーム。1枚目と7枚目が着地、4枚目と10枚目がいちばん高い位置 「ぴょんぴょん」は1枚目が着地した状態なので、ショップで静止画として表示されても、絵が安定して見えます。 4. APNGに書き出して、ループ回数を書き換える エンコードには upng-js を使っています。各フレームの表示時間(ミリ秒)を配列で渡せます。 const delay = Math.round(loopMs / frames.length); // 1000ms ÷ 12 = 83ms const bytes = UPNG.encode( buffers, width, height, colors, frames.map(() => delay) ); ここで書き出したAPNGは、acTL チャンクの num_plays(ループ回数)が 0 になっています。upng-js 2.1.0のソースを見ると、acTL を書くところで num_plays に固定で 0 を入れています。0 はAPNGの仕様では「無限ループ」ですが、LINEのガイドラインは1〜4回です。そこで、書き出したバイト列から acTL を探して num_plays を書き換え、チャンクのCRC32も計算し直しています。 function setLoopCount(png: Uint8Array, loops: number): Uint8Array { const out = png.slice(); const view = new DataView(out.buffer); let offset = 8; // PNGシグネチャ while (offset + 12 <= out.length) { const length = view.getUint32(offset); const type = String.fromCharCode(...out.subarray(offset + 4, offset + 8)); if (type === 'acTL') { view.setUint32(offset + 12, loops); // data: num_frames(4) num_plays(4) view.setUint32( offset + 8 + length, crc32(out.subarray(offset + 4, offset + 8 + length)) ); return out; } offset += 12 + length; } throw new Error('acTL がありません'); } あわせて、解像度の pHYs チャンク(72dpi=1メートルあたり2835ピクセル)も入れています。実際に書き出したファイルのチャンクを読むと、次のようになっていました。 acTL num_frames=12 num_plays=2 fcTL delay=83/1000 秒(12フレームとも同じ) pHYs 2835 × 2835(72dpi) 5. 4秒ルールと容量を、書き出す前に守る ループ回数は、選べる上限を floor(4000 / 1ループの長さ) にしています。1ループが1秒なら4回まで、1.5秒や2秒なら2回まで、3秒や4秒なら1回だけ選べます。 const maxLoops = Math.max(1, Math.min(4, Math.floor(4000 / loopMs))); 容量は、まず色数を減らさずに書き出し、上限(動くスタンプ1MB、アニメーション絵文字300KB)を超えたら、色数を256、128、64、32と減らして書き直し、収まったところで止めます。それでも超えるときは、いちばん小さいファイルを返し、画面に「容量オーバー」と表示します。フレーム数を減らすのも、容量を下げるのに効きます。 まとめ 動きは t ∈ [0, 1) から変形を返す関数にすると、ループのつなぎ目が自然になる 余白は動きごとに必要な方向だけ取ると、キャラクターを大きく描ける 足元を基準点にすると、つぶれる・揺れる動きが自然に見える upng-js 2.1.0の出力は num_plays = 0 なので、LINE向けには acTL を1〜4回に書き換える この処理は、stampchanの無料ツール「APNG作成ツール」として公開しています。ブラウザの中だけで動き、画像はアップロードされません。動くスタンプの規格と、アプリで作る方法などは「LINEの動くスタンプの作り方|APNGの規格とアプリの使い方」にまとめています。 参考 LINE Creators Market 制作ガイドライン(アニメーションスタンプ) LINE Creators Market 制作ガイドライン(アニメーション絵文字) PNG Specification: acTL Animation Control Chunk UPNG.js(GitHub)

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
photopea/UPNG.js mentions in the last 30 days