use std::collections::VecDeque;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
const PRODUCT_TARGETS: &[&str] = &[];
const MAX_CAPTURES: usize = 30;
const MAX_BYTES: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo { pub title: String, pub hwnd: u64, pub x: i32, pub y: i32, pub width: u32, pub height: u32, pub is_debug: bool }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureResult { pub window: WindowInfo, pub thumbnail_path: PathBuf, pub detail_path: PathBuf, pub perceptual_hash: u64, pub size_bytes: usize }
pub struct CaptureCache { entries: VecDeque<CaptureResult>, total_bytes: usize }
impl CaptureCache {
pub fn new() -> Self { Self { entries: VecDeque::new(), total_bytes: 0 } }
pub fn push(&mut self, capture: CaptureResult) {
self.total_bytes += capture.size_bytes;
self.entries.push_back(capture);
while self.entries.len() > MAX_CAPTURES || self.total_bytes > MAX_BYTES {
if let Some(old) = self.entries.pop_front() { self.total_bytes -= old.size_bytes; }
}
}
pub fn len(&self) -> usize { self.entries.len() }
pub fn total_bytes(&self) -> usize { self.total_bytes }
pub fn latest(&self) -> Option<&CaptureResult> { self.entries.back() }
}
pub fn is_product_window(title: &str, targets: &[&str]) -> bool {
targets.iter().any(|t| title.contains(t))
}
pub fn is_product_window_default(title: &str) -> bool {
is_product_window(title, PRODUCT_TARGETS)
}
pub fn is_debug_window(title: &str) -> bool { title.contains("(DEBUG)") }
pub fn perceptual_hash(pixels: &[u8], width: usize, height: usize) -> u64 {
if pixels.is_empty() || width == 0 || height == 0 { return 0; }
let mut grid = [0u8; 64];
for gy in 0..8 { for gx in 0..8 {
let sx = gx * width / 8;
let sy = gy * height / 8;
let idx = (sy * width + sx) * 4;
if idx + 2 < pixels.len() {
grid[gy * 8 + gx] = (
pixels[idx] as f32 * 0.299 + pixels[idx+1] as f32 * 0.587 + pixels[idx+2] as f32 * 0.114 ) as u8;
}
}}
let avg: u16 = grid.iter().map(|&v| v as u16).sum::<u16>() / 64;
let mut hash = 0u64;
for (i, &v) in grid.iter().enumerate() { if v as u16 >= avg { hash |= 1 << i; } }
hash
}
pub fn hamming_distance(a: u64, b: u64) -> u32 { (a ^ b).count_ones() }
pub type TileHash = u64;
pub struct TileDescriptor {
pub tile_x: u32,
pub tile_y: u32,
pub mean_color: [u8; 3],
pub edge_density: f32,
pub has_text: bool,
}
pub struct CompressedFrame {
pub changed_tiles: Vec<TileDescriptor>,
pub total_tiles: usize,
pub frame_width: u32,
pub frame_height: u32,
pub tile_size: u32,
}
pub fn tile_hash_cpu(
pixels: &[u8],
frame_width: usize,
frame_height: usize,
tile_x: usize,
tile_y: usize,
tile_size: usize,
) -> TileHash {
if pixels.is_empty() || frame_width == 0 || frame_height == 0 { return 0; }
let x_start = tile_x * tile_size;
let y_start = tile_y * tile_size;
let x_end = (x_start + tile_size).min(frame_width);
let y_end = (y_start + tile_size).min(frame_height);
let mut hash: u64 = 0;
let mut sample_count: u64 = 0;
let step = 4usize.max(1);
let mut y = y_start;
while y < y_end {
let mut x = x_start;
while x < x_end {
let idx = (y * frame_width + x) * 4;
if idx + 2 < pixels.len() {
let lum = (pixels[idx] as f32 * 0.299
+ pixels[idx + 1] as f32 * 0.587
+ pixels[idx + 2] as f32 * 0.114) as u64;
let pos = (y * frame_width + x) as u64;
hash ^= lum.wrapping_mul(pos.wrapping_add(1).wrapping_mul(0x9e3779b97f4a7c15));
sample_count += 1;
}
x += step;
}
y += step;
}
hash ^ sample_count
}
pub fn compute_tile_hashes(
pixels: &[u8],
frame_width: usize,
frame_height: usize,
tile_size: usize,
) -> Vec<TileHash> {
if frame_width == 0 || frame_height == 0 || tile_size == 0 { return Vec::new(); }
let tiles_x = (frame_width + tile_size - 1) / tile_size;
let tiles_y = (frame_height + tile_size - 1) / tile_size;
let mut hashes = Vec::with_capacity(tiles_x * tiles_y);
for ty in 0..tiles_y {
for tx in 0..tiles_x {
hashes.push(tile_hash_cpu(pixels, frame_width, frame_height, tx, ty, tile_size));
}
}
hashes
}
pub fn detect_changes_cpu(prev: &[TileHash], curr: &[TileHash]) -> Vec<usize> {
prev.iter()
.zip(curr.iter())
.enumerate()
.filter_map(|(i, (p, c))| if p != c { Some(i) } else { None })
.collect()
}
fn tile_descriptor_cpu(
pixels: &[u8],
frame_width: usize,
frame_height: usize,
tile_x: usize,
tile_y: usize,
tile_size: usize,
) -> TileDescriptor {
let x_start = tile_x * tile_size;
let y_start = tile_y * tile_size;
let x_end = (x_start + tile_size).min(frame_width);
let y_end = (y_start + tile_size).min(frame_height);
let mut r_sum = 0u64;
let mut g_sum = 0u64;
let mut b_sum = 0u64;
let mut pixel_count = 0u64;
let mut edge_sum = 0.0f32;
let mut edge_count = 0u32;
for y in y_start..y_end {
for x in x_start..x_end {
let idx = (y * frame_width + x) * 4;
if idx + 2 >= pixels.len() { continue; }
r_sum += pixels[idx] as u64;
g_sum += pixels[idx + 1] as u64;
b_sum += pixels[idx + 2] as u64;
pixel_count += 1;
if x + 1 < x_end && y + 1 < y_end {
let right_idx = (y * frame_width + x + 1) * 4;
let down_idx = ((y + 1) * frame_width + x) * 4;
if right_idx + 2 < pixels.len() && down_idx + 2 < pixels.len() {
let lum_c = pixels[idx] as f32 * 0.299 + pixels[idx+1] as f32 * 0.587 + pixels[idx+2] as f32 * 0.114;
let lum_r = pixels[right_idx] as f32 * 0.299 + pixels[right_idx+1] as f32 * 0.587 + pixels[right_idx+2] as f32 * 0.114;
let lum_d = pixels[down_idx] as f32 * 0.299 + pixels[down_idx+1] as f32 * 0.587 + pixels[down_idx+2] as f32 * 0.114;
let gx = (lum_r - lum_c).abs();
let gy = (lum_d - lum_c).abs();
edge_sum += (gx * gx + gy * gy).sqrt() / 255.0;
edge_count += 1;
}
}
}
}
let mean_color = if pixel_count > 0 {
[
(r_sum / pixel_count) as u8,
(g_sum / pixel_count) as u8,
(b_sum / pixel_count) as u8,
]
} else {
[0, 0, 0]
};
let edge_density = if edge_count > 0 { edge_sum / edge_count as f32 } else { 0.0 };
TileDescriptor {
tile_x: tile_x as u32,
tile_y: tile_y as u32,
mean_color,
edge_density,
has_text: edge_density > 0.4,
}
}
pub fn compress_frame_cpu(
pixels: &[u8],
frame_width: usize,
frame_height: usize,
prev_hashes: &mut Option<Vec<TileHash>>,
) -> CompressedFrame {
const TILE_SIZE: usize = 16;
if frame_width == 0 || frame_height == 0 {
return CompressedFrame {
changed_tiles: Vec::new(),
total_tiles: 0,
frame_width: frame_width as u32,
frame_height: frame_height as u32,
tile_size: TILE_SIZE as u32,
};
}
let tiles_x = (frame_width + TILE_SIZE - 1) / TILE_SIZE;
let tiles_y = (frame_height + TILE_SIZE - 1) / TILE_SIZE;
let total_tiles = tiles_x * tiles_y;
let curr_hashes = compute_tile_hashes(pixels, frame_width, frame_height, TILE_SIZE);
let changed_indices = match prev_hashes {
Some(prev) => detect_changes_cpu(prev, &curr_hashes),
None => (0..total_tiles).collect(), };
let changed_tiles: Vec<TileDescriptor> = changed_indices.iter().map(|&idx| {
let tx = idx % tiles_x;
let ty = idx / tiles_x;
tile_descriptor_cpu(pixels, frame_width, frame_height, tx, ty, TILE_SIZE)
}).collect();
*prev_hashes = Some(curr_hashes);
CompressedFrame {
changed_tiles,
total_tiles,
frame_width: frame_width as u32,
frame_height: frame_height as u32,
tile_size: TILE_SIZE as u32,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn product_window_detection() {
let targets = &["App A", "App B", "App C", "App D", "App E", "App F"];
assert!(is_product_window("App A - Main Window", targets));
assert!(is_product_window("App B", targets));
assert!(!is_product_window("Firefox", targets));
}
#[test]
fn product_window_empty_default() {
assert!(!is_product_window_default("App A - Main Window"));
assert!(!is_product_window_default("Firefox"));
assert!(!is_product_window_default("anything"));
}
#[test]
fn debug_window_detection() {
assert!(is_debug_window("App A (DEBUG) Console"));
assert!(!is_debug_window("App A Main"));
}
#[test]
fn lru_eviction_count() {
let mut cache = CaptureCache::new();
for i in 0..40 {
cache.push(CaptureResult { window: WindowInfo { title: format!("w{}", i), hwnd: i as u64, x: 0, y: 0, width: 100, height: 100, is_debug: false }, thumbnail_path: PathBuf::new(), detail_path: PathBuf::new(), perceptual_hash: 0, size_bytes: 100 });
}
assert!(cache.len() <= MAX_CAPTURES);
}
#[test]
fn lru_eviction_bytes() {
let mut cache = CaptureCache::new();
for i in 0..5 {
cache.push(CaptureResult { window: WindowInfo { title: "w".into(), hwnd: i, x: 0, y: 0, width: 100, height: 100, is_debug: false }, thumbnail_path: PathBuf::new(), detail_path: PathBuf::new(), perceptual_hash: 0, size_bytes: 20 * 1024 * 1024 });
}
assert!(cache.total_bytes() <= MAX_BYTES);
}
#[test]
fn perceptual_hash_deterministic() {
let pixels = vec![128u8; 400]; let a = perceptual_hash(&pixels, 10, 10);
let b = perceptual_hash(&pixels, 10, 10);
assert_eq!(a, b);
}
#[test]
fn hamming_identical() { assert_eq!(hamming_distance(0xFF, 0xFF), 0); }
#[test]
fn hamming_different() { assert!(hamming_distance(0x00, 0xFF) > 0); }
#[test]
fn p6_tile_delta_idempotence() {
let w = 64usize;
let h = 64usize;
let pixels: Vec<u8> = (0..w * h * 4).map(|i| (i % 256) as u8).collect();
let mut prev_hashes: Option<Vec<TileHash>> = None;
let first = compress_frame_cpu(&pixels, w, h, &mut prev_hashes);
assert!(first.changed_tiles.len() > 0, "first frame should have changed tiles");
let second = compress_frame_cpu(&pixels, w, h, &mut prev_hashes);
assert_eq!(
second.changed_tiles.len(), 0,
"identical frame should produce 0 changed tiles (idempotence)"
);
}
#[test]
fn p7_tile_hash_determinism() {
let w = 32usize;
let h = 32usize;
let pixels: Vec<u8> = (0..w * h * 4).map(|i| ((i * 7 + 13) % 256) as u8).collect();
let hash_a = tile_hash_cpu(&pixels, w, h, 0, 0, 16);
let hash_b = tile_hash_cpu(&pixels, w, h, 0, 0, 16);
assert_eq!(hash_a, hash_b, "tile_hash_cpu must be deterministic");
let hashes_a = compute_tile_hashes(&pixels, w, h, 16);
let hashes_b = compute_tile_hashes(&pixels, w, h, 16);
assert_eq!(hashes_a, hashes_b, "compute_tile_hashes must be deterministic");
}
#[test]
fn tile_delta_detects_changes() {
let w = 64usize;
let h = 64usize;
let frame_a: Vec<u8> = vec![128u8; w * h * 4];
let mut frame_b = frame_a.clone();
for y in 0..16 {
for x in 0..16 {
let idx = (y * w + x) * 4;
frame_b[idx] = 255;
frame_b[idx + 1] = 0;
frame_b[idx + 2] = 0;
}
}
let mut prev: Option<Vec<TileHash>> = None;
let _ = compress_frame_cpu(&frame_a, w, h, &mut prev);
let result = compress_frame_cpu(&frame_b, w, h, &mut prev);
assert!(result.changed_tiles.len() >= 1, "changed tile should be detected");
assert!(
result.changed_tiles.iter().any(|t| t.tile_x == 0 && t.tile_y == 0),
"tile (0,0) should be detected as changed"
);
}
#[test]
fn p8_bt601_differs_from_naive_average() {
let w = 64usize;
let h = 64usize;
let mut pixels = vec![0u8; w * h * 4];
for i in 0..w * h {
pixels[i * 4] = 255; pixels[i * 4 + 1] = 0; pixels[i * 4 + 2] = 0; pixels[i * 4 + 3] = 255; }
let bt601_hash = perceptual_hash(&pixels, w, h);
let naive_hash = {
let mut grid = [0u8; 64];
for gy in 0..8usize {
for gx in 0..8usize {
let sx = gx * w / 8;
let sy = gy * h / 8;
let idx = (sy * w + sx) * 4;
if idx + 2 < pixels.len() {
grid[gy * 8 + gx] = ((pixels[idx] as u16 + pixels[idx+1] as u16 + pixels[idx+2] as u16) / 3) as u8;
}
}
}
let avg: u16 = grid.iter().map(|&v| v as u16).sum::<u16>() / 64;
let mut hash = 0u64;
for (i, &v) in grid.iter().enumerate() { if v as u16 >= avg { hash |= 1 << i; } }
hash
};
let bt601_lum = (255.0f32 * 0.299 + 0.0 * 0.587 + 0.0 * 0.114) as u8;
let naive_lum = (255u16 + 0 + 0) / 3;
assert_ne!(bt601_lum as u16, naive_lum, "BT.601 and naive luminance must differ for red-dominant input");
let _ = bt601_hash;
let _ = naive_hash;
}
#[test]
fn p9_perceptual_hash_idempotence() {
let pixels: Vec<u8> = (0..64 * 64 * 4).map(|i| (i % 256) as u8).collect();
let h1 = perceptual_hash(&pixels, 64, 64);
let h2 = perceptual_hash(&pixels, 64, 64);
assert_eq!(h1, h2, "perceptual_hash must be idempotent");
}
#[test]
fn compressed_frame_total_tiles_correct() {
let w = 64usize;
let h = 64usize;
let pixels = vec![0u8; w * h * 4];
let mut prev: Option<Vec<TileHash>> = None;
let result = compress_frame_cpu(&pixels, w, h, &mut prev);
assert_eq!(result.total_tiles, 16);
assert_eq!(result.tile_size, 16);
}
use proptest::prelude::*;
use proptest::collection::vec;
proptest! {
#[test]
fn prop2_capture_cache_lru_invariants(
sizes in vec(1usize..=10_000_000, 1..=50)
) {
let mut cache = CaptureCache::new();
for (i, &sz) in sizes.iter().enumerate() {
cache.push(CaptureResult {
window: WindowInfo {
title: format!("w{}", i),
hwnd: i as u64,
x: 0, y: 0, width: 100, height: 100,
is_debug: false,
},
thumbnail_path: PathBuf::new(),
detail_path: PathBuf::new(),
perceptual_hash: 0,
size_bytes: sz,
});
prop_assert!(cache.len() <= MAX_CAPTURES,
"cache len {} exceeded MAX_CAPTURES {}", cache.len(), MAX_CAPTURES);
prop_assert!(cache.total_bytes() <= MAX_BYTES,
"cache bytes {} exceeded MAX_BYTES {}", cache.total_bytes(), MAX_BYTES);
}
}
}
proptest! {
#[test]
fn prop3_empty_product_targets_default(s in "\\PC*") {
prop_assert!(!is_product_window_default(&s),
"is_product_window_default should always return false for {:?}", s);
}
}
proptest! {
#[test]
fn prop13_tile_hash_determinism(
w in 1usize..=64,
h in 1usize..=64,
data in vec(any::<u8>(), 1..=16384),
) {
let needed = w * h * 4;
let pixels: Vec<u8> = data.into_iter().cycle().take(needed).collect();
let hash_a = tile_hash_cpu(&pixels, w, h, 0, 0, 16);
let hash_b = tile_hash_cpu(&pixels, w, h, 0, 0, 16);
prop_assert_eq!(hash_a, hash_b, "tile_hash_cpu must be deterministic");
let hashes_a = compute_tile_hashes(&pixels, w, h, 16);
let hashes_b = compute_tile_hashes(&pixels, w, h, 16);
prop_assert_eq!(hashes_a, hashes_b, "compute_tile_hashes must be deterministic");
}
}
proptest! {
#[test]
fn prop14_compression_idempotence(
w in 1usize..=64,
h in 1usize..=64,
data in vec(any::<u8>(), 1..=16384),
) {
let needed = w * h * 4;
let pixels: Vec<u8> = data.into_iter().cycle().take(needed).collect();
let mut prev_hashes: Option<Vec<TileHash>> = None;
let _first = compress_frame_cpu(&pixels, w, h, &mut prev_hashes);
let second = compress_frame_cpu(&pixels, w, h, &mut prev_hashes);
prop_assert_eq!(second.changed_tiles.len(), 0,
"identical frame should produce 0 changed tiles on second compression");
}
}
proptest! {
#[test]
fn prop15_change_detection_sensitivity(
w in 32usize..=64,
h in 32usize..=64,
data in vec(any::<u8>(), 1..=16384),
) {
let needed = w * h * 4;
let pixels: Vec<u8> = data.into_iter().cycle().take(needed).collect();
let mut prev_hashes: Option<Vec<TileHash>> = None;
let _first = compress_frame_cpu(&pixels, w, h, &mut prev_hashes);
let mut modified = pixels.clone();
for y in 0..16.min(h) {
for x in 0..16.min(w) {
let idx = (y * w + x) * 4;
if idx + 3 < modified.len() {
modified[idx] = modified[idx].wrapping_add(128);
modified[idx + 1] = modified[idx + 1].wrapping_add(128);
modified[idx + 2] = modified[idx + 2].wrapping_add(128);
}
}
}
let result = compress_frame_cpu(&modified, w, h, &mut prev_hashes);
prop_assert!(result.changed_tiles.len() >= 1,
"modifying a tile's pixels should detect at least 1 changed tile");
}
}
proptest! {
#[test]
fn prop17_hamming_distance_correctness(a: u64, b: u64) {
let expected = (a ^ b).count_ones();
let actual = hamming_distance(a, b);
prop_assert_eq!(actual, expected,
"hamming_distance({}, {}) = {} but (a ^ b).count_ones() = {}",
a, b, actual, expected);
}
}
}