tests/testthat/test-separate-av-codec.R

# M37: per-stream audio_codec / video_codec on separate_audio_video() and its
# _batch sibling, subsuming the old `reencode` switch. `"copy"` (the default)
# reproduces the pre-M37 `reencode = FALSE` command byte-for-byte, NULL is
# D016's sentinel reproducing `reencode = TRUE`, and a named encoder pins that
# stream's encoder. Command construction is tested purely (run = FALSE);
# execution is binary-gated.

# The pre-M37 commands, captured from the default branch before the swap. The
# input path is the only variable, so parity is asserted byte-for-byte rather
# than by substring (AC1, AC2).
pre_m37 <- function(input) {
  list(
    copy_audio  = sprintf('-y -i "%s" -codec:a copy -map "0:a" "a.aac"', input),
    copy_video  = sprintf('-y -i "%s" -codec:v copy -map "0:v" "v.mp4"', input),
    unset_audio = sprintf('-y -i "%s" -map "0:a" "a.aac"', input),
    unset_video = sprintf('-y -i "%s" -map "0:v" "v.mp4"', input)
  )
}

# AC1: the default call reproduces reencode = FALSE ------------------------

test_that("separate_audio_video() defaults reproduce the pre-M37 stream copy byte for byte", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", run = FALSE)
  ref <- pre_m37(f)
  expect_equal(unname(cmds[["audio"]]), ref$copy_audio)
  expect_equal(unname(cmds[["video"]]), ref$copy_video)
})

test_that("separate_audio_video(audio_codec = 'copy', video_codec = 'copy') is the default", {
  f <- make_input()
  expect_equal(
    separate_audio_video(f, "a.aac", "v.mp4", audio_codec = "copy",
                         video_codec = "copy", run = FALSE),
    separate_audio_video(f, "a.aac", "v.mp4", run = FALSE)
  )
})

# AC2: the NULL sentinel reproduces reencode = TRUE ------------------------

test_that("separate_audio_video(audio_codec = NULL, video_codec = NULL) emits no -codec", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", audio_codec = NULL,
                               video_codec = NULL, run = FALSE)
  ref <- pre_m37(f)
  expect_equal(unname(cmds[["audio"]]), ref$unset_audio)
  expect_equal(unname(cmds[["video"]]), ref$unset_video)
})

test_that("separate_audio_video() unsets one stream's codec independently", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", audio_codec = NULL,
                               run = FALSE)
  ref <- pre_m37(f)
  # Audio unset, video still on the default copy.
  expect_equal(unname(cmds[["audio"]]), ref$unset_audio)
  expect_equal(unname(cmds[["video"]]), ref$copy_video)
})

# AC3: a named encoder reaches its own stream and only that one ------------

test_that("separate_audio_video() routes each codec to its own stream's command", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.m4a", "v.mp4", audio_codec = "aac",
                               video_codec = "libx264", run = FALSE)
  expect_match(cmds[["audio"]], "-codec:a aac", fixed = TRUE)
  expect_match(cmds[["video"]], "-codec:v libx264", fixed = TRUE)
  # The audio choice never reaches the video command, nor the reverse: neither
  # the other stream's encoder name nor a cross-slot -codec flag appears. Assert
  # on the codec slot, not the bare encoder name — the command embeds a random
  # hex tempfile path, and "aac" is three hex digits (M37 review).
  expect_no_match(cmds[["audio"]], "libx264", fixed = TRUE)
  expect_no_match(cmds[["audio"]], "-codec:v", fixed = TRUE)
  expect_no_match(cmds[["video"]], "-codec:v aac", fixed = TRUE)
  expect_no_match(cmds[["video"]], "-codec:a", fixed = TRUE)
})

# AC4: `reencode` is gone from both verbs ---------------------------------

test_that("separate_audio_video() has no reencode argument", {
  f <- make_input()
  # No `...` on the scalar verb, so R rejects the retired argument itself.
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", reencode = TRUE, run = FALSE),
    "unused argument"
  )
  expect_false("reencode" %in% names(formals(separate_audio_video)))
})

test_that("separate_audio_video_batch() aborts on the retired reencode argument", {
  f <- make_input()
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4")
  # The batch verb's `...` forwards ffm_batch options, so R would swallow
  # `reencode` silently and stream-copy output the caller asked to re-encode.
  # The guard names the replacement instead (M37).
  expect_error(
    separate_audio_video_batch(jobs, reencode = TRUE, run = FALSE),
    "audio_codec"
  )
  expect_error(
    separate_audio_video_batch(jobs, reencode = FALSE, run = FALSE),
    "was removed"
  )
  expect_false("reencode" %in% names(formals(separate_audio_video_batch)))
})

