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 /// Encode video with Apple's hardware encoder (VideoToolbox) when this Mac
143 /// has it — ~3–5× faster and a fraction of the memory, for a larger file
144 /// (see [`QualityPreset::default_hw_quality`]). Off by default; ignored for
145 /// AV1 and where [`media::hardware_encoding_available`] is false.
146 pub hardware: bool,
147}
148
149impl Default for ShrinkOpts {
150 fn default() -> Self {
151 Self {
152 goal: SizeGoal::Quality,
153 video_codec: VideoCodec::H264,
154 audio: AudioChoice::Keep,
155 resolution: ResolutionOpt::Auto,
156 fps: FpsOpt::Auto,
157 quality: QualityPreset::Balanced,
158 audio_codec: AudioCodec::Aac,
159 mono: false,
160 sample_rate: None,
161 vbr: false,
162 target_vmaf: None,
163 output: None,
164 two_pass: None,
165 dpi: None,
166 allow_larger: false,
167 keep_metadata: true,
168 hardware: false,
169 }
170 }
171}
172
173/// Video encoding parameters.
174#[derive(Debug, Clone, PartialEq)]
175pub struct VideoSpec {
176 pub codec: VideoCodec,
177 /// Target average bitrate (bits/s) for two-pass; `None` in CRF/quality mode.
178 pub bitrate_bps: Option<u64>,
179 /// CRF value for quality mode; `None` in bitrate mode.
180 pub crf: Option<u8>,
181 /// Downscale target of the *short* side ("1080p" = 1080 px on the short
182 /// side, like phones and sites mean it); `None` keeps the source resolution.
183 pub height: Option<u32>,
184 /// The video is shown taller than wide: the short side is its width.
185 pub portrait: bool,
186 /// Frame-rate cap; `None` keeps the source rate.
187 pub fps: Option<u32>,
188 pub preset: QualityPreset,
189 /// Encode with Apple's hardware encoder: `crf` then carries its `-q:v`
190 /// quality (1–100, higher = better) and two-pass doesn't apply.
191 pub hardware: bool,
192 /// Tone-map this HDR source to 8-bit SDR (BT.709): set for size targets /
193 /// platform presets, where the file has to play everywhere — 10-bit H.264
194 /// (High 10) and HDR aren't decoded by most phones and browsers.
195 pub to_sdr: Option<Hdr>,
196}
197
198/// Audio encoding parameters (absent means drop the track).
199#[derive(Debug, Clone, PartialEq)]
200pub struct AudioSpec {
201 pub codec: AudioCodec,
202 pub bitrate_bps: u64,
203 /// Downmix to a single channel.
204 pub mono: bool,
205 /// Resample to this rate (Hz); `None` keeps the source rate.
206 pub sample_rate: Option<u32>,
207 /// Prefer VBR where the codec supports it.
208 pub vbr: bool,
209}
210
211impl AudioSpec {
212 /// A plain CBR/ABR track (used for the audio track inside a video).
213 pub fn cbr(codec: AudioCodec, bitrate_bps: u64) -> Self {
214 Self {
215 codec,
216 bitrate_bps,
217 mono: false,
218 sample_rate: None,
219 vbr: false,
220 }
221 }
222}
223
224/// The full encode recipe.
225#[derive(Debug, Clone, PartialEq)]
226pub struct EncodeSpec {
227 pub video: VideoSpec,
228 /// `None` means `-an` (no audio).
229 pub audio: Option<AudioSpec>,
230 pub faststart: bool,
231 pub two_pass: bool,
232 /// Stream-copy remux only: the source already fits, so never re-encode
233 /// (and never inflate). `video`/`audio` are ignored when set.
234 pub passthrough: bool,
235 /// Pure-audio encode: emit `-vn` and use `audio` only (`video` is ignored).
236 pub audio_only: bool,
237 /// Target DPI for a document's embedded images (see [`ShrinkOpts::dpi`]).
238 /// Document engines only; ignored by the media engine.
239 pub dpi: Option<u32>,
240 /// Copy metadata + mtime from the source (see [`ShrinkOpts::keep_metadata`]).
241 pub keep_metadata: bool,
242 /// Explicit container tags written to the output (`creation_time`,
243 /// `location`, `make`, `model`, `date`) — the capture metadata re-stated
244 /// in the form Apple's readers (Photos, Finder) actually understand.
245 pub tags: Vec<(String, String)>,
246}
247
248/// The encode plan — the result of [`Engine::plan`]. Usable for `--dry-run`.
249#[derive(Debug, Clone, PartialEq)]
250pub struct EncodePlan {
251 pub input: PathBuf,
252 pub output: PathBuf,
253 /// Human-readable description of the plan (codec, bitrates, passes).
254 pub summary: String,
255 /// Expected final size in bytes, if predictable.
256 pub expected_bytes: Option<u64>,
257 /// The hard size cap, if any (drives the post-encode correction retry).
258 pub target_bytes: Option<u64>,
259 /// Target VMAF for a CRF search, if requested (quality mode only).
260 pub target_vmaf: Option<f64>,
261 /// Source duration in seconds (for progress reporting).
262 pub source_duration_sec: f64,
263 /// Source resolution and frame rate — the reference for VMAF measurement.
264 pub source_width: Option<u32>,
265 pub source_height: Option<u32>,
266 pub source_fps: Option<f64>,
267 pub spec: EncodeSpec,
268 /// Keep the original instead of producing a result that isn't smaller
269 /// (see [`ShrinkOpts::allow_larger`]). Engines without a guard set `false`.
270 pub guard_larger: bool,
271 /// Size targets only: the quality preset's CRF, used as a ceiling — when a
272 /// CRF encode is predicted to land well under the target, it's used instead
273 /// of spending the whole budget (a 2 s clip needn't take 10 MB for Discord).
274 pub ceiling_crf: Option<u8>,
275}
276
277/// The execution result — the result of [`Engine::run`].
278#[derive(Debug, Clone, PartialEq)]
279pub struct Outcome {
280 pub output: PathBuf,
281 pub final_bytes: u64,
282 /// Measured VMAF of the result vs the source, if a measurement was taken.
283 pub vmaf: Option<f64>,
284 /// The output is the source copied as-is (stream copy): it already fit the
285 /// target, or re-encoding it would not have made it smaller.
286 pub already_compact: bool,
287}
288
289/// Engine errors.
290#[derive(Debug, Error)]
291pub enum EngineError {
292 #[error("input not supported by this engine: {0}")]
293 Unsupported(String),
294 #[error("cannot reach target size at a reasonable quality")]
295 Infeasible,
296 #[error("not yet implemented: {0}")]
297 NotImplemented(&'static str),
298 #[error(transparent)]
299 Ffmpeg(#[from] deepshrink_ffmpeg::FfmpegError),
300 #[error(transparent)]
301 Io(#[from] std::io::Error),
302}
303
304/// A quality-mode re-encode has to save at least this share of the source to
305/// be worth it; below that the original is kept. A minutes-long 4K encode for
306/// a 1 % saving isn't a compression — just a generation of quality loss.
307pub const MIN_SAVING: f64 = 0.05;
308
309/// Whether a result of `expected` bytes isn't worth producing for a `source`
310/// of `source` bytes (saves less than [`MIN_SAVING`]). The guard, dry runs and
311/// UI previews all ask this, so a preview and the run agree.
312pub fn not_worth_it(expected: u64, source: u64) -> bool {
313 source > 0 && expected as f64 >= source as f64 * (1.0 - MIN_SAVING)
314}
315
316impl EngineError {
317 /// The run was stopped through its cancel token (see
318 /// [`media::MediaEngine::with_cancel`]) — not a failure to report.
319 pub fn is_cancelled(&self) -> bool {
320 matches!(
321 self,
322 Self::Ffmpeg(deepshrink_ffmpeg::FfmpegError::Cancelled)
323 )
324 }
325}
326
327/// The compression engine contract. The one v0.1 implementation is [`media::MediaEngine`].
328pub trait Engine {
329 /// Whether this engine handles the given file.
330 fn supports(&self, input: &Path) -> bool;
331 /// Read metadata (side effect: run a probe, e.g. ffprobe).
332 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError>;
333 /// Build the encode plan. Pure function — tested without encoding.
334 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError>;
335 /// Execute the plan (side effect: run the encoder binary).
336 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError>;
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn a_re_encode_must_save_at_least_five_percent() {
345 assert!(not_worth_it(100, 100));
346 assert!(not_worth_it(120, 100));
347 assert!(not_worth_it(96, 100)); // −4 %: keep the original
348 assert!(!not_worth_it(95, 1_000)); // −90 %
349 assert!(!not_worth_it(949, 1_000)); // −5.1 %
350 assert!(!not_worth_it(0, 0)); // unknown source: no call
351 }
352}