use std::io::BufReader;
use std::path::Path;
use base64::Engine;
const BULB_OPACITY: f32 = 0.50;
const TARGET_MEDIAN_LUM: f32 = 75.0;
const MAX_BRIGHTNESS_FACTOR: f32 = 2.5;
const MIN_BRIGHTNESS_FACTOR: f32 = 0.5;
const DISPLAY_WIDTH: u32 = 1280;
const DISPLAY_HEIGHT: u32 = 1024;
fn decode_b64_image(b64: &str) -> Option<image::DynamicImage> {
let clean: String = b64.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(&clean)
.ok()?;
image::load_from_memory(&bytes).ok()
}
pub(crate) fn median_luminosity(img: &image::RgbImage) -> f32 {
let gray = image::DynamicImage::ImageRgb8(img.clone()).into_luma8();
let mut hist = [0u32; 256];
for p in gray.pixels() {
hist[p[0] as usize] += 1;
}
let total = gray.pixels().count() as u32;
let half = total / 2;
let mut cumul = 0u32;
for (i, &count) in hist.iter().enumerate() {
cumul += count;
if cumul >= half {
return i as f32;
}
}
0.0
}
pub(crate) fn adjust_brightness(img: &mut image::RgbImage, factor: f32) {
for p in img.pixels_mut() {
p[0] = (p[0] as f32 * factor).min(255.0) as u8;
p[1] = (p[1] as f32 * factor).min(255.0) as u8;
p[2] = (p[2] as f32 * factor).min(255.0) as u8;
}
}
#[derive(Debug, Default, PartialEq)]
struct B2sData {
backglass_image: String,
grill_height: String,
bulbs: Vec<B2sBulb>,
}
#[derive(Debug, Default, PartialEq)]
struct B2sBulb {
parent: Option<String>,
image: String,
loc_x: String,
loc_y: String,
}
impl From<directb2s::DirectB2SData> for B2sData {
fn from(d: directb2s::DirectB2SData) -> Self {
B2sData {
backglass_image: d
.images
.backglass_image
.map(|i| i.value)
.unwrap_or_default(),
grill_height: d.grill_height.value,
bulbs: d
.illumination
.bulb
.unwrap_or_default()
.into_iter()
.map(|b| B2sBulb {
parent: b.parent,
image: b.image,
loc_x: b.loc_x,
loc_y: b.loc_y,
})
.collect(),
}
}
}
fn read_b2s_lenient<R: std::io::BufRead>(reader: R) -> Option<B2sData> {
use quick_xml::events::Event;
let mut xml = quick_xml::Reader::from_reader(reader);
let mut buf = Vec::new();
let mut out = B2sData::default();
let mut path: Vec<Vec<u8>> = Vec::new();
let mut seen_root = false;
loop {
let (ev, empty) = match xml.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => (Some(e.into_owned()), false),
Ok(Event::Empty(e)) => (Some(e.into_owned()), true),
Ok(Event::End(_)) => {
path.pop();
(None, false)
}
Ok(_) => (None, false),
Err(_) => break,
};
buf.clear();
let Some(e) = ev else { continue };
let name = e.name().as_ref().as_bytes().to_vec();
seen_root = true;
let attr = |key: &str| -> Option<String> {
e.attributes().flatten().find_map(|a| {
(a.key.as_ref() == key)
.then(|| {
a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|v| v.into_owned())
})
.flatten()
})
};
let parent = path.last().map(|p| p.as_slice());
match (parent, name.as_slice()) {
(Some(b"Images"), b"BackglassImage") => {
out.backglass_image = attr("Value").unwrap_or_default();
}
(Some(b"Illumination"), b"Bulb") => out.bulbs.push(B2sBulb {
parent: attr("Parent"),
image: attr("Image").unwrap_or_default(),
loc_x: attr("LocX").unwrap_or_default(),
loc_y: attr("LocY").unwrap_or_default(),
}),
(Some(_), b"GrillHeight") => {
out.grill_height = attr("Value").unwrap_or_default();
}
_ => {}
}
if !empty {
path.push(name);
}
}
(seen_root && !out.backglass_image.is_empty()).then_some(out)
}
fn composite_bulb(base: &mut image::RgbaImage, bulb: &B2sBulb) {
match &bulb.parent {
Some(p) if p == "Backglass" => {}
_ => return,
}
if bulb.image.is_empty() || bulb.image == "[stripped]" {
return;
}
let x: i32 = bulb.loc_x.parse().unwrap_or(0);
let y: i32 = bulb.loc_y.parse().unwrap_or(0);
if x < 0 || y < 0 {
return;
}
let sprite = match decode_b64_image(&bulb.image) {
Some(img) => img.to_rgba8(),
None => return,
};
let bw = base.width();
let bh = base.height();
for sy in 0..sprite.height() {
for sx in 0..sprite.width() {
let dx = x as u32 + sx;
let dy = y as u32 + sy;
if dx >= bw || dy >= bh {
continue;
}
let src = sprite.get_pixel(sx, sy);
let alpha = (src[3] as f32 / 255.0) * BULB_OPACITY;
if alpha < 0.01 {
continue;
}
let dst = base.get_pixel(dx, dy);
let blend = |s: u8, d: u8| -> u8 {
((s as f32 * alpha) + (d as f32 * (1.0 - alpha))).min(255.0) as u8
};
base.put_pixel(
dx,
dy,
image::Rgba([
blend(src[0], dst[0]),
blend(src[1], dst[1]),
blend(src[2], dst[2]),
255,
]),
);
}
}
}
pub fn extract_backglass_from_launcher_override(table_dir: &Path) -> Option<Vec<u8>> {
let medias = table_dir.join("medias");
for ext in ["png", "webp", "jpg", "jpeg"] {
let candidate = medias.join(format!("launcher.{ext}"));
if candidate.is_file() {
match std::fs::read(&candidate) {
Ok(bytes) if !bytes.is_empty() => {
log::info!("Backglass: user override {}", candidate.display());
return Some(bytes);
}
Ok(_) => {
log::warn!("Backglass: {} is empty, skipping", candidate.display());
}
Err(e) => {
log::warn!("Backglass: failed to read {}: {e}", candidate.display());
}
}
}
}
None
}
pub fn extract_backglass_from_vpinmediadb(table_dir: &Path) -> Option<Vec<u8>> {
let candidate = table_dir.join("medias").join("bg.png");
if !candidate.is_file() {
return None;
}
match std::fs::read(&candidate) {
Ok(bytes) if !bytes.is_empty() => {
log::info!("Backglass: vpinmediadb cache {}", candidate.display());
Some(bytes)
}
Ok(_) => {
log::warn!("Backglass: {} is empty, skipping", candidate.display());
None
}
Err(e) => {
log::warn!("Backglass: failed to read {}: {e}", candidate.display());
None
}
}
}
pub fn extract_backglass_from_b2s(directb2s_path: &Path) -> Option<Vec<u8>> {
if crate::b2s_dmd::made_by_pinready(directb2s_path) {
log::info!(
"Backglass of {} is the black one PinReady made: not used for the tile",
directb2s_path.display()
);
return None;
}
log::info!(
"Extracting backglass from .directb2s: {}",
directb2s_path.display()
);
let file = match std::fs::File::open(directb2s_path) {
Ok(f) => f,
Err(e) => {
log::error!("Failed to open {}: {e}", directb2s_path.display());
return None;
}
};
let reader = BufReader::new(file);
let data: B2sData = match directb2s::read(reader) {
Ok(d) => d.into(),
Err(strict) => {
let again = std::fs::File::open(directb2s_path)
.ok()
.and_then(|f| read_b2s_lenient(BufReader::new(f)));
match again {
Some(d) => {
log::warn!(
"{}: strict parse failed ({strict}); read the backglass leniently",
directb2s_path.display()
);
d
}
None => {
log::error!("Failed to parse {}: {strict}", directb2s_path.display());
return None;
}
}
}
};
if data.backglass_image.is_empty() || data.backglass_image == "[stripped]" {
log::warn!("No backglass image data in {}", directb2s_path.display());
return None;
}
let img = decode_b64_image(&data.backglass_image)?;
let grill_height: u32 = data.grill_height.trim().parse().unwrap_or(0);
let crop_height = if grill_height > 0 && grill_height < img.height() {
img.height() - grill_height
} else {
img.height()
};
let cropped = img.crop_imm(0, 0, img.width(), crop_height);
let mut rgba = cropped.to_rgba8();
let bulb_count = {
let bg_bulbs: Vec<_> = data
.bulbs
.iter()
.filter(|b| {
b.parent.as_deref() == Some("Backglass")
&& !b.image.is_empty()
&& b.image != "[stripped]"
})
.collect();
let count = bg_bulbs.len();
if count > 0 {
log::info!(
"Compositing {} Backglass bulbs at {}% opacity",
count,
(BULB_OPACITY * 100.0) as u32
);
}
for bulb in &data.bulbs {
composite_bulb(&mut rgba, bulb);
}
count
};
let mut rgb = image::DynamicImage::ImageRgba8(rgba).into_rgb8();
let median = median_luminosity(&rgb);
if median > 1.0 {
let factor =
(TARGET_MEDIAN_LUM / median).clamp(MIN_BRIGHTNESS_FACTOR, MAX_BRIGHTNESS_FACTOR);
log::info!(
"Brightness normalization: median {:.0} -> {:.0} (x{:.2})",
median,
median * factor,
factor
);
adjust_brightness(&mut rgb, factor);
} else {
log::warn!(
"Very dark image (median={:.0}), skipping normalization",
median
);
}
let final_img = image::DynamicImage::ImageRgb8(rgb);
let resized = final_img.resize(
DISPLAY_WIDTH,
DISPLAY_HEIGHT,
image::imageops::FilterType::Lanczos3,
);
let bytes = encode_jpeg(&resized)?;
log::info!(
"Extracted backglass ({}x{}, {} bulbs, {} bytes)",
resized.width(),
resized.height(),
bulb_count,
bytes.len()
);
Some(bytes)
}
pub fn extract_backglass_from_vpx(vpx_path: &Path) -> Option<Vec<u8>> {
log::info!(
"Scanning .vpx images/ for a backglass asset: {}",
vpx_path.display()
);
let mut vpx = match vpin::vpx::open(vpx_path) {
Ok(f) => f,
Err(e) => {
log::error!("Failed to open .vpx {}: {e}", vpx_path.display());
return None;
}
};
let images = match vpx.read_images() {
Ok(i) => i,
Err(e) => {
log::error!(
"Failed to read image catalog from {}: {e}",
vpx_path.display()
);
return None;
}
};
let mut candidates: Vec<_> = images
.iter()
.filter(|img| {
let name = img.name.to_lowercase();
let path = img.path.to_lowercase();
name.contains("backglass") || path.contains("backglass")
})
.filter_map(|img| {
let data = img.jpeg.as_ref()?.data.as_slice();
if data.is_empty() {
None
} else {
Some((img, data))
}
})
.collect();
if candidates.is_empty() {
log::debug!(
"No backglass candidate with encoded data in {}",
vpx_path.display()
);
return None;
}
candidates.sort_by_key(|(_, data)| std::cmp::Reverse(data.len()));
for (img, data) in &candidates {
log::info!(
" candidate: name={:?} path={:?} size={}",
img.name,
img.path,
data.len()
);
}
let (candidate, raw) = candidates[0];
log::info!(
"Picked largest backglass candidate: name={:?} ({} bytes)",
candidate.name,
raw.len()
);
let img = match image::load_from_memory(raw) {
Ok(i) => i,
Err(e) => {
log::warn!(
"Failed to decode backglass candidate {} from {}: {e}",
candidate.name,
vpx_path.display()
);
return None;
}
};
let resized = img.resize(
DISPLAY_WIDTH,
DISPLAY_HEIGHT,
image::imageops::FilterType::Lanczos3,
);
let bytes = encode_jpeg(&resized)?;
log::info!(
"Extracted embedded backglass from .vpx ({}x{}, {} bytes)",
resized.width(),
resized.height(),
bytes.len()
);
Some(bytes)
}
const BG_JPEG_QUALITY: u8 = 85;
pub const THUMB_WIDTH: u32 = 480;
pub const THUMB_HEIGHT: u32 = 384;
pub fn make_thumbnail(image_bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(image_bytes).ok()?;
let small = if img.width() <= THUMB_WIDTH && img.height() <= THUMB_HEIGHT {
img
} else {
img.resize(
THUMB_WIDTH,
THUMB_HEIGHT,
image::imageops::FilterType::Triangle,
)
};
encode_jpeg(&small)
}
fn encode_jpeg(img: &image::DynamicImage) -> Option<Vec<u8>> {
let rgb = img.to_rgb8();
let mut buf = Vec::new();
{
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(
std::io::Cursor::new(&mut buf),
BG_JPEG_QUALITY,
);
enc.encode_image(&rgb).ok()?;
}
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgb, RgbImage};
#[test]
fn median_luminosity_black_image() {
let img = RgbImage::from_pixel(10, 10, Rgb([0, 0, 0]));
assert!((median_luminosity(&img) - 0.0).abs() < 1.0);
}
#[test]
fn median_luminosity_white_image() {
let img = RgbImage::from_pixel(10, 10, Rgb([255, 255, 255]));
assert!((median_luminosity(&img) - 255.0).abs() < 1.0);
}
#[test]
fn median_luminosity_gray_image() {
let img = RgbImage::from_pixel(10, 10, Rgb([128, 128, 128]));
let median = median_luminosity(&img);
assert!((median - 128.0).abs() < 2.0, "expected ~128, got {median}");
}
#[test]
fn median_luminosity_mixed() {
let mut img = RgbImage::new(10, 10);
for x in 0..10 {
for y in 0..10 {
let val = if y < 5 { 0 } else { 255 };
img.put_pixel(x, y, Rgb([val, val, val]));
}
}
let median = median_luminosity(&img);
assert!(median == 0.0 || median == 255.0);
}
#[test]
fn adjust_brightness_factor_1_unchanged() {
let mut img = RgbImage::from_pixel(2, 2, Rgb([100, 150, 200]));
adjust_brightness(&mut img, 1.0);
let p = img.get_pixel(0, 0);
assert_eq!(p, &Rgb([100, 150, 200]));
}
#[test]
fn adjust_brightness_factor_2() {
let mut img = RgbImage::from_pixel(1, 1, Rgb([50, 100, 200]));
adjust_brightness(&mut img, 2.0);
let p = img.get_pixel(0, 0);
assert_eq!(p[0], 100);
assert_eq!(p[1], 200);
assert_eq!(p[2], 255); }
#[test]
fn adjust_brightness_factor_half() {
let mut img = RgbImage::from_pixel(1, 1, Rgb([100, 200, 50]));
adjust_brightness(&mut img, 0.5);
let p = img.get_pixel(0, 0);
assert_eq!(p[0], 50);
assert_eq!(p[1], 100);
assert_eq!(p[2], 25);
}
#[test]
fn adjust_brightness_clamps_to_255() {
let mut img = RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]));
adjust_brightness(&mut img, 3.0);
let p = img.get_pixel(0, 0);
assert_eq!(p, &Rgb([255, 255, 255]));
}
#[test]
fn decode_b64_invalid_returns_none() {
assert!(decode_b64_image("not-valid-base64!!!").is_none());
}
#[test]
fn decode_b64_empty_returns_none() {
assert!(decode_b64_image("").is_none());
}
#[test]
fn decode_b64_valid_png() {
let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let img = decode_b64_image(png_b64);
assert!(img.is_some());
let img = img.unwrap();
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
#[test]
fn decode_b64_with_whitespace() {
let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAf\n FcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
assert!(decode_b64_image(png_b64).is_some());
}
const B2S_ODD: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<DirectB2SData Version="1.2">
<Name Value="odd"/>
<Images>
<BackglassOffImage Value="ignored"/>
<BackglassImage Value="IMGDATA"/>
<ThumbnailImage Value="ignored"/>
</Images>
<GrillHeight Value=" 120 "/>
<Illumination>
<Bulb ID="1" Parent="Backglass" Image="BULB1" LocX="10" LocY="20" RomIDType="-1" DualMode="-1"/>
<Bulb ID="2" Parent="DMD" Image="BULB2" LocX="1" LocY="2"/>
<Bulb ID="3" Image="BULB3"/>
</Illumination>
</DirectB2SData>"#;
#[test]
fn lenient_reader_takes_what_the_strict_parser_refuses() {
assert!(directb2s::read(std::io::Cursor::new(B2S_ODD)).is_err());
let d = read_b2s_lenient(std::io::Cursor::new(B2S_ODD)).unwrap();
assert_eq!(d.backglass_image, "IMGDATA");
assert_eq!(d.grill_height.trim(), "120");
assert_eq!(d.bulbs.len(), 3);
assert_eq!(
d.bulbs[0],
B2sBulb {
parent: Some("Backglass".into()),
image: "BULB1".into(),
loc_x: "10".into(),
loc_y: "20".into(),
}
);
assert_eq!(d.bulbs[1].parent.as_deref(), Some("DMD"));
assert_eq!(d.bulbs[2].parent, None);
assert_eq!(d.bulbs[2].loc_x, "");
}
#[test]
fn lenient_reader_needs_a_backglass_image() {
let no_image = "<DirectB2SData><Images/><GrillHeight Value=\"1\"/></DirectB2SData>";
assert!(read_b2s_lenient(std::io::Cursor::new(no_image)).is_none());
assert!(read_b2s_lenient(std::io::Cursor::new("not xml at all")).is_none());
assert!(read_b2s_lenient(std::io::Cursor::new("")).is_none());
}
#[test]
fn lenient_reader_keeps_the_image_of_a_truncated_file() {
let cut = &B2S_ODD[..B2S_ODD.find("<Illumination>").unwrap() + 20];
let d = read_b2s_lenient(std::io::Cursor::new(cut)).unwrap();
assert_eq!(d.backglass_image, "IMGDATA");
assert!(d.bulbs.is_empty());
}
fn png_of(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::ImageRgb8(RgbImage::from_pixel(w, h, Rgb([90, 40, 160])));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
#[test]
fn thumbnail_fits_the_tile_and_keeps_the_ratio() {
let t = make_thumbnail(&png_of(1280, 720)).unwrap();
let img = image::load_from_memory(&t).unwrap();
assert_eq!(img.width(), THUMB_WIDTH);
assert_eq!(img.height(), 270);
let tall = image::load_from_memory(&make_thumbnail(&png_of(1146, 1024)).unwrap()).unwrap();
assert_eq!(tall.height(), THUMB_HEIGHT);
assert!(tall.width() < THUMB_WIDTH);
}
#[test]
fn thumbnail_never_enlarges_and_refuses_garbage() {
let small = image::load_from_memory(&make_thumbnail(&png_of(200, 100)).unwrap()).unwrap();
assert_eq!((small.width(), small.height()), (200, 100));
assert!(make_thumbnail(b"not an image").is_none());
assert!(make_thumbnail(&[]).is_none());
}
}