tests/testthat/test-ffmpeg.R

# Tests for the ffmpeg-backed task functions. Command-construction paths that
# expose a run = FALSE switch are tested purely; everything else is gated on the
# ffmpeg binary being available.

test_that("segment_video() returns a job tibble with one accurate-cut command per segment", {
  f <- make_input()
  res <- segment_video(f, c(0, 5), c(5, 10), outfiles = c("a.mp4", "b.mp4"), run = FALSE)
  expect_s3_class(res, "tbl_df")
  expect_equal(nrow(res), 2)
  # Default reencode = TRUE: accurate output-seek (-ss/-to after -i), no copy.
  # The seek and the output are no longer adjacent: M48's stated map sits
  # between them (D026).
  expect_match(res$command[[1]], '-ss 0 -to 5 -map "0:v?" -map "0:a?" "a.mp4"',
               fixed = TRUE)
  expect_no_match(res$command[[1]], "-codec:v copy", fixed = TRUE)
})

test_that("segment_video(reencode = FALSE) uses the fast copy path", {
  f <- make_input()
  res <- segment_video(f, 0, 5, outfiles = "clip.mp4", reencode = FALSE, run = FALSE)
  expect_match(res$command[[1]], "-ss 0 -to 5 -i", fixed = TRUE)
  expect_match(res$command[[1]], "-codec:v copy -codec:a copy", fixed = TRUE)
  expect_match(res$command[[1]], "-avoid_negative_ts make_zero", fixed = TRUE)
})

test_that("segment_video() defaults outfiles from the input name", {
  f <- make_input()
  res <- segment_video(f, c(0), c(5), run = FALSE)
  expect_match(res$output[[1]], "_1", fixed = TRUE)
})

test_that("segment_video() rejects mismatched timestamp lengths", {
  f <- make_input()
  expect_error(segment_video(f, c(0, 5), c(5)), "same length")
})

# ffmpeg() (Layer 0 escape hatch) ---------------------------------------------

test_that("ffmpeg() passes its command through and returns FFmpeg's output", {
  # The exported escape hatch had no test of its own until M46: every call in
  # the suite was fixture generation, and once those moved to
  # run_ffmpeg_fixture() nothing exercised it at all.
  skip_if_no_ffmpeg()
  out <- ffmpeg("-version")
  expect_type(out, "character")
  expect_match(out[[1]], "^ffmpeg version")
})

test_that("ffmpeg() rejects a command that is not a single string", {
  expect_error(ffmpeg(c("-version", "-hide_banner")), "single string")
  expect_error(ffmpeg(1), "single string")
  expect_error(ffmpeg(NULL), "single string")
})

test_that("ffmpeg_codecs() returns a tidy tibble", {
  skip_if_no_ffmpeg()
  cc <- ffmpeg_codecs()
  expect_s3_class(cc, "tbl_df")
  expect_setequal(
    names(cc),
    c("name", "details", "type", "decoding", "encoding",
      "intraframe", "lossy", "lossless")
  )
  expect_s3_class(cc$type, "factor")
  expect_type(cc$decoding, "logical")
  expect_gt(nrow(cc), 0)
})

test_that("ffmpeg_codecs() sort_by_type toggles ordering", {
  skip_if_no_ffmpeg()
  by_name <- ffmpeg_codecs(sort_by_type = FALSE)
  expect_false(is.unsorted(by_name$name))
})

test_that("ffmpeg_encoders() returns a tidy tibble", {
  skip_if_no_ffmpeg()
  ee <- ffmpeg_encoders()
  expect_s3_class(ee, "tbl_df")
  expect_setequal(
    names(ee),
    c("name", "details", "type", "frame_mt", "slice_mt",
      "experimental", "horiz_band", "direct_render")
  )
  expect_gt(nrow(ee), 0)
})

test_that("extract_audio() writes an audio file", {
  infile <- make_test_video()
  outfile <- withr::local_tempfile(fileext = ".aac")
  extract_audio(infile, outfile)
  expect_true(file.exists(outfile))
  expect_gt(file.size(outfile), 0)
})

