otr_utils/cutting/
mod.rs

1// SPDX-FileCopyrightText: 2024 Michael Picht <mipi@fsfe.org>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5mod cutlist;
6mod ffmpeg;
7mod info;
8mod interval;
9
10use anyhow::{anyhow, Context};
11use log::*;
12use std::{
13    error::Error,
14    fmt::{self, Debug, Display},
15    path::Path,
16    str,
17};
18use which::which;
19
20use super::dirs::tmp_dir;
21use cutlist::Cutlist;
22use info::Metadata;
23
24pub use cutlist::{
25    AccessType as CutlistAccessType, Ctrl as CutlistCtrl, Rating as CutlistRating, ID as CutlistID,
26};
27
28/// Special error type for cutting videos to be able to handle specific
29/// situations - e.g., if no cut list exists
30#[derive(Debug, Default)]
31pub enum CutError {
32    Any(anyhow::Error),
33    #[default]
34    Default,
35    NoCutlist,
36    CutlistSubmissionFailed(anyhow::Error),
37}
38/// Support the use of "{}" format specifier
39impl fmt::Display for CutError {
40    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
41        match *self {
42            CutError::Any(ref source) => write!(f, "Error: {}", source),
43            CutError::Default => write!(f, "Default cut error"),
44            CutError::NoCutlist => write!(f, "No cut list exists"),
45            CutError::CutlistSubmissionFailed(ref source) => {
46                write!(f, "Submission of cut list to cutlist.at failed: {}", source)
47            }
48        }
49    }
50}
51/// Support conversion of Error into CutError
52impl Error for CutError {}
53/// Support conversion of anyhow::Error into CutError
54impl From<anyhow::Error> for CutError {
55    fn from(err: anyhow::Error) -> CutError {
56        CutError::Any(err)
57    }
58}
59
60/// Cut a decoded video file.
61/// - in_video is the path of the decoded video file. out_video is the path of the
62///   to-be-cut video file
63/// - out_video is the path of resulting file
64/// - tmp_dir is the directory where OTR stores the cut list (provided a cut list
65///   file is genererated and uploaded to cutlist.at) and other temporary data
66/// - cutlist_ctrl contains attributes to control handling of cut lists, such as
67///   - access_type: specifies how to (try to) get an appropriate cut list
68///   - min_rating: specifies the minimum rating a cut list must have when
69///     automatically selected from the cut list provider
70///   - submit: whether cut list shall shall be uploaded to cutlist.at. In this
71///     case an access token is required. Submitting cut lists does only make
72///     sense if a video is cut based on intervals
73///   - rating: rating of the to-be-uploaded cut lists (overwriting the default
74///     which is defined in the configuration file)
75pub fn cut<P, Q>(in_video: P, out_video: Q, cutlist_ctrl: &CutlistCtrl) -> Result<(), CutError>
76where
77    P: AsRef<Path>,
78    Q: AsRef<Path>,
79{
80    // Check if required command line tools are installed
81    check_dependencies()?;
82
83    let tmp_dir = tmp_dir()?;
84
85    // Call specialized cut functions based on the cut list access type that was
86    // submitted
87    match cutlist_ctrl.access_type {
88        cutlist::AccessType::Direct(intervals) => cut_with_cutlist_from_intervals(
89            in_video,
90            out_video,
91            tmp_dir,
92            intervals,
93            cutlist_ctrl.submit,
94            cutlist_ctrl.access_token,
95            cutlist_ctrl.rating,
96        ),
97        cutlist::AccessType::File(file) => {
98            cut_with_cutlist_from_file(in_video, out_video, tmp_dir, file)
99        }
100        cutlist::AccessType::ID(id) => {
101            cut_with_cutlist_from_provider_by_id(in_video, out_video, tmp_dir, id)
102        }
103        _ => cut_with_cutlist_from_provider_auto_select(
104            in_video,
105            out_video,
106            tmp_dir,
107            cutlist_ctrl.min_rating,
108        ),
109    }
110}
111
112/// Checks if required command line tools are installed. I.e., it is checked if
113/// they can be called. If that is not possible, it could be that it is either
114/// not installed or not in the path
115fn check_dependencies() -> anyhow::Result<()> {
116    // Check if ffmpeg can be called
117    if which("ffmpeg").is_err() {
118        return Err(anyhow!("ffmpeg must be installed and in the path"));
119    }
120
121    // Check if ffmpeg can be called
122    if which("ffmsindex").is_err() {
123        return Err(anyhow!("ffmsindex must be installed and in the path"));
124    }
125
126    Ok(())
127}
128
129/// Cut a video with a cut list read from an INI file. in_video is the path of the
130/// decoded video file. out_video is the path of the cut video file.
131fn cut_with_cutlist_from_file<P, Q, R, T>(
132    in_video: P,
133    out_video: Q,
134    tmp_dir: T,
135    cutlist_path: R,
136) -> Result<(), CutError>
137where
138    P: AsRef<Path>,
139    Q: AsRef<Path>,
140    R: AsRef<Path>,
141    T: AsRef<Path>,
142{
143    trace!(
144        "Cutting \"{}\" with cut list from \"{}\"",
145        in_video.as_ref().display(),
146        cutlist_path.as_ref().display()
147    );
148
149    let cutlist = Cutlist::try_from(cutlist_path.as_ref())?;
150
151    match cut_with_cutlist(in_video, out_video, tmp_dir, &cutlist).context(format!(
152        "Could not cut video with cut list from \"{}\"",
153        cutlist_path.as_ref().display()
154    )) {
155        Err(err) => Err(CutError::Any(err)),
156        _ => Ok(()),
157    }
158}
159
160/// Cut a video with a cut list derived from an intervals string. in_video is the
161/// path of the decoded video file. out_video is the path of the cut video file.
162/// submit_cutlists defines whether cut lists are submitted to cutlist.at. In
163/// this case an access token is required
164fn cut_with_cutlist_from_intervals<P, Q, T, I>(
165    in_video: P,
166    out_video: Q,
167    tmp_dir: T,
168    intervals: I,
169    submit_cutlists: bool,
170    cutlist_at_access_token: Option<&str>,
171    rating: CutlistRating,
172) -> Result<(), CutError>
173where
174    P: AsRef<Path>,
175    Q: AsRef<Path>,
176    T: AsRef<Path>,
177    I: AsRef<str> + Display,
178{
179    trace!("Cutting \"{}\" with intervals", in_video.as_ref().display());
180
181    let mut cutlist = Cutlist::try_from_intervals(intervals.as_ref())?;
182
183    if let Err(err) = cut_with_cutlist(
184        in_video.as_ref(),
185        out_video.as_ref(),
186        tmp_dir.as_ref(),
187        &cutlist,
188    ) {
189        return Err(CutError::Any(
190            err.context(format!("Could not cut video with {}", intervals)),
191        ));
192    }
193
194    // Submit cut list to cutlist.at (if that is wanted)
195    if submit_cutlists {
196        return match cutlist_at_access_token {
197            // Access token for cutlist.at is required
198            Some(access_token) => {
199                if let Err(err) = cutlist.submit(in_video, tmp_dir, access_token, rating) {
200                    Err(CutError::CutlistSubmissionFailed(err))
201                } else {
202                    Ok(())
203                }
204            }
205            None => Err(CutError::CutlistSubmissionFailed(anyhow!(
206                "No access token for cutlist.at maintained in configuration file"
207            ))),
208        };
209    }
210
211    Ok(())
212}
213
214/// Cut a video with a cut list retrieved from a provider by cut list id. in_video
215/// is the path of the decoded video file. out_video is the path of the cut video
216/// file.
217fn cut_with_cutlist_from_provider_by_id<P, Q, T>(
218    in_video: P,
219    out_video: Q,
220    tmp_dir: T,
221    id: u64,
222) -> Result<(), CutError>
223where
224    P: AsRef<Path>,
225    Q: AsRef<Path>,
226    T: AsRef<Path>,
227{
228    trace!(
229        "Cutting \"{}\" with cut list id {} from provider",
230        in_video.as_ref().display(),
231        id
232    );
233
234    // Retrieve cut lists from provider and cut video
235    match Cutlist::try_from(id) {
236        Ok(cutlist) => match cut_with_cutlist(in_video, out_video, tmp_dir, &cutlist) {
237            Ok(_) => Ok(()),
238            Err(err) => Err(CutError::Any(
239                anyhow!(err).context(format!("Could not cut video with cut list {}", id)),
240            )),
241        },
242        Err(err) => Err(CutError::Any(
243            anyhow!(err).context(format!("Could not retrieve cut list ID={}", id)),
244        )),
245    }
246}
247
248/// Cut a video with a cut list retrieved from a provider by video file name and
249/// selected automatically.
250/// in_video is the path of the decoded video file.  out_video is the path of the
251/// cut video file. min_cutlist_rating specifies the minimum rating a cut list
252/// must have to be accepted
253fn cut_with_cutlist_from_provider_auto_select<P, Q, T>(
254    in_video: P,
255    out_video: Q,
256    tmp_dir: T,
257    min_cutlist_rating: Option<CutlistRating>,
258) -> Result<(), CutError>
259where
260    P: AsRef<Path>,
261    Q: AsRef<Path>,
262    T: AsRef<Path>,
263{
264    let file_name = in_video.as_ref().file_name().unwrap().to_str().unwrap();
265
266    // Retrieve cut list headers from provider
267    let headers: Vec<cutlist::ProviderHeader> =
268        match cutlist::headers_from_provider(file_name, min_cutlist_rating)
269            .context("Could not retrieve cut lists")
270        {
271            Ok(hdrs) => hdrs,
272            _ => return Err(CutError::NoCutlist),
273        };
274
275    // Retrieve cut lists from provider and cut video
276    let mut is_cut = false;
277    for header in headers {
278        match Cutlist::try_from(header.id()) {
279            Ok(cutlist) => {
280                match cut_with_cutlist(
281                    in_video.as_ref(),
282                    out_video.as_ref(),
283                    tmp_dir.as_ref(),
284                    &cutlist,
285                ) {
286                    Ok(_) => {
287                        is_cut = true;
288                        break;
289                    }
290                    Err(err) => {
291                        error!(
292                            "{:?}",
293                            anyhow!(err).context(format!(
294                                "Could not cut video with cut list ID={}",
295                                header.id()
296                            ))
297                        );
298                    }
299                }
300            }
301            Err(err) => {
302                error!(
303                    "{:?}",
304                    anyhow!(err)
305                        .context(format!("Could not retrieve cut list ID={}", header.id(),))
306                );
307            }
308        }
309    }
310
311    if !is_cut {
312        return Err(CutError::Any(anyhow!(
313            "No cut list could be successfully applied to cut video"
314        )));
315    }
316
317    Ok(())
318}
319
320/// Cut a video file stored in in_video with ffmpeg using the given cut list.
321/// The cut video is stored in out_video.
322fn cut_with_cutlist<I, O, T>(
323    in_video: I,
324    out_video: O,
325    tmp_dir: T,
326    cutlist: &Cutlist,
327) -> anyhow::Result<()>
328where
329    I: AsRef<Path>,
330    O: AsRef<Path>,
331    T: AsRef<Path>,
332{
333    trace!("Cutting video with ffmpeg ...");
334
335    // Retrieve metadata of video to be cut
336    let metadata = Metadata::new(&in_video)?;
337
338    // Try all available kinds (frame numbers, time). After the cutting was
339    // successful for one of them, exit:
340
341    // (1) Try cutting with frame intervals
342    if cutlist.has_frame_intervals() {
343        if !metadata.has_frames() {
344            trace!("Since video has no frames, frame-based cut intervals cannot be used");
345        } else if let Err(err) = ffmpeg::cut(
346            &in_video,
347            &out_video,
348            &tmp_dir,
349            cutlist.frame_intervals()?,
350            &metadata,
351        ) {
352            warn!(
353                "Could not cut \"{}\" with frame intervals: {:?}",
354                in_video.as_ref().display(),
355                err
356            );
357        } else {
358            trace!("Cut video with ffmpeg");
359
360            return Ok(());
361        }
362    }
363
364    // (2) Try cutting with time intervals
365    if cutlist.has_time_intervals() {
366        if let Err(err) = ffmpeg::cut(
367            &in_video,
368            &out_video,
369            &tmp_dir,
370            cutlist.time_intervals()?,
371            &metadata,
372        ) {
373            warn!(
374                "Could not cut \"{}\" with time intervals: {:?}",
375                in_video.as_ref().display(),
376                err
377            );
378        } else {
379            trace!("Cut video with ffmpeg");
380            return Ok(());
381        }
382    }
383
384    Err(anyhow!("Could not cut video with ffmpeg"))
385}