mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
306 lines
9.3 KiB
Rust
306 lines
9.3 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use super::MediaError;
|
|
use super::av_metadata::{AVMetadata, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata};
|
|
use super::image_probe::probe_image_dims;
|
|
use super::loaded_image::{validate_dimensions_u32, validate_jxl_decode_pixels};
|
|
use super::nsfw_processing::{
|
|
NSFWScanPreparation, NSFWScanSource, classify_nsfw_buffers, nsfw_scan_buffers,
|
|
};
|
|
use super::placeholder::{encode_thumbhash, encoded_placeholder, optional_thumbhash};
|
|
use crate::{
|
|
constants,
|
|
media_limits::MediaLimits,
|
|
metrics::transform::TransformMetrics,
|
|
mime,
|
|
nsfw::{NSFWClassification, NSFWClient, NSFWPolicy},
|
|
};
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct MetadataOptions {
|
|
pub placeholder: bool,
|
|
pub nsfw: NSFWPolicy,
|
|
pub deadline_ms: Option<i64>,
|
|
}
|
|
|
|
impl Default for MetadataOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
placeholder: true,
|
|
nsfw: NSFWPolicy::Disabled,
|
|
deadline_ms: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct MetadataResponse {
|
|
content_type: String,
|
|
size: usize,
|
|
content_hash: String,
|
|
format: String,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
animated: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
duration: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
placeholder: Option<String>,
|
|
nsfw: bool,
|
|
nsfw_probability: f32,
|
|
}
|
|
|
|
pub struct PreparedMetadata {
|
|
response: MetadataResponse,
|
|
nsfw_scan: Option<NSFWScanPreparation>,
|
|
}
|
|
|
|
impl PreparedMetadata {
|
|
fn take_nsfw_scan(&mut self) -> Option<NSFWScanPreparation> {
|
|
self.nsfw_scan.take()
|
|
}
|
|
}
|
|
|
|
struct MetadataBlockingRequest<'a> {
|
|
media_limits: &'a MediaLimits,
|
|
metrics: &'a TransformMetrics,
|
|
input: &'a [u8],
|
|
options: &'a MetadataOptions,
|
|
}
|
|
|
|
fn metadata_content_type(sniffed_mime: &'static str, av_probe: Option<&AVProbe>) -> &'static str {
|
|
if sniffed_mime == "video/mp4"
|
|
&& av_probe.is_some_and(|probe| probe.has_audio && !probe.has_video)
|
|
{
|
|
return "audio/mp4";
|
|
}
|
|
sniffed_mime
|
|
}
|
|
|
|
fn metadata_format(sniffed_mime: &str) -> String {
|
|
match sniffed_mime {
|
|
"audio/mpeg" => "mp3".to_owned(),
|
|
"video/quicktime" => "mov".to_owned(),
|
|
"video/x-matroska" => "mkv".to_owned(),
|
|
"image/svg+xml" => "svg".to_owned(),
|
|
"audio/mp4" => "m4a".to_owned(),
|
|
"image/avif-sequence" => "avif".to_owned(),
|
|
"image/apng" => "apng".to_owned(),
|
|
other => other
|
|
.rsplit_once('/')
|
|
.map(|(_, suffix)| suffix.strip_prefix("x-").unwrap_or(suffix))
|
|
.unwrap_or("bin")
|
|
.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn validate_metadata_image_dimensions(
|
|
media_limits: &MediaLimits,
|
|
width: u32,
|
|
height: u32,
|
|
frames: u32,
|
|
) -> Result<(), MediaError> {
|
|
validate_dimensions_u32(media_limits, width, height)?;
|
|
let frame_count = frames.max(1);
|
|
if frame_count > media_limits.animated_frames() {
|
|
return Err(MediaError::InvalidImageDimensions);
|
|
}
|
|
if frame_count > 1 {
|
|
let w = width as usize;
|
|
let h = height as usize;
|
|
let per_frame = w.saturating_mul(h);
|
|
let max_total = media_limits.animated_total_pixels();
|
|
let fc = frame_count as usize;
|
|
if per_frame > max_total / fc.max(1) {
|
|
return Err(MediaError::InvalidImageDimensions);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn probe_av_metadata_without_requiring_a_frame(
|
|
input: &[u8],
|
|
media_limits: &MediaLimits,
|
|
) -> Result<AVMetadata, MediaError> {
|
|
match probe_av_metadata(input, NSFW_PREVIEW_MAX_DIMENSION, media_limits, None) {
|
|
Ok(metadata) => Ok(metadata),
|
|
Err(error) => probe_av_metadata(input, 0, media_limits, None).map_err(|_| error),
|
|
}
|
|
}
|
|
|
|
fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result<PreparedMetadata, MediaError> {
|
|
let MetadataBlockingRequest {
|
|
media_limits,
|
|
metrics,
|
|
input,
|
|
options,
|
|
} = request;
|
|
let sniffed = mime::sniff(input);
|
|
if !mime::is_supported_media_mime(sniffed.mime) {
|
|
return Err(MediaError::UnsupportedMediaType);
|
|
}
|
|
let initial_category = mime::category(sniffed.mime).ok_or(MediaError::UnsupportedMediaType)?;
|
|
let is_image = initial_category == mime::Category::Image;
|
|
if is_image && input.len() > constants::MAX_MEDIA_PROXY_BYTES {
|
|
return Err(MediaError::StreamTooLong);
|
|
}
|
|
let dims = if is_image {
|
|
let dims = probe_image_dims(media_limits, input)?;
|
|
validate_jxl_decode_pixels(sniffed.mime, dims)?;
|
|
Some(dims)
|
|
} else {
|
|
None
|
|
};
|
|
let frames_count = dims.map(|d| d.pages).unwrap_or(sniffed.frames);
|
|
let mut width = dims.map(|d| d.width).unwrap_or(sniffed.width);
|
|
let mut height = dims.map(|d| d.height).unwrap_or(sniffed.height);
|
|
if is_image && (width > 0 || height > 0 || frames_count > 1) {
|
|
validate_metadata_image_dimensions(media_limits, width, height, frames_count)?;
|
|
}
|
|
|
|
let av_metadata = if matches!(
|
|
initial_category,
|
|
mime::Category::Video | mime::Category::Audio
|
|
) {
|
|
Some(probe_av_metadata_without_requiring_a_frame(
|
|
input,
|
|
media_limits,
|
|
)?)
|
|
} else {
|
|
None
|
|
};
|
|
let av_probe = av_metadata.as_ref().map(|metadata| metadata.probe);
|
|
let content_type = metadata_content_type(sniffed.mime, av_probe.as_ref());
|
|
let category = mime::category(content_type).ok_or(MediaError::UnsupportedMediaType)?;
|
|
if let Some(probe) = av_probe.as_ref()
|
|
&& category == mime::Category::Audio
|
|
&& !probe.has_audio
|
|
{
|
|
return Err(MediaError::MediaDecodeFailed);
|
|
}
|
|
|
|
let av_frame = av_metadata
|
|
.as_ref()
|
|
.filter(|_| category == mime::Category::Video)
|
|
.and_then(|metadata| metadata.frame.as_ref());
|
|
if let Some(frame) = av_frame {
|
|
width = frame.display_width;
|
|
height = frame.display_height;
|
|
}
|
|
|
|
let animated = sniffed.animated || dims.is_some_and(|d| d.pages > 1);
|
|
let placeholder = if options.placeholder {
|
|
let hash = if is_image {
|
|
optional_thumbhash(
|
|
encode_thumbhash(media_limits, input, options.deadline_ms),
|
|
"image_metadata",
|
|
)
|
|
} else {
|
|
match av_frame {
|
|
Some(frame) => {
|
|
optional_thumbhash(frame.encode_thumbhash(media_limits, None), "video_metadata")
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
encoded_placeholder(hash)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let scan_eligible = is_image
|
|
|| (category == mime::Category::Video
|
|
&& av_probe.as_ref().is_some_and(|probe| probe.has_video));
|
|
let nsfw_scan = if let Some(threshold) = options.nsfw.scan_threshold(scan_eligible) {
|
|
nsfw_scan_buffers(NSFWScanSource {
|
|
media_limits,
|
|
metrics,
|
|
threshold,
|
|
content_type: sniffed.mime,
|
|
animated,
|
|
frame_count: frames_count.max(sniffed.frames),
|
|
input,
|
|
duration_seconds: av_probe.as_ref().and_then(|probe| probe.duration_seconds),
|
|
deadline_ms: options.deadline_ms,
|
|
})?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let duration = av_probe.as_ref().and_then(|probe| {
|
|
probe
|
|
.duration_seconds
|
|
.filter(|duration| duration.is_finite() && *duration > 0.0)
|
|
.map(|duration| duration.ceil() as u32)
|
|
});
|
|
let (response_width, response_height) = if width > 0 && height > 0 {
|
|
(Some(width), Some(height))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let format = metadata_format(content_type);
|
|
let content_hash = hex::encode(Sha256::digest(input));
|
|
Ok(PreparedMetadata {
|
|
response: MetadataResponse {
|
|
content_type: content_type.to_owned(),
|
|
size: input.len(),
|
|
content_hash,
|
|
format,
|
|
width: response_width,
|
|
height: response_height,
|
|
animated,
|
|
duration,
|
|
placeholder,
|
|
nsfw: false,
|
|
nsfw_probability: 0.0,
|
|
},
|
|
nsfw_scan,
|
|
})
|
|
}
|
|
|
|
fn metadata_finalize(prepared: PreparedMetadata, verdict: NSFWClassification) -> MetadataResponse {
|
|
let mut response = prepared.response;
|
|
response.nsfw = verdict.is_nsfw;
|
|
response.nsfw_probability = verdict.probability;
|
|
response
|
|
}
|
|
|
|
pub fn prepare_metadata(
|
|
input: &[u8],
|
|
options: &MetadataOptions,
|
|
media_limits: &MediaLimits,
|
|
metrics: &TransformMetrics,
|
|
) -> Result<PreparedMetadata, MediaError> {
|
|
metadata_blocking(MetadataBlockingRequest {
|
|
media_limits,
|
|
metrics,
|
|
input,
|
|
options,
|
|
})
|
|
}
|
|
|
|
pub async fn finish_metadata(
|
|
mut prepared: PreparedMetadata,
|
|
nsfw_client: &NSFWClient,
|
|
) -> Result<String, MediaError> {
|
|
let scan = prepared.take_nsfw_scan();
|
|
let verdict = classify_nsfw_buffers(nsfw_client, scan).await?;
|
|
let response = metadata_finalize(prepared, verdict);
|
|
serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed)
|
|
}
|
|
|
|
pub async fn metadata_json_with_options(
|
|
input: &[u8],
|
|
_filename: &str,
|
|
options: MetadataOptions,
|
|
media_limits: &MediaLimits,
|
|
nsfw_client: &NSFWClient,
|
|
metrics: &TransformMetrics,
|
|
) -> Result<String, MediaError> {
|
|
let prepared = prepare_metadata(input, &options, media_limits, metrics)?;
|
|
finish_metadata(prepared, nsfw_client).await
|
|
}
|