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}