forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
//! Vision Bridge — decode BMP captures into Frame structs,
//! run CPU-side tile hash compression, produce CompressedFrame JSON.
//!
//! This module bridges ForgeWright's BMP capture output into the
//! compression pipeline (CPU-only, no CUDA).

use std::path::Path;

use crate::inlined_compress::cpu_compress_frame;
use crate::inlined_types::{CompressedFrame, Frame};
use serde_json::Value;

/// Errors from the vision bridge.
#[derive(Debug)]
pub enum BridgeError {
    Io(std::io::Error),
    InvalidBmp(String),
}

impl std::fmt::Display for BridgeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BridgeError::Io(e) => write!(f, "IO error: {}", e),
            BridgeError::InvalidBmp(msg) => write!(f, "Invalid BMP: {}", msg),
        }
    }
}

impl std::error::Error for BridgeError {}

impl From<std::io::Error> for BridgeError {
    fn from(e: std::io::Error) -> Self {
        BridgeError::Io(e)
    }
}

/// Bridge between ForgeWright BMP captures and tile-hash compression.
pub struct VisionBridge {
    /// Previous frame's tile hashes for delta detection.
    prev_hashes: Option<Vec<u64>>,
    /// Tile size for compression (default 16).
    tile_size: u32,
}

impl VisionBridge {
    /// Create a new VisionBridge with the given tile size.
    pub fn new(tile_size: u32) -> Self {
        Self {
            prev_hashes: None,
            tile_size,
        }
    }

    /// Decode a 24-bit BGR bottom-up BMP file into a BGRA Frame.
    ///
    /// Parses the 14-byte file header and 40-byte BITMAPINFOHEADER,
    /// handles row padding (each row padded to 4-byte boundary),
    /// and bottom-up row order (first row in file = bottom of image).
    /// Converts BGR to BGRA by adding alpha = 255.
    pub fn decode_bmp(path: &Path) -> Result<Frame, BridgeError> {
        let data = std::fs::read(path)?;

        // BMP file header: 14 bytes
        if data.len() < 54 {
            return Err(BridgeError::InvalidBmp("file too small for BMP header".into()));
        }

        // Signature check: 'BM'
        if data[0] != b'B' || data[1] != b'M' {
            return Err(BridgeError::InvalidBmp("missing BM signature".into()));
        }

        // Pixel data offset from file header bytes 10..14
        let pixel_offset = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize;

        // BITMAPINFOHEADER starts at byte 14
        let info_size = u32::from_le_bytes([data[14], data[15], data[16], data[17]]);
        if info_size < 40 {
            return Err(BridgeError::InvalidBmp(
                format!("unsupported info header size: {}", info_size),
            ));
        }

        let width = i32::from_le_bytes([data[18], data[19], data[20], data[21]]);
        let height = i32::from_le_bytes([data[22], data[23], data[24], data[25]]);

        if width <= 0 {
            return Err(BridgeError::InvalidBmp(
                format!("invalid width: {}", width),
            ));
        }

        // height can be negative (top-down) or positive (bottom-up)
        let bottom_up = height > 0;
        let abs_height = height.unsigned_abs();
        let w = width as u32;
        let h = abs_height;

        let bits_per_pixel = u16::from_le_bytes([data[28], data[29]]);
        if bits_per_pixel != 24 {
            return Err(BridgeError::InvalidBmp(
                format!("only 24-bit BMP supported, got {}-bit", bits_per_pixel),
            ));
        }

        let compression = u32::from_le_bytes([data[30], data[31], data[32], data[33]]);
        if compression != 0 {
            return Err(BridgeError::InvalidBmp(
                format!("only uncompressed BMP supported, compression={}", compression),
            ));
        }

        // Row stride: each row is padded to 4-byte boundary
        let row_bytes = (w * 3 + 3) & !3;

        let expected_pixel_data = (row_bytes as usize) * (h as usize);
        if data.len() < pixel_offset + expected_pixel_data {
            return Err(BridgeError::InvalidBmp(
                format!(
                    "file too small: need {} bytes of pixel data, have {}",
                    expected_pixel_data,
                    data.len() - pixel_offset
                ),
            ));
        }

        // Convert BGR bottom-up to BGRA top-down
        let pixel_count = (w as usize) * (h as usize);
        let mut bgra = Vec::with_capacity(pixel_count * 4);

        for row in 0..h as usize {
            // In bottom-up BMP, row 0 in file = bottom of image
            let src_row = if bottom_up { (h as usize) - 1 - row } else { row };
            let row_start = pixel_offset + src_row * (row_bytes as usize);

            for col in 0..w as usize {
                let px = row_start + col * 3;
                let b = data[px];
                let g = data[px + 1];
                let r = data[px + 2];
                bgra.push(b);
                bgra.push(g);
                bgra.push(r);
                bgra.push(255); // alpha
            }
        }

        debug_assert_eq!(bgra.len(), pixel_count * 4);

        Ok(Frame::new(w, h, bgra))
    }

