refactor(photo): one plan decides within-limits and decode budget

decode_budget_bytes mirrored the within-limits early return and the
MAX_DECODE_BYTES guard that prepare_png and prepare_jpeg each spelled out
inline (three copies of the same arithmetic, which the reservation and the
branches had to keep in sync by hand). PhotoPlan/plan_photo now hold that
decision once and all three call it; the log order and the exact bounds are
unchanged. decode_budget_follows_the_processing_decision still pins the
reservation against the branches.
This commit is contained in:
2026-09-21 17:03:22 +08:00
parent 5166d97545
commit 3d377f68fb
+55 -30
View File
@@ -88,30 +88,56 @@ async fn reserve(
.expect("memory budget semaphore closed") .expect("memory budget semaphore closed")
} }
/// The decode buffer a downloaded photo will allocate, from its header alone — /// The processing decision for one downloaded photo, taken from its header
/// zero when it is already within Telegram's limits and is uploaded as-is, zero /// alone — the one place the within-limits test and the decode-size guard
/// for a format [`prepare_photo`] does not decode. Mirrors the early return and /// live, so the memory reservation and the branch that acts on it cannot
/// the guard of the two branches below. /// drift.
pub(crate) fn decode_budget_bytes(bytes: &[u8]) -> u64 { enum PhotoPlan {
if let Some((w, h, _depth, color)) = parse_png_header(bytes) { /// Already within Telegram's limits (dimension sum and upload cap): the
if within_limits(w, h, bytes) { /// downloaded file is uploaded untouched, no decode buffer.
return 0; AsIs,
} /// Needs processing: the decode buffer it will allocate, in bytes.
return decode_bytes(w, h, output_channels(color)); Decode(u64),
} /// Processing would need a decode buffer over [`MAX_DECODE_BYTES`]: the
if let Some((w, h)) = jpeg_dims(bytes) { /// caller falls back to the item's smaller URL.
if within_limits(w, h, bytes) { TooLarge,
return 0;
}
return decode_bytes(w, h, 3);
}
0
} }
/// Whether a photo is uploaded untouched (Telegram's dimension sum, and the /// [`PhotoPlan`] for a photo whose header said `w`×`h` in `channels` output
/// upload cap its bytes are compared against). /// channels, `len` bytes long.
fn within_limits(w: u32, h: u32, bytes: &[u8]) -> bool { fn plan_photo(w: u32, h: u32, len: usize, channels: usize) -> PhotoPlan {
w + h <= PHOTO_MAX_DIMENSION_SUM && bytes.len() as u64 <= MAX_UPLOAD_BYTES if w + h <= PHOTO_MAX_DIMENSION_SUM && len as u64 <= MAX_UPLOAD_BYTES {
return PhotoPlan::AsIs;
}
let bytes = decode_bytes(w, h, channels);
if bytes > MAX_DECODE_BYTES {
PhotoPlan::TooLarge
} else {
PhotoPlan::Decode(bytes)
}
}
/// [`PhotoPlan`] from the downloaded bytes: the PNG or JPEG header decides
/// (palette counted as RGB, which `EXPAND` produces); anything else is uploaded
/// as-is, since [`prepare_photo`] does not decode it.
fn photo_plan(bytes: &[u8]) -> PhotoPlan {
if let Some((w, h, _depth, color)) = parse_png_header(bytes) {
return plan_photo(w, h, bytes.len(), output_channels(color));
}
if let Some((w, h)) = jpeg_dims(bytes) {
return plan_photo(w, h, bytes.len(), 3);
}
PhotoPlan::AsIs
}
/// The decode buffer a downloaded photo will allocate, from its header alone —
/// zero when it is already within Telegram's limits and is uploaded as-is, zero
/// for a format [`prepare_photo`] does not decode.
pub(crate) fn decode_budget_bytes(bytes: &[u8]) -> u64 {
match photo_plan(bytes) {
PhotoPlan::Decode(bytes) => bytes,
PhotoPlan::AsIs | PhotoPlan::TooLarge => 0,
}
} }
/// JPEG dimensions from the headers, without decoding any pixels. /// JPEG dimensions from the headers, without decoding any pixels.
@@ -314,17 +340,16 @@ fn target_dims(w: u32, h: u32) -> (u32, u32) {
/// over the upload cap afterwards becomes JPEG. /// over the upload cap afterwards becomes JPEG.
fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> { fn prepare_png(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String> {
let (w, h, _bit_depth, color_type) = parse_png_header(bytes).ok_or("invalid PNG header")?; 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; let channels = output_channels(color_type);
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { let plan = plan_photo(w, h, bytes.len(), channels);
if let PhotoPlan::AsIs = plan {
return Ok(PhotoPrep::Upload(file)); return Ok(PhotoPrep::Upload(file));
} }
log::debug!( log::debug!(
"photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing", "photo {w}x{h} ({_bit_depth:?} {color_type:?}, {} bytes) needs processing",
bytes.len() bytes.len()
); );
if let PhotoPlan::TooLarge = plan {
let channels = output_channels(color_type);
if decode_bytes(w, h, channels) > MAX_DECODE_BYTES {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback); return Ok(PhotoPrep::UseFallback);
} }
@@ -391,11 +416,11 @@ fn prepare_jpeg(file: NamedTempFile, bytes: &[u8]) -> Result<PhotoPrep, String>
.map_err(|e| format!("jpeg headers: {e}"))?; .map_err(|e| format!("jpeg headers: {e}"))?;
let info = decoder.info().ok_or("jpeg info unavailable")?; let info = decoder.info().ok_or("jpeg info unavailable")?;
let (w, h) = (info.width as u32, info.height as u32); let (w, h) = (info.width as u32, info.height as u32);
let size_over = bytes.len() as u64 > MAX_UPLOAD_BYTES; let plan = plan_photo(w, h, bytes.len(), 3);
if w + h <= PHOTO_MAX_DIMENSION_SUM && !size_over { if let PhotoPlan::AsIs = plan {
return Ok(PhotoPrep::Upload(file)); return Ok(PhotoPrep::Upload(file));
} }
if decode_bytes(w, h, 3) > MAX_DECODE_BYTES { if let PhotoPlan::TooLarge = plan {
log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media"); log::warn!("photo decode buffer exceeds the memory budget; falling back to smaller media");
return Ok(PhotoPrep::UseFallback); return Ok(PhotoPrep::UseFallback);
} }