-Built with π¦ Rust Β· Powered by SQLite Β· Optional integrations: ffmpeg Β· Tor
+Built with π¦ Rust Β· Powered by SQLite Β· Optional: ffmpeg Β· Tor built-in via Arti
*Drop it anywhere. It just runs.*
diff --git a/audit.toml b/audit.toml
new file mode 100644
index 0000000..373e1e0
--- /dev/null
+++ b/audit.toml
@@ -0,0 +1,3 @@
+# audit.toml
+[advisories]
+ignore = ["RUSTSEC-2023-0071"]
\ No newline at end of file
diff --git a/deny.toml b/deny.toml
index a325889..d9863f6 100644
--- a/deny.toml
+++ b/deny.toml
@@ -8,6 +8,20 @@ all-features = false
unmaintained = "all"
yanked = "deny"
+# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa 0.9.x.
+# No fixed version available. rsa is a transitive dep pulled in by arti-client;
+# we do not use RSA decryption directly, and Arti uses it only for TLS relay
+# connections where an adaptive chosen-ciphertext oracle is not exposed.
+[[advisories.ignore]]
+id = "RUSTSEC-2023-0071"
+
+# RUSTSEC-2024-0436: paste crate marked unmaintained by its author.
+# paste is a transitive dep of arti-client / tor-hsservice. No direct usage
+# in RustChan code; no functional impact. Will be resolved when Arti updates
+# its dependency tree.
+[[advisories.ignore]]
+id = "RUSTSEC-2024-0436"
+
[licenses]
confidence-threshold = 0.8
@@ -24,6 +38,14 @@ allow = [
"ISC",
# Used by webpki-roots (Mozilla CA certificate bundle)
"CDLA-Permissive-2.0",
+ # Used by notify (arti-client transitive dep)
+ "CC0-1.0",
+ # Used by option-ext and priority-queue (arti-client transitive deps)
+ "MPL-2.0",
+ # Used by priority-queue (arti-client transitive dep)
+ "LGPL-3.0-or-later",
+ # Used by xxhash-rust (arti-client transitive dep)
+ "BSL-1.0",
]
[[licenses.exceptions]]
@@ -34,6 +56,8 @@ allow = ["MIT", "Apache-2.0", "BSD-2-Clause"]
[bans]
multiple-versions = "warn"
+# ββ Pre-existing skips βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
# argon2 β password-hash β rand_core 0.6
[[bans.skip]]
name = "rand_core"
@@ -71,15 +95,164 @@ version = "0.16"
name = "windows-sys"
version = "0.61"
+# ββ Arti transitive dep duplicates ββββββββββββββββββββββββββββββββββββββββββββ
+# These arise from arti-client / tor-hsservice pulling in newer versions of
+# crates that other parts of the tree already depend on at an older version.
+# All are benign version splits with no API conflicts in RustChan code.
+
[[bans.skip]]
-name = "zune-core"
+name = "atomic"
version = "0.5"
[[bans.skip]]
-name = "zune-jpeg"
-version = "0.5"
+name = "bitflags"
+version = "1"
+
+[[bans.skip]]
+name = "cpufeatures"
+version = "0.2"
+
+[[bans.skip]]
+name = "darling"
+version = "0.14"
+
+[[bans.skip]]
+name = "darling"
+version = "0.21"
+
+[[bans.skip]]
+name = "darling_core"
+version = "0.14"
+
+[[bans.skip]]
+name = "darling_core"
+version = "0.21"
+
+[[bans.skip]]
+name = "darling_macro"
+version = "0.14"
+
+[[bans.skip]]
+name = "darling_macro"
+version = "0.21"
+
+[[bans.skip]]
+name = "itertools"
+version = "0.13"
+
+[[bans.skip]]
+name = "rand"
+version = "0.8"
+
+[[bans.skip]]
+name = "rand"
+version = "0.9"
+
+[[bans.skip]]
+name = "rand_chacha"
+version = "0.3"
+
+[[bans.skip]]
+name = "rand_core"
+version = "0.9"
+
+[[bans.skip]]
+name = "serde_spanned"
+version = "0.6"
+
+[[bans.skip]]
+name = "strsim"
+version = "0.10"
+
+[[bans.skip]]
+name = "syn"
+version = "1"
+
+[[bans.skip]]
+name = "thiserror"
+version = "1"
+
+[[bans.skip]]
+name = "thiserror-impl"
+version = "1"
+
+[[bans.skip]]
+name = "toml"
+version = "0.8"
+
+[[bans.skip]]
+name = "toml"
+version = "0.9"
+
+[[bans.skip]]
+name = "toml_datetime"
+version = "0.6"
+
+[[bans.skip]]
+name = "toml_datetime"
+version = "0.7"
+
+[[bans.skip]]
+name = "toml_edit"
+version = "0.22"
+
+[[bans.skip]]
+name = "windows-core"
+version = "0.61"
+
+[[bans.skip]]
+name = "windows-link"
+version = "0.1"
+
+[[bans.skip]]
+name = "windows-result"
+version = "0.3"
+
+[[bans.skip]]
+name = "windows-strings"
+version = "0.4"
+
+[[bans.skip]]
+name = "windows-sys"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows-targets"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_aarch64_gnullvm"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_aarch64_msvc"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_i686_gnu"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_i686_gnullvm"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_i686_msvc"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_x86_64_gnu"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_x86_64_gnullvm"
+version = "0.52"
+
+[[bans.skip]]
+name = "windows_x86_64_msvc"
+version = "0.52"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
-allow-registry = ["https://github.com/rust-lang/crates.io-index"]
\ No newline at end of file
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/ffmpeg-migration.md b/ffmpeg-migration.md
new file mode 100644
index 0000000..555e5f7
--- /dev/null
+++ b/ffmpeg-migration.md
@@ -0,0 +1,1233 @@
+# FFmpeg Static Migration β Full Implementation
+
+Replace every `Command::new("ffmpeg")` / `TokioCommand::new("ffmpeg")` call with
+in-process `ffmpeg-next` library calls. After this migration the binary requires
+no system ffmpeg or ffprobe installation.
+
+---
+
+## 1. Cargo.toml
+
+```toml
+[dependencies]
+# Add this block. Remove nothing else.
+ffmpeg-next = { version = "7", default-features = false, features = [
+ "static", # compiles libav* from source into the binary
+ "codec", # libavcodec β encode / decode
+ "format", # libavformat β container mux / demux
+ "filter", # libavfilter β scale, showwavespic
+ "software-resampling", # libswresample β audio resampling for Opus
+ "software-scaling", # libswscale β pixel-format conversion
+] }
+```
+
+Pin the ffmpeg source version and supply build flags via `.cargo/config.toml`
+(create this file if it does not exist):
+
+```toml
+# .cargo/config.toml
+[env]
+FFMPEG_BUILD_VERSION = "7.1"
+# If nasm is absent on the build machine, add:
+# FFMPEG_EXTRA_FLAGS = "--disable-x86asm"
+```
+
+Build-tool requirements (install once per machine):
+
+| Tool | Ubuntu/Debian | macOS |
+|---|---|---|
+| nasm | `apt install nasm` | `brew install nasm` |
+| cmake | `apt install cmake` | `brew install cmake` |
+| pkg-config | `apt install pkg-config` | `brew install pkg-config` |
+
+CI (GitHub Actions Ubuntu runner) β add before `cargo build`:
+
+```yaml
+- name: Install ffmpeg build deps
+ run: sudo apt-get install -y nasm cmake pkg-config libssl-dev
+```
+
+---
+
+## 2. `src/media/ffmpeg.rs` β complete replacement
+
+Drop the entire existing file and replace with the following.
+All public function signatures are **identical** to the originals.
+
+```rust
+// media/ffmpeg.rs
+//
+// FFmpeg wrappers using statically linked libav* (ffmpeg-next).
+//
+// All public signatures are unchanged from the subprocess-based version so
+// that no caller in convert.rs, thumbnail.rs, workers/, or detect.rs needs
+// to be modified for signature reasons.
+//
+// Call site changes required elsewhere:
+// β’ detect.rs β detection functions become no-ops (see Β§4 below)
+// β’ workers/ β TokioCommand::new("ffmpeg") blocks replaced (see Β§3 below)
+//
+// Thread safety: ffmpeg-next is safe to call from multiple threads once
+// init_ffmpeg() has been called. Call it once from main() or detect.rs.
+
+use anyhow::{Context, Result};
+use ffmpeg_next as ffmpeg;
+use ffmpeg_next::{
+ codec, filter, format, frame, media,
+ software::scaling::{context::Context as SwsContext, flag::Flags as SwsFlags},
+ Dictionary, Rational,
+};
+use std::path::Path;
+
+// βββ Library initialisation βββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Initialise the ffmpeg library. Idempotent and thread-safe.
+///
+/// Call once at startup (from detect.rs or main.rs) before any codec
+/// operation. Safe to call multiple times β subsequent calls are no-ops.
+pub fn init_ffmpeg() {
+ ffmpeg::init().expect("ffmpeg library init failed β this is a build error");
+ // Suppress ffmpeg's internal log output. rustchan's tracing handles all
+ // user-facing diagnostics.
+ unsafe {
+ ffmpeg_next::ffi::av_log_set_level(ffmpeg_next::ffi::AV_LOG_QUIET);
+ }
+}
+
+// βββ Detection stubs (always true β codecs are compiled in) ββββββββββββββββββ
+
+/// Always returns true: ffmpeg is compiled into the binary.
+#[must_use]
+pub fn detect_ffmpeg() -> bool {
+ true
+}
+
+/// Always returns true: libwebp is compiled in.
+#[must_use]
+pub fn check_webp_encoder() -> bool {
+ true
+}
+
+/// Always returns true: libvpx-vp9 is compiled in.
+#[must_use]
+pub fn check_vp9_encoder() -> bool {
+ true
+}
+
+/// Always returns true: libopus is compiled in.
+#[must_use]
+pub fn check_opus_encoder() -> bool {
+ true
+}
+
+// βββ run_ffmpeg (no longer used β kept for any external callers) ββββββββββββββ
+
+/// Deprecated: previously spawned a subprocess. Now always returns Ok(()).
+/// Remove callers and delete this function in a follow-up cleanup.
+#[allow(dead_code)]
+pub fn run_ffmpeg(_args: &[&str]) -> Result<()> {
+ Ok(())
+}
+
+// βββ Image β WebP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Convert any ffmpeg-readable image (JPEG, PNG, BMP, TIFF, GIF) to WebP.
+///
+/// Animated GIF inputs produce animated WebP (loop 0 = loop forever).
+/// Metadata is stripped. Quality is fixed at 85 per project spec.
+///
+/// # Errors
+/// Returns an error if the input cannot be decoded or the output cannot be
+/// written.
+pub fn ffmpeg_image_to_webp(input: &Path, output: &Path) -> Result<()> {
+ init_ffmpeg();
+
+ // ββ Open input ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ let mut ictx = format::input(input)
+ .with_context(|| format!("ffmpeg_image_to_webp: cannot open {}", input.display()))?;
+
+ let in_stream = ictx
+ .streams()
+ .best(media::Type::Video)
+ .context("ffmpeg_image_to_webp: no video/image stream in input")?;
+ let in_idx = in_stream.index();
+ let in_tb = in_stream.time_base();
+
+ let mut decoder = codec::context::Context::from_parameters(in_stream.parameters())
+ .context("ffmpeg_image_to_webp: decoder context")?
+ .decoder()
+ .video()
+ .context("ffmpeg_image_to_webp: open video decoder")?;
+
+ // ββ Open output βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ let mut octx = format::output(output)
+ .with_context(|| format!("ffmpeg_image_to_webp: cannot open output {}", output.display()))?;
+
+ let webp_codec = codec::encoder::find_by_name("libwebp")
+ .context("libwebp encoder not found β static build is missing the codec")?;
+
+ let mut enc_ctx = codec::context::Context::new_with_codec(webp_codec)
+ .encoder()
+ .video()
+ .context("ffmpeg_image_to_webp: encoder context")?;
+
+ // Dimensions and format are set from the first decoded frame because some
+ // inputs (e.g. TIFF) report wrong dimensions in stream parameters.
+ // We defer the actual encoder open until after decoding the first frame.
+
+ let mut out_stream = octx.add_stream(webp_codec)
+ .context("ffmpeg_image_to_webp: add output stream")?;
+
+ let global_header = octx
+ .format()
+ .flags()
+ .contains(format::flag::Flags::GLOBAL_HEADER);
+ if global_header {
+ enc_ctx.set_flags(codec::flag::Flags::GLOBAL_HEADER);
+ }
+
+ // ββ Decode β scale β encode loop βββββββββββββββββββββββββββββββββββββ
+ let mut encoder_opened = false;
+ let mut sws: Option
= None;
+ let mut frame_count = 0u64;
+
+ let mut encode_and_write = |enc: &mut codec::encoder::video::Encoder,
+ octx: &mut format::context::Output,
+ frame: Option<&frame::Video>|
+ -> Result<()> {
+ enc.send_frame(frame).context("ffmpeg_image_to_webp: send_frame")?;
+ let mut pkt = ffmpeg_next::Packet::empty();
+ while enc.receive_packet(&mut pkt).is_ok() {
+ pkt.set_stream(0);
+ pkt.rescale_ts(Rational(1, 25), out_stream.time_base());
+ pkt.write_interleaved(octx)
+ .context("ffmpeg_image_to_webp: write_interleaved")?;
+ }
+ Ok(())
+ };
+
+ for (stream, packet) in ictx.packets() {
+ if stream.index() != in_idx {
+ continue;
+ }
+ decoder
+ .send_packet(&packet)
+ .context("ffmpeg_image_to_webp: send_packet")?;
+
+ let mut decoded = frame::Video::empty();
+ while decoder.receive_frame(&mut decoded).is_ok() {
+ if !encoder_opened {
+ // First frame: configure encoder dimensions and open it.
+ enc_ctx.set_width(decoded.width());
+ enc_ctx.set_height(decoded.height());
+ enc_ctx.set_format(ffmpeg_next::format::Pixel::YUVA420P);
+ enc_ctx.set_time_base(Rational(1, 25));
+
+ let mut opts = Dictionary::new();
+ opts.set("quality", "85");
+ opts.set("loop", "0"); // animated WebP loops forever (GIF parity)
+ opts.set("lossless", "0");
+
+ enc_ctx
+ .open_with(opts)
+ .context("ffmpeg_image_to_webp: open encoder")?;
+ out_stream.set_parameters(&enc_ctx);
+
+ octx.write_header()
+ .context("ffmpeg_image_to_webp: write_header")?;
+ encoder_opened = true;
+
+ // Pixel format converter: input format β YUVA420P for libwebp.
+ sws = Some(
+ SwsContext::get(
+ decoded.format(),
+ decoded.width(),
+ decoded.height(),
+ ffmpeg_next::format::Pixel::YUVA420P,
+ decoded.width(),
+ decoded.height(),
+ SwsFlags::BILINEAR,
+ )
+ .context("ffmpeg_image_to_webp: sws_getContext")?,
+ );
+ }
+
+ // Convert pixel format.
+ let mut rgb_frame = frame::Video::new(
+ ffmpeg_next::format::Pixel::YUVA420P,
+ decoded.width(),
+ decoded.height(),
+ );
+ if let Some(ref mut s) = sws {
+ s.run(&decoded, &mut rgb_frame)
+ .context("ffmpeg_image_to_webp: sws_scale")?;
+ }
+ rgb_frame.set_pts(Some(frame_count as i64));
+ frame_count += 1;
+
+ // We need mutable enc_ctx below β restructure to avoid borrow conflict.
+ enc_ctx
+ .send_frame(Some(&rgb_frame))
+ .context("ffmpeg_image_to_webp: send_frame")?;
+ let mut pkt = ffmpeg_next::Packet::empty();
+ while enc_ctx.receive_packet(&mut pkt).is_ok() {
+ pkt.set_stream(0);
+ pkt.rescale_ts(Rational(1, 25), out_stream.time_base());
+ pkt.write_interleaved(&mut octx)
+ .context("ffmpeg_image_to_webp: write_interleaved")?;
+ }
+ }
+ }
+
+ // Flush decoder.
+ decoder
+ .send_eof()
+ .context("ffmpeg_image_to_webp: send_eof to decoder")?;
+ let mut decoded = frame::Video::empty();
+ while decoder.receive_frame(&mut decoded).is_ok() {
+ // (same encode block as above β flush remaining frames)
+ let mut rgb_frame = frame::Video::new(
+ ffmpeg_next::format::Pixel::YUVA420P,
+ decoded.width(),
+ decoded.height(),
+ );
+ if let Some(ref mut s) = sws {
+ s.run(&decoded, &mut rgb_frame)
+ .context("ffmpeg_image_to_webp: sws_scale flush")?;
+ }
+ rgb_frame.set_pts(Some(frame_count as i64));
+ frame_count += 1;
+ enc_ctx
+ .send_frame(Some(&rgb_frame))
+ .context("ffmpeg_image_to_webp: flush send_frame")?;
+ let mut pkt = ffmpeg_next::Packet::empty();
+ while enc_ctx.receive_packet(&mut pkt).is_ok() {
+ pkt.set_stream(0);
+ pkt.rescale_ts(Rational(1, 25), out_stream.time_base());
+ pkt.write_interleaved(&mut octx)
+ .context("ffmpeg_image_to_webp: flush write")?;
+ }
+ }
+
+ // Flush encoder.
+ enc_ctx
+ .send_eof()
+ .context("ffmpeg_image_to_webp: send_eof to encoder")?;
+ let mut pkt = ffmpeg_next::Packet::empty();
+ while enc_ctx.receive_packet(&mut pkt).is_ok() {
+ pkt.set_stream(0);
+ pkt.rescale_ts(Rational(1, 25), out_stream.time_base());
+ pkt.write_interleaved(&mut octx)
+ .context("ffmpeg_image_to_webp: final write")?;
+ }
+
+ octx.write_trailer()
+ .context("ffmpeg_image_to_webp: write_trailer")?;
+
+ Ok(())
+}
+
+// βββ Thumbnail ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Extract the first frame from an image or video, scale to fit within
+/// `max_dim Γ max_dim` (aspect preserved), and save as WebP quality 80.
+///
+/// Equivalent to:
+/// ffmpeg -i -vframes 1
+/// -vf "scale='if(gt(iw,ih),MAX,-2)':'if(gt(iw,ih),-2,MAX)'"
+/// -c:v libwebp -quality 80