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.
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)
Now that have all these vibecoded clones of Photoshop which don't work properly, is anyone currently working on creating a compatibility layer to make the real thing work?
Skip to the question at the bottom if you aren't interested in my preamble.
I have tried the vibecoded Photoshop clones that people are talking about all over the internet, and there are issues with both of them.
The quick select tool doesn't work in Photon at all, and the font handling is wonky. It is also a slightly stuttery experience to use compared to alternatives. The developer is also an extremely arrogant person who acts like the second coming because he made it "first", and claims anyone who brings up issues with the program is a liar or a bot. (example: reddit.com )
Photocraft doesn't currently have save as PDF support and there are a few niggly functions buried in menus which seemingly don't do anything at all currently, the people who made it do however seem to be much more polite and professional when faced with questions, and they have an active feature update and bugfix roadmap, which is nice. The creator has the deliberate rage-bait username "ai_art_is_art" and a bio talking about how people who don't like AI are luddites though, so I do not want to use it solely on principle.
These kinds of people are not our friends.
Photopea is a long standing alternative which was actually coded properly instead of using AI, although it has introduced AI elements and I wouldn't be shocked if recent updates are AI assisted. Unfortunately again, the quick selection tool does not work at all. You can mess around with it to get it to kind of work, but it still isn't right. The developer doesn't know how to fix it, and doesn't seem interested in trying. Non starter... (source: photopea#2776 )
The WinApps (and similar) VM passthrough solutions are no good, because even if you manage to get them to work as intended, you are running an entire Windows VM on your PC just to have one program open. There is also no GPU acceleration available (unless you fit a physical second GPU and configure GPU pass-through, which is not viable for most people) which makes using Photoshop this way a very laggy experience.
Back in January someone released a Proton version intended to allow Photoshop and possibly other Adobe CS applications to work in Linux without a VM, however the latest version "supported" is Photoshop 2021, newer versions fail to install or fail to open, and more recently it has seemingly stopped working for even Photoshop 2021, with an error saying it cannot connect to the creative cloud. There is no workaround for this.
If anyone mentions GIMP as a viable alternative I will slap them.
SO
My question is this, are there currently any projects being worked on to allow Photoshop and perhaps other Adobe CS apps to work directly on Linux? If people can use AI vibecoding to create approximate and mostly functional clones of entire programs, surely someone can use that technique to make a compatibility layer which just works?
If you know of any such projects, or if you are attempting to work on this yourself, I would love to know about it and possibly discuss further.
69 comments
This is crazy. Someone then pushed an AI bot to find copyright violations of a 14 year old product ( pre AI ).
What a meat proxy lmaoo
photopea#9042 t.co
114 views
Thanks a lot!
1) About 30% of my original code is open-source as JS libraries: they end with ".js"-github.com/photopea
2) WASM is not faster than Javascript "by default". Both can be fast or slow depending on how you write it. I don't think having everything in WASM would make Photopea significantly faster. Also, Photopea uses many libraries as WASM (WEBP decoding, ZSTD decoding, JPG decoding, Harfbuzz, ... JXL / AVIF encoding)
3) I never used any Framework, but if it is just a bunch of a JS code, I think I managed to write an equally good JS code for the UI over the past 14 years.
Then why are you bullying photopea by posting slop from alt accounts?
photopea#9042
This is childish behavior.
I just find it surprising that the author is complaining about "stolen code" while having been piggybacking (and earning money) from unattributed OSS code for years... photopea#9042
Mentions
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.
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)
Now that have all these vibecoded clones of Photoshop which don't work properly, is anyone currently working on creating a compatibility layer to make the real thing work? Skip to the question at the bottom if you aren't interested in my preamble. I have tried the vibecoded Photoshop clones that people are talking about all over the internet, and there are issues with both of them. The quick select tool doesn't work in Photon at all, and the font handling is wonky. It is also a slightly stuttery experience to use compared to alternatives. The developer is also an extremely arrogant person who acts like the second coming because he made it "first", and claims anyone who brings up issues with the program is a liar or a bot. (example: reddit.com ) Photocraft doesn't currently have save as PDF support and there are a few niggly functions buried in menus which seemingly don't do anything at all currently, the people who made it do however seem to be much more polite and professional when faced with questions, and they have an active feature update and bugfix roadmap, which is nice. The creator has the deliberate rage-bait username "ai_art_is_art" and a bio talking about how people who don't like AI are luddites though, so I do not want to use it solely on principle. These kinds of people are not our friends. Photopea is a long standing alternative which was actually coded properly instead of using AI, although it has introduced AI elements and I wouldn't be shocked if recent updates are AI assisted. Unfortunately again, the quick selection tool does not work at all. You can mess around with it to get it to kind of work, but it still isn't right. The developer doesn't know how to fix it, and doesn't seem interested in trying. Non starter... (source: photopea#2776 ) The WinApps (and similar) VM passthrough solutions are no good, because even if you manage to get them to work as intended, you are running an entire Windows VM on your PC just to have one program open. There is also no GPU acceleration available (unless you fit a physical second GPU and configure GPU pass-through, which is not viable for most people) which makes using Photoshop this way a very laggy experience. Back in January someone released a Proton version intended to allow Photoshop and possibly other Adobe CS applications to work in Linux without a VM, however the latest version "supported" is Photoshop 2021, newer versions fail to install or fail to open, and more recently it has seemingly stopped working for even Photoshop 2021, with an error saying it cannot connect to the creative cloud. There is no workaround for this. If anyone mentions GIMP as a viable alternative I will slap them. SO My question is this, are there currently any projects being worked on to allow Photoshop and perhaps other Adobe CS apps to work directly on Linux? If people can use AI vibecoding to create approximate and mostly functional clones of entire programs, surely someone can use that technique to make a compatibility layer which just works? If you know of any such projects, or if you are attempting to work on this yourself, I would love to know about it and possibly discuss further.
This is crazy. Someone then pushed an AI bot to find copyright violations of a 14 year old product ( pre AI ). What a meat proxy lmaoo photopea#9042 t.co
Thanks a lot! 1) About 30% of my original code is open-source as JS libraries: they end with ".js"-github.com/photopea 2) WASM is not faster than Javascript "by default". Both can be fast or slow depending on how you write it. I don't think having everything in WASM would make Photopea significantly faster. Also, Photopea uses many libraries as WASM (WEBP decoding, ZSTD decoding, JPG decoding, Harfbuzz, ... JXL / AVIF encoding) 3) I never used any Framework, but if it is just a bunch of a JS code, I think I managed to write an equally good JS code for the UI over the past 14 years.
Then why are you bullying photopea by posting slop from alt accounts? photopea#9042 This is childish behavior.
I just find it surprising that the author is complaining about "stolen code" while having been piggybacking (and earning money) from unattributed OSS code for years... photopea#9042