Skip to main content

atmos/os_lib/web_engine/
image_budget.rs

1//! 画像取得・デコードの予算判定(描画をブロックしないため)。
2//!
3//! 仕様と実測は `spec/webfont_loading.md` を参照(フォントと同じ方針)。
4//!
5//! ## なぜ必要か
6//! 【2026-07-30 実測】ネットワークが健全になった結果、
7//! `.hero` の背景写真(3,724,477 バイト)を実際に取得するようになり、
8//! 描画が 120 秒たっても終わらなくなった。
9//! それまで表示できていたのは**取得が失敗して先へ進めていた**ため。
10//!
11//! ## 判定は「圧縮サイズ」ではなく「展開後の画素数」で行う
12//! woff2 で同じ轍を踏んだ。102KB の woff2 が Brotli 展開で OOM した。
13//! 圧縮形式は展開後のサイズが桁違いになりうるので、
14//! **可能なら展開後の大きさを見る**。JPEG は SOF マーカーから幅と高さを読める。
15//!
16//! グローバル状態にもハードウェアにも依存しない純粋ロジックのみ。
17
18extern crate alloc;
19
20/// デコードを許容する最大画素数。
21/// 1 画素 4 バイトなので 2M 画素で 8MB のバッファになる。
22pub const MAX_PIXELS: u32 = 2_000_000;
23
24/// 取得を許容する最大バイト数(画素数が読めない形式向けの保険)。
25pub const MAX_IMAGE_BYTES: usize = 2 * 1024 * 1024;
26
27/// 判定結果。
28#[derive(Debug, PartialEq, Eq, Clone, Copy)]
29pub enum ImageDecision {
30    /// 取得・デコードしてよい。
31    Accept,
32    /// 諦める。背景色/代替表示で描く。
33    Skip(SkipReason),
34}
35
36/// 諦める理由。黙って無視せず記録するために型で持つ。
37#[derive(Debug, PartialEq, Eq, Clone, Copy)]
38pub enum SkipReason {
39    /// 展開後の画素数が多すぎる。
40    TooManyPixels { pixels: u32 },
41    /// 圧縮サイズが上限を超える。
42    TooManyBytes { bytes: usize },
43    /// 幅か高さが 0。壊れたヘッダ。
44    InvalidDimensions { width: u32, height: u32 },
45}
46
47/// 幅×高さから画素数を求める。オーバーフローしない。
48pub fn pixel_count(width: u32, height: u32) -> Option<u32> {
49    width.checked_mul(height)
50}
51
52/// 展開後の大きさが分かっている画像を受け入れてよいか。
53pub fn decide_by_dimensions(width: u32, height: u32) -> ImageDecision {
54    if width == 0 || height == 0 {
55        return ImageDecision::Skip(SkipReason::InvalidDimensions { width, height });
56    }
57    match pixel_count(width, height) {
58        // 掛け算があふれるほど巨大なら当然拒否。
59        None => ImageDecision::Skip(SkipReason::TooManyPixels { pixels: u32::MAX }),
60        Some(p) if p > MAX_PIXELS => ImageDecision::Skip(SkipReason::TooManyPixels { pixels: p }),
61        Some(_) => ImageDecision::Accept,
62    }
63}
64
65/// 大きさが不明な段階で、圧縮サイズだけから判断する。
66pub fn decide_by_bytes(bytes: usize) -> ImageDecision {
67    if bytes > MAX_IMAGE_BYTES {
68        return ImageDecision::Skip(SkipReason::TooManyBytes { bytes });
69    }
70    ImageDecision::Accept
71}
72
73/// JPEG の SOF マーカーから (幅, 高さ) を読む。
74///
75/// デコードせずに大きさだけ知るための最小実装。
76/// SOF0/1/2/3/5/6/7/9/10/11/13/14/15 を対象とし、
77/// DHT/DQT などのマーカーは長さを見て読み飛ばす。
78pub fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
79    if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
80        return None;
81    }
82    let mut i = 2usize;
83    while i + 3 < data.len() {
84        if data[i] != 0xFF {
85            i += 1;
86            continue;
87        }
88        let marker = data[i + 1];
89        // スタンドアロンマーカー(長さフィールドを持たない)。
90        if marker == 0xD8 || marker == 0xD9 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
91            i += 2;
92            continue;
93        }
94        let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF);
95        if is_sof {
96            // SOF: [FF][marker][len:2][precision:1][height:2][width:2]
97            if i + 9 >= data.len() {
98                return None;
99            }
100            let h = ((data[i + 5] as u32) << 8) | data[i + 6] as u32;
101            let w = ((data[i + 7] as u32) << 8) | data[i + 8] as u32;
102            return Some((w, h));
103        }
104        let seg_len = ((data[i + 2] as usize) << 8) | data[i + 3] as usize;
105        if seg_len < 2 {
106            return None;
107        }
108        i += 2 + seg_len;
109    }
110    None
111}
112
113/// 諦めた理由を人が読める文字列にする(ログ用)。
114pub fn describe(reason: SkipReason) -> &'static str {
115    match reason {
116        SkipReason::TooManyPixels { .. } => "画素数が多すぎる",
117        SkipReason::TooManyBytes { .. } => "ファイルが大きすぎる",
118        SkipReason::InvalidDimensions { .. } => "幅または高さが 0",
119    }
120}