Nothing
# Tests for ffm_jobs(), the directory -> jobs-table export (M121). Every branch
# here is binary-free: the function lists files and builds a tibble, and every
# batch call -- the ffm_batch() calls and the fifteen *_batch() verbs --
# runs with `run = FALSE`.
# A directory with one file of each shape the selector has to tell apart.
local_media_dir <- function(env = parent.frame()) {
dir <- withr::local_tempdir(.local_envir = env)
dir.create(file.path(dir, "sub"))
for (f in c("a.mp4", "b.MOV", "c.wav", "d.png", "notes.txt",
"sub/deep.mp4")) {
file.create(file.path(dir, f))
}
dir
}
# Raise-and-return, so a refusal's blame frame can be read. Not catch_call():
# that helper forces a `run` argument ffm_jobs() does not have.
catch <- function(expr) tryCatch(expr, error = function(e) e)
# Listing ------------------------------------------------------------------
test_that("ffm_jobs() returns one row per matching file, `input` and nothing else", {
dir <- local_media_dir()
jobs <- ffm_jobs(dir, type = "video")
expect_s3_class(jobs, "tbl_df")
expect_identical(names(jobs), "input")
expect_type(jobs$input, "character")
expect_identical(nrow(jobs), 2L)
expect_setequal(basename(jobs$input), c("a.mp4", "b.MOV"))
})
test_that("`input` holds full paths that resolve to the listed files", {
dir <- local_media_dir()
jobs <- ffm_jobs(dir, type = "video")
# Discriminating: a bare basename or a path relative to the directory would
# both fail here from a different working directory.
withr::with_dir(withr::local_tempdir(), {
expect_true(all(file.exists(jobs$input)))
})
# Stated independently of what ffm_jobs() returned: the two paths the fixture
# wrote, spelled from the directory's own normalized path. Asserting instead
# that normalizePath() is idempotent on jobs$input would hold whatever files
# came back.
root <- normalizePath(dir, winslash = "/", mustWork = TRUE)
expect_setequal(jobs$input, file.path(root, c("a.mp4", "b.MOV")))
})
test_that("the extension match is case-insensitive but the returned name is not", {
dir <- local_media_dir()
jobs <- ffm_jobs(dir, type = "video")
# `b.MOV` is matched by the lower-case pattern and returned unchanged.
expect_true("b.MOV" %in% basename(jobs$input))
})
test_that("each type lists its own category and no other", {
dir <- local_media_dir()
expect_setequal(basename(ffm_jobs(dir, type = "audio")$input), "c.wav")
expect_setequal(basename(ffm_jobs(dir, type = "image")$input), "d.png")
# notes.txt is in none of the three.
for (type in c("video", "audio", "image")) {
expect_false("notes.txt" %in% basename(ffm_jobs(dir, type = type)$input))
}
})
test_that("recursive = TRUE adds subdirectory files and FALSE is the default", {
dir <- local_media_dir()
expect_false("deep.mp4" %in% basename(ffm_jobs(dir, type = "video")$input))
deep <- ffm_jobs(dir, type = "video", recursive = TRUE)
expect_identical(nrow(deep), 3L)
expect_true("deep.mp4" %in% basename(deep$input))
})
test_that("`extension` narrows within the type, with or without a leading dot", {
dir <- local_media_dir()
expect_setequal(
basename(ffm_jobs(dir, type = "video", extension = "mp4")$input), "a.mp4"
)
expect_setequal(
basename(ffm_jobs(dir, type = "video", extension = ".MP4")$input), "a.mp4"
)
expect_identical(
nrow(ffm_jobs(dir, type = "video", extension = c("mp4", "mov"))), 2L
)
})
# The AC1 hand-off ---------------------------------------------------------
test_that("ffm_batch() consumes the returned table unreshaped, plus a derived output", {
dir <- local_media_dir()
jobs <- ffm_jobs(dir, type = "video")
jobs$output <- file.path(
tempdir(), paste0(tools::file_path_sans_ext(basename(jobs$input)), ".mp3")
)
res <- ffm_batch(jobs, run = FALSE, .f = function(input, output, ...) {
ffm_files(input, output) |> ffm_drop("video")
})
expect_identical(nrow(res), nrow(jobs))
expect_true("command" %in% names(res))
# Each compiled command names the row's own input and output -- an `.f` that
# ignored its arguments would not.
for (i in seq_len(nrow(res))) {
expect_true(grepl(basename(jobs$input[[i]]), res$command[[i]], fixed = TRUE))
expect_true(grepl(basename(jobs$output[[i]]), res$command[[i]], fixed = TRUE))
}
})
# Refusals -----------------------------------------------------------------
test_that("AC2's three refusals name the frame the caller typed", {
dir <- local_media_dir()
empty <- withr::local_tempdir()
cases <- list(
missing_dir = catch(ffm_jobs(file.path(dir, "no-such-dir"), type = "video")),
bad_type = catch(ffm_jobs(dir, type = "subtitle")),
no_match = catch(ffm_jobs(empty, type = "video"))
)
for (nm in names(cases)) {
expect_s3_class(cases[[nm]], "error")
expect_identical(blamed_verb(cases[[nm]]), "ffm_jobs", info = nm)
}
expect_match(conditionMessage(cases$missing_dir), "existing directory")
expect_match(conditionMessage(cases$bad_type), "video")
expect_match(conditionMessage(cases$no_match), "No video files")
})
test_that("a file that is not a directory is refused like a missing one", {
dir <- local_media_dir()
cnd <- catch(ffm_jobs(file.path(dir, "a.mp4"), type = "video"))
expect_s3_class(cnd, "error")
expect_match(conditionMessage(cnd), "existing directory")
})
test_that("the no-match message names the extensions it looked for", {
empty <- withr::local_tempdir()
cnd <- catch(ffm_jobs(empty, type = "audio", extension = "flac"))
expect_match(conditionMessage(cnd), "flac")
# Only the narrowed set, not the whole audio vocabulary.
expect_false(grepl("wav", conditionMessage(cnd), fixed = TRUE))
expect_match(
conditionMessage(catch(ffm_jobs(empty, type = "video", recursive = TRUE))),
"subdirectories"
)
})
# M137: the lists stay closed, so the no-match refusal says where a caller goes
# for a container they do not carry. Only the caller who did not narrow the
# search gets that line. A caller who passed `extension` picked the scanned set
# themselves.
test_that("the no-match message names list.files() only when extension was not given", {
empty <- withr::local_tempdir()
wide <- catch(ffm_jobs(empty, type = "video"))
expect_s3_class(wide, "error")
expect_identical(blamed_verb(wide), "ffm_jobs")
wide_msg <- conditionMessage(wide)
expect_match(wide_msg, "Looked for these extensions")
expect_match(wide_msg, "mp4")
expect_match(wide_msg, "list.files()", fixed = TRUE)
# A type other than "video", so the branch is not read off one type's vector.
narrowed <- catch(ffm_jobs(empty, type = "audio", extension = c("mka", "wav")))
expect_s3_class(narrowed, "error")
expect_identical(blamed_verb(narrowed), "ffm_jobs")
narrowed_msg <- conditionMessage(narrowed)
expect_match(narrowed_msg, "Looked for these extensions")
expect_match(narrowed_msg, "mka")
expect_match(narrowed_msg, "wav")
expect_false(grepl("list.files", narrowed_msg, fixed = TRUE))
})
test_that("an extension outside the type is refused, naming the offender and the set", {
dir <- local_media_dir()
cnd <- catch(ffm_jobs(dir, type = "video", extension = c("mp4", "wav")))
expect_s3_class(cnd, "error")
expect_identical(blamed_verb(cnd), "ffm_jobs")
msg <- conditionMessage(cnd)
expect_match(msg, "wav")
expect_match(msg, "mkv") # the accepted set is listed
})
test_that("every argument-form refusal fires and names ffm_jobs()", {
dir <- local_media_dir()
cases <- list(
directory_not_string = catch(ffm_jobs(c(dir, dir), type = "video")),
directory_na = catch(ffm_jobs(NA_character_, type = "video")),
directory_numeric = catch(ffm_jobs(1, type = "video")),
type_missing = catch(ffm_jobs(dir)),
type_not_string = catch(ffm_jobs(dir, type = 1)),
type_na = catch(ffm_jobs(dir, type = NA_character_)),
recursive_not_bool = catch(ffm_jobs(dir, type = "video", recursive = "yes")),
recursive_na = catch(ffm_jobs(dir, type = "video", recursive = NA)),
extension_numeric = catch(ffm_jobs(dir, type = "video", extension = 1)),
extension_empty_vec = catch(ffm_jobs(dir, type = "video",
extension = character(0))),
extension_na = catch(ffm_jobs(dir, type = "video",
extension = NA_character_)),
extension_empty_str = catch(ffm_jobs(dir, type = "video", extension = ""))
)
for (nm in names(cases)) {
expect_s3_class(cases[[nm]], "error")
expect_identical(blamed_verb(cases[[nm]]), "ffm_jobs", info = nm)
}
expect_match(conditionMessage(cases$type_missing), "type")
})
test_that("a subdirectory matching the extension pattern is not a row", {
dir <- local_media_dir()
dir.create(file.path(dir, "takes.mp4"))
jobs <- ffm_jobs(dir, type = "video")
expect_false("takes.mp4" %in% basename(jobs$input))
expect_false(any(dir.exists(jobs$input)))
expect_setequal(basename(jobs$input), c("a.mp4", "b.MOV"))
# And with the directory dropped, recursive = TRUE is a superset of FALSE:
# before the fix `takes.mp4` was in FALSE only, since list.files(recursive =
# TRUE) omits directories.
deep <- ffm_jobs(dir, type = "video", recursive = TRUE)
expect_true(all(jobs$input %in% deep$input))
expect_true("deep.mp4" %in% basename(deep$input))
})
test_that("a relative `directory` still yields absolute paths", {
# AC1's "full paths" clause is promised on every platform, Windows included,
# so it is tested outside the symbolic-link block that Windows skips.
dir <- local_media_dir()
withr::with_dir(dirname(dir), {
jobs <- ffm_jobs(basename(dir), type = "video")
expect_setequal(basename(jobs$input), c("a.mp4", "b.MOV"))
expect_true(all(startsWith(jobs$input, "/") | grepl("^[A-Za-z]:", jobs$input)))
expect_true(all(file.exists(jobs$input)))
})
})
test_that("a dangling symbolic link is not a row", {
# Windows reports a dangling link as existing, so the predicate keeps it
# there and these expectations are about a platform this package no longer
# promises them on -- AC1's disclosed carve-out, held by the `ffm_jobs()`
# candidate row as item (f). file.symlink() succeeds on the runner, so
# skip_if_not() below never fires and this guard is what keeps the leg green.
skip_on_os("windows")
dir <- local_media_dir()
linked <- file.symlink("../gone.mp4", file.path(dir, "broken.mp4"))
skip_if_not(isTRUE(linked), "this filesystem does not support symbolic links")
# list.files() yields the link, dir.exists() is FALSE on it, and
# normalizePath(mustWork = FALSE) returns an unresolvable path unchanged --
# so before the fix `broken.mp4` came back as a row, and from a relative
# `directory` it came back relative too.
withr::with_dir(dirname(dir), {
jobs <- ffm_jobs(basename(dir), type = "video")
expect_false("broken.mp4" %in% basename(jobs$input))
expect_setequal(basename(jobs$input), c("a.mp4", "b.MOV"))
expect_true(all(file.exists(jobs$input)))
expect_false(any(dir.exists(jobs$input)))
# Absolute however the directory was spelled: the criterion says full
# paths, and a relative `directory` is what made row 1 relative before.
expect_true(all(startsWith(jobs$input, "/") | grepl("^[A-Za-z]:", jobs$input)))
})
})
test_that("a directory holding only a dangling link reports no files", {
# Windows reports a dangling link as existing, so the predicate keeps it
# there and these expectations are about a platform this package no longer
# promises them on -- AC1's disclosed carve-out, held by the `ffm_jobs()`
# candidate row as item (f). file.symlink() succeeds on the runner, so
# skip_if_not() below never fires and this guard is what keeps the leg green.
skip_on_os("windows")
dir <- withr::local_tempdir()
linked <- file.symlink("../gone.mp4", file.path(dir, "broken.mp4"))
skip_if_not(isTRUE(linked), "this filesystem does not support symbolic links")
# The link is dropped before the zero-match check, so this is a refusal
# rather than a zero-row tibble.
cnd <- catch(ffm_jobs(dir, type = "video"))
expect_s3_class(cnd, "error")
expect_identical(blamed_verb(cnd), "ffm_jobs")
expect_match(conditionMessage(cnd), "No video files")
})
test_that("a symbolic link whose target exists is still a row", {
dir <- local_media_dir()
target <- file.path(dir, "sub", "deep.mp4")
linked <- file.symlink(target, file.path(dir, "live.mp4"))
skip_if_not(isTRUE(linked), "this filesystem does not support symbolic links")
# Discriminating against a predicate that drops every link rather than only
# the broken ones: this one resolves, so it belongs in the table.
jobs <- ffm_jobs(dir, type = "video")
expect_identical(nrow(jobs), 3L)
expect_true(all(file.exists(jobs$input)))
})
test_that("a directory holding only extension-named subdirectories reports no files", {
dir <- withr::local_tempdir()
dir.create(file.path(dir, "takes.mp4"))
cnd <- catch(ffm_jobs(dir, type = "video"))
expect_s3_class(cnd, "error")
expect_match(conditionMessage(cnd), "No video files")
})
test_that("a multi-value `type` is refused, not silently reduced to its first", {
dir <- local_media_dir()
# Both refuse now. The full-set vector used to pass: arg_match() reduces `arg`
# to its first element whenever `arg` is setequal() to `values`, so any
# permutation slipped through. The two-element vector always aborted, but
# from arg_match(), not with the "single string" message asserted here.
for (val in list(c("video", "audio", "image"), c("audio", "video"))) {
cnd <- catch(ffm_jobs(dir, type = val))
expect_s3_class(cnd, "error")
expect_identical(blamed_verb(cnd), "ffm_jobs")
expect_match(conditionMessage(cnd), "single string")
}
})
test_that("`extension` given as a factor is refused, and so is an empty `directory`", {
dir <- local_media_dir()
fac <- catch(ffm_jobs(dir, type = "video", extension = factor("mp4")))
expect_s3_class(fac, "error")
expect_identical(blamed_verb(fac), "ffm_jobs")
expect_match(conditionMessage(fac), "character vector")
empty_dir <- catch(ffm_jobs("", type = "video"))
expect_s3_class(empty_dir, "error")
expect_identical(blamed_verb(empty_dir), "ffm_jobs")
expect_match(conditionMessage(empty_dir), "existing directory")
})
test_that("the extension refusal agrees in number with the offenders it names", {
dir <- local_media_dir()
one <- conditionMessage(catch(ffm_jobs(dir, type = "video", extension = "wav")))
many <- conditionMessage(
catch(ffm_jobs(dir, type = "video", extension = c("wav", "png")))
)
expect_match(one, "is not one of them", fixed = TRUE)
expect_match(many, "are not among them", fixed = TRUE)
})
test_that("the NEWS entry's six-and-nine split over the *_batch() verbs holds", {
dir <- local_media_dir()
jobs <- ffm_jobs(dir, type = "video")
# The four that need no argument at all.
expect_no_error(standardize_video_batch(jobs, run = FALSE))
expect_no_error(normalize_audio_batch(jobs, run = FALSE))
expect_no_error(format_for_web_batch(jobs, run = FALSE))
expect_no_error(strip_metadata_batch(jobs, run = FALSE))
# And the two that take the table unaltered once their argument is supplied
# -- the pair the entry used to count among the refusers.
expect_no_error(
crop_video_batch(jobs, width = 100, height = 100, x = 0, y = 0, run = FALSE)
)
expect_no_error(sample_frames_batch(jobs, fps = 1, run = FALSE))
# The other nine refuse the bare table, each naming its own column. Three
# name `output`; the rest name what their task needs.
refusers <- list(
convert_audio = list(quote(convert_audio_batch(jobs, run = FALSE)), "output"),
extract_audio = list(quote(extract_audio_batch(jobs, run = FALSE)), "output"),
picture_in_picture = list(quote(picture_in_picture_batch(jobs, run = FALSE)), "output"),
anonymize_video = list(quote(anonymize_video_batch(jobs, run = FALSE)), "regions"),
compare_videos = list(quote(compare_videos_batch(jobs, run = FALSE)), "inputs"),
concatenate_videos = list(quote(concatenate_videos_batch(jobs, run = FALSE)), "inputs"),
extract_frame = list(quote(extract_frame_batch(jobs, run = FALSE)), "timestamp"),
segment_video = list(quote(segment_video_batch(jobs, run = FALSE)), "start"),
separate_audio_video = list(quote(separate_audio_video_batch(jobs, run = FALSE)),
"audiofile")
)
expect_identical(length(refusers), 9L)
for (nm in names(refusers)) {
cnd <- catch(eval(refusers[[nm]][[1]]))
expect_s3_class(cnd, "error")
expect_match(conditionMessage(cnd), refusers[[nm]][[2]], info = nm)
}
})
# What an extra jobs column does (?ffm_jobs details) -----------------------
test_that("an extra column stops ffm_batch() unless .f takes `...`", {
jobs <- ffm_jobs(local_media_dir(), type = "video")
jobs$output <- paste0("out", seq_len(nrow(jobs)), ".mp3")
jobs$notes <- c("camA", "camB")
# Which failure: R's own, naming the column that had no argument to land in.
cnd <- catch(ffm_batch(jobs, run = FALSE, .f = function(input, output) {
ffm_files(input, output) |> ffm_drop("video")
}))
expect_s3_class(cnd, "error")
expect_match(conditionMessage(cnd), "unused argument (notes = ", fixed = TRUE)
res <- ffm_batch(jobs, run = FALSE, .f = function(input, output, ...) {
ffm_files(input, output) |> ffm_drop("video")
})
expect_identical(res$notes, jobs$notes)
expect_length(res$command, nrow(jobs))
})
test_that("crop_video_batch() and extract_audio_batch() return an unread column unchanged", {
jobs <- ffm_jobs(local_media_dir(), type = "video")
# A factor, so a verb coercing the column to character would show.
jobs$notes <- factor(c("camA", "camB"))
cropped <- crop_video_batch(
transform(jobs, output = c("a.mp4", "b.mp4")),
width = 100, height = 50, run = FALSE
)
expect_identical(cropped$notes, jobs$notes)
extracted <- extract_audio_batch(
transform(jobs, output = c("a.m4a", "b.m4a")), run = FALSE
)
expect_identical(extracted$notes, jobs$notes)
})
test_that("a `command` column is replaced by the compiled command, not kept", {
jobs <- ffm_jobs(local_media_dir(), type = "video")
jobs$command <- c("stale", "stale")
cropped <- crop_video_batch(
transform(jobs, output = c("a.mp4", "b.mp4")),
width = 100, height = 50, run = FALSE
)
extracted <- extract_audio_batch(
transform(jobs, output = c("a.m4a", "b.m4a")), run = FALSE
)
for (res in list(cropped, extracted)) {
expect_false(any(res$command == "stale"))
expect_match(res$command, "-i ", fixed = TRUE)
}
})
test_that("crop_video_batch() and extract_audio_batch() read an argument-named column per row", {
jobs <- ffm_jobs(local_media_dir(), type = "video")
# Each row's own value, and never the argument's, reaches its command.
crop_jobs <- transform(jobs, output = c("a.mp4", "b.mp4"), width = c(100, 80))
cropped <- crop_video_batch(crop_jobs, width = 60, height = 50, run = FALSE)
for (i in seq_len(nrow(crop_jobs))) {
expect_match(cropped$command[[i]], paste0("crop=w=", crop_jobs$width[[i]], ":"),
fixed = TRUE)
expect_no_match(cropped$command[[i]], "crop=w=60:", fixed = TRUE)
}
audio_jobs <- transform(jobs, output = c("a.m4a", "b.mp3"),
audio_codec = c("copy", "libmp3lame"))
extracted <- extract_audio_batch(audio_jobs, audio_codec = "aac", run = FALSE)
for (i in seq_len(nrow(audio_jobs))) {
expect_match(extracted$command[[i]],
paste0("-codec:a ", audio_jobs$audio_codec[[i]], " "), fixed = TRUE)
expect_no_match(extracted$command[[i]], "-codec:a aac ", fixed = TRUE)
}
})
# The vocabulary -----------------------------------------------------------
test_that("media_types() and media_extensions() agree and stay lower-case", {
types <- media_types()
expect_identical(types, c("video", "audio", "image"))
for (type in types) {
ext <- media_extensions(type)
expect_type(ext, "character")
expect_gt(length(ext), 0)
expect_identical(ext, tolower(ext))
expect_identical(ext, unique(ext))
# No extension belongs to two categories -- the type selector would not
# separate them.
others <- unlist(lapply(setdiff(types, type), media_extensions))
expect_length(intersect(ext, others), 0)
}
})
# The three lists spelled out, so a change to any of them shows up here as a
# diff rather than passing because the test derives its expectation from the
# function it is testing. Order is part of the pin: media_extensions() feeds the
# refusal that lists what a type accepts, and that list is read in this order.
test_that("media_extensions() returns the pinned vectors", {
expect_identical(
media_extensions("audio"),
c("wav", "mp3", "m4a", "aac", "flac", "ogg", "oga", "opus", "wma",
"aiff", "aif", "mka")
)
expect_identical(
media_extensions("video"),
c("mp4", "mov", "mkv", "avi", "m4v", "webm", "mpg", "mpeg", "wmv",
"flv", "mts", "m2ts", "ts")
)
expect_identical(
media_extensions("image"),
c("png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp")
)
})
# The standing guard behind M137's round-trip rule. `multi_audio_extensions`
# (`R/ffmpeg.R`) is the package's own list of containers that hold several audio
# streams: the multi-track separation refusal offers `.mka` and `.m4a` out of
# it, and separate_audio_video()'s help page renders all nine. A container the
# package puts forward that way has to sit somewhere in the scanned vocabulary,
# or a folder of the package's own output is unlistable. Reads the vector at
# test time rather than a copy, so the guard keeps holding as it grows.
test_that("every multi-audio container is in exactly one media_extensions() vector", {
exts <- tidymedia:::multi_audio_extensions
homes <- vapply(
exts,
function(ext) sum(vapply(media_types(),
function(ty) ext %in% media_extensions(ty),
logical(1))),
integer(1)
)
expect_identical(
unname(homes), rep(1L, length(exts)),
info = paste0(
"multi-audio containers in no media_extensions() vector: ",
paste(names(homes)[homes == 0L], collapse = ", "),
"; in more than one: ",
paste(names(homes)[homes > 1L], collapse = ", ")
)
)
})
# The round trip itself: for each of those containers, a folder holding one such
# file becomes a one-row jobs table, both when the caller narrows the search to
# that extension and when it does not. The type comes from the vectors, not from
# a table written here, so a container no vector holds fails by name rather
# than silently picking a type here: the guard above names it, and every
# expectation below carries it as a label. Two axes ride along, on
# fixed positions so they survive the vector growing: the second element is
# spelled in mixed case, and the last is read from a subdirectory under
# `recursive = TRUE`.
test_that("each multi-audio container round-trips through ffm_jobs()", {
exts <- tidymedia:::multi_audio_extensions
upper_at <- 2L
deep_at <- length(exts)
for (i in seq_along(exts)) {
ext <- exts[[i]]
type <- Filter(function(ty) ext %in% media_extensions(ty), media_types())
# Every expectation in the loop carries the container it is on. Without the
# label a failure at the fifth iteration reads as a bare path mismatch, and
# the reader has to count the vector to learn which container broke.
expect_identical(length(type), 1L, info = ext)
type <- type[[1]]
dir <- withr::local_tempdir()
spelled <- if (i == upper_at) {
sub("^(.)", "\\U\\1", ext, perl = TRUE)
} else {
ext
}
stem <- if (i == deep_at) {
dir.create(file.path(dir, "nested"))
file.path("nested", "clip")
} else {
"clip"
}
name <- paste0(stem, ".", spelled)
file.create(file.path(dir, name))
expected <- file.path(
normalizePath(dir, winslash = "/", mustWork = TRUE),
name
)
for (narrowed in list(ext, NULL)) {
jobs <- ffm_jobs(dir, type = type, extension = narrowed,
recursive = i == deep_at)
expect_identical(nrow(jobs), 1L, info = ext)
expect_identical(jobs$input, expected, info = ext)
}
}
})
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.