test_that("segment_video() writes the segments when run", {
  infile <- make_test_video()
  outfiles <- withr::local_tempfile(fileext = ".mp4", pattern = c("a", "b"))
  segment_video(infile, c(0, 1), c(1, 2), outfiles = outfiles)
  expect_true(all(file.exists(outfiles)))
})

test_that("extract_frame() writes an image", {
  infile <- make_test_video()
  outfile <- withr::local_tempfile(fileext = ".png")
  extract_frame(infile, outfile, timestamp = 0.5)
  expect_true(file.exists(outfile))
  expect_gt(file.size(outfile), 0)
})

test_that("extract_frame() requires exactly one of timestamp/frame", {
  infile <- make_test_video()
  outfile <- withr::local_tempfile(fileext = ".png")
  expect_error(extract_frame(infile, outfile))
})

# Migrated single-output verbs: pure command tests (run = FALSE, no binary) ----

test_that("extract_audio() compiles to a copy + drop-video command", {
  f <- make_input()
  cmd <- extract_audio(f, "out.aac", run = FALSE)
  expect_match(cmd, "-codec:a copy -vn", fixed = TRUE)
  # M43: the map is explicit on every call. Without it FFmpeg picked the track
  # carrying the container's DEFAULT disposition, so which track came out
  # depended on the file's flags rather than on the caller.
  expect_match(cmd, "-vn -map \"0:a:0\"", fixed = TRUE)
  expect_match(cmd, '"out.aac"', fixed = TRUE)
})

test_that("extract_audio(audio_codec=) sets the audio codec", {
  f <- make_input()
  cmd <- extract_audio(f, "out.m4a", audio_codec = "aac", run = FALSE)
  expect_match(cmd, "-codec:a aac -vn", fixed = TRUE)
})

test_that("convert_audio() default (audio_codec = NULL) compiles to -q:a 0 -map \"0:a:0\"", {
  f <- make_input()
  cmd <- convert_audio(f, "out.mp3", run = FALSE)
  expect_match(cmd, "-q:a 0 -map \"0:a:0\"", fixed = TRUE)
})

test_that("convert_audio(audio_codec=) pins -codec:a and drops -q:a", {
  f <- make_input()
  cmd <- convert_audio(f, "out.m4a", audio_codec = "aac", run = FALSE)
  expect_match(cmd, "-codec:a aac", fixed = TRUE)
  expect_match(cmd, "-map \"0:a:0\"", fixed = TRUE)
  expect_no_match(cmd, "-q:a", fixed = TRUE)
})

test_that("convert_audio() commands match the pre-M40 rename apart from the map", {
  # M40 renamed `format` to `audio_codec` without touching the recipe, so both
  # branches compiled exactly what the pre-rename verb compiled. The map is the
  # one deliberate departure since: `-map a` was replaced by `-map 0:a:0` to stop
  # a multi-track input feeding every stream to a single-stream muxer. Every
  # other token below is still the command recorded from master before the
  # rename, not a re-derivation from the current code, which is what makes this
  # a byte-identity check and not a tautology.
  f <- make_input()
  expect_identical(
    convert_audio(f, "out.mp3", run = FALSE),
    paste0('-y -i "', f, '" -q:a 0 -map "0:a:0" "out.mp3"')
  )
  expect_identical(
    convert_audio(f, "out.m4a", audio_codec = "aac", run = FALSE),
    paste0('-y -i "', f, '" -codec:a aac -map "0:a:0" "out.m4a"')
  )
})

test_that("convert_audio() takes exactly one track from a multi-track input (binary-gated)", {
  # Regression: the recipe emitted `-map a`, which maps EVERY audio stream. On a
  # three-track input the mp3 muxer refused it ("Exactly one MP3 audio stream is
  # required"), FFmpeg exited 65514, and a zero-byte output was left on disk --
  # while the documented contract is singular ("Maps the audio stream of infile
  # into outfile"). A single-track input never exposed this, so the fixture must
  # carry more than one track for the test to discriminate.
  skip_if_no_ffprobe()
  f <- make_multitrack_video()
  out <- withr::local_tempfile(fileext = ".mp3")
  expect_no_error(convert_audio(f, out))
  expect_gt(file.size(out), 0)
  expect_equal(nrow(probe_audio(infile = out)), 1L)
})

