Nothing
# ffm_files() ------------------------------------------------------------------
#' Specify Files in an FFmpeg Pipeline
#'
#' Start an FFmpeg pipeline by specifying input and output files.
#'
#' @param input A character vector of paths to the input media files of the
#' pipeline. Give more than one path for stacking.
#' @param output A string with the path of the output media file of the
#' pipeline.
#' @param overwrite A logical. If \code{TRUE} (the default), an output media
#' file that already exists is overwritten.
#' @return An FFmpeg pipeline object.
#' @seealso [ffm_compile()] to build the command and [ffm_run()] to run it. The
#' task functions, such as [standardize_video()] and [segment_video()], are
#' built on the pipeline functions.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_compile()
#' @export
ffm_files <- function(input, output, overwrite = TRUE) {
if (!rlang::is_character(input) || length(input) == 0) {
cli::cli_abort(
"{.arg input} must be a character vector naming at least one input file."
)
}
rlang::check_string(output)
rlang::check_bool(overwrite)
# The pipeline's own input refusal, reaching the same site every verb's front
# door reaches (M63). Its predicate WAS this function's, tested here and
# nowhere else, so a front door refusing existence and a pipeline refusing
# readability disagreed on a file that is there but cannot be opened. One site
# now holds the predicate and the wording, so the two cannot disagree.
# `multiple = TRUE` follows the ARGUMENT's contract -- `input` admits several
# paths for stacking -- not the count this call happened to pass.
check_paths_readable(input, arg = "input", multiple = TRUE)
new_ffm(
input = input,
output = output,
overwrite = overwrite,
drop = vector("character", 0),
codec_video = vector("character", 0),
codec_audio = vector("character", 0),
pixel_format = vector("character", 0),
filter_video = vector("character", 0),
filter_audio = vector("character", 0),
map = vector("character", 0),
complex = FALSE
)
}
# ffm_trim() --------------------------------------------------------------
#' Trim the Duration of the FFmpeg Pipeline
#'
#' Trim the input so that the output keeps one continuous part of the input. If
#' \code{start} is \code{NULL}, the kept section starts at the beginning of the
#' input. If both \code{end} and \code{duration} are \code{NULL}, the kept
#' section ends at the end of the input. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as stream copy.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param start The time of the start of the kept section, given in
#' \code{units}. The frame at this time is the first frame of the output.
#' @param end The time of the first frame that is dropped, given in
#' \code{units}. The frame just before it is the last frame of the output.
#' @param duration The maximum duration of the output, given in time duration
#' syntax.
#' @param units A string that says how \code{start} and \code{end} are given:
#' time duration syntax (\code{"tds"}), timebase units (\code{"pts"}) or frame
#' numbers (\code{"frame"}). The default is \code{"tds"}.
#' @param setpts A logical that says whether the output timestamps change to
#' start at zero. If \code{TRUE}, a \code{setpts} filter is added after the
#' trim.
#' @return \code{object} with added instructions to trim the duration.
#' @seealso [ffm_seek()], the faster cut by seeking, which can use stream copy.
#' \code{ffm_trim()} is the filter that cuts on exact frames.
#' @references https://ffmpeg.org/ffmpeg-filters.html#trim
#' @references https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_trim(start = 1, end = 5) |>
#' ffm_compile()
#' @export
ffm_trim <- function(object,
start = NULL,
end = NULL,
duration = NULL,
units = c("tds", "pts", "frame"),
setpts = TRUE) {
# Validate arguments
check_ffm(object)
if (!is.null(start) && length(start) != 1) {
cli::cli_abort("{.arg start} must be a single value or {.code NULL}.")
}
if (!is.null(end) && length(end) != 1) {
cli::cli_abort("{.arg end} must be a single value or {.code NULL}.")
}
if (!is.null(duration) && length(duration) != 1) {
cli::cli_abort("{.arg duration} must be a single value or {.code NULL}.")
}
units <- rlang::arg_match(units)
rlang::check_bool(setpts)
# select arguments based on units
if (units == "tds") {
s_arg <- "start"
e_arg <- "end"
} else if (units == "pts") {
s_arg <- "start_pts"
e_arg <- "end_pts"
} else if (units == "frame") {
s_arg <- "start_frame"
e_arg <- "end_frame"
}
# create filter command
trim_args <- c(
glue('{s_arg}={start}'),
glue('{e_arg}={end}'),
glue('duration={duration}')
)
cmd <- paste0("trim=", paste(trim_args, collapse = ":"))
# append filter command
object$filter_video <- c(object$filter_video, cmd)
# add setpts only when requested (resets output timestamps to start at zero)
if (setpts) {
object$filter_video <- c(object$filter_video, "setpts=PTS-STARTPTS")
}
object
}
# ffm_seek() -------------------------------------------------------------------
#' Cut a Continuous Section from an FFmpeg Pipeline by Seeking
#'
#' Keep one continuous section of the input with FFmpeg's fast \code{-ss} and
#' \code{-to} seek options. It does not use the \code{trim} *filter* of
#' \code{\link{ffm_trim}}. Unlike the filter, seeking can use stream copy, so it
#' is the tool for fast, lossless cuts. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as keyframe,
#' re-encode and stream copy.
#'
#' The \code{reencode} argument trades accuracy against speed:
#' \itemize{
#' \item \code{reencode = TRUE} (the default) is \strong{frame-accurate}. The
#' section is re-encoded, so it starts and ends on the exact frames you ask
#' for. This is the safe default.
#' \item \code{reencode = FALSE} is a \strong{fast, lossless copy}. But the
#' cut points move to the nearest keyframes. So the output duration can
#' differ from the request by up to the gap between two keyframes. Use it
#' with \code{\link{ffm_copy}} for the fastest path.
#' }
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param start The start of the kept section, in seconds or in FFmpeg time
#' duration syntax. \code{NULL} keeps the section from the beginning.
#' @param end The end of the kept section, in seconds or in FFmpeg time duration
#' syntax. \code{NULL} keeps the section to the end.
#' @param reencode A logical. \code{TRUE} (the default) re-encodes for a
#' frame-accurate cut. \code{FALSE} makes a fast seek that is safe to copy,
#' and its cut points move to keyframes.
#' @return \code{object} with an added instruction to cut the input by seeking.
#' @seealso [ffm_trim()] for the filter that cuts, [ffm_copy()] for the fast
#' copy path, and [segment_video()], the task function built on it.
#' @references https://ffmpeg.org/ffmpeg.html#Main-options
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Fast, lossless copy cut (snaps to keyframes)
#' ffm_files(video, "output.mp4") |>
#' ffm_seek(start = 1, end = 5, reencode = FALSE) |>
#' ffm_copy() |>
#' ffm_compile()
#' @export
ffm_seek <- function(object, start = NULL, end = NULL, reencode = TRUE) {
check_ffm(object)
if (!is.null(start) && length(start) != 1) {
cli::cli_abort("{.arg start} must be a single value or {.code NULL}.")
}
if (!is.null(end) && length(end) != 1) {
cli::cli_abort("{.arg end} must be a single value or {.code NULL}.")
}
if (is.null(start) && is.null(end)) {
cli::cli_abort("Provide at least one of {.arg start} or {.arg end}.")
}
rlang::check_bool(reencode)
if (!is.null(start)) object$seek_start <- as.character(start)
if (!is.null(end)) object$seek_end <- as.character(end)
object$seek_reencode <- reencode
object
}
# ffm_drop() -------------------------------------------------------------------
#' Drop Streams from an FFmpeg Pipeline
#'
#' Remove one or more streams from the media file. For example, remove the
#' video, audio, subtitles or data stream from a media file. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as stream.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param streams A character vector with one or more of these strings:
#' \code{"video"}, \code{"audio"}, \code{"subtitles"} and \code{"data"}.
#' @return \code{object} with an added instruction to drop these streams from
#' the output file when the pipeline runs.
#' @seealso [extract_audio()], the task function that uses \code{ffm_drop()} to
#' drop the video stream.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Drop the audio stream (keep video only)
#' ffm_files(video, "output.mp4") |>
#' ffm_drop(streams = "audio") |>
#' ffm_compile()
#' @export
ffm_drop <- function(object,
streams = c("video", "audio", "subtitles", "data")) {
check_ffm(object)
streams <- rlang::arg_match(streams, multiple = TRUE)
# Store the stream names; ffm_compile() renders the -vn/-an/-sn/-dn output
# options in the correct position (after -i, before the output file).
object$drop <- streams
object
}
# ffm_crop() -------------------------------------------------------------------
#' Crop Frames in an FFmpeg Pipeline
#'
#' Make the video's frames smaller by cropping them.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param width The width of the output video, in pixels. Give a positive real
#' number or a string that contains an FFmpeg expression.
#' @param height The height of the output video, in pixels. Give a positive
#' real number or a string that contains an FFmpeg expression.
#' @param x The horizontal position of the left edge of the output video, in
#' pixels of the input video. Give a positive real number or a string that
#' contains an FFmpeg expression. The default is \code{"(in_w-out_w)/2"}.
#' @param y The vertical position of the top edge of the output video, in pixels
#' of the input video. Give a positive real number or a string that contains
#' an FFmpeg expression. The default is \code{"(in_h-out_h)/2"}.
#' @return \code{object} with an added instruction to crop the frames.
#' @seealso [ffm_scale()] to resize instead of crop. [crop_video()] and
#' [format_for_web()] are the task functions built on \code{ffm_crop()}.
#' @references https://ffmpeg.org/ffmpeg-filters.html#crop
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Crop to a centered 160x120 region
#' ffm_files(video, "output.mp4") |>
#' ffm_crop(width = 160, height = 120) |>
#' ffm_compile()
#' @export
ffm_crop <- function(object,
width,
height,
x = "(in_w-out_w)/2",
y = "(in_h-out_h)/2") {
check_ffm(object)
check_dim(width)
check_dim(height)
check_dim(x, inclusive = TRUE)
check_dim(y, inclusive = TRUE)
cmd <- glue('crop=w={width}:h={height}:x={x}:y={y}')
object$filter_video <- c(object$filter_video, cmd)
object
}
# ffm_scale() ------------------------------------------------------------------
#' Scale (Resize) Frames in an FFmpeg Pipeline
#'
#' Scale (resize) the input video's frames to a width and height in pixels, or
#' with an FFmpeg expression.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param width The width of the output video, in pixels. Give a positive real
#' number or a string that contains an FFmpeg expression.
#' @param height The height of the output video, in pixels. Give a positive
#' real number or a string that contains an FFmpeg expression.
#' @return \code{object} with an added instruction to resize the frames.
#' @seealso [ffm_crop()] to crop instead of resize. [standardize_video()] is the
#' task function built on \code{ffm_scale()}.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_scale(width = 160, height = 120) |>
#' ffm_compile()
#' @export
ffm_scale <- function(object, width, height) {
check_ffm(object)
check_dim(width)
check_dim(height)
cmd <- glue('scale=w={width}:h={height}')
object$filter_video <- c(object$filter_video, cmd)
object
}
# ffm_fps() --------------------------------------------------------------------
#' Set the Frame Rate in an FFmpeg Pipeline
#'
#' Resample the video to a constant frame rate with FFmpeg's \code{fps} filter.
#' The filter duplicates or drops frames as needed. It is added to the end of
#' the video filters, like the other filters that take one input. The glossary
#' in \code{vignette("tidymedia")} explains media terms such as frame rate.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param fps The target frame rate. Give a positive real number of frames per
#' second, or a string that contains an FFmpeg frame rate expression. For
#' example, \code{"30000/1001"} is the NTSC rate.
#' @return \code{object} with an added instruction to resample the frame rate.
#' @seealso [standardize_video()], the task function that uses \code{ffm_fps()}
#' to set the frame rate.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_fps(fps = 30) |>
#' ffm_compile()
#' @export
ffm_fps <- function(object, fps) {
check_ffm(object)
check_dim(fps)
cmd <- glue('fps={fps}')
object$filter_video <- c(object$filter_video, cmd)
object
}
# ffm_loudnorm() ---------------------------------------------------------------
#' Normalize Loudness in an FFmpeg Pipeline
#'
#' Add FFmpeg's \code{loudnorm} (EBU R128) audio filter. It normalizes the
#' input's perceived loudness toward a target integrated loudness, true-peak
#' ceiling and loudness range. The filter compiles to \code{-af}, or joins an
#' existing audio filter chain in the order the filters were added. The glossary
#' in \code{vignette("tidymedia")} explains media terms such as LUFS and true
#' peak.
#'
#' @details
#' This is single-pass (dynamic) \code{loudnorm}. The pipeline stays one
#' reproducible command, with no measurement pass. The defaults follow EBU
#' Recommendation R 128 (2014): \code{target_loudness = -23} LUFS and
#' \code{true_peak = -1} dBTP. Loudness is measured per ITU-R BS.1770-4. The
#' default
#' \code{loudness_range = 7} is FFmpeg's own \code{loudnorm} default. EBU
#' R128 does not prescribe a single value.
#'
#' Two filters are added, not one. \code{loudnorm} is followed by
#' \code{asetnsamples}, which regroups the filtered audio into frames of 4096
#' samples and does not pad the last one. Dynamic \code{loudnorm} resamples to
#' 192 kHz and gives frames of 192000 samples. Some encoders accept whatever
#' frame they are given, FLAC and Vorbis among them. Even those encoders refuse
#' to open at all on frames of 192000 samples.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param target_loudness The target integrated loudness, in LUFS
#' (`r loudnorm_bounds_rd("target_loudness")`). The default, \code{-23}, is
#' the EBU R128 target.
#' @param true_peak The maximum true peak, in dBTP
#' (`r loudnorm_bounds_rd("true_peak")`). The default, \code{-1}, is the EBU
#' R128 ceiling.
#' @param loudness_range The target loudness range, in LU
#' (`r loudnorm_bounds_rd("loudness_range")`). The default is \code{7}.
#' @param measured_i,measured_tp,measured_lra,measured_thresh Measured input
#' values from an earlier \code{loudnorm} analysis pass: integrated loudness,
#' true peak, loudness range and threshold. Give them together for an accurate
#' two-pass (linear) correction. Give these values and \code{offset} as one
#' set, or give none of them. \code{NULL}, the default, gives single-pass
#' dynamic normalization. These map to FFmpeg's \code{measured_I},
#' \code{measured_TP}, \code{measured_LRA} and \code{measured_thresh} options.
#' @param offset The \code{target_offset} (offset gain) that the analysis pass
#' reports. It is part of the measured set (see \code{measured_i}). The
#' default is \code{NULL}.
#' @param linear A logical. \code{TRUE} requests linear normalization
#' (\code{linear=true}), which needs the measured values to hit the target
#' precisely. \code{FALSE} (the default) leaves out the option, so the
#' single-pass dynamic behavior does not change.
#' @param print_format The format of the measurement report for an analysis
#' pass: \code{"json"}, \code{"summary"} or \code{"none"}. \code{NULL} (the
#' default) leaves out the option. Use \code{"json"} for an analysis pass that
#' a program can parse.
#' @return \code{object} with an added instruction to normalize loudness.
#' @seealso [normalize_audio()], the task function built on this filter.
#' @references
#' EBU Recommendation R 128 (2014), \emph{Loudness normalisation and permitted
#' maximum level of audio signals}; ITU-R BS.1770-4.
#' \url{https://ffmpeg.org/ffmpeg-filters.html#loudnorm}
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_loudnorm() |>
#' ffm_compile()
#' @export
ffm_loudnorm <- function(object,
target_loudness = -23,
true_peak = -1,
loudness_range = 7,
measured_i = NULL,
measured_tp = NULL,
measured_lra = NULL,
measured_thresh = NULL,
offset = NULL,
linear = FALSE,
print_format = NULL) {
check_ffm(object)
# The three target ranges live in one binding each (loudnorm_range_*), read
# here and at normalize_audio()'s / _batch's front doors (M65).
check_loudnorm_targets(target_loudness, true_peak, loudness_range)
# Measured values are observed (not user targets), so they are range-free but
# must be finite real numbers.
rlang::check_number_decimal(measured_i, allow_null = TRUE, allow_infinite = FALSE)
rlang::check_number_decimal(measured_tp, allow_null = TRUE, allow_infinite = FALSE)
rlang::check_number_decimal(measured_lra, allow_null = TRUE, allow_infinite = FALSE)
rlang::check_number_decimal(measured_thresh, allow_null = TRUE, allow_infinite = FALSE)
rlang::check_number_decimal(offset, allow_null = TRUE, allow_infinite = FALSE)
rlang::check_bool(linear)
if (!is.null(print_format)) {
rlang::arg_match(print_format, c("json", "summary", "none"))
}
# The four measured values plus offset are one coherent set from a single
# analysis pass: require all or none, so a half-specified correction can't
# silently produce a wrong filter (FFmpeg would ignore the orphans).
measured <- list(measured_I = measured_i, measured_TP = measured_tp,
measured_LRA = measured_lra, measured_thresh = measured_thresh,
offset = offset)
present <- !vapply(measured, is.null, logical(1))
if (any(present) && !all(present)) {
cli::cli_abort(c(
"The measured {.code loudnorm} values must be supplied together.",
"x" = "Missing: {.field {names(measured)[!present]}}.",
"i" = "Provide all of {.field measured_i/tp/lra/thresh} and {.field offset}, or none."
))
}
cmd <- glue("loudnorm=I={target_loudness}:TP={true_peak}:LRA={loudness_range}")
if (all(present)) {
cmd <- paste0(
cmd,
":measured_I=", measured_i, ":measured_TP=", measured_tp,
":measured_LRA=", measured_lra, ":measured_thresh=", measured_thresh,
":offset=", offset
)
}
# Emit `linear=true` only when requested; the default omits it so single-pass
# commands stay byte-for-byte unchanged.
if (linear) cmd <- paste0(cmd, ":linear=true")
if (!is.null(print_format)) cmd <- paste0(cmd, ":print_format=", print_format)
# Re-chunk on the way out. Dynamic `loudnorm` resamples to 192 kHz and emits
# 192000-sample frames; an encoder with a fixed frame size is re-framed by
# FFmpeg on the way in, but flac and vorbis take whatever frame they are
# handed and 192000 is past flac's 65535-sample block ceiling -- measured on
# FFmpeg 9.0.1, both die at `Could not open encoder before EOF` (exit 234)
# leaving a zero-byte file. Unconditional rather than gated on `linear`,
# because FFmpeg falls back from linear to dynamic on its own whenever the
# linear correction would breach the true-peak target. `p=0` so the final
# frame is not padded: padding would lengthen the output by up to one frame.
object$filter_audio <- c(object$filter_audio, cmd, "asetnsamples=n=4096:p=0")
object
}
# ffm_codec() ------------------------------------------------------------------
#' Set Codecs in an FFmpeg Pipeline
#'
#' Set the audio codec, the video codec, or both, for the output file. Use
#' \code{ffmpeg_codecs()} to see a list of the codecs in your FFmpeg version.
#' The glossary in \code{vignette("tidymedia")} explains media terms such as
#' codec and stream copy.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param audio A string that names the audio codec, or \code{NULL} to set only
#' the video codec. The default is \code{NULL}. See
#' \code{\link{audio_stream}} for the two things that the name \code{audio}
#' means in the pipeline functions, and for the input index
#' \code{audio_input}.
#' @param video A string that names the video codec, or \code{NULL} to set only
#' the audio codec. The default is \code{NULL}.
#' @return \code{object} with an added instruction to change the codecs.
#' @seealso [ffm_copy()], the shortcut for stream copy, [ffmpeg_codecs()] to
#' list the codecs you can use, and [standardize_video()], a task function
#' built on it.
#' @references https://ffmpeg.org/ffmpeg-codecs.html
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_codec(video = "libx264", audio = "aac") |>
#' ffm_compile()
#' @export
ffm_codec <- function(object,
audio = NULL,
video = NULL) {
check_ffm(object)
# Cheap sanity check only (D-M06-3): whether the token names a real codec
# stays FFmpeg's call, so compile behavior never depends on the binary.
if (!is.null(audio)) check_token(audio)
if (!is.null(video)) check_token(video)
if (is.null(audio) == FALSE) {
object$codec_audio <- audio
}
if (is.null(video) == FALSE) {
object$codec_video <- video
}
object
}
# ffm_map() ---------------------------------------------------------------
#' Set the Stream Mapping in an FFmpeg Pipeline
#'
#' Choose which input streams go into the output, with FFmpeg's \code{-map}
#' option. The default, \code{"0"}, maps every stream from the first input. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as stream.
#'
#' \code{mapping} can be a character vector. Each element adds one \code{-map},
#' in the order given. For example, \code{ffm_map(object, c("0:v", "0:a:1"))}
#' keeps the video and the \emph{second} audio track of the input.
#'
#' A second \code{ffm_map()} call \strong{adds} to the maps already set. It does
#' not replace them. Pass \code{replace = TRUE} to discard them instead. That is
#' how you narrow the all-streams map that \code{\link{ffm_copy}} sets. Adding
#' to that map puts the stream in the output twice, and does not select it.
#'
#' \code{ffm_map()} is the only pipeline function that adds to earlier calls.
#' Every other \code{ffm_*} function that sets a value, \code{\link{ffm_copy}}
#' included, replaces it. \code{ffm_map()} is different because its arguments
#' are \emph{partial} choices that combine. For example, you keep the video,
#' then name one audio track.
#'
#' When the pipeline uses a function with several inputs, such as
#' \code{\link{ffm_hstack}}, your mapping is added \emph{beside} the automatic
#' \code{-map "[vout]"} of the filtered stream. For example,
#' \code{ffm_map(object, "0:a")} keeps the audio of the first input next to the
#' stacked video.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param mapping A character vector of one or more stream specifiers. Each one
#' adds one \code{-map}.
#' @param replace A logical. \code{TRUE} discards any mapping already set on
#' \code{object}. \code{FALSE} (the default) adds to it.
#' @return \code{object} with an added instruction to map streams.
#' @seealso [ffm_copy()], which maps all streams, and [separate_audio_video()],
#' a task function built on \code{ffm_map()}.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_map(mapping = "0") |>
#' ffm_compile()
#'
#' # Keep the video and the second audio track only
#' ffm_files(video, "output.mkv") |>
#' ffm_map(mapping = c("0:v", "0:a:1")) |>
#' ffm_compile()
#' @export
ffm_map <- function(object, mapping = "0", replace = FALSE) {
check_ffm(object)
# Not check_string(): a vector is the point (M43). Spelled out rather than
# deferred to rlang because check_character() is unexported.
if (!is.character(mapping) || length(mapping) == 0L || anyNA(mapping)) {
cli::cli_abort(
"{.arg mapping} must be a character vector of one or more stream \\
specifiers (no {.val {NA}})."
)
}
rlang::check_bool(replace)
# Append by default. Overwriting was the old behavior and it silently
# discarded the earlier call, so a pipeline could not keep the video and then
# name one audio track; `replace` keeps overwriting reachable for the one case
# that needs it -- narrowing ffm_copy()'s all-streams "0" (M43). ffm_copy()
# now sets that map through `replace` itself (M48/D027), so the two in-package
# callers of this branch are it and segment_pipeline().
#
# No de-duplication here, deliberately: that was considered and rejected at
# M48 (RR03). It would change this contract to fix a defect that is not this
# function's -- `c(existing, "0")` is wrong because "0" SUBSUMES whatever it
# is appended to, which is a fact about ffm_copy()'s specifier, not about
# appending -- and it would leave `ffm_map("0:v") |> ffm_copy()` duplicating
# the video stream regardless.
object$map <- if (replace) mapping else c(object$map, mapping)
object
}
# ffm_copy() --------------------------------------------------------------
#' Copy the codecs and map all streams
#'
#' Copy the audio, the video, or both, with stream copy and no re-encoding. It
#' can also map all streams from the input. This is the fast, lossless path when
#' you only need to put the streams in a new container or cut on keyframes. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as codec,
#' container, keyframe and stream copy.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param audio A logical. \code{TRUE} (the default) copies the audio codec.
#' See \code{\link{audio_stream}} for the two things that the name
#' \code{audio} means in the pipeline functions, and for the input index
#' \code{audio_input}.
#' @param video A logical. \code{TRUE} (the default) copies the video codec.
#' @param streams A logical. \code{TRUE} (the default) maps all streams from the
#' input. It \strong{sets} the mapping to the all-streams specifier
#' \code{"0"}, and does not add to it. So two \code{ffm_copy()} calls compile
#' one \code{-map "0"}, not two. If the pipeline already has a
#' \emph{different} mapping, \code{ffm_copy()} gives an error and does not
#' discard that mapping silently. To keep the mapping you set, pass
#' \code{streams = FALSE}. Or call \code{ffm_copy()} first, and then narrow
#' the mapping with \code{ffm_map(replace = TRUE)}.
#' @return \code{object} with an added instruction to copy codecs, map all
#' streams, or both.
#' @seealso [ffm_codec()] and [ffm_map()], which \code{ffm_copy()} calls, and
#' [segment_video()], which uses it for fast copy cuts.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_copy() |>
#' ffm_compile()
#' @export
ffm_copy <- function(object, audio = TRUE, video = TRUE, streams = TRUE) {
check_ffm(object)
rlang::check_bool(audio)
rlang::check_bool(video)
rlang::check_bool(streams)
if (audio) {
object <- ffm_codec(object, audio = "copy")
}
if (video) {
object <- ffm_codec(object, video = "copy")
}
if (streams) {
# ASSIGN, never append (M48/D027). "0" subsumes every other specifier the
# linear builder can address (one input chain, IP2/D003), so appending it
# has no composition in which it is what the caller wanted: beside an
# existing "0" it duplicates every stream, and beside anything narrower
# ("0:v") it duplicates that selection's streams. An operation whose
# right-hand side subsumes any possible left-hand side is an assignment,
# and ffm_copy() executing it as an increment is what M43's append change
# accidentally made it do.
check_copy_map_conflict(object$map, call = rlang::caller_env())
object <- ffm_map(object, mapping = "0", replace = TRUE)
}
object
}
# Refuse to assign the all-streams map over a DIFFERENT stated mapping. Without
# this, ffm_copy()'s assignment would silently discard a selection the caller
# wrote -- the precise flaw D023 was written to remove ("a second call silently
# discarded the first"), reintroduced for one verb. The package already decided
# this shape one field over: segment_pipeline() aborts on an `audio_codec` that
# ffm_copy() would silently overwrite (M35/D017). RR03 chose an abort over a
# warning because the compiled command is the product (D001), and because an
# abort relaxed to a warning later is backward-compatible where the reverse
# breaks running code.
#
# The identical("0") carve-out is load-bearing, not a convenience: it is what
# keeps ffm_copy() |> ffm_copy() and ffm_concat() |> ffm_copy() silent no-op
# restatements instead of errors. It is literal, so c("0", "0") -- a map already
# doubled by hand -- is a conflict and aborts.
#
# Worded around the PIPELINE's map rather than the caller's frame, because
# ffm_concat() calls ffm_copy() internally: a user chain
# `ffm_map(...) |> ffm_concat()` trips this from a function they never called,
# where "you passed streams = TRUE" would be a lie and `streams = FALSE` is not
# an argument they can reach. That chain compiled `-map 0:v -map 0` before M48
# and duplicated a stream, so erroring is a fix rather than a regression.
check_copy_map_conflict <- function(map, call = rlang::caller_env()) {
if (length(map) == 0L || identical(map, "0")) {
return(invisible(NULL))
}
cli::cli_abort(
c(
"This pipeline already sets a stream mapping ({.val {map}}), which
copying every stream would discard.",
"i" = "Keep it with {.code ffm_copy(streams = FALSE)}, which copies the
codecs and leaves the mapping alone.",
"i" = "Or copy first and narrow after, with
{.code ffm_map(..., replace = TRUE)}."
),
class = "tidymedia_copy_map_conflict",
call = call
)
}
# ffm_pixel_format() ------------------------------------------------------
#' Set the Pixel Format in an FFmpeg Pipeline
#'
#' Set the pixel format of the output with FFmpeg's \code{-pix_fmt} option. For
#' example, use \code{"yuv420p"} for broad player compatibility. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as pixel format.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param format A string that names the pixel format of the output file.
#' @return \code{object} with an added instruction to set the pixel format.
#' @seealso [standardize_video()] and [format_for_web()], the task functions
#' that use \code{ffm_pixel_format()} to set the pixel format.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' ffm_files(video, "output.mp4") |>
#' ffm_pixel_format("yuv420p") |>
#' ffm_compile()
#' @export
ffm_pixel_format <- function(object, format) {
check_ffm(object)
check_token(format)
object$pixel_format <- format
object
}
# ffm_hstack() -----------------------------------------------------------------
#' Horizontally Stack Multiple Videos in an FFmpeg Pipeline
#'
#' Add a complex video filter that stacks several videos horizontally (side by
#' side). It can also resize the videos to the same height.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param shortest A logical that says whether to trim the duration of all
#' videos to that of the shortest video. The default is \code{FALSE}.
#' @param resize A logical that says whether to resize the input videos to the
#' same height. Resizing takes longer, and for now it works only with two
#' inputs. It fits both inputs to the same aspect ratio, so it assumes the
#' inputs share one.
#' @return \code{object} with an added instruction to stack the videos
#' horizontally.
#' @seealso [ffm_vstack()] for vertical stacking, and [compare_videos()], the
#' task function built on both.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Stack two inputs side-by-side (pass more than one input to ffm_files())
#' ffm_files(c(video, video), "output.mp4") |>
#' ffm_hstack() |>
#' ffm_compile()
#' @export
ffm_hstack <- function(object,
shortest = FALSE,
resize = FALSE) {
check_ffm(object)
rlang::check_bool(shortest)
rlang::check_bool(resize)
inputs_n <- length(object$input)
shortest_int <- as.integer(shortest)
if (inputs_n <= 1) {
cli::cli_abort("Stacking requires more than one input file.")
}
if (resize && inputs_n != 2) {
cli::cli_abort("{.arg resize} currently only works with exactly two inputs.")
}
check_multi_input_ordering(object, "Stacking")
# hstack is a blessed multi-input verb: it forces the -filter_complex path
# (see ffm_compile()). The resize graph manages its own stream labels (it
# starts with "[..]"), so ffm_compile() emits it verbatim; the plain hstack
# token is label-free and ffm_compile() prepends the input labels. The graph
# must be a single line (embedded newlines would leak into the command).
if (resize == TRUE) {
cmd <- paste0(
"[0:v][1:v]scale2ref='oh*mdar':'if(lt(main_h,ih),ih,main_h)'[0s][1s];",
"[1s][0s]scale2ref='oh*mdar':'if(lt(main_h,ih),ih,main_h)'[1s][0s];",
"[0s][1s]hstack,setsar=1"
)
} else {
cmd <- glue('hstack=inputs={inputs_n}:shortest={shortest_int}')
}
object$filter_video <- c(object$filter_video, cmd)
object$complex <- TRUE
object
}
# ffm_vstack() -----------------------------------------------------------------
#' Vertically Stack Multiple Videos in an FFmpeg Pipeline
#'
#' Add a complex video filter that stacks several videos vertically (one above
#' the other). It can also resize the videos to the same width.
#'
#' This is the vertical form of \code{\link{ffm_hstack}}. Both are pipeline
#' functions for several inputs. They force the \code{-filter_complex} path and
#' manage their own stream labels internally. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as stream.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param shortest A logical that says whether to trim the duration of all
#' videos to that of the shortest video. The default is \code{FALSE}.
#' @param resize A logical that says whether to resize the input videos to the
#' same width. Resizing takes longer, and for now it works only with two
#' inputs. It fits both inputs to the same aspect ratio, so it assumes the
#' inputs share one.
#' @return \code{object} with an added instruction to stack the videos
#' vertically.
#' @seealso [ffm_hstack()] for horizontal stacking, and [compare_videos()], the
#' task function built on both.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Stack two inputs one above the other (pass more than one input to ffm_files())
#' ffm_files(c(video, video), "output.mp4") |>
#' ffm_vstack() |>
#' ffm_compile()
#' @export
ffm_vstack <- function(object,
shortest = FALSE,
resize = FALSE) {
check_ffm(object)
rlang::check_bool(shortest)
rlang::check_bool(resize)
inputs_n <- length(object$input)
shortest_int <- as.integer(shortest)
if (inputs_n <= 1) {
cli::cli_abort("Stacking requires more than one input file.")
}
if (resize && inputs_n != 2) {
cli::cli_abort("{.arg resize} currently only works with exactly two inputs.")
}
check_multi_input_ordering(object, "Stacking")
# vstack mirrors hstack (see ffm_hstack()) but equalises *widths* instead of
# heights: the scale2ref graph grows each input to the larger of the two
# widths, preserving aspect via ow/mdar, then vertically stacks. Label-free
# plain-vstack token is completed by ffm_compile() with input labels + [vout].
if (resize == TRUE) {
cmd <- paste0(
"[0:v][1:v]scale2ref='if(lt(main_w,iw),iw,main_w)':'ow/mdar'[0s][1s];",
"[1s][0s]scale2ref='if(lt(main_w,iw),iw,main_w)':'ow/mdar'[1s][0s];",
"[0s][1s]vstack,setsar=1"
)
} else {
cmd <- glue('vstack=inputs={inputs_n}:shortest={shortest_int}')
}
object$filter_video <- c(object$filter_video, cmd)
object$complex <- TRUE
object
}
# ffm_overlay() ----------------------------------------------------------------
#' Overlay One Video on Another in an FFmpeg Pipeline
#'
#' Draw the second input (the overlay) on top of the first input (the main
#' video) at position \code{x} and \code{y}. Like \code{\link{ffm_hstack}}, this
#' is a pipeline function for several inputs. It forces the
#' \code{-filter_complex} path and manages its own stream labels internally. It
#' needs exactly two inputs. The first is the background, and the second is
#' drawn over it. The glossary in \code{vignette("tidymedia")} explains media
#' terms such as stream.
#'
#' \code{x} and \code{y} accept plain numbers or FFmpeg overlay expressions. A
#' plain number counts pixels from the top-left of the main video. In an
#' expression, \code{main_w} and \code{main_h} are the main video's dimensions.
#' \code{overlay_w} and \code{overlay_h} are the overlay's dimensions. For
#' example, \code{x = "main_w-overlay_w-16"} puts the overlay 16 pixels from the
#' right edge.
#'
#' When \code{scale} is set, the overlay is first resized to a fraction of the
#' main video's width, and its aspect ratio is kept. The task function
#' \code{\link{picture_in_picture}} uses this resize. Otherwise, to resize the
#' overlay yourself, filter it in a separate pipeline first.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()} with exactly two input files.
#' @param x The horizontal position of the overlay's left edge, as a number of
#' pixels or an FFmpeg expression. The default is \code{0}.
#' @param y The vertical position of the overlay's top edge, as a number of
#' pixels or an FFmpeg expression. The default is \code{0}.
#' @param shortest A logical that says whether to end the output when the
#' shorter input ends. The default is \code{FALSE}.
#' @param scale An optional fraction (\code{0 < scale <= 1}). Before the overlay
#' is drawn, it is resized to \code{scale} times the main video's width, and
#' its aspect ratio is kept. \code{NULL} (the default) draws the overlay at its
#' own size. When \code{scale} is set, \code{overlay_w} and \code{overlay_h} in
#' \code{x} and \code{y} refer to the resized overlay.
#' @return \code{object} with an added instruction to draw the second input on
#' the first.
#' @seealso [picture_in_picture()], the task function built on this function.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Draw the second input over the first, 16px in from the top-right corner
#' ffm_files(c(video, video), "output.mp4") |>
#' ffm_overlay(x = "main_w-overlay_w-16", y = 16) |>
#' ffm_compile()
#' @export
ffm_overlay <- function(object,
x = 0,
y = 0,
shortest = FALSE,
scale = NULL) {
check_ffm(object)
check_dim(x, inclusive = TRUE)
check_dim(y, inclusive = TRUE)
rlang::check_bool(shortest)
rlang::check_number_decimal(scale, allow_null = TRUE)
# The range rule lives in one binding (overlay_scale_range), read here and at
# picture_in_picture()'s / _batch's front doors (M65).
check_overlay_scale(scale)
if (length(object$input) != 2) {
cli::cli_abort("Overlaying requires exactly two input files.")
}
check_multi_input_ordering(object, "Overlaying")
shortest_int <- as.integer(shortest)
if (is.null(scale)) {
# Label-free token: ffm_compile() prepends the two input pads ([0:v][1:v],
# main then overlay) and appends [vout].
cmd <- glue('overlay=x={x}:y={y}:shortest={shortest_int}')
} else {
# Self-labelled graph (starts with "["), so ffm_compile() emits it verbatim
# and only appends [vout]. scale2ref resizes the overlay ([1:v]) using the
# main ([0:v]) as reference: width = main_w*scale, height preserves the
# overlay's own aspect (ih/iw). Must stay a single line (no newlines).
cmd <- glue(
"[1:v][0:v]scale2ref=w='main_w*{scale}':h='main_w*{scale}*ih/iw'",
"[pip][bg];[bg][pip]overlay=x={x}:y={y}:shortest={shortest_int}"
)
}
object$filter_video <- c(object$filter_video, cmd)
object$complex <- TRUE
object
}
# ffm_concat() -----------------------------------------------------------------
#' Concatenate Multiple Inputs in an FFmpeg Pipeline
#'
#' Join the pipeline's input files one after another with FFmpeg's
#' [concat demuxer](https://ffmpeg.org/ffmpeg-formats.html#concat-1). Like
#' \code{\link{ffm_hstack}}, this is a pipeline function for several inputs. It
#' uses stream copy, so it is fast and lossless. But every input must share the
#' same parameters, such as codec, resolution and frame rate. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, re-encode
#' and stream copy.
#'
#' To join inputs with different parameters, you must re-encode with the concat
#' filter. The package does not wrap that filter yet, so use
#' \code{\link{ffmpeg}}.
#'
#' The demuxer needs a list file that names the inputs. When you call
#' \code{ffm_concat()}, it writes one to a temporary path and stores it in the
#' pipeline, so the compiled command can refer to it. It also copies codecs and
#' maps all streams, as \code{\link{ffm_copy}} would.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()} with more than one input file.
#' @return \code{object} with an added instruction to concatenate the inputs.
#' @seealso [concatenate_videos()], the task function built on this function.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Join two inputs end-to-end (they must share codec/resolution/frame rate)
#' ffm_files(c(video, video), "output.mp4") |>
#' ffm_concat() |>
#' ffm_compile()
#' @export
ffm_concat <- function(object) {
check_ffm(object)
if (length(object$input) <= 1) {
cli::cli_abort("Concatenation requires more than one input file.")
}
if (length(object$filter_video) || length(object$filter_audio)) {
cli::cli_abort(c(
"Concatenation must come before other filters.",
"i" = "The concat demuxer copies whole files; filter the result after."
))
}
# The demuxer reads a list file of `file '<path>'` lines. Write it now so the
# compiled command is self-contained; -safe 0 permits absolute paths, and the
# single quotes are escaped per the concat format's rules.
listfile <- tempfile("ffm-concat", fileext = ".txt")
lines <- paste0("file '", gsub("'", "'\\\\''", object$input), "'")
writeLines(lines, listfile)
object$concat <- TRUE
object$concat_list <- listfile
object <- ffm_copy(object)
object
}
# ffm_drawbox() -----------------------------------------------------------
#' Draw a Colored Box on the Videos in an FFmpeg Pipeline
#'
#' Add a video filter that draws a colored rectangle on the input video.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param x The horizontal position of the left edge of the box, in pixels of
#' the input video. Give a nonnegative real number or a string that contains
#' an FFmpeg expression. The default is \code{0}.
#' @param y The vertical position of the top edge of the box, in pixels of the
#' input video. Give a nonnegative real number or a string that contains an
#' FFmpeg expression. The default is \code{0}.
#' @param width The width of the box, in pixels. Give a positive real number or
#' a string that contains an FFmpeg expression. The default is \code{"in_w"}.
#' @param height The height of the box, in pixels. Give a positive real number
#' or a string that contains an FFmpeg expression. The default is
#' \code{"in_h"}.
#' @param color A string with the color of the box, in FFmpeg color syntax. The
#' reference link below explains that syntax. With the special value
#' \code{"invert"}, the box has the color of the video with inverted luma. The
#' default is \code{"black"}.
#' @param thickness The thickness of the box edge, in pixels. The value
#' \code{"fill"} draws a filled box. The default is \code{"fill"}.
#' @return \code{object} with an added instruction to apply the \code{drawbox}
#' filter.
#' @seealso [anonymize_video()], the task function that uses
#' \code{ffm_drawbox()} to fill regions.
#' @references https://ffmpeg.org/ffmpeg-filters.html#drawbox
#' @references https://ffmpeg.org/ffmpeg-utils.html#color-syntax
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Draw a filled red box covering the top-left quarter of the frame
#' ffm_files(video, "output.mp4") |>
#' ffm_drawbox(width = "in_w/2", height = "in_h/2", color = "red") |>
#' ffm_compile()
#' @export
ffm_drawbox <- function(object,
x = 0,
y = 0,
width = "in_w",
height = "in_h",
color = "black",
thickness = "fill") {
check_ffm(object)
check_dim(x, inclusive = TRUE)
check_dim(y, inclusive = TRUE)
check_dim(width)
check_dim(height)
rlang::check_string(color)
check_dim(thickness)
cmd <- glue('drawbox=x={x}:y={y}:w={width}:h={height}:c={color}:t={thickness}')
object$filter_video <- c(object$filter_video, cmd)
object
}
# ffm_output_options() ---------------------------------------------------------
#' Add Raw Output Options to an FFmpeg Pipeline
#'
#' Add one or more raw FFmpeg output options to the pipeline, after any added
#' before. Output options are the flags after the input and before the output
#' file. Use this function for an option that has no pipeline function of its
#' own. \code{ffm_compile()} still decides where the options go and how the rest
#' of the command is quoted. So this is not the same as writing the command
#' string yourself.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param ... One or more strings. Each string is a group of options separated
#' by white space, for example \code{"-q:v 1"} or \code{"-frames:v 1"}. They
#' are added in the order given. When the command runs, each word between
#' white space becomes one FFmpeg argument. So an option value must not
#' contain spaces.
#' @return \code{object} with the added output options.
#' @seealso [ffmpeg()], the direct command that takes any FFmpeg arguments, and
#' [ffm_compile()], which places these options.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Extract a single frame by adding a raw output option
#' ffm_files(video, "frame.png") |>
#' ffm_output_options("-frames:v 1") |>
#' ffm_compile()
#' @export
ffm_output_options <- function(object, ...) {
check_ffm(object)
opts <- c(...)
if (!rlang::is_character(opts) || length(opts) == 0) {
cli::cli_abort("Provide at least one output option as a string.")
}
# Each whitespace-separated token becomes one FFmpeg argument at execution
# (no shell parsing), so quoted values with spaces cannot work — reject them
# loudly rather than emit a command that means something else than printed.
if (any(grepl("[\"']", opts))) {
cli::cli_abort(c(
"Output options can't contain quote characters.",
"x" = "Options are split on whitespace into FFmpeg arguments verbatim;
quoting does not group tokens.",
"i" = "Use values without spaces, or the {.fn ffmpeg} escape hatch."
))
}
object$output_opts <- c(object$output_opts, opts)
object
}
# ffm_compile() ----------------------------------------------------------------
#' Compile the tidymedia pipeline into an FFmpeg command
#'
#' Compile all the instructions into one string, the FFmpeg command that runs
#' them.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @return A string with the FFmpeg command that carries out all the
#' instructions in the tidymedia pipeline.
#' @seealso [ffm_run()] to compile and run in one step, and [ffm_batch()] to
#' compile over many files.
#' @family pipeline functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # ffm_compile() returns the reproducible FFmpeg command as a string
#' ffm_files(video, "output.mp4") |>
#' ffm_trim(start = 1, end = 5) |>
#' ffm_crop(width = 160, height = 120) |>
#' ffm_codec(video = "libx264") |>
#' ffm_compile()
#' @export
ffm_compile <- function(object) {
groups <- ffm_groups(object)
paste(vapply(groups, `[[`, character(1), "display"), collapse = " ")
}
# ffm_args() ---------------------------------------------------------------
# Render the pipeline as an argument vector: one element per CLI argument,
# never shell-quoted. This is what actually gets executed (via run_program()'s
# system2 call), so paths containing spaces, quotes, `$`, or backticks reach
# FFmpeg verbatim. Internal by decision (D-M06-2): the exported surface stays
# `ffm_compile()`'s display string.
ffm_args <- function(object) {
groups <- ffm_groups(object)
unlist(lapply(groups, `[[`, "args"), use.names = FALSE)
}
# ffm_groups() -------------------------------------------------------------
# Shared assembly for the two renderings of a pipeline: the display string
# (`ffm_compile()`, the reproducibility artifact) and the argument vector
# (`ffm_args()`, what gets executed). Each group holds `args` (one element per
# CLI argument, unquoted) and `display` (the string fragment, with `quote`d
# elements wrapped in double quotes). Deriving both renderings from one
# structure is what keeps the printed command and the executed command from
# drifting apart (M06).
ffm_group <- function(args, quote = integer(), display = NULL) {
args <- as.character(args)
if (is.null(display)) {
shown <- args
shown[quote] <- paste0('"', shown[quote], '"')
display <- paste(shown, collapse = " ")
}
list(args = args, display = display)
}
ffm_groups <- function(object) {
check_ffm(object)
# Guard: a copied stream is passed through untouched, so it cannot also be
# filtered. Catch it here with a clear error instead of a cryptic ffmpeg
# failure at run time (M02 D-M02-5).
if (length(object$codec_video) && object$codec_video == "copy" &&
length(object$filter_video)) {
cli::cli_abort(c(
"Can't apply a video filter while the video codec is set to {.val copy}.",
"x" = "{.code copy} passes the stream through without re-encoding.",
"i" = "Re-encode with a real codec via {.fn ffm_codec}, or drop the filter."
))
}
if (length(object$codec_audio) && object$codec_audio == "copy" &&
length(object$filter_audio)) {
cli::cli_abort(c(
"Can't apply an audio filter while the audio codec is set to {.val copy}.",
"x" = "{.code copy} passes the stream through without re-encoding.",
"i" = "Re-encode with a real codec via {.fn ffm_codec}, or drop the filter."
))
}
# Guard: the concat demuxer stream-copies whole files, so it cannot also run
# a filtergraph (that path is the concat *filter*, deferred).
if (isTRUE(object$concat) &&
(length(object$filter_video) || length(object$filter_audio))) {
cli::cli_abort(c(
"Can't apply a filter while concatenating with the concat demuxer.",
"i" = "The demuxer copies whole files; filter the result in a second pass."
))
}
# Seek-based cut (distinct from the trim *filter*): a frame-accurate seek
# re-encodes, so it cannot ride a copied stream.
seek_reencode <- length(object$seek_reencode) && isTRUE(object$seek_reencode)
if (seek_reencode && length(object$codec_video) &&
object$codec_video == "copy") {
cli::cli_abort(c(
"Can't make a frame-accurate seek while the video codec is {.val copy}.",
"x" = "Accurate seeking must re-encode to cut on an exact frame.",
"i" = "Use {.code reencode = FALSE} for a fast copy cut (snaps to a keyframe)."
))
}
# Global options (before the inputs).
overwrite <- list(ffm_group(if (isTRUE(object$overwrite)) "-y" else "-n"))
# Seek options. A frame-accurate seek is placed *after* -i (output seeking,
# re-encoded). A fast copy-safe seek is placed *before* -i (input seeking)
# with -avoid_negative_ts so the copied segment starts cleanly at a keyframe
# instead of the broken output-seek-copy path (see M03 D-M03-5).
seek_pre <- list()
seek_post <- list()
if (length(object$seek_start) || length(object$seek_end)) {
seeks <- c(
if (length(object$seek_start)) list(ffm_group(c("-ss", object$seek_start))),
if (length(object$seek_end)) list(ffm_group(c("-to", object$seek_end)))
)
if (seek_reencode) {
seek_post <- seeks
} else {
seek_pre <- seeks
seek_post <- list(ffm_group(c("-avoid_negative_ts", "make_zero")))
}
}
# Inputs. The concat demuxer replaces the per-file -i list with a single -i
# pointing at the list file that ffm_concat() wrote.
inputs <- if (isTRUE(object$concat)) {
list(ffm_group(
c("-f", "concat", "-safe", "0", "-i", object$concat_list),
quote = 6L
))
} else {
lapply(object$input, function(inp) ffm_group(c("-i", inp), quote = 2L))
}
# Filters and stream mapping. Single-input sequential chains compile to
# -vf/-af; any multi-input (blessed) verb sets `complex` and compiles to
# -filter_complex with explicit labels plus an auto -map (M02 D-M02-2).
filters <- list()
map <- list()
if (isTRUE(object$complex)) {
body <- paste(object$filter_video, collapse = ",")
# A verb that manages its own stream labels starts the graph with "[..]";
# otherwise prepend one video pad per input.
if (!startsWith(body, "[")) {
labels <- paste0("[", seq_along(object$input) - 1L, ":v]", collapse = "")
body <- paste0(labels, body)
}
filters <- list(ffm_group(
c("-filter_complex", paste0(body, "[vout]")),
quote = 2L
))
# D-M06-1: explicit ffm_map() maps ride alongside the auto [vout] map
# (e.g. keep 0:a audio next to stacked video) instead of being dropped.
# Both are `quote = 2L`: the specifier is quoted in the DISPLAY string only
# (D031), so a compiled command survives a paste into a shell, where
# `-map 0:v?` and `-map [vout]` are both glob patterns. `args` is untouched.
map <- c(
list(ffm_group(c("-map", "[vout]"), quote = 2L)),
lapply(object$map, function(m) ffm_group(c("-map", m), quote = 2L))
)
} else {
if (length(object$filter_video)) {
filters <- c(filters, list(ffm_group(
c("-vf", paste(object$filter_video, collapse = ",")),
quote = 2L
)))
}
if (length(object$filter_audio)) {
filters <- c(filters, list(ffm_group(
c("-af", paste(object$filter_audio, collapse = ",")),
quote = 2L
)))
}
# Display-only quoting, as in the complex branch above (D031).
map <- lapply(object$map, function(m) ffm_group(c("-map", m), quote = 2L))
}
# Output options (after the inputs, before the output file).
codecs <- c(
if (length(object$codec_video)) {
list(ffm_group(c("-codec:v", object$codec_video)))
},
if (length(object$codec_audio)) {
list(ffm_group(c("-codec:a", object$codec_audio)))
},
if (length(object$pixel_format)) {
list(ffm_group(c("-pix_fmt", object$pixel_format)))
}
)
drop_flags <- c(
if ("video" %in% object$drop) "-vn",
if ("audio" %in% object$drop) "-an",
if ("subtitles" %in% object$drop) "-sn",
if ("data" %in% object$drop) "-dn"
)
drops <- if (length(drop_flags)) list(ffm_group(drop_flags)) else list()
# Raw output-option passthrough (e.g. "-q:v 1"): positioned here so verbs can
# add specific flags without owning command layout. Each whitespace-separated
# token within a group becomes one CLI argument; the display keeps the group
# verbatim.
output_opts <- lapply(object$output_opts, function(opt) {
ffm_group(strsplit(trimws(opt), "[[:space:]]+")[[1]], display = opt)
})
c(
overwrite,
seek_pre,
inputs,
filters,
codecs,
seek_post,
output_opts,
drops,
map,
list(ffm_group(object$output, quote = 1L))
)
}
# output_targets() -------------------------------------------------------------
# The files a pipeline's `output` designates, as they exist right now.
#
# Usually that is one path. Where the output is an image2 printf PATTERN --
# sample_frames()' "<outdir>/<prefix>_%06d.png" -- it is the numbered files that
# pattern names in its own directory: one FFmpeg command fans out to many files
# there (D003), so the run's output is the SET, and `file.exists()` is false of
# the pattern itself.
#
# The pattern is matched as a REGEX built from the pattern's own text, never as
# a glob: every character outside the `%0Nd` field is escaped, so a prefix
# containing `*` or `[` matches itself and nothing else.
#
# Directories are never targets. `unlink()` leaves one alone without
# `recursive = TRUE`, so reporting it as removed would be a lie, and a directory
# sitting where a frame's file must go is a failure the run hit rather than
# something it wrote.
output_targets <- function(output) {
# A file sitting at the literal path IS the output, whatever its name looks
# like: a caller may legally name one "100%d.mp4", and reading that as a
# pattern would send the search after its neighbors. Only when nothing is
# there does the printf reading get a turn.
if (file.exists(output) && !dir.exists(output)) return(output)
if (!grepl("%[0-9]*d", output)) return(character(0))
escaped <- gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", basename(output))
rx <- paste0("^", gsub("%[0-9]*d", "[0-9]+", escaped), "$")
found <- list.files(dirname(output), pattern = rx, full.names = TRUE)
found[!dir.exists(found)]
}
# output_snapshot() ------------------------------------------------------------
# What each of those files looked like at one moment: a character vector of
# "<size> <mtime>", named by path. Comparing a snapshot taken BEFORE the run
# with one taken after a failure is how the removal below tells what this run
# wrote from what it merely found -- the pre-run one has to be taken by the
# caller, since afterwards the answer is no longer recoverable.
#
# Size alone is not enough: a pre-existing zero-byte output that FFmpeg opens
# and leaves at zero bytes is distinguished only by its mtime (measured
# 2026-08-09, ffmpeg 8.1.2 macOS). Where a filesystem's timestamp resolution
# hides even that, the run leaves a zero-byte file that was already zero bytes.
#
# The mtime is recorded as EPOCH SECONDS, never as a formatted local time: a
# formatted string carries the session's timezone, so a `TZ` change or a DST
# crossing between the two snapshots would make an untouched file compare
# unequal -- and an untouched file comparing unequal is a caller's file deleted
# (M68 review). `sprintf("%.6f")` because `paste()` on the double would round
# the sub-second part away.
output_snapshot <- function(output) {
paths <- output_targets(output)
if (!length(paths)) return(character(0))
info <- file.info(paths, extra_cols = FALSE)
rlang::set_names(
paste(info$size, sprintf("%.6f", as.numeric(info$mtime))),
paths
)
}
# remove_failed_output() -------------------------------------------------------
# Delete what a failed run wrote, and report what happened as cli bullets for
# the abort that follows.
#
# WHY AT ALL. FFmpeg creates its output before it knows the command will work,
# and truncates an existing one to zero on the way, so a failed run left a file
# that is empty and looks like a result (measured 2026-08-09, ffmpeg 8.1.2
# macOS: an AAC-to-MP3 stream copy exits 234 with a zero-byte output). Removing
# it here rather than in each verb keeps execution in Layer 1 once (IP1/D002);
# every verb and ffm_batch() reach this one site.
#
# WHAT IT REMOVES is what THIS RUN wrote, never what it found (D046): a target
# goes only if it is absent from `before` or its size or mtime has moved. The
# rule is not "the output path", because FFmpeg refuses an unknown encoder,
# filter or option value BEFORE opening the output and exits 8 with a
# pre-existing file byte-for-byte intact -- deleting that file was M68's own
# review defect.
#
# THE ONE EXCEPTION is `overwrite = FALSE` against a path that ALREADY EXISTED.
# The rule above already spares it on every build measured, since FFmpeg does
# not touch it; the guard stays because that is a promise the package MADE
# rather than a behavior it observes, and a build that touched the file anyway
# must not cost the caller it.
#
# `unlink(expand = FALSE)` because unlink() globs by default: an output legally
# named `a*.mp4` otherwise takes its neighbors with it (measured at M68's
# review).
#
# Returns a named character vector of cli bullets, interpolated in the calling
# frame -- so `output` must be bound there, it is the only value they reference,
# and it goes through a cli field (`{.file {output}}`), which does not recurse
# into the value: a filename containing braces would otherwise abort the message
# itself (M44's lesson).
remove_failed_output <- function(output, overwrite, before) {
if (isFALSE(overwrite) && output %in% names(before)) {
return(c(
"i" = "{.file {output}} was left as it was: {.arg overwrite} is
{.code FALSE}, so FFmpeg was told not to replace it."
))
}
after <- output_snapshot(output)
if (!length(after)) return(character(0))
prior <- before[names(after)]
written <- names(after)[is.na(prior) | prior != after]
if (!length(written)) {
# Everything at the output is exactly as the run found it.
if (!identical(output, names(after))) return(character(0))
return(c(
"i" = "{.file {output}} was left as it was: FFmpeg never wrote to it."
))
}
unlink(written, expand = FALSE)
# unlink() signals nothing and reports failure only through its return value;
# a read-only directory is the ordinary way it fails. Say so rather than let
# the caller believe in a cleanup that did not happen.
stuck <- written[file.exists(written)]
single <- identical(written, output)
if (length(stuck)) {
return(c("x" = if (single) {
"{.file {output}} could not be removed and is still there."
} else {
sprintf(paste("%s this run wrote for {.file {output}} could not be",
"removed and %s still there."),
n_files(stuck), if (length(stuck) == 1L) "is" else "are")
}))
}
c("i" = if (single) {
"The incomplete {.file {output}} was removed."
} else {
sprintf("The %s this run wrote for {.file {output}} %s removed.",
n_files(written), if (length(written) == 1L) "was" else "were")
})
}
# n_files() --------------------------------------------------------------------
# "1 file" / "3 files". The count is baked into the bullet here rather than left
# as a cli field, so a bullet still references only `output` in the frame that
# renders it (remove_failed_output()'s contract).
n_files <- function(x) {
sprintf("%d file%s", length(x), if (length(x) == 1L) "" else "s")
}
# ffm_run() --------------------------------------------------------------------
#' Run the FFmpeg Pipeline
#'
#' Compile the instructions in the pipeline and run them all through FFmpeg.
#'
#' @param object An FFmpeg pipeline (\code{ffm}) object created by
#' \code{ffm_files()}.
#' @param verify An optional named list of the properties you expect the output
#' to have, for example \code{list(width = 1920, video_codec = "h264")}. It is
#' passed to \code{\link{verify_media}}. After a successful run, the output is
#' probed. If a check fails, \code{ffm_run()} gives an error with the failed
#' checks. It also gives an error when FFmpeg exits non-zero. \code{NULL} (the
#' default) skips the checks.
#' @return FFmpeg's standard output as a character vector, returned invisibly.
#' On a non-zero exit it has a \code{status} attribute. You call
#' \code{ffm_run()} to write the output file, not for its return value. The
#' pipeline runs as a vector of arguments and never through a shell. So paths
#' with spaces or special characters are safe.
#' @section When FFmpeg exits non-zero:
#' If FFmpeg refuses a run, \code{ffm_run()} gives an error of class
#' \code{tidymedia_ffmpeg_exit}. A caller can catch a failed run without
#' reading the error text:
#'
#' \preformatted{
#' tryCatch(
#' ffm_run(pipeline),
#' tidymedia_ffmpeg_exit = function(cnd) cnd$tm_status
#' )
#' }
#'
#' The \code{tm_status} field is one integer, the exit status exactly as
#' \code{system2()} reported it. If a signal stopped FFmpeg, the field holds
#' the shell's number, 128 plus the signal number, unchanged. That number
#' stands for the signal, not for a status FFmpeg chose to return.
#'
#' Two other paths give this class and carry this field, so one handler covers
#' all three:
#' \itemize{
#' \item the \code{loudnorm} analysis pass of
#' \code{normalize_audio(two_pass = TRUE)}, when FFmpeg exits non-zero.
#' \item the error about several audio tracks that
#' \code{\link{separate_audio_video}} adds to a failed audio output.
#' }
#' Each of those two paths also gives a second, narrower class before this one.
#' In the same order, they are \code{tidymedia_loudnorm_no_measurement} and
#' \code{tidymedia_multitrack_separation}. Catch that class when you want only
#' that failure.
#'
#' Two related paths do \strong{not} give this class, each for its own reason:
#' \itemize{
#' \item \code{normalize_audio(two_pass = TRUE)} also gives an error when the
#' analysis pass exits zero and prints no measurement block that can be
#' read. FFmpeg did not exit non-zero there. So that error has only the
#' class \code{tidymedia_loudnorm_no_measurement}, and no \code{tm_status}.
#' \item \code{normalize_audio_batch(two_pass = TRUE)} reports in one error
#' every row that failed in its analysis phase. The failed rows can include
#' rows that exited zero and rows that FFmpeg refused. So a non-zero exit is
#' one of its causes, not the fact it reports, and no single status can
#' stand for the mix. It also has only the class
#' \code{tidymedia_loudnorm_no_measurement}. It carries \code{tm_rows}, the
#' failed rows counted from 1. It also carries \code{tm_row_status}, their
#' exit statuses in the same order, with \code{NA} where a row exited zero.
#' }
#' So \code{tidymedia_loudnorm_no_measurement} is the one class that covers
#' the analysis pass in both forms.
#' @section When the build lacks an encoder:
#' After FFmpeg exits non-zero, \code{ffm_run()} looks at the video and audio
#' codecs that \code{\link{ffm_codec}} set on the pipeline. A name is
#' encodable if \code{\link{ffmpeg_encoders}} lists it, or if
#' \code{\link{ffmpeg_codecs}} lists it as a codec this build can encode, such
#' as \code{"h264"}. If a codec is not encodable, the error also has the class
#' \code{tidymedia_encoder_unavailable}, before \code{tidymedia_ffmpeg_exit}.
#' It carries \code{tm_encoder}, the names, and \code{tm_stream}, which is
#' \code{"video"} or \code{"audio"} for each name.
#'
#' The message says that this build lists no such encoder. It does not say
#' that the missing encoder made the run fail. FFmpeg uses no video encoder for
#' an input that has no video, so a run can fail for another reason. When a
#' task function such as \code{\link{standardize_video}} runs its command, the
#' error names that function.
#'
#' The check runs only after a failed run. A successful run, and a call with
#' \code{run = FALSE}, never start it. It skips \code{"copy"}, a codec the
#' pipeline does not set, the hardware encoders that
#' \code{\link{hardware_encoder}} names, and encoders given through
#' \code{\link{ffm_output_options}}. It asks FFmpeg for the two lists, and the
#' package remembers each list for the session once it reads it (see
#' \code{\link{refresh_ffmpeg_capabilities}}). If the package cannot read a
#' list, the check says nothing, and the error is the
#' \code{tidymedia_ffmpeg_exit} error alone. If reading a list reaches the
#' \code{tidymedia.timeout} limit, a warning of class
#' \code{tidymedia_probe_timeout} also says so, once per call.
#' \code{\link{ffm_batch}} gives one \code{tidymedia_encoder_unavailable}
#' warning for all its rows instead of an error for each.
#' @seealso [ffm_compile()] to get the command without running it, [ffm_batch()]
#' to run many files, and [verify_media()] for the \code{verify} list.
#' @family pipeline functions
#' @examplesIf nzchar(Sys.which("ffmpeg")) && has_encoder("libx264") && has_encoder("aac")
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' out <- tempfile(fileext = ".mp4")
#' ffm_files(video, out) |>
#' ffm_scale(width = 160, height = 120) |>
#' ffm_codec(video = "libx264", audio = "aac") |>
#' ffm_run(verify = list(width = 160, height = 120))
#' @export
ffm_run <- function(object, verify = NULL) {
check_ffm(object)
if (!is.null(verify) && !(rlang::is_list(verify) && rlang::is_named(verify))) {
cli::cli_abort("{.arg verify} must be a named list of expected properties.")
}
# D074, and the reason the site is HERE rather than left to run_program():
# that helper refuses a missing binary (`Could not locate FFmpeg.`) before it
# resolves the limit, so on a machine with no FFmpeg this abort used to be the
# PATH's rather than the option's. A machine-independent refusal reports before
# a machine-dependent one (D036), so it is read at the front door.
resolve_timeout()
# Execute the argument vector directly (one shell-free token per argument),
# so paths containing spaces, quotes, `$`, or backticks reach FFmpeg
# verbatim (M06). stdin is redirected from an empty input so FFmpeg cannot
# drain the parent process's stdin (see ffmpeg()); stderr streams to the
# console as before.
# Snapshot the output BEFORE running: remove_failed_output() removes what this
# run wrote and nothing it merely found, and the difference is only visible
# against a pre-run size and mtime (D046).
output <- object$output
before <- output_snapshot(output)
# A timeout ABORTS inside run_program() rather than returning a status, so the
# cleanup below would never be reached without this handler -- a killed FFmpeg
# leaves exactly the half-written output D046 exists to remove. The disposition
# is appended to the timeout's own message, so which of D046's outcomes applied
# is stated on this path too (M69 AC5). The rule itself is untouched: the same
# remove_failed_output() call, with the same pre-run snapshot.
out <- rlang::try_fetch(
run_program(find_ffmpeg(), ffm_args(object), program = "FFmpeg",
input = "", stderr = ""),
tidymedia_timeout = function(cnd) {
# Read the parts off the condition rather than reusing its formatted
# message: re-interpolating that would re-run glue over whatever the
# message already contains (M44's brace trap). `.envir` is this handler's
# frame, so `{.file {output}}` inside `disposition` resolves against
# ffm_run()'s `output` up the enclosure chain, while `program` and `limit`
# resolve to the locals below.
program <- cnd$tm_program
limit <- cnd$tm_limit
disposition <- remove_failed_output(output, object$overwrite, before)
abort_timeout(program, limit, extra = disposition,
.envir = environment())
}
)
status <- attr(out, "status")
if (!is.null(status)) {
disposition <- remove_failed_output(output, object$overwrite, before)
# M139: name a codec this build does not list as encodable, only now that
# FFmpeg has failed (diagnose_missing_encoders() says why not before). The
# frame named is the verb the caller typed where one set `tm_call` on the
# pipeline (ffm_finish() and the separation runs), else this one. NULL from
# the diagnosis is its fail-open answer, and the abort below is unchanged.
call <- attr(object, "tm_call", exact = TRUE)
if (is.null(call)) call <- rlang::current_env()
# `tm_no_diagnosis` is set by a caller whose earlier run's listing read
# reached the limit, so one call warns once about the listing (D049;
# R/ffmpeg.R says how).
missing <- if (isTRUE(attr(object, "tm_no_diagnosis", exact = TRUE))) {
character()
} else {
diagnose_missing_encoders(object, call = call)
}
if (length(missing)) {
# One bullet per missing codec. The stream word is one of two constants and
# the name reaches cli only through {.val}, so a brace in a codec name is
# printed, never evaluated (M44).
lacks <- vapply(seq_along(missing), function(i) {
sprintf("This FFmpeg build lists no %s encoder {.val {missing[[%d]]}}.",
names(missing)[[i]], i)
}, character(1))
cli::cli_abort(
c(
"FFmpeg exited with status {status}.",
rlang::set_names(lacks, rep("x", length(lacks))),
"i" = "{.fn ffmpeg_encoders} lists the encoders this build has.",
"i" = "FFmpeg's error output is printed above.",
disposition,
"i" = "The failing command was: {.code ffmpeg {ffm_compile(object)}}"
),
class = c("tidymedia_encoder_unavailable", "tidymedia_ffmpeg_exit"),
tm_status = as.integer(status),
tm_encoder = unname(missing),
tm_stream = names(missing),
call = call
)
}
cli::cli_abort(
c(
"FFmpeg exited with status {status}.",
"i" = "FFmpeg's error output is printed above.",
disposition,
"i" = "The failing command was: {.code ffmpeg {ffm_compile(object)}}"
),
class = "tidymedia_ffmpeg_exit",
tm_status = as.integer(status)
)
}
if (!is.null(verify)) verify_output(object$output, verify)
invisible(out)
}
# verify_output() ---------------------------------------------------------
# Probe a just-written output against a `verify` spec (named list) and abort
# with the failing checks if any do not pass. Shared by ffm_run(verify=); the
# batch path records outcomes instead of aborting (see ffm_batch()).
verify_output <- function(file, verify, call = rlang::caller_env()) {
report <- do.call(verify_media, c(list(file = file), verify))
failed <- report[!report$pass, , drop = FALSE]
if (nrow(failed) == 0) return(invisible(report))
bullets <- rlang::set_names(
sprintf(
"%s: expected %s, got %s",
failed$check, failed$expected,
ifelse(is.na(failed$actual), "NA", failed$actual)
),
rep("x", nrow(failed))
)
cli::cli_abort(
c("Output failed {nrow(failed)} verification check{?s}.", bullets),
call = call
)
}
# ffm_finish() -----------------------------------------------------------------
# Shared tail of the Layer 2 task verbs: compile the pipeline and, when
# run = TRUE, execute it. Returns the compiled command (invisibly after
# running) so every verb yields its reproducible command (M03 D-M03-6).
#
# `call` is the verb the caller typed, and it carries no default for the reason
# tm_hardware_encoder()'s does not: a site that forgot it would name this
# helper. It reaches ffm_run() as the pipeline's `tm_call` attribute, which only
# the missing-encoder diagnosis reads (M139). An attribute rather than an
# internal ffm_run() body taking `call`, so every condition ffm_run() already
# raises keeps the frame it names today.
ffm_finish <- function(object, run, call) {
rlang::check_bool(run)
command <- ffm_compile(object)
if (run) {
attr(object, "tm_call") <- call
ffm_run(object)
invisible(command)
} else {
command
}
}
# https://ffmpeg.org/ffmpeg-filters.html#toc-drawbox
# https://ffmpeg.org/ffmpeg-filters.html#toc-drawgrid
# https://ffmpeg.org/ffmpeg-filters.html#toc-drawtext-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-fade
# https://ffmpeg.org/ffmpeg-filters.html#toc-fillborders
# https://ffmpeg.org/ffmpeg-filters.html#toc-framestep
# https://ffmpeg.org/ffmpeg-filters.html#toc-loop
# https://ffmpeg.org/ffmpeg-filters.html#toc-subtitles-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-zoompan
# https://ffmpeg.org/ffmpeg-filters.html#toc-format-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-fps-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-rotate
# https://ffmpeg.org/ffmpeg-filters.html#toc-scale-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-thumbnail
# https://ffmpeg.org/ffmpeg-filters.html#toc-tile-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-trim
# https://ffmpeg.org/ffmpeg-filters.html#toc-vstack
# https://ffmpeg.org/ffmpeg-filters.html#overlay
# https://ffmpeg.org/ffmpeg-filters.html#toc-pad-1
# https://ffmpeg.org/ffmpeg-filters.html#toc-concat
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.