test_that("separate_audio_video() rejects a non-string codec on either stream", {
  f <- make_input()
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", audio_codec = 1, run = FALSE)
  )
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", video_codec = 1, run = FALSE)
  )
  # check_token() rejects a value carrying shell metacharacters.
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", audio_codec = "aac; rm -rf /",
                         run = FALSE)
  )
})

# AC6: execution — copy preserves the source codec, a name transcodes -------

test_that("separate_audio_video() copy preserves each source codec (binary-gated)", {
  skip_if_no_ffmpeg()
  skip_if_no_ffprobe()
  # MP3-in-MP4: the source audio codec is NOT the MP4 container's default, so a
  # stream copy is distinguishable from a re-encode by the output alone (M35).
  input <- make_mp3_audio_video()
  expect_equal(probe_audio(infile = input)$codec_name, "mp3")

  dir <- withr::local_tempdir()
  audiofile <- file.path(dir, "a.mp4")
  videofile <- file.path(dir, "v.mp4")
  separate_audio_video(input, audiofile, videofile)   # defaults: copy both
  expect_equal(probe_audio(infile = audiofile)$codec_name, "mp3")
  expect_equal(probe_video(infile = videofile)$codec_name,
               probe_video(infile = input)$codec_name)
})

test_that("separate_audio_video() transcodes the stream whose codec is named (binary-gated)", {
  skip_if_no_ffmpeg()
  skip_if_no_ffprobe()
  input <- make_mp3_audio_video()
  dir <- withr::local_tempdir()
  audiofile <- file.path(dir, "a.m4a")
  videofile <- file.path(dir, "v.mp4")
  # Naming an encoder on one stream re-encodes it; the other keeps its default
  # copy, so the two arguments are observably independent end to end.
  separate_audio_video(input, audiofile, videofile, audio_codec = "aac")
  expect_equal(probe_audio(infile = audiofile)$codec_name, "aac")
  expect_equal(probe_video(infile = videofile)$codec_name,
               probe_video(infile = input)$codec_name)
})

test_that("separate_audio_video() NULL hands the codec to the container (binary-gated)", {
  skip_if_no_ffmpeg()
  skip_if_no_ffprobe()
  input <- make_mp3_audio_video()
  dir <- withr::local_tempdir()
  audiofile <- file.path(dir, "a.mp4")
  videofile <- file.path(dir, "v.mp4")
  # The pre-M37 `reencode = TRUE` behavior: no -codec:a, so MP4's default
  # encoder decides and the source mp3 becomes aac.
  separate_audio_video(input, audiofile, videofile, audio_codec = NULL)
  expect_equal(probe_audio(infile = audiofile)$codec_name, "aac")
})

test_that("separate_audio_video_batch() honors a per-row codec column end to end (binary-gated)", {
  skip_if_no_ffmpeg()
  skip_if_no_ffprobe()
  input <- make_mp3_audio_video()
  dir <- withr::local_tempdir()
  jobs <- tibble::tibble(
    input       = c(input, input),
    audiofile   = file.path(dir, c("a1.mp4", "a2.m4a")),
    videofile   = file.path(dir, c("v1.mp4", "v2.mp4")),
    audio_codec = c("copy", "aac")
  )
  res <- separate_audio_video_batch(jobs)
  expect_true(all(res$success))
  # Row 1 copied the mp3 through; row 2 transcoded it to aac.
  expect_equal(probe_audio(infile = jobs$audiofile[[1]])$codec_name, "mp3")
  expect_equal(probe_audio(infile = jobs$audiofile[[2]])$codec_name, "aac")
})

# M37 review findings: stale spellings and argument validation -------------

test_that("separate_audio_video_batch() aborts on a stale reencode column", {
  f <- make_input()
  # The reshape builds its own table and would drop an unknown `jobs` column, so
  # a jobs table migrated from the pre-M37 API would stream-copy silently where
  # it asked to re-encode. The column form is guarded like the argument form.
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4",
                         reencode = TRUE)
  expect_error(separate_audio_video_batch(jobs, run = FALSE), "jobs column")
  expect_error(separate_audio_video_batch(jobs, run = FALSE), "audio_codec")
})

test_that("separate_audio_video_batch() names both stale reencode spellings at once", {
  f <- make_input()
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4",
                         reencode = TRUE)
  # Argument and column together: one message, both spellings named (a 2+ item
  # cli interpolation, which is where the M18 plural crash used to surface).
  expect_error(
    separate_audio_video_batch(jobs, reencode = TRUE, run = FALSE),
    "argument and jobs column"
  )
})

test_that("separate_audio_video_batch() rejects a codec argument through the dots", {
  f <- make_input()
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4")
  # `codec` names the internal per-stream column; forwarded through `...` it
  # would reach pmap and set both streams' codec at once.
  expect_error(
    separate_audio_video_batch(jobs, codec = "libmp3lame", run = FALSE),
    "not an argument"
  )
})

