Nothing
# ffmpeg() ----------------------------------------------------------------
#' Run a raw FFmpeg command
#'
#' Send a raw argument string to the FFmpeg command-line program. This is a
#' direct command. The function passes the string to FFmpeg unchanged, after
#' the path of the FFmpeg program. So you are responsible for the quoting and
#' the order of the options.
#'
#' @param command A string containing the arguments to pass to FFmpeg.
#' @return A character vector containing the text output by FFmpeg.
#' @seealso [ffmpeg_codecs()] and [ffmpeg_encoders()] ask FFmpeg what it
#' supports. The `ffm_*()` pipeline functions, such as
#' [ffm_run()], are a safer way to build a command.
#' @family direct command functions
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' # A direct command: the function passes the string to FFmpeg unchanged
#' ffmpeg("-version")
#' @export
ffmpeg <- function(command) {
rlang::check_string(command)
# Redirect FFmpeg's stdin from an empty input (the `input = ""` temp file) so
# it cannot drain the parent process's stdin. FFmpeg reads stdin for
# interactive control while encoding; without this it would swallow whatever
# is feeding R's stdin (e.g. the example stream during R CMD check). This is
# the equivalent of FFmpeg's -nostdin flag, applied without touching the
# verbatim `command` string.
# A hung FFmpeg would otherwise block the session forever; the limit is off
# (0) unless the caller sets `tidymedia.timeout` (M69/D047).
limit <- resolve_timeout()
out <- guard_timeout(
"FFmpeg", limit,
system(glue('{find_ffmpeg()} {command}'), intern = TRUE, input = "",
timeout = limit)
)
out
}
# extract_frame_batch() --------------------------------------------------------
#' Extract a single frame from a video
#'
#' Save one frame of a video to an image file, selected either by timestamp or
#' by frame number. Provide exactly one of \code{timestamp} or \code{frame}.
#'
#' @inheritParams crop_video
#' @param outfile A string containing the path of the image file to write.
#' @param timestamp Either a number of seconds, a time-duration-syntax string,
#' or \code{NULL}. Provide exactly one of \code{timestamp} or \code{frame}.
#' @param frame Either an integerish frame number or \code{NULL}. Provide
#' exactly one of \code{timestamp} or \code{frame}.
#' @return `r command_return()`
#' @seealso [ffm_seek()], the pipeline function it uses to get the frame.
#' [extract_frame_batch()] for the many-file (batch) form.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # run = FALSE returns the reproducible command instead of executing it
#' extract_frame(video, "frame.png", timestamp = 0.5, run = FALSE)
#' @export
extract_frame <- function(infile, outfile, timestamp = NULL, frame = NULL,
run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
if (!is.null(timestamp) &&
!(rlang::is_double(timestamp, n = 1, finite = TRUE) ||
rlang::is_string(timestamp))) {
cli::cli_abort("{.arg timestamp} must be a single number, a string, or {.code NULL}.")
}
if (!is.null(frame)) rlang::check_number_whole(frame)
if (is.null(timestamp) == is.null(frame)) {
cli::cli_abort("Provide exactly one of {.arg timestamp} or {.arg frame}.")
}
# D074, ABOVE the frame->timestamp conversion rather than below the pipeline
# like its siblings: get_frame_rate() runs FFprobe, and it has a front door of
# its own now, so `frame = ` used to be refused in ITS name (M094 review F4).
# The rule is the same one -- as late as the verb allows, but never after a
# probe (D036).
resolve_timeout()
if (rlang::is_null(timestamp)) timestamp <- frame / get_frame_rate(infile)
ffm_finish(frame_pipeline(infile, outfile, timestamp), run,
call = rlang::current_env())
}
# frame_pipeline() --------------------------------------------------------
# Shared single-frame grab for extract_frame() and extract_frame_batch(): a fast
# input seek to the (already resolved) timestamp plus the still-image quality
# flags, one frame out. Both verbs build identical commands from this helper;
# frame->timestamp resolution stays in the verb layer (scalar vs. per-row).
frame_pipeline <- function(input, output, timestamp) {
p <- ffm_files(input, output)
p <- ffm_seek(p, start = timestamp, reencode = FALSE)
ffm_output_options(
p, "-qmin 1", "-q:v 1", "-qscale:v 2", "-frames:v 1", "-huffman optimal"
)
}
# sample_frames() ---------------------------------------------------------
#' Sample frames from a video at a fixed rate
#'
#' Sample a video into a numbered image sequence. Sample at a fixed rate
#' (\code{fps}) or at a fixed interval (\code{interval}, seconds between
#' frames). This is the first step for per-frame coding and for computer-vision
#' feature pipelines. Provide exactly one of \code{fps} or \code{interval}.
#'
#' \code{\link{extract_frame}} saves one frame, and
#' \code{\link{extract_frame_batch}} saves a set of frames that you list. This
#' function is different. It builds a \emph{single} FFmpeg command whose output
#' is a printf-style file name pattern. FFmpeg's \code{image2} muxer fills the
#' pattern. FFmpeg decides the frame count when it decodes the video, and you
#' do not list the frames. The function writes frames to \code{outdir} as
#' \code{<prefix>_<n>.<format>}, where \code{<n>} is a zero-padded integer
#' starting at 1. The glossary in \code{vignette("tidymedia")} explains media
#' terms such as frame rate.
#'
#' @inheritParams crop_video
#' @param outdir A string naming the directory to write the image sequence to.
#' The function creates it (recursively) if it does not exist.
#' @param fps The sampling rate, in frames per second: either a positive number
#' or an FFmpeg framerate expression string (for example \code{"30000/1001"}).
#' Provide exactly one of \code{fps} or \code{interval}.
#' @param interval The number of seconds between sampled frames (a positive
#' number). The function uses the reciprocal as the frame rate. Provide
#' exactly one of \code{fps} or \code{interval}.
#' @param format A string giving the output image file extension (one of
#' \code{"png"}, \code{"jpg"}, \code{"jpeg"}, \code{"bmp"}, \code{"tif"},
#' \code{"tiff"}, \code{"webp"}). (default = \code{"png"})
#' @param prefix A string used as the basename stem of each image, or
#' \code{NULL} to derive it from \code{infile}'s basename. (default =
#' \code{NULL})
#' @return `r command_return()`
#' @seealso [ffm_fps()], the pipeline function it uses to set the sampling rate.
#' [extract_frame()] for a single frame, and [extract_frame_batch()] for a
#' set of frames that you list. [sample_frames_batch()] for the many-file
#' form.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # run = FALSE returns the reproducible command instead of executing it
#' sample_frames(video, tempdir(), fps = 2, run = FALSE)
#' @export
sample_frames <- function(infile, outdir, fps = NULL, interval = NULL,
format = "png", prefix = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outdir)
if (!is.null(prefix)) rlang::check_string(prefix)
format <- check_image_format(format)
fps <- resolve_sample_fps(fps, interval)
outdir <- ensure_dir(outdir)
pattern <- derive_frame_pattern(infile, outdir, prefix, format)
p <- sample_frames_pipeline(infile, pattern, fps)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# sample_frames_pipeline() ------------------------------------------------
# Shared fixed-rate sampling pipeline for sample_frames() and
# sample_frames_batch(): a constant-rate fps filter into an image2 printf
# pattern, with a quality flag for the still encoder. Both verbs build identical
# commands from this helper. The rate is validated by resolve_sample_fps() at
# each verb's own front door before it gets here -- per row on the batch verb
# (M64) -- so a bad rate blames the verb, never this pipeline or ffm_fps();
# ffm_fps() still re-checks the value for its own direct callers. `output` is a
# %0Nd pattern, so the single command fans out to many image files (D003: still
# one input chain, one output target).
sample_frames_pipeline <- function(input, output, fps) {
p <- ffm_files(input, output)
p <- ffm_fps(p, fps = fps)
ffm_output_options(p, "-qscale:v 2")
}
# resolve_sample_fps() ----------------------------------------------------
# Resolve the exclusive fps/interval pair to a single frame-rate value for
# ffm_fps(): `fps` passes through (a positive number, coerced to double so it
# clears check_dim()'s integer rejection, M20; or an FFmpeg rate-expression
# string), while `interval` (seconds/frame) becomes its reciprocal. Enforces the
# exactly-one contract, mirroring extract_frame()'s timestamp/frame XOR.
resolve_sample_fps <- function(fps, interval,
call = rlang::caller_env()) {
if (is.null(fps) == is.null(interval)) {
cli::cli_abort("Provide exactly one of {.arg fps} or {.arg interval}.",
call = call)
}
if (!is.null(fps)) {
if (rlang::is_string(fps)) return(fps)
if (!(rlang::is_bare_numeric(fps, n = 1) && is.finite(fps) && fps > 0)) {
cli::cli_abort(
"{.arg fps} must be a single positive number or a string.", call = call
)
}
return(as.double(fps))
}
if (!(rlang::is_bare_numeric(interval, n = 1) && is.finite(interval) &&
interval > 0)) {
cli::cli_abort("{.arg interval} must be a single positive number.",
call = call)
}
1 / as.double(interval)
}
# derive_frame_pattern() --------------------------------------------------
# Build the image2 output pattern for one input: `<outdir>/<prefix>_%0Nd.<fmt>`,
# with `prefix` defaulting to the input's basename (sans extension). A fixed pad
# width keeps the numbering zero-padded and lexically sortable; FFmpeg widens it
# automatically past the cap. Basename prefixes distinguish most batch sequences
# sharing one `outdir`, but same-basename inputs still collide — the batch verb
# guards that at the pattern level before running.
derive_frame_pattern <- function(input, outdir, prefix, format, digits = 6L) {
if (is.null(prefix)) prefix <- tools::file_path_sans_ext(basename(input))
file.path(outdir, paste0(prefix, "_%0", digits, "d.", format))
}
# check_image_format() ----------------------------------------------------
# Validate `format` as a supported still-image extension, returning it
# lower-cased. Keeps a non-image container (e.g. "mp4") from silently producing
# a broken sequence, and a clean token from reaching the output pattern.
check_image_format <- function(format, arg = rlang::caller_arg(format),
call = rlang::caller_env()) {
rlang::check_string(format, arg = arg, call = call)
allowed <- c("png", "jpg", "jpeg", "bmp", "tif", "tiff", "webp")
format <- tolower(format)
if (!format %in% allowed) {
cli::cli_abort(
c("{.arg {arg}} must be a supported image format.",
"x" = "{.val {format}} is not one of {.val {allowed}}."),
call = call
)
}
format
}
# ensure_dir() ------------------------------------------------------------
# Create `dir` (recursively) if it is absent, then confirm it exists — so an
# uncreatable path (e.g. under an existing file) aborts here with a clear
# message rather than as an opaque FFmpeg write error.
ensure_dir <- function(dir, arg = rlang::caller_arg(dir),
call = rlang::caller_env()) {
if (!dir.exists(dir)) {
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
if (!dir.exists(dir)) {
cli::cli_abort(
c("Can't create {.arg {arg}}.", "x" = "Not a creatable directory: {.file {dir}}."),
call = call
)
}
}
dir
}
# audio_stream_map() ------------------------------------------------------
# Resolve an `audio_stream` selector to the FFmpeg stream specifier the audio
# verbs map: `0:a:<n>`, the n-th audio stream *within the input* (0-based), not
# the n-th stream overall. NULL is the documented default and, on the extraction
# verbs, resolves to the same `0:a:0` an explicit 0 does -- the argument's own
# sentinel for "no selection", which is what lets a batch column's NA cell say
# "leave this row on the default" (M43).
#
# `null_map` is what NULL resolves to, and it is a parameter because M45 added a
# caller whose no-selection default is a DIFFERENT MAP, not merely a different
# hint: on separate_audio_video() NULL stays `0:a` -- EVERY audio track -- which
# is what that verb has compiled since it shipped and what its Matroska callers
# receive today, so baking `0:a:0` in here would have silently narrowed them to
# one track. D023's NULL bullet reads the other way for the extraction verbs and
# the M45 D-entry records the split. Same shape as
# check_batch_audio_col(na_means =), parameterized at M43 when a new caller's NA
# meant something else (M40's lesson).
#
# The check lives here so every caller inherits it, including a batch row whose
# value arrives from an override column rather than the argument (M13/M32); the
# scalar verbs check again at their own front door so a bad argument blames the
# verb rather than aborting mid-fan-out (M41).
audio_stream_map <- function(audio_stream = NULL, null_map = "0:a:0",
call = rlang::caller_env()) {
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE,
arg = "audio_stream", call = call)
if (is.null(audio_stream)) return(null_map)
sprintf("0:a:%d", as.integer(audio_stream))
}
# pass_through_maps() -----------------------------------------------------
# The map pair the PASS-THROUGH verbs compile: every video stream, plus either
# every audio stream or the one track `audio_stream` names. Returned as a
# character vector for a single ffm_map() call, because ffm_map() appends and
# two calls would be indistinguishable from a pipeline that mapped twice by
# accident -- the thing test-ffm.R's invariant exists to catch.
#
# These verbs emitted NO -map before M47, so FFmpeg's implicit selection chose
# for them: one stream of each type, preferring whichever audio track carried
# the container's DEFAULT disposition. Measured on a 3-audio-track .mkv with
# DEFAULT on track 1 (ffmpeg 8.1.2): one audio stream out, and it was the
# SECOND track. D023's second bullet rules that out in terms that are not
# verb-scoped -- "a heuristic consulted only sometimes is still a heuristic" --
# so the map is now stated on every call.
#
# NULL resolves to `0:a`, EVERY audio track, which is M45's reading rather than
# D023's first-track one: these verbs pass audio through rather than producing
# an audio stream, so their unselected case has an answer an extraction verb
# does not have. That answers the question D025's fifth bullet left open.
#
# `0:v` and not `0:v:0`: it is the shape separate_stream_pipeline() already
# compiles, and narrowing video is a separate argument nobody has asked for
# (the M45-Out candidate row). Verified that -vf applies to both streams of a
# two-video-stream input under this map and exits 0.
#
# What this does NOT map is subtitles and data. A uniform `-map 0` would carry
# them, and was rejected at plan time: `-map 0` into .mp4 on a subtitle-bearing
# input fails outright (exit 8, no default mp4 subtitle encoder), which is a
# failure crop_video() already has today and M48 removes.
#
# The trailing `?` on the UNSELECTED specifiers is load-bearing, not a
# belt-and-braces flourish. A bare `-map 0:a` aborts FFmpeg outright when the
# input has no audio (exit 234, "Stream map '' matches no streams"), and a bare
# `-map 0:v` does the same on an audio-only input -- where master, emitting no
# map at all, exits 0 and passes the stream through. Both are ordinary research
# inputs (a silent screen recording; an audio file). Without the `?` this
# milestone would have shipped two regressions, and the suite caught only the
# first because one existing test happens to standardize a silent fixture.
#
# The NAMED track deliberately keeps no `?`: `0:a:9` on a 3-track input must
# stay an FFmpeg error, because every `@param audio_stream` in the package
# promises "Naming a track the input does not have is an FFmpeg error, not an R
# one" (D023). Making it optional would turn a mistyped index into a silently
# audio-less output.
# NOT used by normalize_audio(). M49 briefly routed that verb through here with
# a first-track `null_map`, then with a container-conditional video half; both
# broke audio-only destinations and both are gone (D030). That verb now maps
# audio alone via audio_stream_map() and carries no `?`, because when EVERY map
# specifier is optional and matches nothing FFmpeg discards the maps and reverts
# to default selection. Do not copy this helper's `0:a?` spelling into an
# audio-producing verb: this pair is for verbs whose product is a video file.
pass_through_maps <- function(audio_stream = NULL,
call = rlang::caller_env()) {
c("0:v?", audio_stream_map(audio_stream, null_map = "0:a?", call = call))
}
# warn_dropped_audio() ----------------------------------------------------
# Emit the single classed warning for inputs carrying audio tracks the output
# does not receive. `inputs` and `n` are parallel vectors (`n` = that input's
# audio-stream count from count_audio_streams_all(), NA where it could not
# be had); `rows` is the jobs-table row index per element, or NULL on the
# scalar path.
#
# ONE warning whatever the length. The batch form names every affected row
# instead of warning per row, so a large jobs table cannot bury the message
# under R's "There were 50 or more warnings" collapse (M44 gate). Scalar and
# batch share this builder so their wording cannot drift -- the divergence this
# repo has already fixed twice (M19, M35).
#
# NA `n` means the probe could not answer, and is skipped silently: D024 licenses
# this probe only while its outcome changes nothing but whether a diagnostic is
# signalled, so "could not check" must look exactly like "nothing to report".
# Callers gate on `run` and on audio_stream being NULL before they get here; this
# builder decides only whether there is anything to say.
warn_dropped_audio <- function(inputs, n, rows = NULL,
call = rlang::caller_env()) {
keep <- !is.na(n) & n > 1
if (!any(keep)) return(invisible(NULL))
inputs <- inputs[keep]
n <- as.integer(n[keep])
dropped <- n - 1L
bullets <- if (is.null(rows)) {
sprintf("%s carries %d audio tracks; the output takes 1 and drops %d.",
inputs, n, dropped)
} else {
sprintf("Row %d (%s) carries %d audio tracks; the output takes 1 and drops %d.",
rows[keep], basename(inputs), n, dropped)
}
# cli_warn() glue-interpolates every bullet in this function's own frame, so a
# file path carrying a brace is executed rather than printed: `my{video}.mkv`
# ABORTS the verb ("could not evaluate cli expression"), and `{n}.mkv` --
# naming a local of this very function -- silently prints a filename that does
# not exist. Either one turns a diagnostic into something observable beyond the
# diagnostic, which is exactly what D024 licenses this probe on NOT doing.
# sprintf() has already built the line, so escape rather than route through a
# cli field: doubling is what glue reads as a literal brace. Braces are legal
# in filenames on every platform this package supports (M44 review F1).
bullets <- gsub("}", "}}", gsub("{", "{{", bullets, fixed = TRUE), fixed = TRUE)
cli::cli_warn(
c(
"Dropping {sum(dropped)} audio track{?s} from {length(dropped)} input{?s}.",
rlang::set_names(bullets, rep("x", length(bullets))),
"i" = "Name the track you want with {.arg audio_stream}: {.val {0}} is \\
the first audio track, {.val {1}} the second.",
"i" = "{.fn probe_audio} lists the tracks, but its {.field index} column \\
counts ALL streams while {.arg audio_stream} counts audio streams \\
from {.val {0}} -- on a video file with three audio tracks those \\
read 1, 2, 3 there and 0, 1, 2 here."
),
class = "tidymedia_dropped_audio",
call = call
)
invisible(NULL)
}
# warn_dropped_audio_batch() ----------------------------------------------
# The batch form of the D024 diagnostic: probe the rows that named no track and
# emit ONE aggregated warning naming every affected row.
#
# Runs in the Layer-2 verb BEFORE ffm_batch(), never inside it. ffm_batch()'s
# contract is generic -- any verb, any pipeline -- and a track-drop diagnostic is
# these two verbs' semantics, so it does not belong in the runner (D011 settled
# that verb-agnostic verification may live there and verb-specific meaning may
# not, and an engine-signature change for one diagnostic inverts the thin-verb
# economy of IP1). Up front is also the better diagnostic: the warning lands
# before the fan-out spends its time encoding, while the caller can still stop
# and add audio_stream, and before FFmpeg's console output buries it.
#
# `audio_stream` is the scalar argument; an `audio_stream` column overrides it
# per row, where an NA cell is the column form of the NULL sentinel -- "leave
# this row on the first track" (D023) -- so an NA cell is a row that named NO
# track and is probed. A batch whose every row names one probes nothing at all.
# Unique inputs are probed once: a jobs table legitimately repeats an input (the
# package's own examples do) and the answer is per file, not per row.
warn_dropped_audio_batch <- function(jobs, audio_stream = NULL,
call = rlang::caller_env()) {
sel <- if ("audio_stream" %in% names(jobs)) {
jobs$audio_stream
} else {
rep(if (is.null(audio_stream)) NA_real_ else as.numeric(audio_stream),
nrow(jobs))
}
rows <- which(is.na(sel))
if (length(rows) == 0) return(invisible(NULL))
# The M082 seam, read here rather than at the three call sites so one return
# covers all three verbs. Below the rows check, not above it, so the batch
# form consults the option exactly when the scalar form does -- when a probe
# would otherwise run. A batch whose every row named a track reads no option
# and so cannot be aborted by a malformed one.
if (!resolve_check_tracks(call = call)) return(invisible(NULL))
inputs <- jobs$input[rows]
# progress = TRUE here and nowhere else: this is the one sweep that visits
# many files, serially, before the caller's work starts (M082).
counts <- count_audio_streams_all(inputs, call = call, progress = TRUE)
warn_dropped_audio(inputs, counts, rows = rows, call = call)
}
# extract_audio() ---------------------------------------------------------
# Shared recipe behind extract_audio() and extract_audio_batch(): map the audio
# stream out (dropping video), applying `audio_codec` (default "copy" =
# stream-copy, lossless). Holding it here gives the batch sibling per-row parity
# for free (M13); command assembly stays in Layer 1 (IP1/D002).
#
# The map is explicit (`0:a:0` by default) rather than absent. Emitting no -map
# left the choice to FFmpeg's default-stream heuristic, which prefers the track
# carrying the container's DEFAULT disposition -- so the extracted track depended
# on the file's flags rather than on anything the caller or this package said,
# and could differ across FFmpeg versions. `audio_stream` names the track
# instead (M43). The `-vn` from ffm_drop() is now redundant beside the map and is
# kept anyway: it costs nothing, keeps every existing compiled command a
# superset of what it was TOKEN-wise, and this verb is still documented as an
# ffm_drop() caller.
#
# A superset of tokens is NOT a superset of output streams, and the difference is
# a real behavior change. With no -map, FFmpeg's default selection carried one
# stream of EACH type, so a subtitle stream reached any container that accepts
# one; -vn removed only the video. Measured at M43 review: a video+audio+srt
# input written to .mkv gave audio+subtitle before and audio alone now.
# Audio-only containers never carried it, so .aac / .m4a / .mka are unaffected.
# Documented in NEWS rather than restored -- this verb extracts audio, and the
# subtitle was FFmpeg's default leaking through rather than anything the package
# chose.
extract_audio_pipeline <- function(input, output, audio_codec = "copy",
audio_stream = NULL,
call = rlang::caller_env()) {
p <- ffm_files(input, output)
# Through M35's apply_audio_codec() seam rather than ffm_codec() directly, so
# a malformed token names `audio_codec` and blames extract_audio() instead of
# naming Layer-1's `audio` and blaming ffm_codec() (M56). The seam's NULL
# branch emits no -codec:a, which is what ffm_codec(audio = NULL) did here
# anyway, so every compiled command is unchanged.
p <- apply_audio_codec(p, audio_codec, call = call)
p <- ffm_map(p, audio_stream_map(audio_stream, call = call))
ffm_drop(p, "video")
}
#' Extract the audio stream from a media file
#'
#' Take one audio track out of \code{infile}, and drop the video. When the
#' input has more than one audio track, \code{audio_stream} names which one to
#' take. With no \code{audio_stream}, the function takes the \strong{first}
#' audio track. The glossary in \code{vignette("tidymedia")} explains media
#' terms such as codec, container and stream copy.
#'
#' `r dropped_audio_paragraph()`
#'
#' `r check_tracks_off_paragraph()`
#'
#' @inheritParams crop_video
#' @param infile A string containing the path to a media file.
#' @param outfile `r write_path_param("audio")`
#' @param audio_codec A string naming the audio codec for the output stream.
#' The default \code{"copy"} copies the stream into the new container
#' without re-encoding. \code{NULL} writes no \code{-codec:a}, so the output
#' container's default encoder decides. That is useful when FFmpeg cannot
#' copy the source codec into the extension you asked for.
#' @param audio_stream `r audio_stream_param("take", "takes", "first")`
#' @return `r command_return()`
#' @seealso [ffm_drop()] and [ffm_codec()], the pipeline functions it wraps.
#' [convert_audio()] to re-encode the extracted audio.
#' [extract_audio_batch()] for the many-file form.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' extract_audio(video, "audio.aac", run = FALSE)
#' # Take the second audio track instead of the first
#' extract_audio(video, "audio.aac", audio_stream = 1, run = FALSE)
#' @export
extract_audio <- function(infile, outfile, audio_codec = "copy",
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
# allow_null is D022's family rule: NULL emits no -codec:a and lets the output
# container's default encoder decide. This verb refused it until M42 while
# extract_audio_batch() next door had always compiled the same call.
rlang::check_string(audio_codec, allow_null = TRUE)
# States the contract at the signature, where the repo puts scalar validation
# (M32/M37/M41). It is deliberately NOT what produces the blame: deleting this
# line leaves every test green, because audio_stream_map() carries the same
# check and its `call` already resolves to this verb's frame. Kept as
# defense-in-depth against that `call` chain being refactored away, not
# because it currently changes any message -- the earlier comment here claimed
# it did, which the M42 delete-and-run probe disproved at review.
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# D024's diagnostic probe. Gated on `run` because compilation and every
# run = FALSE call stay binary-free, and on a NULL audio_stream because that
# is the only case where the drop is implicit -- a caller who named a track
# chose it. The probe changes nothing but this warning: the compiled command
# below is byte-identical whether it runs, succeeds, or fails. isTRUE() rather
# than a bare `run` so a non-logical value still gets ffm_finish()'s own
# check_bool() message.
#
# resolve_check_tracks() is LAST in the chain (M082). A caller who switched
# the check off is spared the FFprobe call, and the two conditions before it
# keep their existing precedence: a `run = FALSE` call still consults no
# option, so it cannot be aborted by a malformed one, and a call that named
# an `audio_stream` still reads nothing it has no use for.
if (isTRUE(run) && is.null(audio_stream) && resolve_check_tracks()) {
warn_dropped_audio(infile, count_audio_streams_all(infile))
}
p <- extract_audio_pipeline(infile, outfile, audio_codec, audio_stream)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# separate_stream_pipeline() ----------------------------------------------
# Shared recipe behind separate_audio_video() and separate_audio_video_batch():
# build one single-output pipeline for a single stream — map `0:a` (audio) or
# `0:v` (video) out of `input` into `output`, naming that stream's encoder via
# `codec`. The default `"copy"` stream-copies, the lossless path this verb has
# had since D-M06-4; `NULL` is D016's sentinel, emitting no `-codec` at all so
# the output container's default encoder decides (M37). `codec` applies to the
# audio or the video slot by `stream`, so a caller's audio choice can never
# reach the video command. `hardware`/`fallback` ride the same routing (M38):
# nvenc encodes video, so only the video branch forwards them and the audio
# command is byte-identical whatever the caller asked for -- the routing lives
# here once rather than at each call site. `audio_stream` rides the same routing
# in the other direction: it narrows the audio map to one track and the video
# branch never reads it, so the video command is byte-identical whatever the
# caller selected (M45).
# Splitting one input into audio + video is a fan-out,
# so each stream stays its own single-output pipeline (D003/D007); both verbs
# wrap this once per stream. Command assembly stays in Layer 1 (IP1/D002). Kept
# ABOVE the roxygen block below so document() does not re-target it (M28 lesson).
separate_stream_pipeline <- function(input, output, stream, codec = "copy",
hardware = "none", fallback = FALSE,
audio_stream = NULL, quality = NULL,
call = rlang::caller_env()) {
# `null_map = "0:a"` keeps EVERY audio track when the caller named none, which
# is this verb's map since it shipped -- audio_stream_map()'s own default is
# the extraction verbs' `0:a:0` and would silently narrow to one track (M45).
map <- if (stream == "audio") {
audio_stream_map(audio_stream, null_map = "0:a", call = call)
} else {
"0:v"
}
p <- ffm_map(ffm_files(input, output), map)
if (stream == "audio") {
# nvenc encodes video, so `hardware`/`fallback` never reach this branch and
# the audio command is byte-identical whatever the caller asked for (M38).
apply_audio_codec(p, codec, call = call)
} else {
# The copy-versus-hardware contradiction (condition 1), worded once in
# check_hardware_needs_encode(). The guard sits in the shared recipe so both
# verbs inherit it per stream; ffm_batch builds every row's pipeline before
# running any (R/ffm_batch.R), so a batch fails before it encodes. The
# _batch verb ALSO calls it at its front door (M58), where the abort can
# name the verb; here it still covers separate_audio_video().
check_hardware_needs_encode(codec, hardware, call = call)
# `quality` rides the seam (M135); a "copy" codec with it set is refused
# there, by check_quality(), after the hardware contradiction above.
apply_video_codec(p, codec, hardware, fallback, quality, call = call)
}
}
# multi_audio_extensions -------------------------------------------------
# Output extensions naming a container that holds SEVERAL audio streams.
#
# The multi-track diagnostic below tells a caller to keep every track by
# writing a container that holds several. When their `audiofile` extension
# already names one, that advice is not merely unhelpful -- it is false blame:
# the failure cannot be the capacity refusal the message describes, so whatever
# FFmpeg did object to goes unnamed while the caller is told to do the thing
# they already did. The gate below therefore fails the diagnostic open on these
# extensions (M091).
#
# The list is STATIC and measured, never probed. FFmpeg exposes no "how many
# audio streams will this muxer accept" query, and settling it per call would
# mean spawning FFmpeg a second time on a call that has already failed.
#
# Measured 2026-08-30 against ffmpeg 9.0.1, writing the suite's three-audio-
# track fixture (make_multitrack_video(): three AAC streams) out with
# `-map 0:a`. Every extension here exited 0 carrying three audio streams --
# under `-c:a copy` for all but `webm`, `ogg` and `opus`, which hold no AAC and
# took `-c:a libopus`. Refused with exit 234, and so deliberately absent: mp3,
# wav, aac, flac, wv, caf, aiff, au, w64 -- each for a capacity reason, on the
# evidence the paragraph below gives.
#
# A refusal under ONE codec is not a capacity refusal, and reading it as one is
# how `ogg` and `opus` were first left off this list (D071). Each container
# above says capacity in its own words ("Exactly one MP3 audio stream is
# required", "wav muxer does not support more than one stream of type audio",
# "AIFF allows only one audio stream and a picture"); `wv`'s message names a
# codec, but it still exits 234 under `-c:a wavpack`, the one codec it holds.
#
# Layer-2 knowledge, kept beside the other separation helpers rather than in
# the engine: what a task verb's output container implies about that verb's own
# diagnostic is no business of ffm_run() (IP1/D002).
multi_audio_extensions <- c("mka", "m4a", "mp4", "mov", "mkv", "webm", "ogg",
"opus", "ts")
# The two renderers the separation help pages paste that vector in through.
# Same mechanism as `rd_verb_list()` in R/audio-stream-doc.R and for the same
# reason: roxygen evaluates them at document() time, so the prose cannot name a
# container list the vector does not hold. Before M93 both pages spelled the
# nine out by hand and said "nine" twice each, and the hand copies went stale
# on each of the two occasions the vector grew (M91).
# "\code{.mka}, \code{.m4a} and \code{.ts}" -- the extensions dotted the way
# the prose writes them, comma-joined with one `and` before the last.
multi_audio_rd_list <- function(exts = multi_audio_extensions) {
# An emptied vector would otherwise return character(0) and vanish silently
# from both blocks -- the one way this mechanism could lose the enumeration
# it exists to keep correct (rd_verb_list()'s reason, unchanged).
stopifnot(length(exts) >= 2)
items <- sprintf("\\code{.%s}", exts)
paste0(paste(items[-length(items)], collapse = ", "), " and ",
items[length(items)])
}
# The vector's length as the English word the prose reads with ("Those nine are
# an exclusion list"). A lookup, not a number-speller: past its last entry it
# aborts, so a vector grown out of its vocabulary stops document() with the
# length named rather than rendering `NA` into a help page.
#
# The first entry is the word for length TWO, not one -- the renderers refuse a
# vector under 2 members, so a word for one would never be reachable. Both the
# `n - 1L` index below and its bound read that offset; prepending "one" here
# would shift every word by a position.
multi_audio_count_words <- c("two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve")
multi_audio_rd_count <- function(exts = multi_audio_extensions) {
n <- length(exts)
stopifnot(n >= 2)
if (n > length(multi_audio_count_words) + 1L) {
stop("multi_audio_rd_count() cannot name a length of ", n,
"; extend multi_audio_count_words")
}
multi_audio_count_words[[n - 1L]]
}
# TRUE for each path whose extension names one of those containers.
#
# Case-insensitive: FFmpeg picks the output muxer from the extension without
# regard to case, so `out.MKA` is the same container as `out.mka` and an exact
# match would leave the false blame alive in an uppercase spelling (M091).
#
# The list is an EXCLUSION list, so everything else is FALSE -- a path with no
# extension, and any container nobody has measured, keep the diagnostic they
# have today rather than losing it to an omission.
holds_multiple_audio <- function(path) {
tolower(tools::file_ext(path)) %in% multi_audio_extensions
}
# run_separation_audio() --------------------------------------------------
# Run separate_audio_video()'s AUDIO command and, when FFmpeg refuses it on a
# multi-track input, re-raise with the way out. Lives in Layer 2, never in
# ffm_run(): the message names `audio_stream`, a Layer-2 argument the engine has
# no business knowing (IP1/D002), and an engine hook for one verb's diagnostic is
# the same inversion D024/RR02 Q3 rejected for ffm_batch().
#
# Enrichment is deliberately NARROW -- it fires only when the caller named NO
# track. With `0:a:<n>` mapped the command carried exactly one stream, so a
# failure is something else (a codec the container will not hold, a bad path) and
# "take one track with audio_stream" would be false under the branch that fired
# it -- M38's lesson, which this repo has now paid for twice.
#
# It is narrow in a second direction too: the message's other way out is "write
# a container that holds several", so on an `outfile` whose extension already
# names one the advice is false blame -- the failure cannot be the capacity
# refusal the message describes. Those extensions fail open as well (M091).
#
# The probe is D024's diagnostic licence at its narrowest: it runs only after
# FFmpeg has ALREADY failed, so the call aborts under every outcome and the probe
# decides only WHICH abort is signalled -- never whether execution proceeds
# (D024's third exclusion), never what was compiled, never a default, never a
# pipeline. It fails open to ffm_run()'s own abort, unchanged in class and
# status. Its MESSAGE is unchanged too as far as this function goes; what the
# caller finally reads carries one more bullet whenever the video half wrote its
# file, appended downstream by abort_after_video() to whichever condition the
# audio half raised -- this branch, the two beside it, and the enriched
# diagnostic alike (M090's contract, measured on this branch 2026-08-30).
run_separation_audio <- function(pipeline, infile, outfile, audio_stream,
call = rlang::caller_env()) {
# The verb the caller typed, for ffm_run()'s missing-encoder diagnosis (M139;
# ffm_finish() says why an attribute).
attr(pipeline, "tm_call") <- call
if (!is.null(audio_stream)) return(invisible(ffm_run(pipeline)))
tryCatch(
ffm_run(pipeline),
error = function(cnd) {
# The `tidymedia_ffmpeg_exit` class is also what tells a non-zero EXIT
# apart from every other way the run can fail: a missing ffmpeg binary
# aborts in run_program() carrying no class and no status at all, and a
# track count would be a nonsense answer to that. NA therefore means "not
# the failure this diagnostic is about" as well as "no status", and both
# fall through.
status <- ffmpeg_exit_status(cnd)
# Fail open: no status, or an `outfile` that already holds several audio
# streams, re-raises the ORIGINAL condition object, so its message, class
# and trace are the ones ffm_run() raises today (D024's fail-open
# consequence).
#
# The container gate is asked BEFORE the probe, unlike the track count
# below: on such an output the diagnostic cannot fire whatever the count
# turns out to be, so probing first would spawn FFprobe for an answer
# nothing reads. It is asked AFTER the status check for the same reason
# that one comes first -- a failure that is not an exit is not the failure
# this diagnostic is about, whatever the extension says (M091).
if (is.na(status) || holds_multiple_audio(outfile)) stop(cnd)
# A single-track input fails open too: with one stream mapped, the count
# is not what FFmpeg objected to.
n <- count_audio_streams_all(infile)
if (is.na(n) || n <= 1L) stop(cnd)
cli::cli_abort(
c(
"Can't write {.file {outfile}}: FFmpeg exited with status {status}.",
"x" = "{.file {infile}} carries {n} audio tracks and no
{.arg audio_stream} was named, so all {n} were mapped into one
output.",
"i" = "Most audio containers hold exactly one stream ({.file .aac},
{.file .mp3}, {.file .wav}) and FFmpeg fails when asked to
write more.",
"i" = "Take one track with {.arg audio_stream}: {.val {0}} is the
first audio track, {.val {1}} the second.",
"i" = "Or keep all {n} by writing a container that holds several --
Matroska ({.file .mka}) or {.file .m4a}."
),
# Both classes, most specific first: the enriched diagnostic keeps the
# name handlers were written against, and the exit class makes
# ?tidymedia's promise -- a failed FFmpeg run is catchable by one class
# -- true on this path too (M086). D062 leaves hierarchies open, and
# this branch only runs when `status` is a real non-NA exit number, so
# the class names the event that actually occurred.
class = c("tidymedia_multitrack_separation", "tidymedia_ffmpeg_exit"),
tm_status = as.integer(status),
parent = cnd,
call = call
)
}
)
}
# abort_after_video() -----------------------------------------------------
# Re-raise the condition separate_audio_video()'s AUDIO half raised, after the
# video half has had its turn, adding one bullet when the video file was written
# and the video run's own condition on `tm_video_error` when there was one.
#
# The condition object is the one the audio run produced -- not a rebuilt copy.
# Rebuilding would have to re-run cli's formatter over text it has already
# formatted, which is M44's brace trap on a path the caller chose, and would
# copy the class vector and `tm_status` across by hand, so any field left out
# would vanish silently. Appending to the condition's own body leaves both
# branches of run_separation_audio() -- the enriched multi-track diagnostic and
# the fall-open re-raise -- carrying exactly the classes and status they carry
# when the video command is not run at all.
#
# The bullet's path is formatted HERE, once, and attached as a finished string:
# an unattached "{.file {videofile}}" would leave a second glue pass to run over
# the caller's path, so an output named `v{n}.mp4` would name a local of
# whatever frame rendered the message (M44).
abort_after_video <- function(cnd, videofile, wrote, video_error = NULL) {
# The video run's condition, or NULL when that command succeeded, so the
# field's presence is the answer (D068). Every R condition is a list, so this
# needs no class guard -- unlike the note below, which lives in `body` and so
# reaches only an rlang condition. Assigning NULL adds no field, which is the
# same thing a caller reading `cnd$tm_video_error` sees.
cnd$tm_video_error <- video_error
if (wrote) {
note <- cli::format_inline(
"The video output was written to {.file {videofile}}."
)
# cli_abort() and rlang conditions carry their bullets in `body`; a bare
# error (a missing binary aborts in run_program() with neither) has only
# `message`, and stop() renders `message` alone -- an appended `body` rides
# along on the object and never reaches the reader (measured 2026-08-30 on
# a `simpleError`). So the guard is explicit and the note is REFUSED on a
# shape this function has not met, rather than assigned into a field
# nothing would print. Today none can reach this line with `wrote` TRUE --
# the causes that raise a bare condition stop the video command too, so
# nothing is written -- which is why the loss is left visible here instead
# of being papered over with a second formatting rule (M090).
if (inherits(cnd, "rlang_error")) {
cnd$body <- c(cnd$body, "i" = note)
}
}
stop(cnd)
}
# warn_failed_separation() ------------------------------------------------
# The batch form of T2's abort. separate_audio_video_batch() cannot abort on a
# failed row -- ffm_batch() records `success = FALSE` and carries on, which is the
# batch contract (D007) -- so the same diagnostic arrives as ONE warning after the
# fan-out, naming every audio row that failed on a multi-track input without
# naming a track.
#
# ONE warning whatever the length, for M44's reason: R collapses many warnings
# into "There were 50 or more warnings" and a large jobs table would bury the
# message it exists to deliver. The wording carries the same three clauses the
# scalar abort does -- the count, `audio_stream`, and a container that holds
# several -- so scalar and batch cannot drift, the divergence this repo has fixed
# twice (M19, M35).
#
# `rows` are INPUT row numbers (the caller's jobs table), not row numbers of the
# 2N-row result: a caller reads the message to fix a row of the table they wrote.
warn_failed_separation <- function(rows, inputs, outputs, n,
call = rlang::caller_env()) {
keep <- !is.na(n) & n > 1
if (!any(keep)) return(invisible(NULL))
rows <- rows[keep]
inputs <- inputs[keep]
outputs <- outputs[keep]
n <- as.integer(n[keep])
# Stated as fact about the CALL, never about the cause. "FFmpeg would not write
# all 3 to a.mka" was false whenever the row failed for an unrelated reason -- a
# missing output directory, an unknown encoder -- and .mka would have held all
# three (M45 review F2). What is always true is the count and the mapping; why
# FFmpeg refused is in its own output, not in this bullet.
bullets <- sprintf(
"Input row %d (%s) carries %d audio tracks, all %d mapped into %s, which failed.",
rows, basename(inputs), n, n, basename(outputs)
)
# Double every brace sprintf() has already interpolated: cli glue-evaluates
# each bullet in THIS frame, so a path like `my{n}.aac` would otherwise print a
# local of this function and `my{video}.aac` would abort the call outright.
# Braces are legal in filenames everywhere this package runs (M44 review F1).
bullets <- gsub("}", "}}", gsub("{", "{{", bullets, fixed = TRUE), fixed = TRUE)
cli::cli_warn(
c(
"{length(rows)} audio output{?s} failed on a multi-track input.",
rlang::set_names(bullets, rep("x", length(bullets))),
"i" = "Most audio containers hold exactly one stream ({.file .aac}, \\
{.file .mp3}, {.file .wav}) and FFmpeg fails when asked to write \\
more.",
"i" = "Take one track with {.arg audio_stream}, batch-wide or as a per-row \\
{.field audio_stream} column: {.val {0}} is the first audio track, \\
{.val {1}} the second.",
"i" = "Or keep every track by writing a container that holds several -- \\
Matroska ({.file .mka}) or {.file .m4a}."
),
class = "tidymedia_multitrack_separation",
call = call
)
invisible(NULL)
}
# Probe the failed audio rows of a finished separation batch and warn once.
#
# Runs AFTER ffm_batch() returns, unlike M44's up-front probe: this diagnostic is
# about rows that actually failed, which is not knowable before they run, and the
# probe therefore costs nothing on a batch where every row succeeds. Same D024
# licence as M44's (see this milestone's M45-D2): the outcome moves nothing but
# whether the warning is signalled, and an unanswerable count is skipped in
# silence rather than reported as a second failure.
#
# `out` is the 2N-row result; audio rows are the odd ones, so an input row number
# is (i + 1) %/% 2. A row that NAMED a track is excluded -- it mapped one stream,
# so a track count says nothing about why it failed (T2's narrowing).
warn_failed_separation_batch <- function(out, audio_stream = NULL,
call = rlang::caller_env()) {
if (!"success" %in% names(out)) return(invisible(NULL))
sel <- if ("audio_stream" %in% names(out)) {
out$audio_stream
} else {
rep(if (is.null(audio_stream)) NA_real_ else as.numeric(audio_stream),
nrow(out))
}
bad <- which(out$stream == "audio" & !out$success & is.na(sel))
# A row whose own `audiofile` already names a container holding several audio
# streams is dropped here, the batch counterpart of the scalar verb's gate:
# the warning's way out is "write a container that holds several", which that
# row already did, so the bullet would be false blame (M091).
#
# Dropped BEFORE the probe and before warn_failed_separation() sees the row:
# the probe is an FFprobe spawn per failed row whose answer nothing would
# read. The headline stays consistent either way -- warn_failed_separation()
# applies its own `keep` filter and counts what survives it -- so cost is the
# whole reason, not half of it.
bad <- bad[!holds_multiple_audio(out$output[bad])]
if (length(bad) == 0) return(invisible(NULL))
inputs <- out$input[bad]
counts <- count_audio_streams_all(inputs, call = call)
warn_failed_separation(
rows = (bad + 1L) %/% 2L,
inputs = inputs,
outputs = out$output[bad],
n = counts,
call = call
)
}
# Read FFmpeg's exit status off a `tidymedia_ffmpeg_exit` condition -- the class
# ffm_run(), the loudnorm analysis pass and the multi-track separation
# diagnostic all raise on a non-zero exit -- or NA
# for any other condition, and for one carrying the class but no `tm_status`.
# The message is never consulted: the class answers WHICH failure this is and
# the field carries the number, so rewording either abort cannot change what
# this returns.
ffmpeg_exit_status <- function(cnd) {
if (!inherits(cnd, "tidymedia_ffmpeg_exit")) return(NA_integer_)
status <- cnd$tm_status
if (is.null(status)) return(NA_integer_)
status
}
# separate_audio_video() --------------------------------------------------
#' Split a media file into separate audio and video files
#'
#' By default, each stream is copied, not re-encoded (\code{audio_codec =
#' "copy"}, \code{video_codec = "copy"}). A copy loses no quality and is fast.
#' Each output container must then support the source codec. For example, write
#' AAC audio from an MP4 to \code{.aac} or \code{.m4a}, not to \code{.mp3}. To
#' re-encode a stream, name an encoder (\code{audio_codec = "libmp3lame"}). Pass
#' \code{NULL} to set no codec option, so the output extension picks the
#' encoder. Each argument governs only its own output file. Where the video is
#' re-encoded, \code{hardware = "nvenc"} or \code{"videotoolbox"} moves that
#' encode onto a GPU. The audio output is never affected. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, container
#' and stream copy.
#'
#' @inheritParams extract_audio
#' @param audiofile `r write_path_param("audio")`
#' @param videofile `r write_path_param("video")`
#' @param audio_codec A string that names the encoder for \code{audiofile}. It
#' goes to FFmpeg's \code{-codec:a}. The default \code{"copy"} copies the
#' audio stream with no quality loss. A codec name (e.g.
#' \code{"libmp3lame"}) re-encodes it. \code{NULL} sets no \code{-codec:a},
#' so the \code{audiofile} extension picks the encoder.
#' @param video_codec A string that names the encoder for \code{videofile}. It
#' goes to FFmpeg's \code{-codec:v}. The default \code{"copy"} copies the
#' video stream with no quality loss. A codec name (e.g. \code{"libx264"})
#' re-encodes it. \code{NULL} sets no \code{-codec:v}, so the
#' \code{videofile} extension picks the encoder.
#' @param hardware The encoder backend for \code{videofile}. \code{"none"}
#' (default) uses the software \code{video_codec}. \code{"nvenc"} uses NVIDIA
#' GPU encoding, and \code{"videotoolbox"} uses Apple GPU encoding. Each uses
#' its own encoder for the family of \code{video_codec}. For example,
#' \code{"libx264"} becomes \code{"h264_nvenc"} or
#' \code{"h264_videotoolbox"}. With \code{video_codec = NULL}, the family is
#' H.264. Only video is encoded on the GPU, so this never affects
#' \code{audiofile}. The default \code{video_codec = "copy"} is a stream
#' copy, which runs no encoder at all. So a \code{hardware} other than
#' \code{"none"} with \code{video_codec = "copy"} is an error. Name an
#' encoder or pass \code{video_codec = NULL}. See
#' \code{\link{has_hardware_encoder}} for availability and its caveats.
#' `r hardware_probe_sentences()`
#' The stream-copy conflict above is caught first, so such a call aborts
#' without asking FFmpeg.
#' @param fallback `r fallback_param("software", unset = "injecting")`
#' @param quality `r quality_param()`
#' It applies to \code{videofile} only.
#' @param audio_stream `r audio_stream_param("write to \\code{audiofile}", "keeps", "every", extra = audio_stream_extras$separation_container)`
#' @param run A logical: run the commands through FFmpeg (\code{TRUE}, default)
#' or return the compiled commands without running them (\code{FALSE}).
#' @return A named character vector of the two compiled commands
#' (\code{audio}, \code{video}). It is invisible when \code{run = TRUE}. Under
#' \code{run = TRUE}, the audio command runs first and the video command runs
#' second. The video command runs whether or not the audio command succeeded.
#' A failed audio command still aborts the call. By then, the video command
#' has written \code{videofile}, unless it failed too. See \emph{When the
#' audio output fails}.
#' @seealso [ffm_map()] and [ffm_codec()], the pipeline functions it wraps.
#' [has_hardware_encoder()] for the \code{hardware} argument.
#' [extract_audio()] to pull out just the audio.
#' [probe_audio()] to list an input's audio tracks.
#' @section When the audio output fails:
#' The two commands run in order: audio first, video second. The video command
#' runs even when the audio command failed, so a failed audio half does not cost
#' you the video. In that case, the call still aborts with the audio failure.
#' That error carries one added line that names the video file that was written.
#' When the video command fails too, the added line is not there. The audio
#' failure is still the error you get. FFmpeg's own output for the failed video
#' command is printed above it.
#'
#' A time limit reached on the audio command is held like any other audio
#' failure, so the video command still runs. The video command gets a fresh
#' limit of its own, because \code{\link{with_timeout}()} limits each program
#' that the call starts, not the call. So a call whose audio half reaches the
#' limit can wait up to two limits, not one.
#'
#' A failed command treats its own output path by the same rule on either path.
#' It removes a partial file that the run wrote. It leaves a file that was
#' already at that path, and that FFmpeg never wrote to, exactly as it was. So
#' neither failure path promises that the path is empty afterwards. It promises
#' only that nothing half-written is left there. The audio failure's own error
#' says which of the two happened to \code{audiofile}. The same error's
#' \code{tm_video_error} field says what became of the video command. It holds
#' the condition that command raised when it failed too, and \code{NULL} when it
#' succeeded.
#'
#' The default keeps every audio track. So FFmpeg fails when it writes a
#' multi-track input to a container that holds only one track (\code{.aac},
#' \code{.mp3}, \code{.wav}). When that happens, the error also reports how many
#' audio tracks \code{infile} carries, and it names the two ways out. Use
#' \code{audio_stream} to write one track, or use a container such as
#' \code{.mka} or \code{.m4a} to keep them all.
#'
#' The error carries that extra report only when all four of these hold:
#' \itemize{
#' \item No \code{audio_stream} was named.
#' \item FFmpeg returned a non-zero exit status.
#' \item \code{infile} carries more than one audio track.
#' \item The extension of \code{audiofile} is not among the containers named
#' here as holding several audio streams.
#' }
#' Those containers are `r multi_audio_rd_list()`. The
#' `r multi_audio_rd_count()` are an exclusion list and not a survey. FFmpeg
#' writes several audio streams into other containers too, \code{.avi} and
#' \code{.nut} among them. A failure on one of those still gets the report. The
#' container condition keeps the report off a call that already does what the
#' report advises. When a call writes to one of the `r multi_audio_rd_count()`,
#' the failure cannot be the container refusing a second audio stream. The
#' report would then leave unnamed whatever FFmpeg did object to.
#'
#' If any of the four does not hold, the error you get is the one the run itself
#' raised, whatever that error is. It has the same class, the same status field
#' and the same message. The one difference is the line saying that the video
#' output was written. A failing audio half carries that line when the video
#' command wrote its file and the audio failure is an rlang condition. When the
#' exit status is the one that does not hold, there is no exit status to carry.
#' A run that never reached FFmpeg has none.
#'
#' The report states what the call \emph{did}: the track count, and that every
#' track was mapped into one output. It never states why FFmpeg refused.
#' FFmpeg's own error and exit status are printed beneath it and carried on the
#' condition. They remain the only authority on the cause. Several causes look
#' alike from here. A stream copy into a container that will not hold the source
#' codec fails on a multi-track input too. The default \code{audio_codec =
#' "copy"} into \code{.mp3} is one example. An unknown encoder and a missing
#' output directory fail the same way.
#'
#' The condition carries two class names, so a caller can catch it at either
#' width. It is \code{tidymedia_ffmpeg_exit}, the class that every non-zero
#' FFmpeg exit raises. An exit-status handler catches that class, and the number
#' is on the condition's \code{tm_status} field. It is also
#' \code{tidymedia_multitrack_separation}, the class of this report itself.
#' Catch that class when it is this failure in particular you want:
#'
#' \preformatted{
#' tryCatch(
#' separate_audio_video("three-tracks.mkv", "audio.mp3", "video.mp4"),
#' tidymedia_ffmpeg_exit = function(cnd) cnd$tm_status
#' )
#' }
#'
#' When the report is omitted, the error that reaches the caller is the one the
#' run itself raised, apart from that video-output line. A non-zero exit still
#' answers to \code{tidymedia_ffmpeg_exit}. A failure that is not an exit
#' answers to neither class here. An FFmpeg that the package cannot locate
#' raises an error with no \code{tidymedia_} class at all. A reached limit
#' raises \code{tidymedia_timeout}.
#'
#' Counting the tracks means running FFprobe, so the report is not guaranteed.
#' The error has it when FFprobe is available and \code{infile} can be probed.
#' Otherwise the package omits it silently and leaves FFmpeg's own error alone.
#' So the report may not appear, and its absence is never itself a second
#' failure. The count never runs under \code{run = FALSE} and never changes the
#' compiled commands. It is skipped when \code{audio_stream} names a track, or
#' when \code{audiofile} names one of the multi-stream containers above. With
#' one track mapped, the track count cannot be what FFmpeg objected to.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' separate_audio_video(video, "audio.aac", "video.mp4", run = FALSE)
#' # transcode the audio to MP3 while copying the video through untouched
#' separate_audio_video(video, "audio.mp3", "video.mp4",
#' audio_codec = "libmp3lame", run = FALSE)
#' # write only the second audio track (this sample has one, so compile only)
#' separate_audio_video(video, "audio.aac", "video.mp4",
#' audio_stream = 1, run = FALSE)
#' @export
separate_audio_video <- function(infile, audiofile, videofile,
audio_codec = "copy", video_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(audiofile)
rlang::check_string(videofile)
# Resolve `hardware` at the front door: the copy guard below compares it to
# "none", so the unresolved default vector would fire the guard on every
# default call. Validating here also attributes the error to this verb (M37).
hardware <- rlang::arg_match(hardware)
rlang::check_bool(fallback)
# Last of the front-door checks, so adding it cannot move the precedence of
# the ones above (M41). It duplicates the check inside audio_stream_map(),
# which the audio pipeline below always reaches with `call` resolving to this
# frame -- so on the scalar verb it is defense-in-depth and parity with the
# batch sibling, where the same call IS load-bearing because the reshape reads
# NA as the NULL sentinel. No test is named after this line (M43's finding).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# One input -> two outputs is a fan-out: emit two single-output pipelines
# (D-M03-2) rather than a dual-`-map` command the linear engine can't model.
# separate_stream_pipeline() carries the per-stream recipe shared with
# separate_audio_video_batch(), and token-checks each codec there.
# `audio_stream` is passed to the audio call only: the video command cannot
# narrow its map even by mistake, because the value never reaches it.
audio <- separate_stream_pipeline(infile, audiofile, "audio", audio_codec,
hardware, fallback, audio_stream)
video <- separate_stream_pipeline(infile, videofile, "video", video_codec,
hardware, fallback, quality = quality)
commands <- c(audio = ffm_compile(audio), video = ffm_compile(video))
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
if (run) {
# The audio command still runs FIRST and its failure is still what aborts
# the verb -- but the video command now runs either way, so a failed audio
# half no longer costs the caller the video (M088). Any failure of the audio
# run is held, not just a non-zero exit: one rule to state and one to
# document. A reached limit is held like the rest, and the video command then
# runs on a FRESH budget and can well succeed, so a timed-out audio half can
# reach the caller as `tidymedia_timeout` carrying the video-written bullet
# -- measured, and the cost is a second spawn past the caller's limit, which
# is the limit's documented per-spawned-program scope (D066 supersedes the
# claim D065 made here). Layer 1 removes what a failed run WROTE, on either
# half, and leaves a file it never wrote to as it found it (D046).
#
# The video run names this verb if its encoder is missing (M139; ffm_finish()
# says why an attribute). The audio half's helper sets its own.
attr(video, "tm_call") <- rlang::current_env()
# One call, one encoder-check timeout warning (D049): when the audio half's
# encoder-check warning (`tm_check = "encoders"`) passes by, the video run
# skips the missing-encoder diagnosis rather than wait on the listing a
# second time (M139 review return 1). The track-count probe's own timeout
# warning is another check: it passes by untouched and sets nothing (M139
# review pass 2).
held <- withCallingHandlers(
tryCatch({
run_separation_audio(audio, infile, audiofile, audio_stream)
NULL
}, error = function(cnd) cnd),
tidymedia_probe_timeout = function(w) {
if (identical(w$tm_check, "encoders")) {
attr(video, "tm_no_diagnosis") <<- TRUE
}
}
)
if (is.null(held)) {
ffm_run(video)
} else {
# What the added line answers is "did this run write videofile?", and the
# video run's exit status does not answer it on its own: a command can
# return 0 having left a pre-existing file untouched, which is the case
# the same pre/post comparison Layer 1 uses to decide what a failed run
# WROTE (D046, output_snapshot()) is here to settle.
#
# It does not answer it alone either. A failed run that had already
# written over a file the caller kept at that path leaves the two
# snapshots DIFFERENT, because Layer 1 removed what that run wrote and
# the caller's file went with it -- so a changed snapshot would name a
# path now holding nothing, and the read-only-directory branch of the
# removal would name a path holding a half-written file. Neither is a
# video output. So the two answers are taken together: the run has to
# have succeeded AND to have changed what is at the path (M090 review,
# superseding this milestone's implement gate; reproduced end to end).
before_video <- output_snapshot(videofile)
# The video run's own condition is HELD the same way the audio one is,
# and travels to the caller on the raised condition's `tm_video_error`
# field (D068). It is still not reported: no rendered text changes on any
# path, so D065's reasoning for one message survives -- what is superseded
# is only D065's decision to throw the object away, which left a caller
# who could read FFmpeg's console output, and nothing else, to learn that
# the video half had failed too.
video_error <- tryCatch({
ffm_run(video)
NULL
}, error = function(cnd) cnd)
wrote <- is.null(video_error) &&
!identical(output_snapshot(videofile), before_video)
abort_after_video(held, videofile, wrote, video_error)
}
invisible(commands)
} else {
commands
}
}
# convert_audio() ---------------------------------------------------------
# Shared recipe behind convert_audio() and convert_audio_batch(): map the audio
# stream out and either encode at highest VBR quality (`audio_codec = NULL`, the
# extension picks the codec) or pin `-c:a` to `audio_codec`. The per-value
# check_string(audio_codec) lives here so the batch sibling inherits it per row
# (M13); command assembly stays in Layer 1 (IP1/D002).
#
# The map is `0:a:<n>` -- ONE audio stream of the input -- not `a`, which is
# every audio stream. On a multi-track input the unbounded form fed three
# streams to a single-stream muxer and FFmpeg aborted ("Exactly one MP3 audio
# stream is required", exit 65514) leaving a zero-byte output, against a
# documented contract that has always been singular. The hotfix made the track
# taken deterministic and one; `audio_stream` now lets the caller choose WHICH,
# and its default resolves to the same `0:a:0` the hotfix pinned (M43).
#
# The argument was spelled `format` until M40 renamed it to D014's `audio_codec`
# vocabulary; the NULL branch is unchanged, so every default command stays
# byte-identical. NULL here means "-q:a 0", NOT D016's emit-nothing sentinel --
# the departure is deliberate and recorded in D021.
convert_audio_pipeline <- function(input, output, audio_codec = NULL,
audio_stream = NULL,
call = rlang::caller_env()) {
p <- ffm_files(input, output)
p <- ffm_map(p, audio_stream_map(audio_stream, call = call))
if (is.null(audio_codec)) {
p <- ffm_output_options(p, "-q:a 0")
} else {
rlang::check_string(audio_codec)
# Through M35's seam (M56): a malformed token now names `audio_codec` and
# blames convert_audio(), where ffm_codec() named Layer-1's `audio`. The
# check_string() above stays and still fires first, so the NON-string
# message and its blame target are untouched here and in the batch sibling
# that inherits this helper's per-row validation (M41).
p <- apply_audio_codec(p, audio_codec, call = call)
}
p
}
#' Extract or convert a media file's audio track
#'
#' Write the audio stream of \code{infile} into \code{outfile}. By default
#' (\code{audio_codec = NULL}), the output format follows the \code{outfile}
#' file extension, at the highest VBR quality (\code{-q:a 0}). For example, an
#' \code{.mp3} extension gives an MP3. Pass \code{audio_codec} to set the output
#' audio codec yourself, whatever the extension is. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec and stream.
#'
#' When \code{infile} has more than one audio track, \code{audio_stream} names
#' which one to take. With no \code{audio_stream}, the function takes the
#' \strong{first} one.
#'
#' `r dropped_audio_paragraph()`
#'
#' `r check_tracks_off_paragraph()`
#'
#' @inheritParams extract_audio
#' @param audio_codec An optional string naming the output audio codec (e.g.
#' \code{"libmp3lame"}, \code{"aac"}, \code{"flac"}), passed to FFmpeg's
#' \code{-c:a}. When \code{NULL} (default), FFmpeg infers the codec from the
#' \code{outfile} extension and encodes at the highest VBR quality. On the
#' other task functions, \code{NULL} means "leave the codec unset". Here
#' \code{NULL} does \emph{not} leave the codec unset. \code{NULL} selects
#' \code{-q:a 0}.
#' @param audio_stream `r audio_stream_param("take", "takes", "first")`
#' @return `r command_return()`
#' @seealso [ffm_codec()] and [ffm_map()], the pipeline functions it wraps.
#' [extract_audio()] to copy audio without re-encoding.
#' [convert_audio_batch()] for the many-file form.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' convert_audio(video, "audio.mp3", run = FALSE)
#' convert_audio(video, "audio.m4a", audio_codec = "aac", run = FALSE)
#' # Convert the second audio track instead of the first
#' convert_audio(video, "audio.mp3", audio_stream = 1, run = FALSE)
#' @export
convert_audio <- function(infile, outfile, audio_codec = NULL,
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
# Duplicates the check inside convert_audio_pipeline() on purpose. That helper
# is shared with convert_audio_batch(), which relies on it for per-row
# validation, so threading a `call` through it would also rewrite the batch
# verb's per-row messages; checking here instead blames this verb and leaves
# the batch path untouched (M41). NULL passes straight through: it is not the
# emit-nothing sentinel here, it selects `-q:a 0` (D021).
#
# Spelled this way rather than check_string(allow_null = TRUE) so the message
# matches convert_audio_batch()'s, which is the identical guard on the
# identical value. The allow_null spelling said "must be a single string or
# `NULL`" while the batch sibling said "must be a single string" -- a
# divergence this milestone introduced, and one M41 has no business
# introducing when it exists to make these messages agree (review A7).
# Neither message mentions the `NULL` both verbs accept; that is a
# pre-existing habit across the codec family (review A8) and M42's to settle.
if (!is.null(audio_codec)) rlang::check_string(audio_codec)
# States the contract at the signature, where the repo puts scalar validation
# (M32/M37/M41). It is deliberately NOT what produces the blame: deleting this
# line leaves every test green, because audio_stream_map() carries the same
# check and its `call` already resolves to this verb's frame. Kept as
# defense-in-depth against that `call` chain being refactored away, not
# because it currently changes any message -- the earlier comment here claimed
# it did, which the M42 delete-and-run probe disproved at review.
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# D024's diagnostic probe; see extract_audio() for why it is gated on `run`
# and on a NULL audio_stream.
if (isTRUE(run) && is.null(audio_stream) && resolve_check_tracks()) {
warn_dropped_audio(infile, count_audio_streams_all(infile))
}
# No `...` here, so a stale `format =` gets R's own `unused argument` error --
# no guard needed (M37 lesson; the batch sibling, which has `...`, does need
# one).
p <- convert_audio_pipeline(infile, outfile, audio_codec, audio_stream)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# crop_video() ------------------------------------------------------------
# Shared recipe behind crop_video() and crop_video_batch(): a crop filter to the
# requested rectangle mapping every stream through. The four geometry values are
# validated by check_dim() at each verb's own front door before they get here --
# per row on the batch verb -- so a bad dimension blames the verb rather than
# ffm_crop() (M59/M64); ffm_crop() still re-checks them for its own direct
# callers. Command assembly stays in Layer 1 (IP1/D002).
crop_video_pipeline <- function(input, output, width, height,
x = "(in_w-out_w)/2", y = "(in_h-out_h)/2",
video_codec = NULL, audio_codec = "copy",
hardware = "none",
fallback = FALSE, audio_stream = NULL,
quality = NULL,
call = rlang::caller_env()) {
p <- ffm_files(input, output)
p <- ffm_crop(p, width = width, height = height, x = x, y = y)
# Was `ffm_map(p, "0")`: every stream, including subtitles and data. That
# carried every audio track, which is the behavior D026 keeps -- but it also
# carried subtitles, which fails outright into .mp4 on a subtitle-bearing
# input (exit 8, no default mp4 subtitle encoder), a failure this verb had
# today and M48 removes. The pair also gives the caller a way to name ONE
# track, which `-map 0` never offered (M48/D026).
p <- ffm_map(p, pass_through_maps(audio_stream, call = call))
# The map carries the audio through, and the default audio_codec = "copy"
# stream-copies it rather than letting the container's default encoder
# re-encode it (M35/D017).
p <- apply_audio_codec(p, audio_codec, call = call)
# The default video_codec = NULL emits no -codec:v, so the output keeps its
# container's default *video* encoder. This no longer makes the whole command
# byte-identical to the pre-M34 one -- M35's audio default added -codec:a copy
# (M34/D016, M35/D017). `quality` rides the same seam (M135).
apply_video_codec(p, video_codec, hardware, fallback, quality, call = call)
}
#' Crop a video to a rectangular region
#'
#' Crop a video to a rectangular region. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, container
#' and stream copy.
#'
#' @param infile A string containing the path to a video file.
#' @param outfile `r write_path_param("video")`
#' @param width The width of the output video, in pixels.
#' @param height The height of the output video, in pixels.
#' @param x The horizontal offset, in pixels, of the left edge of the crop.
#' (default = centered)
#' @param y The vertical offset, in pixels, of the top edge of the crop.
#' (default = centered)
#' @param video_codec `r video_codec_unset_param()`
#' @param audio_codec A string naming the output audio codec.
#' `r audio_codec_copy_sentences("the audio")` Stream-copying fails if
#' the output container cannot hold the source audio codec, for example FLAC
#' in \code{.mp4}. In that case, name an encoder.
#' @param hardware `r hardware_param(null_default = TRUE)`
#' @param fallback `r fallback_param("software", unset = "picking")`
#' @param quality `r quality_param()`
#' @param audio_stream `r audio_stream_param("carry into the output", "carries", "every", extra = audio_stream_extras$passthrough_subtitles)`
#' @param run A logical: run the command through FFmpeg (\code{TRUE}, default)
#' or return the compiled command without running it (\code{FALSE}).
#' @return `r command_return()`
#' @seealso [ffm_crop()], the pipeline function it wraps;
#' [has_hardware_encoder()] for the \code{hardware} toggle;
#' [crop_video_batch()] for the many-file form.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' crop_video(video, "cropped.mp4", width = 160, height = 120, run = FALSE)
#' @export
crop_video <- function(infile, outfile, width, height,
x = "(in_w-out_w)/2", y = "(in_h-out_h)/2",
video_codec = NULL, audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL, audio_stream = NULL,
run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
rlang::check_string(video_codec, allow_null = TRUE)
rlang::check_string(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
# The four geometry values, swept here so a bad dimension blames this verb
# instead of ffm_crop(), the builder the caller never called (M64). Same
# shared checker the builder calls -- never a second copy of the message --
# and the same site crop_video_batch() sweeps per row (M59-D1/M59-D2).
#
# Placed at the END of this verb's front-door validation, where the value was
# effectively read before: ffm_crop() runs first inside crop_video_pipeline(),
# ahead of the codec seams and pass_through_maps(), so every check above still
# reports first and every check below still reports after (M41).
check_dim(width)
check_dim(height)
check_dim(x, inclusive = TRUE)
check_dim(y, inclusive = TRUE)
# No front-door check for `audio_stream`, matching standardize_video() (M47
# review F8). It would be the only guard on this verb reporting BEFORE the
# dimensions, so a caller wrong about a dimension AND the track would be told
# about the track -- M41's precedence trap. That reasoning used to cite
# ffm_crop() as the dimension validator; the sweep above is now the site, and
# it sits above this comment for exactly the same reason (M64).
# pass_through_maps() carries the identical track check with `call` resolving
# to this frame, so the blame is unchanged. The BATCH sibling keeps its own,
# where it is load-bearing.
p <- crop_video_pipeline(infile, outfile, width, height, x, y,
video_codec, audio_codec, hardware, fallback,
audio_stream, quality = quality)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# format_for_web() --------------------------------------------------------
# Shared recipe behind format_for_web() and format_for_web_batch(): the fixed
# web-delivery re-encode (H.264 + yuv420p + AAC + faststart), padding odd
# dimensions down to even as the codec requires. No per-row knobs — every input
# gets the same recipe. Command assembly stays in Layer 1 (IP1/D002).
format_for_web_pipeline <- function(input, output, hardware = "none",
fallback = FALSE, audio_stream = NULL,
quality = NULL,
call = rlang::caller_env()) {
p <- ffm_files(input, output)
p <- ffm_crop(p, width = "floor(in_w/2)*2", height = "floor(in_h/2)*2")
# This verb emitted NO map until M49, so FFmpeg's implicit selection picked
# one audio track for it -- whichever carried the container's DEFAULT
# disposition, measured as the THIRD track of a 3-track fixture. D026's
# every-track rule applies here unchanged: the recipe re-encodes audio to AAC
# and writes .mp4, a container that holds many audio tracks, so the
# unselected case has no reason to narrow (D028).
p <- ffm_map(p, pass_through_maps(audio_stream, call = call))
# The recipe stays H.264 (family fixed); hardware = "nvenc" swaps libx264 for
# h264_nvenc when available. Layer 2 only computes the codec name (D009, IP1).
#
# Resolved HERE rather than at the top of this function (M095). Under
# hardware = "nvenc" the resolution asks this FFmpeg build what encoders it
# has, and asking first meant a malformed `audio_stream` came back as "nvenc
# is not available" -- the machine deciding which error the caller sees. This
# verb's only machine-independent check is pass_through_maps()' track check
# above, so one line below it is as late as the resolution can go. The
# compiled command is unchanged: ffm_groups() emits by group, not by call
# order (measured over every cell by data-raw/nvenc-probe-order-baseline.R).
#
# Through the shared emit half since M135, so `quality` takes the one seam
# every verb uses: the literal "libx264" is checked (idempotently) and
# resolved there, and the flag is emitted after the codec.
p <- ffm_codec(p, audio = "aac")
p <- emit_video_codec(p, "libx264", hardware, fallback, quality, call = call)
p <- ffm_pixel_format(p, "yuv420p")
ffm_output_options(p, "-movflags +faststart")
}
#' Re-encode a video for web playback
#'
#' Re-encode a video into a widely compatible, web-friendly form: H.264 video
#' with \code{yuv420p} and \code{+faststart}, and AAC audio. Odd dimensions are
#' padded down to even values, as the codec requires. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, pixel format
#' and re-encode.
#'
#' @inheritParams crop_video
#' @param hardware The encoder backend. \code{"none"} (default) uses software
#' libx264. \code{"nvenc"} uses NVIDIA GPU H.264 encoding
#' (\code{"h264_nvenc"}), and \code{"videotoolbox"} uses Apple GPU H.264
#' encoding (\code{"h264_videotoolbox"}). The backend you name is the one
#' used. An unavailable one aborts unless \code{fallback = TRUE}. See
#' \code{\link{has_hardware_encoder}}.
#' `r hardware_probe_sentences()`
#' @param fallback `r fallback_param("libx264")`
#' @param quality `r quality_param(fixed_h264 = TRUE)`
#' @param audio_stream `r audio_stream_param("carry into the output", "carries", "every", extra = audio_stream_extras$passthrough_subtitles)`
#' @return `r command_return()`
#' @seealso [ffm_codec()] and [ffm_pixel_format()], among the pipeline functions
#' it wraps;
#' [has_hardware_encoder()] for the \code{hardware} toggle;
#' [standardize_video()] for a configurable re-encode;
#' [format_for_web_batch()] for the many-file form.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' format_for_web(video, "web.mp4", run = FALSE)
#' @export
format_for_web <- function(infile, outfile,
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
hardware <- rlang::arg_match(hardware)
# No front-door check for `audio_stream`, matching crop_video() and
# standardize_video() (M47 review F8): pass_through_maps() carries the
# identical check with `call` resolving to this frame, so the blame is
# unchanged and this verb gains no guard that reorders its complaints. The
# BATCH sibling keeps its own, where it is load-bearing.
p <- format_for_web_pipeline(infile, outfile, hardware, fallback, audio_stream,
quality = quality)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# strip_metadata() --------------------------------------------------------
# Shared recipe behind strip_metadata() and strip_metadata_batch(): a lossless
# stream copy that discards all container/global metadata and chapters, and
# muxes bit-exactly so FFmpeg does not re-stamp its own creation_time / encoder
# tag onto the output. Holding it here gives the batch sibling per-row parity
# for free (M13). Metadata scrubbing is pure command assembly, so compile stays
# binary-free (IP1/D002).
strip_metadata_pipeline <- function(input, output) {
p <- ffm_files(input, output)
# -c:v copy -c:a copy -map 0: carry every stream through untouched.
p <- ffm_copy(p)
# -map_metadata -1 drops global tags (creation_time, location/GPS, make/model,
# title, comment); -map_chapters -1 drops chapters; -fflags +bitexact stops the
# muxer writing a fresh creation_time and an encoder=Lavf... tag. Per-stream
# tags (handler_name, language) and codec-embedded identifiers survive a copy.
ffm_output_options(
p, "-map_metadata -1", "-map_chapters -1", "-fflags +bitexact"
)
}
#' Strip identifying metadata from a media file
#'
#' Remove a media file's container and global metadata tags, together with any
#' chapters, and write a de-identified copy. The tags include creation time,
#' GPS or other location, device make and model, title and comment. Use it to
#' de-identify research recordings, for example for an IRB (a research ethics
#' board). The audio and video streams are **stream-copied**, not re-encoded. So the operation is
#' lossless and fast, and the picture and sound are bit-for-bit unchanged. That
#' includes any rotation display matrix, which is stream side data and not a
#' metadata tag. The glossary in \code{vignette("tidymedia")} explains media
#' terms such as container, stream and stream copy.
#'
#' @details
#' The output is written bit-exactly (\code{-fflags +bitexact}). So FFmpeg does
#' not stamp the container again with a fresh \code{creation_time}, or with an
#' \code{encoder} tag that names its own version. Either tag would defeat
#' de-identification and reproducibility.
#'
#' Because the streams are copied and not re-encoded, some data is not removed.
#' Those are identifiers **inside** the encoded bitstream, and per-stream
#' metadata such as \code{handler_name} or \code{language}. Removing them would
#' require one of two things. One is re-encoding, which is out of scope for this
#' function, so use the direct command \code{\link{ffmpeg}} for it. The other
#' is per-stream metadata mapping that must probe the file first.
#'
#' @inheritParams extract_audio
#' @param outfile A string containing the path of the de-identified file to
#' write. Use the same container extension as \code{infile} so the copied
#' streams remux cleanly.
#' @return `r command_return()`
#' @seealso [anonymize_video()] removes faces or regions from the picture, for
#' visual de-identification. [probe_container()] and [mediainfo_query()]
#' inspect a file's metadata before and after. This function wraps the
#' pipeline functions [ffm_copy()] and [ffm_output_options()].
#' [strip_metadata_batch()] is the many-file form.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' strip_metadata(video, "clean.mp4", run = FALSE)
#' @export
strip_metadata <- function(infile, outfile, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
p <- strip_metadata_pipeline(infile, outfile)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# standardize_video() -----------------------------------------------------
#' Standardize a video to a reproducible format
#'
#' Re-encode a video to a consistent, reproducible format for analysis. The
#' format is one video codec and pixel format, and optionally a resolution and
#' frame rate, with \code{+faststart} for smooth playback.
#' \code{\link{format_for_web}} uses a fixed recipe for web delivery. Here,
#' every part of the standard is an argument. So a lab can set its own house
#' format once and apply it across a dataset. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, pixel format
#' and frame rate.
#'
#' @details
#' The default standard, \code{standardize_video(infile, outfile)}, re-encodes
#' to H.264 video (\code{video_codec = "libx264"}) with
#' \code{pixel_format = "yuv420p"} and \code{-movflags +faststart}. It keeps
#' the source resolution and frame rate. Audio is stream-copied unchanged
#' (\code{-c:a copy}) unless \code{audio_codec} names an encoder. The same
#' input therefore always compiles to a byte-identical command. Loudness
#' standardization is out of scope. For that, see
#' \code{\link{normalize_audio}}.
#'
#' Resolution follows \code{width} and \code{height}:
#' \itemize{
#' \item With both, the output has exactly those dimensions.
#' \item With only one, the aspect ratio is kept, and the other dimension is
#' rounded to the nearest even number (FFmpeg's \code{-2}).
#' \item With neither, the source resolution is kept, but odd dimensions are
#' rounded down to the nearest even value. \code{yuv420p} and
#' \code{libx264} require this, and it changes nothing for input that is
#' already even. So the output always encodes.
#' }
#'
#' @inheritParams crop_video
#' @param width The output width in pixels (a positive number), or \code{NULL}
#' (default) to leave the width unconstrained.
#' @param height The output height in pixels (a positive number), or \code{NULL}
#' (default) to leave the height unconstrained.
#' @param fps The output frame rate (a positive number or FFmpeg framerate
#' expression such as \code{"30000/1001"}), or \code{NULL} (default) to keep
#' the input frame rate.
#' @param video_codec A string naming the output video codec (default
#' \code{"libx264"}). \code{NULL} emits no \code{-codec:v} and lets the
#' output container's default encoder decide. \code{NULL} is how you opt out
#' of the H.264 default for a container that does not hold it. For a
#' \code{.webm} output, pass \code{video_codec = NULL} \emph{and}
#' \code{audio_codec = NULL}, because the default \code{audio_codec = "copy"}
#' would otherwise carry a codec WebM cannot hold.
#' @param audio_codec A string naming the output audio codec. The default
#' \code{"copy"} stream-copies the source audio unchanged. Name a real
#' encoder, such as \code{"aac"}, when the source audio codec cannot be copied
#' into the output container. \code{NULL} emits no \code{-codec:a} and lets
#' the container's default encoder decide.
#' @param pixel_format A string naming the output pixel format (default
#' \code{"yuv420p"}).
#' @param hardware `r hardware_param(null_default = FALSE, video_only = TRUE)`
#' @param fallback `r fallback_param("video_codec", reproducible = TRUE)`
#' @param audio_stream `r audio_stream_param("carry into the output", "carries", "every", extra = audio_stream_extras$passthrough_subtitles)`
#' @return `r command_return()`
#' @seealso [ffm_scale()], [ffm_codec()], and [ffm_pixel_format()], among the
#' pipeline functions it wraps; [has_hardware_encoder()] for the
#' \code{hardware} toggle;
#' [standardize_video_batch()] for the many-file form.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # The documented default standard (H.264 / yuv420p / +faststart)
#' standardize_video(video, "std.mp4", run = FALSE)
#' # Pin resolution and frame rate too
#' standardize_video(video, "std.mp4", width = 1280, height = 720, fps = 30,
#' run = FALSE)
#' # Carry only the second audio track instead of all of them
#' standardize_video(video, "std.mp4", audio_stream = 1, run = FALSE)
#' @export
standardize_video <- function(infile, outfile,
width = NULL, height = NULL, fps = NULL,
video_codec = "libx264", audio_codec = "copy",
pixel_format = "yuv420p",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
hardware <- rlang::arg_match(hardware)
# Without this, a non-string video_codec reached ffm_codec() and aborted naming
# Layer-1's `video` instead of the argument the caller actually passed (M41).
# allow_null keeps NULL compiling as it does today: no -codec:v emitted.
# audio_codec is already checked inside standardize_pipeline(), which blames
# this verb because the pipeline is called from here.
rlang::check_string(video_codec, allow_null = TRUE)
# The three geometry values, swept here so a bad one blames this verb instead
# of ffm_scale()/ffm_fps() (M64). Same shared checker the builders call. NULL
# is legal on all three -- it means "leave this dimension alone" -- so each is
# guarded rather than passed straight through.
#
# This is where the values were effectively read before: ffm_scale() and
# ffm_fps() run at the top of standardize_pipeline(), ahead of the codec
# seams, so nothing above or below changes its reporting order (M41).
# `pixel_format` is NOT swept here: the pipeline validates it AFTER both codec
# seams, and hoisting it would silently move it ahead of them. It is checked
# in standardize_pipeline() instead, at its own position, where `call` already
# resolves to this verb (the shape anonymize_pipeline() uses).
if (!is.null(width)) check_dim(width)
if (!is.null(height)) check_dim(height)
if (!is.null(fps)) check_dim(fps)
# No front-door check for `audio_stream` here, deliberately. It would report
# BEFORE `pixel_format` and the audio codec, which the pipeline validates --
# so a caller wrong about one of those AND the track would be told about the
# track, which is exactly M41's precedence trap. That reasoning used to cite
# the pipeline for width/height/fps too; the sweep above is now their site,
# and it sits above this comment for exactly the same reason (M64).
# pass_through_maps() carries the identical check with `call` resolving to
# this frame, so the blame is unchanged and the guard bought nothing
# (M42/M43: such a guard is unpinnable anyway). The BATCH sibling keeps its
# own, where it is load-bearing (M47 review F8).
p <- standardize_pipeline(infile, outfile, width, height, fps, video_codec,
audio_codec, pixel_format, hardware, fallback,
audio_stream, quality = quality)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# standardize_pipeline() --------------------------------------------------
# Shared standardization pipeline for standardize_video() and
# standardize_video_batch(): build one single-output re-encode pipeline for a single
# input. Both verbs compile identical commands from this helper, and M12's
# guards (audio stream-copy, even-dimension safeguard, +faststart) live here
# once (D002, D003, D007). The per-value checks no longer ride the pipeline:
# each verb sweeps its dimensions and pixel format at its own front door --
# per row on the batch verb -- so a bad value blames the verb (M64), and the
# codec seams below re-check their tokens with `call` threaded.
standardize_pipeline <- function(input, output, width, height, fps, video_codec,
audio_codec = "copy", pixel_format,
hardware = "none", fallback = FALSE,
audio_stream = NULL, quality = NULL,
call = rlang::caller_env()) {
p <- ffm_files(input, output)
# Resolution: exact when both given; aspect-preserving with an even output
# dimension (FFmpeg's -2) when only one. Both callers sweep the dimensions
# with check_dim() at their own front doors before building (M64), so a bad
# one blames the verb; ffm_scale() re-checks them for its own direct callers.
# When neither is given, still force even dimensions so
# yuv420p/libx264 can encode odd-dimensioned sources -- floor-to-even is a
# no-op for already-even input, mirroring format_for_web()'s guard.
if (!is.null(width) || !is.null(height)) {
p <- ffm_scale(
p,
width = if (is.null(width)) "-2" else width,
height = if (is.null(height)) "-2" else height
)
} else {
p <- ffm_crop(p, width = "floor(in_w/2)*2", height = "floor(in_h/2)*2")
}
if (!is.null(fps)) {
p <- ffm_fps(p, fps)
}
# Audio defaults to a stream copy, not a re-encode: standardization is
# video-only, so "leave audio untouched" means copy the bytes (matching
# extract_audio()). `audio_codec` is the escape hatch for the D017 trap --
# copying a source codec the output container cannot hold -- and reuses M35's
# apply_audio_codec() seam, so NULL emits no -codec:a (M39/D017). `call` is
# threaded so a bad token names standardize_video(), not this internal helper
# (parity with anonymize_pipeline(); M39 review F2).
#
# Video goes through the matching seam (M56), so a malformed token names
# `video_codec` and blames standardize_video() rather than naming Layer-1's
# `video`. `hardware` and `fallback` go through the seam TOO, and this
# function no longer resolves the encoder itself -- the shape
# crop_video_pipeline() already had. Resolving first fed check_token() the
# RESOLVED name: under hardware = "nvenc", codec_family() read "libx264 -evil"
# as h264 and handed the seam "h264_nvenc", which is a clean token, so the
# malformed value compiled (measured at M56 review, F2/F3 -- and on master
# too). Checking before family inference is what the seam's own comment
# promises. The cost was precedence: the nvenc-unavailable abort fired after
# ffm_scale()'s dimension checks rather than before them, matching
# crop_video(). M095 moved it further still -- see the next paragraph, which
# supersedes this one on where the abort now sits.
# M095 splits the seam here. The TOKEN check stays exactly where the combined
# apply_video_codec() call sat, so a malformed `video_codec` still reports
# before `audio_codec` and `pixel_format` as it always has; the encoder
# RESOLUTION moves to the bottom of this function, below every check that
# cannot depend on what this FFmpeg build has. Under hardware = "nvenc" the
# resolution asks the build for its encoder list, and asking used to outrank a
# malformed `audio_codec`, `pixel_format` or `audio_stream`: the caller was
# told nvenc was missing when what was wrong was their own argument (M094
# review H1/H3, and D036's front-door rule reaching the builder). The compiled
# command is unchanged by the move -- ffm_groups() emits by group, not by call
# order -- and data-raw/nvenc-probe-order-baseline.R measures that over every
# cell rather than asserting it.
check_video_codec(video_codec, call = call)
p <- apply_audio_codec(p, audio_codec, call = call)
# State the stream selection instead of inheriting FFmpeg's (M47). One
# ffm_map() call with both specifiers, never two: ffm_map() appends, so two
# calls look exactly like a pipeline that mapped twice by accident.
# Checked here rather than at the verb's front door so its position in the
# reporting order is unmoved: it has always been read after both codec seams,
# and hoisting it would have made a bad pixel format outrank a bad codec
# (M41/M64). `call` resolves to standardize_video() for the scalar verb, which
# calls this function directly; the batch sibling reaches it inside pmap and
# so carries its own front-door sweep instead. ffm_pixel_format() re-checks
# the same value for its own direct callers, naming its `format` argument.
check_token(pixel_format, call = call)
p <- ffm_pixel_format(p, pixel_format)
p <- ffm_map(p, pass_through_maps(audio_stream, call = call))
# The other half of the split above: every machine-independent check has now
# run, so the build-time probe is free to run last. `fallback` is checked
# inside resolve_hw_encoder() and moves down with it, which reorders it
# against `pixel_format` -- both are still refused, and the refusal is
# identical with and without hardware = "nvenc", which is what this milestone
# promises (M095 gate).
p <- emit_video_codec(p, video_codec, hardware, fallback, quality, call = call)
ffm_output_options(p, "-movflags +faststart")
}
# anonymize_video() -------------------------------------------------------
#' Cover fixed regions of a video with opaque boxes
#'
#' Anonymize a video by covering one or more fixed rectangular regions with
#' opaque filled boxes. For example, you can redact a face, a name badge, or a
#' screen that stays in one place for the whole clip. The regions are fixed
#' (there is no face or object tracking), so this suits footage where the areas
#' to cover do not move. The glossary in \code{vignette("tidymedia")} explains
#' media terms such as codec, pixel format and stream copy.
#'
#' @details
#' \code{regions} is a data frame with one row per box and the columns
#' \code{x}, \code{y}, \code{width}, and \code{height}. Each value is a pixel
#' number or an FFmpeg expression such as \code{"in_w/2"}. \code{x} and
#' \code{y} give the top-left corner, and \code{width} and \code{height} give
#' the size. An optional
#' \code{color} column overrides the \code{color} argument for that row. Every
#' box is a solid fill (FFmpeg's \code{drawbox} with \code{t=fill}). The
#' function intentionally does not offer hollow outlines.
#'
#' Because the function applies a filter, it re-encodes the video. The
#' \code{video_codec} and \code{pixel_format} arguments set the encoding, and
#' default to H.264 and \code{yuv420p}. The function floors odd source
#' dimensions to even, so the output always encodes. \code{yuv420p} and
#' \code{libx264} require even dimensions, and the step changes nothing for
#' input that is already even. The function stream-copies the audio unchanged
#' (\code{-c:a copy}) unless
#' \code{audio_codec} names an encoder. The same input and regions therefore
#' always compile to a byte-identical command.
#'
#' @inheritParams standardize_video
#' @param regions A data frame with one row per box and columns \code{x},
#' \code{y}, \code{width}, \code{height} (and optionally \code{color}); see
#' Details.
#' @param color A string naming the default fill color in FFmpeg color syntax,
#' used for any row without its own \code{color} (default \code{"black"}).
#' @param audio_stream `r audio_stream_param("carry into the output", "carries", "every", extra = audio_stream_extras$passthrough_subtitles)`
#' @return `r command_return()`
#' @seealso [ffm_drawbox()], the pipeline function it wraps;
#' [has_hardware_encoder()] for the
#' \code{hardware} argument; [anonymize_video_batch()]
#' for the many-file (batch) form.
#' @references https://ffmpeg.org/ffmpeg-filters.html#drawbox
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Cover two fixed regions with black boxes
#' regions <- data.frame(
#' x = c(10, 200), y = c(10, 150),
#' width = c(120, 80), height = c(90, 60)
#' )
#' anonymize_video(video, "anon.mp4", regions, run = FALSE)
#' # Carry only the second audio track instead of all of them
#' anonymize_video(video, "anon.mp4", regions, audio_stream = 1, run = FALSE)
#' @export
anonymize_video <- function(infile, outfile, regions,
color = "black",
video_codec = "libx264", audio_codec = "copy",
pixel_format = "yuv420p",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL, run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
hardware <- rlang::arg_match(hardware)
# No front-door check for `audio_stream`, for the reason spelled out in
# standardize_video(): this verb's front door is thinner still, so such a
# guard reported before `regions` -- the argument a caller is likeliest to
# get wrong, and one they pass positionally (M47 review F8).
p <- anonymize_pipeline(infile, outfile, regions, color, video_codec,
audio_codec, pixel_format, hardware, fallback,
audio_stream, quality = quality)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# anonymize_pipeline() ----------------------------------------------------
# Shared anonymization pipeline for anonymize_video() and (M21) the batch
# sibling: build one single-output box-fill pipeline for a single input. Both
# verbs compile identical commands from this helper, so region-table validation
# and the encode guards (even-dimension safeguard, audio stream-copy) live here
# once -- the batch sibling inherits them by construction (D002, D003, D007;
# M13 extract-first lesson).
anonymize_pipeline <- function(input, output, regions, color, video_codec,
audio_codec = "copy", pixel_format,
hardware = "none", fallback = FALSE,
audio_stream = NULL, quality = NULL,
call = rlang::caller_env()) {
check_regions(regions, call = call)
# The region VALUES, beside the structure check (M65): the same per-field
# check_dim() refusals ffm_drawbox() makes below, re-called from
# check_region_values() so the abort names the caller's verb rather than the
# builder (D042). `call` threaded, so anonymize_video() is blamed on the
# scalar path; the batch verb sweeps each cell at its own front door, where
# this per-row copy would blame purrr::pmap().
check_region_values(regions, call = call)
rlang::check_string(color, call = call)
# NULL is D016's "leave the codec alone" sentinel, and D022 makes it the
# family-wide spelling of "unset": emit no -codec:v and let the output
# container's default encoder decide. Refusing it here is the whole reason
# anonymize_video() aborted on a call standardize_video() has always compiled
# (measured at M42 T1).
#
# `allow_null = TRUE` rather than `if (!is.null(video_codec)) check_token(...)`:
# the two accept identical values, but only this spelling makes the refusal
# message say "must be a single string or `NULL`". The other one keeps
# advertising NULL as illegal on the argument where it is now the documented
# escape hatch (M42 review F1).
#
# The check stays at this position rather than moving into apply_video_codec()
# below so it keeps reporting before pixel_format and the drawbox dimensions,
# exactly as it did (the precedence M41's review twice caught moving).
check_token(video_codec, allow_null = TRUE, call = call)
check_token(pixel_format, call = call)
# Integer coordinates are natural pixel values, but ffm_drawbox()'s check_dim()
# accepts only doubles or expression strings; coerce numeric columns so an
# integer/integerish table is not rejected.
for (col in c("x", "y", "width", "height")) {
if (is.numeric(regions[[col]])) regions[[col]] <- as.double(regions[[col]])
}
p <- ffm_files(input, output)
# Force even output dimensions so yuv420p/libx264 can encode odd-dimensioned
# sources (M12 guard); a no-op for already-even input. drawbox coordinates use
# a top-left origin, so the <=1px floor never shifts a region's x/y.
p <- ffm_crop(p, width = "floor(in_w/2)*2", height = "floor(in_h/2)*2")
colors <- if ("color" %in% names(regions)) {
regions$color
} else {
rep(color, nrow(regions))
}
# One filled drawbox per region; the values were swept above (M65), and
# ffm_drawbox() still checks its own direct callers.
for (i in seq_len(nrow(regions))) {
p <- ffm_drawbox(
p,
x = regions$x[i], y = regions$y[i],
width = regions$width[i], height = regions$height[i],
color = colors[i], thickness = "fill"
)
}
# Re-encode video (a filter is applied); audio defaults to a stream copy --
# the same encode profile as standardize_video(). `audio_codec` is the escape
# hatch for the D017 trap (a source codec the output container cannot hold),
# and reuses M35's apply_audio_codec() seam, so NULL emits no -codec:a
# (M39/D017).
p <- apply_audio_codec(p, audio_codec, call = call)
# State the stream selection instead of inheriting FFmpeg's (M47); see
# standardize_pipeline() for why this is one ffm_map() call and not two.
p <- ffm_map(p, pass_through_maps(audio_stream, call = call))
# hardware = "nvenc" swaps the software video_codec for its nvenc encoder;
# Layer 2 computes the name here, Layer 1 assembles it unchanged (IP1; D-M31).
#
# Resolved BELOW the two checks above rather than before them (M095). The
# resolution asks this FFmpeg build what encoders it has, so asking first made
# a malformed `audio_codec` or `audio_stream` come back as "nvenc is not
# available". `video_codec` and `pixel_format` were already checked at the top
# of this function and keep their positions; the compiled command is unchanged
# either way, since ffm_groups() emits by group and not by call order
# (measured over every cell by data-raw/nvenc-probe-order-baseline.R).
#
# Through the shared emit half since M135, so `quality` takes the one seam
# every verb uses. Its own token check is idempotent over the check_token()
# at the top of this function, so the refusal order above is unchanged.
p <- emit_video_codec(p, video_codec, hardware, fallback, quality, call = call)
ffm_pixel_format(p, pixel_format)
}
# check_regions() ---------------------------------------------------------
# Validate the `regions` data frame for anonymize_video()/its batch sibling:
# structure and column type/NA only. Per-value dimension checks (positive size,
# valid expression) are swept beside this check by check_region_values() (M65),
# which re-calls the same check_dim() that ffm_drawbox() still runs for its own
# direct callers.
check_regions <- function(regions, call = rlang::caller_env()) {
if (!is.data.frame(regions)) {
cli::cli_abort("{.arg regions} must be a data frame with one row per box.",
call = call)
}
if (nrow(regions) == 0) {
cli::cli_abort("{.arg regions} must have at least one row.", call = call)
}
required <- c("x", "y", "width", "height")
missing <- setdiff(required, names(regions))
if (length(missing)) {
cli::cli_abort(
c(
"{.arg regions} is missing {length(missing)} required column{?s}: {.field {missing}}.",
"i" = "Each row needs {.field x}, {.field y}, {.field width}, and {.field height}."
),
call = call
)
}
for (col in required) {
v <- regions[[col]]
if (!(is.numeric(v) || is.character(v))) {
cli::cli_abort(
"The {.field {col}} column of {.arg regions} must be numeric or character.",
call = call
)
}
if (anyNA(v)) {
cli::cli_abort(
"The {.field {col}} column of {.arg regions} must not contain {.val {NA}}.",
call = call
)
}
}
if ("color" %in% names(regions)) {
v <- regions$color
if (!is.character(v) || anyNA(v)) {
cli::cli_abort(
"The {.field color} column of {.arg regions} must be character (no {.val {NA}}).",
call = call
)
}
}
invisible(regions)
}
# derive_anonymized_names() -----------------------------------------------
# Derive one output path per input for anonymize_video_batch() when the `output`
# column is absent: `<base>_anonymized.<input-ext>` (box-fill re-encodes but
# keeps the source container). One input -> one output, so a duplicated input
# with no explicit `output` would collide; the caller (anonymize_video_batch)
# rejects that up front, so this helper assumes unique inputs and stays a pure
# name map (parity with derive_standardized_names()).
derive_anonymized_names <- function(input) {
paste0(
tools::file_path_sans_ext(input), "_anonymized.", tools::file_ext(input)
)
}
# anonymize_video_batch() ------------------------------------------------------
#' Anonymize Many Videos From a Jobs Table
#'
#' Cover fixed rectangular regions of many input videos with opaque filled boxes
#' from a single jobs tibble. This is the **batch** (table-driven) form of
#' [anonymize_video()], for when you have more than one video to
#' redact. Each row is one input with its own regions. The required columns name
#' the source (\code{input}) and the boxes to cover (\code{regions}). This is a
#' thin wrapper over \code{\link{ffm_batch}}. It compiles one reproducible
#' command per input. It shares the same box-fill pipeline (and per-region
#' validation) as [anonymize_video()]. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, pixel format
#' and stream copy.
#'
#' @param jobs A data frame with one row per input and (at least) an
#' \code{input} column (source path) and a \code{regions} list-column. Each
#' \code{regions} cell is itself a data frame of boxes for that input. It has
#' the same shape that \code{\link{anonymize_video}} takes: \code{x},
#' \code{y}, \code{width}, \code{height} and an optional per-box
#' \code{color}. An optional
#' \code{output} column names the destination. When it is absent, the function
#' derives one per row. It appends \code{_anonymized} to each input's basename
#' and keeps the input's extension (e.g. \code{clip.mkv} becomes
#' \code{clip_anonymized.mkv}).
#' `r duplicate_output_sentences()` Four encoding arguments may also appear as
#' a column: \code{color}, \code{video_codec}, \code{audio_codec} and
#' \code{pixel_format}. Such a column overrides the corresponding argument on
#' a per-row basis. Rows (or arguments) that omit the column fall back to the
#' argument's value. In either codec column, \code{NA} leaves that row's codec
#' unset. This is the column form of \code{video_codec = NULL} /
#' \code{audio_codec = NULL}. In a
#' \code{color} or \code{pixel_format} column \code{NA} is an error, because
#' those have no unset state. An \code{audio_stream} column overrides the
#' \code{audio_stream} argument per row, where \code{NA} keeps that row on
#' every audio track. A numeric \code{quality} column overrides the
#' \code{quality} argument per row (see \code{quality}). Any other columns
#' are ignored.
#' @param color A string naming the default fill color (FFmpeg color syntax)
#' applied to every row. A \code{color} column in \code{jobs}, or a box that
#' supplies its own \code{color}, overrides it. (default = \code{"black"})
#' @param video_codec A string naming the output video codec applied to every
#' row, unless \code{jobs} carries a \code{video_codec} column. In that
#' column, \code{NA} in a cell leaves that row's codec unset.
#' `r batch_libx264_sentences()`
#' @param audio_codec A string naming the output audio codec applied to every
#' row, unless \code{jobs} carries an \code{audio_codec} column. In that
#' column, \code{NA} in a cell leaves that row's codec unset. \code{"copy"}
#' (default) stream-copies the audio through untouched. Name an encoder (e.g.
#' \code{"aac"}) when the source audio cannot be copied into the output
#' container.
#' @param pixel_format A string naming the output pixel format applied to every
#' row, unless \code{jobs} carries a \code{pixel_format} column.
#' (default = \code{"yuv420p"})
#' @param hardware The encoder backend applied to every row. \code{"none"}
#' (default) uses the software \code{video_codec}. \code{"nvenc"} gives NVIDIA
#' GPU encoding (H.264, HEVC and AV1). \code{"videotoolbox"} gives Apple
#' GPU encoding (H.264 and HEVC). Batch-wide (a machine property), not a
#' per-row column; a \code{hardware} column in \code{jobs} is ignored. See
#' \code{\link{has_hardware_encoder}}.
#' `r hardware_probe_sentences()`
#' `r encoder_check_sentences()`
#' A call can also be wrong about a per-row value, for example a
#' \code{regions} table that is missing a required column. The function
#' refuses that call for the value first, whether or not this machine has the
#' encoder.
#' @param fallback `r fallback_param("video_codec", batch = TRUE)`
#' @param quality `r batch_quality_param("anonymize_video")`
#' @param audio_stream `r audio_stream_param("carry into each output", "carries", "every", batch = TRUE, extra = audio_stream_extras$passthrough_subtitles)`
#' @param run A logical: run each input's command through FFmpeg (\code{TRUE},
#' default) or only compile them for inspection (\code{FALSE}).
#' @param parallel A logical passed to \code{\link{ffm_batch}}: anonymize in
#' parallel with \pkg{furrr} (\code{TRUE}) or sequentially (\code{FALSE},
#' default). Parallelism follows the active \code{\link[future:plan]{future}}
#' plan; \code{TRUE} under the default sequential plan runs one input at a
#' time and warns. Set a plan first, e.g.
#' \code{future::plan(future::multisession)}.
#' @param ... Additional arguments forwarded to \code{\link{ffm_batch}}, such as
#' \code{verify}, \code{manifest}, \code{checksums}, and \code{progress}.
#' @return `r batch_return("output")`
#' @seealso [anonymize_video()] for the single-input form.
#' [has_hardware_encoder()] for the \code{hardware} argument. [ffm_batch()]
#' for the batch runner and the arguments forwarded through \code{...}.
#' [standardize_video_batch()] and [segment_video_batch()] for the other
#' table-driven functions.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' output = c("a.mp4", "b.mp4"),
#' regions = list(
#' data.frame(x = 10, y = 10, width = 120, height = 90),
#' data.frame(x = 200, y = 150, width = 80, height = 60)
#' )
#' )
#' # run = FALSE compiles one command per input without calling FFmpeg
#' anonymize_video_batch(jobs, run = FALSE)
#' @export
anonymize_video_batch <- function(jobs, color = "black", video_codec = "libx264",
audio_codec = "copy", pixel_format = "yuv420p",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE, parallel = FALSE, ...) {
hardware <- rlang::arg_match(hardware)
# NULL is legal (the "emit no -codec:a" escape hatch), so allow_null (M39).
check_token(audio_codec, allow_null = TRUE)
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
if (!"regions" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have a {.field regions} column.",
"x" = "Missing column: {.val regions}.",
"i" = "Make it a list-column: one boxes data frame per input row."
))
}
# The regions column is a list-column (one boxes data frame per row); a flat
# column can't hold per-row tables, so reject it here with a clear message
# rather than as an opaque per-row abort. Each cell's structure and values
# are checked at this front door below (M59 structure, M65 values).
if (!is.list(jobs$regions) || is.data.frame(jobs$regions)) {
cli::cli_abort(c(
"The {.field regions} column of {.arg jobs} must be a list-column.",
"i" = "Each element is a boxes data frame, one per input row."
))
}
# A factor input column carries paths as levels; treat them as strings
# (parity with standardize_video_batch()).
jobs$input <- as.character(jobs$input)
# Validate present override columns up front so a bad column fails clearly
# here rather than as an opaque FFmpeg error mid-batch (M11 parity lesson).
# Value-level checks (valid color/codec/pixfmt tokens) are inherited per row
# from anonymize_pipeline()'s guards.
str_cols <- c("color", "pixel_format")
for (col in intersect(str_cols, names(jobs))) {
if (!is.character(jobs[[col]]) || anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be character (no {.val {NA}}).")
}
}
# Both codec columns take the codec guard, which admits NA and the all-NA
# logical column R hands back (M34 lesson). `video_codec` sat in str_cols
# above until M42, justified by a comment calling it "a literal libx264
# default with no sentinel" -- false when written, since the argument accepts
# NULL, and now the family rule: NA is the column form of that NULL (D022).
# `color` and `pixel_format` stay in str_cols: not codec arguments, no
# sentinel, so an NA cell there spells nothing.
#
# Checking video_codec here rather than in the loop's slot moves it after
# `color` and `pixel_format` in the reporting order for a jobs table with two
# bad columns. Named because M41's review twice caught a guard reassigning
# precedence unremarked; the two codec columns now report together.
check_batch_codec_col(jobs, "video_codec")
check_batch_codec_col(jobs, "audio_codec")
# video_codec had no front-door check, so a non-string reached the per-row
# pipeline and aborted inside purrr::pmap(), carrying `In index: <n>` and
# blaming pmap rather than this verb (M41).
#
# M42 answered the question M41 left here: `NULL` IS legal, the family-wide
# sentinel for "emit no -codec:v" (D022). So this takes allow_null = TRUE,
# not the `if (!is.null(...))` shape M41 chose to avoid advertising NULL --
# that shape accepts the same values but keeps saying NULL is illegal, which
# is now false (M42 review F1). separate_audio_video_batch() still carries the
# older spelling; its arguments' NULL semantics are D020's, not this
# milestone's, so it is left alone rather than swept.
#
# No longer the LAST of this verb's front-door checks, though it was when
# M41 placed it here: the audio_stream column and argument guards below it,
# the input sweep, and the duplicated-input refusal all now follow (M80 T8,
# D058). It is still deliberately AFTER the jobs shape and content checks:
# before M41 this argument was only read per row inside the fan-out, so
# EVERY check above it reported first on a call that was wrong about two
# things at once. Moving the guard up the function silently reassigned that
# precedence -- first past the jobs SHAPE block (review A6), then past its
# content checks too (review A1r3). Here it changes nothing but the message
# a bad codec gets.
check_token(video_codec, allow_null = TRUE)
# See standardize_video_batch() for why the hint says "every" here and why
# check_batch_stream_values() is not needed on a verb that does not reshape.
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep jobs$input now that its shape/type is settled, and before the
# per-row regions value sweep below, so a missing input blames this verb
# rather than purrr::pmap() (M62). Above the derived-output block below too,
# so a table naming one absent path in every row hears about the path (M80).
#
# The sweep and that block move together and land HERE, below the column and
# scalar guards above, not at the block's old site: D057 narrows the ordering
# rule for the DUPLICATION guard only, and lifting the sweep to the block's
# old position also lifted it past `audio_stream`'s column guard and past the
# two scalar checks -- the precedence reassignment the comment above warns
# about, caught at M80's review (F1/F2) after the first attempt shipped it.
check_batch_inputs(jobs)
# Auto-name outputs when the column is absent. One input -> one output, so a
# duplicated input with no explicit output would map to the same file; reject
# that rather than silently overwrite. Below the sweep above and not above it,
# so a table whose rows all name the same unreadable file is told about the
# file rather than about the duplication (M80, D057).
if (!"output" %in% names(jobs)) {
reject_duplicate_inputs(jobs)
jobs$output <- derive_anonymized_names(jobs$input)
}
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output box-fill
# pipeline per row, sharing anonymize_pipeline() with anonymize_video(). A
# per-row knob column (arriving via `...` from pmap) overrides the scalar arg
# of the same name; `...` also forwards ffm_batch options (verify/manifest/...)
# to the runner, never to the pipeline builder. The `regions` list-column
# arrives unwrapped per row (pmap passes each cell's data frame by name).
#
# Each cell's SHAPE is checked here rather than left to the per-row
# check_regions() inside anonymize_pipeline(), which reported against
# purrr::pmap() (M59 site 3). check_regions() is the one site those messages
# are written and the pipeline still calls it, so anonymize_video() reaches
# the same site (M59-D2). The list-column guard above covers only the column.
for (i in seq_along(jobs$regions)) {
check_batch_cell(i, check_regions(jobs$regions[[i]]))
# And each cell's VALUES (M65): the same check_dim() refusals
# ffm_drawbox() makes, re-called here -- beside the structure check, as in
# anonymize_pipeline() -- so a bad region value blames this verb rather
# than purrr::pmap() (D042). `regions` is a required list-column, so the
# locator is unconditional here (M66).
check_batch_cell(i, check_region_values(jobs$regions[[i]]))
}
# Two rows resolving to one output path (M125); placed as in
# standardize_video_batch(), after every machine-free check and above the
# nvenc probe.
reject_duplicate_outputs(jobs)
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame, so every check above still
# reports first. The sweep covers each distinct family a `video_codec` column
# spells, never only the argument's.
# `quality`, argument and column, against each row's resolved encoder
# (M136); above the probe (D036), naming this verb and the row (D076).
check_batch_quality(
jobs, quality,
batch_arg_rows(jobs, "video_codec", video_codec, batch_codec_cell),
hardware
)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, regions, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
anonymize_pipeline(
input, output, regions,
color = pick("color", color),
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
pixel_format = pick("pixel_format", pixel_format),
# hardware/fallback are batch-wide (a machine property), never per-row
# columns -- parity with standardize_video_batch (D-M31).
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality)),
# Arrives through `dots` rather than a named closure argument: only
# `regions` is named here, because pmap must unwrap that list-column.
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# normalize_audio() -------------------------------------------------------
#' Normalize a file's audio loudness (EBU R128)
#'
#' Normalize the perceived loudness of a file's audio toward an EBU R128 target.
#' The function uses FFmpeg's single-pass \code{loudnorm} filter. It can also
#' downmix the channel count and resample. The output holds \strong{one audio
#' stream and no video}, whatever the input and whatever container
#' \code{outfile} names. So the output of this function is audio, as with
#' \code{\link{extract_audio}} and \code{\link{convert_audio}}. It does not
#' carry the other streams through. To normalize a recording's soundtrack
#' \emph{and} keep its picture, first normalize to an audio file. Then put the
#' audio back with the picture using the \code{\link{ffmpeg}} direct command.
#' The glossary in \code{vignette("tidymedia")} explains media terms such as
#' LUFS, true peak and sample rate.
#'
#' @details
#' The default targets follow EBU Recommendation R 128 (2014). They are
#' \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} is
#' \code{7}. This is
#' single-pass (dynamic) \code{loudnorm}. The same input and arguments always
#' compile to one reproducible command, with no separate measurement pass. The
#' filter changes the audio, so FFmpeg re-encodes it. Set \code{audio_codec} to
#' name the output encoder, or leave it \code{NULL} to use the default of the
#' output container. Leaving \code{channels} at \code{NULL} keeps the source
#' channel layout. FFmpeg's \code{loudnorm} filter resamples its output, up to
#' 192 kHz, capped by the encoder. So the output sample rate is \emph{not} the
#' source rate unless you set it. Set \code{sample_rate} to control the output
#' rate.
#'
#' The function warns when no \code{audio_stream} is named and \code{infile}
#' carries tracks that the output will not. \code{\link{extract_audio}} and
#' \code{\link{convert_audio}} emit the same warning. Naming a track with
#' \code{audio_stream} silences it, as does
#' \code{suppressWarnings(classes = "tidymedia_dropped_audio")}.
#' `r dropped_audio_cost_sentences()` It never runs under \code{run = FALSE},
#' and never changes the compiled command. Under \code{two_pass = TRUE}, the
#' warning comes \emph{before} the analysis pass. So it arrives while adding
#' \code{audio_stream} can still save that pass.
#'
#' `r check_tracks_off_paragraph()`
#'
#' @param infile A string containing the path to a media file (with audio). An
#' input with no audio stream is an FFmpeg error, not a silent copy of the
#' video.
#' @param outfile A string containing the path of the audio file to write. The
#' function accepts any container that FFmpeg can write. The compiled command
#' does not depend on which container it is. An audio container (\code{.wav},
#' \code{.flac}) holds the result exactly as a video container (\code{.mkv})
#' does. The video container carries one audio stream and nothing else.
#' @param target_loudness The target integrated loudness, in LUFS
#' (`r loudnorm_bounds_rd("target_loudness")`; default \code{-23}, the EBU
#' R128 target).
#' @param true_peak The maximum true peak, in dBTP
#' (`r loudnorm_bounds_rd("true_peak")`; default \code{-1}, the EBU R128
#' ceiling).
#' @param loudness_range The target loudness range, in LU
#' (`r loudnorm_bounds_rd("loudness_range")`; default \code{7}).
#' @param channels The output channel count, e.g. \code{1} to downmix to mono (a
#' positive whole number), or \code{NULL} (default) to keep the source layout.
#' @param sample_rate The output sample rate in Hz, e.g. \code{48000} (a
#' positive whole number). \code{NULL} (default) lets \code{loudnorm} choose.
#' It resamples, up to 192 kHz and capped by the encoder, and does not keep
#' the source rate. Set this argument to fix the output rate.
#' @param audio_codec An optional string naming the output audio encoder (e.g.
#' \code{"aac"}, \code{"libmp3lame"}, \code{"flac"}), passed to FFmpeg's
#' \code{-codec:a}. \code{NULL} (default) sets no \code{-codec:a}, which
#' leaves the default encoder of the output container in place.
#' \code{"copy"} is an error. Loudness normalization filters the audio, so
#' the stream must be re-encoded and cannot be copied.
#' @param two_pass A logical. When \code{TRUE}, the function uses accurate
#' two-pass (measured/linear) normalization. The default (\code{FALSE}) is
#' single-pass. A first \emph{analysis pass} measures the loudness of the
#' input. A second \emph{correction pass} feeds those measurements back with
#' \code{linear=true}, so the output hits the EBU R128 target precisely.
#' So two-pass \strong{always runs the analysis pass through FFmpeg}, even
#' when \code{run = FALSE}. It needs the binary and a readable input. Under
#' \code{run = FALSE}, the analysis still runs. The returned value is the
#' exact correction command, which is not run. The single-pass default
#' touches no binary under \code{run = FALSE}. If the input is
#' \strong{silent}, the analysis pass measures its loudness as \code{-inf}.
#' Normalizing silence to a target is undefined, so two-pass aborts with a
#' clear error. The single-pass default leaves silence untouched. The batch
#' form differs here. \code{\link{normalize_audio_batch}}
#' does not abort on a silent row. It sets that row aside, marks it in a \code{silent} column,
#' and normalizes the rest. When the analysis pass gives no usable
#' measurement at all, the abort has class
#' \code{tidymedia_loudnorm_no_measurement}. The batch form raises the same
#' class, so one handler covers both. Where FFmpeg exited non-zero, the abort
#' also carries \code{tidymedia_ffmpeg_exit}, and the exit number on
#' \code{tm_status}. Where FFmpeg exited zero but printed no measurement
#' block that can be parsed, the abort carries the shared class alone. The
#' silence abort above is neither: a silent input \emph{was} measured.
#' @param audio_stream `r audio_stream_param("normalize", "normalizes", "first", extra = audio_stream_extras$normalize_one_track)`
#' @param run A logical: run the (correction) command through FFmpeg
#' (\code{TRUE}, default) or return the compiled command without running it
#' (\code{FALSE}). Under \code{two_pass = TRUE} this gates only the correction
#' pass; the analysis pass runs regardless (see \code{two_pass}).
#' @return The compiled FFmpeg command (invisibly when \code{run = TRUE}). Under
#' \code{two_pass = TRUE} this is the correction command built from the
#' measured values.
#' @seealso [ffm_loudnorm()], the pipeline function it wraps.
#' [normalize_audio_batch()] for the many-file form.
#' [extract_audio()] and [convert_audio()], the other task functions whose
#' output is one audio stream.
#' @references `r ebu_r128_reference()`
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # The output holds audio only, so name an audio file for it
#' normalize_audio(video, "normalized.wav", run = FALSE)
#' # Normalize to a streaming target and downmix to mono
#' normalize_audio(video, "mono.wav", target_loudness = -16, channels = 1,
#' run = FALSE)
#' # Name the output audio encoder instead of taking the container's default
#' normalize_audio(video, "normalized.m4a", audio_codec = "aac", run = FALSE)
#' @export
normalize_audio <- function(infile, outfile,
target_loudness = -23,
true_peak = -1,
loudness_range = 7,
channels = NULL,
sample_rate = NULL,
audio_codec = NULL,
two_pass = FALSE,
audio_stream = NULL,
run = TRUE) {
check_file_readable(infile)
rlang::check_string(outfile)
rlang::check_bool(two_pass)
# The three loudness targets, ABOVE the two_pass block deliberately (M65):
# they were validated by ffm_loudnorm() -- on the two-pass path only after
# the analysis pass had already run, and blaming the builder either way.
# Re-calling the shared checker here (one binding per range, D042) names
# this verb and refuses a bad target before FFmpeg spawns. This is the one
# sweep in the M64/M65 family that is not "last among the value guards";
# the reordering it makes is recorded in the milestone's precedence table.
check_loudnorm_targets(target_loudness, true_peak, loudness_range)
# Two-pass: measure the input first, then build a linear correction from the
# measurements. Validate the shaping knobs up front so a bad channels/
# sample_rate fails before the analysis pass runs, not after wasting it
# (the targets are refused above, before either pass). Single-pass keeps
# its pure, binary-free run = FALSE compile.
measured <- NULL
if (two_pass) {
rlang::check_number_whole(channels, min = 1, allow_null = TRUE)
rlang::check_number_whole(sample_rate, min = 1, allow_null = TRUE)
# Hoisted for the same reason as the two above: on the single-pass path
# pass_through_maps() carries this check, but there the compile is all
# there is. Here an unchecked bad index would abort from the correction
# pipeline AFTER the analysis pass had already run (M49).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
check_audio_codec_not_copy(audio_codec)
# Token-check here too, not only inside apply_audio_codec(): the whole point
# of hoisting is to fail before the analysis pass runs, and a malformed
# encoder name is as fatal as "copy".
if (!is.null(audio_codec)) check_token(audio_codec)
# D074, on the two-pass path, for the same reason the batch sibling has its
# own: this branch probes (the D024 drop check below) and then spawns the
# analysis pass, both above the site at the bottom of this verb -- so a
# two-pass call read the limit inside run_program(), which refuses a missing
# binary BEFORE it resolves one. On a machine with no FFmpeg the caller was
# told about the PATH rather than about the option they set (M094 review F3,
# measured again on CI). Sited here it is after every check above, all
# machine-independent, and before the first probe (D036).
resolve_timeout()
# D024's diagnostic probe, two-pass site. There are TWO sites in this verb,
# mutually exclusive on `two_pass`, because a single site below every guard
# would warn only AFTER the analysis pass had run -- while the batch verb
# warns before ITS Phase 1, the scalar/batch ordering divergence D039 exists
# to prevent. Same gates as extract_audio()'s site: `run` because a
# run = FALSE call stays binary-free, and a NULL audio_stream because a
# caller who named a track chose the drop.
if (isTRUE(run) && is.null(audio_stream) && resolve_check_tracks()) {
warn_dropped_audio(infile, count_audio_streams_all(infile))
}
measured <- run_loudnorm_analysis(infile, target_loudness, true_peak,
loudness_range,
audio_stream = audio_stream)
}
# Duplicates the check apply_audio_codec() makes inside
# normalize_audio_pipeline(); that pipeline is shared with the batch sibling
# for per-row validation, so threading a `call` through it would rewrite the
# batch verb's per-row messages instead (M41). allow_null because NULL is
# this verb's documented sentinel (D019).
#
# Below the two_pass block on purpose: that block already type-checks this
# argument via check_token(), and validates channels/sample_rate. Guarding
# above it changed which complaint a two-pass call wrong about both gets
# (review A3r3). Here the two-pass path is exactly as it was, and the
# default path -- the one M41 is about -- is still fixed.
rlang::check_string(audio_codec, allow_null = TRUE)
# The single-pass half of the guard-then-probe pair. Gated on `!two_pass`
# because the block above does NOT return -- it falls through to here -- so
# without this gate the two-pass path would make both calls a second time and
# warn TWICE about one drop (review round 1). With it the two sites are
# mutually exclusive on `two_pass`, and exactly one probe runs per call.
#
# The copy guard is hoisted for the probe below: the pipeline's own copy
# guard runs inside ffm_finish()'s argument, i.e. AFTER the probe, so a
# multi-track `audio_codec = "copy"` call would otherwise warn about a drop
# that the very next line refuses to perform. Refuse first, probe second. The
# message is check_audio_codec_not_copy()'s own either way (D042).
# The shaping knobs come first, because normalize_audio_pipeline() validates
# them (R/ffmpeg.R, its first two lines) BEFORE its own copy guard. Hoisting
# the copy guard alone would reassign their precedence: a call wrong about
# both channels and audio_codec would start answering with the copy complaint
# instead of the channels one. M41's guards were placed to reassign no other
# check's precedence, and its review (A3r3) backed this same hoist out of the
# two-pass path for exactly that reason. Re-checking here is idempotent --
# the pipeline still checks them -- and the two-pass block above hoists the
# same two in the same order.
if (!two_pass) {
rlang::check_number_whole(channels, min = 1, allow_null = TRUE)
rlang::check_number_whole(sample_rate, min = 1, allow_null = TRUE)
check_audio_codec_not_copy(audio_codec)
if (isTRUE(run) && is.null(audio_stream) && resolve_check_tracks()) {
warn_dropped_audio(infile, count_audio_streams_all(infile))
}
}
p <- normalize_audio_pipeline(infile, outfile, target_loudness, true_peak,
loudness_range, channels, sample_rate,
audio_codec = audio_codec, measured = measured,
audio_stream = audio_stream)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# check_audio_codec_not_copy(): refuse audio_codec = "copy" on the loudness
# verbs (M36). These filter the audio, so a stream copy is impossible and
# D017's "copy" default deliberately does not transfer. Layer 1 already refuses
# a filtered copied stream (ffm_groups(), M02 D-M02-5) and stays the
# enforcement point (IP1); this front door names the argument the caller
# actually passed, and -- called before run_loudnorm_analysis() -- fails before
# the two-pass path burns an analysis pass. One helper, three call sites
# (pipeline, scalar two-pass pre-check, batch column guard).
check_audio_codec_not_copy <- function(audio_codec, call = rlang::caller_env()) {
if (any(audio_codec == "copy", na.rm = TRUE)) {
cli::cli_abort(c(
"{.arg audio_codec} can't be {.val copy}.",
"x" = "Loudness normalization filters the audio, so it must be re-encoded.",
"i" = "Name an encoder (e.g. {.val aac}), or use {.code NULL} to leave the
encoder unset."
), call = call)
}
invisible(audio_codec)
}
# normalize_audio_pipeline() ----------------------------------------------
# Shared loudness-normalization pipeline for normalize_audio() and (M15)
# normalize_audio_batch(): build one single-output pipeline for a single input.
# Both verbs compile identical commands from this helper, so channels/
# sample_rate validation lives here once and the batch sibling inherits it by
# construction (D002, D007; M13 lesson). The loudness TARGETS are swept at
# both verbs' front doors since M65; ffm_loudnorm() below still checks them
# for its own direct callers.
normalize_audio_pipeline <- function(input, output,
target_loudness = -23,
true_peak = -1,
loudness_range = 7,
channels = NULL,
sample_rate = NULL,
audio_codec = NULL,
measured = NULL,
audio_stream = NULL,
call = rlang::caller_env()) {
rlang::check_number_whole(channels, min = 1, allow_null = TRUE)
rlang::check_number_whole(sample_rate, min = 1, allow_null = TRUE)
check_audio_codec_not_copy(audio_codec)
p <- ffm_files(input, output)
# ONE audio stream out, and no video (D030). This verb emitted no map at all
# until M49, so FFmpeg's implicit selection picked the audio track by DEFAULT
# disposition -- measured as the THIRD track of a 3-track fixture -- and
# decided the video question by whether the output muxer would take a video
# stream. M49 removed the audio guess; two attempts to also STATE the video
# half failed, because `-map 0:v?` forces video into the muxer and the `?`
# covers an absent stream rather than a refusing one. Enumerating the
# containers that refuse it missed six on the second attempt (D030 carries
# the measurements).
#
# So this verb no longer carries video at all, which is what makes the
# question go away rather than move: an audio verb whose output is one audio
# stream, like extract_audio() and convert_audio(). NULL resolves to the FIRST
# track (D028's carve-out), and carries NO trailing `?`.
#
# The `?` is not merely unnecessary here, it is harmful. When EVERY map
# specifier is optional and matches nothing, FFmpeg discards the maps and
# reverts to default stream selection -- measured: `-map 0:a:5?` on a
# video+audio file writes video AND audio, the map ignored entirely. This
# verb emits exactly one map, so "all maps matched nothing" is reachable by
# an ordinary input: a silent screen recording. With a `?` that call would
# exit 0 and silently write the VIDEO through, by way of the very
# DEFAULT-disposition heuristic this milestone exists to remove. Without it
# the input fails loudly (exit 234, "Stream map '' matches no streams").
# This is also the measured reason D026's named specifiers carry no `?`.
#
# No `-codec:v copy` either -- with no video stream mapped it named a stream
# that does not exist, and the compiled command is the product (D001).
p <- ffm_map(p, audio_stream_map(audio_stream, null_map = "0:a:0",
call = call))
# Loudness: EBU R128 loudnorm. The target ranges were swept at the verbs'
# front doors (M65); ffm_loudnorm() still checks its own direct callers. With
# `measured` (the two-pass correction path), feed the analysis-pass values back
# and switch to linear normalization so the target is hit precisely (M16).
if (is.null(measured)) {
p <- ffm_loudnorm(p, target_loudness = target_loudness,
true_peak = true_peak, loudness_range = loudness_range)
} else {
p <- ffm_loudnorm(p, target_loudness = target_loudness,
true_peak = true_peak, loudness_range = loudness_range,
measured_i = measured$i, measured_tp = measured$tp,
measured_lra = measured$lra, measured_thresh = measured$thresh,
offset = measured$offset, linear = TRUE)
}
# Name the audio encoder, if asked. NULL emits no -codec:a, leaving the
# output container's default encoder in place -- the pre-M36 behavior.
# `call` threaded (M56): without it this seam's token check blamed
# normalize_audio_pipeline(), the one seam call in the package that omitted it.
p <- apply_audio_codec(p, audio_codec, call = call)
if (!is.null(channels)) {
p <- ffm_output_options(p, paste0("-ac ", channels))
}
if (!is.null(sample_rate)) {
p <- ffm_output_options(p, paste0("-ar ", sample_rate))
}
p
}
# ffmpeg_codecs() ------------------------------------------------------------
#' Get a data frame of all installed codecs
#'
#' Ask FFmpeg for its list of installed codecs, and return the list as a data
#' frame with information about each codec. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec and encoder.
#'
#' @param sort_by_type A logical. \code{TRUE} sorts the tibble by type and then
#' by name. \code{FALSE} sorts it by name only. (default = \code{TRUE})
#' @return A [tibble][tibble::tibble-package] with the following variables:
#' \item{name}{A character vector including the name/code of each codec}
#' \item{details}{A character vector including details about each codec}
#' \item{type}{A factor vector indicating whether each codec supports
#' \code{"Video"}, \code{"Audio"} or \code{"Subtitles"}} \item{decoding}{A
#' logical vector indicating whether each codec supports decoding}
#' \item{encoding}{A logical vector indicating whether each codec supports
#' encoding} \item{intraframe}{A logical vector indicating whether each codec
#' is an intra-frame-only codec} \item{lossy}{A logical vector indicating
#' whether each codec supports lossy compression} \item{lossless}{A logical
#' vector indicating whether each codec supports lossless compression}
#' @seealso [ffmpeg_encoders()] for the encoder list, [ffm_codec()] to set a
#' codec in a pipeline, and [ffmpeg()] for the direct command.
#' @family capability functions
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' head(ffmpeg_codecs())
#' ffmpeg_codecs(sort_by_type = FALSE)
#' @export
ffmpeg_codecs <- function(sort_by_type = TRUE) {
# The check `ffmpeg_encoders()` has had all along. Without it this verb alone
# of the pair ran FFmpeg, parsed its output, and only then failed on the `if`
# in the sort branch below -- so a call it should have refused outright cost a
# process, and the message came from `if` rather than from the verb (M96 AC3).
rlang::check_bool(sort_by_type)
# D074: the verb the caller typed names a bad limit, not the hatch below.
resolve_timeout()
# -hide_banner: FFmpeg writes its version banner to stderr, which ffmpeg()
# does not capture, so without it the banner reached the console on every
# read -- after a failed run's own error output, when the missing-encoder
# diagnosis reads this list (M139 review). The parse below starts after the
# dashes line either way.
output <- ffmpeg("-hide_banner -codecs")
output2 <- output[-(1:which(output == " -------"))]
key <- regmatches(
output2,
regexpr("(?<=\\s)\\S+(?=\\s)", output2, perl = TRUE)
)
decoding <- substr(key, 1, 1) == "D"
encoding <- substr(key, 2, 2) == "E"
type <- substr(key, 3, 3)
intraframe <- substr(key, 4, 4) == "I"
lossy <- substr(key, 5, 5) == "L"
lossless <- substr(key, 6, 6) == "S"
abbrev <- regmatches(
output2,
regexpr("(?<=\\s\\S{6}\\s)\\S+(?=\\s+)", output2, perl = TRUE)
)
description <- regmatches(
output2,
regexpr("(?<=\\s{2})\\S[[:print:]]+$", output2, perl = TRUE)
)
out <-
tibble::tibble(
name = abbrev,
details = description,
type = factor(
type,
levels = c("V", "A", "S"),
labels = c("Video", "Audio", "Subtitles")
),
decoding,
encoding,
intraframe,
lossy,
lossless
)
# Sort as requested
if (sort_by_type) {
out <- out[order(out$type, out$name), ]
} else {
out <- out[order(out$name), ]
}
out
}
# ffmpeg_encoders() ------------------------------------------------------------
#' Get a data frame of all installed encoders
#'
#' Ask FFmpeg for its list of installed encoders, and return the list as a data
#' frame with information about each encoder. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec and encoder.
#'
#' @inheritParams ffmpeg_codecs
#' @return A [tibble][tibble::tibble-package] with the following variables:
#' \item{name}{A character vector including the name/code of each encoder}
#' \item{details}{A character vector including details about each encoder}
#' \item{type}{A factor vector indicating whether each encoder supports
#' \code{"Video"}, \code{"Audio"} or \code{"Subtitles"}} \item{frame_mt}{A
#' logical vector indicating whether each encoder supports frame-level
#' multithreading} \item{slice_mt}{A logical vector indicating whether each
#' encoder supports slice-level multithreading} \item{experimental}{A logical
#' vector indicating whether each encoder is experimental} \item{horiz_band}{A
#' logical vector indicating whether each encoder supports draw_horiz_band}
#' \item{direct_render}{A logical vector indicating whether each encoders
#' supports direct rending method 1}
#' @seealso [ffmpeg_codecs()] for the codec list, [ffm_codec()] to set a codec
#' in a pipeline, and [ffmpeg()] for the direct command.
#' @family capability functions
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' head(ffmpeg_encoders())
#' ffmpeg_encoders(sort_by_type = FALSE)
#' @export
ffmpeg_encoders <- function(sort_by_type = TRUE) {
rlang::check_bool(sort_by_type)
# D074: the verb the caller typed names a bad limit, not the hatch below.
resolve_timeout()
# -hide_banner for the reason ffmpeg_codecs() gives (M139 review).
output <- ffmpeg("-hide_banner -encoders")
output2 <- output[-(1:which(output == " ------"))]
key <- regmatches(
output2,
regexpr("(?<=\\s)\\S+(?=\\s)", output2, perl = TRUE)
)
type <- substr(key, 1, 1)
frame_mt <- substr(key, 2, 2) == "F"
slice_mt <- substr(key, 3, 3) == "S"
experimental <- substr(key, 4, 4) == "X"
horiz_band <- substr(key, 5, 5) == "B"
direct_render <- substr(key, 6, 6) == "D"
abbrev <- regmatches(
output2,
regexpr("(?<=\\s\\S{6}\\s)\\S+(?=\\s+)", output2, perl = TRUE)
)
description <- regmatches(
output2,
regexpr("(?<=\\s{2})\\S[[:print:]]+$", output2, perl = TRUE)
)
out <-
tibble::tibble(
name = abbrev,
details = description,
type = factor(
type,
levels = c("V", "A", "S"),
labels = c("Video", "Audio", "Subtitles")
),
frame_mt,
slice_mt,
experimental,
horiz_band,
direct_render
)
# Sort as requested
if (sort_by_type) {
out <- out[order(out$type, out$name), ]
} else {
out <- out[order(out$name), ]
}
out
}
# hardware_encoder() / has_hardware_encoder() ------------------------------------------------
# hardware_backend_families(): the per-backend codec-family table, and the one
# place either backend's coverage is written down. The encoder name is the
# family and the backend joined by "_", which is how FFmpeg spells every member
# of both rows (h264_nvenc, hevc_videotoolbox).
#
# prores is deliberately in NEITHER row. `prores_videotoolbox` exists and runs,
# but it cannot mux into `.mp4` -- measured 2026-08-31: a 0-byte output and
# "Could not find tag for codec prores in stream #0", while `.mov` and `.mkv`
# take it -- so shipping it needs a container guard it does not have (M100
# scope, ROADMAP candidate). codec_family() still infers "prores", so a prores
# codec is refused by the lookup below, which knows which backend was asked
# for, rather than by a family inference that does not.
hardware_backend_families <- function() {
list(
nvenc = c("h264", "hevc", "av1"),
videotoolbox = c("h264", "hevc")
)
}
# hardware_backends(): the backend vocabulary, read off the table so the two can
# never drift. The two exported helpers arg_match() against this; the 16 verbs
# and resolve_hw_encoder() keep a literal c("none", "nvenc", "videotoolbox")
# default instead, because their accepted set is read off the formal's own
# default and a computed default reads as a call, not a vector (RR07 Q2).
hardware_backends <- function() {
names(hardware_backend_families())
}
# hardware_codec_families(): every family codec_family() can infer, which is the
# mapper's `codec` vocabulary. Wider than any one backend's row on purpose: a
# family this list omits is refused as an unknown family, naming no backend,
# while a family a BACKEND omits is refused by the mapper's body,
# tm_hardware_encoder(), naming both.
hardware_codec_families <- function() {
c("h264", "hevc", "av1", "prores")
}
#' Hardware video encoders
#'
#' These functions help with optional hardware video encoding.
#' \code{hardware_encoder()} gives the hardware encoder name for a codec family.
#' \code{has_hardware_encoder()} reports whether that encoder is available in
#' the local FFmpeg build. The package supports two backends: NVIDIA nvenc
#' (H.264, HEVC and AV1) and Apple videotoolbox (H.264 and HEVC). So
#' \code{hardware_encoder("h264", "nvenc")} is \code{"h264_nvenc"}, and
#' \code{hardware_encoder("h264", "videotoolbox")} is
#' \code{"h264_videotoolbox"}. The glossary in \code{vignette("tidymedia")}
#' explains media terms such as codec, container and hardware encoder.
#'
#' \code{has_hardware_encoder()} is a \emph{cheap} check. It asks whether FFmpeg
#' lists the encoder (via \code{\link{ffmpeg_encoders}}). That list reflects how
#' FFmpeg was built. It does not reflect whether working hardware and a driver
#' are present at run time. An encode can still fail at run time on a machine
#' with no capable GPU. To override detection in a known environment (or in
#' tests), set \code{options(tidymedia.hardware_encoders = )} to a character
#' vector of encoder names to treat as available.
#'
#' The \code{hardware} argument of the task functions uses the same encoder
#' names and the same check. These task functions have that argument:
#' \code{\link{standardize_video}}, \code{\link{format_for_web}},
#' \code{\link{anonymize_video}}, \code{\link{crop_video}},
#' \code{\link{segment_video}}, \code{\link{compare_videos}},
#' \code{\link{picture_in_picture}}, and \code{\link{separate_audio_video}}
#' (and their \code{_batch} forms). Some of these functions have a
#' \code{video_codec} that defaults to \code{NULL} (no codec named), and they
#' assume the H.264 family. So a container that does not take H.264 (e.g.
#' \code{.webm}) needs an explicit HEVC- or AV1-family \code{video_codec}.
#' AV1 works only under \code{"nvenc"}. Hardware \emph{decoding}
#' (\code{-hwaccel}) and GPU filter pipelines are out of scope. Use the
#' \code{\link{ffmpeg}} direct command for those.
#'
#' @param codec The video codec family: one of \code{"h264"}, \code{"hevc"},
#' \code{"av1"}, or \code{"prores"}. These are the families the package
#' recognizes, not the families a given backend covers. If the chosen
#' \code{hardware} backend has no encoder for a family, the function refuses
#' the call. The error names both the backend and the family (e.g.
#' \code{"av1"} under \code{"videotoolbox"}). Both backends refuse
#' \code{"prores"} today.
#' @param hardware The backend: \code{"nvenc"} or \code{"videotoolbox"}.
#' Required, with no default. This set is narrower than the \code{hardware}
#' argument of the task functions. There, \code{"none"} means "use no
#' backend". That has no meaning here, so the function refuses it.
#' @return \code{hardware_encoder()} returns a single encoder-name string (e.g.
#' \code{"h264_nvenc"}). \code{has_hardware_encoder()} returns a length-one
#' logical. Neither returns for a \code{codec} that the chosen \code{hardware}
#' backend has no encoder for. That pair is a wrong argument, not a machine
#' without something. So both give the error that \code{codec} describes
#' above. \code{has_hardware_encoder()} returns \code{FALSE} only for a pair
#' that the chosen backend has an encoder for and this FFmpeg build does not
#' list.
#' @seealso \code{\link{ffmpeg_encoders}} for the full encoder list.
#' These task functions have the \code{hardware} argument:
#' \code{\link{standardize_video}}, \code{\link{format_for_web}},
#' \code{\link{anonymize_video}}, \code{\link{crop_video}},
#' \code{\link{segment_video}}, \code{\link{compare_videos}},
#' \code{\link{picture_in_picture}}, and
#' \code{\link{separate_audio_video}}.
#' @family capability functions
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' hardware_encoder("h264", "nvenc")
#' has_hardware_encoder("h264", "nvenc")
#' @export
hardware_encoder <- function(codec = c("h264", "hevc", "av1", "prores"),
hardware) {
# The `codec` default stays a LITERAL, not hardware_codec_families(): the Rd
# usage line publishes a formal's default verbatim, and a reader cannot
# evaluate an unexported call there. The same reason the 16 verbs spell their
# `hardware` vocabulary out (RR07 Q2); the sweep in test-hardware-backends.R
# pins this literal to the table it mirrors. The body below is where
# arg_match() runs, and it is handed both tables as explicit `values`, so the
# literal here is a published spelling rather than a second vocabulary (M112).
tm_hardware_encoder(codec, hardware, call = rlang::current_env())
}
# tm_hardware_encoder(): hardware_encoder()'s body, with `call` threaded.
#
# The split exists so `call` stops appearing in an exported signature and in
# the Rd usage line a reader copies from: it names the frame a refusal is
# reported from, which only tidymedia's own verbs have a value for (M112,
# superseding M110's decision to leave it a published formal). Every internal
# caller -- resolve_hw_encoder(), check_hardware_available(),
# hardware_encoder_available() -- reaches the table through here and passes the
# verb the caller typed; the exported wrapper above passes its own frame, since
# an exported function refusing its own argument names itself (M100 pass 2).
#
# `call` carries no default, for the reason hardware_encoder_available()'s does
# not: a default would hand a site that forgot it this helper's own frame,
# which is the blame the split exists to remove.
tm_hardware_encoder <- function(codec, hardware, call) {
codec <- rlang::arg_match(codec, hardware_codec_families(),
error_arg = "codec", error_call = call)
hardware <- rlang::arg_match(hardware, hardware_backends(),
error_arg = "hardware", error_call = call)
families <- hardware_backend_families()[[hardware]]
# The (family, backend)-not-in-table refusal, sited here and nowhere else: a
# lookup into a two-key table is where a missing pair is refused, and the
# predicate and resolve_hw_encoder() both reach it through this call (D079).
# It fires under `fallback = TRUE` too -- a pair no backend can encode is a
# wrong argument, not a machine that lacks something, which is the reading
# codec_family() has always had for a codec it cannot place at all.
if (!codec %in% families) {
cli::cli_abort(
c(
"{hardware} has no {.val {codec}} encoder.",
"i" = "{hardware} covers the {.val {families}} famil{?y/ies}.",
# No {.arg video_codec} or `hardware = "none"` advice here: this frame
# is reached from the exported helpers too, which have neither
# argument, and naming the backend that DOES cover the family would
# break the refusal's promise to name only the backend asked for.
"i" = "Name a codec in one of those families, or encode in software."
),
# Threaded by every internal caller, never left to default: master blamed
# this class of refusal on the verb the caller typed, through
# `codec_family(video_codec, call = call)`, and losing that is M094 F2 /
# D074's defect one layer down (M100 review pass 1, finding 1).
call = call
)
}
paste0(codec, "_", hardware)
}
#' @rdname hardware_encoder
#' @export
has_hardware_encoder <- function(codec = c("h264", "hevc", "av1", "prores"),
hardware) {
# `call` is this frame, so the refusal below names has_hardware_encoder() when the caller
# typed it. Internal callers pass their own verb's frame instead.
hardware_encoder_available(codec, hardware, call = rlang::current_env())
}
# hardware_encoder_available(): has_hardware_encoder()'s body with `call` threaded, so the
# probe can be reached from inside a verb without that verb's caller being told
# about has_hardware_encoder().
#
# The split exists for D074's siting. This is the one capability probe that runs
# while a command is being BUILT -- resolve_hw_encoder() consults it from inside
# the pipeline, and the nine fan-out verbs consult it at their front doors
# through check_hardware_available() -- so it is where a `hardware = "nvenc"` call
# first reads the limit. Left calling the exported has_hardware_encoder(), every such call
# was refused in has_hardware_encoder()'s name, a function the caller never typed (M094
# review F2). D042's carve-out covers the shape: an internal helper threads
# `call`, and this one does not build the reached-limit condition, so D049's
# blame is untouched.
# `call` carries no default on purpose. Every site has one to pass -- the verb
# the caller typed -- and a default would hand a site that forgot it this
# helper's own frame, which is the blame this split exists to remove (M094
# review G7).
hardware_encoder_available <- function(codec, hardware, call) {
enc <- tm_hardware_encoder(codec, hardware, call = call)
pool <- getOption("tidymedia.hardware_encoders", default = NULL)
# The option seam is read first on every call, so setting it mid-session takes
# effect at once; only the fall-through consults the session memo (D044).
if (is.null(pool)) {
# D074, INSIDE the fall-through and ABOVE the memo. The override branch
# above asks FFmpeg nothing and reads no limit, which is the one carve-out.
# The memo is a different matter: siting the call below cached_encoder_names()
# would make the refusal depend on whether this session had already asked,
# so the first call refused and the second did not (M094 review F5).
resolve_timeout(call = call)
pool <- cached_encoder_names()
}
enc %in% pool
}
# has_encoder() ---------------------------------------------------------------
#' Ask whether your FFmpeg build has an encoder
#'
#' FFmpeg builds differ in the encoders they were compiled with. A build without
#' \code{libx264} refuses every call that names it, and some Linux
#' distributions ship exactly that build, because x264 is not free software.
#' This function answers whether one encoder is in the build this package
#' resolves. You can then name another encoder, or skip the work, before you
#' run anything.
#'
#' The answer comes from the list \code{\link{ffmpeg_encoders}} returns. The
#' list is asked of FFmpeg once per session and then remembered, so a repeated
#' call does not start FFmpeg again. \code{\link{refresh_ffmpeg_capabilities}}
#' makes the next call ask again.
#'
#' The \code{tidymedia.hardware_encoders} option does not change this answer.
#' That option names the hardware encoders of the machine, and this question is
#' about the whole build. Use \code{\link{has_hardware_encoder}} for the GPU
#' backends.
#'
#' Where the package finds no FFmpeg at all, the answer is \code{FALSE}: nothing
#' can be encoded, so no encoder is available. A location you saved with
#' \code{\link{set_ffmpeg}} that no longer works still gives its warning beside
#' the \code{FALSE}. So a machine you can repair is never reported silently as a
#' build without the encoder.
#'
#' @param name A string naming one encoder, spelled as
#' \code{\link{ffmpeg_encoders}} spells it, such as \code{"libx264"},
#' \code{"aac"} or \code{"libmp3lame"}.
#' @return A single \code{TRUE} or \code{FALSE}.
#' @seealso \code{\link{ffmpeg_encoders}} for the whole list,
#' \code{\link{has_hardware_encoder}} for the GPU backends, and
#' \code{\link{refresh_ffmpeg_capabilities}} to make the next call ask FFmpeg
#' again.
#' @family capability functions
#' @export
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' has_encoder("libx264")
#' has_encoder("no_such_encoder")
has_encoder <- function(name) {
# `call` is this frame, so a wrong `name` is refused in has_encoder()'s name
# when the caller typed it (D087).
tm_encoder_available(name, call = rlang::current_env())
}
# tm_encoder_available(): has_encoder()'s body with `call` threaded, the split
# D087 fixes for an export whose refusal must name the function the caller
# typed. `call` carries no default, for the reason the hardware sibling above
# states.
#
# The limit is read FIRST, above the location question rather than inside it.
# The sibling reads it inside its fall-through because its option seam asks
# FFmpeg nothing; here there is no such seam, and siting the read below the
# location question would make an unusable `tidymedia.timeout` refused on a
# machine that has FFmpeg and ignored on one that does not (D074).
tm_encoder_available <- function(name, call) {
rlang::check_string(name, call = call)
resolve_timeout(call = call)
# No FFmpeg, no encoder. find_program()'s plain not-found warning is muffled:
# a caller asking whether this build has an encoder has not asked to be told
# FFmpeg is missing, and the verb they call next is what says so. The three
# warnings that name a file on this machine the caller can repair stay
# audible, on the partition D088 drew and the vector program_status() reads,
# so a remembered location that has gone is never reported silently as a
# build without the encoder: the FALSE comes with its warning.
location <- withCallingHandlers(
find_ffmpeg(),
warning = function(w) {
if (!inherits(w, tm_audible_location_classes())) {
invokeRestart("muffleWarning")
}
}
)
if (is.null(location)) return(FALSE)
name %in% cached_encoder_names()
}
# hardware_encoder_names(): every name tm_hardware_encoder() can return, read off
# the backend table. The diagnosis below leaves these alone: whether a backend
# encoder is usable is check_hardware_available()'s question, answered through
# the `tidymedia.hardware_encoders` seam, and has_encoder()'s pool deliberately
# does not read that seam.
hardware_encoder_names <- function() {
table <- hardware_backend_families()
unlist(lapply(names(table), function(hw) paste(table[[hw]], hw, sep = "_")),
use.names = FALSE)
}
# diagnose_missing_encoders(): after FFmpeg has ALREADY exited non-zero in
# ffm_run(), name the pipeline's codecs this build does not list as encodable
# (M139). D024's diagnostic licence, in the shape run_separation_audio() uses:
# the call aborts under every outcome, and this decides only WHICH abort is
# signalled -- never whether execution proceeds, what was compiled, a default,
# or a pipeline.
#
# Why after the failure and never before the start: FFmpeg looks up an encoder
# only for a stream kind the output has, so `-c:v libx264` on an audio-only
# input runs on a build without libx264. A check before the start would refuse
# that call, which runs today (M139 plan gate, second round).
#
# Returns a character vector of the missing names, named by stream ("video",
# "audio"), possibly empty; or NULL when the listing could not be read, which
# is the fail-open answer: ffm_run() then raises the merge-base condition
# unchanged. A reached limit is the one failure that also speaks, as a
# `tidymedia_probe_timeout` warning carrying `tm_limit` and
# `tm_check = "encoders"`, because a limit reached on a spawn is never silent
# (D049). D049's grain is one warning per check per call, so a caller that
# runs several pipelines -- ffm_batch(), separate_audio_video() -- takes that
# warning from the first run whose read reaches the limit and sets
# `tm_no_diagnosis` on the later runs (in ffm_batch(), the later runs on the
# same worker), which ffm_run() reads to skip this function (M139 review
# return 1). Those callers match `tm_check`, so another check's probe-timeout
# warning neither sets the flag nor is held by them (M139 review pass 2).
#
# Every warning raised while the listings are read is muffled, the way
# program_status() and has_encoder() keep a warning silent until it is decided
# about (D088): the failed run already resolved the same program and said what
# there was to say about its location. The probe-timeout warning below is
# raised outside that handler.
diagnose_missing_encoders <- function(object, call) {
wanted <- c(
stats::setNames(object$codec_video, rep("video", length(object$codec_video))),
stats::setNames(object$codec_audio, rep("audio", length(object$codec_audio)))
)
wanted <- wanted[!is.na(wanted) & nzchar(wanted) &
!wanted %in% c("copy", hardware_encoder_names())]
if (length(wanted) == 0L) return(character())
rlang::try_fetch(
withCallingHandlers(
{
unlisted <- wanted[!wanted %in% cached_encoder_names()]
if (length(unlisted)) {
unlisted <- unlisted[!unlisted %in% cached_encodable_codecs()]
}
unlisted
},
warning = function(w) invokeRestart("muffleWarning")
),
tidymedia_timeout = function(cnd) {
limit <- cnd$tm_limit
cli::cli_warn(
c(
"The encoder check timed out after {limit} second{?s}.",
"i" = "FFmpeg failed, and the package cannot say whether this \\
FFmpeg build lacks an encoder the run named. Raise or remove \\
{.code options(tidymedia.timeout = )}."
),
class = "tidymedia_probe_timeout",
tm_limit = limit,
tm_check = "encoders",
call = call
)
NULL
},
error = function(cnd) NULL
)
}
# codec_family(): infer the codec family from a software codec name, so a user
# can turn `hardware` on while keeping a familiar video_codec (e.g. "libx264"
# -> "h264"). Backend-free by design (D079): "libx264" is the h264 family under
# every backend, and WHICH backend has an encoder for a family is the table's
# question, refused in tm_hardware_encoder(). So this abort fires only when no
# family matches at all, and it names no backend -- a codec this cannot place
# is not a backend's fault.
codec_family <- function(video_codec, call = rlang::caller_env()) {
if (grepl("264|avc", video_codec, ignore.case = TRUE)) return("h264")
if (grepl("265|hevc", video_codec, ignore.case = TRUE)) return("hevc")
if (grepl("av1", video_codec, ignore.case = TRUE)) return("av1")
if (grepl("prores", video_codec, ignore.case = TRUE)) return("prores")
cli::cli_abort(
c(
"Cannot use {.arg hardware} with
{.arg video_codec} = {.val {video_codec}}.",
"x" = "No hardware encoder family maps to that codec.",
# The families some backend actually encodes, not hardware_codec_families():
# that vector holds "prores" so the nvenc refusal can name the family
# (Decisions, T2/T3), and a hint naming it would send the caller to a
# value every backend then refuses (pass 2, finding 4).
"i" = "The families are
{.val {unique(unlist(hardware_backend_families(), use.names = FALSE))}}
(e.g. {.val libx264}, {.val libx265}, {.val libaom-av1})."
),
call = call
)
}
# quality_flags(): the one table behind the `quality` argument (M135). Keyed on
# the EXACT resolved encoder name, never on a family or an alias: `-crf`, `-cq`
# and `-q:v` are three different scales, and each means what its own encoder
# says it means. The number is passed through unchanged, so no cross-encoder
# scale exists and none is implied. An encoder outside this table -- an alias
# such as "h264", a software encoder such as libvpx-vp9 -- is refused with
# `quality` set rather than handed a flag it may read differently or not at all.
# Layer 2 knowledge, like the backend table above it: the engine carries raw
# output options and never learns what a flag means (IP1).
quality_flags <- function() {
tibble::tibble(
encoder = c("libx264", "libx265",
"h264_nvenc", "hevc_nvenc", "av1_nvenc",
"h264_videotoolbox", "hevc_videotoolbox"),
flag = c("-crf", "-crf", "-cq", "-cq", "-cq", "-q:v", "-q:v"),
min = c(0, 0, 0, 0, 0, 1, 1),
max = c(51, 51, 51, 51, 51, 100, 100)
)
}
# check_quality(): refuse a `quality` the table cannot pass through. Three
# refusals, in this order: not one finite number; an encoder the table lacks
# (NULL -- no codec named under hardware = "none" -- and "copy" each get their
# own wording, since neither is an encoder); a value outside the row's range.
# NULL is never refused: it emits nothing, so the encoder's default applies.
#
# Pure: `encoder` is the name the verb WOULD encode with when its backend is
# available, computed without asking the machine, so this refusal reports
# before the availability probe (D036). `call` is threaded with no default
# (D087): every site has the verb the caller typed to pass.
check_quality <- function(quality, encoder, call) {
if (is.null(quality)) {
return(invisible(NULL))
}
rlang::check_number_decimal(quality, allow_infinite = FALSE,
arg = "quality", call = call)
if (is.null(encoder)) {
cli::cli_abort(
c(
"{.arg quality} needs a {.arg video_codec}.",
"x" = "With {.code video_codec = NULL} the output container's default
encoder is used, and {.arg quality} has no encoder to apply to.",
"i" = "Name an encoder (e.g. {.code video_codec = \"libx264\"}), or
drop {.arg quality}."
),
call = call
)
}
if (identical(encoder, "copy")) {
cli::cli_abort(
c(
"{.arg quality} needs a re-encoding {.arg video_codec}.",
"x" = "{.code video_codec = \"copy\"} stream-copies the video, so no
encoder runs and no quality value applies.",
"i" = "Name an encoder (e.g. {.code video_codec = \"libx264\"}), or
drop {.arg quality}."
),
call = call
)
}
tbl <- quality_flags()
row <- tbl[tbl$encoder == encoder, ]
if (nrow(row) == 0L) {
cli::cli_abort(
c(
"{.arg quality} has no meaning for encoder {.val {encoder}}.",
"x" = "The value is passed through as the encoder's own rate-control
flag, and no flag is known for {.val {encoder}}.",
"i" = "The encoders with a known flag are {.val {tbl$encoder}}.",
"i" = "Use one of those, or drop {.arg quality} and set the flag
through the {.fn ffm_output_options} pipeline function."
),
call = call
)
}
if (quality < row$min || quality > row$max) {
cli::cli_abort(
c(
"{.arg quality} must be between {row$min} and {row$max} for
encoder {.val {encoder}}.",
"x" = "{.val {encoder}} reads {.arg quality} as its {.code {row$flag}}
value, and {quality} is outside that range."
),
call = call
)
}
invisible(NULL)
}
# resolve_hw_encoder(): pick the encoder name for a verb's hardware= choice.
# hardware = "none" returns the software video_codec unchanged; a backend
# returns that backend's encoder for video_codec's family when available,
# otherwise aborts
# (fallback = FALSE) or returns the software video_codec with a message
# (fallback = TRUE). Layer 2 only computes the argument here; Layer 1 assembles
# the command (D009, IP1).
#
# video_codec = NULL is the "leave the codec alone" sentinel carried by the
# codec-less re-encode verbs (M34/D016): no -codec:v is emitted, so the output
# keeps its container's default encoder. It is resolved here, in the one
# resolver seam, rather than in a second per-verb fork.
#
# resolve_hw_encoder_info() is the body: it answers the encoder name AND whether
# it fell back, because the `quality` seam below needs the second answer (M135)
# and the name alone cannot carry it -- a fallback from h264_nvenc to a
# video_codec spelled "h264_nvenc" returns the intended name while encoding in
# software. `quality` is threaded in only so the fallback message can say the
# value was dropped; the flag itself is emitted by emit_quality(), never here.
# resolve_hw_encoder() keeps its one-string contract for every other caller.
resolve_hw_encoder <- function(video_codec,
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, call = rlang::caller_env()) {
hardware <- rlang::arg_match(hardware)
resolve_hw_encoder_info(video_codec, hardware, fallback, call = call)$encoder
}
resolve_hw_encoder_info <- function(video_codec,
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
call = rlang::caller_env()) {
hardware <- rlang::arg_match(hardware)
rlang::check_bool(fallback, call = call)
if (hardware == "none") {
return(list(encoder = video_codec, fell_back = FALSE))
}
# The sentinel branch sits BEFORE codec_family(), which cannot infer a family
# from nothing (it errors on NULL): under either backend the sentinel assumes
# H.264, the one family both backends encode and every common container
# accepts.
family <- if (is.null(video_codec)) {
"h264"
} else {
codec_family(video_codec, call = call)
}
# hardware_encoder_available() goes through the mapper, so a (family, backend)
# pair the table lacks is refused here rather than falling back: that is a
# wrong argument, not an absent encoder (see tm_hardware_encoder()).
if (fallback && !hardware_encoder_available(family, hardware, call = call)) {
cli::cli_inform(c(
"!" = if (is.null(video_codec)) {
# Falling back from the sentinel keeps the sentinel -- never a silently
# injected libx264, which would change the codec behind the user's back.
"{hardware} encoder {.val {tm_hardware_encoder(family, hardware, call = call)}} is not
available; falling back to the output container's default video
encoder."
} else {
"{hardware} encoder {.val {tm_hardware_encoder(family, hardware, call = call)}} is not
available; falling back to {.arg video_codec} = {.val {video_codec}}."
},
# The value was the hardware encoder's own scale, and no cross-encoder
# scale exists to carry it (M135 plan gate); saying so here is what keeps
# the lower-quality file from going unexplained.
"i" = if (!is.null(quality)) {
"{.arg quality} = {quality} is dropped: it was
{.val {tm_hardware_encoder(family, hardware, call = call)}}'s own
rate-control value, and the software encoder keeps its default."
}
))
return(list(encoder = video_codec, fell_back = TRUE))
}
# The abort lives in check_hardware_available(), never in a copy here: the nine
# fan-out verbs call that same function at their front doors (M57/D035), and
# two copies of the wording and the firing condition is exactly the drift the
# single site exists to make impossible.
#
# This call is reached on BOTH arms, and only for a pair the table HOLDS.
# On `fallback = TRUE` it is reached whenever the encoder IS available, since
# the `&&` above is then FALSE and nothing returns (measured 2026-09-04:
# resolve_hw_encoder("libx264", "nvenc", TRUE) against a build listing
# h264_nvenc returns "h264_nvenc" through this line). A pair the table omits
# never gets this far on either arm, but by different routes: on
# `fallback = TRUE` the predicate above goes through the mapper and
# tm_hardware_encoder() refuses it there, while on `fallback = FALSE` the `&&`
# short-circuits and the predicate never runs -- there the refusal comes from
# check_hardware_available()'s own table sweep, one line below (M107). The
# comment here used to claim `fallback = TRUE` always returned above; it
# returns only when the predicate answers.
check_hardware_available(video_codec, hardware, fallback, call = call)
list(encoder = tm_hardware_encoder(family, hardware, call = call),
fell_back = FALSE)
}
# intended_encoder(): the encoder a (video_codec, hardware) pair encodes with
# when its backend is available -- resolve_hw_encoder_info()'s answer with the
# machine left out of it. Pure, so check_quality() can read the table row
# before the availability probe (D036). The family inference and the backend
# lookup are the same two refusals the resolver makes, from the same frame.
intended_encoder <- function(video_codec, hardware = "none",
call = rlang::caller_env()) {
if (identical(hardware, "none")) {
return(video_codec)
}
family <- if (is.null(video_codec)) "h264" else codec_family(video_codec, call = call)
tm_hardware_encoder(family, hardware, call = call)
}
# check_hardware_available(): the hardware-encoder availability gate, and the
# only place its abort is worded. Called twice per verb by design (D035) -- once at the front
# door of each verb that fans out through ffm_batch(), so the abort names the
# verb rather than purrr::pmap(), and once from resolve_hw_encoder() while the
# pipeline is built, which is where it has fired since M31.
#
# `video_codec` takes either one codec value or a LIST of them, because a _batch
# verb's `video_codec` column may spell several families in one call and each
# one needs its own encoder. NULL -- and NA, its column form (D022) -- spells
# the h264 family, matching the sentinel branch resolve_hw_encoder() applies
# above; the two readings must agree or the front door would refuse a call the
# pipeline compiles.
#
# The `fallback = TRUE` early return sits BELOW the family sweep, not above it
# (M107). Above it, the sweep's two refusals -- codec_family()'s "no family maps
# to that codec" and tm_hardware_encoder()'s "this backend has no encoder for that
# family" -- were left to resolve_hw_encoder() while the pipeline was built,
# which in a _batch verb is inside purrr::pmap(): the very frame this front-door
# call exists to keep out of the caller's error (D035). Neither refusal is about
# the machine, so neither is something `fallback` can offer a way around: no
# build of FFmpeg grows a videotoolbox AV1 encoder, and no build makes
# "notacodec" name a family. The comment here used to say a `fallback = TRUE`
# call could not abort at this site, and that sweeping the column anyway would
# refuse a call that falls back happily today. Both halves were false --
# measured 2026-09-04, all 8 fan-out members refused those calls one frame down,
# under purrr::pmap().
#
# What the early return still guards is the AVAILABILITY probe below it: an
# encoder the table holds and this build does not list is exactly what
# `fallback` is for, so that pair must reach resolve_hw_encoder()'s fallback
# branch rather than abort here.
#
# `fallback` is VALIDATED before it is read, never tested with isTRUE(): under
# isTRUE() a malformed value (NA, "yes", c(TRUE, TRUE)) read as FALSE and got
# the availability abort in place of its own type error -- and only on a
# machine missing the encoder, so one wrong call was diagnosed two ways
# depending on the machine (M57 review F1). resolve_hw_encoder() has always
# checked it with rlang::check_bool(), so this raises the same error the
# pipeline would have raised, at the verb instead of inside purrr::pmap().
# It sits AFTER the hardware test: a hardware = "none" call never consults
# fallback here, and refusing one at the front door would be a new refusal.
check_hardware_available <- function(video_codec, hardware = "none",
fallback = FALSE,
call = rlang::caller_env()) {
# Membership in the backend set, never `identical(hardware, "nvenc")`: that
# gate let a `hardware = "videotoolbox"` call return early, so a missing
# encoder in a _batch call was blamed on purrr::pmap() instead of the verb --
# the defect the front-door call exists to prevent (D035, RR07). isTRUE()
# keeps a non-scalar value from reaching `if`, as identical() did.
if (!isTRUE(hardware %in% hardware_backends())) {
return(invisible(NULL))
}
rlang::check_bool(fallback, call = call)
codecs <- if (is.list(video_codec)) video_codec else list(video_codec)
families <- unique(vapply(codecs, function(vc) {
if (is.null(vc) || (length(vc) == 1L && is.na(vc))) {
"h264"
} else {
codec_family(vc, call = call)
}
}, character(1)))
# The (family, backend) table lookup, on BOTH fallback arms. The return value
# is discarded: what is wanted is the refusal tm_hardware_encoder() raises for a
# pair the table omits, sited here so it names the verb. Pure and free of the
# build, so nothing about the machine is consulted on the fallback arm.
for (family in families) tm_hardware_encoder(family, hardware, call = call)
if (fallback) {
return(invisible(NULL))
}
for (family in families) {
if (!hardware_encoder_available(family, hardware, call = call)) {
encoder <- tm_hardware_encoder(family, hardware, call = call)
# The same event as ffm_run()'s diagnosis, a build lacking an encoder,
# classed the same so one event is not classed at one site and unclassed
# at the other (M139, D062). It carries that site's `tm_encoder` and
# `tm_stream`. With no exit established here, it carries neither
# `tidymedia_ffmpeg_exit` nor `tm_status` (D063).
cli::cli_abort(
c(
"{hardware} encoder {.val {encoder}} is
not available.",
"x" = "This FFmpeg build does not list it (see {.fn ffmpeg_encoders}).",
"i" = "Use a machine whose FFmpeg build has it, or set
{.code fallback = TRUE} to encode in software instead."
),
class = "tidymedia_encoder_unavailable",
tm_encoder = encoder,
tm_stream = "video",
call = call
)
}
}
invisible(NULL)
}
# apply_video_codec(): thread a verb's video_codec/hardware/fallback choice into
# a pipeline. The NULL sentinel (M34/D016) means "leave the codec alone", so no
# ffm_codec() call is made at all and the compiled command gains no -codec:v.
# Shared by the four codec-less re-encode verbs' pipelines, so the sentinel and
# the up-front token check are handled once. Not an ffm_* name: this is Layer 2
# computing an argument, not engine surface (D014, IP1).
apply_video_codec <- function(object, video_codec, hardware = "none",
fallback = FALSE, quality = NULL,
call = rlang::caller_env()) {
check_video_codec(video_codec, call = call)
emit_video_codec(object, video_codec, hardware, fallback, quality, call = call)
}
# The two halves, separately callable (M095). They are one call at the three
# pipelines that can keep them together; the three M095 reorders cannot, because
# the second half ASKS THE MACHINE something -- resolve_hw_encoder() consults
# this FFmpeg build's encoder list under hardware = "nvenc" -- and a question
# about the machine must not outrank a complaint about the caller's own
# arguments. Splitting the seam is what lets standardize_pipeline() check the
# token where the pair used to sit and emit the codec after its last
# machine-independent check, with neither half moving relative to anything else.
#
# The halves are separately CALLABLE and no longer separately SKIPPABLE (M106).
# The emit half checks the token itself, above its resolver call, so a pipeline
# that reaches emit_video_codec() without a check_video_codec() above it emits
# nothing unchecked -- the ordering property is a property of the code rather
# than of what each future caller remembers to do. The two calls a
# check-then-emit caller now makes are idempotent: check_video_codec() reads
# `video_codec` and returns, so the second pass over a token the first accepted
# cannot change what the caller is told.
# check_video_codec(): the user's token, validated before family inference so
# the error is the same under hardware = "none" and "nvenc" (parity with
# anonymize_pipeline()). NULL is M34/D016's "leave the codec alone" sentinel and
# is not a token.
check_video_codec <- function(video_codec, call = rlang::caller_env()) {
if (!is.null(video_codec)) check_token(video_codec, call = call)
invisible(NULL)
}
# emit_video_codec(): resolve the encoder for the verb's hardware= choice and
# add it to the pipeline. Under hardware = "nvenc" this is where the build-time
# capability probe runs, so this half is what a pipeline sinks below its
# argument checks. The NULL sentinel emits nothing at all.
#
# The token check ABOVE the resolver, never below it and never left to the
# caller (M106). Both orderings refuse the same calls; only this one refuses
# them for the caller's own reason, because resolve_hw_encoder() consults this
# FFmpeg build under hardware = "nvenc" and would otherwise be free to answer
# "that encoder is not available" about a token that was never a codec name.
# A caller that checked already pays a second check that cannot change its
# answer; a caller that did not is no longer able to skip one.
#
# `quality` (M135) rides the same seam. Its check reads the table row for the
# encoder this pair WOULD use, computed without the machine, so a wrong value
# reports before the probe like every other argument complaint (D036); the
# flag is emitted after the resolver answers, and only when it did not fall
# back. With `quality = NULL` nothing here changes: intended_encoder() is not
# even called, so the refusal order of a quality-less call is untouched.
emit_video_codec <- function(object, video_codec, hardware = "none",
fallback = FALSE, quality = NULL,
call = rlang::caller_env()) {
check_video_codec(video_codec, call = call)
if (!is.null(quality)) {
check_quality(quality, intended_encoder(video_codec, hardware, call = call),
call = call)
}
info <- resolve_hw_encoder_info(video_codec, hardware, fallback, quality,
call = call)
if (is.null(info$encoder)) {
return(object)
}
object <- ffm_codec(object, video = info$encoder)
emit_quality(object, info$encoder, quality, fell_back = info$fell_back)
}
# emit_quality(): add the encoder's rate-control flag to the pipeline through
# ffm_output_options(), the engine's raw output-option slot -- Layer 2 computes
# the flag and the value, Layer 1 assembles the command (IP1, M31 precedent).
# The value is passed through unchanged. Nothing is emitted for NULL (the
# encoder default applies) or after a fallback (the value belonged to the
# hardware encoder's scale; resolve_hw_encoder_info() has already said so).
# `encoder` is the RESOLVED name, checked against the table above, so the
# lookup here cannot miss on the non-fallback arm.
emit_quality <- function(object, encoder, quality, fell_back = FALSE) {
if (is.null(quality) || fell_back) {
return(object)
}
tbl <- quality_flags()
flag <- tbl$flag[tbl$encoder == encoder]
ffm_output_options(object, paste(flag, as.character(quality)))
}
# apply_audio_codec(): thread a verb's audio_codec choice into a pipeline. The
# default "copy" stream-copies the audio, matching the norm standardize_video()
# and anonymize_video() already document, so these verbs stop re-encoding audio
# to whatever the local build's container default is (M35/D017). NULL is the
# escape hatch: no ffm_codec() call at all, so the command gains no -codec:a.
# ffm_codec() token-checks the value too, but checking here attributes the error
# to the user-facing verb rather than to the engine (parity with
# apply_video_codec()).
apply_audio_codec <- function(object, audio_codec, call = rlang::caller_env()) {
if (is.null(audio_codec)) {
return(object)
}
check_token(audio_codec, call = call)
ffm_codec(object, audio = audio_codec)
}
# the argument contradictions ---------------------------------------------
# Five checkers, one per contradiction between values a verb already holds
# before anything runs, and the only place each is worded. Each of the five
# aborts used to sit inside a `*_pipeline()` function, which is still where it
# fires for the verbs that call their pipeline directly; the verbs that fan out
# through ffm_batch() -> purrr::pmap() call the same checker at their front
# doors, so the abort names the verb the user called instead of
# "Error in `purrr::pmap(jobs, .f, ...)` / In index: 1" (the M47/M48-F1 shape,
# and the same fix M57 made for encoder availability). D035 supplies the SHAPE
# only, never the licence: none of these consults FFmpeg, the filesystem, or
# anything else outside the arguments already in hand, so D024's exclusions and
# D034 are not engaged.
#
# Each takes ONE row's already-resolved values and answers for that row. A
# _batch verb resolves its override columns to per-row values with
# batch_arg_rows() and calls the checker once per row, so a column carrying one
# violating row is refused while a column carrying none compiles -- an
# all-or-nothing gate on the whole table would do neither (the shape M57 review
# F4 caught on segment_video_batch's `reencode` column).
#
# `call` is threaded by every caller: from a pipeline it carries the scalar
# verb's frame, and at a front door the caller_env() default already IS the
# verb. The message text is the pipeline's own, moved rather than rewritten, so
# these guards change WHERE a call is refused and never WHICH calls are.
# Condition 1 (separate_stream_pipeline). A stream copy writes the source video
# bytes through untouched, so no encoder -- GPU or software -- runs on that path
# (D008 keeps the copy lossless and opt-in; D016 rules the same way for
# segment_video). Caught here rather than left to codec_family("copy"), which
# blames the codec name instead of the copy.
check_hardware_needs_encode <- function(video_codec, hardware = "none",
call = rlang::caller_env()) {
if (identical(video_codec, "copy") && !identical(hardware, "none")) {
cli::cli_abort(
c(
"{.arg hardware} needs a re-encoding {.arg video_codec}.",
"x" = "{.code video_codec = \"copy\"} stream-copies the video, so no
encoder runs.",
"i" = "Name an encoder (e.g. {.code video_codec = \"libx264\"}), or
pass {.code video_codec = NULL} to assume the H.264 family --
a non-H.264 container then needs an explicit HEVC- or
AV1-family codec.",
"i" = "Or drop {.arg hardware} to keep stream-copying the video."
),
call = call
)
}
invisible(NULL)
}
# Condition 2 (segment_pipeline). Same reasoning as condition 1 for the cut
# verbs, where the copy is spelled `reencode = FALSE` rather than a codec value:
# naming an encoder -- in software or on the GPU -- cannot mean anything on that
# path, so abort rather than silently drop the request (M34/D016).
check_codec_needs_reencode <- function(reencode, video_codec = NULL,
hardware = "none",
call = rlang::caller_env()) {
# `!reencode` on anything that is not a flag raised base R's `invalid
# argument type` from inside a front-door guard (M80). Every caller checks
# its own `reencode` first, so this refuses no call that was reaching here;
# it keeps the predicate from crashing when handed a non-flag.
rlang::check_bool(reencode, call = call)
if (!reencode && (!is.null(video_codec) || !identical(hardware, "none"))) {
cli::cli_abort(
c(
"{.arg video_codec} and {.arg hardware} need a re-encoding cut.",
"x" = "{.code reencode = FALSE} stream-copies each segment, so no
encoder runs.",
"i" = "Pass {.code reencode = TRUE} to cut by re-encoding, or drop
{.arg video_codec} / {.arg hardware}."
),
call = call
)
}
invisible(NULL)
}
# Condition 2b (segment_pipeline; M135): `quality` on a stream-copying cut.
# The same reasoning as condition 2, for the one value that names no encoder
# and yet only means something when one runs. Separate from condition 2 so its
# message names the argument the caller set, and checked AFTER it so a call
# wrong about both is told about the codec first (the check that was there).
check_quality_needs_reencode <- function(reencode, quality,
call = rlang::caller_env()) {
rlang::check_bool(reencode, call = call)
if (!reencode && !is.null(quality)) {
cli::cli_abort(
c(
"{.arg quality} needs a re-encoding cut.",
"x" = "{.code reencode = FALSE} stream-copies each segment, so no
encoder runs and no quality value applies.",
"i" = "Pass {.code reencode = TRUE} to cut by re-encoding, or drop
{.arg quality}."
),
call = call
)
}
invisible(NULL)
}
# Condition 3 (segment_pipeline), with one wrinkle over condition 2: the copy
# path's ffm_copy() sets -codec:a copy itself, so "copy" is the one value that
# agrees with it. Anything else -- a named encoder, or NULL asking for no
# -codec:a at all -- would be silently overwritten by ffm_copy() (M35/D017).
check_audio_codec_needs_reencode <- function(reencode, audio_codec,
call = rlang::caller_env()) {
# `!reencode` on anything that is not a flag raised a bare base R error from
# inside a front-door guard, as the twin above did before M80 (M81). WHICH
# error depends on the type: `missing value where TRUE/FALSE needed` for a
# logical NA, `invalid argument type` for a character one, which is why the
# twin's comment names the other of the two. All three callers -- segment_video(),
# segment_pipeline() and segment_video_batch()'s row sweep -- run
# check_codec_needs_reencode() on the same value first, and that one checks
# it, so this refuses no call that was reaching here.
rlang::check_bool(reencode, call = call)
if (!reencode && !identical(audio_codec, "copy")) {
cli::cli_abort(
c(
"{.arg audio_codec} needs a re-encoding cut.",
"x" = "{.code reencode = FALSE} stream-copies every stream, so the
audio is always copied.",
"i" = "Pass {.code reencode = TRUE} to cut by re-encoding, or leave
{.code audio_codec = \"copy\"}."
),
call = call
)
}
invisible(NULL)
}
# Conditions 4 and 6 (compare_videos_pipeline, picture_in_picture_pipeline).
# audio_codec configures an encode; with no audio mapped there is no stream to
# encode, so a named encoder is a contradiction rather than a no-op. NULL stays
# legal -- it only ever means "emit no -codec:a", which is already the case
# (M35/D017). IP3/D009 is untouched: the graph and its labels are unchanged.
#
# ONE checker for two verbs, not two: the headline and the "x" line are
# byte-identical on both and only the way out differs, so `hint` carries that
# difference as a parameter. Two sites spelling the same headline is the drift
# M40 hit by copying a shared guard's wording, and it is what the one-site
# uniqueness test in test-contradiction-front-door.R now fails on.
check_audio_codec_needs_audio <- function(audio_input, audio_codec, hint,
call = rlang::caller_env()) {
if (is.null(audio_input) && !is.null(audio_codec) &&
!identical(audio_codec, "copy")) {
cli::cli_abort(
c(
"{.arg audio_codec} needs an audio stream to encode.",
"x" = "{.code audio_input = NULL} carries no audio into the output.",
"i" = hint
),
call = call
)
}
invisible(NULL)
}
# Condition 5 (compare_videos_pipeline). ffm_hstack()/ffm_vstack() scale to the
# first input's size, which is defined for a pair and not for a stack of three,
# so resize supports exactly two inputs. Takes the input COUNT rather than the
# paths: the count is all the condition reads, and a fan-in front door already
# has it per row without materializing anything.
check_resize_needs_two_inputs <- function(resize, n_inputs,
call = rlang::caller_env()) {
# `resize &&` on anything that is not a flag raised a bare base R error from
# inside a front-door guard (M81) -- `missing value where TRUE/FALSE needed`
# for a logical NA, `invalid 'x' type in 'x && y'` for a character one. Both
# direct callers check `resize` first: compare_videos_pipeline(), which
# compare_videos() reaches after its own rlang::check_bool() on the scalar,
# and compare_videos_batch(), whose column guard refuses a non-logical or NA
# `resize` column -- so this refuses no call that was reaching here.
rlang::check_bool(resize, call = call)
if (resize && n_inputs != 2) {
cli::cli_abort(
c(
"{.arg resize} currently supports exactly two inputs.",
"i" = "Pass {.code resize = FALSE} to compare more than two videos."
),
call = call
)
}
invisible(NULL)
}
# Enumerated vocabularies and their one refusal site (M59) ------------------
#
# M59 sites 5 and 6. The two vocabularies below were each spelled out in THREE
# signatures -- the scalar verb, its _batch sibling, and the shared pipeline --
# and arg-matched separately at each, so one wrong value had three possible
# abort sites and the front-door column sweep would have added a fourth.
#
# What is single-sourced here is CHECKING, not display (M59-D3). Every check
# reads its vocabulary from these accessors and refuses through the one
# check_vocab_arg() below. The four EXPORTED signatures still spell their values
# out, so `?compare_videos` shows them and formals() returns something a caller
# can evaluate; only the two internal pipelines default to an accessor. Editing
# a vocabulary here therefore means editing those four signatures too -- which
# is not left to memory: test-value-check-front-door.R EVALUATES every signature
# default and fails unless it equals the accessor's answer.
#
# A function rather than a bare constant so the two internal pipelines can
# default to it, and so a caller of these accessors gets a fresh vector rather
# than a package-level object created at build time and kept in step with
# lazy-loading. check_vocab_arg() passes `values` explicitly rather than letting
# arg_match() read a formal default, because a column sweep has no formals to
# read from; the single-argument arg_match() calls elsewhere in the package do
# read theirs, as usual.
stack_directions <- function() c("horizontal", "vertical")
pip_positions <- function() {
c("topright", "topleft", "bottomright", "bottomleft", "center")
}
# The one site an out-of-vocabulary value is refused, for a scalar argument and
# for a `jobs` column alike. arg_match() normally reads its values from the
# CALLER's formals, which a column sweep has no equivalent of -- but `values`
# is a parameter, so passing them explicitly keeps every branch of its contract
# rather than replacing any of it. Nothing about which values are accepted, how
# a refusal reads, or which frame is blamed moves with this: an unsupplied
# argument (the value still IS the whole vector) and a caller-reordered
# permutation both return the value's first element, a length-1 value is matched
# against the vocabulary, and everything else aborts naming `arg` and `call`.
#
# This went through rlang::arg_match0() until M59's review (F1/F2). arg_match0()
# takes a STRING, so on any longer value its own length guard fired first and
# aborted with ITS call, ignoring `error_call` -- which put
# `rlang::arg_match0(value, values, arg_nm = arg, error_call = call)`, and that
# helper's own formal names, in front of a user who had passed a two-element
# `position`. arg_match() is the entry point that takes a vector; reaching past
# it to the string-only one was the whole defect.
check_vocab_arg <- function(value, values, arg, call = rlang::caller_env()) {
rlang::arg_match(value, values, error_arg = arg, error_call = call)
}
# segment_video() ---------------------------------------------------------
#' Segment Video
#'
#' Use FFmpeg to quickly break a single video file into multiple smaller video
#' files (with the same encoding). Pairs of start and stop timestamps set the
#' segments. The function names each segment file after \code{infile}. It
#' appends a suffix of an underscore (_) and an integer indicating which
#' segment, based on the order provided in \code{start} and \code{end}. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as codec,
#' keyframe and stream copy.
#'
#' @inheritParams crop_video
#' @param start A vector containing one or more timestamps indicating the
#' start of each segment to create. Can be either a numeric vector indicating
#' seconds or a character vector with time duration syntax. Must have the same
#' length as \code{end}.
#' @param end A vector containing one or more timestamps indicating the stop
#' of each segment to create. Can be either a numeric vector indicating
#' seconds or a character vector with time duration syntax. Must have the same
#' length as \code{start}.
#' @param outfiles Either NULL or a character vector indicating the filename
#' (with extension) for each segment to create. If NULL, will append a
#' zero-padded integer to \code{infile}. If not NULL, must have the same
#' length as \code{start}, and each element must be a single string. So the
#' function accepts a list of strings as well as a character vector. This
#' function itself refuses a missing value or a number in any position, before
#' the per-segment step below it can. Two segments given the same path
#' are refused before any segment is cut.
#' @param reencode A logical passed to \code{\link{ffm_seek}}: cut each segment
#' frame-accurately by re-encoding (\code{TRUE}, default) or with a fast,
#' lossless copy that snaps to keyframes (\code{FALSE}). See \code{ffm_seek}
#' for the trade-off.
#' @param video_codec `r video_codec_unset_param()` A stream copy runs no
#' encoder, so naming a codec (or a \code{hardware} backend) alongside
#' \code{reencode = FALSE} is an error.
#' @param audio_codec A string naming the output audio codec.
#' `r audio_codec_copy_sentences("the audio")` A stream copy
#' (\code{reencode = FALSE}) always copies the audio, so any other value is an
#' error there. Stream-copying fails if the output container cannot hold the
#' source audio codec (e.g. FLAC in \code{.mp4}). Name an encoder instead.
#' @param hardware `r hardware_param(null_default = TRUE)`
#' `r encoder_check_sentences()` `r contradiction_sentences("cut")`
#' The stream-copy conflict named under \code{reencode} is caught first, so
#' such a call aborts without probing.
#' @param quality `r quality_param()`
#' A stream-copying cut (\code{reencode = FALSE}) runs no encoder, so it
#' refuses \code{quality} too.
#' @param audio_stream `r audio_stream_param("carry into the output", "carries", "every", extra = audio_stream_extras$passthrough_subtitles)`
#' @param run A logical: run each segment's command (\code{TRUE}, default) or
#' only compile them (\code{FALSE}).
#' @param parallel A logical passed to \code{\link{ffm_batch}}: cut segments in
#' parallel with \pkg{furrr} (\code{TRUE}) or sequentially (\code{FALSE},
#' default). Parallelism follows the active \code{\link[future:plan]{future}}
#' plan; \code{TRUE} under the default sequential plan runs one segment at a
#' time and warns. Set a plan first, e.g.
#' \code{future::plan(future::multisession)}.
#' @return The [tibble][tibble::tibble-package] returned by
#' \code{\link{ffm_batch}}: one row per segment with its \code{command} (and,
#' when \code{run = TRUE}, \code{success}).
#' @seealso [ffm_seek()], the pipeline function it uses to cut; [ffm_batch()],
#' the runner; [has_hardware_encoder()] for the \code{hardware} argument;
#' [segment_video_batch()] for the many-file form.
#' @references `r time_duration_reference()`
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' # Two segments; run = FALSE compiles one command per segment
#' segment_video(video, start = c(0, 0.5), end = c(0.5, 1), run = FALSE)
#' @export
segment_video <- function(infile,
start,
end,
outfiles = NULL,
reencode = TRUE,
video_codec = NULL,
audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE,
quality = NULL,
audio_stream = NULL,
run = TRUE,
parallel = FALSE) {
check_file_readable(infile)
if (!(is.numeric(start) || is.character(start))) {
cli::cli_abort("{.arg start} must be a numeric or character vector.")
}
if (!(is.numeric(end) || is.character(end))) {
cli::cli_abort("{.arg end} must be a numeric or character vector.")
}
if (length(start) != length(end)) {
cli::cli_abort("{.arg start} and {.arg end} must have the same length.")
}
if (!is.null(outfiles) && length(outfiles) != length(start)) {
cli::cli_abort("{.arg outfiles} must have the same length as {.arg start}.")
}
# Each element must be what `ffm_files()` will be handed for that segment: a
# single string. Written per ELEMENT rather than as a check on `outfiles` as a
# whole because the fan-out receives one cell of the jobs table at a time, so
# `list("a.mp4")` is a call this verb compiles today and a whole-object
# `is_character()` test would refuse it (M96 AC4).
#
# Without this the refusal came from `ffm_files()` inside
# `ffm_batch()` -> `purrr::pmap()`, so a caller who mistyped an output name
# read "Error in `purrr::pmap(jobs, .f, ...)` / In index: 1" -- a dependency's
# name and an internal row number in place of the verb they called, the M41
# shape every other argument on this verb already avoids (M48 review F1).
#
# Sited here, below the length check so a miscounted call still reads about
# its count, and above `check_hardware_available()` below so a build without
# nvenc cannot decide whether a wrong `outfiles` is reported as a wrong
# `outfiles` or as a missing encoder (D075).
#
# `arg` names the ELEMENT once there is more than one, because the message
# `rlang::check_string()` writes is "must be a single string" -- true of the
# element it was handed and false of `outfiles`, which the length check three
# lines up has just required to be as long as `start`. A caller who correctly
# passed forty output names and mistyped the thirty-first would otherwise be
# told `outfiles` must be a single string, with no way to tell which one is
# wrong (M96 review F2).
for (i in seq_along(outfiles)) {
rlang::check_string(
outfiles[[i]],
arg = if (length(outfiles) == 1L) "outfiles" else paste0("outfiles[[", i, "]]")
)
}
rlang::check_bool(reencode)
check_token(video_codec, allow_null = TRUE)
check_token(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
# Unlike crop_video(), this verb DOES need its own front-door check. M47's F8
# reasoning -- "pass_through_maps() carries the identical check with `call`
# resolving to the verb" -- holds only where the verb calls its pipeline
# directly. This one fans out through ffm_batch() -> purrr::pmap(), so
# segment_pipeline()'s caller_env() resolves to the anonymous closure and a bad
# value was reported as "Error in `purrr::pmap(jobs, .f, ...)` / In index: 1",
# leaking a dependency's name and an internal index -- the exact M41 shape
# every other argument on this verb already avoids (M48 review F1).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# The two cut contradictions (conditions 2 and 3), re-checked here so a
# contradictory call blames this verb instead of purrr::pmap() (M58). Every
# value is a scalar argument on this verb -- only the _batch sibling has
# columns to sweep -- so one call each covers every segment.
check_codec_needs_reencode(reencode, video_codec, hardware)
check_audio_codec_needs_reencode(reencode, audio_codec)
check_quality_needs_reencode(reencode, quality)
# If no names are provided, derive per-segment names from the input file.
# Above the nvenc probe below rather than beside the fan-out, so the
# collision check can read the names this call will write before any program
# starts (M125).
if (is.null(outfiles)) {
outfiles <- derive_segment_names(rep(infile, length(start)))
}
# Two segments written to one path (M125). A derived set cannot repeat --
# numbering is per segment -- so only a supplied `outfiles` can reach the
# abort. After every machine-free check above, so each still reports first.
check_distinct_outputs(
unlist(outfiles, use.names = FALSE),
"{.arg outfiles} names the same path for more than one segment.",
"Give each segment its own output file.",
call = rlang::current_env()
)
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Last in the front-door block, so
# every check above still reports first (M41).
#
# UNGATED since M58, where M57 gated it on `reencode`. The gate was there
# because segment_pipeline() aborted EARLIER on a non-re-encoding cut naming
# an encoder, and firing here would have replaced that message with an
# availability one. The contradiction check two lines up now makes that
# impossible: this guard only ever acts on `hardware = "nvenc"`, and a
# `reencode = FALSE` call naming nvenc has already been refused above. The
# gate is dead code, not a live protection (M58 T2).
#
# `quality` (M135), checked here for the reason the guards above are: the
# seam's own check runs inside purrr::pmap() for this verb and would blame
# the closure. Pure -- intended_encoder() never asks the machine -- and
# placed ABOVE the availability probe, so a wrong value reports before an
# absent encoder (D036). Skipped entirely for NULL, so a quality-less call
# keeps its refusal order.
if (!is.null(quality)) {
check_quality(quality, intended_encoder(video_codec, hardware),
call = rlang::current_env())
}
check_hardware_available(video_codec, hardware, fallback)
# Fan-out (one input -> many outputs) is a Layer 2 concern: build one
# single-output seek pipeline per segment and run them through ffm_batch
# (D-M03-2). The engine stays single-output (D003).
jobs <- tibble::tibble(
input = infile, output = outfiles, start = start, end = end
)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, start, end, ...) {
# `audio_stream` is captured from the enclosing call rather than read off
# a column: this fan-out builds its own jobs tibble from one input, so
# every segment of one call takes the same track by construction.
segment_pipeline(input, output, start, end, reencode,
video_codec = video_codec, audio_codec = audio_codec,
hardware = hardware, fallback = fallback,
audio_stream = audio_stream, quality = quality)
},
run = run,
parallel = parallel
)
}
# derive_segment_names() --------------------------------------------------
# Derive one output path per segment from its input path, appending
# `_<n>.<ext>` to each input's basename. Numbering restarts per input file (in
# row order) and is zero-padded to that input's own segment count, so the
# single-input case (segment_video) and the multi-input jobs table
# (segment_video_batch with no `output` column) share one naming rule.
derive_segment_names <- function(input) {
out <- character(length(input))
for (f in unique(input)) {
sel <- input == f
padded <- pad_integers(seq_len(sum(sel)))
out[sel] <- paste0(
tools::file_path_sans_ext(f), "_", padded, ".", tools::file_ext(f)
)
}
out
}
# derive_frame_names() ----------------------------------------------------
# Derive one image path per frame from its input path, appending `_<n>.<format>`
# to each input's basename. Same per-input-restart, zero-padded rule as
# derive_segment_names(), but the extension is the image `format` (a frame is an
# image, not a copy of the source container) rather than the input's extension.
derive_frame_names <- function(input, format = "png") {
out <- character(length(input))
for (f in unique(input)) {
sel <- input == f
padded <- pad_integers(seq_len(sum(sel)))
out[sel] <- paste0(tools::file_path_sans_ext(f), "_", padded, ".", format)
}
out
}
# segment_pipeline() ------------------------------------------------------
# Shared cut logic for segment_video() and segment_video_batch(): build one
# single-output seek pipeline for a single segment, stream-copying on the fast
# (non-reencode) path. Fan-out verbs stay single-output per job (D003, D007);
# both verbs wrap this in a closure that captures the scalar `reencode`.
segment_pipeline <- function(input, output, start, end, reencode,
video_codec = NULL, audio_codec = "copy",
hardware = "none",
fallback = FALSE, audio_stream = NULL,
quality = NULL,
call = rlang::caller_env()) {
# The two cut contradictions (conditions 2 and 3), worded once in their
# checkers. They live here so both callers inherit them per row (M34/D016,
# M35/D017); segment_video() and segment_video_batch() ALSO call them at their
# front doors (M58), where the abort can name the verb instead of
# purrr::pmap().
check_codec_needs_reencode(reencode, video_codec, hardware, call = call)
check_audio_codec_needs_reencode(reencode, audio_codec, call = call)
check_quality_needs_reencode(reencode, quality, call = call)
p <- ffm_seek(ffm_files(input, output), start = start, end = end,
reencode = reencode)
if (!reencode) p <- ffm_copy(p)
# ORDER IS LOAD-BEARING: this must stay BELOW the ffm_copy() line. ffm_copy()
# assigns the all-streams map and aborts on a pipeline that already states a
# different one (M48/D027), so hoisting this above -- the shared-line
# placement standardize_pipeline() and crop_video_pipeline() use, and the
# obvious tidy-up -- would abort EVERY reencode = FALSE call. `replace = TRUE`
# because ffm_map() appends (D023): on the copy branch ffm_copy() has already
# set `0`, and appending beside it would compile three maps and duplicate
# every stream instead of narrowing to two. On the re-encode branch there is
# no prior map, so `replace` is a no-op and one line serves both.
p <- ffm_map(p, pass_through_maps(audio_stream, call = call), replace = TRUE)
p <- apply_audio_codec(p, audio_codec, call = call)
apply_video_codec(p, video_codec, hardware, fallback, quality, call = call)
}
# segment_video_batch() --------------------------------------------------------
#' Segment Many Videos From a Jobs Table
#'
#' Cut segments across many input files from a single jobs tibble. This is the
#' **batch** (table-driven) form of [segment_video()], for when your segments
#' span more than one input. Each row is one segment; the four required columns
#' name its source, destination, and cut points. This is a thin wrapper over
#' \code{\link{ffm_batch}}: one reproducible compiled command per segment. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as codec,
#' keyframe and stream copy.
#'
#' @param jobs A data frame with one row per segment and (at least) the columns
#' \code{input} (source path), \code{start} and \code{end} (cut points). Each
#' cut-point column is a numeric column of seconds or a character column with
#' time-duration syntax.
#' Two optional columns are recognized: \code{output} (destination path) and
#' \code{reencode} (a logical; see the \code{reencode} argument). If
#' \code{output} is absent, the function derives one per row by appending
#' \code{_<n>.<ext>} to each input's basename. The segment number
#' restarts at 1 for each input file (the same rule as
#' \code{\link{segment_video}}). Two rows given the same \code{output} path
#' are refused before any row runs. A \code{video_codec} or \code{audio_codec}
#' column overrides that argument per row, with \code{NA} meaning "leave the
#' codec unset" (the column's way of writing the argument's \code{NULL}). An
#' \code{audio_stream} column likewise overrides that argument per row, with
#' \code{NA} meaning "keep every audio track" (the column's way of writing
#' that argument's \code{NULL}). A numeric \code{quality} column overrides
#' the \code{quality} argument per row (see \code{quality}). Any other
#' columns are ignored.
#' @param reencode A logical passed to \code{\link{ffm_seek}}: cut each segment
#' frame-accurately by re-encoding (\code{TRUE}, default) or with a fast,
#' lossless copy that snaps to keyframes (\code{FALSE}). See \code{ffm_seek}
#' for the trade-off. Applies to every row, unless \code{jobs} carries a
#' \code{reencode} column, which overrides this argument on a per-row basis.
#' @param video_codec A string naming the output video codec, applied to every
#' row lacking a \code{video_codec} column. \code{NULL} (default) leaves
#' it unset, so each segment keeps its container's default encoder. A row can
#' resolve to a codec while cutting by stream copy (\code{reencode = FALSE},
#' as an argument or a column). That row is an error, because no encoder runs
#' on that path.
#' @param audio_codec A string naming the output audio codec, applied to every
#' row lacking an \code{audio_codec} column. \code{"copy"} (default)
#' stream-copies the audio; name an encoder to re-encode it, or \code{NULL} to
#' leave the codec unset. A row can resolve to anything but \code{"copy"}
#' while cutting by stream copy (\code{reencode = FALSE}, as an argument or a
#' column). That row is an error. So split a jobs table that mixes
#' stream-copy rows with a re-encoding \code{audio_codec} into separate calls.
#' @param hardware,fallback `r batch_hardware_param("segment_video")`
#' Because \code{hardware} is batch-wide, a non-\code{"none"} value
#' conflicts with a stream-copy row on its own, even one naming no codec.
#' So split a jobs table that mixes \code{reencode = FALSE} rows with GPU
#' encoding into separate calls.
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' `r contradiction_sentences("cut")`
#' The stream-copy conflict named under \code{reencode} is caught first, so
#' such a call aborts without probing.
#' @param quality `r batch_quality_param("segment_video")`
#' A cell on a row that stream-copies (\code{reencode = FALSE}) is refused
#' too, because no encoder runs on that row.
#' @param audio_stream `r audio_stream_param("carry into each output", "carries", "every", batch = TRUE, extra = audio_stream_extras$passthrough_subtitles)`
#' @param run A logical: run each segment's command through FFmpeg
#' (\code{TRUE}, default) or only compile them for inspection (\code{FALSE}).
#' @inheritParams segment_video
#' @inheritParams anonymize_video_batch
#' @return `r batch_return("output")`
#' @seealso [segment_video()] for the single-input, parallel-vector form.
#' [ffm_batch()] for the batch runner and the arguments forwarded through
#' \code{...}. [has_hardware_encoder()] for the \code{hardware} argument.
#' [ffm_seek()] for the cut trade-off.
#' @references `r time_duration_reference()`
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' output = c("a.mp4", "b.mp4"),
#' start = c(0, 0.5),
#' end = c(0.5, 1)
#' )
#' # run = FALSE compiles one command per segment without calling FFmpeg
#' segment_video_batch(jobs, run = FALSE)
#' @export
segment_video_batch <- function(jobs, reencode = TRUE, video_codec = NULL,
audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE, parallel = FALSE, ...) {
check_token(video_codec, allow_null = TRUE)
check_token(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per segment.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
required <- c("input", "start", "end")
missing <- setdiff(required, names(jobs))
if (length(missing) > 0) {
cli::cli_abort(c(
"{.arg jobs} must have columns {.val {required}}.",
"x" = "Missing column{?s}: {.val {missing}}."
))
}
# Validate cut-point column types up front (parity with segment_video()), so a
# bad column fails clearly here rather than as an opaque FFmpeg error.
if (!(is.numeric(jobs$start) || is.character(jobs$start))) {
cli::cli_abort("The {.field start} column of {.arg jobs} must be numeric or character.")
}
if (!(is.numeric(jobs$end) || is.character(jobs$end))) {
cli::cli_abort("The {.field end} column of {.arg jobs} must be numeric or character.")
}
if ("reencode" %in% names(jobs) &&
(!is.logical(jobs$reencode) || anyNA(jobs$reencode))) {
cli::cli_abort(
"The {.field reencode} column of {.arg jobs} must be {.val {TRUE}} or {.val {FALSE}} (no {.val {NA}})."
)
}
check_batch_codec_col(jobs)
check_batch_codec_col(jobs, "audio_codec")
# The stream-index column's own type guard, with a hint saying what NA means
# HERE: on the pass-through family an unselected row keeps EVERY track, where
# on the extraction verbs the same cell keeps the first one, and on the
# composites it drops audio entirely (M40's stale-hint lesson, which is why
# the wording is a parameter).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
# And the argument's front-door check. Load-bearing here, unlike on the scalar
# sibling: the column path resolves NA to the NULL sentinel, so without it
# `audio_stream = NA` would quietly keep every track instead of erroring (the
# M37/M41 shape). Per-row VALUES are checked again inside audio_stream_map(),
# which every row's pipeline reaches (M32). No check_batch_stream_values()
# here -- that is only needed where a verb RESHAPES its jobs table before the
# fan-out, and this one is 1 row in, 1 row out, so pmap's index already IS the
# caller's row (M45 review F4).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
rlang::check_bool(reencode)
# Auto-name outputs when the column is absent: derive per-input segment names
# (numbering restarts per input file) and carry them on the returned tibble.
if (!"output" %in% names(jobs)) {
jobs$output <- derive_segment_names(jobs$input)
}
# Sweep jobs$input here, below the shape/type guards and above the cut
# contradiction sweep below, so a missing input blames this verb rather
# than purrr::pmap() (M62).
check_batch_inputs(jobs)
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output seek pipeline
# per row, sharing segment_pipeline() with segment_video(). A per-row
# `reencode` column (arriving via `...` from pmap) overrides the scalar arg;
# `...` also forwards ffm_batch options (verify/manifest/...) to the runner,
# never to the pipeline builder.
# The two cut contradictions (conditions 2 and 3), re-checked here so a
# contradictory call blames this verb instead of purrr::pmap() (M58). Swept
# ROW BY ROW because all three values can arrive as columns: a table mixing a
# copying row with a re-encoding one is refused for the copying row alone,
# where an all-or-nothing gate would either refuse the whole table or miss it
# (the shape M57 review F4 caught on this verb's nvenc guard).
reencode_rows <- batch_arg_rows(jobs, "reencode", reencode)
vcodec_rows <- batch_arg_rows(jobs, "video_codec", video_codec,
batch_codec_cell)
acodec_rows <- batch_arg_rows(jobs, "audio_codec", audio_codec,
batch_codec_cell)
vcodec_cols <- any(c("reencode", "video_codec") %in% names(jobs))
acodec_cols <- any(c("reencode", "audio_codec") %in% names(jobs))
# Condition 2b (M135/M136): `quality` on a stream-copying row. Resolved per
# row like the codecs -- an NA cell is the column form of NULL (D022) -- and
# checked after the two above, so a row wrong about both is told about the
# codec first, as the scalar verb answers.
quality_rows <- batch_arg_rows(jobs, "quality", quality, batch_stream_cell)
quality_cols <- any(c("reencode", "quality") %in% names(jobs))
for (i in seq_len(nrow(jobs))) {
check_batch_cell(
if (vcodec_cols) i else NA_integer_,
check_codec_needs_reencode(reencode_rows[[i]], vcodec_rows[[i]], hardware)
)
check_batch_cell(
if (acodec_cols) i else NA_integer_,
check_audio_codec_needs_reencode(reencode_rows[[i]], acodec_rows[[i]])
)
check_batch_cell(
if (quality_cols) i else NA_integer_,
check_quality_needs_reencode(reencode_rows[[i]], quality_rows[[i]])
)
}
# Two rows resolving to one output path (M125), after the row sweep above and
# above the nvenc probe below. A derived column cannot repeat -- numbering
# restarts per input and counts that input's rows, and each derived name keeps
# its input's stem and extension -- so only a supplied `output` can reach the
# abort.
reject_duplicate_outputs(jobs)
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035), immediately before ffm_batch() so
# every check above still reports first (M41).
#
# UNSCOPED since M58, where M57 swept only the re-encoding rows. That scoping
# was there because a copying row has no encoder to check and its own cut
# error had to report instead. The row sweep above now makes that impossible:
# this guard only ever acts on `hardware = "nvenc"`, which contradicts EVERY
# copying row, so any table reaching this line re-encodes on every row. The
# scoping is dead code, not a live protection (M58 T2).
# `quality`, argument and column, against each row's resolved encoder
# (M136); above the probe (D036), naming this verb and the row (D076), as
# the scalar verb checks it above its own probe.
check_batch_quality(jobs, quality, vcodec_rows, hardware)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, start, end, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
segment_pipeline(
input, output, start, end,
reencode = pick("reencode", reencode),
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# extract_frame_batch() --------------------------------------------------------
#' Extract Still Frames From Many Videos From a Jobs Table
#'
#' Save one still image for each row, across many input files, using one jobs
#' table. This is the **batch** form of [extract_frame()], for when your frames
#' come from more than one input. Each row is one frame. The required columns
#' name its source and the moment to capture. The function is a thin wrapper
#' over \code{\link{ffm_batch}}. It builds one reproducible command for each
#' frame. The glossary in \code{vignette("tidymedia")} explains media terms
#' such as frame rate.
#'
#' @param jobs A data frame with one row per frame. It needs at least an
#' \code{input} column (source path). It also needs \strong{exactly one} of a
#' \code{timestamp} column and a \code{frame} column. A \code{timestamp}
#' holds seconds, or \pkg{FFmpeg} time-duration strings. A \code{frame} holds
#' whole frame numbers. The function converts each one to a timestamp with
#' the input's frame rate, as \code{\link{extract_frame}} does. An optional
#' \code{output} column names the destination image. When it is absent, the
#' function derives one per row by appending \code{_<n>.<format>} to each
#' input's basename. The frame number restarts at 1 for each input file. The
#' function refuses two rows whose destination is the same path, before any
#' row runs. That covers a repeated \code{output}, and two derived names that
#' match. For example, \code{clip.mp4} and \code{clip.mkv} both give
#' \code{clip_1.png}. The function ignores any other columns.
#' @param format A string giving the image file extension used when the
#' function derives \code{output}. The function ignores it when \code{jobs}
#' has an \code{output} column. (default = \code{"png"})
#' @param run A logical: run each frame's command through FFmpeg (\code{TRUE},
#' default) or only build the commands for inspection (\code{FALSE}).
#' @param parallel A logical passed to \code{\link{ffm_batch}}: save frames in
#' parallel with \pkg{furrr} (\code{TRUE}) or one after another (\code{FALSE},
#' default). Parallel work follows the active
#' \code{\link[future:plan]{future}} plan. \code{TRUE} under the default
#' sequential plan runs one frame at a time and warns.
#' @inheritParams anonymize_video_batch
#' @return `r batch_return("output")`
#' @seealso [extract_frame()] for the single-frame form. [ffm_batch()] for the
#' batch runner and the arguments passed on through \code{...}.
#' [segment_video_batch()] for the batch function that cuts segments.
#' @references `r time_duration_reference()`
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' output = c("a.png", "b.png"),
#' timestamp = c(0.25, 0.75)
#' )
#' # run = FALSE compiles one command per frame without calling FFmpeg
#' extract_frame_batch(jobs, run = FALSE)
#' @export
extract_frame_batch <- function(jobs, format = "png", run = TRUE,
parallel = FALSE, ...) {
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per frame.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
rlang::check_string(format)
# Table-level exclusivity: exactly one of the selection columns, mirroring
# extract_frame()'s scalar timestamp/frame exclusive-or.
has_ts <- "timestamp" %in% names(jobs)
has_fr <- "frame" %in% names(jobs)
if (has_ts == has_fr) {
cli::cli_abort(c(
"{.arg jobs} must have exactly one of a {.field timestamp} or {.field frame} column.",
"x" = if (has_ts) "Both columns are present." else "Neither column is present."
))
}
# Validate the selection column's type + reject NA up front, so a bad column
# fails clearly here rather than as an opaque FFmpeg (or framerate) error.
if (has_ts) {
if (!(is.numeric(jobs$timestamp) || is.character(jobs$timestamp))) {
cli::cli_abort("The {.field timestamp} column of {.arg jobs} must be numeric or character.")
}
if (anyNA(jobs$timestamp)) {
cli::cli_abort("The {.field timestamp} column of {.arg jobs} must not contain {.val {NA}}.")
}
# A numeric timestamp must be finite (parity with extract_frame()'s
# finite = TRUE check); anyNA() above already caught NA/NaN, so this is Inf.
if (is.numeric(jobs$timestamp) && any(!is.finite(jobs$timestamp))) {
check_batch_cell(which(!is.finite(jobs$timestamp))[1], cli::cli_abort(
"The {.field timestamp} column of {.arg jobs} must be finite."
))
}
} else {
if (!is.numeric(jobs$frame)) {
cli::cli_abort("The {.field frame} column of {.arg jobs} must be numeric.")
}
if (anyNA(jobs$frame)) {
cli::cli_abort("The {.field frame} column of {.arg jobs} must not contain {.val {NA}}.")
}
# Whole numbers only (parity with extract_frame()'s check_number_whole() and
# this verb's documented "whole frame numbers" contract).
if (any(jobs$frame %% 1 != 0)) {
check_batch_cell(which(jobs$frame %% 1 != 0)[1], cli::cli_abort(
"The {.field frame} column of {.arg jobs} must contain whole numbers."
))
}
}
# A factor input column carries paths as levels; treat them as the strings
# they are (parity with the character case).
jobs$input <- as.character(jobs$input)
# Auto-name outputs when the column is absent: per-input frame names with the
# image extension, carried on the returned tibble.
if (!"output" %in% names(jobs)) {
jobs$output <- derive_frame_names(jobs$input, format = format)
}
# Sweep jobs$input now that its shape/type is settled, immediately before
# the fan-out, so a missing input blames this verb rather than
# purrr::pmap() (M62).
check_batch_inputs(jobs)
# Two rows resolving to one output path (M125), last in the front door. Both
# forms reach it: a supplied `output` column, and a derived one, whose
# extension is the image `format` rather than the source's, so two inputs
# sharing a stem under different containers (`a.mp4`, `a.mkv`) derive the
# same `a_1.png`.
reject_duplicate_outputs(jobs)
# Thin Layer-2 fan-out over ffm_batch (D007): one single-frame pipeline per
# row, sharing frame_pipeline() with extract_frame(). frame->timestamp
# resolution happens per row (via the input's frame rate); `...` forwards
# ffm_batch options (verify/manifest/...) to the runner, never to the builder.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
timestamp <- if (!is.null(dots$timestamp)) {
dots$timestamp
} else {
dots$frame / get_frame_rate(input)
}
frame_pipeline(input, output, timestamp)
},
run = run,
parallel = parallel,
...
)
}
# derive_frames_dir() -----------------------------------------------------
# Derive one output directory per input for sample_frames_batch() when neither
# an `outdir` column nor a scalar `outdir` is given: `<input-base>_frames` beside
# each input. Per-input directories keep each recording's sequence separate, so
# the batch never collides even when many inputs sample at once.
derive_frames_dir <- function(input) {
file.path(
dirname(input),
paste0(tools::file_path_sans_ext(basename(input)), "_frames")
)
}
# sample_frames_batch() ---------------------------------------------------
#' Sample frames from many videos at a fixed rate from a jobs table
#'
#' Sample many videos into numbered image sequences, using one jobs table. This
#' is the **batch** form of [sample_frames()]. Each row is one input video,
#' sampled at a fixed rate into its own image sequence. The function is a thin
#' wrapper over \code{\link{ffm_batch}}. It builds one reproducible command for
#' each input. The glossary in \code{vignette("tidymedia")} explains media terms
#' such as frame rate.
#'
#' Supply the sampling rate once as the single \code{fps} or \code{interval}
#' argument, which applies to every row. Or supply it per row as an \code{fps}
#' or \code{interval} column, which overrides the argument of the same name.
#' Supply exactly one of the two, fps \emph{or} interval, across arguments and
#' columns.
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column (source path). An optional \code{outdir} column gives
#' the output directory for that row's sequence. When it is absent, the
#' function derives one as \code{<input-base>_frames} beside each input.
#' Optional \code{fps} and \code{interval} columns override the rate per row.
#' The function ignores any other columns. The function refuses two rows
#' whose image sequences would share a file-name pattern, before any row
#' runs. Two rows share a pattern when they have the same output directory
#' path and the same input file name without its extension. The directory
#' path can come from the column, from the \code{outdir} argument, or from
#' the derived name.
#' @param fps,interval The sampling rate applied to every row, as in
#' [sample_frames()]. A per-row column of the same name overrides it. Supply
#' exactly one of the two (as an argument or a column). (default = \code{NULL})
#' @param outdir An optional single output directory for all rows. An
#' \code{outdir} column overrides it. When both are absent, the function
#' derives one directory per input. (default = \code{NULL})
#' @param format A string giving the output image file extension, as in
#' [sample_frames()]. (default = \code{"png"})
#' @inheritParams anonymize_video_batch
#' @param parallel A logical passed to \code{\link{ffm_batch}}: sample in
#' parallel with \pkg{furrr} (\code{TRUE}) or one after another (\code{FALSE},
#' default). Parallel work follows the active
#' \code{\link[future:plan]{future}} plan. \code{TRUE} under the default
#' sequential plan runs one at a time and warns.
#' @return `r batch_return("outdir")`
#' @seealso [sample_frames()] for the single-video form. [ffm_batch()] for the
#' batch runner and the arguments passed on through \code{...}.
#' [extract_frame_batch()] for the batch function that takes a list of
#' frames.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' outdir = c(file.path(tempdir(), "a"), file.path(tempdir(), "b"))
#' )
#' # run = FALSE compiles one command per input without calling FFmpeg
#' sample_frames_batch(jobs, fps = 2, run = FALSE)
#' @export
sample_frames_batch <- function(jobs, fps = NULL, interval = NULL,
outdir = NULL, format = "png", run = TRUE,
parallel = FALSE, ...) {
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
format <- check_image_format(format)
# Table-level rate exclusivity: exactly one of an fps source or an interval
# source (argument or column), mirroring sample_frames()' scalar XOR. The
# per-row VALUES are swept below, immediately before the fan-out, calling the
# same resolve_sample_fps() the closure calls (M64); before that they were
# only read inside the closure, so a bad rate was blamed on purrr::pmap().
fps_src <- !is.null(fps) || "fps" %in% names(jobs)
interval_src <- !is.null(interval) || "interval" %in% names(jobs)
if (fps_src == interval_src) {
cli::cli_abort(c(
"Provide exactly one of {.arg fps} or {.arg interval} (argument or column).",
"x" = if (fps_src) "Both are present." else "Neither is present."
))
}
# Validate present override columns up front so a bad column fails clearly here
# rather than as an opaque FFmpeg error mid-batch (M11 parity lesson). An `fps`
# column may be character (an FFmpeg rate expression) but `interval` may not —
# resolve_sample_fps() rejects a character interval, so type it numeric-only
# here (parity with extract_frame_batch()'s per-column typing).
if ("fps" %in% names(jobs) &&
!(is.numeric(jobs$fps) || is.character(jobs$fps))) {
cli::cli_abort("The {.field fps} column of {.arg jobs} must be numeric or character.")
}
if ("interval" %in% names(jobs) && !is.numeric(jobs$interval)) {
cli::cli_abort("The {.field interval} column of {.arg jobs} must be numeric.")
}
for (col in intersect(c("fps", "interval"), names(jobs))) {
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
# A factor input column carries paths as levels; treat them as strings
# (parity with extract_frame_batch()).
jobs$input <- as.character(jobs$input)
# Resolve the per-row output directory: an explicit `outdir` column wins; else
# the scalar `outdir` (one directory for every row); else one derived per
# input. Carried on the returned tibble.
if ("outdir" %in% names(jobs)) {
if (!is.character(jobs$outdir) || anyNA(jobs$outdir)) {
cli::cli_abort("The {.field outdir} column of {.arg jobs} must be character (no {.val {NA}}).")
}
} else if (!is.null(outdir)) {
rlang::check_string(outdir)
jobs$outdir <- outdir
} else {
jobs$outdir <- derive_frames_dir(jobs$input)
}
# Reject colliding output patterns before running: each row's pattern is
# `<outdir>/<input-base>_%0Nd.<fmt>`, so two rows sharing a directory whose
# inputs also share a basename (e.g. a duplicated input, or `cam1/rec.mp4` +
# `cam2/rec.mp4` under one `outdir`) would silently overwrite each other's
# frames. Fail clearly here rather than lose data mid-batch (the sibling
# dup-input guard, adapted to the pattern level).
patterns <- derive_frame_pattern(jobs$input, jobs$outdir, NULL, format)
collisions <- unique(patterns[duplicated(patterns)])
if (length(collisions) > 0) {
cli::cli_abort(c(
"Two or more jobs would write to the same image sequence.",
"x" = "Colliding output pattern{?s}: {.file {collisions}}.",
"i" = "Give colliding inputs distinct {.field outdir}s or rename them."
))
}
# Sweep jobs$input here, below the shape/type/collision guards above and
# immediately before the fan-out, so a missing input blames this verb
# rather than purrr::pmap() (M62).
check_batch_inputs(jobs)
# Per-row rate VALUES, swept here so a bad rate blames this verb instead of
# purrr::pmap() (M64). The guards above cover each column's TYPE and the
# table-level exactly-one contract; this covers each value the fan-out would
# resolve. resolve_sample_fps() is the one site the message is written -- the
# same function the closure below calls, and the same one sample_frames()
# already reaches at its own front door, so the two forms refuse a bad rate
# with one sentence rather than two.
#
# Immediately before ffm_batch(), which is where M41 puts a guard added for
# blame, so every check above still reports first.
fps_rows <- batch_arg_rows(jobs, "fps", fps)
interval_rows <- batch_arg_rows(jobs, "interval", interval)
rate_cols <- any(c("fps", "interval") %in% names(jobs))
for (i in seq_len(nrow(jobs))) {
check_batch_cell(if (rate_cols) i else NA_integer_,
resolve_sample_fps(fps_rows[[i]], interval_rows[[i]]))
}
# Thin Layer-2 fan-out over ffm_batch (D007): one image2-pattern sampling
# pipeline per input, sharing sample_frames_pipeline() with sample_frames().
# A per-row rate column (arriving via `...` from pmap) overrides the scalar
# arg of the same name; `...` also forwards ffm_batch options
# (verify/manifest/...) to the runner, never to the pipeline builder.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, outdir, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
rate <- resolve_sample_fps(pick("fps", fps), pick("interval", interval))
dir_i <- ensure_dir(outdir)
pattern <- derive_frame_pattern(input, dir_i, NULL, format)
sample_frames_pipeline(input, pattern, rate)
},
run = run,
parallel = parallel,
...
)
}
# derive_standardized_names() ---------------------------------------------
# Derive one output path per input for standardize_video_batch() when the `output`
# column is absent: `<base>_standardized.<input-ext>` (standardization keeps the
# source container, unlike a frame which becomes an image). Standardization is
# one-input -> one-output, so -- unlike the per-input-numbering siblings -- a
# duplicated input with no explicit `output` would collide; the caller
# (standardize_video_batch) rejects that up front, so this helper assumes unique
# inputs and stays a pure name map.
derive_standardized_names <- function(input) {
paste0(
tools::file_path_sans_ext(input), "_standardized.", tools::file_ext(input)
)
}
# standardize_video_batch() ----------------------------------------------------
#' Standardize Many Videos From a Jobs Table
#'
#' Re-encode many files to a reproducible format, using one jobs table. This is
#' the **batch** form of [standardize_video()], for when you have more than one
#' video to standardize. Each row is one input, and the only required column
#' names its source. The function is a thin wrapper over
#' \code{\link{ffm_batch}}. It builds one reproducible command for each input.
#' The glossary in \code{vignette("tidymedia")} explains media terms such as
#' codec, pixel format and frame rate.
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column, the source path. An optional \code{output} column
#' names the destination. Without it, each row's output name adds
#' \code{_standardized} to the input's base name and keeps its extension.
#' For example, \code{clip.mkv} becomes \code{clip_standardized.mkv}.
#' `r duplicate_output_sentences()`
#' A column can override any of the six format arguments for each row:
#' \code{width}, \code{height}, \code{fps}, \code{video_codec},
#' \code{audio_codec} and \code{pixel_format}. An argument with no column
#' applies its value to every row. In either codec column, \code{NA} leaves
#' that row's codec unset. That is the column form of
#' \code{video_codec = NULL} or \code{audio_codec = NULL}. In a
#' \code{width}, \code{height}, \code{fps} or \code{pixel_format} column,
#' \code{NA} is an error. \code{pixel_format} has no unset state to express.
#' \code{width}, \code{height} and \code{fps} do accept \code{NULL} as
#' arguments, but their columns have no \code{NA} form for it. An
#' \code{audio_stream} column overrides the \code{audio_stream} argument for
#' each row, and \code{NA} keeps that row on every audio track. A numeric
#' \code{quality} column overrides the \code{quality} argument per row (see
#' \code{quality}). Any other columns are ignored.
#' @param width,height Optional target dimensions for every row, unless
#' \code{jobs} has a column of the same name (see \code{jobs}). When only one
#' is given, the other is derived to keep the aspect ratio. When neither is
#' given, the frame is floor-cropped to even dimensions, so odd-sized sources
#' encode. (default = \code{NULL})
#' @param fps Optional target frame rate applied to every row, unless
#' \code{jobs} carries an \code{fps} column. (default = \code{NULL}, i.e.
#' leave the frame rate unchanged)
#' @param video_codec A string naming the video codec for every row, unless
#' \code{jobs} has a \code{video_codec} column. In that column, \code{NA}
#' leaves that row's codec unset.
#' `r batch_libx264_sentences()`
#' @param audio_codec A string naming the audio codec for every row, unless
#' \code{jobs} has an \code{audio_codec} column. In that column, \code{NA}
#' leaves that row's codec unset. \code{"copy"} (default) stream-copies the
#' audio through untouched. Name an encoder, such as \code{"aac"}, when the
#' source audio cannot be copied into the output container.
#' @param pixel_format A string naming the pixel format applied to every row,
#' unless \code{jobs} carries a \code{pixel_format} column.
#' (default = \code{"yuv420p"})
#' @param hardware The encoder backend for every row. \code{"none"} is the
#' default. \code{"nvenc"} uses NVIDIA GPU encoding (H.264, HEVC and AV1),
#' and \code{"videotoolbox"} uses Apple GPU encoding (H.264 and HEVC). It
#' applies to the whole batch and is not read as a column. See
#' \code{\link{standardize_video}} and \code{\link{has_hardware_encoder}}.
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' @param fallback `r fallback_param("video_codec")`
#' @param quality `r batch_quality_param("standardize_video")`
#' @param audio_stream `r audio_stream_param("carry into each output", "carries", "every", batch = TRUE, extra = audio_stream_extras$passthrough_subtitles)`
#' @inheritParams anonymize_video_batch
#' @param parallel A logical passed to \code{\link{ffm_batch}}: standardize in
#' parallel with \pkg{furrr} (\code{TRUE}) or sequentially (\code{FALSE},
#' default). Parallelism follows the active \code{\link[future:plan]{future}}
#' plan; \code{TRUE} under the default sequential plan runs one input at a
#' time and warns. Set a plan first, e.g.
#' \code{future::plan(future::multisession)}.
#' @return `r batch_return("output")`
#' @seealso [standardize_video()] for the single-input form; [ffm_batch()] for
#' the batch runner and the arguments forwarded through \code{...};
#' [segment_video_batch()] and [extract_frame_batch()] for the other batch
#' task functions.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' output = c("a.mp4", "b.mp4"),
#' width = c(640, 320)
#' )
#' # run = FALSE compiles one command per input without calling FFmpeg
#' standardize_video_batch(jobs, run = FALSE)
#' @export
standardize_video_batch <- function(jobs, width = NULL, height = NULL, fps = NULL,
video_codec = "libx264", audio_codec = "copy",
pixel_format = "yuv420p",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE, parallel = FALSE, ...) {
hardware <- rlang::arg_match(hardware)
# NULL is legal (the "emit no -codec:a" escape hatch), so allow_null (M39).
check_token(audio_codec, allow_null = TRUE)
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
# A factor input column carries paths as levels; treat them as strings
# (parity with extract_frame_batch()).
jobs$input <- as.character(jobs$input)
# Validate present override columns up front so a bad column fails clearly
# here rather than as an opaque FFmpeg error mid-batch (M11 parity lesson).
# These cover each column's TYPE; the per-row VALUES (positive dimensions,
# clean pixel-format tokens) are swept below, above the nvenc check, so a bad
# cell blames this verb rather than purrr::pmap() (M64).
dim_cols <- c("width", "height", "fps")
for (col in intersect(dim_cols, names(jobs))) {
if (!(is.numeric(jobs[[col]]) || is.character(jobs[[col]]))) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be numeric or character.")
}
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
str_cols <- c("pixel_format")
for (col in intersect(str_cols, names(jobs))) {
if (!is.character(jobs[[col]]) || anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be character (no {.val {NA}}).")
}
}
# Both codec columns take the codec guard, which admits NA and the all-NA
# logical column R hands back (M34 lesson). `video_codec` sat in str_cols
# above until M42, justified by a comment calling it "a literal libx264
# default with no sentinel" -- false when written, since the argument accepts
# NULL, and now the family rule: NA is the column form of that NULL (D022).
# `pixel_format` stays in str_cols: not a codec argument, no sentinel, so an
# NA cell there spells nothing.
#
# Checking video_codec here rather than in the loop's slot moves it after
# `pixel_format` in the reporting order for a jobs table with two bad columns.
# Named because M41's review twice caught a guard reassigning precedence
# unremarked; the two codec columns now report together.
check_batch_codec_col(jobs, "video_codec")
check_batch_codec_col(jobs, "audio_codec")
# video_codec had no front-door check, so a non-string reached ffm_codec() per
# row and aborted inside purrr::pmap() naming Layer-1's `video` -- the caller's
# own argument name never appeared (M41). allow_null keeps NULL compiling
# exactly as it does today: no -codec:v, the container's default encoder.
#
# No longer the LAST of this verb's front-door checks, though it was when
# M41 placed it here: the audio_stream column and argument guards below it,
# the input sweep, and the duplicated-input refusal all now follow (M80 T8,
# D058). It is still deliberately AFTER the jobs shape and content checks:
# before M41 this argument was only read per row inside the fan-out, so
# EVERY check above it reported first on a call that was wrong about two
# things at once. Moving the guard up the function silently reassigned that
# precedence -- first past the jobs SHAPE block (review A6), then past its
# content checks too (review A1r3). Here it changes nothing but the message
# a bad codec gets.
check_token(video_codec, allow_null = TRUE)
# The stream-index column's own type guard, with a hint saying what NA means
# HERE. The shared default ("drop audio") belongs to the composite verbs and
# the extraction verbs say "keep the first audio track"; on the pass-through
# family an unselected row keeps them ALL, so a borrowed hint would be false
# (M40).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
# And the scalar argument's front-door check. Unlike the scalar verb's, this
# one is load-bearing: the column path resolves NA to the NULL sentinel, so
# without it `audio_stream = NA` would quietly compile every track instead of
# erroring (the M37/M41 shape). Per-row VALUES are checked again inside
# audio_stream_map(), which every row's pipeline reaches (M32). No
# check_batch_stream_values() here -- that is only needed where a verb
# RESHAPES its jobs table before the fan-out, and this one is 1 row in, 1 row
# out, so pmap's index already IS the caller's row (M45 review F4).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep jobs$input now that its shape/type is settled, and before the nvenc
# availability sweep below, so a missing input blames this verb rather
# than purrr::pmap() (M62). Above the derived-output block below too, so a
# table naming one absent path in every row hears about the path (M80).
#
# The sweep and that block move together and land HERE, below the column and
# scalar guards above, not at the block's old site: D057 narrows the ordering
# rule for the DUPLICATION guard only, and lifting the sweep to the block's
# old position also lifted it past `audio_stream`'s column guard and past the
# two scalar checks -- the precedence reassignment the comment above warns
# about, caught at M80's review (F1/F2) after the first attempt shipped it.
check_batch_inputs(jobs)
# Auto-name outputs when the column is absent. One input -> one output, so a
# duplicated input with no explicit output would map to the same file; reject
# that rather than silently overwrite. Below the sweep above and not above it,
# so a table whose rows all name the same unreadable file is told about the
# file rather than about the duplication (M80, D057).
if (!"output" %in% names(jobs)) {
reject_duplicate_inputs(jobs)
jobs$output <- derive_standardized_names(jobs$input)
}
# Per-row geometry and pixel-format VALUES, swept here so a bad one blames
# this verb instead of purrr::pmap() (M64). Same shape and same site as
# crop_video_batch()'s sweep: inputs above, nvenc availability below.
#
# That placement REORDERS this verb: these values were read inside the
# fan-out, so an unavailable encoder used to report first and now does not.
# Deliberate, and the reason crop_video_batch() already answers this way -- a
# dimension is wrong on every machine, an encoder only on this one. Recorded
# in M64's reordering table.
#
# NULL means "leave this dimension alone" for the three geometry knobs, so a
# resolved NULL cell is skipped; `pixel_format` has no such sentinel and is
# always a token.
for (dim in c("width", "height", "fps")) {
rows <- batch_arg_rows(jobs, dim, get(dim))
for (i in seq_along(rows)) {
if (!is.null(rows[[i]])) {
check_batch_cell(if (dim %in% names(jobs)) i else NA_integer_,
check_dim(rows[[i]], arg = dim))
}
}
}
pixfmt_rows <- batch_arg_rows(jobs, "pixel_format", pixel_format)
for (i in seq_along(pixfmt_rows)) {
check_batch_cell(if ("pixel_format" %in% names(jobs)) i else NA_integer_,
check_token(pixfmt_rows[[i]], arg = "pixel_format"))
}
# Two rows resolving to one output path (M125), on the resolved column: a
# supplied `output` is compared as given, and a derived one cannot repeat once
# reject_duplicate_inputs() above has passed, since each keeps its input's
# stem and extension. Last among the machine-free
# checks, so every refusal above still reports first, and above the nvenc
# probe below, so a colliding table starts no program.
reject_duplicate_outputs(jobs)
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output re-encode
# pipeline per row, sharing standardize_pipeline() with standardize_video().
# A per-row knob column (arriving via `...` from pmap) overrides the scalar
# arg of the same name; `...` also forwards ffm_batch options
# (verify/manifest/...) to the runner, never to the pipeline builder.
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame, so every check above still
# reports first. The sweep covers each distinct family a `video_codec` column
# spells, never only the argument's.
# `quality`, argument and column, checked against each row's resolved
# encoder (M136). Pure, so it sits with the machine-free checks and above
# the availability probe below (D036); here rather than inside the fan-out
# so the abort names this verb and the row (D076, M56).
check_batch_quality(
jobs, quality,
batch_arg_rows(jobs, "video_codec", video_codec, batch_codec_cell),
hardware
)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
standardize_pipeline(
input, output,
width = pick("width", width),
height = pick("height", height),
fps = pick("fps", fps),
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
pixel_format = pick("pixel_format", pixel_format),
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022): that row keeps its
# encoder's default, whatever the argument says.
quality = batch_stream_cell(pick("quality", quality)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# derive_stripped_names() -------------------------------------------------
# Derive one output path per input for strip_metadata_batch() when the `output`
# column is absent: `<base>_stripped.<input-ext>` (a metadata scrub keeps the
# source container). The base keeps the input's directory, so inputs in
# different folders never collide; strip_metadata_batch() rejects any duplicated
# *resolved* output up front (M26), so this helper stays a pure name map.
derive_stripped_names <- function(input) {
paste0(
tools::file_path_sans_ext(input), "_stripped.", tools::file_ext(input)
)
}
# strip_metadata_batch() --------------------------------------------------------
#' Strip Metadata From Many Files From a Jobs Table
#'
#' De-identify many files, using one jobs table. This is the **batch** form of
#' [strip_metadata()], for when you have more than one file to scrub. Each row
#' is one input, and the only required column names its source. The function
#' is a thin wrapper over \code{\link{ffm_batch}}. It builds one reproducible
#' stream-copy strip command for each input. Each command uses the same steps
#' as \code{strip_metadata()}, so it is bit-exact and drops the same metadata.
#' The glossary in \code{vignette("tidymedia")} explains media terms such as
#' stream and stream copy.
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column, the source path. An optional \code{output} column
#' names the destination. Without it, each row's output name adds
#' \code{_stripped} to the input's base name and keeps its extension. For
#' example, \code{clip.mkv} becomes \code{clip_stripped.mkv}. Any two rows
#' with the **same** output path are rejected, so one file cannot silently
#' overwrite another. That happens with a duplicated \code{input} and no
#' \code{output} column, or with a repeated explicit \code{output}. Any other
#' columns are ignored, because the scrub has no per-row settings.
#' @inheritParams anonymize_video_batch
#' @param parallel A logical passed to \code{\link{ffm_batch}}: scrub in
#' parallel with \pkg{furrr} (\code{TRUE}) or sequentially (\code{FALSE},
#' default). Parallelism follows the active \code{\link[future:plan]{future}}
#' plan; \code{TRUE} under the default sequential plan runs one input at a
#' time and warns. Set a plan first, e.g.
#' \code{future::plan(future::multisession)}.
#' @return `r batch_return("output")`
#' @seealso [strip_metadata()] for the single-input form; [ffm_batch()] for the
#' batch runner and the arguments forwarded through \code{...};
#' [standardize_video_batch()] and [anonymize_video_batch()] for the other
#' batch task functions.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(input = video, output = "clean.mp4")
#' # run = FALSE compiles one command per input without calling FFmpeg
#' strip_metadata_batch(jobs, run = FALSE)
#' @export
strip_metadata_batch <- function(jobs, run = TRUE, parallel = FALSE, ...) {
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
# A factor input column carries paths as levels; treat them as strings
# (parity with the other *_batch verbs).
jobs$input <- as.character(jobs$input)
if (anyNA(jobs$input)) {
cli::cli_abort("The {.field input} column of {.arg jobs} must not contain {.val {NA}}.")
}
# Resolve outputs (derive when absent), then reject any collision on the
# *resolved* path — not just duplicated inputs — so an explicit `output`
# column repeated across rows can't silently overwrite either (M26).
if (!"output" %in% names(jobs)) {
jobs$output <- derive_stripped_names(jobs$input)
} else {
jobs$output <- as.character(jobs$output)
if (anyNA(jobs$output)) {
cli::cli_abort("The {.field output} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
reject_duplicate_outputs(jobs)
# Sweep jobs$input here, below the shape/type/collision guards above and
# immediately before the fan-out, so a missing input blames this verb
# rather than purrr::pmap() (M62).
check_batch_inputs(jobs)
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output strip pipeline
# per row, sharing strip_metadata_pipeline() with strip_metadata(). `...`
# forwards ffm_batch options (verify/manifest/...) to the runner; the scrub
# itself has no per-row knobs, so extra job columns are ignored.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) strip_metadata_pipeline(input, output),
run = run,
parallel = parallel,
...
)
}
# derive_normalized_names() -----------------------------------------------
# Derive one output path per input for normalize_audio_batch() when the `output`
# column is absent: `<base>_normalized.<input-ext>` (loudness normalization
# keeps the source container). One input -> one output, so a duplicated input
# with no explicit `output` would collide; the caller (normalize_audio_batch)
# rejects that up front, so this helper assumes unique inputs and stays a pure
# name map (parity with derive_standardized_names()).
derive_normalized_names <- function(input) {
paste0(
tools::file_path_sans_ext(input), "_normalized.", tools::file_ext(input)
)
}
# normalize_audio_batch() ------------------------------------------------------
#' Normalize Many Files' Audio Loudness From a Jobs Table
#'
#' Normalize the audio loudness of many input files (EBU R128) from a single
#' jobs tibble. This is the \strong{batch} (table-driven) form of
#' [normalize_audio()], for when you have more than one file to normalize. Each
#' row is one input, and the only required column names its source. The function
#' is a thin wrapper over \code{\link{ffm_batch}}. It gives one reproducible
#' compiled command per input. Each row uses the same \code{loudnorm} pipeline,
#' and the same check of each value, as [normalize_audio()]. Set
#' \code{two_pass = TRUE} for accurate measured/linear normalization across the
#' whole table (see \code{two_pass}). The glossary in
#' \code{vignette("tidymedia")} explains media terms such as LUFS, encoder and
#' sample rate.
#'
#' The function warns \strong{once} for the whole batch when a row names no
#' \code{audio_stream} and its input carries tracks that the output will not.
#' The warning names every affected row. Naming a track silences it. Use the
#' \code{audio_stream} argument, or an \code{audio_stream} cell on every row.
#' \code{suppressWarnings(classes = "tidymedia_dropped_audio")} silences it
#' too.
#' `r dropped_audio_cost_sentences(batch = TRUE)` The check never runs under
#' \code{run = FALSE} and never changes any compiled command. It is skipped
#' entirely when every row names a track. Under \code{two_pass = TRUE}, the
#' warning comes \emph{before} the analysis pass. So it arrives while adding
#' \code{audio_stream} can still save that pass.
#'
#' `r check_tracks_off_paragraph(batch = TRUE)`
#'
#' @param jobs A data frame with one row per input and (at least) an
#' \code{input} column (source path). An optional \code{output} column names
#' the destination. Without it, the function derives one per row. It appends
#' \code{_normalized} to the basename of each input and keeps the extension
#' of the input (e.g. \code{clip.mkv} becomes \code{clip_normalized.mkv}).
#' The derived name keeps a \emph{video} extension while the file itself
#' holds audio only. So name an \code{output} column yourself when that
#' matters. The function refuses two rows that name the same output path
#' before any row runs. With \code{two_pass = TRUE}, that is before the
#' analysis pass. The refusal covers a path repeated in the \code{output}
#' column, or a repeated \code{input} when there is no \code{output} column.
#' Five loudness arguments can also appear as a column that overrides the
#' argument per row. They are \code{target_loudness}, \code{true_peak},
#' \code{loudness_range}, \code{channels} and \code{sample_rate}. Rows that
#' omit the column fall back to the value of the argument. An optional
#' \code{audio_codec} column (character) names the output audio encoder of
#' each row. There, \code{NA} means "leave the encoder unset". Rows that omit
#' it fall back to the \code{audio_codec} argument. An optional numeric
#' \code{audio_stream} column likewise overrides the \code{audio_stream}
#' argument per row. There, \code{NA} normalizes the first audio track of
#' that row. Any other columns are ignored.
#' @param target_loudness,true_peak,loudness_range The EBU R128 loudness targets
#' applied to every row, unless \code{jobs} carries a column of the same name
#' (see \code{jobs}). Defaults follow EBU Recommendation R 128 (2014):
#' \code{target_loudness = -23} LUFS, \code{true_peak = -1} dBTP,
#' \code{loudness_range = 7} LU.
#' @param channels The output channel count applied to every row, unless
#' \code{jobs} carries a \code{channels} column, e.g. \code{1} to downmix to
#' mono. \code{NULL} (default) keeps each source's channel layout.
#' @param sample_rate The output sample rate in Hz applied to every row, unless
#' \code{jobs} carries a \code{sample_rate} column. \code{NULL} (default) lets
#' \code{loudnorm} choose. It resamples, up to 192 kHz and capped by the
#' encoder, and does not keep the source rate. Set this argument to fix the
#' output rate.
#' @param audio_codec The output audio encoder applied to every row, unless
#' \code{jobs} carries an \code{audio_codec} column, e.g. \code{"aac"}.
#' \code{NULL} (default) sets no \code{-codec:a}, which leaves the default
#' encoder of the output container in place. \code{"copy"} is an error.
#' Loudness normalization filters the audio, so it must be re-encoded. See
#' \code{\link{normalize_audio}}.
#' @param two_pass A logical that selects the normalization mode for
#' \emph{every} row. It applies to the whole table and is not a per-row
#' column. \code{FALSE} (default) keeps the single-pass \code{loudnorm}
#' pipeline. \code{TRUE} runs accurate two-pass (measured/linear)
#' normalization in two phases. An \emph{analysis pass} first measures the
#' loudness of every input. It honors \code{parallel} and the targets of each
#' row. A \emph{correction pass} then feeds those measurements back with
#' \code{linear=true}, so each output hits its EBU R128 target precisely.
#' This is the table-wide form of \code{two_pass} in
#' \code{\link{normalize_audio}}. The result shows the five measured values
#' as columns \code{measured_I}, \code{measured_TP}, \code{measured_LRA},
#' \code{measured_thresh} and \code{offset}. Two-pass must measure each
#' input. So it \strong{always runs the analysis pass through FFmpeg}, even
#' when \code{run = FALSE}. It needs the binary and readable inputs. If the
#' analysis of any row fails or gives no measurement that can be parsed, the
#' call aborts and names those rows. It aborts before it builds any
#' correction command. That abort has class
#' \code{tidymedia_loudnorm_no_measurement}, the same class that
#' \code{\link{normalize_audio}} raises for this event. It carries the same
#' row numbers on \code{tm_rows}, alongside \code{tm_row_status}. That field has
#' the FFmpeg exit status of each row, or \code{NA} where the row exited zero
#' but printed nothing that can be parsed. It carries no single exit status
#' on \code{tm_status}, and it does not have class
#' \code{tidymedia_ffmpeg_exit}. The reason is that it also fires for
#' rows that exited zero. A batch can mix causes, so there is no one number to
#' report. The one-file form carries both only where FFmpeg exited non-zero.
#' Where FFmpeg exited zero and printed nothing that can be parsed, the
#' one-file abort carries the shared class alone, with no \code{tm_status}
#' either. \strong{Silent} rows are the exception. A silent input (analysis
#' loudness \code{-inf}) cannot be normalized to a target, but one silent row
#' does not abort the batch. The function normalizes the rows that are not
#' silent. It marks the silent rows in a logical \code{silent} column, with
#' \code{success = FALSE} and no output written, and a warning names them.
#' This is where the batch form and the one-file form differ.
#' \code{\link{normalize_audio}} aborts on a silent input, because one silent
#' input is the whole call. Here, the other rows still have work to do. The
#' single-pass default touches no binary under \code{run = FALSE}.
#' @param audio_stream `r audio_stream_param("normalize", "normalizes", "first", batch = TRUE, extra = audio_stream_extras$normalize_one_track)`
#' @param run A logical: run each input's command through FFmpeg (\code{TRUE},
#' default) or only compile them for inspection (\code{FALSE}). Under
#' \code{two_pass = TRUE} this gates only the correction pass; the analysis
#' pass runs regardless (see \code{two_pass}).
#' @param parallel A logical passed to \code{\link{ffm_batch}}: normalize in
#' parallel with \pkg{furrr} (\code{TRUE}) or sequentially (\code{FALSE},
#' default). Parallelism follows the active \code{\link[future:plan]{future}}
#' plan. \code{TRUE} under the default sequential plan runs one input at a
#' time and warns. Set a plan first, e.g.
#' \code{future::plan(future::multisession)}.
#' @inheritParams anonymize_video_batch
#' @return `r batch_return("output")` Under \code{two_pass = TRUE}, the result
#' also carries the five measured columns (\code{measured_I} etc.) and a
#' logical \code{silent} column. The \code{command} column then holds the
#' linear correction commands. It is \code{NA} for silent rows, which carry
#' \code{NA} measurements and are not normalized. The columns of the two-pass
#' result do not depend on how many rows are silent. The \code{verified}
#' column (under \code{verify}) and the provenance manifest (under
#' \code{manifest}, read with \code{\link{ffm_manifest}}) are present
#' whenever requested. That holds even when \emph{every} row is silent.
#' Silent rows carry \code{NA} for those outputs.
#' @references `r ebu_r128_reference()`
#' @seealso [normalize_audio()] for the single-input form.
#' [ffm_batch()] for the batch runner and the arguments forwarded through
#' \code{...}.
#' [standardize_video_batch()] for the table-driven form on the video side.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' output = c(tempfile(fileext = ".m4a"), tempfile(fileext = ".m4a")),
#' target_loudness = c(-23, -16)
#' )
#' # run = FALSE compiles one command per input without calling FFmpeg
#' normalize_audio_batch(jobs, run = FALSE)
#' \donttest{
#' # Accurate two-pass (measured/linear) normalization across the whole table.
#' # This one runs FFmpeg to measure each input, so it needs the binary, and it
#' # re-encodes, so it needs an encoder this build has.
#' if (nzchar(Sys.which("ffmpeg")) && has_encoder("aac")) {
#' normalize_audio_batch(jobs, two_pass = TRUE, audio_codec = "aac")
#' }
#' }
#' @export
normalize_audio_batch <- function(jobs, target_loudness = -23, true_peak = -1,
loudness_range = 7, channels = NULL,
sample_rate = NULL, audio_codec = NULL,
two_pass = FALSE, audio_stream = NULL,
run = TRUE, parallel = FALSE, ...) {
rlang::check_bool(two_pass)
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
))
}
# A factor input column carries paths as levels; treat them as strings
# (parity with standardize_video_batch()).
jobs$input <- as.character(jobs$input)
# Validate present override columns up front so a bad column fails clearly
# here rather than as an opaque FFmpeg error mid-batch (M11 parity lesson).
# The loudness RANGES are swept per resolved row further down this front
# door (M65); whole-number channels/sample_rate values stay inherited per
# row from normalize_audio_pipeline()'s check_number_whole() guards.
knob_cols <- c("target_loudness", "true_peak", "loudness_range",
"channels", "sample_rate")
for (col in intersect(knob_cols, names(jobs))) {
if (!is.numeric(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be numeric.")
}
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
# audio_codec is a codec column, not a numeric knob: NA is legal and spells
# the NULL sentinel, so it needs check_batch_codec_col(), never the numeric
# guard above (M34/M35). Refuse "copy" from the argument and from any cell up
# front, so two-pass fails before Phase 1 wastes an analysis pass per row.
check_batch_codec_col(jobs, "audio_codec")
check_audio_codec_not_copy(audio_codec)
if ("audio_codec" %in% names(jobs)) {
check_batch_cell(which(jobs$audio_codec == "copy")[1],
check_audio_codec_not_copy(jobs$audio_codec))
}
# An audio_stream column is a numeric stream-index column, and on THIS verb an
# NA cell means the first audio track, not every track -- the hint has to say
# so, which is why check_batch_audio_col() takes `na_means` (M40's lesson).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep the first audio track")
# Unlike the pass-through batch verbs, the two-pass path RESHAPES the jobs
# table (it corrects jobs[!silent, ]), so a per-row abort from inside the
# fan-out would name a row of the reshaped table rather than the caller's.
# Check every cell here, against the caller's row numbers (M45 review F4).
check_batch_stream_values(jobs, "audio_stream")
# The scalar argument needs its own front door: batch_stream_cell() maps a
# scalar NA to the NULL sentinel exactly as it maps an NA cell, so without
# this `audio_stream = NA` would silently compile the first-track default
# (M37/M41).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep jobs$input here, below the shape/type guards above and before
# Phase 1 reads any input, so a missing input blames this verb rather than
# purrr::pmap() (M62). Above the derived-output block too, so a table naming
# one absent path in every row hears about the path (M80).
check_batch_inputs(jobs)
# Auto-name outputs when the column is absent. One input -> one output, so a
# duplicated input with no explicit output would map to the same file; reject
# that rather than silently overwrite. Below the sweep above and not above it,
# so a table whose rows all name the same unreadable file is told about the
# file rather than about the duplication (M80, D057).
if (!"output" %in% names(jobs)) {
reject_duplicate_inputs(jobs)
jobs$output <- derive_normalized_names(jobs$input)
}
# Per-row loudness target VALUES, last among the value guards (M65, D042):
# the knob loop above checks the columns' TYPE only, so an out-of-range
# target was refused by ffm_loudnorm() -- from purrr::pmap() on the
# single-pass path, and only after Phase 1 had analyzed every row on the
# two-pass path. Resolved column-over-argument, as the fan-out will read
# them; above the two_pass block, so a bad target costs no analysis pass --
# mirroring the scalar verb's placement above ITS two_pass block.
target_rows <- batch_arg_rows(jobs, "target_loudness", target_loudness)
peak_rows <- batch_arg_rows(jobs, "true_peak", true_peak)
range_rows <- batch_arg_rows(jobs, "loudness_range", loudness_range)
loud_cols <- any(c("target_loudness", "true_peak", "loudness_range") %in%
names(jobs))
for (i in seq_len(nrow(jobs))) {
check_batch_cell(
if (loud_cols) i else NA_integer_,
check_loudnorm_targets(target_rows[[i]], peak_rows[[i]], range_rows[[i]])
)
}
# Two rows resolving to one output path (M125), after the per-row value sweeps
# above and above both programs this verb can start before its fan-out: the
# dropped-track probe below and, on the two-pass path, the Phase 1 analysis.
# The two-pass knob checks and the single-pass `audio_codec` token check below
# still report after it.
reject_duplicate_outputs(jobs)
# D024's diagnostic probe, above the two_pass block so it lands before Phase 1
# analyzes anything and before the fan-out encodes -- the placement the scalar
# verb's two sites exist to match (D039). Below the per-row target sweep, so a
# wrong value still refuses before anything warns. isTRUE() rather than a bare
# `run` so a non-logical value still gets ffm_batch()'s own check_bool()
# message; the per-row audio_stream decision is the batch builder's.
if (isTRUE(run)) warn_dropped_audio_batch(jobs, audio_stream)
# Two-pass (measured/linear): the audio-side M16 analyze-then-build path fanned
# across the jobs table (D013). Phase 1 measures every input (honoring
# `parallel`) and appends the five measured columns; Phase 2 builds & runs one
# linear correction per row from them. Fail-fast before Phase 2 if any row's
# analysis did not yield a usable measurement (assemble_measured names the
# row). Like the scalar verb, `run = FALSE` still runs Phase 1 (it needs the
# binary and readable inputs) and gates only the Phase 2 correction commands.
if (two_pass) {
# Validate the shaping knobs up front (parity with the scalar two-pass verb)
# so a bad channels/sample_rate fails before Phase 1 wastes an analysis pass
# per row; the loudness targets were already swept per row above (M65).
rlang::check_number_whole(channels, min = 1, allow_null = TRUE)
rlang::check_number_whole(sample_rate, min = 1, allow_null = TRUE)
# Same reason, for the encoder name: a malformed token would otherwise abort
# from apply_audio_codec() in Phase 2, after Phase 1 has already analyzed
# every row. The argument and every non-NA cell are checked here.
if (!is.null(audio_codec)) check_token(audio_codec)
if ("audio_codec" %in% names(jobs)) {
for (i in which(!is.na(jobs$audio_codec))) {
check_batch_cell(i, check_token(jobs$audio_codec[[i]]))
}
}
for (col in intersect(c("channels", "sample_rate"), names(jobs))) {
if (any(jobs[[col]] %% 1 != 0) || any(jobs[[col]] < 1)) {
check_batch_cell(
which(jobs[[col]] %% 1 != 0 | jobs[[col]] < 1)[1],
cli::cli_abort(
"The {.field {col}} column of {.arg jobs} must be whole numbers \\
({.val {1}} or greater) for two-pass normalization."
)
)
}
}
col_or <- function(nm, default) {
if (nm %in% names(jobs)) jobs[[nm]] else rep(default, nrow(jobs))
}
# D074, on the two-pass path: this branch `return()`s below, so the site at
# the bottom of the verb was never reached and Phase 1's analysis spawned
# under an invalid limit, blaming purrr::pmap() (M094 review F3). Here it
# sits after the shaping-knob checks above -- all machine-independent -- and
# immediately before the first spawn.
resolve_timeout()
outputs <- run_loudnorm_analysis_batch(
jobs$input,
col_or("target_loudness", target_loudness),
col_or("true_peak", true_peak),
col_or("loudness_range", loudness_range),
parallel,
# col_or() would collapse a NULL default to NULL rather than to one value
# per row, so the column is passed straight through and the argument is
# expanded inside run_loudnorm_analysis_batch().
audio_stream = if ("audio_stream" %in% names(jobs)) {
jobs$audio_stream
} else {
audio_stream
}
)
# Continue-and-mark on silence (M18): a silent input (input_i = -inf) cannot
# be normalized to a loudness target, but one silent row must not abort the
# whole batch. assemble_measured() sets silent rows aside (measured cols NA)
# and flags them; genuine failures still abort. Correct only the non-silent
# rows, warn about the silent ones, and reassemble in original row order.
measured <- assemble_measured(outputs)
silent <- measured$silent
for (nm in names(measured$measured)) jobs[[nm]] <- measured$measured[[nm]]
if (any(silent)) {
# Drive pluralization off the scalar {length(rows)} and list the row
# indices without a `{?s}` marker: a `{?s}` governed by a `{.val
# {vector}}` across cli_warn() message elements throws
# `length(object) == 1` (M18 review).
rows <- which(silent)
cli::cli_warn(c(
"Found {length(rows)} silent input{?s} that cannot be normalized to a \\
loudness target.",
"!" = "Affected rows (1-indexed): {.val {rows}}.",
"i" = "Silent rows are marked in the {.field silent} column \\
({.field success} = {.val {FALSE}}, no output written)."
))
}
ok_res <- if (any(!silent)) {
run_normalize_correction(
jobs[!silent, , drop = FALSE], target_loudness, true_peak,
loudness_range, channels, sample_rate, audio_codec,
audio_stream = audio_stream, run = run, parallel = parallel, ...
)
} else {
NULL
}
# Thread the opt-in intent (verify/manifest/checksums, forwarded via `...`)
# so an all-silent batch synthesizes the same schema a mixed one produces
# (D011); a mixed batch reads those from ok_res and ignores these.
dots <- list(...)
return(bind_two_pass_result(
jobs, silent, ok_res, run,
verify = !is.null(dots$verify),
manifest = isTRUE(dots$manifest),
checksums = isTRUE(dots$checksums)
))
}
# The scalar argument needs its own front-door check, and the column guard
# above cannot stand in for it: batch_codec_cell() maps a scalar NA to the
# NULL sentinel exactly as it maps an NA cell, so `audio_codec = NA` used to
# compile the default command in silence rather than erroring -- the one
# codec argument in the package that did (M41). allow_null because NULL is
# this verb's documented sentinel (D019).
#
# Placed at the END of this verb's front-door validation, not beside the
# other scalar checks: before M41 this argument was only read per row
# inside the fan-out, so EVERY check above it reported first on a call
# that was wrong about two things at once. Moving the guard up the
# function silently reassigned that precedence -- first past the jobs
# SHAPE block (review A6), then past its content checks too (review
# A1r3). Here it changes nothing but the message a bad codec gets.
check_token(audio_codec, allow_null = TRUE)
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output loudnorm
# pipeline per row, sharing normalize_audio_pipeline() with normalize_audio().
# A per-row knob column (arriving via `...` from pmap) overrides the scalar
# arg of the same name; `...` also forwards ffm_batch options
# (verify/manifest/...) to the runner, never to the pipeline builder (the
# runner's params sit after `...` and bind by name — M09 lesson).
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
normalize_audio_pipeline(
input, output,
target_loudness = pick("target_loudness", target_loudness),
true_peak = pick("true_peak", true_peak),
loudness_range = pick("loudness_range", loudness_range),
channels = pick("channels", channels),
sample_rate = pick("sample_rate", sample_rate),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# Batch jobs-table guards (shared by the M28 single-in/out batch verbs) --------
# Validate the common jobs-table contract and return `jobs` with `input` (and,
# when present, `output`) coerced to character. `require_output = TRUE` demands
# an explicit `output` column (audio verbs, whose extension is the instruction
# and cannot be auto-named); `verb` names the operation in that error. A factor
# path column carries paths as levels, so coerce to character (parity with the
# other *_batch verbs). Value-level per-row checks stay in the shared pipelines.
check_batch_jobs <- function(jobs, require_output = FALSE, verb = NULL,
call = rlang::caller_env()) {
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per input.", call = call)
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.", call = call)
}
if (!"input" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field input} column.",
"x" = "Missing column: {.val input}."
), call = call)
}
jobs$input <- as.character(jobs$input)
if (anyNA(jobs$input)) {
cli::cli_abort("The {.field input} column of {.arg jobs} must not contain {.val {NA}}.", call = call)
}
if (require_output && !"output" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field output} column.",
"x" = "Missing column: {.val output}.",
"i" = "{verb} can't derive an output container; name each destination."
), call = call)
}
if ("output" %in% names(jobs)) {
jobs$output <- as.character(jobs$output)
if (anyNA(jobs$output)) {
cli::cli_abort("The {.field output} column of {.arg jobs} must not contain {.val {NA}}.", call = call)
}
}
jobs
}
# Sweep a jobs table's INPUT paths at the front door, so a row naming a file
# that isn't there blames the verb the user called instead of
# `purrr::pmap(jobs, .f, ...)` / "In index: 1" (M62). Handles both input shapes:
# a character column (`input`, `main`, `overlay`) and D015's `inputs`
# list-column, whose cells are character vectors.
#
# `multiple = TRUE` unconditionally: the message shape follows the COLUMN's
# contract, so a one-row table answers like a fifty-row one.
#
# The abort itself is check_paths_readable()'s and is never copied here -- that
# single site is what keeps this guard and ffm_files()' own refusal from
# drifting apart, and since M63 it IS ffm_files()' refusal: one predicate, one
# wording, reached from both ends. It is D035's shape (one abort site, no new
# refusal, fails closed) rather than D035's licence, which is conditioned on a
# probe whose result enters the compiled command. A path's readability never
# does.
#
# PLACEMENT is per verb, deliberately not inside check_batch_jobs() /
# check_fanin_jobs(): those run above each verb's column-type guards, and this
# sweep belongs below them (you cannot usefully sweep a column whose type is
# still unvalidated) and above the M58 contradiction sweep, so a caller who
# mistyped a path hears about the path. That ordering is measured cell by cell
# in data-raw/input-guard-baseline.R.
#
# `col` may name SEVERAL carriers, and a verb with more than one input column
# passes them all in ONE call: two calls would abort on the first column and
# hide the second, so a picture-in-picture row missing both files named only
# `main` (M62 review F2). One call sweeps the union and names every missing
# path, which is what "names every missing path, not the first" asks for.
#
# Sweeping the union in one call is not the same as blaming the union: `arg`
# was the whole `col` vector, so a picture-in-picture row whose `main` is fine
# and whose `overlay` is missing still read `` `jobs$main` and `jobs$overlay`
# name 1 file ``, sending the caller to a column with nothing wrong in it (M62
# N3). The carriers are therefore tested separately and only those actually
# holding a bad path are named — one call still, so both are named when both
# are bad, and the predicate, the wording and the abort site are D041's
# unchanged.
check_batch_inputs <- function(jobs, col = "input",
call = rlang::caller_env()) {
paths <- lapply(col, function(nm) {
x <- jobs[[nm]]
if (is.list(x)) x <- unlist(x, use.names = FALSE)
as.character(x)
})
# WHICH carriers hold a bad path, asked of the same predicate that formats
# the abort below rather than of a second file.access() spelled the same way
# (M81/D059). The per-carrier test is unchanged: each column is asked
# separately, so a call whose two carriers are both bad still names both.
holding <- vapply(paths, function(x) length(unreadable_paths(x)) > 0,
logical(1))
# Nothing bad: the sweep passes and which carriers it would have named never
# reaches anyone.
if (!any(holding)) {
return(invisible(jobs))
}
check_paths_readable(unlist(paths[holding], use.names = FALSE),
arg = paste0("jobs$", col[holding]), multiple = TRUE,
call = call)
invisible(jobs)
}
# The derived-output counterpart to reject_duplicate_outputs(): with no
# `output` column a verb derives one name per input, so two rows naming the
# same input would derive the same output and silently overwrite. Written here
# once instead of inline in each of the three verbs that derive names, all of
# which carry a single `input` column.
#
# WHAT A LATER VERB INHERITS, AND WHAT IT DOES NOT. The ORDER below is general
# and a fourth verb inherits it. This WORDING is not: it reads `jobs$input` by
# name, and a derived-output verb with more than one input column has to
# compare each row's whole input TUPLE instead. Duplication there is a
# property of the row, not of a column: a fan-in table whose `main` repeats
# against distinct `overlay` values would derive distinct outputs and would be
# legal, and a per-column check would refuse it. Such a verb writes its own row
# comparison; it does not reach this one with a column name.
#
# Parameterizing this by carrier column was considered and refused for that
# reason (M81/D059). reject_duplicate_outputs()' `col` is not the precedent it
# looks like -- that one sweeps a VECTOR in a single call, which a scalar
# `jobs[[col]]` is not.
#
# ORDER: every caller runs this BELOW check_batch_inputs(). The duplication
# message names no path the caller can act on, while the sweep names the file
# that is not there; a table whose twenty rows carry the same typo is one
# missing file, which is what the explicit-output path already reported and
# what the derived-output path now reports too (M62 N7, D057).
reject_duplicate_inputs <- function(jobs, call = rlang::caller_env()) {
dupes <- unique(jobs$input[duplicated(jobs$input)])
if (length(dupes) > 0) {
cli::cli_abort(c(
"{.arg jobs} has duplicated {.field input} paths but no {.field output} column.",
"x" = "Duplicated input{?s}: {.val {dupes}}.",
"i" = "Add an {.field output} column to name each row's destination."
), call = call)
}
invisible(jobs)
}
# Reject any two rows that resolve to the same output path — not just duplicated
# inputs — so an explicit `output` repeated across rows can't silently overwrite
# either (M26). Assumes `jobs$output` is resolved (present or derived).
reject_duplicate_outputs <- function(jobs, call = rlang::caller_env()) {
check_distinct_outputs(
jobs$output,
"{.arg jobs} has rows that resolve to the same output path.",
"Give each row a distinct {.field output}, or de-duplicate the inputs.",
call = call
)
jobs
}
# Guard an optional per-row video_codec column (M34/D016). Unlike
# check_batch_string_col(), NA is legal: it is the column form of
# video_codec = NULL, the "leave the codec alone" sentinel. An all-NA column is
# typed logical by R, so accept that too (the audio-column pattern); any other
# type (e.g. numeric) is rejected up front rather than mid-batch.
#
# `na_means` states what NA spells on the CALLING verb, because the hint must be
# true under the branch that fired the guard (M38 lesson). It is "leave the
# codec unset" on every verb whose NULL is D016's emit-nothing sentinel, but
# convert_audio_batch() is the one caller where NULL/NA selects `-q:a 0`
# instead (D021), so that verb overrides the wording rather than shipping a hint
# that contradicts its own documentation.
check_batch_codec_col <- function(jobs, col = "video_codec",
na_means = "leave the codec unset",
call = rlang::caller_env()) {
# Legal: a character column (NA cells allowed), or the all-NA column R types
# as logical. Testing `all(is.na(.))` alone would also admit an all-NA numeric
# or an all-NA Date, which the contract above says is rejected; testing
# `is.logical(.)` alone would admit a c(TRUE, FALSE) column.
ok <- function(x) is.character(x) || (is.logical(x) && all(is.na(x)))
if (col %in% names(jobs) && !ok(jobs[[col]])) {
cli::cli_abort(
"The {.field {col}} column of {.arg jobs} must be character
({.val {NA}} to {na_means}).",
call = call
)
}
# Every non-NA cell must also be a clean token, checked HERE rather than per
# row inside the fan-out (M56). A batch verb reaches its pipeline through
# ffm_batch() -> purrr::pmap(), so a per-row abort resolves its `call` to the
# anonymous closure and reports "Error in `.f()` / In index: 1" -- a
# dependency's name and an internal index in place of the verb the user
# called, the exact shape M48 review F1 removed from segment_video()'s
# front door and M41 removed from the scalar arguments. This is the column
# form of the same fix, and it is one site because every batch verb's codec
# columns already come through here. normalize_audio_batch()'s two_pass block
# made the same check for its own reason (a reshaped jobs table renumbers the
# rows); that one now duplicates this and stays, since it must fire before
# Phase 1 analyzes anything.
if (col %in% names(jobs)) {
for (i in which(!is.na(jobs[[col]]))) {
check_batch_cell(i, check_token(jobs[[col]][[i]], arg = col, call = call))
}
}
invisible(jobs)
}
# Resolve a per-row video_codec cell to the scalar the pipelines take: NA is the
# column form of the NULL sentinel (M34/D016).
batch_codec_cell <- function(value) {
if (length(value) == 1L && is.na(value)) NULL else value
}
# check_batch_quality(): the `quality` argument and column at a _batch verb's
# front door (M136). Column side: numeric, or the all-NA column R types logical
# (the codec-column shape above, for the same reason); an NA cell is the column
# form of `quality = NULL` (D022), so that row keeps its encoder's own default
# whatever the argument says. Argument side: the scalar repeated per row, as
# batch_arg_rows() repeats every other argument. Each non-NULL value then takes
# M135's check_quality() against the encoder THAT row would encode with --
# intended_encoder() over the row's resolved codec and the batch-wide
# `hardware`, pure, so this refuses before the availability probe (D036) and
# the abort names the verb rather than purrr::pmap() (D076, M56). A "copy" cell
# is handed to check_quality() as itself: intended_encoder() would ask
# codec_family() for a family "copy" does not have, and the caller's error is
# the copy, not the family. check_batch_cell() names the row when the value
# arrived in a column, or when an argument is refused for one row's own codec
# cell; an argument refused for its value applies to every row and names none.
#
# `codec_rows` is the per-row resolved `video_codec` (batch_arg_rows() with
# batch_codec_cell()), passed in rather than read here because
# format_for_web_batch() fixes its codec by identity and honours no column.
check_batch_quality <- function(jobs, quality, codec_rows, hardware,
call = rlang::caller_env()) {
has_col <- "quality" %in% names(jobs)
codec_col <- "video_codec" %in% names(jobs)
if (has_col) {
ok <- function(x) is.numeric(x) || (is.logical(x) && all(is.na(x)))
if (!ok(jobs$quality)) {
# The locator names the first cell that is not NA; a column of NA
# strings has no such cell, and the refusal then names no row.
check_batch_cell(which(!is.na(jobs$quality))[1], cli::cli_abort(
"The {.field quality} column of {.arg jobs} must be numeric
({.val {NA}} to leave that row's encoder default in place).",
call = call
))
}
# NaN is numeric and is.na(NaN) is TRUE, so batch_stream_cell() would
# read it as the NULL sentinel; the scalar check refuses it, so this does.
if (is.numeric(jobs$quality) && any(is.nan(jobs$quality))) {
check_batch_cell(which(is.nan(jobs$quality))[1], cli::cli_abort(
"The {.field quality} column of {.arg jobs} must not contain
{.val {NaN}} ({.val {NA}} leaves that row's encoder default in place).",
call = call
))
}
# A column overrides the argument on every row (D022), but the argument is
# still a value the caller passed: its type is checked as the scalar verb
# checks it, so no call refused without the column compiles with it.
if (!is.null(quality)) {
rlang::check_number_decimal(quality, allow_infinite = FALSE,
arg = "quality", call = call)
}
}
rows <- if (has_col) {
lapply(jobs$quality, batch_stream_cell)
} else {
rep(list(quality), nrow(jobs))
}
for (i in seq_along(rows)) {
if (is.null(rows[[i]])) next
vc <- codec_rows[[i]]
encoder <- if (identical(vc, "copy")) {
vc
} else {
intended_encoder(vc, hardware, call = call)
}
# The row is named when the value came from the column, or when the
# argument is refused for THAT row's codec cell (a copy or NA cell in a
# `video_codec` column): the offender is then the row, not the argument.
codec_side <- is.null(encoder) || identical(encoder, "copy")
check_batch_cell(if (has_col || (codec_col && codec_side)) i else NA_integer_,
check_quality(rows[[i]], encoder, call = call))
}
invisible(jobs)
}
# batch_arg_rows(): the per-row values a jobs table will hand the pipeline for
# ONE argument -- the override column's cells where the table carries one,
# resolved through `resolve`, and the scalar argument repeated otherwise. This
# is the same column-over-argument rule each _batch verb's `pick()` closure
# applies inside the fan-out, hoisted so a front-door checker can be run per row
# before anything is built (M58).
#
# Returns a LIST, never a vector: a resolved cell may be NULL (the codec and
# audio columns spell their argument's NULL sentinel as NA, via
# batch_codec_cell() / batch_stream_cell()), and a vector cannot hold one.
#
# Deliberately NOT unique()'d, unlike batch_video_codecs(): a row-swept checker
# names no row in its message today, but collapsing here would make naming one
# impossible later, and the sweep is over a handful of comparisons per row.
batch_arg_rows <- function(jobs, col, arg, resolve = identity) {
n <- nrow(jobs)
if (!col %in% names(jobs)) {
# `resolve` applies to the ARGUMENT too, not only to a column's cells. The
# fan-out's pick() hands its result -- column cell or argument alike -- to
# batch_codec_cell()/batch_stream_cell(), so resolving only one branch would
# let the front door read a scalar NA as NA while the pipeline read it as
# NULL, and refuse a call the pipeline compiles. Upstream validators reject a
# scalar NA on all four verbs today, so this closes a hole rather than
# changing a reachable answer -- but D035's "no new refusal" condition is
# held here by the helper, not by those validators (M58 review F9).
return(rep(list(resolve(arg)), n))
}
lapply(seq_len(n), function(i) resolve(jobs[[col]][[i]]))
}
# batch_video_codecs(): the distinct video_codec values a jobs table will hand
# the pipeline -- the column's cells where the verb honours one (seven of the
# eight _batch verbs do; format_for_web_batch fixes its codecs by identity), and
# the scalar argument otherwise. This is what the front-door nvenc guard sweeps,
# because one call's column may spell several families and each needs its own
# encoder. NA stays NA here and check_hardware_available() reads it as the h264
# sentinel, the same reading batch_codec_cell() gives the pipeline (D022).
batch_video_codecs <- function(jobs, video_codec) {
if (!"video_codec" %in% names(jobs)) {
return(list(video_codec))
}
as.list(unique(jobs[["video_codec"]]))
}
# check_batch_audio_col(): type-guard a numeric stream-index column up front.
# Legal: a numeric column (NA cells allowed), or the all-NA column R types as
# logical. The same spelled-out shape check_batch_codec_col() uses, and for the
# same reason: testing `all(is.na(.))` alone would admit an all-NA character or
# Date column, while testing `is.logical(.)` alone would admit c(TRUE, FALSE)
# (M35, M34 lesson). Shared by compare_videos_batch() and
# picture_in_picture_batch(), which drifted apart before M35 -- compare had no
# up-front guard at all -- and by the two audio verbs' `audio_stream` (M43).
#
# `col` and `na_means` are parameters because M43 added a caller whose NA means
# something else: on the composite verbs an NA `audio_input` cell drops audio, while
# an NA `audio_stream` cell leaves that row on the first audio track. The
# inherited wording would be false for the new caller -- the failure M40 hit by
# ADDING a caller to a shared guard rather than by writing a wrong branch.
check_batch_audio_col <- function(jobs, col = "audio_input",
na_means = "drop audio",
call = rlang::caller_env()) {
ok <- function(x) is.numeric(x) || (is.logical(x) && all(is.na(x)))
if (col %in% names(jobs) && !ok(jobs[[col]])) {
cli::cli_abort(
"The {.field {col}} column of {.arg jobs} must be numeric
({.val {NA}} to {na_means}).",
call = call
)
}
invisible(jobs)
}
# Validate every non-NA cell of a numeric stream-index column, blaming the row of
# the CALLER's jobs table. check_batch_audio_col() above covers the column's
# type; this covers each value. Needed wherever a verb reshapes its jobs table
# before the fan-out, because then the per-row check inside the pipeline reports
# an index of the reshaped table instead of the caller's (M45 review F4).
#
# No `{?s}` anywhere in the message: a plural governed by a `{.val {vector}}`
# throws `length(object) == 1` once there are 2+ items (M18), and this message
# names a vector of rows by construction.
check_batch_stream_values <- function(jobs, col, call = rlang::caller_env()) {
if (!col %in% names(jobs)) return(invisible(jobs))
vals <- jobs[[col]]
bad <- which(vapply(seq_along(vals), function(i) {
if (is.na(vals[[i]])) return(FALSE)
!isTRUE(tryCatch({
rlang::check_number_whole(vals[[i]], min = 0)
TRUE
}, error = function(e) FALSE))
}, logical(1)))
if (length(bad) > 0) {
cli::cli_abort(
c(
"Every {.field {col}} cell of {.arg jobs} must be a whole number
{.val {0}} or greater, or {.val {NA}}.",
"x" = "Bad at row {.val {bad}}, value {.val {vals[bad]}}."
),
call = call
)
}
invisible(jobs)
}
# Resolve a per-row `audio_stream` cell to the scalar the pipelines take: NA is
# the column form of the NULL sentinel, i.e. "leave this row on the first audio
# track" -- NOT "fall back to the argument", which is what an ABSENT column
# means (M43; the same shape batch_codec_cell() gives the codec columns, kept
# separate so the codec helper stays about codecs).
batch_stream_cell <- function(value) {
if (length(value) == 1L && is.na(value)) NULL else value
}
# Guard an optional string override column: present -> character, no NA.
check_batch_string_col <- function(jobs, col, call = rlang::caller_env()) {
if (col %in% names(jobs)) {
if (!is.character(jobs[[col]]) || anyNA(jobs[[col]])) {
cli::cli_abort(
"The {.field {col}} column of {.arg jobs} must be character (no {.val {NA}}).",
call = call
)
}
}
invisible(jobs)
}
# Sweep an enumerated-vocabulary column's VALUES at the front door (M59 sites 5
# and 6). check_batch_string_col() above covers only such a column's TYPE, so a
# `direction` cell of "sideways" or a `position` cell of "middleish" used to
# reach the pipeline's own arg_match() inside the fan-out and be reported
# against purrr::pmap().
#
# Row by row through check_vocab_arg(), for D036's reason: a table with one
# violating row is refused while a table with none compiles. batch_arg_rows()
# supplies the same column-over-argument resolution the fan-out's pick() applies,
# so the sweep sees exactly the values the pipeline would.
check_batch_vocab_col <- function(jobs, col, arg, values,
call = rlang::caller_env()) {
rows <- batch_arg_rows(jobs, col, arg)
for (i in seq_along(rows)) {
check_batch_cell(if (col %in% names(jobs)) i else NA_integer_,
check_vocab_arg(rows[[i]], values, col, call = call))
}
invisible(jobs)
}
# Validate a fan-in jobs table (D015): unlike the scalar-input `input` column of
# check_batch_jobs(), the many-inputs -> one-output verbs carry their per-row
# inputs in an `inputs` LIST-COLUMN (each cell a character vector of >= min_inputs
# paths) alongside a scalar `output` column. purrr::pmap passes each list cell to
# .f row-wise, so ffm_batch needs no change. Coerces `output` to character;
# leaves duplicate-path rejection to reject_duplicate_outputs (called after).
check_fanin_jobs <- function(jobs, min_inputs = 1L, verb = NULL,
call = rlang::caller_env()) {
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per output.", call = call)
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.", call = call)
}
if (!"inputs" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field inputs} list-column.",
"x" = "Missing column: {.val inputs}.",
"i" = "Each cell is a character vector of input paths for that output."
), call = call)
}
if (!"output" %in% names(jobs)) {
cli::cli_abort(c(
"{.arg jobs} must have an {.field output} column.",
"x" = "Missing column: {.val output}.",
"i" = "{verb} writes one output per row; name each destination."
), call = call)
}
if (!is.list(jobs$inputs) || is.data.frame(jobs$inputs)) {
cli::cli_abort(c(
"The {.field inputs} column of {.arg jobs} must be a list-column.",
"i" = "Build it with e.g. {.code tibble::tibble(inputs = list(c(a, b)), output = o)}."
), call = call)
}
ok <- vapply(
jobs$inputs,
function(x) is.character(x) && length(x) >= min_inputs && !anyNA(x),
logical(1)
)
if (!all(ok)) {
# Drive pluralization off the scalar count and list the offending rows
# without a `{?s}` governed by the numeric `{.val {vector}}`, which cli reads
# as a quantity and throws `length(object) == 1` on with 2+ items (M18
# review; see the loudnorm silent-input guard above for the same fix).
bad <- which(!ok)
cli::cli_abort(c(
"Each {.field inputs} cell must be a character vector of {min_inputs} or \\
more paths with no {.val {NA}}.",
"x" = "Found {length(bad)} invalid cell{?s} at row{?s} (1-indexed): {.val {bad}}."
), call = call)
}
jobs$output <- as.character(jobs$output)
if (anyNA(jobs$output)) {
cli::cli_abort("The {.field output} column of {.arg jobs} must not contain {.val {NA}}.", call = call)
}
jobs
}
# extract_audio_batch() ---------------------------------------------------
#' Extract Audio From Many Files From a Jobs Table
#'
#' Take the audio track out of many input files, using one jobs table. This is
#' the **batch** form of [extract_audio()], for when you have more than one
#' file. Each row is one input. The \code{input} and \code{output} columns are
#' required. The function is a thin wrapper over \code{\link{ffm_batch}}. It
#' builds one reproducible command for each input, with the same steps as
#' \code{extract_audio()}: select the audio track and drop the video. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as codec,
#' container and stream copy.
#'
#' `r dropped_audio_paragraph(batch = TRUE)`
#'
#' `r check_tracks_off_paragraph(batch = TRUE)`
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column (source path) and an \code{output} column (destination
#' path). The \code{output} column is **required**. Unlike the video batch
#' functions, this function cannot name an audio destination for you,
#' because the extension is the instruction. The extension picks the
#' container, and with \code{audio_codec = "copy"} it must match the source
#' codec. An optional \code{audio_codec} column overrides the
#' \code{audio_codec} argument per row. Rows without a value use the
#' argument. \code{NA} in a cell leaves that row's codec unset, which is the
#' column form of \code{audio_codec = NULL}. An optional \code{audio_stream}
#' column overrides the \code{audio_stream} argument per row in the same
#' way, and \code{NA} keeps that row on the first audio track. The function
#' refuses two rows with the same \code{output} path, before any row runs.
#' The function ignores any other columns.
#' @param audio_codec The audio codec applied to every row, unless \code{jobs}
#' has an \code{audio_codec} column. In that column, \code{NA} in a cell
#' leaves that row's codec unset. \code{"copy"} (default) copies the audio
#' stream with no quality loss. Name an encoder (e.g. \code{"aac"}) to
#' re-encode. Or pass \code{NULL} to write no \code{-codec:a}, so the output
#' container's default encoder decides.
#' @param audio_stream `r audio_stream_param("take", "takes", "first", batch = TRUE)`
#' @param run A logical: run each command through FFmpeg (\code{TRUE}, default)
#' or only compile them for inspection (\code{FALSE}).
#' @param parallel A logical: process the jobs in parallel with \pkg{furrr}
#' (\code{TRUE}) or one at a time (\code{FALSE}, default). See
#' \code{\link{ffm_batch}} for the \pkg{future} plan requirement.
#' @param ... Additional arguments forwarded to \code{\link{ffm_batch}} (e.g.
#' \code{verify}, \code{manifest}, \code{progress}).
#' @return `r jobs_return()`
#' @seealso [extract_audio()], the single-input form it wraps. [ffm_batch()],
#' the batch runner. [convert_audio_batch()] to re-encode audio in batch.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(input = c(video, video), output = c("a.aac", "b.aac"))
#' extract_audio_batch(jobs, run = FALSE)
#' @export
extract_audio_batch <- function(jobs, audio_codec = "copy",
audio_stream = NULL, run = TRUE,
parallel = FALSE, ...) {
jobs <- check_batch_jobs(jobs, require_output = TRUE, verb = "Audio extraction")
jobs <- reject_duplicate_outputs(jobs)
# check_batch_codec_col(), never check_batch_string_col(), which rejects NA
# and so leaves the column unable to spell the "unset" its own argument can
# say with NULL (D022). This was the third and last codec column on the wrong
# guard.
check_batch_codec_col(jobs, "audio_codec")
# Without this, a non-string audio_codec reached ffm_codec() per row and
# aborted inside purrr::pmap() naming Layer-1's `audio` -- the only pair in the
# package that leaked the engine's name, fired mid-fan-out, AND blamed pmap all
# at once (M41).
#
# allow_null carries D022's family rule: NULL emits no -codec:a. The scalar
# sibling now agrees; until M42 it aborted on this same call, which is the
# split D021 recorded from the wrong side (it called extract_audio() the verb
# that "accepts neither NULL nor NA" without noticing this one always had).
check_token(audio_codec, allow_null = TRUE)
# The stream-index column's own type guard, with a hint saying what NA means
# HERE -- the shared default ("drop audio") belongs to the composite verbs and
# would be false on this one (M40).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep the first audio track")
# And the scalar argument's front-door check: the column path resolves NA to
# the NULL sentinel, so without this `audio_stream = NA` would quietly compile
# track 0 instead of erroring (the M37/M41 shape). Per-row VALUES are checked
# again inside audio_stream_map(), which every row's pipeline calls (M32).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep jobs$input before the D024 probe below, which reads each input via
# FFprobe, so a missing input blames this verb rather than that probe or
# purrr::pmap() (M62).
check_batch_inputs(jobs)
# D024's diagnostic probe, up front so it lands before the fan-out encodes;
# ffm_batch() itself is untouched. isTRUE() rather than a bare `run` so a
# non-logical value still gets ffm_batch()'s own check_bool() message.
if (isTRUE(run)) warn_dropped_audio_batch(jobs, audio_stream)
# Thin Layer-2 fan-out over ffm_batch (D007): one map/drop-video pipeline per
# row, sharing extract_audio_pipeline() with extract_audio(). A per-row
# `audio_codec` or `audio_stream` column (via `...` from pmap) overrides the
# scalar arg; `...` also forwards ffm_batch options to the runner.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
extract_audio_pipeline(
input, output,
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# convert_audio_batch() ---------------------------------------------------
#' Convert the Audio of Many Files From a Jobs Table
#'
#' Extract or re-encode the audio track of many input files, using one jobs
#' table. This is the **batch** form of [convert_audio()], for when you have
#' more than one file. Each row is one input. The \code{input} and
#' \code{output} columns are required. The function is a thin wrapper over
#' \code{\link{ffm_batch}}. It builds one reproducible command for each input.
#' Each command uses the same audio steps as \code{convert_audio()}, and the
#' function checks each \code{audio_codec} value in the same way. The glossary
#' in \code{vignette("tidymedia")} explains media terms such as codec and
#' stream.
#'
#' `r dropped_audio_paragraph(batch = TRUE)`
#'
#' `r check_tracks_off_paragraph(batch = TRUE)`
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column (source path) and an \code{output} column (destination
#' path). The \code{output} column is **required**. The function cannot name
#' an audio destination for you, because its extension picks the output
#' format. An optional \code{audio_codec} column overrides the
#' \code{audio_codec} argument per row. There, \code{NA} means "use the
#' highest-VBR-quality default". Rows without a value use the argument. An
#' optional \code{audio_stream} column overrides the \code{audio_stream}
#' argument per row in the same way, and \code{NA} keeps that row on the
#' first audio track. The function refuses two rows with the same
#' \code{output} path, before any row runs. The function ignores any other
#' columns, with one exception. A \code{format} column is an error, not a
#' silent no-op. The package retired that column with the argument of the
#' same name.
#' @param audio_codec The output audio codec applied to every row, unless
#' \code{jobs} has an \code{audio_codec} column. With \code{NULL} (default),
#' FFmpeg infers the codec from each \code{output} extension, at the highest
#' VBR quality. Name a codec (e.g. \code{"aac"}, \code{"flac"}) to set
#' \code{-c:a}.
#' @param audio_stream `r audio_stream_param("take", "takes", "first", batch = TRUE)`
#' @inheritParams extract_audio_batch
#' @return `r jobs_return()`
#' @seealso [convert_audio()], the single-input form it wraps. [ffm_batch()],
#' the batch runner. [extract_audio_batch()] to stream-copy audio in batch.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(input = c(video, video), output = c("a.mp3", "b.mp3"))
#' convert_audio_batch(jobs, run = FALSE)
#' @export
convert_audio_batch <- function(jobs, audio_codec = NULL,
audio_stream = NULL, run = TRUE,
parallel = FALSE, ...) {
jobs <- check_batch_jobs(jobs, require_output = TRUE, verb = "Audio conversion")
jobs <- reject_duplicate_outputs(jobs)
# M40 renamed `format` to `audio_codec` in BOTH its spellings. The scalar
# sibling has no `...`, so R rejects a stale call itself; here `...` forwards
# ffm_batch options and would swallow a stale argument, while a stale `jobs`
# column would fall through as one of the "other columns are ignored" -- either
# way silently ignoring the codec the caller named. Name the replacement
# instead of ignoring it -- a diagnostic, not a lifecycle shim (D014's clean
# break stands; M37's precedent).
stale <- c(
if ("format" %in% names(list(...))) "argument",
if ("format" %in% names(jobs)) "jobs column"
)
if (length(stale) > 0) {
cli::cli_abort(c(
"The {.arg format} {stale} was removed from {.fn convert_audio_batch}.",
"i" = "Use {.arg audio_codec} instead; it takes the same encoder names.",
"i" = "In {.arg jobs}, name it as an {.field audio_codec} column, where
{.val {NA}} keeps that row on the highest-VBR-quality default."
))
}
# NA is legal in the column: it is the column form of audio_codec = NULL, so
# this needs check_batch_codec_col(), never check_batch_string_col(), which
# rejects NA and so cannot spell "unset" (M34/D016). The hint is overridden
# because on THIS verb NA selects `-q:a 0` rather than leaving the codec unset
# (D021) -- the shared default would be false here (M38 lesson).
check_batch_codec_col(jobs, "audio_codec",
na_means = "use the highest-VBR-quality default")
# The scalar argument is resolved through batch_codec_cell() below, which maps
# NA to the NULL sentinel -- so without this front-door check `audio_codec =
# NA` would quietly compile the default instead of erroring, the M37 shape
# where a scalar arg reaching the pipeline by the column path skips its own
# type check. NULL stays legal: it IS the sentinel.
if (!is.null(audio_codec)) check_token(audio_codec)
# The stream-index column's own type guard, with a hint saying what NA means
# HERE -- the shared default ("drop audio") belongs to the composite verbs and
# would be false on this one (M40).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep the first audio track")
# And the scalar argument's front-door check: the column path resolves NA to
# the NULL sentinel, so without this `audio_stream = NA` would quietly compile
# track 0 instead of erroring (the M37/M41 shape). Per-row VALUES are checked
# again inside audio_stream_map(), which every row's pipeline calls (M32).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep jobs$input before the D024 probe below, which reads each input via
# FFprobe, so a missing input blames this verb rather than that probe or
# purrr::pmap() (M62).
check_batch_inputs(jobs)
# D024's diagnostic probe, up front so it lands before the fan-out encodes;
# ffm_batch() itself is untouched. isTRUE() rather than a bare `run` so a
# non-logical value still gets ffm_batch()'s own check_bool() message.
if (isTRUE(run)) warn_dropped_audio_batch(jobs, audio_stream)
# Thin Layer-2 fan-out over ffm_batch (D007): one audio-map pipeline per row,
# sharing convert_audio_pipeline() with convert_audio(). A per-row
# `audio_codec` or `audio_stream` column overrides the scalar arg; the
# per-value check_string(audio_codec) and check_number_whole(audio_stream) are
# inherited from the shared pipeline.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
convert_audio_pipeline(
input, output,
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# derive_cropped_names() / derive_web_names() -----------------------------
# Derive one output path per input for the video batch verbs when the `output`
# column is absent. crop keeps the source container (`<base>_cropped.<ext>`);
# the web re-encode always writes H.264/mp4 (`<base>_web.mp4`). The base keeps
# the input's directory, so inputs in different folders never collide; the batch
# verbs reject any duplicated *resolved* output up front (M26), so these stay
# pure name maps.
derive_cropped_names <- function(input) {
paste0(tools::file_path_sans_ext(input), "_cropped.", tools::file_ext(input))
}
derive_web_names <- function(input) {
paste0(tools::file_path_sans_ext(input), "_web.mp4")
}
# crop_video_batch() ------------------------------------------------------
#' Crop Many Videos From a Jobs Table
#'
#' Crop many videos to a rectangular region, using one jobs table. This is the
#' **batch** form of [crop_video()], for when you have more than one file. Each
#' row is one input. The function is a thin wrapper over
#' \code{\link{ffm_batch}}. It builds one reproducible command for each input,
#' with the same crop steps as \code{crop_video()}. This function checks each
#' row's crop size and position before any command runs. So a bad cell is
#' refused with an error that names this function. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, container
#' and stream copy.
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column, the source path. An optional \code{output} column
#' names the destination. Without it, each row's output name adds
#' \code{_cropped} to the input's base name and keeps its extension. For
#' example, \code{clip.mp4} becomes \code{clip_cropped.mp4}. A \code{width},
#' \code{height}, \code{x} or \code{y} column overrides that argument for each
#' row. A dimension with no column uses the argument. A \code{video_codec}
#' column overrides that argument for each row, and \code{NA} leaves the codec
#' unset. That is the column form of the argument's \code{NULL}. An
#' \code{audio_codec} column works the same way. An \code{audio_stream} column
#' overrides that argument for each row, and \code{NA} keeps every audio
#' track. That is the column form of that argument's \code{NULL}. Two rows
#' with the same destination path are refused before any row runs. That
#' happens with a repeated \code{output}, or with a repeated \code{input} when
#' there is no \code{output} column. A numeric \code{quality} column
#' overrides the \code{quality} argument per row (see \code{quality}). Any
#' other columns are ignored.
#' @param width,height The output crop size in pixels, for every row unless
#' \code{jobs} has a column of the same name. Each is required: pass it as an
#' argument or as a column. There is no default crop size.
#' @param x,y The offset in pixels of the crop's left and top edge, for every
#' row unless \code{jobs} has a column of the same name. The default centers
#' the crop.
#' @param video_codec A string naming the output video codec, applied to every
#' row lacking a \code{video_codec} column. \code{NULL} (default) leaves it
#' unset, so each output keeps its container's default encoder.
#' @param audio_codec A string naming the output audio codec, for every row
#' when \code{jobs} has no \code{audio_codec} column. \code{"copy"} (default)
#' stream-copies the audio. Name an encoder to transcode it. \code{NULL}
#' leaves the codec unset, so each output keeps its container's default
#' encoder.
#' @param hardware,fallback `r batch_hardware_param("crop_video")`
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' A call can also have a per-row \code{width} or \code{height} that is
#' neither a positive number nor an FFmpeg expression. Such a call is refused
#' for the value first, whether or not this machine has the encoder.
#' @param quality `r batch_quality_param("crop_video")`
#' @param audio_stream `r audio_stream_param("carry into each output", "carries", "every", batch = TRUE, extra = audio_stream_extras$passthrough_subtitles)`
#' @inheritParams extract_audio_batch
#' @return `r jobs_return()`
#' @seealso [crop_video()], the single-input form it wraps; [ffm_batch()],
#' the batch runner; [has_hardware_encoder()] for the \code{hardware} toggle;
#' [standardize_video_batch()] to re-encode in batch.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(input = c(video, video), output = c("a.mp4", "b.mp4"),
#' width = c(160, 80), height = c(120, 60))
#' crop_video_batch(jobs, run = FALSE)
#' @export
crop_video_batch <- function(jobs, width = NULL, height = NULL,
x = "(in_w-out_w)/2", y = "(in_h-out_h)/2",
video_codec = NULL, audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE, parallel = FALSE, ...) {
check_token(video_codec, allow_null = TRUE)
check_token(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
jobs <- check_batch_jobs(jobs, require_output = FALSE)
# width/height have no default: each must be resolvable as an argument (applied
# to every row) or a per-row column, else fail here rather than as an opaque
# FFmpeg error mid-batch.
for (dim in c("width", "height")) {
if (is.null(get(dim)) && !dim %in% names(jobs)) {
cli::cli_abort(c(
"{.arg {dim}} is required.",
"i" = "Pass {.arg {dim}} (applied to every row) or add a {.field {dim}} column."
))
}
}
# Validate present override columns up front. These cover each column's TYPE;
# the per-row VALUES are swept below, above the nvenc check, so a bad cell
# blames this verb rather than ffm_crop() inside purrr::pmap() (M59/M64).
for (col in intersect(c("width", "height", "x", "y"), names(jobs))) {
if (!(is.numeric(jobs[[col]]) || is.character(jobs[[col]]))) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be numeric or character.")
}
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
check_batch_codec_col(jobs)
check_batch_codec_col(jobs, "audio_codec")
# See segment_video_batch() for why the hint says "every" here and why
# check_batch_stream_values() is not needed on a verb that does not reshape.
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
if (!"output" %in% names(jobs)) {
jobs$output <- derive_cropped_names(jobs$input)
}
jobs <- reject_duplicate_outputs(jobs)
# Sweep jobs$input here, below the shape/type/duplicate-output guards above
# and before the per-row dimension sweep below, so a missing input blames
# this verb rather than purrr::pmap() (M62).
check_batch_inputs(jobs)
# Per-row geometry VALUES, swept here so a bad dimension blames this verb
# instead of purrr::pmap() (M59 site 1; `x`/`y` added at M64). The column
# guards above cover the column's TYPE; this covers each value the fan-out
# would resolve. check_dim() is called directly rather than reached through
# ffm_crop(), and is the one site the message is written -- the scalar verb
# calls the same checker at its own front door (M59-D1/M59-D2, M64). `arg` is
# passed because caller_arg() would otherwise name the loop variable.
#
# `x`/`y` are positions, so they permit zero; width/height are sizes and do
# not. Splitting the loop keeps that distinction at the call site rather than
# hiding it in a lookup.
for (dim in c("width", "height")) {
rows <- batch_arg_rows(jobs, dim, get(dim))
for (i in seq_along(rows)) {
check_batch_cell(if (dim %in% names(jobs)) i else NA_integer_,
check_dim(rows[[i]], arg = dim))
}
}
for (dim in c("x", "y")) {
rows <- batch_arg_rows(jobs, dim, get(dim))
for (i in seq_along(rows)) {
check_batch_cell(if (dim %in% names(jobs)) i else NA_integer_,
check_dim(rows[[i]], arg = dim, inclusive = TRUE))
}
}
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame, so every check above still
# reports first. The sweep covers each distinct family a `video_codec` column
# spells, never only the argument's.
# `quality`, argument and column, against each row's resolved encoder
# (M136); above the probe (D036), naming this verb and the row (D076).
check_batch_quality(
jobs, quality,
batch_arg_rows(jobs, "video_codec", video_codec, batch_codec_cell),
hardware
)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
crop_video_pipeline(
input, output,
width = pick("width", width),
height = pick("height", height),
x = pick("x", x),
y = pick("y", y),
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# format_for_web_batch() --------------------------------------------------
#' Re-encode Many Videos for the Web From a Jobs Table
#'
#' Re-encode many videos into a widely compatible, web-friendly form, using one
#' jobs table. This is the **batch** form of [format_for_web()], for when you
#' have more than one file. Each row is one input. The function is a thin
#' wrapper over \code{\link{ffm_batch}}. It builds one reproducible command for
#' each input. Each command uses the same fixed H.264, AAC and
#' \code{+faststart} steps as \code{format_for_web()}, with no per-row settings.
#' The glossary in \code{vignette("tidymedia")} explains media terms such as
#' codec and re-encode.
#'
#' @param jobs A data frame with one row per input. It needs at least an
#' \code{input} column, the source path. An optional \code{output} column
#' names the destination. Without it, each row's output name adds \code{_web}
#' to the input's base name, with an \code{.mp4} extension. The web re-encode
#' always writes H.264 in mp4. For example, \code{clip.mkv} becomes
#' \code{clip_web.mp4}. Two rows with the same destination path are refused
#' before any row runs. That happens with a repeated \code{output}, or with
#' two derived names that match. For example, \code{clip.mov} and
#' \code{clip.mkv} both give \code{clip_web.mp4}. An optional numeric
#' \code{audio_stream} column overrides the \code{audio_stream} argument for
#' each row. \code{NA} keeps every audio track in that row. A numeric
#' \code{quality} column overrides the \code{quality} argument per row (see
#' \code{quality}). Any other columns
#' are ignored, \code{video_codec} and \code{audio_codec} included. The
#' sibling batch functions read those two columns as per-row overrides, but
#' this one does not. The web recipe fixes which codecs the output uses:
#' H.264 video and AAC audio. For per-row codecs, use a function that has them, such as
#' \code{\link{standardize_video_batch}} or \code{\link{crop_video_batch}}.
#' @param hardware The encoder backend for every row. \code{"none"} (default)
#' uses software libx264. \code{"nvenc"} uses NVIDIA GPU H.264 encoding, and
#' \code{"videotoolbox"} uses Apple GPU H.264 encoding. It applies to the
#' whole batch and is not read as a column. See
#' \code{\link{has_hardware_encoder}}.
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' @param quality `r batch_quality_param("format_for_web")`
#' @param audio_stream `r audio_stream_param("carry into each output", "carries", "every", batch = TRUE, extra = audio_stream_extras$passthrough_subtitles)`
#' @inheritParams extract_audio_batch
#' @inheritParams format_for_web
#' @return `r jobs_return()`
#' @seealso [format_for_web()], the single-input form it wraps;
#' [ffm_batch()], the batch runner; [standardize_video_batch()] for a configurable re-encode.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(input = c(video, video), output = c("a.mp4", "b.mp4"))
#' format_for_web_batch(jobs, run = FALSE)
#' @export
format_for_web_batch <- function(jobs,
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE, parallel = FALSE,
...) {
jobs <- check_batch_jobs(jobs, require_output = FALSE)
hardware <- rlang::arg_match(hardware)
# See crop_video_batch() for why the hint says "every" here and why
# check_batch_stream_values() is not needed on a verb that does not reshape.
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
# The scalar argument needs its own front door: batch_stream_cell() maps a
# scalar NA to the NULL sentinel exactly as it maps an NA cell, so without
# this `audio_stream = NA` would silently compile the every-track default
# (M37/M41).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
if (!"output" %in% names(jobs)) {
jobs$output <- derive_web_names(jobs$input)
}
jobs <- reject_duplicate_outputs(jobs)
# Sweep jobs$input here, below the shape/type/duplicate-output guards above
# and before the nvenc availability sweep below, so a missing input blames
# this verb rather than purrr::pmap() (M62).
check_batch_inputs(jobs)
# Thin Layer-2 fan-out over ffm_batch (D007): one web re-encode pipeline per
# row, sharing format_for_web_pipeline() with format_for_web(). hardware/
# fallback are batch-wide; `audio_stream` is the one per-row override this
# verb reads, and other extra job columns are still ignored.
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035), immediately before ffm_batch() so
# every check above still reports first (M41). The web recipe fixes the codec
# by identity, so the family is always h264 -- the same "libx264"
# format_for_web_pipeline() hands resolve_hw_encoder().
# `quality`, argument and column, against the fixed H.264 codec every row
# encodes with (M136); above the probe (D036), naming this verb (D076).
check_batch_quality(jobs, quality, rep(list("libx264"), nrow(jobs)),
hardware)
check_hardware_available("libx264", hardware, fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(input, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
format_for_web_pipeline(
input, output, hardware, fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality)),
audio_stream = batch_stream_cell(pick("audio_stream", audio_stream))
)
},
run = run,
parallel = parallel,
...
)
}
# separate_audio_video_batch() --------------------------------------------
#' Separate Audio and Video for Many Files From a Jobs Table
#'
#' Split the audio and video streams of many input files from a single jobs
#' tibble. This is the \strong{batch} (table-driven) form of
#' [separate_audio_video()], for when you have more than one file. Each row is
#' one input that gives \strong{two} outputs. The \code{input},
#' \code{audiofile} and \code{videofile} columns are all required. The function
#' is a thin wrapper over \code{\link{ffm_batch}}. It turns every input row into
#' two single-output jobs, one per stream. So a jobs table of \code{N} rows
#' returns \code{2N} rows, with one reproducible compiled command per stream.
#' Each stream uses the same map and stream-copy pipeline as
#' [separate_audio_video()]. The glossary in \code{vignette("tidymedia")}
#' explains media terms such as codec, container and stream copy.
#'
#' @param jobs A data frame with one row per input. It has at least an
#' \code{input} column (source path), plus \code{audiofile} and
#' \code{videofile} columns that name the two destinations. All three are
#' \strong{required}. Like \code{\link{separate_audio_video}}, this function
#' derives no output paths, because the container extension of a copied stream
#' is the instruction. That extension must match the source codec. No two destinations in
#' a table can be the same path. That covers an \code{audiofile} and a
#' \code{videofile} in one row, and any two across rows. The function refuses
#' such a table before any row runs. Optional \code{audio_codec} and
#' \code{video_codec} columns override the arguments of the same name per row.
#' They are character, with \code{NA} to set no codec option for that stream.
#' Rows that omit a column fall back to that argument. An optional numeric
#' \code{audio_stream} column likewise overrides the \code{audio_stream}
#' argument per row. There, \code{NA} keeps every audio track in that row's
#' \code{audiofile}. A numeric \code{quality} column overrides the
#' \code{quality} argument per row and applies to that row's
#' \code{videofile} (see \code{quality}). Any other columns are ignored,
#' with one exception. A
#' \code{reencode} column, retired with the argument of the same name, is an
#' error and not a silent no-op.
#' @param audio_codec A string that names the encoder for every
#' \code{audiofile}, unless \code{jobs} carries an \code{audio_codec} column.
#' The default \code{"copy"} copies the audio stream with no quality loss.
#' \code{NULL} sets no \code{-codec:a}. See
#' \code{\link{separate_audio_video}}.
#' @param video_codec A string that names the encoder for every
#' \code{videofile}, unless \code{jobs} carries a \code{video_codec} column.
#' The default \code{"copy"} copies the video stream with no quality loss.
#' \code{NULL} sets no \code{-codec:v}. See
#' \code{\link{separate_audio_video}}.
#' @param hardware,fallback The encoder backend for every \code{videofile} and
#' its fallback behavior. They apply to the whole batch. They are a property
#' of the machine, not of a row, so neither is read as a \code{jobs} column.
#' See [separate_audio_video()]. \code{hardware} is batch-wide, and a stream
#' copy runs no encoder. So a \code{hardware} other than \code{"none"}
#' conflicts with any row whose video codec resolves to \code{"copy"}. That
#' includes the default. So split a jobs table that mixes copied and
#' re-encoded video into separate calls.
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' `r contradiction_sentences("copy")`
#' The stream-copy conflict above is caught first, so such a call aborts
#' without asking FFmpeg.
#' @param quality `r batch_quality_param("separate_audio_video", output = "every \\code{videofile}")`
#' The \code{audiofile} never takes it. A cell on a row whose video codec
#' is \code{"copy"}, the default, is refused, because no encoder runs.
#' @param audio_stream `r audio_stream_param("write to each \\code{audiofile}", "keeps", "every", batch = TRUE, extra = audio_stream_extras$separation_container)`
#' @inheritParams extract_audio_batch
#' @return A [tibble][tibble::tibble-package] with \strong{two rows per input},
#' one per stream. It has the reshaped \code{input}, a single \code{output}
#' path, a \code{stream} marker (\code{"audio"} or \code{"video"}), and an
#' added \code{command} column. When \code{run = TRUE}, it also has a
#' \code{success} column. A run also gives \code{verified} and the
#' provenance manifest, each when requested via \code{...}. When \code{jobs} supplies
#' either codec column, a
#' single \code{codec} column carries each row's resolved encoder for its own
#' stream (\code{NA} where none is set). When \code{audio_stream} is supplied
#' as either the argument or a \code{jobs} column, an \code{audio_stream}
#' column likewise carries each row's resolved track. That is the selected
#' index on an audio row. It is \code{NA} on every video row, which takes no
#' audio, and on an audio row that named no track. So \code{NA} does not by
#' itself mark a video row. Read the \code{stream} column for that. The
#' columns match the output of the other \code{_batch} functions, plus the
#' \code{stream} marker. See \code{\link{ffm_batch}}.
#' @seealso [separate_audio_video()], the one-file function it wraps.
#' [ffm_batch()], the batch runner.
#' [has_hardware_encoder()] for the \code{hardware} argument.
#' [segment_video_batch()] for the other batch function where one input file
#' can give several outputs.
#' @section Failed audio outputs:
#' A row whose audio command does not finish cleanly is recorded as
#' \code{success = FALSE}, and the batch does not abort. One warning for the
#' whole batch, emitted \strong{once}, names such rows. It lists every affected
#' input row and the ways out.
#'
#' A row reaches that warning only when all four of these hold:
#' \itemize{
#' \item It named no \code{audio_stream}.
#' \item The row is recorded \code{success = FALSE}.
#' \item Its input carries more than one audio track.
#' \item The extension of its \code{audiofile} is not among the containers
#' named here as holding several audio streams.
#' }
#' Those containers are `r multi_audio_rd_list()`. No exit status is among those
#' conditions, and the difference from \code{\link{separate_audio_video}} is
#' deliberate. The batch runner records \emph{whether} a row succeeded and not
#' how. So it records a non-zero exit, a hard error and a reached limit the same
#' way. It treats alike a row put here by any of them. The
#' `r multi_audio_rd_count()` are an exclusion list and not a survey. FFmpeg
#' writes several audio streams into other containers too, \code{.avi} and
#' \code{.nut} among them. A row that fails on one of those is still named. The
#' container condition keeps a row off the list when it already does what the
#' warning advises. Such a row is silently not named. A batch whose failed audio
#' rows all write to those `r multi_audio_rd_count()` does not warn at all. The
#' headline count follows the rows actually named.
#'
#' Each bullet of the warning states what that row \emph{did}: its track count, and that every
#' track was mapped into one output. It never states why FFmpeg refused.
#' Several causes look alike from here. Examples are a stream copy into a
#' container
#' that will not hold the source codec, an unknown encoder and a missing output
#' directory.
#'
#' The check runs FFprobe on the failed rows only. So the function emits the
#' warning when FFprobe is available and the input can be probed, and skips it
#' silently otherwise. The warning may not appear, and its absence is never
#' itself a second failure. The check never runs under \code{run = FALSE} and
#' never changes any compiled command. Suppress the warning with
#' \code{suppressWarnings(classes = "tidymedia_multitrack_separation")}.
#'
#' The warning names the same event as the error of
#' \code{\link{separate_audio_video}} and answers to the same class. But it
#' carries no exit status: no \code{tm_status} field, and no
#' \code{tidymedia_ffmpeg_exit} class. The batch runner records, per row,
#' \emph{whether} the row succeeded, not \emph{how} FFmpeg exited. The
#' \code{success} column holds that record. So the exit number is gone by the
#' time this warning is assembled. To catch a specific row's exit status, use
#' [separate_audio_video()].
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(
#' input = c(video, video),
#' audiofile = c("a1.aac", "a2.aac"),
#' videofile = c("v1.mp4", "v2.mp4")
#' )
#' # run = FALSE compiles two commands per input without calling FFmpeg
#' separate_audio_video_batch(jobs, run = FALSE)
#' @export
separate_audio_video_batch <- function(jobs, audio_codec = "copy",
video_codec = "copy",
hardware = c("none", "nvenc",
"videotoolbox"),
fallback = FALSE, quality = NULL,
audio_stream = NULL,
run = TRUE,
parallel = FALSE, ...) {
jobs <- check_batch_jobs(jobs, verb = "Audio/video separation")
# Batch-wide, never a jobs column: nvenc availability is a property of the
# machine, not of a file (D016). Resolved here for the same reason as the
# scalar -- the copy guard compares against "none".
hardware <- rlang::arg_match(hardware)
rlang::check_bool(fallback)
# Two required output columns; this verb derives nothing (parity with the
# scalar, which requires both audiofile and videofile).
outcols <- c("audiofile", "videofile")
missing <- setdiff(outcols, names(jobs))
if (length(missing) > 0) {
cli::cli_abort(c(
"{.arg jobs} must have {.field audiofile} and {.field videofile} columns.",
"x" = "Missing column{?s}: {.val {missing}}."
))
}
for (col in outcols) {
jobs[[col]] <- as.character(jobs[[col]])
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
# M37 removed `reencode` from this verb in BOTH its spellings. The scalar
# sibling has no `...`, so R rejects a stale call itself; here `...` forwards
# ffm_batch options and would swallow a stale argument, and the reshape below
# builds `long` fresh and would drop a stale `jobs` column -- either way
# silently stream-copying output the caller asked to have re-encoded. Name the
# replacement instead of ignoring it -- a diagnostic, not a lifecycle shim
# (D014's clean break stands).
stale <- c(
if ("reencode" %in% names(list(...))) "argument",
if ("reencode" %in% names(jobs)) "jobs column"
)
if (length(stale) > 0) {
cli::cli_abort(c(
"The {.arg reencode} {stale} was removed from
{.fn separate_audio_video_batch}.",
"i" = "Use {.arg audio_codec} / {.arg video_codec} instead:
{.val copy} replaces {.code reencode = FALSE} and {.code NULL}
replaces {.code reencode = TRUE}.",
"i" = "In {.arg jobs}, name them as {.field audio_codec} /
{.field video_codec} columns, where {.val {NA}} leaves that
stream's codec unset."
))
}
# `codec` is the name the reshape below gives its resolved per-stream column,
# so a caller passing `codec =` would have it forwarded through `...` into the
# same pmap slot and applied to BOTH streams -- the cross-stream leak the
# per-stream split exists to make impossible. Every other _batch verb is immune
# because its per-row column names are also formals; this one's is not, so it
# is guarded here (M37 review).
if ("codec" %in% names(list(...))) {
cli::cli_abort(c(
"{.arg codec} is not an argument of {.fn separate_audio_video_batch}.",
"x" = "It names an internal per-stream column and would set the codec on
the audio and the video output alike.",
"i" = "Use {.arg audio_codec} / {.arg video_codec} to name each stream's
encoder separately."
))
}
# The scalar arguments are materialized into the reshaped column below, which
# bypasses the per-row token check in the pipeline for their *type*: without
# this, `video_codec = TRUE` compiled `-codec:v TRUE` and `video_codec = NA`
# silently emitted nothing whenever `jobs` happened to carry a codec column
# (M37 review). NULL stays legal -- it is the sentinel.
if (!is.null(audio_codec)) check_token(audio_codec)
if (!is.null(video_codec)) check_token(video_codec)
# NA is legal in either codec column: it is the column form of the NULL
# sentinel, so these need check_batch_codec_col(), never a guard that rejects
# NA (M34/D016).
check_batch_codec_col(jobs, "audio_codec")
check_batch_codec_col(jobs, "video_codec")
# The stream-index column's own type guard, with a hint saying what NA means
# HERE: on this verb it keeps EVERY track, where on the extraction verbs the
# same cell keeps the first one (M40's stale-hint lesson, which is why the
# wording is a parameter).
check_batch_audio_col(jobs, "audio_stream",
na_means = "keep every audio track")
# Each CELL's value too, not only the column's type. The range check inside
# audio_stream_map() does run per row, but under purrr::pmap over the RESHAPED
# 2N table -- so on this verb a bad cell aborted mid-fan-out reporting
# "In index: 3" for a two-row jobs table, a row number the caller cannot find,
# and naming Layer-1's pmap (M45 review F4; M32's per-row revalidation rule and
# M41's don't-abort-mid-fan-out rule, which every other _batch verb satisfies
# for free because its index IS the caller's row).
check_batch_stream_values(jobs, "audio_stream")
# And the argument's front-door check. Load-bearing here, unlike on the scalar
# sibling: the reshape below materializes this argument into a column whose NA
# cells mean the NULL sentinel, so without it `audio_stream = NA` would quietly
# keep every track instead of erroring (the M37/M41 shape).
rlang::check_number_whole(audio_stream, min = 0, allow_null = TRUE)
# Sweep the CALLER's jobs$input before the reshape below, so a missing input
# blames this verb rather than the reshaped `long` table or purrr::pmap()
# (M62).
check_batch_inputs(jobs)
# Reshape N input rows -> 2N single-output rows (D003/D007): each input fans out
# into an audio row (0:a -> audiofile) and a video row (0:v -> videofile),
# tagged by a `stream` marker; interleaved audio,video per input. Melting both
# output columns into one `output` lets a single duplicate-path guard pool
# across audio and video — and catch within-row audiofile == videofile (M26).
n <- nrow(jobs)
long <- tibble::tibble(
input = rep(jobs$input, each = 2L),
output = as.vector(rbind(jobs$audiofile, jobs$videofile)),
stream = rep(c("audio", "video"), times = n)
)
# The two input-side codec columns collapse into ONE `codec` column on the
# reshaped table, resolved per stream: an audio row carries the audio choice, a
# video row the video one, so neither can reach the other's command. Each
# stream falls back to its own argument where `jobs` supplies no column, and
# the column is added only when `jobs` supplied one — a table naming no codec
# keeps the pre-M37 shape.
pick_codec <- function(col, arg) {
if (col %in% names(jobs)) {
# An all-NA column is typed logical by R (M34); as.character() carries the
# NA cells through as the character sentinel the reshape needs.
as.character(jobs[[col]])
} else if (is.null(arg)) {
rep(NA_character_, n)
} else {
rep(arg, n)
}
}
if (any(c("audio_codec", "video_codec") %in% names(jobs))) {
long$codec <- as.vector(rbind(
pick_codec("audio_codec", audio_codec),
pick_codec("video_codec", video_codec)
))
}
# `audio_stream` is one choice per INPUT that applies to the audio row only, so
# the reshaped column carries each input's value on its audio row and NA on its
# video row. NA is the no-selection sentinel, and the video branch of
# separate_stream_pipeline() never reads the value anyway -- two independent
# reasons a video output cannot be narrowed by this argument. as.numeric()
# carries an all-NA column, which R types logical (M34).
if ("audio_stream" %in% names(jobs) || !is.null(audio_stream)) {
per_input <- if ("audio_stream" %in% names(jobs)) {
as.numeric(jobs$audio_stream)
} else {
rep(as.numeric(audio_stream), n)
}
long$audio_stream <- as.vector(rbind(per_input, rep(NA_real_, n)))
}
long <- reject_duplicate_outputs(long)
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame and where the other seven
# guarded _batch verbs put theirs: above the reshape it preempted
# reject_duplicate_outputs(), and a row whose audiofile equals its videofile
# was told the encoder was missing instead of that its two outputs collide --
# M26's within-row catch, which only the reshaped table can make (review F3).
# It reads `jobs`, not `long`: the caller's `video_codec` column survives the
# reshape only as a per-stream `codec` column mixing both streams' choices.
#
# The copy-versus-hardware contradiction (condition 1), re-checked here so a
# contradictory call blames this verb instead of purrr::pmap() (M58). Swept
# ROW BY ROW over the caller's `video_codec` column, and placed BELOW the
# reshape for the reason M57 review F3 gave its neighbour: above it, a row
# whose audiofile equals its videofile would be told about the copy instead of
# about its colliding outputs -- M26's within-row catch, which only the
# reshaped table can make.
vcodec_rows <- batch_arg_rows(jobs, "video_codec", video_codec,
batch_codec_cell)
for (i in seq_along(vcodec_rows)) {
check_batch_cell(if ("video_codec" %in% names(jobs)) i else NA_integer_,
check_hardware_needs_encode(vcodec_rows[[i]], hardware))
}
# And nvenc availability (M57/D035).
#
# The Filter() that dropped "copy" cells before sweeping is retired with M58.
# It was there because a copied video stream has no encoder to check and its
# own error had to report instead; the sweep above now refuses every such cell
# whenever `hardware = "nvenc"`, which is the only setting under which this
# guard acts at all, so no copy cell can reach it.
# `quality`, argument and column, against each row's resolved VIDEO codec
# (M136): a copy row is refused for the copy, after the hardware
# contradiction above and before the probe below (D036), naming this verb
# and the caller's row (D076). It reads `jobs`, not `long`, for the reason
# the two guards above do.
check_batch_quality(jobs, quality, vcodec_rows, hardware)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# `quality` on the reshaped table (M136), the mirror image of `audio_stream`
# above: one choice per INPUT that applies to the video row only, so the
# column carries each input's value on its video row and NA on its audio
# row, where no encoder it names runs. Built AFTER the check above, which
# is what keeps a non-numeric column or a two-element argument from
# reaching as.numeric() and rbind(). as.numeric() carries an all-NA column,
# which R types logical (M34). Added only when the caller supplied either
# form, so a quality-less table keeps its shape.
if ("quality" %in% names(jobs) || !is.null(quality)) {
per_input <- if ("quality" %in% names(jobs)) {
as.numeric(jobs$quality)
} else {
rep(as.numeric(quality), n)
}
long$quality <- as.vector(rbind(rep(NA_real_, n), per_input))
}
# Thin Layer-2 fan-out over ffm_batch (D007): one single-output pipeline per
# reshaped row, sharing separate_stream_pipeline() with separate_audio_video().
# The reshaped `codec` column (via `...` from pmap) already carries the
# column-over-argument resolution; without it each row takes its stream's
# argument. `...` also forwards ffm_batch options (verify/manifest/...) to the
# runner.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
out <- ffm_batch(
long,
function(input, output, stream, ...) {
dots <- list(...)
codec <- if ("codec" %in% names(dots)) {
batch_codec_cell(dots$codec)
} else if (stream == "audio") {
audio_codec
} else {
video_codec
}
# Passed on audio rows only: the video command never receives the value, so
# no column or argument can narrow a video map even by mistake.
sel <- if (identical(stream, "audio")) {
batch_stream_cell(
if ("audio_stream" %in% names(dots)) dots$audio_stream else audio_stream
)
}
# And `quality` on video rows only, the mirror image: the audio command
# never receives it. An NA cell is the column form of NULL (D022).
q <- if (identical(stream, "video")) {
batch_stream_cell(if ("quality" %in% names(dots)) dots$quality else quality)
}
separate_stream_pipeline(input, output, stream, codec, hardware, fallback,
sel, quality = q)
},
run = run,
parallel = parallel,
...
)
# The failed-row diagnostic, after the fan-out rather than before it: which
# rows failed is not knowable until they run, so a batch where every row
# succeeds pays nothing (M45-D2).
if (isTRUE(run)) warn_failed_separation_batch(out, audio_stream)
out
}
# concatenate_videos() ----------------------------------------------------
# Build the concat-demuxer pipeline shared by concatenate_videos() and its
# _batch sibling (M32): warn on mixed extensions, then ffm_concat() writes the
# demuxer list file and sets copy + map 0. Kept ABOVE the roxygen block so
# document() does not re-target it (M28 lesson).
concatenate_pipeline <- function(infiles, outfile) {
if (length(unique(tools::file_ext(infiles))) != 1) {
cli::cli_warn("Not all {.arg infiles} have the same extension.")
}
ffm_concat(ffm_files(infiles, outfile))
}
#' Combine video files using the concat demuxer
#'
#' Combine multiple video files one after another without needing to re-encode
#' them by using the [concat
#' demuxer](https://ffmpeg.org/ffmpeg-formats.html#concat-1). This will be much
#' faster than re-encoding but requires that the files have the same parameters
#' (width, height, etc.) and formats/codecs. To concatenate videos using
#' re-encoding, see the [concat video
#' filter](https://ffmpeg.org/ffmpeg-filters.html#concat). The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec and
#' re-encode.
#'
#' @param infiles A character vector containing the file paths to video files.
#' `r infiles_check_sentences()`
#' @param outfile A string containing the desired file path to write the new,
#' concatenated video file to.
#' @inheritParams crop_video
#' @return `r command_return()`
#' @seealso [ffm_concat()], the pipeline function it wraps.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' concatenate_videos(c(video, video), "joined.mp4", run = FALSE)
#' @export
concatenate_videos <- function(infiles, outfile, run = TRUE) {
if (!rlang::is_character(infiles)) {
cli::cli_abort("{.arg infiles} must be a character vector of file paths.")
}
rlang::check_string(outfile)
# Sweep infiles here, below the type guards above and before the pipeline
# (whose contradiction checks live inside concatenate_pipeline()), so a
# missing input blames this verb rather than ffm_files() (M62).
check_paths_readable(infiles, arg = "infiles", multiple = TRUE)
p <- concatenate_pipeline(infiles, outfile)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# compare_videos() --------------------------------------------------------
# Build the side-by-side comparison pipeline shared by compare_videos() and its
# _batch sibling (M32): resize supports exactly two inputs, so guard it here;
# then stack (h/v) and optionally carry one input's audio. Assumes `resize`/
# `audio_input` already type-checked by the caller; `direction` is arg-matched here so
# both callers get a clean per-value error. ABOVE the roxygen block (M28 lesson).
compare_videos_pipeline <- function(infiles, outfile,
direction = stack_directions(),
resize = TRUE, audio_input = NULL,
video_codec = NULL, audio_codec = "copy",
hardware = "none",
fallback = FALSE, quality = NULL,
call = rlang::caller_env()) {
# Conditions 4 and 5, worded once in their checkers; compare_videos_batch()
# ALSO calls both at its front door (M58). The `call = call` on the resize
# guard is new with M58 -- without it the abort displayed
# `compare_videos_pipeline()`, the one of the six that leaked an internal name
# to the user.
check_audio_codec_needs_audio(
audio_input, audio_codec,
hint = "Pass {.arg audio_input} the 0-based index of the input whose audio to
keep, or drop {.arg audio_codec}.",
call = call
)
check_resize_needs_two_inputs(resize, length(infiles), call = call)
# BELOW the two contradiction checkers, deliberately (M61): a call wrong in
# both `direction` and one of them is told about the contradiction, and the
# column sweep in compare_videos_batch() already sits below them, so the two
# forms of the same mistake now answer alike (D036 restored unconditionally).
# `call = call` so compare_videos() is blamed rather than this internal
# pipeline -- the same leak M58 closed on the resize guard.
direction <- check_vocab_arg(direction, stack_directions(), "direction",
call = call)
p <- ffm_files(infiles, outfile)
p <- switch(
direction,
horizontal = ffm_hstack(p, resize = resize),
vertical = ffm_vstack(p, resize = resize)
)
if (!is.null(audio_input)) {
p <- ffm_map(p, paste0(audio_input, ":a"))
# The carried track is mapped straight through, so the default audio_codec
# stream-copies it instead of letting the container re-encode it (M35/D017).
p <- apply_audio_codec(p, audio_codec, call = call)
}
# The stacked video is a filtered stream, so a -codec:v rides alongside the
# -filter_complex … [vout] mapping the blessed stack verbs emit; the default
# video_codec = NULL emits none (M34/D016).
apply_video_codec(p, video_codec, hardware, fallback, quality, call = call)
}
#' Build a side-by-side comparison video
#'
#' Stack two or more videos into a single comparison video. The videos go
#' side-by-side (\code{direction = "horizontal"}) or one above the other
#' (\code{direction = "vertical"}). This is a common need when reviewing
#' annotations or before/after processing. Built on the stacking pipeline
#' functions (\code{\link{ffm_hstack}} / \code{\link{ffm_vstack}}). The glossary
#' in \code{vignette("tidymedia")} explains media terms such as codec, encoder
#' and stream copy.
#'
#' By default the two inputs are resized to share an edge (equal heights for a
#' horizontal stack, equal widths for a vertical one). Resizing currently
#' supports exactly two inputs, so pass \code{resize = FALSE} to compare more.
#' Audio is dropped unless \code{audio_input} names an input to carry; a carried
#' track is stream-copied unless \code{audio_codec} names an encoder.
#'
#' @param infiles A character vector of two or more video file paths.
#' `r infiles_check_sentences()`
#' @param outfile A string giving the path to write the comparison video to.
#' @param direction Either \code{"horizontal"} (side-by-side, the default) or
#' \code{"vertical"} (stacked top to bottom).
#' @param resize A logical indicating whether to resize the inputs to share an
#' edge. Only supported for exactly two inputs. (default = \code{TRUE})
#' @param audio_input `r audio_input_param()`
#' @param audio_codec A string naming the codec for the carried audio track.
#' `r audio_codec_copy_sentences("it")` When \code{audio_input} is
#' \code{NULL}, no audio reaches the output, so nothing is emitted. Naming an
#' encoder in that case is an error.
#' @inheritParams crop_video
#' @return `r command_return()`
#' @seealso [ffm_hstack()] and [ffm_vstack()], the pipeline functions it wraps;
#' [has_hardware_encoder()] for the \code{hardware} argument;
#' [picture_in_picture()] for insetting instead of stacking.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' compare_videos(c(video, video), "compare.mp4", run = FALSE)
#' @export
compare_videos <- function(infiles, outfile,
direction = c("horizontal", "vertical"),
resize = TRUE, audio_input = NULL, video_codec = NULL,
audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
run = TRUE) {
if (!rlang::is_character(infiles) || length(infiles) < 2) {
cli::cli_abort("{.arg infiles} must name two or more video files.")
}
rlang::check_string(outfile)
rlang::check_bool(resize)
rlang::check_number_whole(
audio_input, min = 0, max = length(infiles) - 1, allow_null = TRUE
)
rlang::check_string(video_codec, allow_null = TRUE)
rlang::check_string(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
# Sweep infiles here, below the type guards above and before the pipeline
# (whose contradiction checks live inside compare_videos_pipeline()), so a
# missing input blames this verb rather than ffm_files() (M62).
check_paths_readable(infiles, arg = "infiles", multiple = TRUE)
p <- compare_videos_pipeline(infiles, outfile, direction, resize, audio_input,
video_codec = video_codec,
audio_codec = audio_codec,
hardware = hardware, fallback = fallback,
quality = quality)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# picture_in_picture() ----------------------------------------------------
# Build the picture-in-picture overlay pipeline shared by picture_in_picture()
# and its _batch sibling (M32): translate the corner/center choice into overlay
# x/y expressions, scale + position the inset, optionally carry one input's
# audio. Assumes `scale`/`margin`/`audio_input` already type-checked by the caller;
# `position` is arg-matched here so both callers get a clean per-value error.
# ABOVE the roxygen block (M28 lesson).
picture_in_picture_pipeline <- function(main, overlay, outfile,
position = pip_positions(),
scale = 0.25, margin = 16, audio_input = NULL,
video_codec = NULL,
audio_codec = "copy",
hardware = "none",
fallback = FALSE, quality = NULL,
call = rlang::caller_env()) {
# Condition 6 -- the same contradiction as compare_videos(), which is why it
# shares that verb's checker and differs only in the way out (M58).
# picture_in_picture_batch() ALSO calls it at its front door.
check_audio_codec_needs_audio(
audio_input, audio_codec,
hint = "Pass {.arg audio_input} {.val {0}} for the main video's audio or
{.val {1}} for the overlay's, or drop {.arg audio_codec}.",
call = call
)
# BELOW the contradiction checker, deliberately (M61), for the reason given at
# the same move in compare_videos_pipeline(). `call = call`, as there.
position <- check_vocab_arg(position, pip_positions(), "position",
call = call)
# The `scale` RANGE, below the contradiction and `position` checks (M61's
# ordering: the contradiction reports first) and where ffm_overlay()
# effectively read it before -- so this changes blame and nothing else for
# the scalar verb (M65, D042). `call` threaded; the batch verb sweeps its
# resolved rows at its own front door, where this per-row copy would blame
# purrr::pmap().
check_overlay_scale(scale, call = call)
m <- as.integer(margin)
pos <- switch(
position,
topleft = list(x = as.character(m), y = as.character(m)),
topright = list(x = sprintf("main_w-overlay_w-%d", m),
y = as.character(m)),
bottomleft = list(x = as.character(m),
y = sprintf("main_h-overlay_h-%d", m)),
bottomright = list(x = sprintf("main_w-overlay_w-%d", m),
y = sprintf("main_h-overlay_h-%d", m)),
center = list(x = "(main_w-overlay_w)/2",
y = "(main_h-overlay_h)/2")
)
p <- ffm_files(c(main, overlay), outfile)
p <- ffm_overlay(p, x = pos$x, y = pos$y, scale = scale)
if (!is.null(audio_input)) {
p <- ffm_map(p, paste0(audio_input, ":a"))
# The carried track is mapped straight through, so the default audio_codec
# stream-copies it instead of letting the container re-encode it (M35/D017).
p <- apply_audio_codec(p, audio_codec, call = call)
}
# The composited video is a filtered stream, so a -codec:v rides alongside the
# -filter_complex … [vout] mapping ffm_overlay() emits; the default
# video_codec = NULL emits none (M34/D016).
apply_video_codec(p, video_codec, hardware, fallback, quality, call = call)
}
#' Inset one video over another (picture-in-picture)
#'
#' Composite a smaller \code{overlay} video onto a \code{main} video in one
#' corner (or the center). This is the classic picture-in-picture layout for
#' pairing a speaker with a screen recording, or a stimulus with a webcam. Built
#' on the \code{\link{ffm_overlay}} pipeline function, which resizes the overlay
#' to a fraction of the main video's width and positions it. The glossary in
#' \code{vignette("tidymedia")} explains media terms such as codec, encoder and
#' stream copy.
#'
#' Audio is dropped unless \code{audio_input} names an input to carry (\code{0} = the
#' main video, \code{1} = the overlay). A carried track is
#' stream-copied unless \code{audio_codec} names an encoder.
#'
#' @param main A string giving the path to the background (full-size) video.
#' @param overlay A string giving the path to the inset video.
#' @param outfile A string giving the path to write the result to.
#' @param position Where to place the inset: one of \code{"topright"} (default),
#' \code{"topleft"}, \code{"bottomright"}, \code{"bottomleft"}, or
#' \code{"center"}.
#' @param scale The inset's width as a fraction of the main video's width, aspect
#' preserved (\code{0 < scale <= 1}). (default = \code{0.25})
#' @param margin The gap in pixels between the inset and the video edges (ignored
#' for \code{position = "center"}). (default = \code{16})
#' @param audio_input `r audio_input_param()`
#' @inheritParams compare_videos
#' @return `r command_return()`
#' @seealso [ffm_overlay()], the pipeline function it wraps;
#' [has_hardware_encoder()] for the
#' \code{hardware} argument; [compare_videos()] for
#' side-by-side stacking.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' picture_in_picture(video, video, "pip.mp4", run = FALSE)
#' @export
picture_in_picture <- function(main, overlay, outfile,
position = c("topright", "topleft",
"bottomright", "bottomleft",
"center"),
scale = 0.25, margin = 16, audio_input = NULL,
video_codec = NULL, audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
run = TRUE) {
check_file_readable(main)
check_file_readable(overlay)
rlang::check_string(outfile)
rlang::check_number_decimal(scale)
rlang::check_number_whole(margin, min = 0)
rlang::check_number_whole(audio_input, min = 0, max = 1, allow_null = TRUE)
rlang::check_string(video_codec, allow_null = TRUE)
rlang::check_string(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
p <- picture_in_picture_pipeline(
main, overlay, outfile, position, scale, margin, audio_input,
video_codec = video_codec, audio_codec = audio_codec,
hardware = hardware, fallback = fallback, quality = quality
)
# D074: the verb the caller typed names a bad limit, not the builder below.
# Below the pipeline, so the builder's own argument checks keep reporting
# first; above the `run` gate, so a dry-run compile is refused too.
resolve_timeout()
ffm_finish(p, run, call = rlang::current_env())
}
# concatenate_videos_batch() ----------------------------------------------
#' Concatenate Many Videos From a Jobs Table
#'
#' Join clips end to end for many outputs from a single jobs tibble. This is the
#' **batch** (table-driven) form of [concatenate_videos()], for when you have
#' more than one concatenation to produce. Unlike the single-input batch
#' functions, each row's inputs are **many**. So \code{jobs} carries an
#' \code{inputs} list-column (each cell a character vector of source paths)
#' plus an \code{output} column. This is a thin wrapper over
#' \code{\link{ffm_batch}}: one reproducible concat-demuxer command per row,
#' sharing the copy + map-0 pipeline with [concatenate_videos()].
#'
#' @param jobs A data frame with one row per output and (at least) an
#' \code{inputs} list-column and an \code{output} column (destination path).
#' Each \code{inputs} cell is a character vector of the source paths to join,
#' in order.
#' An \code{output} column is required; this function derives no destination.
#' Two rows given the same \code{output} path are refused before any row runs.
#' Any other columns are ignored.
#' @inheritParams extract_audio_batch
#' @return `r jobs_return()`
#' @seealso [concatenate_videos()], the one-output function it wraps; [ffm_batch()],
#' the batch runner; [compare_videos_batch()] and
#' [picture_in_picture_batch()], the other batch functions that take several
#' inputs per row.
#' @family task functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(inputs = list(c(video, video)), output = "joined.mp4")
#' concatenate_videos_batch(jobs, run = FALSE)
#' @export
concatenate_videos_batch <- function(jobs, run = TRUE, parallel = FALSE, ...) {
jobs <- check_fanin_jobs(jobs, verb = "Concatenation")
jobs <- reject_duplicate_outputs(jobs)
# Sweep jobs$inputs here, below the shape/type/duplicate-output guards above
# and immediately before the fan-out, so a missing input blames this verb
# rather than purrr::pmap() (M62).
check_batch_inputs(jobs, "inputs")
# Thin Layer-2 fan-in over ffm_batch (D007/D015): one concat-demuxer pipeline
# per row, sharing concatenate_pipeline() with concatenate_videos(). pmap
# passes each `inputs` list cell as a character vector; `...` forwards
# ffm_batch options (verify/manifest/...) to the runner.
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(inputs, output, ...) concatenate_pipeline(inputs, output),
run = run,
parallel = parallel,
...
)
}
# compare_videos_batch() ---------------------------------------------------
#' Build Many Comparison Videos From a Jobs Table
#'
#' Stack videos side by side for many outputs from a single jobs tibble. This is
#' the **batch** (table-driven) form of [compare_videos()], for when you have
#' more than one comparison to produce. Each row carries an \code{inputs}
#' list-column (each cell two or more video paths) plus an \code{output} column.
#' This is a thin wrapper over \code{\link{ffm_batch}}: one reproducible stacking
#' command per row, sharing the pipeline with [compare_videos()]. The glossary
#' in \code{vignette("tidymedia")} explains media terms such as codec, encoder
#' and stream copy.
#'
#' @param jobs A data frame with one row per output and (at least) an
#' \code{inputs} list-column and an \code{output} column (destination path).
#' Each \code{inputs} cell is a character vector of **two or more** video
#' paths. Optional
#' \code{direction}, \code{resize}, \code{audio_input}, \code{video_codec}, and
#' \code{audio_codec} columns override the
#' like-named arguments per row (a row omitting one falls back to the
#' argument). `r fan_in_na_sentences()` A numeric \code{quality} column
#' overrides the \code{quality} argument per row (see \code{quality}). Two rows
#' given the same \code{output} path are refused before any row runs; other
#' columns are ignored.
#' @param direction,resize Defaults applied to every row lacking the
#' corresponding column. \code{direction} is \code{"horizontal"} (the default)
#' or \code{"vertical"}; a \code{direction} column is held to the same two
#' values, per row. See [compare_videos()] for their fuller meaning.
#' @param audio_input `r audio_input_param(batch = TRUE, extra = "Each row's value is validated against that row's input count.")`
#' @param audio_codec A string naming the codec for the carried audio track,
#' applied to every row lacking an \code{audio_codec} column. \code{"copy"}
#' (default) stream-copies it. Name an encoder to re-encode it, or
#' \code{NULL} to leave the codec unset. A row carrying no audio emits no
#' \code{-codec:a}, and naming an encoder on such a row is an error.
#' @param hardware,fallback `r batch_hardware_param("compare_videos")`
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' `r contradiction_sentences("audio_codec")`
#' A per-row value error likewise reports ahead of the encoder check. Examples
#' are an \code{audio_input} index past that row's input count, and a
#' \code{direction} outside the two accepted values.
#' `r value_error_order_sentences()`
#' @param quality `r batch_quality_param("compare_videos")`
#' @inheritParams extract_audio_batch
#' @inheritParams crop_video_batch
#' @return `r jobs_return()`
#' @seealso [compare_videos()], the one-output function it wraps; [ffm_batch()],
#' the batch runner; [has_hardware_encoder()] for the \code{hardware}
#' argument. [concatenate_videos_batch()] and [picture_in_picture_batch()],
#' the other batch functions that take several inputs per row.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(inputs = list(c(video, video)), output = "compare.mp4")
#' compare_videos_batch(jobs, run = FALSE)
#' @export
compare_videos_batch <- function(jobs, direction = c("horizontal", "vertical"),
resize = TRUE, audio_input = NULL,
video_codec = NULL, audio_codec = "copy",
hardware = c("none", "nvenc", "videotoolbox"),
fallback = FALSE, quality = NULL,
run = TRUE, parallel = FALSE, ...) {
# `direction` and `audio_input` are checked BELOW the contradiction sweep (M61); see
# there. `resize` stays here because check_resize_needs_two_inputs() consumes
# it and degrades to unattributed base-R errors without this type guard.
rlang::check_bool(resize)
check_token(video_codec, allow_null = TRUE)
check_token(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
jobs <- check_fanin_jobs(jobs, min_inputs = 2L, verb = "Comparison")
jobs <- reject_duplicate_outputs(jobs)
# Validate present override columns up front (types); per-value checks
# (direction vocabulary, resize/length compatibility, audio range) are
# inherited per row from compare_videos_pipeline() / the per-row audio check.
check_batch_string_col(jobs, "direction")
check_batch_audio_col(jobs)
check_batch_codec_col(jobs)
check_batch_codec_col(jobs, "audio_codec")
if ("resize" %in% names(jobs) &&
(!is.logical(jobs$resize) || anyNA(jobs$resize))) {
cli::cli_abort(
"The {.field resize} column of {.arg jobs} must be {.val {TRUE}} or {.val {FALSE}} (no {.val {NA}})."
)
}
# Sweep jobs$inputs here, below the shape/type guards above and before the
# contradiction sweep below, so a missing input blames this verb rather
# than purrr::pmap() (M62).
check_batch_inputs(jobs, "inputs")
# Thin Layer-2 fan-in over ffm_batch (D007/D015): one stacking pipeline per
# row, sharing compare_videos_pipeline() with compare_videos(). A per-row
# override column (via `...` from pmap) wins over the scalar arg; an `audio_input`
# cell of NA means "drop audio" (the column form of the scalar's NULL).
#
# Conditions 4 and 5, re-checked here so a contradictory call blames this verb
# instead of purrr::pmap() (M58). Swept ROW BY ROW: `audio_input`, `audio_codec` and
# `resize` can each arrive as a column, and the input count is per row by
# construction on a fan-in verb, so no two rows need agree.
audio_rows <- batch_arg_rows(jobs, "audio_input", audio_input, batch_stream_cell)
acodec_rows <- batch_arg_rows(jobs, "audio_codec", audio_codec,
batch_codec_cell)
resize_rows <- batch_arg_rows(jobs, "resize", resize)
audio_cols <- any(c("audio_input", "audio_codec") %in% names(jobs))
for (i in seq_len(nrow(jobs))) {
check_batch_cell(
if (audio_cols) i else NA_integer_,
check_audio_codec_needs_audio(
audio_rows[[i]], acodec_rows[[i]],
hint = "Pass {.arg audio_input} the 0-based index of the input whose audio to
keep, or drop {.arg audio_codec}."
)
)
# Locator only when `resize` itself arrives as a column: with a scalar
# `resize` on a uniform table every row offends alike, and "row 1" would
# send the caller to a row where nothing is distinct (M66 review F1).
check_batch_cell(
if ("resize" %in% names(jobs)) i else NA_integer_,
check_resize_needs_two_inputs(resize_rows[[i]], length(jobs$inputs[[i]]))
)
}
# `direction`, both forms, BELOW the contradiction sweep (M61). The scalar
# argument is normalized and checked here rather than at the top of the verb,
# so a call wrong in both `direction` and one of the two contradictions is
# told about the contradiction whichever form the bad value arrived in. The
# scalar guard still runs when a `direction` column overrides it, so no call
# that was refused before compiles now.
direction <- check_vocab_arg(direction, stack_directions(), "direction")
# Per-row `direction` VALUES (M59 site 5). check_batch_string_col() above
# covers that column's TYPE only, so an out-of-vocabulary cell used to reach
# compare_videos_pipeline()'s own check inside the fan-out and be reported
# against purrr::pmap() -- additionally leaking the pipeline's name. The
# vocabulary is stack_directions()'s, never a copy (M59-D2).
check_batch_vocab_col(jobs, "direction", direction, stack_directions())
# Per-row `audio_input` index against THAT row's own input count (M59 site 4). The
# count is per row by construction on a fan-in verb, so the scalar check above
# can only bound the index below; the upper bound used to be re-checked inside
# the fan-out closure and reported against purrr::pmap(). That closure copy
# retires with this sweep (M59-D2). `arg` is named because the closure's local
# was called `aud`, which is the name the message showed the user.
#
# AFTER the contradiction sweep above, deliberately: a call whose value error
# arrives in a `jobs` column and which also contradicts itself reports the
# contradiction (D036's ordering). The scalar guard below sits here for the
# same reason, so the argument form answers alike (M61); it is not redundant
# with the sweep, which reads an `audio_input` COLUMN over the argument and so
# never sees a bad argument a column overrides.
rlang::check_number_whole(audio_input, min = 0, allow_null = TRUE)
for (i in seq_len(nrow(jobs))) {
if (!is.null(audio_rows[[i]])) {
# Locator only when `audio_input` itself arrives as a column: a scalar `audio_input`
# against a uniform `inputs` column offends on every row, and naming
# row 1 misleads — the pip sibling gates the same way (M66 review F1).
check_batch_cell(
if ("audio_input" %in% names(jobs)) i else NA_integer_,
rlang::check_number_whole(audio_rows[[i]], min = 0,
max = length(jobs$inputs[[i]]) - 1,
arg = "audio_input")
)
}
}
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame, so every check above still
# reports first. The sweep covers each distinct family a `video_codec` column
# spells, never only the argument's.
# `quality`, argument and column, against each row's resolved encoder
# (M136); above the probe (D036), naming this verb and the row (D076).
check_batch_quality(
jobs, quality,
batch_arg_rows(jobs, "video_codec", video_codec, batch_codec_cell),
hardware
)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(inputs, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
aud <- pick("audio_input", audio_input)
if (length(aud) == 1L && is.na(aud)) aud <- NULL
# The index needs no re-check here: the front door sweeps every row's
# value against that row's own input count (M59-D2 retires the copy).
compare_videos_pipeline(
inputs, output,
direction = pick("direction", direction),
resize = pick("resize", resize),
audio_input = aud,
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality))
)
},
run = run,
parallel = parallel,
...
)
}
# picture_in_picture_batch() -----------------------------------------------
#' Inset One Video Over Another For Many Outputs From a Jobs Table
#'
#' Composite an inset (overlay) video onto a main video for many outputs from a
#' single jobs tibble. This is the **batch** (table-driven) form of
#' [picture_in_picture()], for when you have more than one to produce. Its two
#' inputs have distinct roles, so \code{jobs} carries fixed \code{main} and
#' \code{overlay} columns (not a list-column) plus an \code{output} column.
#' This is a thin wrapper over \code{\link{ffm_batch}}: one reproducible overlay
#' command per row, sharing the pipeline with [picture_in_picture()]. The
#' glossary in \code{vignette("tidymedia")} explains media terms such as codec,
#' encoder and stream copy.
#'
#' @param jobs A data frame with one row per output and (at least) \code{main}
#' (background path), \code{overlay} (inset path), and \code{output}
#' (destination path) columns. Optional \code{position}, \code{scale},
#' \code{margin}, \code{audio_input}, \code{video_codec}, and \code{audio_codec}
#' columns override the
#' like-named arguments
#' per row (a row omitting one falls back to the argument).
#' `r fan_in_na_sentences()` A numeric \code{quality} column overrides the
#' \code{quality} argument per row (see \code{quality}). Two rows given the
#' same \code{output}
#' path are refused before any row runs; other columns are ignored.
#' @param position,scale,margin Defaults applied to every row lacking the
#' corresponding column. \code{position} is one of \code{"topright"} (the
#' default), \code{"topleft"}, \code{"bottomright"}, \code{"bottomleft"} or
#' \code{"center"}; a \code{position} column is held to those same five
#' values, per row. See [picture_in_picture()] for their fuller meaning.
#' @param audio_input `r audio_input_param(batch = TRUE)`
#' @param hardware,fallback `r batch_hardware_param("picture_in_picture")`
#' `r hardware_probe_sentences()` `r encoder_check_sentences()`
#' `r contradiction_sentences("audio_codec")`
#' A per-row value error likewise reports ahead of the encoder check. Examples
#' are a negative \code{margin}, an \code{audio_input} index outside the two
#' inputs, and a \code{position} outside the five accepted values.
#' `r value_error_order_sentences()`
#' @param quality `r batch_quality_param("picture_in_picture")`
#' @inheritParams compare_videos_batch
#' @return `r jobs_return()`
#' @seealso [picture_in_picture()], the one-output function it wraps;
#' [ffm_batch()], the batch runner; [has_hardware_encoder()] for the
#' \code{hardware} argument. [concatenate_videos_batch()] and
#' [compare_videos_batch()], the other batch functions that take several
#' inputs per row.
#' @family task functions
#' @family audio selection functions
#' @examples
#' video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#' jobs <- tibble::tibble(main = video, overlay = video, output = "pip.mp4")
#' picture_in_picture_batch(jobs, run = FALSE)
#' @export
picture_in_picture_batch <- function(jobs,
position = c("topright", "topleft",
"bottomright", "bottomleft",
"center"),
scale = 0.25, margin = 16, audio_input = NULL,
video_codec = NULL, audio_codec = "copy",
hardware = c("none", "nvenc",
"videotoolbox"),
fallback = FALSE, quality = NULL,
run = TRUE, parallel = FALSE, ...) {
# `position`, `margin` and `audio_input` are checked BELOW the contradiction sweep
# (M61); see there. `scale`'s TYPE check stays here, where it has always
# been; its RANGE is swept below the contradiction sweep with the other
# per-value guards (M65).
rlang::check_number_decimal(scale)
check_token(video_codec, allow_null = TRUE)
check_token(audio_codec, allow_null = TRUE)
hardware <- rlang::arg_match(hardware)
# Fixed two-input shape (D015): main/overlay are distinct roles, so named
# columns rather than a list-column — validated inline (parity with the
# scalar's two required inputs), not via check_fanin_jobs().
if (!is.data.frame(jobs)) {
cli::cli_abort("{.arg jobs} must be a data frame with one row per output.")
}
if (nrow(jobs) == 0) {
cli::cli_abort("{.arg jobs} must have at least one row.")
}
cols <- c("main", "overlay", "output")
missing <- setdiff(cols, names(jobs))
if (length(missing) > 0) {
cli::cli_abort(c(
"{.arg jobs} must have {.field main}, {.field overlay}, and {.field output} columns.",
"x" = "Missing column{?s}: {.val {missing}}."
))
}
for (col in cols) {
jobs[[col]] <- as.character(jobs[[col]])
if (anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must not contain {.val {NA}}.")
}
}
jobs <- reject_duplicate_outputs(jobs)
# Validate present override columns up front. scale/margin are required values
# (no NA); audio_input may be NA (means "drop audio"). Per-value checks (position
# vocabulary, audio range) are inherited per row below / from the pipeline.
check_batch_string_col(jobs, "position")
for (col in intersect(c("scale", "margin"), names(jobs))) {
if (!is.numeric(jobs[[col]]) || anyNA(jobs[[col]])) {
cli::cli_abort("The {.field {col}} column of {.arg jobs} must be numeric (no {.val {NA}}).")
}
}
check_batch_audio_col(jobs)
check_batch_codec_col(jobs)
check_batch_codec_col(jobs, "audio_codec")
# Sweep both role columns here, below the shape/type guards above and
# before the contradiction sweep below, so a missing input blames this verb
# rather than purrr::pmap() (M62). ONE call over both columns, never one per
# column: a row missing both files must name both, as the pipeline's own
# refusal did before this guard existed (M62 review F2).
check_batch_inputs(jobs, c("main", "overlay"))
# Thin Layer-2 fan-in over ffm_batch (D007/D015): one overlay pipeline per row,
# sharing picture_in_picture_pipeline() with picture_in_picture(). A per-row
# override column (via `...` from pmap) wins over the scalar arg; an `audio_input`
# cell of NA means "drop audio" (the column form of the scalar's NULL).
#
# Condition 6, re-checked here so a contradictory call blames this verb
# instead of purrr::pmap() (M58). Swept ROW BY ROW: both `audio_input` and
# `audio_codec` can arrive as columns. The hint is this verb's own -- the
# checker is shared with compare_videos_batch(), whose inputs are open-ended
# where these are the two fixed roles (D015).
audio_rows <- batch_arg_rows(jobs, "audio_input", audio_input, batch_stream_cell)
acodec_rows <- batch_arg_rows(jobs, "audio_codec", audio_codec,
batch_codec_cell)
audio_cols <- any(c("audio_input", "audio_codec") %in% names(jobs))
for (i in seq_len(nrow(jobs))) {
check_batch_cell(
if (audio_cols) i else NA_integer_,
check_audio_codec_needs_audio(
audio_rows[[i]], acodec_rows[[i]],
hint = "Pass {.arg audio_input} {.val {0}} for the main video's audio or
{.val {1}} for the overlay's, or drop {.arg audio_codec}."
)
)
}
# `position`, `margin` and `audio_input`, both forms, BELOW the contradiction sweep
# (M61). Each scalar guard is checked here rather than at the top of the verb,
# so a call wrong in both one of these values and the contradiction is told
# about the contradiction whichever form the bad value arrived in. None of the
# three is redundant with the sweep beside it: a sweep reads that column over
# the argument, so it never sees a bad argument a column overrides, and
# dropping the scalar guard would let a call that is refused today compile.
position <- check_vocab_arg(position, pip_positions(), "position")
# Per-row `position` VALUES (M59 site 6). check_batch_string_col() above
# covers that column's TYPE only, so an out-of-vocabulary cell used to reach
# picture_in_picture_pipeline()'s own check inside the fan-out and be reported
# against purrr::pmap() -- additionally leaking the pipeline's name. The
# vocabulary is pip_positions()'s, never a copy (M59-D2).
check_batch_vocab_col(jobs, "position", position, pip_positions())
# Per-row `margin` VALUES (M59 site 2). The column guard above covers the
# column's TYPE; a negative cell used to reach the fan-out closure's own
# re-check and be reported against purrr::pmap(). AFTER the contradiction
# sweep above, deliberately: a call whose value error arrives in a `jobs`
# column and which also contradicts itself reports the contradiction (D036's
# ordering), and the scalar guard beside it now answers the same way (M61).
rlang::check_number_whole(margin, min = 0)
margin_rows <- batch_arg_rows(jobs, "margin", margin)
for (i in seq_along(margin_rows)) {
check_batch_cell(if ("margin" %in% names(jobs)) i else NA_integer_,
rlang::check_number_whole(margin_rows[[i]], min = 0,
arg = "margin"))
}
# Per-row `audio_input` VALUES. The two inputs are fixed roles (D015), so the bound
# is a constant 0..1 rather than compare_videos_batch()'s per-row input count
# -- but the check was still made only inside the fan-out closure, where it
# reported against purrr::pmap() with the closure's local name `aud` (M59
# review F7). It runs at the front door now, naming `audio_input`, and the closure's
# copy retires with it. Same placement and the same reason as `margin` above.
rlang::check_number_whole(audio_input, min = 0, max = 1, allow_null = TRUE)
pip_audio_rows <- batch_arg_rows(jobs, "audio_input", audio_input, batch_stream_cell)
for (i in seq_along(pip_audio_rows)) {
if (!is.null(pip_audio_rows[[i]])) {
check_batch_cell(if ("audio_input" %in% names(jobs)) i else NA_integer_,
rlang::check_number_whole(pip_audio_rows[[i]], min = 0,
max = 1, arg = "audio_input"))
}
}
# Per-row `scale` RANGE values (M65), last among the value guards. The type
# checks above cover the argument and the column; the RANGE lived only in
# ffm_overlay(), so an out-of-range value was reported against purrr::pmap()
# from inside the fan-out. Same shared checker (one binding, one wording),
# same placement logic as `margin` and `audio_input` above: below the
# contradiction sweep (M61), above the nvenc probe -- a machine-independent
# refusal reports before a machine-dependent one (D036, M64-D2's shape).
scale_rows <- batch_arg_rows(jobs, "scale", scale)
for (i in seq_along(scale_rows)) {
check_batch_cell(if ("scale" %in% names(jobs)) i else NA_integer_,
check_overlay_scale(scale_rows[[i]]))
}
# nvenc availability, re-checked here so an unavailable encoder blames this
# verb instead of purrr::pmap() (M57/D035). Immediately before ffm_batch(),
# which is where M41 puts a guard added for blame, so every check above still
# reports first. The sweep covers each distinct family a `video_codec` column
# spells, never only the argument's.
# `quality`, argument and column, against each row's resolved encoder
# (M136); above the probe (D036), naming this verb and the row (D076).
check_batch_quality(
jobs, quality,
batch_arg_rows(jobs, "video_codec", video_codec, batch_codec_cell),
hardware
)
check_hardware_available(batch_video_codecs(jobs, video_codec), hardware,
fallback)
# D074: the verb the caller typed names a bad limit, not the builder below.
resolve_timeout()
ffm_batch(
jobs,
function(main, overlay, output, ...) {
dots <- list(...)
pick <- function(nm, default) if (nm %in% names(dots)) dots[[nm]] else default
# The resolved margin needs no re-check here: the front door sweeps every
# row's value through the same batch_arg_rows() resolution this pick()
# applies, so a re-check could never fire (M59-D2 retires it).
mrg <- pick("margin", margin)
aud <- pick("audio_input", audio_input)
if (length(aud) == 1L && is.na(aud)) aud <- NULL
# The index needs no re-check here: the front door sweeps every row's
# value through the same batch_arg_rows() resolution this pick() applies
# (M61 retires the copy, as M59-D2 did for `margin`).
picture_in_picture_pipeline(
main, overlay, output,
position = pick("position", position),
scale = pick("scale", scale),
margin = mrg,
audio_input = aud,
video_codec = batch_codec_cell(pick("video_codec", video_codec)),
audio_codec = batch_codec_cell(pick("audio_codec", audio_codec)),
hardware = hardware,
fallback = fallback,
# An NA cell is the column form of NULL (D022).
quality = batch_stream_cell(pick("quality", quality))
)
},
run = run,
parallel = parallel,
...
)
}
# Get volume levels -------------------------------------------------------
get_volume <- function(infile) {
rlang::check_string(infile)
command <- glue::glue('-i {infile} -af "volumedetect" -vn -sn -dn -f null NUL')
output <- ffmpeg(command)
#TODO: Clean up output
mean_volumes <- regmatches(
output,
regexpr("^\\[Parsed_volumedetect.*mean_volume.*", output, perl=TRUE)
)
max_volumes <- regmatches(
output,
regexpr("^\\[Parsed_volumedetect.*max_volume.*", output, perl=TRUE)
)
output
}
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.