mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
fix: handle oversized photos Telegram rejects with pure Rust processing
This commit is contained in:
@@ -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/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/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/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` |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
Generated
+74
@@ -505,6 +505,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenv"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -599,12 +608,33 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1305,6 +1335,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jpeg-encoder"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.98"
|
||||||
@@ -1352,6 +1388,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1604,6 +1646,19 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3318,9 +3373,12 @@ name = "xmedia-bot"
|
|||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"fast_image_resize",
|
||||||
"html-escape",
|
"html-escape",
|
||||||
|
"jpeg-encoder",
|
||||||
"log",
|
"log",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"png",
|
||||||
"pretty_env_logger",
|
"pretty_env_logger",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -3332,6 +3390,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"x-media",
|
"x-media",
|
||||||
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3535,3 +3594,18 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
png = "0.18"
|
||||||
|
zune-jpeg = "0.5"
|
||||||
|
fast_image_resize = "6"
|
||||||
|
jpeg-encoder = "0.7"
|
||||||
x-media = { path = "../x-media" }
|
x-media = { path = "../x-media" }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use x_media::site;
|
|||||||
mod config;
|
mod config;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod link_cache;
|
mod link_cache;
|
||||||
|
mod photo;
|
||||||
mod queue;
|
mod queue;
|
||||||
mod send;
|
mod send;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
@@ -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<u8>),
|
||||||
|
GrayAlpha(Vec<u8>),
|
||||||
|
Rgb(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8> {
|
||||||
|
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<PhotoPrep, String> {
|
||||||
|
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<u8> {
|
||||||
|
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<PixBuf, String> {
|
||||||
|
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<u8>, 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<Vec<u8>, 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<u8> = 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<NamedTempFile, String> {
|
||||||
|
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<u8>) -> Result<PhotoPrep, String> {
|
||||||
|
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<u8>) -> Result<PhotoPrep, String> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> = (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<PhotoPrep, String> {
|
||||||
|
let (channels, data): (usize, Vec<u8>) = 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE};
|
use crate::handlers::{CHAT_STORE, LINK_CACHE, TASK_QUEUE};
|
||||||
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
|
use crate::link_cache::{CachedMedia, CachedMediaKind, CachedPost};
|
||||||
|
use crate::photo::{self, PhotoPrep, MAX_UPLOAD_BYTES};
|
||||||
use crate::queue::QueueError;
|
use crate::queue::QueueError;
|
||||||
use crate::state::{EditMessage, unix_now};
|
use crate::state::{EditMessage, unix_now};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@@ -210,9 +211,6 @@ pub async fn invalidate_cache(task: &Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const MAX_MEDIA_GROUP: usize = 9;
|
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.
|
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items.
|
||||||
pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
||||||
@@ -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
|
/// these errors are handled by the download-and-reupload fallback, NOT by a
|
||||||
/// queue retry (resending the URL cannot succeed).
|
/// queue retry (resending the URL cannot succeed).
|
||||||
pub fn is_media_fetch_failure(e: &ApiError) -> bool {
|
pub fn is_media_fetch_failure(e: &ApiError) -> bool {
|
||||||
const MARKERS: [&str; 5] = [
|
const MARKERS: [&str; 6] = [
|
||||||
"webpage_media_empty",
|
"webpage_media_empty",
|
||||||
"media_empty",
|
"media_empty",
|
||||||
"empty_web_media",
|
"empty_web_media",
|
||||||
"webpage_curl_failed",
|
"webpage_curl_failed",
|
||||||
"timeout",
|
"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();
|
let description = e.to_string().to_lowercase();
|
||||||
MARKERS.iter().any(|marker| description.contains(marker))
|
MARKERS.iter().any(|marker| description.contains(marker))
|
||||||
@@ -437,6 +438,13 @@ enum FallbackError {
|
|||||||
MediaTooLarge,
|
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
|
/// 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.
|
/// are retryable; size over the upload cap and other download errors are not.
|
||||||
async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, FallbackError> {
|
async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, FallbackError> {
|
||||||
@@ -458,7 +466,11 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if bytes.len() as u64 > 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);
|
return Err(FallbackError::MediaTooLarge);
|
||||||
}
|
}
|
||||||
let ext = sniff_ext(&bytes);
|
let ext = sniff_ext(&bytes);
|
||||||
@@ -531,11 +543,13 @@ async fn send_batch_via_upload(
|
|||||||
for (i, item) in batch.iter().enumerate() {
|
for (i, item) in batch.iter().enumerate() {
|
||||||
let item_caption = if i == 0 { caption } else { None };
|
let item_caption = if i == 0 { caption } else { None };
|
||||||
// Size check before downloading/uploading: over the cap, use the
|
// 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 {
|
let too_large = match x_media::site::media_size(item_url(item)).await {
|
||||||
Ok(Some(size)) => size > MAX_UPLOAD_BYTES,
|
Ok(Some(size)) => size > MAX_UPLOAD_BYTES,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
let too_large = too_large && !matches!(item, MediaItemPayload::Photo { .. });
|
||||||
let media = if too_large {
|
let media = if too_large {
|
||||||
match item.fallback_url() {
|
match item.fallback_url() {
|
||||||
Some(url) => match media_from_url(item, url, item_caption) {
|
Some(url) => match media_from_url(item, url, item_caption) {
|
||||||
@@ -553,10 +567,47 @@ async fn send_batch_via_upload(
|
|||||||
} else {
|
} else {
|
||||||
match download_to_temp(item).await {
|
match download_to_temp(item).await {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
|
// 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();
|
let path = file.path().to_path_buf();
|
||||||
files.push(file);
|
files.push(file);
|
||||||
media_from_file(item, path, item_caption)
|
media_from_file(item, path, item_caption)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(FallbackError::MediaTooLarge) => match item.fallback_url() {
|
Err(FallbackError::MediaTooLarge) => match item.fallback_url() {
|
||||||
Some(url) => match media_from_url(item, url, item_caption) {
|
Some(url) => match media_from_url(item, url, item_caption) {
|
||||||
Ok(media) => media,
|
Ok(media) => media,
|
||||||
@@ -1085,6 +1136,14 @@ pub async fn dead_letter_notify(payload: serde_json::Value, message: String) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn chunk_media_items_sizes() {
|
fn chunk_media_items_sizes() {
|
||||||
assert_eq!(chunk_media_items::<i32>(vec![]), Vec::<Vec<i32>>::new());
|
assert_eq!(chunk_media_items::<i32>(vec![]), Vec::<Vec<i32>>::new());
|
||||||
|
|||||||
Reference in New Issue
Block a user