test_that("separate_audio_video_batch() validates its codec args whether or not a column is present", {
  f <- make_input()
  bare <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4")
  withcol <- bare
  withcol$audio_codec <- "copy"
  # The same bad argument must be caught in both shapes: before the fix, a
  # present codec column materialized the argument straight into the reshaped
  # column and skipped the type check entirely.
  for (jobs in list(bare, withcol)) {
    expect_error(separate_audio_video_batch(jobs, video_codec = TRUE, run = FALSE))
    expect_error(separate_audio_video_batch(jobs, video_codec = 1, run = FALSE))
    expect_error(separate_audio_video_batch(jobs, video_codec = NA, run = FALSE))
    expect_error(
      separate_audio_video_batch(jobs, video_codec = c("a", "b"), run = FALSE)
    )
    expect_error(separate_audio_video_batch(jobs, audio_codec = TRUE, run = FALSE))
  }
  # NULL stays legal on both: it is the sentinel, not a bad value.
  expect_no_error(
    separate_audio_video_batch(withcol, video_codec = NULL, run = FALSE)
  )
})

# M38: hardware = "nvenc" on the video output ------------------------------
#
# nvenc encodes video, so `hardware`/`fallback` reach the video branch of
# separate_stream_pipeline() and never the audio one. Availability is simulated
# through the `tidymedia.hardware_encoders` option seam has_hardware_encoder() consults, so
# every compile test here is GPU-free; the real encode is skip-gated below.

test_that("separate_audio_video() defaults compile no hardware options", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", run = FALSE)
  ref <- pre_m37(f)
  # hardware = "none" is the default, so the pre-M38 commands are unchanged.
  expect_equal(unname(cmds[["audio"]]), ref$copy_audio)
  expect_equal(unname(cmds[["video"]]), ref$copy_video)
  expect_no_match(cmds[["video"]], "nvenc", fixed = TRUE)
})

test_that("separate_audio_video(hardware = 'none') matches the argument-free call", {
  f <- make_input()
  for (vc in list(NULL, "libx264")) {
    expect_equal(
      separate_audio_video(f, "a.aac", "v.mp4", video_codec = vc,
                           hardware = "none", run = FALSE),
      separate_audio_video(f, "a.aac", "v.mp4", video_codec = vc, run = FALSE)
    )
  }
})

test_that("separate_audio_video(hardware = 'nvenc') compiles to the nvenc encoder", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                               hardware = "nvenc", run = FALSE)
  expect_match(cmds[["video"]], "-codec:v h264_nvenc", fixed = TRUE)
})

test_that("separate_audio_video(hardware = 'nvenc') respects the video_codec family", {
  withr::local_options(tidymedia.hardware_encoders = c("h264_nvenc", "hevc_nvenc"))
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", video_codec = "libx265",
                               hardware = "nvenc", run = FALSE)
  expect_match(cmds[["video"]], "-codec:v hevc_nvenc", fixed = TRUE)
})

test_that("separate_audio_video() hardware never reaches the audio command", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  ref <- pre_m37(f)
  # nvenc encodes video only, so the audio output stays byte-identical across
  # every hardware/fallback combination -- neither argument reaches that branch.
  for (hw in c("none", "nvenc")) {
    for (fb in c(FALSE, TRUE)) {
      cmds <- separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                                   hardware = hw, fallback = fb, run = FALSE)
      expect_equal(unname(cmds[["audio"]]), ref$copy_audio)
      expect_no_match(cmds[["audio"]], "nvenc", fixed = TRUE)
    }
  }
})

test_that("separate_audio_video(hardware = 'nvenc') aborts when unavailable", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  f <- make_input()
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                         hardware = "nvenc", run = FALSE),
    "not available"
  )
})

test_that("separate_audio_video() fallback keeps the sentinel, never injects a codec", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  f <- make_input()
  expect_message(
    cmds <- separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                                 hardware = "nvenc", fallback = TRUE,
                                 run = FALSE),
    "container"
  )
  expect_equal(unname(cmds[["video"]]), pre_m37(f)$unset_video)
})

test_that("separate_audio_video() rejects an unknown hardware value", {
  f <- make_input()
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                         hardware = "gpu", run = FALSE),
    class = "rlang_error"
  )
})

test_that("separate_audio_video_batch(hardware = 'nvenc') applies nvenc to video rows only", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  jobs <- tibble::tibble(input = c(f, f), audiofile = c("a1.aac", "a2.aac"),
                         videofile = c("v1.mp4", "v2.mp4"))
  res <- separate_audio_video_batch(jobs, video_codec = NULL,
                                    hardware = "nvenc", run = FALSE)
  expect_true(all(grepl("-codec:v h264_nvenc",
                        res$command[res$stream == "video"], fixed = TRUE)))
  expect_false(any(grepl("nvenc", res$command[res$stream == "audio"],
                         fixed = TRUE)))
})

