🖼️

nostterに画像最適化機能つけたよ(フロント編)

2024/12/10に公開

nostterに画像最適化機能つけたよ(フロント編)

この記事はNostr Advent Calendar 2024 の10日目の記事です。

まえがき

nostterというのは日本人の開発者(雪猫さん)が開発している往年のTwitterライクなかわいいSNSクライアントです。WebアプリですがモバイルでもPWAとしてアプリのように利用可能です。私はAndroidにkiwiブラウザを入れた上で、PWAインストールして使用しています。

前回の記事で書いたように画像の最適化を行うAPIを作成したためせっかくなので広く使われているNostrクライアントで利用してもらいたいということで、今回プルリクエストを作成させていただきマージさせてもらいました。

というのも去年だったかfiatjafというNostrの発案者の人が「みんなでたくさんのクライアントを作るんじゃなくて、みんなで今あるクライアントを良くしていきなよ(うろ覚え)」と書いていたのですが、そのとおりだなと感じたので、そのときからnostterには貢献したいなと考えており、今回はいい機会だったのです。

まあ画像最適化プロキシ作っても使われなければ意味ないので…

Nostrの画像を扱う不安定性

Nostrの画像なにが来るかわからないのですが、Webのクライアントでは<img src=URL >という感じで突っ込んで表示しているのではないかと思います。今回プロキシを通すことでプロキシサーバ内部でエラーになるパターンが作る前からいろいろ想定できました。

  • 画像が大きすぎて計算に時間がかかり無料枠を超えてしまってエラーを返す
  • CloudflareのIPが画像の置かれているサーバにブロックされていて画像を取得できずエラーを返す
  • なんかわからないが想定外にエラーを返す

とくに影響したのがCloudflare workersの無料枠の制約がありCPU timeで10msecしか使用できないという問題で、テストしてみたところ4~5MB以上の画像だと必ずエラーになることがわかりました。そこでワーカーではまず画像のサイズを計算して4MB以上であれば画像を最適化せずデータをそのまま返すように対策をいれました。

対策したとしても防ぐことができないエラーもあり、実働してみるとエラーレートが10%前後という結果になっています。無料なので上出来というところですが、とはいえプロキシが死んでるから画像が表示されないというのでは困ってしまいます。

なんとしてもプロキシの安定性に頼らないでフロントで画像を安定して表示することを実現する必要がありました。

解決したい課題

  1. 画像プロキシサーバを使った画像最適化をクライアントに実装する
  2. その際プロキシサーバがエラーを返してもクライアント側でフォールバックして利用者が違和感を感じないようにする

つまりプロキシが存在することをユーザに悟らせなければ最高ということです。

ソリューション

そもそもの話ですがnostterはSvelteで実装されているので、画像最適化のフロント側もSvelteで開発しています。

成果物はコンポーネントライブラリとして分けて実装しておりこちらにあります。
一番基本的な部分は画像を表示するimg要素です。

<img
    id={imgId}
    width={imgSrc.w}
    height={imgSrc.h}
    {style}
    class={`${className} ${visibilityStyle}`}
    src={imgSrc.img}
    srcset={imgSrc.srcsets ? imgSrc.srcsets.map((s) => `${s.src} ${s.w}w`).join(', ') : ''}
    alt={alt}
    title={title}
    on:error={handleImgError}
    on:load={handleLoaded}
    loading="lazy"
/>

imgに対してデータを渡しているだけなので自明な部分は説明しません

srcset

srcsetを設定することで一つのimg要素に対して複数の画像のリソース候補を指定することができます。

詳しくはMDNを読んでくださいなのですが、画像のリソース候補にサイズの条件を設定してあげるとブラウザが最適なリソースを選択して読み込んでくれる仕組みになっています。
サイズ以外にピクセル密度の指定もできます。

今回はあんまり活用してないです。

className

親コンポーネントからこのImgコンポーネントのclassの属性にCSSのクラス名を渡すとこのimg要素のクラス名として透過的に渡せるようにしています。
こうすることでライブラリを使う側の親コンポーネント上でのCSSの指定をimg要素に当てることが比較的容易にできるようになります。

