自分のサイトの画像まわりを点検していた夜のことです。確かめたかったのは、スマホ幅とPC幅の両方で、各ページの画像がきちんと出ているかどうかでした。
ページを一つずつ開いて見ていくと手間がかかるので、点検用の別タブを一枚作り、そこに本番ページを iframe で差し込んで、幅を 375px と 1280px に切り替えながら画像の状態を読む形にしました。ところが、結果の一覧には「読み込まれていない画像」が何枚も並びました。
直さなければ、と焦ってしまったのです。しかし、実ページを自分の目で開いてみると、画像はすべて出ておりました。壊れていたのは画像ではなく、私の確かめ方のほうでした。
ここでは、そのとき何が起きていたのかを手元で再現して測った数値とともに、確かめ直すときの順番を書き残します。
最初にお伝えしたいのは、疑う順番です
画像が出ていないように見えたとき、私はまず画像のURLやサーバーを疑っておりました。いまは、疑う順番を逆にしています。
- 確かめている場所が、画像の読み込みを始められる状態にあるか
- 読み込みが始まったあとの状態を読んでいるか
- それでも空なら、そこで初めて画像そのものを疑う
誤診の大半は、一つ目と二つ目で起きます。つまり、画像は壊れていないのに、読み込みが始まっていない場所か、始まる前の瞬間を測っていたのです。
再現した環境と、先に結果の表
再現には、自分のマシンで Playwright の Chromium(ヘッドレス)を使いました。内容は、400px 幅で表示する画像を二枚と、存在しないファイルを指す画像を一枚置いた、ごく小さなページです。一枚目は loading="lazy" を付けて、ページの下のほう(約 6,000px 下)に置きました。二枚目は srcset と sizes を付けた通常の画像です。
数えてみると、再現ページの画像 3 枚のうち、最初の読み取りで正常と判定できたのは 1 枚(33%)だけでした。スクロールのあとには 2 枚(67%)に増え、残りの 1 枚(33%)は存在しないファイルを指す本当に壊れた画像です。つまり、最初の読み取りで正常でなかった 2 枚のうち、1 枚は読み込みが始まっていなかっただけだったのだと気づきました。
この表で、私が誤診した理由はほぼ説明がつきます。非表示の枠では、読み込みが終わった通常の画像ですら naturalWidth が 0 になります。complete は true なのに、です。単純に「naturalWidth が 0 なら壊れている」と書いた判定は、ここで総崩れになります。
もう一つ、naturalWidth について気づいたことがあります。幅 375px の枠で srcset から 400px 幅の画像が選ばれたとき、ファイルの実寸は 400px なのに naturalWidth は 374 でした。srcset の幅記述子を使うと、naturalWidth は画素数そのものではなく、選ばれた倍率で割った値になるためです。実寸と一致しないからといって、画像が縮んでいるわけではありません。
見るべきなのは、currentSrc と描画幅です
「いまブラウザは、どの画像を選んで、どれだけの幅で描いているか」を知りたいなら、読む値は三つに絞れます。
currentSrc: ブラウザが実際に選んだ画像のURL。幅によって変わります。空なら、まだ選ばれていません
getBoundingClientRect().width: 画面に描かれている幅。0 なら、そもそも描かれていません
complete と naturalWidth の組: 読み込みが終わっているのに中身が空なら、壊れています
一つ目のコードは、ブラウザの開発者ツールのコンソールに貼って使える最小の読み取りです。実ページを開いた状態で、そのまま実行してください。
// 実ページのコンソールで実行する: 画像ごとの状態を一覧にする
console.table(
[...document.images].map((img) => {
const r = img.getBoundingClientRect();
return {
src: img.getAttribute("src"),
lazy: img.loading === "lazy",
complete: img.complete,
naturalWidth: img.naturalWidth,
currentSrc: img.currentSrc.split("/").pop() || "(未選択)",
renderedWidth: Math.round(r.width),
};
})
);
私は、幅を変えて見たいときにこのコードをそのまま使いまわすのではなく、次の検査スクリプトにまとめました。点検の手間が変わるのはここからです。
検査スクリプト: 分類してから、スクロールして、もう一度分類します
二つ目のコードが、点検用に書き直した Playwright のスクリプトです。ポイントは三つあります。描画幅 0 の画像を「測れない」として別扱いにすること、遅延読み込みが始まる前の状態を壊れた画像と混ぜないこと、最後に必ずスクロールして読み直すことです。
# verify_images.py — 実ページで画像の状態を分類する(Playwright / Python 3)
import sys
from playwright.sync_api import sync_playwright
SNAPSHOT = """() => [...document.images].map(i => {
const r = i.getBoundingClientRect();
return { id: i.id || i.getAttribute('src'), complete: i.complete,
nw: i.naturalWidth, cur: i.currentSrc.split('/').pop(),
rw: Math.round(r.width), lazy: i.loading === 'lazy' };
})"""
def classify(s):
if s["rw"] == 0:
return "hidden" # 描画幅 0。ここで naturalWidth を信じない
if s["lazy"] and s["cur"] == "" and not s["complete"]:
return "not-started" # 遅延読み込みがまだ始まっていない
if s["complete"] and s["nw"] == 0:
return "BROKEN" # 読み込み完了なのに中身が空
return "ok"
def scroll_through(page, step=600):
h = page.evaluate("document.documentElement.scrollHeight")
for y in range(0, h + step, step):
page.evaluate(f"window.scrollTo(0, {y})")
page.wait_for_timeout(120) # 遅延読み込みの発火を待つ
page.wait_for_timeout(500)
def check(url, width):
with sync_playwright() as p:
b = p.chromium.launch()
page = b.new_page(viewport={"width": width, "height": 800})
page.goto(url, wait_until="load")
first = {s["id"]: classify(s) for s in page.evaluate(SNAPSHOT)}
scroll_through(page)
after = page.evaluate(SNAPSHOT)
print(f"== {url} @ {width}px")
for s in after:
print(f'{s["id"]:12} first={first[s["id"]]:12} '
f'after={classify(s):8} cur={s["cur"] or "-":10} '
f'nw={s["nw"]:5} rw={s["rw"]}')
b.close()
if __name__ == "__main__":
check(sys.argv[1], int(sys.argv[2]))
手元の再現ページに対して、375px と 1280px で実行した出力は次のとおりです。
== http://localhost:8767/page.html @ 375px
below first=not-started after=ok cur=img_l.png nw= 1600 rw=400
gone first=BROKEN after=BROKEN cur=missing.png nw= 0 rw=47
eager first=ok after=ok cur=img_s.png nw= 374 rw=400
== http://localhost:8767/page.html @ 1280px
below first=not-started after=ok cur=img_l.png nw= 1600 rw=400
gone first=BROKEN after=BROKEN cur=missing.png nw= 0 rw=47
eager first=ok after=ok cur=img_m.png nw= 800 rw=400
below(遅延読み込み)は、最初は not-started でした。スクロール後には ok に変わります。これを最初の一回だけ測って「壊れている」と書いていたら、私と同じ誤診になっていたはずです。gone は存在しないファイルを指しているので、最初もスクロール後も BROKEN のままです。壊れた画像は、こうして遅れても変わらないものだけが残ります。
eager の cur が 375px では img_s.png、1280px では img_m.png に変わっている点にも注目してください。どの画像を選んだかは幅で変わります。だから、幅を変えて検査する価値があるのです。
枠の中で測るなら、せめて条件を揃えます
別タブの iframe 検証を、完全にやめるべきだとは考えておりません。複数ページを素早く見たいときには便利です。ただ、次の条件が揃っていないと、数字は信用できません。
- 枠は
display:none や画面外ではなく、実際に見える大きさで置く
- 点検の最後に、枠の中のウィンドウをスクロールして遅延読み込みを発火させる
- 枠の幅は、実際に見たい端末の幅と一致させる(幅が違えば
currentSrc も違います)
- 判定は
naturalWidth だけに頼らず、描画幅と currentSrc を併せて読む
それでも枠の中の結果は、実ページを開いたときの結果とずれることがあります。私は、枠の結果に「怪しい」が出たら、必ず実ページで再確認するという線引きにしました。枠の結果で直すのは、実ページで同じ症状が再現した画像だけです。
エージェントに確認を頼むときも、同じ誤診を引き継ぎます
ここまでの話は、人が手で確かめる場合に限りません。Antigravity のエージェントに「画像が全部表示されているか確認して」と頼むときにも、同じ落とし穴があります。エージェントがどこで確かめ、何を合格とみなすかを指示文に書かなければ、枠の中の naturalWidth だけを見て「壊れています」と報告してくることも十分にありえます。
指示文の骨組みを書き残しておきます。
画像の表示確認をお願いします。次の条件を守ってください。
1. 実ページを直接開いて確認してください。iframe に差し込んだ検査ページでは判定しないでください。
2. 幅 375px と 1280px の両方で確認してください。
3. 判定は naturalWidth だけで行わず、描画幅(0 でないこと)と currentSrc も記録してください。
4. 最初に読んだあと、ページ最下部までスクロールしてからもう一度読み、変化を報告してください。
5. 壊れていると判定した画像は、実ページを開き直して同じ症状が再現するかを確認し、再現したものだけを報告してください。
報告を読む私の側では、「再現したものだけ」と書いてあるかどうかを見ます。ここが抜けていたら、そのレポートはいったん保留にします。直す前に再現を一度挟む、という手間を足しただけで、直さなくてよい箇所を直しに行く時間を省けるようになりました。
もう一つの失敗: 置換を機械的に行って、ブレークポイントまで縮めました
画像の点検のついでに、もう一つ、同じ点検の中でやらかした失敗があります。画像の表示幅を全体的に小さくしようとして、CSS の px の数値を正規表現でまとめて置換したのです。
結果は、見た目の一部が静かに崩れました。メディアクエリの閾値まで縮んでしまったからです。再現用の三行の CSS で、何が起きたのかをお見せします。
# shrink.py — 数値を半分にする置換: 素朴な版と、安全な版(Python 3)
import re
css = """.gallery img { width: 800px; max-height: 600px; }
@media (min-width: 768px) { .gallery img { width: 640px; } }
@media (max-width: 480px) { .gallery img { width: 320px; } }
"""
def naive(css, ratio):
# すべての「数値px」を縮める。@media の閾値も巻き込む
return re.sub(r"(\d+)px", lambda m: f"{round(int(m.group(1))*ratio)}px", css)
PROP = r"(?<![\w-])(width|max-height)(\s*:\s*)(\d+)px" # min-width/max-width は除外
def scale_props(text, ratio):
return re.sub(PROP, lambda k: f"{k.group(1)}{k.group(2)}{round(int(k.group(3))*ratio)}px", text)
def safe(css, ratio):
out = []
for line in css.splitlines():
m = re.match(r"\s*@media[^{]*\{(.*)\}\s*$", line)
if m: # @media の条件部分(括弧の中)には触れず、波括弧の中身だけ縮める
head = line[: line.index("{") + 1]
out.append(head + scale_props(line[len(head):], ratio))
else:
out.append(scale_props(line, ratio))
return "\n".join(out) + "\n"
print("--- naive 0.5 ---"); print(naive(css, 0.5))
print("--- safe 0.5 ---"); print(safe(css, 0.5))
実行した出力は、次のようになりました。
--- naive 0.5 ---
.gallery img { width: 400px; max-height: 300px; }
@media (min-width: 384px) { .gallery img { width: 320px; } }
@media (max-width: 240px) { .gallery img { width: 160px; } }
--- safe 0.5 ---
.gallery img { width: 400px; max-height: 300px; }
@media (min-width: 768px) { .gallery img { width: 320px; } }
@media (max-width: 480px) { .gallery img { width: 160px; } }
素朴な版では、min-width: 768px が 384px に、max-width: 480px が 240px に変わっています。切り替わる画面幅そのものが、画像と一緒に縮んでしまったのです。タブレットとスマホの境目がずれるので、どの幅でも見た目が少しずつおかしい、という最も原因を探しにくい崩れ方になります。
安全な版は、プロパティ名(width・max-height)を指定して値だけを縮めます。min-width や max-width は先頭の - で除外し、@media の括弧の中には触れません。ただし、これは今回の三行のためのごく単純な版です。本物のスタイルシートでは、変数や calc()、@supports が入り組んでおり、正規表現には限界があります。置換の対象が増えてきたら、CSS の構文を理解するパーサーに任せる判断も必要になります。
落とし穴を、置換の前後で確かめる表にしました
次にやること
いま思えば、あの夜の私に足りなかったのは技術よりも、結果を疑う一拍だったのかもしれません。
もし、いま画像の点検を iframe で行っている方がいらしたら、今日の点検の最後に、実ページでスクリプトを一度だけ走らせてみていただければ幸いです。枠の結果と食い違った画像があれば、その差こそが、確かめ方に潜んでいた問題の手がかりです。
私自身は、壊れたと判定する前に「確かめる場所は画像が読み込める状態か」と一度立ち止まることを、点検の最初の手順に書き込みました。直す前に再現を挟むという線引きは、これからも守っていくつもりです。
関連する話として、削除ボタンを押す前の確認を扱った Antigravity のプロジェクトを削除する前に、手元に残るものを確かめる手順 も、同じ「直す前に確かめる」姿勢で書いております。エージェントに配信物の整理を任せる場面の門番については 位置で読む API 応答を変えるときの門番 もご覧ください。