test_that("separate_audio_video_batch() ignores a per-row hardware column (batch-wide)", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  jobs <- tibble::tibble(input = c(f, f), audiofile = c("a1.aac", "a2.aac"),
                         videofile = c("v1.mp4", "v2.mp4"),
                         hardware = c("nvenc", "nvenc"))
  # hardware is a property of the machine, not of a row (D016), so the column is
  # ignored like any other unrecognized one and the scalar default wins.
  res <- separate_audio_video_batch(jobs, run = FALSE)
  expect_false(any(grepl("nvenc", res$command, fixed = TRUE)))
})

# M38: GPU encoding conflicts with this verb's stream-copy default ----------
#
# video_codec defaults to "copy" here (D020), unlike the four M34 verbs whose
# default is D016's NULL sentinel. A copy runs no encoder, so a GPU request on
# that path cannot mean anything and aborts (D008/D016) -- rather than reaching
# codec_family("copy"), which would blame the codec name instead of the copy.

test_that("separate_audio_video(hardware = 'nvenc') aborts on the copy default", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  # The message names the cause and the fix, not codec_family()'s "No nvenc
  # encoder maps to that codec", which points at the wrong thing entirely.
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", hardware = "nvenc", run = FALSE),
    "stream-copies"
  )
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", hardware = "nvenc", run = FALSE),
    "video_codec"
  )
  msg <- tryCatch(
    separate_audio_video(f, "a.aac", "v.mp4", hardware = "nvenc", run = FALSE),
    error = conditionMessage
  )
  expect_no_match(msg, "No nvenc encoder")
  # Under hardware = "nvenc" the NULL sentinel assumes H.264 -- it does NOT hand
  # the choice to the container, so the hint must not say so or it walks a .webm
  # caller into an h264_nvenc-in-WebM command FFmpeg rejects (M38 review).
  expect_match(msg, "H.264", fixed = TRUE)
  expect_no_match(msg, "container choose", fixed = TRUE)
})

test_that("separate_audio_video(hardware = 'nvenc') aborts on an explicit copy", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  expect_error(
    separate_audio_video(f, "a.aac", "v.mp4", video_codec = "copy",
                         hardware = "nvenc", run = FALSE),
    "stream-copies"
  )
})

test_that("separate_audio_video() copies audio happily under nvenc", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  # Only the VIDEO copy conflicts: the audio stream is never encoded on the GPU,
  # so the default audio_codec = "copy" stays legal alongside hardware = "nvenc".
  expect_no_error(
    separate_audio_video(f, "a.aac", "v.mp4", video_codec = NULL,
                         hardware = "nvenc", run = FALSE)
  )
})

test_that("separate_audio_video() copy is fine without a hardware request", {
  f <- make_input()
  # hardware = "none" is not a GPU request, so the copy default stays untouched.
  expect_no_error(
    separate_audio_video(f, "a.aac", "v.mp4", hardware = "none", run = FALSE)
  )
})

test_that("separate_audio_video_batch(hardware = 'nvenc') aborts on the copy default", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4")
  expect_error(
    separate_audio_video_batch(jobs, hardware = "nvenc", run = FALSE),
    "stream-copies"
  )
})

test_that("separate_audio_video_batch() fires the copy guard per row", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  # hardware is batch-wide, so one stream-copy row conflicts with it on its own:
  # a table mixing copy and re-encode rows must be split into separate calls.
  jobs <- tibble::tibble(
    input       = c(f, f),
    audiofile   = c("a1.aac", "a2.aac"),
    videofile   = c("v1.mp4", "v2.mp4"),
    video_codec = c("libx264", "copy")
  )
  expect_error(
    separate_audio_video_batch(jobs, hardware = "nvenc", run = FALSE),
    "stream-copies"
  )
  # Every row re-encoding is fine -- the guard is about the copy, not the table.
  jobs$video_codec <- c("libx264", "libx264")
  expect_no_error(
    separate_audio_video_batch(jobs, hardware = "nvenc", run = FALSE)
  )
})

test_that("separate_audio_video_batch() treats an NA codec cell as the sentinel under nvenc", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  f <- make_input()
  # NA is the column form of the NULL sentinel, not a copy, so it resolves to
  # the h264 nvenc encoder rather than tripping the guard.
  jobs <- tibble::tibble(input = f, audiofile = "a.aac", videofile = "v.mp4",
                         video_codec = NA)
  res <- separate_audio_video_batch(jobs, hardware = "nvenc", run = FALSE)
  expect_match(res$command[res$stream == "video"], "-codec:v h264_nvenc",
               fixed = TRUE)
})

Try the tidymedia package in your browser

Any scripts or data that you put into this service are public.

tidymedia documentation built on Oct. 11, 2026, 5:08 p.m.