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