From 5617d98947a157089e6b3bcaa5bae2d825fdfce6 Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:49:01 -0400 Subject: [PATCH] transcode: add GIF transcoding for Discord inline playback --- Cargo.lock | 22 +++++ Cargo.toml | 2 + config/teapot.example.toml | 11 +++ flake.nix | 30 +++++- src/cache/gif_cache.rs | 130 ++++++++++++++++++++++++++ src/cache/mod.rs | 4 + src/config.rs | 74 ++++++++++++++- src/main.rs | 42 +++++++-- src/routes/media.rs | 66 ++++++++++++++ src/transcode.rs | 182 +++++++++++++++++++++++++++++++++++++ src/utils/formatters.rs | 13 +++ src/views/embed.rs | 76 ++++++++-------- src/views/layout.rs | 9 +- 13 files changed, 614 insertions(+), 47 deletions(-) create mode 100644 src/cache/gif_cache.rs create mode 100644 src/transcode.rs diff --git a/Cargo.lock b/Cargo.lock index c8bcde1..d841468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "eyre" version = "0.6.12" @@ -854,6 +864,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -920,6 +940,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "indexmap", "maud", "percent-encoding", "regex", @@ -1011,6 +1032,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 7812d92..ccda744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ data-encoding = "2.10.0" eyre = "0.6.12" flate2 = "1.1.9" form_urlencoded = "1.2.2" +indexmap = "2.9.0" http-body-util = "0.1.3" hyper = { features = [ "http1", "client" ], version = "1.8.1" } hyper-rustls = { @@ -58,6 +59,7 @@ tokio = { "sync", "fs", "io-util", + "process", ], version = "1.49.0", } diff --git a/config/teapot.example.toml b/config/teapot.example.toml index b205f98..40acc1b 100644 --- a/config/teapot.example.toml +++ b/config/teapot.example.toml @@ -41,6 +41,17 @@ disableTid = false # Max requests per session to avoid race conditions maxConcurrentReqs = 2 +[gifTranscoding] +# Transcode Twitter GIF MP4s to actual GIF format for Discord inline playback +# Modes: "off" (default), "local" (ffmpeg on server), "external" (URL rewrite) +mode = "off" +# Cache directory for transcoded GIFs (local mode only) +cacheDir = "./cache/gif" +# Maximum cache size in MB (local mode only) +cacheMaxMb = 512 +# External transcoding domain (external mode only, e.g. "gif.example.com") +externalDomain = "" + [preferences] # Default theme theme = "teapot" diff --git a/flake.nix b/flake.nix index 7d585a8..a0bb9d2 100644 --- a/flake.nix +++ b/flake.nix @@ -118,7 +118,7 @@ } ( lib.recursiveUpdate { - inherit (cfg) cache preferences; + inherit (cfg) cache preferences gifTranscoding; config = cfg.config // { hmacKey = "@hmac@"; }; @@ -254,6 +254,33 @@ }; }; + gifTranscoding = { + mode = lib.mkOption { + type = lib.types.enum [ + "off" + "local" + "external" + ]; + default = "off"; + description = "GIF transcoding mode. 'local' requires ffmpeg, 'external' rewrites URLs to an external domain."; + }; + cacheDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/teapot/cache/gif"; + description = "Cache directory for transcoded GIFs (local mode)."; + }; + cacheMaxMb = lib.mkOption { + type = lib.types.int; + default = 512; + description = "Maximum cache size in MB (local mode)."; + }; + externalDomain = lib.mkOption { + type = lib.types.str; + default = ""; + description = "External transcoding domain (external mode)."; + }; + }; + preferences = { theme = lib.mkOption { type = lib.types.str; @@ -316,6 +343,7 @@ wantedBy = [ "multi-user.target" ]; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; + path = lib.mkIf (cfg.gifTranscoding.mode == "local") [ pkgs.ffmpeg-headless ]; serviceConfig = { DynamicUser = true; LoadCredential = "sessionsFile:${cfg.sessionsFile}"; diff --git a/src/cache/gif_cache.rs b/src/cache/gif_cache.rs new file mode 100644 index 0000000..dd090b7 --- /dev/null +++ b/src/cache/gif_cache.rs @@ -0,0 +1,130 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::SystemTime, +}; + +use indexmap::IndexMap; +use tokio::{ + fs, + sync::RwLock, +}; + +struct GifCacheEntry { + file_size: u64, + last_access: SystemTime, +} + +pub struct GifCache { + dir: PathBuf, + max_bytes: u64, + entries: Arc>>, +} + +impl GifCache { + /// Create a new GIF cache, scanning existing files to rebuild state. + pub async fn new(dir: &str, max_mb: u64) -> eyre::Result { + let dir = PathBuf::from(dir); + fs::create_dir_all(&dir).await?; + + let mut entries = IndexMap::new(); + let mut read_dir = fs::read_dir(&dir).await?; + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "gif") + && let Some(stem) = path.file_stem().and_then(|st| st.to_str()) + && let Ok(meta) = entry.metadata().await + { + entries.insert( + stem.to_owned(), + GifCacheEntry { + file_size: meta.len(), + last_access: meta + .accessed() + .or_else(|_| meta.modified()) + .unwrap_or_else(|_| SystemTime::now()), + }, + ); + } + } + + // Sort by last access time (oldest first) for LRU ordering + entries.sort_by(|_, lhs, _, rhs| lhs.last_access.cmp(&rhs.last_access)); + + tracing::info!( + "GIF cache loaded: {} entries from {}", + entries.len(), + dir.display() + ); + + Ok(Self { + dir, + max_bytes: max_mb * 1024 * 1024, + entries: Arc::new(RwLock::new(entries)), + }) + } + + /// Get the path for a cached GIF, returning `None` if not cached. + #[expect(clippy::significant_drop_tightening, reason = "entry borrows from map")] + pub async fn get(&self, hash: &str) -> Option { + let path = self.dir.join(format!("{hash}.gif")); + if !fs::try_exists(&path).await.unwrap_or(false) { + // File was deleted externally — remove from tracking + self.entries.write().await.shift_remove(hash); + return None; + } + + let mut entries = self.entries.write().await; + if let Some(entry) = entries.get_mut(hash) { + entry.last_access = SystemTime::now(); + // Move to end (most recently used) + let idx = entries.get_index_of(hash).unwrap_or_default(); + let last = entries.len().saturating_sub(1); + entries.move_index(idx, last); + Some(path) + } else { + None + } + } + + /// Insert a transcoded GIF into the cache, evicting LRU entries if needed. + pub async fn put(&self, hash: &str, data: &[u8]) -> eyre::Result { + let path = self.dir.join(format!("{hash}.gif")); + fs::write(&path, data).await?; + + let file_size = data.len() as u64; + + let mut entries = self.entries.write().await; + entries.insert( + hash.to_owned(), + GifCacheEntry { + file_size, + last_access: SystemTime::now(), + }, + ); + Self::evict(&self.dir, &mut entries, self.max_bytes).await; + drop(entries); + + Ok(path) + } + + /// Remove oldest entries until total size is under the limit. + async fn evict(dir: &Path, entries: &mut IndexMap, max_bytes: u64) { + let total: u64 = entries.values().map(|entry| entry.file_size).sum(); + if total <= max_bytes { + return; + } + + let mut current = total; + while current > max_bytes && !entries.is_empty() { + // Remove from the front (oldest) + if let Some((hash, entry)) = entries.shift_remove_index(0) { + current -= entry.file_size; + let path = dir.join(format!("{hash}.gif")); + if let Err(err) = fs::remove_file(&path).await { + tracing::warn!("Failed to evict cached GIF {}: {err}", path.display()); + } + } + } + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs index ce6259a..fbf6bd2 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,3 +1,7 @@ +pub mod gif_cache; + +pub use gif_cache::GifCache; + use std::{ any::Any, collections::HashMap, diff --git a/src/config.rs b/src/config.rs index 5dc9f55..0119ed5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,52 @@ use std::{ fs, path::Path, + process::Command, }; use serde::Deserialize; use crate::error::Result; +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GifTranscodingMode { + #[default] + Off, + Local, + External, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GifTranscodingConfig { + #[serde(default)] + pub mode: GifTranscodingMode, + #[serde(default = "default_gif_cache_dir", rename = "cacheDir")] + pub cache_dir: String, + #[serde(default = "default_gif_cache_max_mb", rename = "cacheMaxMb")] + pub cache_max_mb: u64, + #[serde(default, rename = "externalDomain")] + pub external_domain: String, +} + +impl Default for GifTranscodingConfig { + fn default() -> Self { + Self { + mode: GifTranscodingMode::Off, + cache_dir: default_gif_cache_dir(), + cache_max_mb: default_gif_cache_max_mb(), + external_domain: String::new(), + } + } +} + +fn default_gif_cache_dir() -> String { + "./cache/gif".to_owned() +} +const fn default_gif_cache_max_mb() -> u64 { + 512 +} + #[derive(Debug, Clone, Deserialize)] #[expect( clippy::struct_field_names, @@ -17,10 +57,12 @@ pub struct Config { pub cache: CacheConfig, pub config: AppConfig, #[serde(default)] - pub preferences: PreferencesConfig, + pub preferences: PreferencesConfig, + #[serde(default, rename = "gifTranscoding")] + pub gif_transcoding: GifTranscodingConfig, /// Precomputed `{scheme}://{hostname}` — set in `Config::load`. #[serde(skip)] - pub url_prefix: String, + pub url_prefix: String, } #[derive(Debug, Clone, Deserialize)] @@ -143,6 +185,7 @@ const fn default_true() -> bool { } impl Config { + #[expect(clippy::cognitive_complexity, reason = "validation is straightforward")] pub fn load>(path: P) -> Result { let content = fs::read_to_string(path)?; let mut config = toml::from_str::(&content)?; @@ -152,6 +195,33 @@ impl Config { config" ); } + // Validate GIF transcoding config + match config.gif_transcoding.mode { + GifTranscodingMode::Local => { + if Command::new("ffmpeg") + .arg("-version") + .output() + .is_err() + { + tracing::error!( + "gifTranscoding.mode is 'local' but ffmpeg was not found on PATH — falling back \ + to 'off'" + ); + config.gif_transcoding.mode = GifTranscodingMode::Off; + } + }, + GifTranscodingMode::External => { + if config.gif_transcoding.external_domain.is_empty() { + tracing::error!( + "gifTranscoding.mode is 'external' but externalDomain is empty — falling back \ + to 'off'" + ); + config.gif_transcoding.mode = GifTranscodingMode::Off; + } + }, + GifTranscodingMode::Off => {}, + } + let scheme = if config.server.https { "https" } else { "http" }; let default_port = if config.server.https { 443 } else { 80 }; let url_port = config.server.public_port.unwrap_or(config.server.port); diff --git a/src/main.rs b/src/main.rs index 6e76436..4d10eec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cache; mod config; mod error; mod routes; +mod transcode; mod types; mod utils; mod views; @@ -40,16 +41,21 @@ use crate::{ SessionPool, }, cache::Cache, - config::Config, + config::{ + Config, + GifTranscodingMode, + }, + transcode::GifTranscoder, }; /// Application state shared across all routes. #[derive(Clone)] pub struct AppState { - pub config: Arc, - pub cache: Cache, - pub api: ApiClient, - pub http_client: HttpClient, + pub config: Arc, + pub cache: Cache, + pub api: ApiClient, + pub http_client: HttpClient, + pub gif_transcoder: Option>, } #[tokio::main] @@ -87,12 +93,36 @@ async fn main() -> eyre::Result<()> { // Initialize API client let api = ApiClient::new(&config, sessions); + // Initialize GIF transcoder if local mode + let http_client = HttpClient::new(&config.config.proxy, &config.config.proxy_auth); + let gif_transcoder = if config.gif_transcoding.mode == GifTranscodingMode::Local { + match GifTranscoder::new(http_client.clone(), config.gif_transcoding.clone()).await { + Ok(transcoder) => { + tracing::info!("GIF transcoder enabled (local mode)"); + Some(Arc::new(transcoder)) + }, + Err(err) => { + tracing::error!("Failed to initialize GIF transcoder: {err}"); + None + }, + } + } else { + if config.gif_transcoding.mode == GifTranscodingMode::External { + tracing::info!( + "GIF transcoding enabled (external mode, domain: {})", + config.gif_transcoding.external_domain + ); + } + None + }; + // Create application state let state = AppState { config: Arc::clone(&config), cache, api, - http_client: HttpClient::new(&config.config.proxy, &config.config.proxy_auth), + http_client, + gif_transcoder, }; // Build router - order matters: specific routes first, then static files diff --git a/src/routes/media.rs b/src/routes/media.rs index d8f6a57..11c684e 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -16,6 +16,7 @@ use axum::{ }, routing::get, }; +use tokio::fs; use crate::{ AppState, @@ -37,6 +38,8 @@ pub fn router() -> Router { .route("/pic/orig/enc/{url}", get(pic_orig_proxy_encoded)) .route("/video/{sig}/{url}", get(video_proxy)) .route("/video/enc/{sig}/{url}", get(video_proxy_encoded)) + .route("/gif/{sig}/{url}", get(gif_proxy)) + .route("/gif/enc/{sig}/{url}", get(gif_proxy_encoded)) } async fn pic_proxy(State(state): State, Path(url): Path) -> Result { @@ -162,6 +165,69 @@ async fn proxy_image(state: &AppState, url: &str, original: bool) -> Result, + Path((sig, url)): Path<(String, String)>, +) -> Result { + // Strip .gif suffix added for Discord's image proxy extension detection + let raw_url = url.strip_suffix(".gif").unwrap_or(&url); + let decoded_url = percent_encoding::percent_decode_str(raw_url) + .decode_utf8() + .map_err(|_| Error::InvalidUrl("Invalid URL encoding".into()))? + .to_string(); + + if !hmac::verify(&decoded_url, &sig, &state.config.config.hmac_key) { + return Err(Error::HmacVerification); + } + + serve_gif(&state, &decoded_url).await +} + +async fn gif_proxy_encoded( + State(state): State, + Path((sig, url)): Path<(String, String)>, +) -> Result { + let raw_url = url.strip_suffix(".gif").unwrap_or(&url); + let decoded = formatters::base64_decode_url(raw_url) + .ok_or_else(|| Error::InvalidUrl("Invalid base64 encoding".into()))?; + + if !hmac::verify(&decoded, &sig, &state.config.config.hmac_key) { + return Err(Error::HmacVerification); + } + + serve_gif(&state, &decoded).await +} + +async fn serve_gif(state: &AppState, mp4_url: &str) -> Result { + let transcoder = state + .gif_transcoder + .as_ref() + .ok_or_else(|| Error::Internal("GIF transcoding not enabled".into()))?; + + match transcoder.get_or_transcode(mp4_url).await { + Ok(path) => { + let bytes = fs::read(&path).await.map_err(|err| { + Error::Internal(format!("read cached GIF: {err}")) + })?; + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "image/gif".to_owned()), + (header::CACHE_CONTROL, "public, max-age=604800".to_owned()), + ], + bytes, + ) + .into_response()) + }, + Err(err) => { + tracing::warn!("GIF transcode failed, falling back to MP4 proxy: {err}"); + // Fall back to proxying the MP4 directly + proxy_video(state, mp4_url, &HeaderMap::new()).await + }, + } +} + async fn proxy_video(state: &AppState, url: &str, req_headers: &HeaderMap) -> Result { // Forward Range header to upstream for seeking support let mut upstream_headers = HeaderMap::new(); diff --git a/src/transcode.rs b/src/transcode.rs new file mode 100644 index 0000000..690fc8c --- /dev/null +++ b/src/transcode.rs @@ -0,0 +1,182 @@ +use std::{ + collections::HashMap, + path::PathBuf, + process::Stdio, + sync::Arc, +}; + +use data_encoding::HEXLOWER; +use ring::digest; +use tokio::{ + fs, + process::Command, + sync::{Mutex, Notify, Semaphore}, +}; + +use crate::{ + api::HttpClient, + cache::GifCache, + config::GifTranscodingConfig, +}; + +pub struct GifTranscoder { + cache: GifCache, + http_client: HttpClient, + cache_dir: PathBuf, + semaphore: Semaphore, + inflight: Mutex>>, +} + +impl GifTranscoder { + pub async fn new( + http_client: HttpClient, + config: GifTranscodingConfig, + ) -> eyre::Result { + let cache = GifCache::new(&config.cache_dir, config.cache_max_mb).await?; + Ok(Self { + cache, + http_client, + cache_dir: PathBuf::from(config.cache_dir), + semaphore: Semaphore::new(3), + inflight: Mutex::new(HashMap::new()), + }) + } + + /// Hash an MP4 URL to a cache key (truncated SHA-256, 32 hex chars). + fn hash_url(url: &str) -> String { + let hash = digest::digest(&digest::SHA256, url.as_bytes()); + HEXLOWER.encode(hash.as_ref())[..32].to_owned() + } + + /// Get a cached GIF or transcode the MP4. Returns the path to the GIF file. + pub async fn get_or_transcode(&self, mp4_url: &str) -> eyre::Result { + let hash = Self::hash_url(mp4_url); + + // Check cache + if let Some(path) = self.cache.get(&hash).await { + return Ok(path); + } + + // Atomically check-and-register: either wait on an existing transcoder or + // register ourselves as the inflight one. + let existing = { + let map = self.inflight.lock().await; + map.get(&hash).map(Arc::clone) + }; + if let Some(existing) = existing { + existing.notified().await; + if let Some(path) = self.cache.get(&hash).await { + return Ok(path); + } + } + + let notify = Arc::new(Notify::new()); + self.inflight + .lock() + .await + .insert(hash.clone(), Arc::clone(¬ify)); + + // Acquire semaphore + let _permit = self.semaphore.acquire().await?; + + // Double-check cache after acquiring permit + if let Some(path) = self.cache.get(&hash).await { + self.inflight.lock().await.remove(&hash); + notify.notify_waiters(); + return Ok(path); + } + + let result = self.do_transcode(mp4_url, &hash).await; + + // Notify waiters and remove inflight entry + self.inflight.lock().await.remove(&hash); + notify.notify_waiters(); + + result + } + + async fn do_transcode(&self, mp4_url: &str, hash: &str) -> eyre::Result { + let cache_dir = &self.cache_dir; + let input = cache_dir.join(format!("{hash}.mp4.tmp")); + let palette = cache_dir.join(format!("{hash}.palette.png")); + let output = cache_dir.join(format!("{hash}.gif.tmp")); + + // Fetch MP4 + let response = self + .http_client + .get(mp4_url) + .await + .map_err(|err| eyre::eyre!("Failed to fetch MP4: {err}"))?; + + if !response.status().is_success() { + return Err(eyre::eyre!("MP4 fetch returned {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|err| eyre::eyre!("Failed to read MP4 body: {err}"))?; + fs::write(&input, &bytes).await?; + + // Pass 1: generate palette + let palette_out = Command::new("ffmpeg") + .args([ + "-i", + &input.to_string_lossy(), + "-vf", + "fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff", + "-y", + &palette.to_string_lossy(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await?; + + if !palette_out.status.success() { + let stderr = String::from_utf8_lossy(&palette_out.stderr); + Self::cleanup(&[&input, &palette, &output]).await; + return Err(eyre::eyre!("ffmpeg palettegen failed: {stderr}")); + } + + // Pass 2: generate GIF with palette + let gif_out = Command::new("ffmpeg") + .args([ + "-i", + &input.to_string_lossy(), + "-i", + &palette.to_string_lossy(), + "-lavfi", + "fps=15,scale=480:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5", + "-f", + "gif", + "-y", + &output.to_string_lossy(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await?; + + if !gif_out.status.success() { + let stderr = String::from_utf8_lossy(&gif_out.stderr); + Self::cleanup(&[&input, &palette, &output]).await; + return Err(eyre::eyre!("ffmpeg paletteuse failed: {stderr}")); + } + + // Read the output GIF and insert into cache + let gif_data = fs::read(&output).await?; + let result = self.cache.put(hash, &gif_data).await; + + // Clean up temp files + Self::cleanup(&[&input, &palette, &output]).await; + + result + } + + async fn cleanup(paths: &[&PathBuf]) { + for path in paths { + let _ = fs::remove_file(path).await; + } + } +} diff --git a/src/utils/formatters.rs b/src/utils/formatters.rs index 78504a7..d85abbb 100644 --- a/src/utils/formatters.rs +++ b/src/utils/formatters.rs @@ -201,6 +201,19 @@ pub fn get_video_embed_url(config: &Config, tweet_id: i64) -> String { format!("{}/i/videos/tweet/{tweet_id}", config.url_prefix()) } +/// Generate GIF URL with HMAC signature (for local transcoding). +/// The `.gif` suffix is critical: Discord's image proxy uses the URL +/// extension to decide whether to preserve animation. +pub fn get_gif_url(mp4_url: &str, hmac_key: &str, base64_media: bool) -> String { + let sig = super::sign(mp4_url, hmac_key); + format!("{}.gif", encode_media_url(&format!("/gif/{sig}"), mp4_url, base64_media)) +} + +/// Generate external GIF URL by rewriting the domain. +pub fn get_external_gif_url(mp4_url: &str, external_domain: &str) -> String { + mp4_url.replace("video.twimg.com", external_domain) +} + /// Base64 encode a URL (URL-safe variant). pub fn base64_encode_url(url: &str) -> String { BASE64URL_NOPAD.encode(url.as_bytes()) diff --git a/src/views/embed.rs b/src/views/embed.rs index bcee5d5..7beaa0a 100644 --- a/src/views/embed.rs +++ b/src/views/embed.rs @@ -13,7 +13,10 @@ use super::{ tweet::TweetRenderer, }; use crate::{ - config::Config, + config::{ + Config, + GifTranscodingMode, + }, types::{ Gif, Prefs, @@ -87,14 +90,6 @@ pub fn og_images_with_quote(tweet: &Tweet) -> Vec<&str> { .map_or_else(Vec::new, og_images) } -/// Check if this tweet has video/gif, including from quote. -pub fn has_video_with_quote(tweet: &Tweet) -> bool { - with_quote_fallback(tweet, |tw| { - (tw.video.is_some() || tw.gif.is_some()).then_some(()) - }) - .is_some() -} - /// Get the video, falling back to quote tweet. pub fn video_with_quote(tweet: &Tweet) -> Option<&Video> { with_quote_fallback(tweet, |tw| tw.video.as_ref()) @@ -157,27 +152,32 @@ pub fn build_embed_description(tweet: &Tweet) -> String { /// Shared between `render_tweet_embed` and `render_status_page`. fn render_media_meta_tags(tweet: &Tweet, config: &Config, url_prefix: &str) -> Markup { let images = og_images_with_quote(tweet); - let has_video = has_video_with_quote(tweet); + let has_video = video_with_quote(tweet).is_some(); + // GIF tweets provide their own og:image (the transcoded .gif URL) + // in the GIF branch below, so skip the thumbnail from the image loop. + let has_gif = gif_with_quote(tweet).is_some(); html! { @if has_video { - meta property="og:type" content="video"; + meta property="og:type" content="video.other"; } @else if !images.is_empty() { meta property="og:type" content="photo"; } @else { meta property="og:type" content="article"; } - // Image meta tags - @for image in &images { - @let pic_url = formatters::get_pic_url(image, config.config.base64_media); - @let full_pic_url = format!("{url_prefix}{pic_url}"); - meta property="og:image" content=(full_pic_url); + // Image meta tags (skip for GIF tweets — GIF branch provides og:image) + @if !has_gif { + @for image in &images { + @let pic_url = formatters::get_pic_url(image, config.config.base64_media); + @let full_pic_url = format!("{url_prefix}{pic_url}"); + meta property="og:image" content=(full_pic_url); + } } @if has_video { meta property="twitter:image" content="0"; - } @else { + } @else if !has_gif { @for image in &images { @let pic_url = formatters::get_pic_url(image, config.config.base64_media); @let full_pic_url = format!("{url_prefix}{pic_url}"); @@ -209,25 +209,29 @@ fn render_media_meta_tags(tweet: &Tweet, config: &Config, url_prefix: &str) -> M meta name="twitter:player:stream:content_type" content="video/mp4"; } } @else if let Some(gif) = gif_with_quote(tweet) { - @let vid_url = formatters::get_vid_url(&gif.url, &config.config.hmac_key, config.config.base64_media); - @let full_gif_url = format!("{url_prefix}{vid_url}"); - @let thumb_url = formatters::get_pic_url(&gif.thumb, config.config.base64_media); - @let full_thumb_url = format!("{url_prefix}{thumb_url}"); - @let embed_url = formatters::get_video_embed_url(config, tweet.id); - - meta property="og:video" content=(full_gif_url); - meta property="og:video:secure_url" content=(full_gif_url); - meta property="og:video:type" content="video/mp4"; - meta property="og:video:width" content="480"; - meta property="og:video:height" content="480"; - meta property="og:image" content=(full_thumb_url); - - meta name="twitter:card" content="player"; - meta name="twitter:player" content=(embed_url); - meta name="twitter:player:width" content="480"; - meta name="twitter:player:height" content="480"; - meta name="twitter:player:stream" content=(full_gif_url); - meta name="twitter:player:stream:content_type" content="video/mp4"; + // GIF tweets: point og:image directly at the transcoded GIF. + // Discord's image proxy will fetch it, get image/gif content, + // and render it animated. + @match config.gif_transcoding.mode { + GifTranscodingMode::Local => { + @let gif_url = formatters::get_gif_url(&gif.url, &config.config.hmac_key, config.config.base64_media); + @let full_gif_url = format!("{url_prefix}{gif_url}"); + meta property="og:image" content=(full_gif_url); + meta property="twitter:image" content=(full_gif_url); + }, + GifTranscodingMode::External => { + @let ext_gif_url = formatters::get_external_gif_url(&gif.url, &config.gif_transcoding.external_domain); + meta property="og:image" content=(ext_gif_url); + meta property="twitter:image" content=(ext_gif_url); + }, + GifTranscodingMode::Off => { + @let thumb_url = formatters::get_pic_url(&gif.thumb, config.config.base64_media); + @let full_thumb_url = format!("{url_prefix}{thumb_url}"); + meta property="og:image" content=(full_thumb_url); + meta property="twitter:image" content=(full_thumb_url); + }, + } + meta name="twitter:card" content="summary_large_image"; } @else if !images.is_empty() { meta name="twitter:card" content="summary_large_image"; } @else { diff --git a/src/views/layout.rs b/src/views/layout.rs index 4e63a2c..c168c19 100644 --- a/src/views/layout.rs +++ b/src/views/layout.rs @@ -175,12 +175,17 @@ impl<'a> PageLayout<'a> { meta property="og:locale" content="en_US"; meta property="og:title" content=(self.title); meta property="og:description" content=(strip_html(self.description)); - meta property="og:type" content=(self.og_type); + // head_extra provides its own og:type and twitter:card + // when present (e.g. video/player for GIF embeds). + // Discord takes the first occurrence, so suppress defaults. + @if self.head_extra.is_none() { + meta property="og:type" content=(self.og_type); + } @if !self.og_image.is_empty() { meta property="og:image" content=(self.og_image); meta property="twitter:image:src" content=(self.og_image); } - @if !self.rss.is_empty() { + @if self.head_extra.is_none() && !self.rss.is_empty() { meta name="twitter:card" content="summary"; }