test_that("convert_audio() maps the first audio stream deterministically", {
  # The pure counterpart to the execution test above: `-map a` is unbounded,
  # `-map 0:a:0` names one stream. Pinning the emitted map here means a recipe
  # change is caught without the binaries. `audio_stream` selection is M43's;
  # this only fixes WHICH single stream is taken to a stated one.
  f <- make_input()
  expect_match(convert_audio(f, "out.mp3", run = FALSE), "-map \"0:a:0\"", fixed = TRUE)
  expect_no_match(convert_audio(f, "out.mp3", run = FALSE), "-map \"a\"", fixed = TRUE)
})

test_that("convert_audio() rejects the retired `format` argument", {
  # The scalar verb has no `...`, so R's own `unused argument` is the guard --
  # no cli_abort of ours (M37 lesson). Assert the retired spelling really is an
  # error rather than being silently swallowed.
  f <- make_input()
  expect_error(
    convert_audio(f, "out.m4a", format = "aac", run = FALSE),
    "unused argument"
  )
})

test_that("crop_video() compiles to a crop filter mapping all streams", {
  f <- make_input()
  cmd <- crop_video(f, "out.mp4", width = 100, height = 50, x = 0, y = 0, run = FALSE)
  # The filter and the map are no longer adjacent: the default audio_codec
  # emits -codec:a copy between them (M35/D017).
  expect_match(cmd, '-vf "crop=w=100:h=50:x=0:y=0"', fixed = TRUE)
  expect_match(cmd, '-map "0:v?" -map "0:a?" "out.mp4"', fixed = TRUE)
})

test_that("format_for_web() compiles to the web-friendly re-encode", {
  f <- make_input()
  cmd <- format_for_web(f, "out.mp4", run = FALSE)
  expect_match(cmd, "-codec:v libx264 -codec:a aac", fixed = TRUE)
  expect_match(cmd, "-pix_fmt yuv420p", fixed = TRUE)
  expect_match(cmd, "-movflags +faststart", fixed = TRUE)
  expect_match(cmd, "crop=w=floor(in_w/2)*2", fixed = TRUE)
})

test_that("standardize_video() compiles the full standardization command", {
  f <- make_input()
  cmd <- standardize_video(
    f, "out.mp4",
    width = 1280, height = 720, fps = 30,
    video_codec = "libx264", pixel_format = "yuv420p",
    run = FALSE
  )
  expect_equal(
    cmd,
    sprintf(
      paste(
        '-y -i "%s" -vf "scale=w=1280:h=720,fps=30"',
        "-codec:v libx264 -codec:a copy -pix_fmt yuv420p -movflags +faststart",
        # M47: the verb states its stream selection instead of inheriting
        # FFmpeg's. The `?` suffixes keep an input missing a stream type working.
        "-map \"0:v?\" -map \"0:a?\"",
        '"out.mp4"'
      ),
      f
    )
  )
})

test_that("standardize_video() preserves aspect when only one dimension is given", {
  f <- make_input()
  w_only <- standardize_video(f, "out.mp4", width = 640, run = FALSE)
  expect_match(w_only, '-vf "scale=w=640:h=-2', fixed = TRUE)
  h_only <- standardize_video(f, "out.mp4", height = 480, run = FALSE)
  expect_match(h_only, '-vf "scale=w=-2:h=480', fixed = TRUE)
})

test_that("standardize_video() forces exact dimensions when both are given", {
  f <- make_input()
  cmd <- standardize_video(f, "out.mp4", width = 640, height = 480, run = FALSE)
  expect_match(cmd, "scale=w=640:h=480", fixed = TRUE)
})

test_that("standardize_video() applies the even-dimension safeguard, not scale, when no dimension is given", {
  f <- make_input()
  cmd <- standardize_video(f, "out.mp4", fps = 25, run = FALSE)
  expect_no_match(cmd, "scale=", fixed = TRUE)
  expect_match(cmd, '-vf "crop=w=floor(in_w/2)*2:h=floor(in_h/2)*2', fixed = TRUE)
  expect_match(cmd, ',fps=25"', fixed = TRUE)
})

