Skip to main content

long_screen/kernels/
chrome.rs

1//! Screen-fixed chrome evidence on native frames (`src/core/layers.ts::stationaryBoundary`,
2//! `stickyOcclusions`): a persistent appearance edge within a stationary band, and the per-observation sticky
3//! header band whose native texture agrees with zero motion and disagrees with the accepted page translation.
4//! Thresholds, sampling steps and the "return the carried bands" fallbacks are the historical ones.
5
6use crate::kernels::geometry::{js_ceil, js_floor, js_round, Rect};
7
8#[derive(Clone, Copy, PartialEq, Eq)]
9pub enum Axis {
10    X,
11    Y,
12}
13
14#[derive(Clone, Copy, PartialEq, Eq)]
15pub enum Choose {
16    First,
17    Last,
18}
19
20/// Strong persistent appearance boundary within a stationary run: the first/last coordinate `v` in
21/// `[max(1, from), min(limit, to + 1))` whose row (or column) differs from its predecessor over more than
22/// 68 % of sampled cross positions with mean difference > 3. `None` when the image offers no boundary.
23#[allow(clippy::too_many_arguments)]
24pub fn stationary_boundary(
25    rgba: &[u8],
26    width: usize,
27    height: usize,
28    axis: Axis,
29    from: f64,
30    to: f64,
31    cross_from: f64,
32    cross_to: f64,
33    choose: Choose,
34) -> Option<f64> {
35    let (w, h) = (width as i64, height as i64);
36    let limit = if axis == Axis::X { w } else { h };
37    let cross_limit = if axis == Axis::X { h } else { w };
38    // Math.floor((crossTo - crossFrom) / 160) on the real span, then at least 1.
39    let step = (((cross_to - cross_from) / 160.0).floor() as i64).max(1);
40    let mut found = None;
41    // `for (let v = Math.max(1, from); v < Math.min(limit, to + 1); v++)`: a fractional `from` keeps its
42    // fraction in the loop variable, and `Math.max(1, from)` is 1 or `from` itself.
43    let mut v = if from > 1.0 { from } else { 1.0 };
44    let v_end = (limit as f64).min(to + 1.0);
45    let c_start = (cross_from + 2.0).max(0.0);
46    let c_end = (cross_limit as f64).min(cross_to - 2.0);
47    while v < v_end {
48        let (mut strong, mut count, mut sum) = (0u32, 0u32, 0.0f64);
49        // JS indexed `data[(py * width + px) * 4]` with real-valued loop variables: an integral product reads
50        // that byte (a half-row offset when width is even), a fractional one reads `undefined`, whose NaN
51        // poisons the row's sum so it can never qualify. Both outcomes are reproduced exactly.
52        let mut poisoned = false;
53        let mut c = c_start;
54        while c < c_end {
55            let (px, py) = if axis == Axis::X { (v, c) } else { (c, v) };
56            let idx = (py * w as f64 + px) * 4.0;
57            if idx.fract() != 0.0 {
58                poisoned = true;
59                break;
60            }
61            let i = idx as usize;
62            let j = if axis == Axis::X {
63                i - 4
64            } else {
65                i - (w as usize) * 4
66            };
67            let d = ((rgba[i] as i32 - rgba[j] as i32).abs()
68                + (rgba[i + 1] as i32 - rgba[j + 1] as i32).abs()
69                + (rgba[i + 2] as i32 - rgba[j + 2] as i32).abs()) as f64
70                / 3.0;
71            if d > 3.0 {
72                strong += 1;
73            }
74            sum += d;
75            count += 1;
76            c += step as f64;
77        }
78        if !poisoned && count > 0 && strong as f64 / count as f64 > 0.68 && sum / count as f64 > 3.0
79        {
80            // `found = v` keeps the real-valued loop variable; callers only ever pass integers.
81            found = Some(v);
82            if choose == Choose::First {
83                break;
84            }
85        }
86        v += 1.0;
87    }
88    found
89}
90
91/// Sticky-band evidence for one observation. `carry` are the previous observation's bands, retained while
92/// every native pixel under them is unchanged. Returns the bands to exclude from the moving canvas.
93///
94/// A row is band evidence when its textured samples disagree with the accepted page translation and agree with the
95/// previous frame at a small vertical shift of their own: zero (a header pinned to the screen), or any `k` with
96/// `|k| <= min(16, |dy| / 4)` and `|dy - k| >= 8`. A header that collapses or expands at its own speed while the page
97/// scrolls far faster (new_ip_8: 2–12 px against 60–135 px a frame) is still screen chrome for that observation; the
98/// window stays a quarter of the page's step so content moving at a large share of the page's speed is not, and
99/// at least 8 px from the page's own step so a slow scroll cannot match the page itself. A row whose page partner
100/// (`y + dy`) falls outside the region takes no shift of its own: its page comparison reads screen chrome.
101#[allow(clippy::too_many_arguments)]
102pub fn sticky_occlusions(
103    previous: &[u8],
104    current: &[u8],
105    width: usize,
106    height: usize,
107    region: Rect,
108    motion_x: f64,
109    motion_y: f64,
110    carry_in: &[Rect],
111) -> Vec<Rect> {
112    let (w, h) = (width as i64, height as i64);
113    // A clock/cursor elsewhere may change a paused frame. Retain an already-proven sticky band only while
114    // ALL its native RGBA pixels are unchanged; do not drop the mask just because the page stops scrolling.
115    let carry: Vec<Rect> = carry_in
116        .iter()
117        .copied()
118        .filter(|o| {
119            if o.x < 0.0 || o.y < 0.0 || o.x + o.width > w as f64 || o.y + o.height > h as f64 {
120                return false;
121            }
122            let y_end = o.y + o.height;
123            let mut y = o.y.ceil();
124            while y < y_end {
125                let start = ((y as i64 * w) as f64 + (o.x).ceil()) as usize * 4;
126                let end = ((y as i64 * w) as f64 + (o.x + o.width).ceil()) as usize * 4;
127                if previous[start..end] != current[start..end] {
128                    return false;
129                }
130                y += 1.0;
131            }
132            true
133        })
134        .collect();
135    if crate::kernels::geometry::js_hypot(motion_x, motion_y) < 2.0 {
136        return carry;
137    }
138    let r = region;
139    let (dx, dy) = (js_round(motion_x) as i64, js_round(motion_y) as i64);
140    let end = (h - 2).min(js_floor(r.y + (288.0f64).min(r.height * 0.23)) as i64);
141    let start = 2i64.max(js_floor(r.y) as i64);
142    let step = 1i64.max(js_floor(r.width / 700.0) as i64);
143    let (mut last_fixed, mut strong_rows, mut matched) = (-1i64, 0u32, 0u32);
144    let x_start = 2i64.max(js_ceil(r.x) as i64);
145    let x_end = (w - 2) as f64;
146    let window = (dy.abs() / 4).min(16);
147    let mut samples: Vec<usize> = Vec::new();
148    for y in start..end {
149        if y + dy < 2 || y + dy >= h - 2 {
150            continue;
151        }
152        let (mut zero, mut shifted) = (0i64, 0i64);
153        samples.clear();
154        let mut x = x_start;
155        // `x < Math.min(w - 2, r.x + r.width)` compares against a real bound.
156        while (x as f64) < x_end.min(r.x + r.width) {
157            if x + dx < 2 || x + dx >= w - 2 {
158                x += step;
159                continue;
160            }
161            let i = ((y * w + x) * 4) as usize;
162            if (current[i - 4] as i32 - current[i + 4] as i32).abs() < 24 {
163                x += step;
164                continue;
165            }
166            let j = (((y + dy) * w + x + dx) * 4) as usize;
167            zero += (previous[i] as i64 - current[i] as i64).abs();
168            shifted += (previous[j] as i64 - current[i] as i64).abs();
169            samples.push(i);
170            x += step;
171        }
172        let n = samples.len() as u32;
173        let nf = n as f64;
174        if n < 8 || (shifted as f64) / nf <= 18.0 {
175            continue;
176        }
177        // Agreement with the previous frame at zero shift, or at a small shift of the band's own. A shift of its own
178        // needs the page's own position of this row inside the region: above it (scrolling back at the region's top
179        // edge) the page-shift comparison reads fixed chrome, which never matches, and says nothing about the page.
180        let mut still = (zero as f64) / nf < 6.0;
181        let page_inside = (y + dy) as f64 >= r.y && ((y + dy) as f64) < r.y + r.height;
182        let mut k = if page_inside { -window } else { window + 1 };
183        while !still && k <= window {
184            if k != 0 && (dy - k).abs() >= 8 && y + k >= 2 && y + k < h - 2 {
185                let offset = k * w * 4;
186                let sum: i64 = samples
187                    .iter()
188                    .map(|&i| {
189                        (previous[(i as i64 + offset) as usize] as i64 - current[i] as i64).abs()
190                    })
191                    .sum();
192                still = (sum as f64) / nf < 6.0;
193            }
194            k += 1;
195        }
196        if still {
197            last_fixed = y;
198            strong_rows += 1;
199            matched += n;
200        }
201    }
202    if strong_rows < 4 || matched < 64 || last_fixed < 0 {
203        return carry;
204    }
205    let edge = stationary_boundary(
206        current,
207        width,
208        height,
209        Axis::Y,
210        (last_fixed + 1) as f64,
211        (end.min(last_fixed + 80)) as f64,
212        r.x,
213        r.x + r.width,
214        Choose::First,
215    );
216    match edge {
217        Some(edge) if edge - start as f64 <= r.height * 0.23 => vec![Rect {
218            x: r.x,
219            y: r.y,
220            width: r.width,
221            height: edge - r.y + 1.0,
222        }],
223        _ => carry,
224    }
225}
226
227/// Visible structure of a masked area on a native frame: how many sampled points sit on an edge, across rows
228/// (`vertical`) and across columns (`horizontal`), and on how many distinct sampled rows and columns such a point
229/// lies. Sampling follows the bands' texture test: every 2nd analysis row and column, the native pixel at
230/// `floor(x·factor)`, its red channel against the pixels `round(factor)` away, an edge where they differ by more
231/// than 12. A point is sampled only where the analysis pixels on both sides of it are in the mask as well, so
232/// structure next to the area does not count for it.
233#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
234pub struct Structure {
235    pub vertical: u32,
236    pub horizontal: u32,
237    pub rows: u32,
238    pub cols: u32,
239}
240
241#[allow(clippy::too_many_arguments)]
242pub fn visible_structure(
243    rgba: &[u8],
244    width: usize,
245    height: usize,
246    mask: &[u8],
247    mask_width: usize,
248    mask_height: usize,
249    factor: f64,
250) -> Structure {
251    let (w, h) = (width as i64, height as i64);
252    let (mw, mh) = (mask_width as i64, mask_height as i64);
253    let step = (js_round(factor) as i64).max(1);
254    let inside =
255        |x: i64, y: i64| x >= 0 && y >= 0 && x < mw && y < mh && mask[(y * mw + x) as usize] != 0;
256    let red = |x: i64, y: i64| rgba[((y * w + x) * 4) as usize] as i32;
257    let mut out = Structure::default();
258    let mut cols = vec![false; mask_width];
259    let mut y = 1i64;
260    while y < mh - 1 {
261        let ny = js_floor(y as f64 * factor) as i64;
262        let mut row_hit = false;
263        let mut x = 1i64;
264        while x < mw - 1 {
265            if inside(x, y) {
266                let nx = js_floor(x as f64 * factor) as i64;
267                let mut hit = false;
268                if inside(x, y - 1)
269                    && inside(x, y + 1)
270                    && ny - step >= 0
271                    && ny + step < h
272                    && nx < w
273                    && (red(nx, ny - step) - red(nx, ny + step)).abs() > 12
274                {
275                    out.vertical += 1;
276                    hit = true;
277                }
278                if inside(x - 1, y)
279                    && inside(x + 1, y)
280                    && nx - step >= 0
281                    && nx + step < w
282                    && ny < h
283                    && (red(nx - step, ny) - red(nx + step, ny)).abs() > 12
284                {
285                    out.horizontal += 1;
286                    hit = true;
287                }
288                if hit {
289                    row_hit = true;
290                    cols[x as usize] = true;
291                }
292            }
293            x += 2;
294        }
295        if row_hit {
296            out.rows += 1;
297        }
298        y += 2;
299    }
300    out.cols = cols.iter().filter(|&&c| c).count() as u32;
301    out
302}
303
304#[cfg(test)]
305mod tests {
306    //! Ways the sticky-band test can fail, written before widening it to bands that move at their own small speed
307    //! (new_ip_8: the page scrolls 60–135 px a frame while the header slides 2–12 px):
308    //! - a band that holds still is no longer found (the historical case);
309    //! - a band sliding 2, 5 or 12 px a frame over a fast page is still missed;
310    //! - page content, moving at the page's speed, is taken for a band, including vertically periodic content that
311    //!   matches itself at a small shift;
312    //! - a band moving at a large share of the page's speed (a parallax hero at half speed) is taken for sticky, or a
313    //!   slow scroll lets the shift window reach the page's own displacement;
314    //! - a shift window reaching above the frame's top reads out of bounds;
315    //! - a band sliding down while the page scrolls back (the header expanding again) is missed.
316    use super::*;
317
318    const W: usize = 400;
319    const H: usize = 600;
320    const BAND: i64 = 64;
321
322    /// Textured value with vertical and horizontal structure at every pixel, like anti-aliased glyphs or a photo.
323    fn noise(x: i64, y: i64, seed: u32) -> u8 {
324        let mut v =
325            (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b) ^ seed;
326        v ^= v >> 15;
327        v = v.wrapping_mul(0x2c1b_3c6d);
328        v ^= v >> 12;
329        (v & 0xff) as u8
330    }
331
332    fn put(frame: &mut [u8], x: usize, y: usize, v: u8) {
333        let i = (y * W + x) * 4;
334        frame[i..i + 4].copy_from_slice(&[v, v.wrapping_mul(3), 255 - v, 255]);
335    }
336
337    /// A frame of the page at `page` (world row of screen row 0) under a 64-row band whose top is at screen row
338    /// `band_top` (rows above 0 clipped); the band's last row is a uniform rule. `page_row` maps a world row to the
339    /// texture row, so a periodic page can be drawn.
340    fn frame(page: i64, band_top: Option<i64>, page_row: &dyn Fn(i64) -> i64) -> Vec<u8> {
341        let mut out = vec![0u8; W * H * 4];
342        for y in 0..H as i64 {
343            for x in 0..W as i64 {
344                let v = match band_top {
345                    Some(top) if y >= top && y < top + BAND => {
346                        if y - top == BAND - 1 {
347                            30
348                        } else {
349                            noise(x, y - top, 7)
350                        }
351                    }
352                    _ => noise(x, page_row(y + page), 1),
353                };
354                put(&mut out, x as usize, y as usize, v);
355            }
356        }
357        out
358    }
359
360    fn region() -> Rect {
361        Rect {
362            x: 0.0,
363            y: 0.0,
364            width: W as f64,
365            height: H as f64,
366        }
367    }
368
369    fn plain(y: i64) -> i64 {
370        y
371    }
372
373    fn bands(previous: &[u8], current: &[u8], dy: f64) -> Vec<Rect> {
374        sticky_occlusions(previous, current, W, H, region(), 0.0, dy, &[])
375    }
376
377    fn band_bottom(found: &[Rect]) -> Option<f64> {
378        found.first().map(|b| b.y + b.height)
379    }
380
381    #[test]
382    fn a_still_band_over_a_scrolling_page_is_found() {
383        let previous = frame(0, Some(0), &plain);
384        let current = frame(40, Some(0), &plain);
385        assert_eq!(band_bottom(&bands(&previous, &current, 40.0)), Some(64.0));
386    }
387
388    #[test]
389    fn a_band_sliding_at_its_own_small_speed_is_found() {
390        for (k, dy) in [(2, 60.0), (5, 60.0), (12, 60.0), (3, 135.0)] {
391            let previous = frame(0, Some(0), &plain);
392            let current = frame(dy as i64, Some(-k), &plain);
393            assert_eq!(
394                band_bottom(&bands(&previous, &current, dy)),
395                Some((BAND - k) as f64),
396                "band sliding {k} px over a page moving {dy} px"
397            );
398        }
399    }
400
401    #[test]
402    fn a_band_sliding_down_while_the_page_scrolls_back_is_found() {
403        // The header expands again: it moves 6 px down while the page moves 40 px back. The band starts below a
404        // 20-row status strip, so the shift window reaches rows above the band but stays inside the frame.
405        let previous = frame(-40, Some(20), &plain);
406        let current = frame(-80, Some(26), &plain);
407        let region = Rect {
408            x: 0.0,
409            y: 20.0,
410            width: W as f64,
411            height: H as f64 - 20.0,
412        };
413        let found = sticky_occlusions(&previous, &current, W, H, region, 0.0, -40.0, &[]);
414        assert_eq!(band_bottom(&found), Some(26.0 + BAND as f64));
415    }
416
417    #[test]
418    fn page_content_is_never_a_band() {
419        let previous = frame(0, None, &plain);
420        let current = frame(40, None, &plain);
421        assert!(bands(&previous, &current, 40.0).is_empty());
422        // Vertically periodic content (period 6, page step 40 not a multiple of it) matches itself at a small shift
423        // (k = 4 or −2), but it also matches the page's own displacement, so it is still page content.
424        let periodic = |y: i64| y.rem_euclid(6);
425        let previous = frame(0, None, &periodic);
426        let current = frame(40, None, &periodic);
427        assert!(bands(&previous, &current, 40.0).is_empty());
428    }
429
430    #[test]
431    fn rows_whose_page_partner_is_chrome_above_the_region_take_no_shift() {
432        // Scrolling back under a 40-row status strip (outside the region): the page partner of the region's first
433        // rows is the strip, so their page comparison fails for any content. Content that repeats every 9 rows there
434        // (card borders, list rules) matches itself at k = −9 and would make a band of page content.
435        let periodic = |y: i64| y.rem_euclid(9);
436        let strip = |f: &mut Vec<u8>| {
437            for y in 0..40 {
438                for x in 0..W {
439                    put(f, x, y, 30);
440                }
441            }
442        };
443        let mut previous = frame(0, None, &periodic);
444        let mut current = frame(-37, None, &periodic);
445        strip(&mut previous);
446        strip(&mut current);
447        let region = Rect {
448            x: 0.0,
449            y: 40.0,
450            width: W as f64,
451            height: H as f64 - 40.0,
452        };
453        assert!(sticky_occlusions(&previous, &current, W, H, region, 0.0, -37.0, &[]).is_empty());
454    }
455
456    #[test]
457    fn content_at_a_large_share_of_the_page_speed_is_not_a_band() {
458        // A parallax hero at half the page's speed: its shift (20) is beyond the window (|dy| / 4 = 10).
459        let previous = frame(0, Some(0), &plain);
460        let current = frame(40, Some(-20), &plain);
461        assert!(bands(&previous, &current, 40.0).is_empty());
462        // Just past the window: 11 px over a 40 px step.
463        let current = frame(40, Some(-11), &plain);
464        assert!(bands(&previous, &current, 40.0).is_empty());
465    }
466
467    #[test]
468    fn content_slower_than_a_quarter_of_the_page_is_a_band() {
469        // The known cost of the window: content in the top 23 % moving at a fifth of the page's speed (a slow parallax
470        // hero) is excluded from this observation like a sticky header.
471        let previous = frame(0, Some(0), &plain);
472        let current = frame(40, Some(-8), &plain);
473        assert_eq!(
474            band_bottom(&bands(&previous, &current, 40.0)),
475            Some((BAND - 8) as f64)
476        );
477    }
478
479    #[test]
480    fn a_slow_scroll_keeps_the_shift_away_from_the_page() {
481        // Page step 10: the window is ±2 and |10 − k| ≥ 8 leaves k ∈ {−2..2}; a band moving 2 px is found.
482        let previous = frame(0, Some(0), &plain);
483        let current = frame(10, Some(-2), &plain);
484        assert_eq!(
485            band_bottom(&bands(&previous, &current, 10.0)),
486            Some((BAND - 2) as f64)
487        );
488        // Page step 9: |9 − 2| < 8, so a 2 px move is too close to the page's own to count; zero shift only.
489        let current = frame(9, Some(-2), &plain);
490        assert!(bands(&previous, &current, 9.0).is_empty());
491        // Page step 8: the window is ±2 but only k ≤ 0 keep |8 − k| ≥ 8; a band moving up 2 px is not found.
492        let current = frame(8, Some(-2), &plain);
493        assert!(bands(&previous, &current, 8.0).is_empty());
494    }
495
496    #[test]
497    fn the_shift_window_never_reads_above_the_frame() {
498        // Rows from 2 with the window reaching k = −10 (page step 40): a band at the very top moving down 3 px against
499        // the page's direction has rows 2.. whose shifted partner is above row 0; those shifts are skipped, not read.
500        let previous = frame(0, Some(0), &plain);
501        let current = frame(40, Some(3), &plain);
502        assert_eq!(
503            band_bottom(&bands(&previous, &current, 40.0)),
504            Some((BAND + 3) as f64)
505        );
506    }
507}
508
509#[cfg(test)]
510mod structure_tests {
511    use super::*;
512
513    /// A grey `w × h` RGBA frame at factor 1 with `paint(x, y)` as its level.
514    fn frame(w: usize, h: usize, paint: impl Fn(usize, usize) -> u8) -> Vec<u8> {
515        let mut out = vec![255u8; w * h * 4];
516        for y in 0..h {
517            for x in 0..w {
518                let v = paint(x, y);
519                out[(y * w + x) * 4..(y * w + x) * 4 + 3].fill(v);
520            }
521        }
522        out
523    }
524
525    #[test]
526    fn a_blank_area_has_no_structure() {
527        let img = frame(40, 40, |_, _| 0);
528        let s = visible_structure(&img, 40, 40, &[1; 1600], 40, 40, 1.0);
529        assert_eq!(s, Structure::default());
530    }
531
532    #[test]
533    fn a_single_horizontal_line_sits_on_at_most_two_sampled_rows() {
534        // pub_2's empty chat margin: black but for the line where it meets the bar above.
535        let img = frame(40, 40, |_, y| if y == 10 { 80 } else { 0 });
536        let s = visible_structure(&img, 40, 40, &[1; 1600], 40, 40, 1.0);
537        assert!(s.rows <= 2 && s.cols >= 15 && s.horizontal == 0, "{s:?}");
538    }
539
540    #[test]
541    fn a_single_vertical_line_sits_on_at_most_two_sampled_columns() {
542        let img = frame(40, 40, |x, _| if x == 10 { 80 } else { 0 });
543        let s = visible_structure(&img, 40, 40, &[1; 1600], 40, 40, 1.0);
544        assert!(s.cols <= 2 && s.rows >= 15 && s.vertical == 0, "{s:?}");
545    }
546
547    #[test]
548    fn icons_spread_over_the_area_have_structure_on_many_rows_and_columns() {
549        let img = frame(
550            40,
551            40,
552            |x, y| if x % 10 < 4 && y % 10 < 4 { 200 } else { 0 },
553        );
554        let s = visible_structure(&img, 40, 40, &[1; 1600], 40, 40, 1.0);
555        assert!(
556            s.rows >= 3 && s.cols >= 3 && s.vertical > 0 && s.horizontal > 0,
557            "{s:?}"
558        );
559    }
560
561    #[test]
562    fn structure_outside_the_mask_does_not_count() {
563        // Texture everywhere, but the mask is a blank 20 × 20 square in the middle that the texture never enters;
564        // the texture abuts the square on all sides.
565        let img = frame(40, 40, |x, y| {
566            if (10..30).contains(&x) && (10..30).contains(&y) {
567                0
568            } else {
569                ((x * 37 + y * 91) % 256) as u8
570            }
571        });
572        let mut mask = vec![0u8; 1600];
573        for y in 10..30 {
574            mask[y * 40 + 10..y * 40 + 30].fill(1);
575        }
576        let s = visible_structure(&img, 40, 40, &mask, 40, 40, 1.0);
577        assert_eq!(s, Structure::default());
578    }
579
580    #[test]
581    fn native_points_are_read_at_the_factor() {
582        // Factor 5: the analysis grid is 8 × 8 over a 40 × 40 native frame; 5 px icons every 15 native px.
583        let img = frame(40, 40, |x, y| {
584            if (x / 5) % 3 == 0 && (y / 5) % 3 == 0 {
585                200
586            } else {
587                0
588            }
589        });
590        let s = visible_structure(&img, 40, 40, &[1; 64], 8, 8, 5.0);
591        assert!(s.rows >= 3 && s.cols >= 3, "{s:?}");
592    }
593}