    /// Compress a Frame into a CompressedFrame using CPU-side tile hashing.
    /// Tracks delta state across calls (static screens → 0 changed tiles).
    pub fn compress(&mut self, frame: &Frame) -> CompressedFrame {
        cpu_compress_frame(frame, &mut self.prev_hashes, self.tile_size)
    }

    /// Compress a BMP file end-to-end: decode → compress → return.
    pub fn compress_bmp(&mut self, path: &Path) -> Result<CompressedFrame, BridgeError> {
        let frame = Self::decode_bmp(path)?;
        Ok(self.compress(&frame))
    }

    /// Serialize a CompressedFrame to JSON for agent consumption.
    ///
    /// Produces JSON with: total_tiles, changed_tiles, change_ratio,
    /// and per-tile descriptors with col, row, mean_color, edge_density, likely_text.
    pub fn to_json(compressed: &CompressedFrame) -> Value {
        let total = compressed.total_tiles;
        let changed = compressed.changed_tiles;
        let change_ratio = if total > 0 {
            changed as f64 / total as f64
        } else {
            0.0
        };

        let tiles: Vec<Value> = compressed
            .tiles
            .iter()
            .map(|t| {
                serde_json::json!({
                    "col": t.col,
                    "row": t.row,
                    "mean_color": t.mean_color,
                    "edge_density": t.edge_density,
                    "likely_text": t.likely_text,
                })
            })
            .collect();

        serde_json::json!({
            "total_tiles": total,
            "changed_tiles": changed,
            "change_ratio": change_ratio,
            "tiles": tiles,
        })
    }

