Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ hex = "0.4.3"
hmac = "0.13.0"
image = { version = "0.25.5", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp", "rayon"] }
infer = "0.22.0"
jxl = "0.5.1"
lettre = { version = "0.11.12", features = ["serde"] }
md5 = "0.7.0"
mime = "0.3.17"
Expand Down
3 changes: 3 additions & 0 deletions server/src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub enum ApiError {
Image(#[from] image::ImageError),
JsonRejection(#[from] axum::extract::rejection::JsonRejection),
JsonSerialization(#[from] serde_json::Error),
JxlDecoding(#[from] jxl::error::Error),
#[error("Missing {0} content")]
MissingContent(ResourceType),
#[error("Failed to infer content type")]
Expand Down Expand Up @@ -141,6 +142,7 @@ impl ApiError {
| Self::InvalidTime(_)
| Self::InvalidUploadToken
| Self::InvalidUserRank
| Self::JxlDecoding(_)
| Self::NoEmail
| Self::NoNamesGiven(_)
| Self::NotAnInteger(_)
Expand Down Expand Up @@ -197,6 +199,7 @@ impl ApiError {
Self::Image(_) => "Image Error",
Self::JsonRejection(_) => "JSON Rejection",
Self::JsonSerialization(_) => "JSON Serialization Error",
Self::JxlDecoding(_) => "JPEG XL Decoding Error",
Self::MissingContent(_) => "Missing Content",
Self::MissingContentType => "Missing Content Type",
Self::MissingFormData => "Missing Form Data",
Expand Down
4 changes: 3 additions & 1 deletion server/src/content/decode.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::api::error::{ApiError, ApiResult};
use crate::config::Config;
use crate::content::{self, flash};
use crate::content::{self, flash, jxl};
use crate::model::enums::{MimeType, PostType};
use ffmpeg_sidecar::child::FfmpegChild;
use ffmpeg_sidecar::command::FfmpegCommand;
Expand Down Expand Up @@ -30,6 +30,7 @@ pub fn representative_image(config: &Config, file_path: &Path, mime_type: MimeTy
MimeType::Jpeg => image(config, file_path, ImageFormat::Jpeg),
MimeType::Png => image(config, file_path, ImageFormat::Png),
MimeType::Webp => image(config, file_path, ImageFormat::WebP),
MimeType::Jxl => jxl::image(file_path),
MimeType::Swf => flash_image(config, file_path).and_then(|frame| frame.ok_or(ApiError::EmptySwf)),
MimeType::Avif => ffmpeg_frame(config, file_path, PostType::Image)
.and_then(|frame| frame.ok_or(ApiError::FfmpegError("Unable to decode AVIF image with FFmpeg".into()))),
Expand Down Expand Up @@ -90,6 +91,7 @@ pub fn detect_post_type(config: &Config, file_path: &Path, mime_type: MimeType)
match mime_type {
MimeType::Avif => avif_is_animated(config, file_path).map(image_type),
MimeType::Gif => gif_is_animated(config, file_path).map(image_type),
MimeType::Jxl => jxl::is_animated(file_path).map(image_type),
MimeType::Webp => webp_is_animated(config, file_path).map(image_type),
MimeType::Bmp | MimeType::Jpeg | MimeType::Png => Ok(PostType::Image),
MimeType::Mp4 | MimeType::Mov | MimeType::Webm => Ok(PostType::Video),
Expand Down
97 changes: 97 additions & 0 deletions server/src/content/jxl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use crate::api::error::{ApiError, ApiResult};
use crate::content;
use image::{DynamicImage, GrayAlphaImage, GrayImage, RgbImage, RgbaImage};
use jxl::api::states::WithImageInfo;
use jxl::api::{
JxlBitstreamInput, JxlColorType, JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlPixelFormat,
ProcessingResult,
};
use jxl::error::Error;
use jxl::headers::extra_channels::ExtraChannel;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;

/// Decodes the first visible frame of the JPEG XL file at the given `file_path`.
pub fn image(file_path: &Path) -> ApiResult<DynamicImage> {
let file = content::map_read_result(File::open(file_path))?;
let mut input = BufReader::new(file);

let mut decoder = jxl_read_info(&mut input)?;
let info = decoder.basic_info();

let (width, height) = info.size;
let has_alpha = info
.extra_channels
.iter()
.any(|channel| channel.ec_type == ExtraChannel::Alpha);
let grayscale = decoder.current_pixel_format().color_type.is_grayscale();

let color_type = match (grayscale, has_alpha) {
(false, false) => JxlColorType::Rgb,
(false, true) => JxlColorType::Rgba,
(true, false) => JxlColorType::Grayscale,
(true, true) => JxlColorType::GrayscaleAlpha,
};
let samples_per_pixel = color_type.samples_per_pixel();
decoder.set_pixel_format(JxlPixelFormat {
color_type,
// u8 output keeps buffer rows free of alignment requirements
color_data_format: Some(JxlDataFormat::U8 { bit_depth: 8 }),
// None ignores non-color extra channels (depth, spot colors, ...)
extra_channel_format: vec![None; info.extra_channels.len()],
});

// Advance to the first frame
let decoder = match decoder.process(&mut input)? {
ProcessingResult::Complete { result } => result,
ProcessingResult::NeedsMoreInput { size_hint, .. } => {
return Err(ApiError::JxlDecoding(Error::OutOfBounds(size_hint)));
}
};

let bytes_per_row = width * samples_per_pixel;
let mut pixel_data = vec![0; bytes_per_row * height];
// One buffer for the interleaved color channels; ignored extra channels need none
let mut buffers = [JxlOutputBuffer::new(&mut pixel_data, height, bytes_per_row)];
// Decode the frame's pixels; for animations this stops after the first frame
match decoder.process(&mut input, &mut buffers)? {
ProcessingResult::Complete { .. } => {}
ProcessingResult::NeedsMoreInput { size_hint, .. } => {
return Err(ApiError::JxlDecoding(Error::OutOfBounds(size_hint)));
}
}

let buffer_len = pixel_data.len();
let (width, height) = (u32::try_from(width).expect(CAST_MESSAGE), u32::try_from(height).expect(CAST_MESSAGE));
match color_type {
JxlColorType::Rgb => RgbImage::from_raw(width, height, pixel_data).map(DynamicImage::ImageRgb8),
JxlColorType::Rgba => RgbaImage::from_raw(width, height, pixel_data).map(DynamicImage::ImageRgba8),
JxlColorType::Grayscale => GrayImage::from_raw(width, height, pixel_data).map(DynamicImage::ImageLuma8),
JxlColorType::GrayscaleAlpha => {
GrayAlphaImage::from_raw(width, height, pixel_data).map(DynamicImage::ImageLumaA8)
}
JxlColorType::Bgr | JxlColorType::Bgra => unreachable!("Unrequested JPEG XL color type"),
}
.ok_or(ApiError::FrameBufferMismatch(width, height, buffer_len))
}

/// Returns whether the JPEG XL file declares an animation, based on the image header.
pub fn is_animated(path: &Path) -> ApiResult<bool> {
let file = content::map_read_result(File::open(path))?;
let mut input = BufReader::new(file);
jxl_read_info(&mut input).map(|decoder| decoder.basic_info().animation.is_some())
}

const CAST_MESSAGE: &str = "JPEG XL level 10 caps dimensions at 2^30, so u32 cannot overflow";

/// Parses input up to the image header, returning a decoder ready to report
/// image info. Fails if the input ends before the header is complete.
fn jxl_read_info<In: JxlBitstreamInput>(input: &mut In) -> ApiResult<JxlDecoder<WithImageInfo>> {
let decoder = JxlDecoder::new(JxlDecoderOptions::default());
match decoder.process(input)? {
ProcessingResult::Complete { result } => Ok(result),
// The full file is available, so needing more input means it's truncated
ProcessingResult::NeedsMoreInput { size_hint, .. } => Err(ApiError::JxlDecoding(Error::OutOfBounds(size_hint))),
}
}
1 change: 1 addition & 0 deletions server/src/content/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod decode;
pub mod download;
mod flash;
pub mod hash;
mod jxl;
pub mod signature;
pub mod thumbnail;
pub mod upload;
Expand Down
Loading