test_that("standardize_video() defaults are deterministic and documented", {
  f <- make_input()
  cmd1 <- standardize_video(f, "out.mp4", run = FALSE)
  cmd2 <- standardize_video(f, "out.mp4", run = FALSE)
  expect_identical(cmd1, cmd2)
  expect_equal(
    cmd1,
    sprintf(
      paste(
        '-y -i "%s"',
        '-vf "crop=w=floor(in_w/2)*2:h=floor(in_h/2)*2:x=(in_w-out_w)/2:y=(in_h-out_h)/2"',
        "-codec:v libx264 -codec:a copy -pix_fmt yuv420p -movflags +faststart",
        # M47: the verb states its stream selection instead of inheriting
        # FFmpeg's. The `?` suffixes keep an input missing a stream type working.
        "-map \"0:v?\" -map \"0:a?\"",
        '"out.mp4"'
      ),
      f
    )
  )
})

test_that("standardize_video() validates dimensions, fps, and the input file", {
  f <- make_input()
  expect_error(standardize_video(f, "out.mp4", width = 0, run = FALSE))
  expect_error(standardize_video(f, "out.mp4", height = -10, run = FALSE))
  expect_error(standardize_video(f, "out.mp4", fps = 0, run = FALSE))
  missing <- file.path(tempdir(), "tidymedia-does-not-exist.mp4")
  expect_error(standardize_video(missing, "out.mp4", run = FALSE))
})

test_that("standardize_video() writes an output with the requested fps and width", {
  skip_if_no_mediainfo()
  skip_if_no_encoder("libx264")
  infile <- make_test_video()
  outfile <- withr::local_tempfile(fileext = ".mp4")
  standardize_video(infile, outfile, width = 48, height = 32, fps = 5)
  expect_true(file.exists(outfile))
  expect_equal(get_width(outfile), 48)
  expect_equal(get_frame_rate(outfile), 5)
})

test_that("standardize_video() default path encodes an odd-dimensioned source", {
  # Regression: without the even-dimension safeguard the default libx264 /
  # yuv420p re-encode rejects odd dimensions and writes a 0-byte file.
  skip_if_no_encoder("libx264")
  infile <- withr::local_tempfile(fileext = ".mp4")
  run_ffmpeg_fixture(sprintf(
    "-y -f lavfi -i testsrc=duration=1:size=65x49:rate=10 -pix_fmt yuv444p \"%s\"",
    infile
  ))
  skip_if_not(file.exists(infile), "odd-dimensioned test video could not be generated")
  outfile <- withr::local_tempfile(fileext = ".mp4")
  standardize_video(infile, outfile)
  expect_true(file.exists(outfile))
  expect_gt(file.size(outfile), 0)
})

test_that("standardize_video() stream-copies audio unchanged", {
  # Regression: a bare video re-encode transcodes audio to the container
  # default; -c:a copy must preserve the source audio codec.
  skip_if_no_ffprobe()
  skip_if_no_encoder("libx264")
  infile <- withr::local_tempfile(fileext = ".mp4")
  run_ffmpeg_fixture(sprintf(paste(
    "-y -f lavfi -i testsrc=duration=1:size=64x64:rate=10",
    "-f lavfi -i sine=frequency=440:duration=1",
    "-c:a libmp3lame -shortest -pix_fmt yuv420p \"%s\""
  ), infile))
  skip_if_not(file.exists(infile), "test video could not be generated")
  outfile <- withr::local_tempfile(fileext = ".mp4")
  standardize_video(infile, outfile)
  acodec <- function(path) ffprobe(sprintf(
    "-v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 \"%s\"",
    path
  ))
  expect_equal(acodec(outfile), acodec(infile))
})

test_that("extract_frame() compiles to a fast input-seek single-frame grab", {
  f <- make_input()
  cmd <- extract_frame(f, "out.png", timestamp = 1.5, run = FALSE)
  expect_match(cmd, "-ss 1.5 -i", fixed = TRUE)
  expect_match(cmd, "-frames:v 1", fixed = TRUE)
})

# sample_frames() (fixed-rate frame sampling) --------------------------------