    /// Reset delta tracking. Next compression reports all tiles as changed.
    pub fn reset(&mut self) {
        self.prev_hashes = None;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use tempfile::NamedTempFile;

    /// Write a BGRA pixel buffer as a 24-bit BGR bottom-up BMP file.
    /// This is the inverse of decode_bmp for round-trip testing.
    fn write_bmp_24bit(path: &Path, width: u32, height: u32, bgra: &[u8]) {
        assert_eq!(bgra.len(), (width as usize) * (height as usize) * 4);

        let row_bytes = (width * 3 + 3) & !3;
        let pixel_data_size = row_bytes * height;
        let file_size = 54 + pixel_data_size;

        let mut out = Vec::with_capacity(file_size as usize);

        // File header (14 bytes)
        out.extend_from_slice(b"BM");
        out.extend_from_slice(&file_size.to_le_bytes());
        out.extend_from_slice(&[0u8; 4]); // reserved
        out.extend_from_slice(&54u32.to_le_bytes()); // pixel data offset

        // BITMAPINFOHEADER (40 bytes)
        out.extend_from_slice(&40u32.to_le_bytes()); // header size
        out.extend_from_slice(&(width as i32).to_le_bytes());
        out.extend_from_slice(&(height as i32).to_le_bytes()); // positive = bottom-up
        out.extend_from_slice(&1u16.to_le_bytes()); // planes
        out.extend_from_slice(&24u16.to_le_bytes()); // bits per pixel
        out.extend_from_slice(&0u32.to_le_bytes()); // compression (none)
        out.extend_from_slice(&pixel_data_size.to_le_bytes()); // image size
        out.extend_from_slice(&[0u8; 16]); // ppm_x, ppm_y, colors_used, colors_important

        // Pixel data: bottom-up BGR with row padding
        let w = width as usize;
        let pad = (row_bytes - width * 3) as usize;

        for row in (0..height as usize).rev() {
            // Bottom-up: last row of image first in file
            for col in 0..w {
                let px = (row * w + col) * 4;
                let b = bgra[px];
                let g = bgra[px + 1];
                let r = bgra[px + 2];
                // Write BGR (drop alpha)
                out.push(b);
                out.push(g);
                out.push(r);
            }
            // Pad row to 4-byte boundary
            for _ in 0..pad {
                out.push(0);
            }
        }

        std::fs::write(path, &out).expect("failed to write BMP");
    }

    /// **Property 10: BMP decode round-trip**
    ///
    /// Write a BGRA buffer as 24-bit BGR bottom-up BMP, decode via `decode_bmp`,
    /// verify equivalent pixel data and `data.len() == width * height * 4`.
    ///
    /// **Validates: Requirement 5.1**
    proptest! {
        #[test]
        fn prop_bmp_decode_round_trip(
            width in 1u32..=64,
            height in 1u32..=64,
            seed in any::<u8>(),
        ) {
            // Generate deterministic BGRA pixel data
            let pixel_count = (width as usize) * (height as usize);
            let mut bgra = Vec::with_capacity(pixel_count * 4);
            for i in 0..pixel_count {
                let v = ((i as u8).wrapping_add(seed)) as u8;
                bgra.push(v);           // B
                bgra.push(v.wrapping_add(1)); // G
                bgra.push(v.wrapping_add(2)); // R
                bgra.push(255);         // A (will be reconstructed as 255)
            }

            // Write as BMP
            let tmp = NamedTempFile::new().unwrap();
            let path = tmp.path().to_path_buf();
            write_bmp_24bit(&path, width, height, &bgra);

            // Decode
            let frame = VisionBridge::decode_bmp(&path).unwrap();

            // Verify dimensions
            prop_assert_eq!(frame.width, width);
            prop_assert_eq!(frame.height, height);

            // Verify data length invariant
            prop_assert_eq!(frame.data.len(), pixel_count * 4);

            // Verify pixel equivalence (BMP drops alpha, decode sets alpha=255)
            for i in 0..pixel_count {
                let orig_b = bgra[i * 4];
                let orig_g = bgra[i * 4 + 1];
                let orig_r = bgra[i * 4 + 2];

                let dec_b = frame.data[i * 4];
                let dec_g = frame.data[i * 4 + 1];
                let dec_r = frame.data[i * 4 + 2];
                let dec_a = frame.data[i * 4 + 3];

                prop_assert_eq!(dec_b, orig_b, "B mismatch at pixel {}", i);
                prop_assert_eq!(dec_g, orig_g, "G mismatch at pixel {}", i);
                prop_assert_eq!(dec_r, orig_r, "R mismatch at pixel {}", i);
                prop_assert_eq!(dec_a, 255, "A should be 255 at pixel {}", i);
            }
        }
    }

    /// **Property 11: CompressedFrame JSON completeness**
    ///
    /// Serialize any CompressedFrame via `to_json`, verify JSON contains keys:
    /// total_tiles, changed_tiles, change_ratio, tiles array with col, row,
    /// mean_color, edge_density, likely_text per element.
    ///
    /// **Validates: Requirement 5.8**
    proptest! {
        #[test]
        fn prop_compressed_frame_json_completeness(
            width in 1u32..=128,
            height in 1u32..=128,
            tile_size_exp in 2u32..=5, // 4, 8, 16, 32
            seed in any::<u8>(),
        ) {
            let tile_size = 1 << tile_size_exp;
            let pixel_count = (width as usize) * (height as usize);
            let data: Vec<u8> = (0..pixel_count * 4)
                .map(|i| ((i as u8).wrapping_add(seed)))
                .collect();
            let frame = Frame::new(width, height, data);

            let mut bridge = VisionBridge::new(tile_size);
            let compressed = bridge.compress(&frame);
            let json = VisionBridge::to_json(&compressed);

            // Top-level keys
            prop_assert!(json.get("total_tiles").is_some(), "missing total_tiles");
            prop_assert!(json.get("changed_tiles").is_some(), "missing changed_tiles");
            prop_assert!(json.get("change_ratio").is_some(), "missing change_ratio");
            prop_assert!(json.get("tiles").is_some(), "missing tiles");

            // Tiles array
            let tiles = json["tiles"].as_array().unwrap();
            prop_assert_eq!(tiles.len(), compressed.tiles.len());

            for tile in tiles {
                prop_assert!(tile.get("col").is_some(), "tile missing col");
                prop_assert!(tile.get("row").is_some(), "tile missing row");
                prop_assert!(tile.get("mean_color").is_some(), "tile missing mean_color");
                prop_assert!(tile.get("edge_density").is_some(), "tile missing edge_density");
                prop_assert!(tile.get("likely_text").is_some(), "tile missing likely_text");
            }

            // Verify numeric consistency
            let json_total = json["total_tiles"].as_u64().unwrap() as u32;
            let json_changed = json["changed_tiles"].as_u64().unwrap() as u32;
            prop_assert_eq!(json_total, compressed.total_tiles);
            prop_assert_eq!(json_changed, compressed.changed_tiles);
        }
    }
}