styleについても同様でImgコンポーネントにstyleを設定すると透過的にimgにstyleが適用されます。
ここらへんはいまいちSvelteのコンポーネントライブラリのベストプラクティスがわからず試行錯誤した部分です。

visibilityStyle

これは画像がフォールバックする際に読み込みに失敗した画像のalt属性が表示されてちらついてしまわないようにするためのCSSクラスです。
visibilityStyleの中身を動的に変えることで、エラーは表示せず読み込み終わった画像は表示するようにしています。

styleを以下のように指定してあげて、

<style>
	.visible {
		opacity: 1;
	}
	.invisible {
		opacity: 0;
	}
</style>

コード上でvisibilityStylevisibleを代入すれば表示されinvisibleを代入すれば非表示になります。

なぜdisplay: noneとかではなくopacityを使っているのかという疑問があると思いますが、safariのレンダリングの挙動の不具合で非表示にできないバグがあったためやむなくこうなっています。
そちらが解決したらdisplay: noneとかでいいと思います。

画像の表示の戦略としてはユーザが違和感をなるべく感じないように、読み込み前の初期値では画像は非表示(invisible)とし画像のフォールバックを含めた読み込み全体が完了したらはじめて表示(visible)に切り替える方針としました。

on:***

on:error={handleImgError}
on:load={handleLoaded}

on:errorで画像読み込み時のエラーをコールバックで受け取ってhandleImgErrorを呼び出します。
on:loadで画像読み込み完了のイベントをコールバックで受け取ってhandleLoadedを呼び出します。

基本的な考え方としてはこのイベントハンドラをつかって画像のフォールバックや表示・非表示の切り替えを行います。ただしこのイベントの呼ばれ方が結構自分の想定と異なる挙動をして苦労したところでもあります。

フォールバックの実装

プロキシからの画像の読み込みに失敗した場合に画像のもとソースであるプロキシを使わないURLに切り替えます。

読み込みに失敗(エラー)であることを特定する方法には2つあり、ひとつはさきほどのhandleImgErrorが呼ばれたこと。もう一つはロード完了後のimg要素のnaturalWidth``naturalHeightが0であることです。
どちらも使わなければいけない理由はhandleImgErrorがかならず呼ばれて補足できるわけではないためです。

まずhandleImgErrorについて説明すると呼ばれた時点で使用しているURLソースをフォールバックURLの配列から読み出してきて、その配列の次のURLに切り替えていきます。
切り替え先が存在しなければ非表示から表示に切り替えてエラーになった画像を表示します。

フォールバック先を特定したら、img要素のsrcにそのまま渡されるimgSrc.imgの値を書き換えます。

// 一部実際のコードから省略箇所あり
const handleImgError = () => {
	visibilityStyle = 'invisible'

	const img = getImgElement()

	if (!img) {
		return
	}

	let fallbackUrl: string | undefined = undefined

	const index = imgSrc.fallback.findIndex(
		(url) => new URL(url).toString() === new URL(img.src).toString(),
	)
	if (index === -1) {
		fallbackUrl = imgSrc.fallback[0]
	} else {
		fallbackUrl = imgSrc.fallback[index + 1]
	}

	if (!fallbackUrl) {
		visibilityStyle = 'visible'
		return
	}

	// fallback
	imgSrc = {
		...imgSrc,
		img: fallbackUrl,
		srcsets: undefined,
	}
}

// on:loadでは画像を非表示から表示に切り替えるだけ
const handleLoaded = () => {
	visibilityStyle = 'visible'
}

一方でコンポーネントの初回表示タイミングでhandleImgErrorは呼ばれずフォールバックができない事象が発生しました。
そこでsvelteのコンポーネントのafterUpdateというフックを活用して初回表示の画像がエラーになっているかどうかを判定する処理をいれました。

afterUpdate(async () => {
	const img = getImgElement()

	if (!img || !img.complete) {
		return
	}

	// Image load success check.
	if (img.naturalWidth !== 0 && img.naturalHeight !== 0) {
		visibilityStyle = 'visible'
	} else {
		// Failed
		handleImgError()
	}
})