test_that("sample_frames() compiles to an fps filter into an image2 pattern", {
  f <- make_input()
  d <- withr::local_tempdir()
  cmd <- sample_frames(f, d, fps = 2, run = FALSE)
  expect_match(cmd, '-vf "fps=2"', fixed = TRUE)
  # Output is a zero-padded printf pattern under outdir, stem from the input.
  expect_match(cmd, "_%06d.png", fixed = TRUE)
  base <- tools::file_path_sans_ext(basename(f))
  expect_match(cmd, file.path(d, paste0(base, "_%06d.png")), fixed = TRUE)
})

test_that("sample_frames() maps interval to the reciprocal frame rate", {
  f <- make_input()
  d <- withr::local_tempdir()
  # interval = 0.5 s between frames -> fps = 1/0.5 = 2.
  expect_identical(
    sample_frames(f, d, interval = 0.5, run = FALSE),
    sample_frames(f, d, fps = 2, run = FALSE)
  )
  expect_match(sample_frames(f, d, interval = 4, run = FALSE),
               '-vf "fps=0.25"', fixed = TRUE)
})

test_that("sample_frames() requires exactly one of fps/interval", {
  f <- make_input()
  d <- withr::local_tempdir()
  expect_error(sample_frames(f, d, run = FALSE), "exactly one")
  expect_error(sample_frames(f, d, fps = 2, interval = 1, run = FALSE),
               "exactly one")
})

test_that("sample_frames() accepts a bare-integer rate (M20 coercion)", {
  f <- make_input()
  d <- withr::local_tempdir()
  # 2L must not trip ffm_fps()'s check_dim() integer rejection.
  expect_match(sample_frames(f, d, fps = 2L, run = FALSE), '-vf "fps=2"',
               fixed = TRUE)
})

test_that("sample_frames() honors format and prefix", {
  f <- make_input()
  d <- withr::local_tempdir()
  cmd <- sample_frames(f, d, fps = 1, format = "jpg", prefix = "shot",
                       run = FALSE)
  expect_match(cmd, file.path(d, "shot_%06d.jpg"), fixed = TRUE)
})

test_that("sample_frames() rejects a non-image format", {
  f <- make_input()
  d <- withr::local_tempdir()
  expect_error(sample_frames(f, d, fps = 1, format = "mp4", run = FALSE),
               "image format")
})

test_that("sample_frames() rejects a non-positive rate", {
  f <- make_input()
  d <- withr::local_tempdir()
  expect_error(sample_frames(f, d, fps = 0, run = FALSE), "positive")
  expect_error(sample_frames(f, d, interval = -1, run = FALSE), "positive")
})

test_that("sample_frames() rejects a missing input file", {
  d <- withr::local_tempdir()
  missing <- withr::local_tempfile(fileext = ".mp4")  # not created
  expect_error(sample_frames(missing, d, fps = 1), "can't be found or read")
})

test_that("sample_frames() aborts on an uncreatable output directory", {
  f <- make_input()
  # A path *under* an existing file cannot be created as a directory.
  blocker <- make_input()
  expect_error(sample_frames(f, file.path(blocker, "sub"), fps = 1,
                             run = FALSE),
               "directory")
})

test_that("sample_frames() writes a numbered sequence at the requested rate", {
  skip_if_no_ffmpeg()
  v <- make_test_video()  # 2 s at 10 fps
  d <- withr::local_tempdir()
  sample_frames(v, d, fps = 2)
  files <- sort(list.files(d, pattern = "\\.png$"))
  # 2 fps over 2 s -> ~4 frames (allow the boundary +/- 1).
  expect_gte(length(files), 3)
  expect_lte(length(files), 5)
  # Zero-padded, sequential numbering starting at 1.
  expect_match(files[[1]], "_000001\\.png$")
})

test_that("separate_audio_video() emits two single-output mapped commands", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", run = FALSE)
  expect_named(cmds, c("audio", "video"))
  expect_match(cmds[["audio"]], '-map "0:a" "a.aac"', fixed = TRUE)
  expect_match(cmds[["video"]], '-map "0:v" "v.mp4"', fixed = TRUE)
})

