transcode: add GIF transcoding for Discord inline playback

This commit is contained in:
4cuck committed 2026-03-17 04:55:03 -04:00
1 parent 12f224a8af
commit 5617d98947
13 files changed
+614 -47

No files matched your search

Generated
+22
View File
@@ -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",
+2
View File
@@ -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",
}
+11
View File
@@ -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"
+29 -1
View File
@@ -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}";
+130
View File
@@ -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<RwLock<IndexMap<String, GifCacheEntry>>>,
}
impl GifCache {
/// Create a new GIF cache, scanning existing files to rebuild state.
pub async fn new(dir: &str, max_mb: u64) -> eyre::Result<Self> {
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<PathBuf> {
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<PathBuf> {
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<String, GifCacheEntry>, 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());
}
}
}
}
}
+4
View File
@@ -1,3 +1,7 @@
pub mod gif_cache;
pub use gif_cache::GifCache;
use std::{
any::Any,
collections::HashMap,
+72 -2
View File
@@ -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<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)?;
let mut config = toml::from_str::<Self>(&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);
+36 -6
View File
@@ -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<Config>,
pub cache: Cache,
pub api: ApiClient,
pub http_client: HttpClient,
pub config: Arc<Config>,
pub cache: Cache,
pub api: ApiClient,
pub http_client: HttpClient,
pub gif_transcoder: Option<Arc<GifTranscoder>>,
}
#[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
+66
View File
@@ -16,6 +16,7 @@ use axum::{
},
routing::get,
};
use tokio::fs;
use crate::{
AppState,
@@ -37,6 +38,8 @@ pub fn router() -> Router<AppState> {
.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<AppState>, Path(url): Path<String>) -> Result<Response> {
@@ -162,6 +165,69 @@ async fn proxy_image(state: &AppState, url: &str, original: bool) -> Result<Resp
.into_response())
}
async fn gif_proxy(
State(state): State<AppState>,
Path((sig, url)): Path<(String, String)>,
) -> Result<Response> {
// 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<AppState>,
Path((sig, url)): Path<(String, String)>,
) -> Result<Response> {
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<Response> {
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<Response> {
// Forward Range header to upstream for seeking support
let mut upstream_headers = HeaderMap::new();
+182
View File
@@ -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<HashMap<String, Arc<Notify>>>,
}
impl GifTranscoder {
pub async fn new(
http_client: HttpClient,
config: GifTranscodingConfig,
) -> eyre::Result<Self> {
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<PathBuf> {
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(&notify));
// 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<PathBuf> {
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;
}
}
}
+13
View File
@@ -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())
+40 -36
View File
@@ -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 {
+7 -2
View File
@@ -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";
}