afterUpdateがよばれたタイミングでimg.completeが偽であればまだ画像のロード中なので、その場合はこの後エラーになったときにhandleImgErrorが呼ばれるためここでは早期リターンします。

afterUpdateのタイミングで画像のロードがすでに完了していてnaturalWidth,naturalHeightが0でなければ画像のロードが成功しているので画像の表示を完了します。

naturalWidth,naturalHeightが0の場合は画像の読み込みに失敗していると判定できるため、フォールバックさせるためにhandleImgErrorを呼び出します。

どういう場合にon:erroron:loadが呼ばれないか自分なりに検証してみたんですが、主にcacheが効いていて画像の読み込みが早い場合に再現できることがわかり、イベントリスナの追加タイミングよりも画像読み込みのほうが早い場合があるのではないかと推測しています。

アイコン画像を諦める判断

もともとSNSの画像表示で数が多いのがユーザアイコンであり、また実際の表示サイズが小さくかなり画質を落としても問題ない箇所があるためプロキシを適用するつもりでした。
しかし前回のブログでも書いたとおり、WAFでIPごとのリクエスト数制限をかけており、通常の使用でもTLの初回表示時や早めにスクロールした場合にはWAFの制限にひっかかって500エラーが頻発する状態になることがわかりました。
そのためアイコンに対して画像最適化を行うのはあきらめて投稿に添付された画像のみに対して適応しています。

nostterの開発者の雪猫さんと相談して、エラーが頻発すると全体として表示速度が遅くなってしまうこと。またアイコンのプロフィール画像はみんなはじめからそんなに大きい画像は使用していないだろうから画像最適化の恩恵はあまりないだろう、ということでこの判断になっています。

リリースへ

長々とプルリクを開かせていただくことになり(確認したら6/27-10/19だった。まじか)開発者の雪猫さんには大変お世話になりました。無事10/19日ごろにリリースされました。

心配していたサーバ側のエラーレートも10~20%くらいで抑えられており、特にやばいHotfixも上がってこなかったので良かったです。

実際のところ通信量減った?

いまいち正確な比較方法ではないですが、nostterのトレンド画面を適当なタイミングで1時間ほど遡って通信量を比較してみました

  • 画像最適化あり: 55.5 MB transferred
  • 画像最適化なし: 75.8 MB transferred

まあもっと減ってほしい気がするものの有意に差はみられます。アイコン画像がやはり多いのもあるし、重い画像が少ないタイミングだったとかあるかもしれない。

ということで自分のスマホのモバイルデータ通信量で比較してみました。
マージしたのが10月中旬だったので9月と11月で比較しましょう。

9月

11月

減った…?

実は私の通信量は結局途中でギガが足りなくなって通信制限に突入してサチっているので違いが正確にはわかりません。

もっとわかりやすく比較できる人いたら教えてください。

(というが自分はまずバックグラウンドの通信を制限したほうがよかった)

まとめ

思いつきで始めた画像最適化プロキシのNostrクライアントへの適応ですがいろいろ課題も乗り越え無事にとりこんでリリースしていただけました。
サーバ側よりもフロント側のほうが課題が山盛りという感じでしたが、結果的にプロキシが信頼できなくても違和感のないUXにできたかなと思います。

振り返り

「自分のブログで使おっかな」というレベルとクライアントに実装させてもらうというレベルだとやはり全然ちがうので、振り返ってみると、雪猫さんのレビューの結果だいぶ実装が磨かれたなと思いました。実装して自分でしばらく使ってというのを繰り返してすすめていきましたが、なんというかエッジケースでのバグが数限りなく出てくるのでいつまでたっても出さないかもなと気が遠くなっていましたが出せてよかったです。

その後いくらどんさんが互換のAPIを実装公開してくれてAPIを選択できるようになったりなどの変化もあり、責任が分散されてよかったです。無料枠を超える恐れもほぼなくなりました。

みなさんのデータ通信料金が浮いた分を毎月私にZapしていただけますと幸いです

次回のアドベントカレンダーの記事は Kirino Minato さんによる”リレー周りでなにか書きます”ですね!楽しみです!

Tip me!

Discussion