test_that("concatenate_videos() compiles to the concat demuxer", {
  f1 <- make_input()
  f2 <- make_input()
  cmd <- concatenate_videos(c(f1, f2), "out.mp4", run = FALSE)
  expect_match(cmd, "-f concat -safe 0 -i ", fixed = TRUE)
  expect_match(cmd, "-codec:v copy -codec:a copy -map \"0\"", fixed = TRUE)
})

test_that("concatenate_videos() warns on mixed extensions", {
  f1 <- make_input("mp4")
  f2 <- make_input("mkv")
  expect_warning(
    concatenate_videos(c(f1, f2), "out.mp4", run = FALSE),
    "same extension"
  )
})

test_that("concatenate_videos() joins inputs end to end (binary-gated)", {
  skip_if_no_ffmpeg()
  skip_if_no_ffprobe()
  a <- make_test_video()
  b <- make_test_video()
  out <- withr::local_tempfile(fileext = ".mp4")
  concatenate_videos(c(a, b), out)
  expect_true(file.exists(out))
  expect_gt(probe_duration(out), 3.5)
})

test_that("task verbs reject a missing input file (no binary needed)", {
  missing <- withr::local_tempfile(fileext = ".mp4")  # not created
  expect_error(extract_audio(missing, "out.aac"))
  expect_error(convert_audio(missing, "out.mp3"))
  expect_error(separate_audio_video(missing, "a.aac", "v.mp4"))
  expect_error(crop_video(missing, "out.mp4", 10, 10, 0, 0))
  expect_error(format_for_web(missing, "out.mp4"))
})

# compare_videos() (Layer-2 on hstack/vstack) ----------------------------------

test_that("compare_videos() defaults to a horizontal resized stack", {
  f1 <- make_input()
  f2 <- make_input()
  cmd <- compare_videos(c(f1, f2), "out.mp4", run = FALSE)
  expect_match(cmd, "scale2ref", fixed = TRUE)
  expect_match(cmd, 'hstack,setsar=1[vout]" -map "[vout]"', fixed = TRUE)
})

test_that("compare_videos(direction = 'vertical') stacks with vstack", {
  f1 <- make_input()
  f2 <- make_input()
  cmd <- compare_videos(c(f1, f2), "out.mp4", direction = "vertical",
                        run = FALSE)
  expect_match(cmd, 'vstack,setsar=1[vout]" -map "[vout]"', fixed = TRUE)
})

test_that("compare_videos(resize = FALSE) accepts more than two inputs", {
  f1 <- make_input()
  f2 <- make_input()
  f3 <- make_input()
  cmd <- compare_videos(c(f1, f2, f3), "out.mp4", resize = FALSE, run = FALSE)
  expect_match(cmd, "hstack=inputs=3:shortest=0[vout]", fixed = TRUE)
})

test_that("compare_videos(audio_input = ) carries that input's audio via -map", {
  f1 <- make_input()
  f2 <- make_input()
  cmd <- compare_videos(c(f1, f2), "out.mp4", audio_input = 1, run = FALSE)
  expect_match(cmd, '-map "[vout]" -map "1:a"', fixed = TRUE)
})

test_that("compare_videos() drops audio by default (no extra -map)", {
  f1 <- make_input()
  f2 <- make_input()
  cmd <- compare_videos(c(f1, f2), "out.mp4", run = FALSE)
  expect_no_match(cmd, ":a", fixed = TRUE)
})

test_that("compare_videos() validates inputs, resize arity, and audio index", {
  f1 <- make_input()
  f2 <- make_input()
  expect_error(compare_videos(f1, "out.mp4", run = FALSE), "two")
  expect_error(
    compare_videos(c(f1, f2, f1), "out.mp4", run = FALSE),
    "exactly two"
  )
  expect_error(
    compare_videos(c(f1, f2), "out.mp4", audio_input = 5, run = FALSE)
  )
})

test_that("compare_videos() runs through ffmpeg (binary-gated)", {
  skip_if_no_ffmpeg()
  a <- make_test_video()
  b <- make_test_video()
  out <- withr::local_tempfile(fileext = ".mp4")
  compare_videos(c(a, b), out, audio_input = 0)
  expect_true(file.exists(out))
  expect_gt(file.size(out), 0)
})

