diff --git a/AGENTS.md b/AGENTS.md index c77f4f3..1497554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,8 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi |---|---| | `crates/xmedia-bot/src/main.rs` | Startup sequence, webhook vs polling, graceful shutdown (SIGINT via teloxide ctrlc / SIGTERM via `stop_token` for docker, → sweep stop → admin msg → queue stop) | | `crates/xmedia-bot/src/handlers.rs` | `CHAT_STORE`/`TASK_QUEUE`/`CONFIG` singletons (open `data/task_queue.db` **relative to CWD**); command dispatch; URL extraction; retry enqueue | -| `crates/xmedia-bot/src/send.rs` | Constants `MAX_MEDIA_GROUP = 9`, `MAX_UPLOAD_BYTES = 10 MiB`; fallback chain; `classify_request_error` | +| `crates/xmedia-bot/src/send.rs` | Constants `MAX_MEDIA_GROUP = 9`; fallback chain; `classify_request_error`; download-and-reupload fallback triggered only by Telegram API errors (`is_media_fetch_failure` / `is_size_error`) | +| `crates/xmedia-bot/src/photo.rs` | Pure-Rust photo processing (no ffmpeg): `png` (image-png) decode/encode + `zune-jpeg` decode + `fast_image_resize` Lanczos3 downscale + `jpeg-encoder`. Photos over Telegram's limits (width + height > 10000 px → `PHOTO_INVALID_DIMENSIONS`; bytes > 10 MiB) are decoded, downscaled keeping the format, PNG bit depth > 24 (RGBA 32-bit / 16-bit per channel) reduced to 24-bit RGB with alpha flattened white (≤24-bit untouched, never upconverted), and transcoded to JPEG only if still over the cap; memory budget guarded, otherwise the item's smaller fallback URL | | `crates/x-media/src/site/mod.rs` | Dispatcher, `Fetched`/`FetchError`, shared `CLIENT`, `download_media` (adds `Referer: https://www.pixiv.net/` for `pximg.net` hotlink protection) | | `crates/x-media/src/site/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` | | `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime hack, static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint | diff --git a/Cargo.lock b/Cargo.lock index 1c2d408..4c120bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -599,12 +608,33 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fast_image_resize" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c50201dc184ba6553da1695aac20a042efffbe2d84542cee31917c86c3ab1e" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1305,6 +1335,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + [[package]] name = "js-sys" version = "0.3.98" @@ -1352,6 +1388,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1604,6 +1646,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3318,9 +3373,12 @@ name = "xmedia-bot" version = "1.0.6" dependencies = [ "dotenv", + "fast_image_resize", "html-escape", + "jpeg-encoder", "log", "parking_lot", + "png", "pretty_env_logger", "rand 0.8.6", "regex", @@ -3332,6 +3390,7 @@ dependencies = [ "tokio", "url", "x-media", + "zune-jpeg", ] [[package]] @@ -3535,3 +3594,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/xmedia-bot/Cargo.toml b/crates/xmedia-bot/Cargo.toml index 0f4e0fa..1757fee 100644 --- a/crates/xmedia-bot/Cargo.toml +++ b/crates/xmedia-bot/Cargo.toml @@ -18,4 +18,8 @@ rusqlite = { version = "0.32", features = ["bundled"] } rand = "0.8" tempfile = "3" parking_lot = "0.12" +png = "0.18" +zune-jpeg = "0.5" +fast_image_resize = "6" +jpeg-encoder = "0.7" x-media = { path = "../x-media" } diff --git a/crates/xmedia-bot/src/main.rs b/crates/xmedia-bot/src/main.rs index 6857e64..3e52d4f 100644 --- a/crates/xmedia-bot/src/main.rs +++ b/crates/xmedia-bot/src/main.rs @@ -10,6 +10,7 @@ use x_media::site; mod config; mod handlers; mod link_cache; +mod photo; mod queue; mod send; mod state; diff --git a/crates/xmedia-bot/src/photo.rs b/crates/xmedia-bot/src/photo.rs new file mode 100644 index 0000000..6120ea8 --- /dev/null +++ b/crates/xmedia-bot/src/photo.rs @@ -0,0 +1,515 @@ +//! Pure-Rust photo processing: brings a downloaded photo within Telegram's +//! limits (width + height ≤ 10000 px, bytes ≤ 10 MiB) without ffmpeg. +//! +//! Stack: `png` (image-png) for PNG decode/encode, `zune-jpeg` for JPEG +//! decode, `fast_image_resize` (Lanczos3) for downsampling, `jpeg-encoder` +//! for JPEG output. +//! +//! Bit-depth rule: a PNG above 24 bits (32-bit RGBA or 16-bit per channel) +//! is reduced to 24-bit RGB; 24-bit and lower depths are left untouched — +//! gray stays gray, never upconverted. The only upconversion is palette +//! expansion, which resampling requires. Alpha is flattened onto white (JPEG +//! and 24-bit RGB have no alpha channel). + +use std::io::Write; + +use fast_image_resize as fir; +use tempfile::NamedTempFile; + +/// Telegram rejects photos whose width + height exceed this limit +/// (PHOTO_INVALID_DIMENSIONS). Verified empirically: 6300x3730 (sum 10030) +/// fails, 6100x3900 (sum 10000) passes. +pub const PHOTO_MAX_DIMENSION_SUM: u32 = 10000; +/// Resize target with a safety margin so rounding cannot cross the cap. +pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900; +/// Upload cap (bytes): files above this are not uploaded; the bot falls back +/// to a smaller media URL instead. +pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; +/// Decode budget (bytes): a larger intermediate buffer is not worth the peak +/// memory; the photo degrades to the smaller URL instead. +const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024; +/// JPEG output quality (1-100). +const JPEG_QUALITY: u8 = 90; + +/// What to upload for a downloaded photo. +pub enum PhotoPrep { + /// Upload this file (the original when within limits, else the processed + /// copy). + Upload(NamedTempFile), + /// The photo cannot be brought within Telegram's limits — the caller + /// falls back to the item's smaller URL. + UseFallback, +} + +/// A decoded image buffer tagged with its channel layout. +#[derive(Debug)] +enum PixBuf { + Gray(Vec), + GrayAlpha(Vec), + Rgb(Vec), +} + +impl PixBuf { + fn pixel_type(&self) -> fir::PixelType { + match self { + PixBuf::Gray(_) => fir::PixelType::U8, + PixBuf::GrayAlpha(_) => fir::PixelType::U8x2, + PixBuf::Rgb(_) => fir::PixelType::U8x3, + } + } + + fn into_vec(self) -> Vec { + match self { + PixBuf::Gray(v) | PixBuf::GrayAlpha(v) | PixBuf::Rgb(v) => v, + } + } +} + +/// Entry point: detects the format and processes the photo if needed. +pub fn prepare_photo(file: NamedTempFile) -> Result { + let bytes = std::fs::read(file.path()).map_err(|e| format!("prepare read failed: {e}"))?; + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + prepare_png(file, bytes) + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + prepare_jpeg(file, bytes) + } else { + log::warn!("photo in unsupported format; falling back to smaller media"); + Ok(PhotoPrep::UseFallback) + } +} + +/// Parses the PNG IHDR (bytes 8..26: signature + length + "IHDR" + width + +/// height + bit depth + color type). +fn parse_png_header(bytes: &[u8]) -> Option<(u32, u32, png::BitDepth, png::ColorType)> { + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 26 { + return None; + } + let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?); + let depth = match *bytes.get(24)? { + 1 => png::BitDepth::One, + 2 => png::BitDepth::Two, + 4 => png::BitDepth::Four, + 8 => png::BitDepth::Eight, + 16 => png::BitDepth::Sixteen, + _ => return None, + }; + let color = match *bytes.get(25)? { + 0 => png::ColorType::Grayscale, + 2 => png::ColorType::Rgb, + 3 => png::ColorType::Indexed, + 4 => png::ColorType::GrayscaleAlpha, + 6 => png::ColorType::Rgba, + _ => return None, + }; + Some((w, h, depth, color)) +} + +/// Output channels of a decoded frame for the given color type (post +/// STRIP_16; palette expands to RGB). +fn output_channels(color: png::ColorType) -> usize { + match color { + png::ColorType::Grayscale => 1, + png::ColorType::GrayscaleAlpha => 2, + png::ColorType::Rgb | png::ColorType::Indexed => 3, + png::ColorType::Rgba => 4, + } +} + +/// The 32→24 rule: RGBA (32-bit) becomes RGB with alpha composited onto +/// white; 16-bit per channel was already stripped to 8-bit at decode. +fn flatten_rgba_to_rgb(rgba: &[u8]) -> Vec { + let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3); + for px in rgba.chunks_exact(4) { + let a = px[3] as u32; + for v in &px[..3] { + // Over white: C = C*a/255 + 255*(1 - a/255). + let v = (*v as u32 * a + 255 * (255 - a)) / 255; + rgb.push(v.min(255) as u8); + } + } + rgb +} + +/// Lanczos3 downsampling via fast_image_resize. +fn resize_pix(pix: PixBuf, w: u32, h: u32, nw: u32, nh: u32) -> Result { + let pixel_type = pix.pixel_type(); + let src = fir::images::Image::from_vec_u8(w, h, pix.into_vec(), pixel_type) + .map_err(|e| format!("resize input: {e}"))?; + let mut dst = fir::images::Image::new(nw, nh, pixel_type); + let mut resizer = fir::Resizer::new(); + let options = fir::ResizeOptions::default() + .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3)); + resizer + .resize(&src, &mut dst, &options) + .map_err(|e| format!("resize: {e}"))?; + let buf = dst.into_vec(); + Ok(match pixel_type { + fir::PixelType::U8 => PixBuf::Gray(buf), + fir::PixelType::U8x2 => PixBuf::GrayAlpha(buf), + _ => PixBuf::Rgb(buf), + }) +} + +fn encode_png(out: &mut Vec, pix: &PixBuf, w: u32, h: u32) -> Result<(), png::EncodingError> { + let (color, buf) = match pix { + PixBuf::Gray(v) => (png::ColorType::Grayscale, v.as_slice()), + PixBuf::GrayAlpha(v) => (png::ColorType::GrayscaleAlpha, v.as_slice()), + PixBuf::Rgb(v) => (png::ColorType::Rgb, v.as_slice()), + }; + let mut encoder = png::Encoder::new(out, w, h); + encoder.set_color(color); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(buf)?; + Ok(()) +} + +fn encode_jpeg(pix: &PixBuf, w: u32, h: u32) -> Result, String> { + use jpeg_encoder::{ColorType, Encoder}; + let mut out = Vec::new(); + let encoder = Encoder::new(&mut out, JPEG_QUALITY); + match pix { + PixBuf::Gray(v) => encoder + .encode(v, w as u16, h as u16, ColorType::Luma) + .map_err(|e| format!("jpeg encode: {e}"))?, + PixBuf::GrayAlpha(v) => { + // JPEG has no alpha: composite onto white, output as gray. + let gray: Vec = v + .chunks_exact(2) + .map(|px| { + let (g, a) = (px[0] as u32, px[1] as u32); + ((g * a + 255 * (255 - a)) / 255).min(255) as u8 + }) + .collect(); + encoder + .encode(&gray, w as u16, h as u16, ColorType::Luma) + .map_err(|e| format!("jpeg encode: {e}"))?; + } + PixBuf::Rgb(v) => encoder + .encode(v, w as u16, h as u16, ColorType::Rgb) + .map_err(|e| format!("jpeg encode: {e}"))?, + } + Ok(out) +} + +fn write_temp(bytes: &[u8], ext: &str) -> Result { + let mut file = tempfile::Builder::new() + .suffix(&format!(".{ext}")) + .tempfile() + .map_err(|e| format!("temp file failed: {e}"))?; + file.as_file_mut() + .write_all(bytes) + .map_err(|e| format!("temp file write failed: {e}"))?; + Ok(file) +} + +fn target_dims(w: u32, h: u32) -> (u32, u32) { + let scale = PHOTO_TARGET_DIMENSION_SUM as f64 / (w + h) as f64; + ( + ((w as f64 * scale).round() as u32).max(1), + ((h as f64 * scale).round() as u32).max(1), + ) +} + +/// PNG branch: decode (16→8, palette→RGB; gray/GA stay), flatten RGBA to +/// RGB, Lanczos-downscale beyond the dimension cap, encode PNG — a PNG still +/// over the upload cap afterwards becomes JPEG. +fn prepare_png(file: NamedTempFile, bytes: Vec) -> Result { + let (w, h, _bit_depth, color_type) = + parse_png_header(&bytes).ok_or("invalid PNG header")?; + let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; + if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { + return Ok(PhotoPrep::Upload(file)); + } + log::info!("photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing", bytes.len()); + + let channels = output_channels(color_type); + if (w as u64) * (h as u64) * channels as u64 > MAX_DECODE_BYTES { + log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); + return Ok(PhotoPrep::UseFallback); + } + + // STRIP_16 drops 16-bit to 8-bit (the depth-reduction step); palette + // expands to RGB (resampling requires it). Gray and gray-alpha are kept. + let transforms = match color_type { + png::ColorType::Indexed => png::Transformations::EXPAND, + _ => png::Transformations::STRIP_16, + }; + let mut decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); + decoder.set_transformations(transforms); + let mut reader = decoder.read_info().map_err(|e| format!("png decode: {e}"))?; + let out_w = reader.info().width; + let out_h = reader.info().height; + let mut buf = vec![ + 0u8; + reader + .output_buffer_size() + .ok_or("png output buffer size")? + ]; + reader + .next_frame(&mut buf) + .map_err(|e| format!("png frame: {e}"))?; + + let mut pix = match color_type { + png::ColorType::Rgba => PixBuf::Rgb(flatten_rgba_to_rgb(&buf)), + png::ColorType::Grayscale => PixBuf::Gray(buf), + png::ColorType::GrayscaleAlpha => PixBuf::GrayAlpha(buf), + png::ColorType::Rgb | png::ColorType::Indexed => PixBuf::Rgb(buf), + }; + + let (mut w, mut h) = (out_w, out_h); + if w + h > PHOTO_MAX_DIMENSION_SUM { + let (nw, nh) = target_dims(w, h); + pix = resize_pix(pix, w, h, nw, nh)?; + (w, h) = (nw, nh); + log::info!("downscaled photo to {w}x{h} (Lanczos3)"); + } + + let mut png_bytes = Vec::new(); + encode_png(&mut png_bytes, &pix, w, h).map_err(|e| format!("png encode: {e}"))?; + if png_bytes.len() as u64 <= MAX_UPLOAD_BYTES { + return Ok(PhotoPrep::Upload(write_temp(&png_bytes, "png")?)); + } + log::info!("PNG still over the upload cap after processing; transcoding to JPEG"); + let jpeg_bytes = encode_jpeg(&pix, w, h)?; + if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES { + return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?)); + } + log::warn!("processed photo still exceeds the upload cap; falling back to smaller media"); + Ok(PhotoPrep::UseFallback) +} + +/// JPEG branch: zune-jpeg decode → Lanczos downscale → jpeg-encoder output. +fn prepare_jpeg(file: NamedTempFile, bytes: Vec) -> Result { + let mut decoder = zune_jpeg::JpegDecoder::new(std::io::Cursor::new(&bytes)); + // Decodes to RGB by default. Headers first so dimensions are known before + // the (potentially huge) pixel decode. + decoder + .decode_headers() + .map_err(|e| format!("jpeg headers: {e}"))?; + let info = decoder.info().ok_or("jpeg info unavailable")?; + let (w, h) = (info.width as u32, info.height as u32); + let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; + if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { + return Ok(PhotoPrep::Upload(file)); + } + if (w as u64) * (h as u64) * 3 > MAX_DECODE_BYTES { + log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); + return Ok(PhotoPrep::UseFallback); + } + let pixels = decoder.decode().map_err(|e| format!("jpeg decode: {e}"))?; + let mut pix = PixBuf::Rgb(pixels); + let (mut w, mut h) = (w, h); + if w + h > PHOTO_MAX_DIMENSION_SUM { + let (nw, nh) = target_dims(w, h); + pix = resize_pix(pix, w, h, nw, nh)?; + (w, h) = (nw, nh); + log::info!("downscaled jpeg to {w}x{h} (Lanczos3)"); + } + let jpeg_bytes = encode_jpeg(&pix, w, h)?; + if jpeg_bytes.len() as u64 <= MAX_UPLOAD_BYTES { + return Ok(PhotoPrep::Upload(write_temp(&jpeg_bytes, "jpg")?)); + } + log::warn!("processed photo still exceeds the upload cap; falling back to smaller media"); + Ok(PhotoPrep::UseFallback) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_header(w: u32, h: u32, depth: u8, color: u8) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec(); + bytes.extend(w.to_be_bytes()); + bytes.extend(h.to_be_bytes()); + bytes.extend([depth, color, 0, 0, 0]); + bytes + } + + #[test] + fn parses_png_header() { + let bytes = png_header(8979, 5316, 16, 6); // 16-bit RGBA + let (w, h, depth, color) = parse_png_header(&bytes).unwrap(); + assert_eq!((w, h), (8979, 5316)); + assert_eq!(depth, png::BitDepth::Sixteen); + assert_eq!(color, png::ColorType::Rgba); + + let (_, _, depth, color) = parse_png_header(&png_header(10, 10, 8, 0)).unwrap(); + assert_eq!(depth, png::BitDepth::Eight); + assert_eq!(color, png::ColorType::Grayscale); + + assert!(parse_png_header(b"not a png").is_none()); + } + + #[test] + fn flatten_rgba_to_rgb_composites_over_white() { + // opaque red stays red + assert_eq!(flatten_rgba_to_rgb(&[255, 0, 0, 255]), vec![255, 0, 0]); + // fully transparent → white + assert_eq!(flatten_rgba_to_rgb(&[0, 0, 0, 0]), vec![255, 255, 255]); + // half alpha red → (255+255)/2 = 255, (0*128 + 255*127)/255 = 127 + let out = flatten_rgba_to_rgb(&[255, 0, 0, 128]); + assert_eq!(out[0], 255); + assert_eq!(out[1], 127); + assert_eq!(out[2], 127); + } + + #[test] + fn target_dims_stay_under_the_cap() { + for (w, h) in [(12000u32, 7000u32), (10000, 10000), (8979, 5316)] { + let (nw, nh) = target_dims(w, h); + assert!(nw + nh <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h} -> {nw}x{nh}"); + assert!(nw >= 1 && nh >= 1); + } + // already within limits: no change expected from the caller, but the + // helper must not produce zero dimensions. + let (nw, nh) = target_dims(500, 400); + assert!(nw >= 1 && nh >= 1); + } + + #[test] + fn resize_pix_changes_dimensions() { + // 300x200 RGB → 100x66 + let buf: Vec = (0..300 * 200 * 3).map(|i| (i % 251) as u8).collect(); + let resized = resize_pix(PixBuf::Rgb(buf), 300, 200, 100, 66).unwrap(); + match resized { + PixBuf::Rgb(v) => assert_eq!(v.len(), 100 * 66 * 3), + other => panic!("expected rgb, got {other:?}"), + } + } + + #[test] + fn png_encode_roundtrip_keeps_gray() { + let gray = vec![128u8; 4 * 4]; + let mut out = Vec::new(); + encode_png(&mut out, &PixBuf::Gray(gray), 4, 4).unwrap(); + assert!(!out.is_empty()); + let (_, _, depth, color) = parse_png_header(&out).unwrap(); + assert_eq!(depth, png::BitDepth::Eight); + assert_eq!(color, png::ColorType::Grayscale); + } + + #[test] + fn jpeg_encode_produces_bytes() { + let rgb = vec![128u8; 8 * 8 * 3]; + let out = encode_jpeg(&PixBuf::Rgb(rgb), 8, 8).unwrap(); + assert!(out.len() > 100); + assert!(out.starts_with(&[0xFF, 0xD8])); + } + + /// Writes a small dimension-oversized PNG (9999x2 → sum 10001) to a temp + /// file and runs the full pipeline. + fn run_pipeline(w: u32, h: u32, color: png::ColorType, fill: u8) -> Result { + let (channels, data): (usize, Vec) = match color { + png::ColorType::Grayscale => (1, vec![fill; (w * h) as usize]), + png::ColorType::Rgb => (3, vec![fill; (w * h * 3) as usize]), + _ => unreachable!(), + }; + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, w, h); + encoder.set_color(color); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&data).unwrap(); + } + assert_eq!(data.len(), channels * (w * h) as usize); + + let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap(); + prepare_photo(file) + } + + #[test] + fn pipeline_downscales_oversized_png_keeping_format() { + let prep = run_pipeline(9999, 2, png::ColorType::Rgb, 128).unwrap(); + match prep { + PhotoPrep::Upload(file) => { + let out = std::fs::read(file.path()).unwrap(); + let (w, h, depth, color) = parse_png_header(&out).unwrap(); + assert!(w + h <= PHOTO_MAX_DIMENSION_SUM, "{w}x{h}"); + assert_eq!(depth, png::BitDepth::Eight); + assert_eq!(color, png::ColorType::Rgb); + } + PhotoPrep::UseFallback => panic!("over-dimension PNG should have been resized"), + } + } + + #[test] + fn pipeline_keeps_gray_png_gray() { + let prep = run_pipeline(9999, 2, png::ColorType::Grayscale, 200).unwrap(); + match prep { + PhotoPrep::Upload(file) => { + let out = std::fs::read(file.path()).unwrap(); + let (_, _, _, color) = parse_png_header(&out).unwrap(); + assert_eq!(color, png::ColorType::Grayscale, "gray must not upconvert"); + } + PhotoPrep::UseFallback => panic!("over-dimension gray PNG should have been resized"), + } + } + + #[test] + fn pipeline_resizes_oversized_jpeg() { + // Build a small over-dimension JPEG with jpeg-encoder. + let (w, h) = (9999u16, 2u16); + let rgb = vec![90u8; (w as usize) * (h as usize) * 3]; + let mut bytes = Vec::new(); + { + let encoder = jpeg_encoder::Encoder::new(&mut bytes, 90); + encoder.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap(); + } + let mut file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap(); + std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap(); + match prepare_photo(file).unwrap() { + PhotoPrep::Upload(file) => { + let out = std::fs::read(file.path()).unwrap(); + assert!(out.starts_with(&[0xFF, 0xD8]), "output must stay jpeg"); + // 9999x2 downscaled: the buffer length tells the new dims. + assert!(out.len() > 100); + } + PhotoPrep::UseFallback => panic!("over-dimension JPEG should have been resized"), + } + } + + #[test] + #[ignore = "heavy: generates a >10 MiB PNG (run explicitly)"] + fn pipeline_transcodes_oversized_png_to_jpeg() { + // 6000x4000 (sum 10000 — under the dimension cap) smooth gradient with + // small per-pixel noise: PNG-incompressible (delta filters defeated) + // but JPEG-friendly (DCT smooths the small noise). Verified with + // ffmpeg: 8000x6000 amp-5 variant is a 59 MB PNG / 3.3 MB JPEG. + let (w, h) = (6000u32, 4000u32); + let mut rng = 0x1234_5678_9abc_def0u64; + let mut data = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + let base = (x + y) * 255 / (w + h); + rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let n = ((rng >> 33) % 11) as i32 - 5; // noise in [-5, 5] + let v = (base as i32 + n).clamp(0, 255) as u8; + data.extend_from_slice(&[v, v, v]); + } + } + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, w, h); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&data).unwrap(); + } + assert!(bytes.len() as u64 > MAX_UPLOAD_BYTES, "test needs a >10MiB PNG, got {}", bytes.len()); + + let mut file = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + std::io::Write::write_all(file.as_file_mut(), &bytes).unwrap(); + match prepare_photo(file).unwrap() { + PhotoPrep::Upload(file) => { + let out = std::fs::read(file.path()).unwrap(); + assert!(out.starts_with(&[0xFF, 0xD8]), "must transcode to JPEG"); + assert!(out.len() as u64 <= MAX_UPLOAD_BYTES); + } + PhotoPrep::UseFallback => panic!("PNG over the byte cap must transcode to JPEG"), + } + } +} diff --git a/crates/xmedia-bot/src/send.rs b/crates/xmedia-bot/src/send.rs index fd103d2..23aaa62 100644 --- a/crates/xmedia-bot/src/send.rs +++ b/crates/xmedia-bot/src/send.rs @@ -5,6 +5,7 @@ use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE}; use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost}; +use crate::photo::{self, PhotoPrep, MAX_UPLOAD_BYTES}; use crate::queue::QueueError; use crate::state::{EditMessage, unix_now}; use rand::Rng; @@ -210,9 +211,6 @@ pub async fn invalidate_cache(task: &Task) { } pub const MAX_MEDIA_GROUP: usize = 9; -/// Upload cap (bytes): files above this are not uploaded; the bot falls back -/// to a smaller media URL instead. -pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; // 10485760 /// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items. pub fn chunk_media_items(items: Vec) -> Vec> { @@ -229,12 +227,15 @@ pub fn retry_delay_seconds(attempts: u32) -> f64 { /// these errors are handled by the download-and-reupload fallback, NOT by a /// queue retry (resending the URL cannot succeed). pub fn is_media_fetch_failure(e: &ApiError) -> bool { - const MARKERS: [&str; 5] = [ + const MARKERS: [&str; 6] = [ "webpage_media_empty", "media_empty", "empty_web_media", "webpage_curl_failed", "timeout", + // Oversized photos (width + height > 10000 px) are rejected on URL + // sends too; route them to the download-and-resize fallback. + "PHOTO_INVALID_DIMENSIONS", ]; let description = e.to_string().to_lowercase(); MARKERS.iter().any(|marker| description.contains(marker)) @@ -437,6 +438,13 @@ enum FallbackError { MediaTooLarge, } +/// Brings a downloaded photo within Telegram's limits via the pure-Rust +/// chain in [`crate::photo`] (no ffmpeg): dimension cap / upload cap +/// exceeded photos are decoded, downscaled with Lanczos3, PNG bit depth +/// reduced (>24-bit → 24-bit RGB, ≤24-bit untouched) and transcoded to JPEG +/// only if still too big. Anything that cannot be fixed falls back to the +/// item's smaller URL. +/// /// Downloads one media item to a temp file (deleted on drop). Network errors /// are retryable; size over the upload cap and other download errors are not. async fn download_to_temp(item: &MediaItemPayload) -> Result { @@ -458,7 +466,11 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result MAX_UPLOAD_BYTES { + // Photos are downloaded even over the cap so `prepare_photo` can + // downscale / transcode them; only videos/animations short-circuit. + if !matches!(item, MediaItemPayload::Photo { .. }) + && bytes.len() as u64 > MAX_UPLOAD_BYTES + { return Err(FallbackError::MediaTooLarge); } let ext = sniff_ext(&bytes); @@ -531,11 +543,13 @@ async fn send_batch_via_upload( for (i, item) in batch.iter().enumerate() { let item_caption = if i == 0 { caption } else { None }; // Size check before downloading/uploading: over the cap, use the - // smaller URL instead of the file. + // smaller URL instead of the file. Photos are exempt — they are + // downloaded and processed (downscale / PNG→JPEG) before uploading. let too_large = match x_media::site::media_size(item_url(item)).await { Ok(Some(size)) => size > MAX_UPLOAD_BYTES, _ => false, }; + let too_large = too_large && !matches!(item, MediaItemPayload::Photo { .. }); let media = if too_large { match item.fallback_url() { Some(url) => match media_from_url(item, url, item_caption) { @@ -553,9 +567,46 @@ async fn send_batch_via_upload( } else { match download_to_temp(item).await { Ok(file) => { - let path = file.path().to_path_buf(); - files.push(file); - media_from_file(item, path, item_caption) + // Telegram rejects photos wider+taller than 10000 px + // combined (PHOTO_INVALID_DIMENSIONS): downscale the + // downloaded file before uploading; photos that cannot be + // brought within the limits degrade to the smaller URL. + if matches!(item, MediaItemPayload::Photo { .. }) { + // CPU-heavy (decode/resize/encode): run off the async + // executor thread. + let prep = tokio::task::spawn_blocking(move || photo::prepare_photo(file)) + .await + .map_err(|e| FallbackError::Permanent { + message: format!("photo worker panicked: {e}"), + })? + .map_err(|message| FallbackError::Permanent { message })?; + match prep { + PhotoPrep::Upload(upload) => { + let path = upload.path().to_path_buf(); + files.push(upload); + media_from_file(item, path, item_caption) + } + PhotoPrep::UseFallback => match item.fallback_url() { + Some(url) => match media_from_url(item, url, item_caption) { + Ok(media) => media, + Err(message) => { + return Err(FallbackError::Permanent { message }); + } + }, + None => { + return Err(FallbackError::Permanent { + message: + "photo dimensions exceed Telegram limits and no smaller variant is available" + .into(), + }); + } + }, + } + } else { + let path = file.path().to_path_buf(); + files.push(file); + media_from_file(item, path, item_caption) + } } Err(FallbackError::MediaTooLarge) => match item.fallback_url() { Some(url) => match media_from_url(item, url, item_caption) { @@ -1085,6 +1136,14 @@ pub async fn dead_letter_notify(payload: serde_json::Value, message: String) { mod tests { use super::*; + #[test] + fn oversized_photo_boundary() { + // The empirical Telegram limit: sum 10000 passes, 10001 fails. + assert!(crate::photo::PHOTO_MAX_DIMENSION_SUM == 10000); + assert!(6100 + 3900 <= crate::photo::PHOTO_MAX_DIMENSION_SUM); + assert!(6300 + 3730 > crate::photo::PHOTO_MAX_DIMENSION_SUM); + } + #[test] fn chunk_media_items_sizes() { assert_eq!(chunk_media_items::(vec![]), Vec::>::new());