Skip to main content

deepshrink_core/engine/
mod.rs

1//! The compression engine contract and shared types.
2//!
3//! Key architectural decision: `core` is designed around the [`Engine`]
4//! interface even while there is exactly one engine (media/ffmpeg). This lets us
5//! plug in images/PDF/office later without rewriting the skeleton.
6//!
7//! Principle: [`Engine::plan`] is a pure, testable function (bitrate math);
8//! side effects are isolated in [`Engine::run`].
9
10#[cfg(feature = "ffmpeg")]
11pub mod media;
12pub mod plan;
13
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17use crate::detect::MediaKind;
18use crate::options::{AudioChoice, AudioCodec, FpsOpt, QualityPreset, ResolutionOpt, VideoCodec};
19use crate::size::Preset;
20
21/// Source file metadata — the result of [`Engine::probe`].
22#[derive(Debug, Clone, PartialEq)]
23pub struct MediaInfo {
24    pub path: PathBuf,
25    pub kind: MediaKind,
26    pub duration_sec: f64,
27    pub size_bytes: u64,
28    pub width: Option<u32>,
29    pub height: Option<u32>,
30    pub fps: Option<f64>,
31    pub video_codec: Option<String>,
32    pub audio_codec: Option<String>,
33    pub audio_channels: Option<u32>,
34    /// Bit rate of the (first) audio stream in bits/s, when the probe reports
35    /// it — the ceiling for re-encoding a video's audio track.
36    pub audio_bitrate_bps: Option<u64>,
37    /// When / where / with what it was shot (container tags), carried over to
38    /// the output when metadata is kept.
39    pub capture: CaptureMeta,
40    /// The video is HDR (HLG or PQ transfer), `None` for SDR / unknown / audio.
41    pub hdr: Option<Hdr>,
42}
43
44/// HDR transfer function of a source video.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Hdr {
47    /// Hybrid Log-Gamma (`arib-std-b67`) — iPhone and most phone cameras.
48    Hlg,
49    /// Perceptual Quantizer (`smpte2084`) — HDR10 / Dolby Vision from cameras,
50    /// screen recordings, films.
51    Pq,
52}
53
54impl Hdr {
55    /// From an ffprobe `color_transfer` value.
56    pub fn from_transfer(transfer: &str) -> Option<Self> {
57        match transfer {
58            "arib-std-b67" => Some(Self::Hlg),
59            "smpte2084" => Some(Self::Pq),
60            _ => None,
61        }
62    }
63}
64
65/// Capture metadata read from the source's container tags. iPhone videos keep
66/// these as `com.apple.quicktime.*` keys; others use plain `location` / `make`
67/// / `model` / `creation_time`.
68#[derive(Debug, Clone, Default, PartialEq)]
69pub struct CaptureMeta {
70    /// When it was shot, UTC ISO-8601 (`2026-09-26T17:01:54Z`): Apple's
71    /// `creationdate` (local time + offset) when present — the container's
72    /// `creation_time` can be the moment the file was exported or AirDropped.
73    pub created_utc: Option<String>,
74    /// The original local timestamp as written (`2026-09-26T20:01:54+0300`).
75    pub created_local: Option<String>,
76    /// ISO 6709 location (`+50.4160+030.2796+155.635/`).
77    pub location: Option<String>,
78    pub make: Option<String>,
79    pub model: Option<String>,
80}
81
82impl MediaInfo {
83    /// Whether the source carries an audio track.
84    pub fn has_audio(&self) -> bool {
85        self.audio_codec.is_some()
86    }
87}
88
89/// What the user wants in terms of size.
90#[derive(Debug, Clone, PartialEq)]
91pub enum SizeGoal {
92    /// Fit into an absolute size (bytes).
93    Target(u64),
94    /// Reduce by a fraction of the original (0.70 = "by 70%").
95    Reduce(f64),
96    /// Platform preset (sets the target size).
97    Preset(Preset),
98    /// Smart, quality-preserving shrink without a hard limit.
99    Quality,
100}
101
102/// Compression options passed to the engine — the decoded form of the flags.
103#[derive(Debug, Clone, PartialEq)]
104pub struct ShrinkOpts {
105    pub goal: SizeGoal,
106    pub video_codec: VideoCodec,
107    /// What to do with the audio track *inside a video*.
108    pub audio: AudioChoice,
109    pub resolution: ResolutionOpt,
110    pub fps: FpsOpt,
111    pub quality: QualityPreset,
112    /// Codec for a *pure-audio* input.
113    pub audio_codec: AudioCodec,
114    /// Downmix pure audio to mono.
115    pub mono: bool,
116    /// Force a sample rate (Hz) for pure audio; `None` keeps the source rate.
117    pub sample_rate: Option<u32>,
118    /// Prefer VBR where the codec supports it.
119    pub vbr: bool,
120    /// Target VMAF: in quality mode, search CRF for the smallest output that
121    /// still scores at least this. `None` disables VMAF-aware encoding.
122    pub target_vmaf: Option<f64>,
123    /// Explicit output path; when `None` the engine derives one.
124    pub output: Option<PathBuf>,
125    /// Force two-pass on or off for a size-targeted video encode. `None` lets
126    /// the engine decide (two-pass whenever it encodes to a bitrate budget).
127    /// `Some(true)` is only meaningful in bitrate mode — a CRF encode has no
128    /// budget for a first pass to measure.
129    pub two_pass: Option<bool>,
130    /// Target resolution (dots per inch) for raster images *embedded in a
131    /// document*, measured at the size they are placed on the page. Only the
132    /// document engines act on it — the media engine ignores it, as pixels in a
133    /// video have no physical size.
134    pub dpi: Option<u32>,
135    /// Allow a quality-mode result that is not smaller than the source.
136    /// Off by default: re-encoding an already-compact lossy file only loses
137    /// quality, so the engine keeps the original instead (sample-predicted for
138    /// video, bitrate-compared for audio, and checked after every encode).
139    pub allow_larger: bool,
140    /// Carry the source's metadata over (creation date, location, camera —
141    /// QuickTime keys included) and its modification time. On by default;
142    /// `false` strips metadata from the output.
143    pub keep_metadata: bool,
144    /// Encode video with Apple's hardware encoder (VideoToolbox) when this Mac
145    /// has it — ~3–5× faster and a fraction of the memory, for a larger file
146    /// (see [`QualityPreset::default_hw_quality`]). Off by default; ignored for
147    /// AV1 and where `media::hardware_encoding_available` is false.
148    pub hardware: bool,
149}
150
151impl Default for ShrinkOpts {
152    fn default() -> Self {
153        Self {
154            goal: SizeGoal::Quality,
155            video_codec: VideoCodec::H264,
156            audio: AudioChoice::Keep,
157            resolution: ResolutionOpt::Auto,
158            fps: FpsOpt::Auto,
159            quality: QualityPreset::Balanced,
160            audio_codec: AudioCodec::Aac,
161            mono: false,
162            sample_rate: None,
163            vbr: false,
164            target_vmaf: None,
165            output: None,
166            two_pass: None,
167            dpi: None,
168            allow_larger: false,
169            keep_metadata: true,
170            hardware: false,
171        }
172    }
173}
174
175/// Video encoding parameters.
176#[derive(Debug, Clone, PartialEq)]
177pub struct VideoSpec {
178    pub codec: VideoCodec,
179    /// Target average bitrate (bits/s) for two-pass; `None` in CRF/quality mode.
180    pub bitrate_bps: Option<u64>,
181    /// CRF value for quality mode; `None` in bitrate mode.
182    pub crf: Option<u8>,
183    /// Downscale target of the *short* side ("1080p" = 1080 px on the short
184    /// side, like phones and sites mean it); `None` keeps the source resolution.
185    pub height: Option<u32>,
186    /// The video is shown taller than wide: the short side is its width.
187    pub portrait: bool,
188    /// Frame-rate cap; `None` keeps the source rate.
189    pub fps: Option<u32>,
190    pub preset: QualityPreset,
191    /// Encode with Apple's hardware encoder: `crf` then carries its `-q:v`
192    /// quality (1–100, higher = better) and two-pass doesn't apply.
193    pub hardware: bool,
194    /// Tone-map this HDR source to 8-bit SDR (BT.709): set for size targets /
195    /// platform presets, where the file has to play everywhere — 10-bit H.264
196    /// (High 10) and HDR aren't decoded by most phones and browsers.
197    pub to_sdr: Option<Hdr>,
198}
199
200/// Audio encoding parameters (absent means drop the track).
201#[derive(Debug, Clone, PartialEq)]
202pub struct AudioSpec {
203    pub codec: AudioCodec,
204    pub bitrate_bps: u64,
205    /// Downmix to a single channel.
206    pub mono: bool,
207    /// Resample to this rate (Hz); `None` keeps the source rate.
208    pub sample_rate: Option<u32>,
209    /// Prefer VBR where the codec supports it.
210    pub vbr: bool,
211}
212
213impl AudioSpec {
214    /// A plain CBR/ABR track (used for the audio track inside a video).
215    pub fn cbr(codec: AudioCodec, bitrate_bps: u64) -> Self {
216        Self {
217            codec,
218            bitrate_bps,
219            mono: false,
220            sample_rate: None,
221            vbr: false,
222        }
223    }
224}
225
226/// The full encode recipe.
227#[derive(Debug, Clone, PartialEq)]
228pub struct EncodeSpec {
229    pub video: VideoSpec,
230    /// `None` means `-an` (no audio).
231    pub audio: Option<AudioSpec>,
232    pub faststart: bool,
233    pub two_pass: bool,
234    /// Stream-copy remux only: the source already fits, so never re-encode
235    /// (and never inflate). `video`/`audio` are ignored when set.
236    pub passthrough: bool,
237    /// Pure-audio encode: emit `-vn` and use `audio` only (`video` is ignored).
238    pub audio_only: bool,
239    /// Target DPI for a document's embedded images (see [`ShrinkOpts::dpi`]).
240    /// Document engines only; ignored by the media engine.
241    pub dpi: Option<u32>,
242    /// Copy metadata + mtime from the source (see [`ShrinkOpts::keep_metadata`]).
243    pub keep_metadata: bool,
244    /// Explicit container tags written to the output (`creation_time`,
245    /// `location`, `make`, `model`, `date`) — the capture metadata re-stated
246    /// in the form Apple's readers (Photos, Finder) actually understand.
247    pub tags: Vec<(String, String)>,
248}
249
250/// The encode plan — the result of [`Engine::plan`]. Usable for `--dry-run`.
251#[derive(Debug, Clone, PartialEq)]
252pub struct EncodePlan {
253    pub input: PathBuf,
254    pub output: PathBuf,
255    /// Human-readable description of the plan (codec, bitrates, passes).
256    pub summary: String,
257    /// Expected final size in bytes, if predictable.
258    pub expected_bytes: Option<u64>,
259    /// The hard size cap, if any (drives the post-encode correction retry).
260    pub target_bytes: Option<u64>,
261    /// Target VMAF for a CRF search, if requested (quality mode only).
262    pub target_vmaf: Option<f64>,
263    /// Source duration in seconds (for progress reporting).
264    pub source_duration_sec: f64,
265    /// Source resolution and frame rate — the reference for VMAF measurement.
266    pub source_width: Option<u32>,
267    pub source_height: Option<u32>,
268    pub source_fps: Option<f64>,
269    pub spec: EncodeSpec,
270    /// Keep the original instead of producing a result that isn't smaller
271    /// (see [`ShrinkOpts::allow_larger`]). Engines without a guard set `false`.
272    pub guard_larger: bool,
273    /// Size targets only: the quality preset's CRF, used as a ceiling — when a
274    /// CRF encode is predicted to land well under the target, it's used instead
275    /// of spending the whole budget (a 2 s clip needn't take 10 MB for Discord).
276    pub ceiling_crf: Option<u8>,
277}
278
279/// The execution result — the result of [`Engine::run`].
280#[derive(Debug, Clone, PartialEq)]
281pub struct Outcome {
282    pub output: PathBuf,
283    pub final_bytes: u64,
284    /// Measured VMAF of the result vs the source, if a measurement was taken.
285    pub vmaf: Option<f64>,
286    /// The output is the source copied as-is (stream copy): it already fit the
287    /// target, or re-encoding it would not have made it smaller.
288    pub already_compact: bool,
289}
290
291/// Engine errors.
292#[derive(Debug, Error)]
293pub enum EngineError {
294    #[error("input not supported by this engine: {0}")]
295    Unsupported(String),
296    #[error("cannot reach target size at a reasonable quality")]
297    Infeasible,
298    #[error("not yet implemented: {0}")]
299    NotImplemented(&'static str),
300    #[cfg(feature = "ffmpeg")]
301    #[error(transparent)]
302    Ffmpeg(#[from] deepshrink_ffmpeg::FfmpegError),
303    #[error(transparent)]
304    Io(#[from] std::io::Error),
305}
306
307/// A quality-mode re-encode has to save at least this share of the source to
308/// be worth it; below that the original is kept. A minutes-long 4K encode for
309/// a 1 % saving isn't a compression — just a generation of quality loss.
310pub const MIN_SAVING: f64 = 0.05;
311
312/// Whether a result of `expected` bytes isn't worth producing for a `source`
313/// of `source` bytes (saves less than [`MIN_SAVING`]). The guard, dry runs and
314/// UI previews all ask this, so a preview and the run agree.
315pub fn not_worth_it(expected: u64, source: u64) -> bool {
316    source > 0 && expected as f64 >= source as f64 * (1.0 - MIN_SAVING)
317}
318
319impl EngineError {
320    /// The run was stopped through its cancel token (see
321    /// `MediaEngine::with_cancel`) — not a failure to report.
322    pub fn is_cancelled(&self) -> bool {
323        #[cfg(feature = "ffmpeg")]
324        return matches!(
325            self,
326            Self::Ffmpeg(deepshrink_ffmpeg::FfmpegError::Cancelled)
327        );
328        #[cfg(not(feature = "ffmpeg"))]
329        false
330    }
331}
332
333/// The compression engine contract. The one v0.1 implementation is `media::MediaEngine`.
334pub trait Engine {
335    /// Whether this engine handles the given file.
336    fn supports(&self, input: &Path) -> bool;
337    /// Read metadata (side effect: run a probe, e.g. ffprobe).
338    fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError>;
339    /// Build the encode plan. Pure function — tested without encoding.
340    fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError>;
341    /// Execute the plan (side effect: run the encoder binary).
342    fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError>;
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn a_re_encode_must_save_at_least_five_percent() {
351        assert!(not_worth_it(100, 100));
352        assert!(not_worth_it(120, 100));
353        assert!(not_worth_it(96, 100)); // −4 %: keep the original
354        assert!(!not_worth_it(95, 1_000)); // −90 %
355        assert!(!not_worth_it(949, 1_000)); // −5.1 %
356        assert!(!not_worth_it(0, 0)); // unknown source: no call
357    }
358}