# picture_in_picture() (Layer-2 on overlay) ------------------------------------

test_that("picture_in_picture() insets a scaled overlay top-right by default", {
  m <- make_input()
  o <- make_input()
  cmd <- picture_in_picture(m, o, "out.mp4", run = FALSE)
  expect_match(cmd, "scale2ref=w='main_w*0.25'", fixed = TRUE)
  expect_match(cmd, "overlay=x=main_w-overlay_w-16:y=16", fixed = TRUE)
})

test_that("picture_in_picture(position = ) places the inset per corner/center", {
  m <- make_input()
  o <- make_input()
  expect_match(
    picture_in_picture(m, o, "out.mp4", position = "topleft", run = FALSE),
    "overlay=x=16:y=16", fixed = TRUE
  )
  expect_match(
    picture_in_picture(m, o, "out.mp4", position = "bottomright", run = FALSE),
    "overlay=x=main_w-overlay_w-16:y=main_h-overlay_h-16", fixed = TRUE
  )
  expect_match(
    picture_in_picture(m, o, "out.mp4", position = "center", run = FALSE),
    "overlay=x=(main_w-overlay_w)/2:y=(main_h-overlay_h)/2", fixed = TRUE
  )
})

test_that("picture_in_picture() honours scale and margin", {
  m <- make_input()
  o <- make_input()
  cmd <- picture_in_picture(m, o, "out.mp4", scale = 0.4, margin = 8,
                            run = FALSE)
  expect_match(cmd, "scale2ref=w='main_w*0.4'", fixed = TRUE)
  expect_match(cmd, "overlay=x=main_w-overlay_w-8:y=8", fixed = TRUE)
})

test_that("picture_in_picture(audio_input = ) carries that input's audio", {
  m <- make_input()
  o <- make_input()
  cmd <- picture_in_picture(m, o, "out.mp4", audio_input = 0, run = FALSE)
  expect_match(cmd, '-map "[vout]" -map "0:a"', fixed = TRUE)
})

test_that("picture_in_picture() validates scale, margin, and audio", {
  m <- make_input()
  o <- make_input()
  expect_error(picture_in_picture(m, o, "out.mp4", scale = 2, run = FALSE))
  expect_error(picture_in_picture(m, o, "out.mp4", audio_input = 2, run = FALSE))
  missing <- withr::local_tempfile(fileext = ".mp4")
  expect_error(picture_in_picture(missing, o, "out.mp4", run = FALSE))
})

test_that("picture_in_picture() runs through ffmpeg (binary-gated)", {
  skip_if_no_ffmpeg()
  a <- make_test_video()
  b <- make_test_video()
  out <- withr::local_tempfile(fileext = ".mp4")
  picture_in_picture(a, b, out, audio_input = 0)
  expect_true(file.exists(out))
  expect_gt(file.size(out), 0)
})

# M06: separate_audio_video() stream-copy default (D-M06-4) ----------------------

test_that("separate_audio_video() stream-copies by default", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", run = FALSE)
  expect_match(cmds[["audio"]], "-codec:a copy", fixed = TRUE)
  expect_match(cmds[["video"]], "-codec:v copy", fixed = TRUE)
})

test_that("separate_audio_video() omits the stream copy when both codecs are NULL", {
  f <- make_input()
  cmds <- separate_audio_video(f, "a.aac", "v.mp4", audio_codec = NULL,
                               video_codec = NULL, run = FALSE)
  expect_no_match(cmds[["audio"]], "copy", fixed = TRUE)
  expect_no_match(cmds[["video"]], "copy", fixed = TRUE)
})

test_that("separate_audio_video() copy outputs exist and are nonempty", {
  skip_if_no_ffmpeg()
  src <- make_test_video()
  dir <- withr::local_tempdir()
  audiofile <- file.path(dir, "a.aac")
  videofile <- file.path(dir, "v.mp4")
  separate_audio_video(src, audiofile, videofile)
  expect_true(file.exists(audiofile))
  expect_gt(file.size(audiofile), 0)
  expect_true(file.exists(videofile))
  expect_gt(file.size(videofile), 0)
})

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.