R/render.R

Defines functions render_timeline .assemble_track .caption_file .subtitles_filter .framing_vf .timeline_framing .prerender_sources .motion_from_effects .video_sequence .clip_urls .resolve_media .is_absolute_path .timeline_layout .track_role .is_caption_track

Documented in .assemble_track .caption_file .clip_urls .framing_vf .is_absolute_path .is_caption_track .motion_from_effects .prerender_sources render_timeline .resolve_media .subtitles_filter .timeline_framing .timeline_layout .track_role .video_sequence

# render.R
# Lower a rotio (OpenTimelineIO) Timeline to a rendered video via ffmpeg.
# This is the OTIO -> ffmpeg renderer: it reads the timeline structure and the
# cornball metadata conventions, then emits a single ffmpeg invocation.
#
# Scope: one video track of sequential clips (concatenated, dissolving over
# Transitions), an optional separate audio track, an optional caption track
# that references a subtitle file, and a framing transform (scale + pad)
# carried in `metadata$cornball$framing`. Still-image and ImageSequenceReference
# clips are pre-rendered into video via still_clip()/frames_clip(), honoring
# the `cornball.*` motion-effect namespace (inst/schema/cornball-effects.md).
# Roled tracks (narrator, ...) compose onto a canvas per the cornball layout
# contract (inst/schema/cornball-layout.md) via compose_layout(). Arbitrary
# stacks and overlapping layers beyond that are not lowered.

#' Is this track a caption track?
#'
#' A track is treated as captions if its cornball metadata sets
#' \code{role = "caption"} or its name contains "caption".
#'
#' @param track A rotio Track.
#' @return TRUE if the track holds captions.
#' @keywords internal
.is_caption_track <- function(track) {
    role <- tryCatch(rotio::metadata(track)$cornball$role,
                     error = function(e) NULL)
    if (!is.null(role) && identical(role, "caption")) {
        return(TRUE)
    }
    grepl("caption", rotio::name(track), ignore.case = TRUE)
}

#' A track's cornball role ("" when untagged)
#' @param track A rotio Track.
#' @keywords internal
.track_role <- function(track) {
    role <- tryCatch(rotio::metadata(track)$cornball$role,
                     error = function(e) NULL)
    if (is.null(role)) {
        return("")
    }
    as.character(role)
}

#' The timeline's cornball layout metadata, or NULL
#' @param timeline A rotio Timeline.
#' @keywords internal
.timeline_layout <- function(timeline) {
    tryCatch(rotio::metadata(timeline)$cornball$layout,
             error = function(e) NULL)
}

#' Is a media url an absolute filesystem path?
#'
#' Recognizes the forms an OTIO bundle can carry regardless of the OS it was
#' authored on: POSIX absolute paths (leading \code{/}), Windows drive paths
#' (\code{C:\\} or \code{C:/}), and UNC paths (leading backslash). Anything
#' else is relative and gets resolved against \code{media_dir}. Deliberately
#' platform-agnostic: a bundle written on Linux may be rendered on Windows and
#' vice versa, so the test must not depend on \code{.Platform$file.sep}.
#'
#' @param url A media reference url.
#' @return TRUE if \code{url} is an absolute filesystem path.
#' @keywords internal
.is_absolute_path <- function(url) {
    grepl("^(/|\\\\|[A-Za-z]:)", url)
}

#' Resolve a media reference url against an optional base directory
#'
#' @param url Target url from an OTIO media reference.
#' @param media_dir Base directory for relative urls, or NULL.
#' @return The resolved path.
#' @keywords internal
.resolve_media <- function(url, media_dir) {
    if (is.null(media_dir) || .is_absolute_path(url)) {
        return(url)
    }
    file.path(media_dir, url)
}

#' Ordered media urls of the Clips on a track
#'
#' Gaps and non-clip children are skipped.
#'
#' @param track A rotio Track.
#' @param media_dir Base directory for relative urls, or NULL.
#' @return Character vector of resolved media paths, in track order.
#' @keywords internal
.clip_urls <- function(track, media_dir = NULL) {
    ch <- rotio::children(track)
    urls <- character(0)
    for (item in ch) {
        if (!inherits(item, "Clip")) {
            next
        }
        mr <- tryCatch(rotio::media_reference(item), error = function(e) NULL)
        if (is.null(mr)) {
            next
        }
        u <- tryCatch(rotio::target_url(mr), error = function(e) NA_character_)
        if (!is.na(u) && nzchar(u)) {
            urls <- c(urls, .resolve_media(u, media_dir))
        }
    }
    urls
}

#' Walk a video track into renderable clip feeds and join fades
#'
#' Returns the clips' resolved files, per-clip source windows (the media to
#' feed in: the source range, widened at the front by a preceding Transition's
#' in_offset so the dissolve has its handle), and per-join fade durations
#' (0 = butt join). A clip without a source_range feeds the whole file.
#'
#' @param track A rotio video Track.
#' @param media_dir Base directory for relative urls, or NULL.
#' @return list(files, windows, fades, clips). A Clip over an
#'   ImageSequenceReference contributes an NA file, filled in by
#'   \code{.prerender_sources()}.
#' @keywords internal
.video_sequence <- function(track, media_dir = NULL) {
    files <- character(0)
    windows <- list()
    fades <- numeric(0)
    clips <- list()
    pending <- 0
    for (kd in rotio::children(track)) {
        if (inherits(kd, "Transition")) {
            if (rotio::to_seconds(kd$out_offset) > 0) {
                stop("render_timeline(): Transition with out_offset > 0 is ",
                     "not lowered yet (the outgoing clip would need a tail ",
                     "handle)", call. = FALSE)
            }
            pending <- rotio::to_seconds(kd$in_offset)
            next
        }
        if (!inherits(kd, "Clip")) {
            next
        }
        mr <- tryCatch(rotio::media_reference(kd), error = function(e) NULL)
        if (is.null(mr)) {
            next
        }
        if (inherits(mr, "ImageSequenceReference")) {
            # No single url to resolve; .prerender_sources() encodes the
            # sequence from the reference fields.
            u <- NA_character_
        } else {
            u <- tryCatch(rotio::target_url(mr),
                          error = function(e) NA_character_)
            if (is.na(u) || !nzchar(u)) {
                next
            }
            u <- .resolve_media(u, media_dir)
        }
        sr <- tryCatch(rotio::source_range(kd), error = function(e) NULL)
        if (length(files) > 0) {
            fade <- pending
        } else {
            fade <- 0
        }
        pending <- 0
        win <- NULL
        if (!is.null(sr)) {
            s <- rotio::to_seconds(sr$start_time)
            e <- s + rotio::to_seconds(sr$duration)
            # Widen the front by the fade so the dissolve consumes the head
            # handle (the conditioning replay) instead of played content.
            ws <- max(0, s - fade)
            # Legacy timelines wrote nominal [0, dur] ranges over whole-file
            # media; only a range that starts past 0 (a real head trim) or a
            # fade needing its handle constitutes an actual window.
            if (ws > 0 || fade > 0) {
                win <- c(ws, e)
            }
        }
        files <- c(files, u)
        if (length(files) > 1) {
            fades <- c(fades, fade)
        }
        # c(list(win)) rather than [[<-: assigning NULL would drop the slot.
        windows <- c(windows, list(win))
        clips <- c(clips, list(kd))
    }
    list(files = files, windows = windows, fades = fades, clips = clips)
}

#' Motion spec from a clip's OTIO effects
#'
#' Reads the open \code{cornball.*} effect namespace (see
#' \code{inst/schema/cornball-effects.md}). Effects outside the namespace
#' belong to other ecosystems and are ignored silently. Inside it, anything
#' this renderer cannot honor -- an unrecognized effect_name, enabled = FALSE,
#' or a metadata schema newer than supported -- degrades to a static clip with
#' one warning. The first usable motion effect wins; extras warn.
#'
#' @param effects List of rotio Effect objects (a clip's effects).
#' @return A Ken Burns metadata list for \code{\link{still_clip}}, or NULL.
#' @keywords internal
.motion_from_effects <- function(effects) {
    motion <- NULL
    for (e in effects) {
        en <- tryCatch(rotio::effect_name(e), error = function(err) "")
        if (!startsWith(en, "cornball.")) {
            next
        }
        if (!identical(en, "cornball.kenburns")) {
            warning("render_timeline(): skipping unrecognized effect '", en,
                    "' (clip renders static)", call. = FALSE)
            next
        }
        if (!isTRUE(tryCatch(rotio::enabled(e), error = function(err) TRUE))) {
            warning("render_timeline(): skipping disabled effect '", en, "'",
                    call. = FALSE)
            next
        }
        md <- tryCatch(rotio::metadata(e), error = function(err) NULL)
        sch <- if (is.null(md$schema)) {
            1L
        } else {
            suppressWarnings(as.integer(unlist(md$schema)[1]))
        }
        if (is.na(sch) || sch > 1L) {
            warning("render_timeline(): effect '", en, "' has schema ",
                    md$schema, "; this renderer supports 1 (clip renders ",
                    "static)", call. = FALSE)
            next
        }
        if (!is.null(motion)) {
            warning("render_timeline(): multiple motion effects on one clip; ",
                    "using the first", call. = FALSE)
            next
        }
        motion <- md
    }
    motion
}

#' Pre-render still-image and image-sequence clips into video
#'
#' A still image has no intrinsic duration or motion, and an
#' ImageSequenceReference has no single file: both are encoded into temporary
#' video clips here, so everything downstream (concat, crossfades, framing,
#' captions, audio) handles only video. Stills go through
#' \code{\link{still_clip}} at the source_range's rate, covering the media
#' window up to the range's end (so a preceding dissolve keeps its head
#' handle), rastered to the source aspect fitted inside the framing box, and
#' animated by the clip's \code{cornball.*} motion effects. Sequences go
#' through \code{\link{frames_clip}} at the reference's own rate; motion
#' effects are not applied to them (a drawn sequence animates itself). Video
#' files pass through untouched.
#'
#' @param seq_v A \code{.video_sequence()} result.
#' @param framing The timeline framing list, for still rastering.
#' @param media_dir Base directory for relative urls, or NULL.
#' @return list(files, temps): files with pre-rendered replacements, and the
#'   temp paths for the caller to clean up.
#' @keywords internal
.prerender_sources <- function(seq_v, framing, media_dir = NULL) {
    files <- seq_v$files
    temps <- character(0)
    for (i in seq_along(files)) {
        clip <- seq_v$clips[[i]]
        mr <- rotio::media_reference(clip)
        sr <- tryCatch(rotio::source_range(clip), error = function(e) NULL)

        if (inherits(mr, "ImageSequenceReference")) {
            zp <- as.integer(mr$frame_zero_padding)
            pattern <- paste0(mr$name_prefix,
                if (!is.na(zp) && zp > 0) {
                    sprintf("%%0%dd", zp)
                } else {
                    "%d"
                },
                              mr$name_suffix)
            fps <- as.numeric(mr$rate)
            if (!isTRUE(fps > 0)) {
                if (is.null(sr)) {
                    fps <- 30
                } else {
                    fps <- rotio::rate(sr$duration)
                }
            }
            tmp <- tempfile(fileext = ".mp4")
            frames_clip(.resolve_media(mr$target_url_base, media_dir), tmp,
                        fps = fps, pattern = pattern,
                        start = as.integer(mr$start_frame))
            files[i] <- tmp
            temps <- c(temps, tmp)
            next
        }

        if (!.is_image(files[i])) {
            next
        }
        if (is.null(sr)) {
            stop("render_timeline(): still-image clip '", rotio::name(clip),
                 "' needs a source_range (a still has no intrinsic duration)",
                 call. = FALSE)
        }
        # Media length = the range's end, so the head handle a preceding
        # Transition widened into exists in the pre-rendered file.
        dur <- rotio::to_seconds(sr$start_time) +
        rotio::to_seconds(sr$duration)
        fps <- rotio::rate(sr$duration)
        if (!isTRUE(fps > 0)) {
            fps <- 30
        }
        img <- normalizePath(files[i], mustWork = TRUE)
        size <- .still_size(as.integer(probe(img, "width")),
                            as.integer(probe(img, "height")), framing)
        motion <- .motion_from_effects(
                                       tryCatch(rotio::effects(clip), error = function(e) list()))
        tmp <- tempfile(fileext = ".mp4")
        still_clip(img, tmp, duration = dur, fps = fps, size = size,
                   motion = motion)
        files[i] <- tmp
        temps <- c(temps, tmp)
    }
    list(files = files, temps = temps)
}

#' Framing transform for the timeline
#'
#' Looks for \code{metadata$cornball$framing} on the first video clip's media
#' reference, then the clip, then the timeline. A framing is a list with optional
#' \code{scale} (a box edge, or c(w, h)), \code{pad} (c(w, h)), and \code{pos}
#' (c(x, y), defaulting to c(0, 0)). Any element may be a string for ffmpeg
#' expressions (e.g. pos = c("(ow-iw)/2", "(oh-ih)/2")).
#'
#' @param vtrack The primary video Track.
#' @param timeline The Timeline.
#' @return A framing list, or NULL.
#' @keywords internal
.timeline_framing <- function(vtrack, timeline) {
    clips <- Filter(function(x) inherits(x, "Clip"), rotio::children(vtrack))
    if (length(clips) > 0) {
        f <- tryCatch(rotio::metadata(rotio::media_reference(clips[[1]]))$cornball$framing,
                      error = function(e) NULL)
        if (!is.null(f)) {
            return(f)
        }
        f <- tryCatch(rotio::metadata(clips[[1]])$cornball$framing,
                      error = function(e) NULL)
        if (!is.null(f)) {
            return(f)
        }
    }
    tryCatch(rotio::metadata(timeline)$cornball$framing, error = function(e) NULL)
}

#' Build the scale/pad ffmpeg filter chain from a framing list
#'
#' @param framing A framing list, or NULL.
#' @return Character vector of filter expressions (possibly empty).
#' @keywords internal
.framing_vf <- function(framing) {
    vf <- character(0)
    if (is.null(framing)) {
        return(vf)
    }
    if (!is.null(framing$scale)) {
        s <- framing$scale
        if (length(s) == 1) {
            s <- c(s, s)
        }
        vf <- c(vf, sprintf("scale=%s:%s:force_original_aspect_ratio=decrease",
                            s[1], s[2]))
    }
    if (!is.null(framing$pad)) {
        pad <- framing$pad
        if (is.null(framing$pos)) {
            pos <- c(0, 0)
        } else {
            pos <- framing$pos
        }
        vf <- c(vf, sprintf("pad=%s:%s:%s:%s:black",
                            pad[1], pad[2], pos[1], pos[2]))
    }
    vf
}

#' Build the subtitles burn filter for a subtitle file
#'
#' Escapes the path the way ffmpeg's subtitles filter requires.
#'
#' @param sub_file Path to an .ass/.srt/.vtt file.
#' @return A single filter expression.
#' @keywords internal
.subtitles_filter <- function(sub_file) {
    p <- gsub("\\\\", "/", sub_file)
    p <- gsub(":", "\\\\:", p)
    sprintf("subtitles='%s'", p)
}

#' First subtitle file referenced by any caption track
#'
#' @param ctracks List of caption Tracks.
#' @param media_dir Base directory for relative urls, or NULL.
#' @return A subtitle path, or NULL.
#' @keywords internal
.caption_file <- function(ctracks, media_dir = NULL) {
    for (t in ctracks) {
        # A caption track may point at its burn artifact directly via
        # metadata$cornball$ass (its clips hold text, not an .ass reference).
        ass <- tryCatch(rotio::metadata(t)$cornball$ass,
                        error = function(e) NULL)
        if (!is.null(ass) && nzchar(ass)) {
            return(.resolve_media(ass, media_dir))
        }
        urls <- .clip_urls(t, media_dir)
        subs <- urls[grepl("\\.(ass|srt|vtt)$", urls, ignore.case = TRUE)]
        if (length(subs) > 0) {
            return(subs[[1]])
        }
    }
    NULL
}

#' Assemble one video track into a single renderable file
#'
#' The track walk + still/sequence pre-render + passthrough/concat/crossfade
#' pipeline, shared by the content track and layout slot tracks.
#'
#' @param track A rotio video Track.
#' @param media_dir Base directory for relative urls, or NULL.
#' @param framing Framing for still rastering (NULL for slot tracks).
#' @return list(file, temps), or NULL when the track has no clips.
#' @keywords internal
.assemble_track <- function(track, media_dir, framing) {
    seq_v <- .video_sequence(track, media_dir)
    if (length(seq_v$files) == 0) {
        return(NULL)
    }
    pre <- .prerender_sources(seq_v, framing, media_dir)
    temps <- pre$temps
    vclips <- normalizePath(pre$files, mustWork = TRUE)
    trivial <- all(seq_v$fades == 0) &&
    all(vapply(seq_v$windows, is.null, logical(1)))
    if (length(vclips) == 1 && trivial) {
        file <- vclips[[1]]
    } else if (trivial) {
        # Whole files, butt joins: stream-copy concat beats filtering.
        file <- tempfile(fileext = ".mp4")
        temps <- c(temps, file)
        concat(vclips, file, overwrite = TRUE)
    } else {
        file <- tempfile(fileext = ".mp4")
        temps <- c(temps, file)
        crossfade_concat(vclips, file,
                         fade = if (length(seq_v$fades)) seq_v$fades else 0,
                         windows = seq_v$windows, overwrite = TRUE)
    }
    list(file = file, temps = temps)
}

#' Render an OTIO Timeline to a Video File
#'
#' Lowers a rotio (OpenTimelineIO) Timeline to a single ffmpeg invocation and
#' renders it. The video track's clips are concatenated in order; an optional
#' separate audio track is mapped over them; a framing transform (scale + pad,
#' from \code{metadata$cornball$framing}) and an optional caption burn (from a
#' caption track referencing an .ass/.srt file) are applied.
#'
#' The video track is lowered honoring per-clip \code{source_range} trims and
#' \code{Transition}s: a transition between two clips becomes a dissolve over
#' exactly its duration, fed by the incoming clip's head handle (the media
#' before its source range -- for chained generation, the conditioning-head
#' replay). Only transitions with \code{out_offset == 0} are lowered (the
#' cornductor bundle shape: the outgoing clip has no tail handle). Gaps and
#' overlapping layers are not lowered yet.
#'
#' Still-image clips (a media reference targeting a \code{.png}/\code{.jpg}/
#' ...) are pre-rendered into video via \code{\link{still_clip}} at the
#' source_range's rate, honoring \code{cornball.*} motion effects on the clip
#' (Ken Burns pan/zoom; the schema lives in
#' \code{system.file("schema", "cornball-effects.md", package = "compost")}).
#' A still-image clip must carry a source_range. \code{ImageSequenceReference}
#' clips are pre-rendered via \code{\link{frames_clip}} at the reference's
#' rate; motion effects are not applied to sequences. Effects the renderer
#' cannot honor degrade to a static clip with a warning; effects outside the
#' \code{cornball.} namespace are ignored silently.
#'
#' When the timeline carries \code{metadata$cornball$layout} (schema in
#' \code{system.file("schema", "cornball-layout.md", package = "compost")}),
#' tracks whose \code{cornball$role} matches a layout slot are each assembled
#' like the content track and composed onto the canvas via
#' \code{\link{compose_layout}} -- muted, painted in slot order, with the
#' content track bound to the \code{visual} slot as the timing reference.
#' The framing transform is suppressed when composing (the layout defines
#' the canvas). A layout the renderer cannot honor -- schema too new, a
#' slot's track missing, or a roled track without a slot -- degrades to the
#' plain single-stream render with one warning.
#'
#' Caption tracks are identified by \code{metadata$cornball$role == "caption"} or
#' a name containing "caption". When the primary video track has a single clip
#' that already carries its own audio (the talking-head case), no separate audio
#' track is needed.
#'
#' @param timeline A rotio Timeline, or a path to a \code{.otio} file.
#' @param output Path for the output video file.
#' @param media_dir Base directory for resolving relative media urls. Defaults to
#'   the timeline file's directory when \code{timeline} is a path, otherwise NULL
#'   (urls used as-is).
#' @param overwrite If TRUE (default), overwrite the output file.
#' @param dry_run If TRUE, return the ffmpeg command string without executing.
#'   Note: concatenation of multiple video clips, still/sequence
#'   pre-renders, and layout composition still run, since they produce
#'   intermediates the final command depends on.
#'
#' @return Invisibly returns the output path. If dry_run, returns the command
#'   string for the final render pass.
#'
#' @examples
#' \dontrun{
#' render_timeline("AAA/20260131/t41_n22_intro/timeline.otio",
#'                 "AAA/20260131/t41_n22_intro/video.mp4")
#' }
#'
#' @export
render_timeline <- function(timeline, output, media_dir = NULL,
                            overwrite = TRUE, dry_run = FALSE) {
    if (is.character(timeline)) {
        tl_path <- normalizePath(timeline, mustWork = TRUE)
        if (is.null(media_dir)) {
            media_dir <- dirname(tl_path)
        }
        timeline <- rotio::from_json_file(tl_path)
    }

    output <- normalizePath(output, mustWork = FALSE)

    # Partition the Video-kind tracks: captions, layout slot tracks (role
    # matching a layout slot other than "visual"), and content.
    vk_tracks <- rotio::video_tracks(timeline)
    is_cap <- vapply(vk_tracks, .is_caption_track, logical(1))
    ctracks <- vk_tracks[is_cap]
    vtracks <- vk_tracks[!is_cap]
    atracks <- rotio::audio_tracks(timeline)

    layout <- .timeline_layout(timeline)
    if (!is.null(layout) && is.null(.layout_slots(layout))) {
        layout <- NULL # schema too new: warned inside, degrade to slides-only
    }
    slot_names <- if (is.null(layout)) {
        character(0)
    } else {
        setdiff(names(layout$slots), "visual")
    }
    roles <- vapply(vtracks, .track_role, character(1))
    in_slot <- roles %in% slot_names
    stray <- !(roles %in% c("", "visual")) & !in_slot
    if (any(stray)) {
        warning("render_timeline(): track role(s) ",
                paste(unique(roles[stray]), collapse = ", "),
                " have no layout slot; ignoring those tracks", call. = FALSE)
    }
    slot_tracks <- vtracks[in_slot]
    names(slot_tracks) <- roles[in_slot]
    vtracks <- vtracks[!in_slot & !stray]

    if (length(vtracks) == 0) {
        stop("render_timeline(): no video track found", call. = FALSE)
    }

    # Framing is resolved before pre-rendering: still clips are rastered to
    # their source aspect fitted inside the framing box.
    framing <- .timeline_framing(vtracks[[1]], timeline)

    base <- .assemble_track(vtracks[[1]], media_dir, framing)
    if (is.null(base)) {
        stop("render_timeline(): the video track has no clips", call. = FALSE)
    }
    if (length(base$temps) > 0) {
        on.exit(unlink(base$temps), add = TRUE)
    }
    base_video <- base$file

    # Layout composition: paint the content and each slot track onto the
    # canvas. The composed base is video-only, so the narration-bed mapping
    # below stays authoritative. Framing is suppressed afterwards (the
    # layout defines the canvas); it was already consumed above for still
    # rastering, so slides keep their full-quality raster and the compose
    # downscales into the slot.
    if (!is.null(layout) && length(slot_names) > 0) {
        lost <- setdiff(slot_names, names(slot_tracks))
        ok <- length(lost) == 0
        if (!ok) {
            warning("render_timeline(): layout slot(s) ",
                    paste(lost, collapse = ", "),
                    " have no matching track; rendering without composition",
                    call. = FALSE)
        }
        srcs <- list(visual = base_video)
        if (ok) {
            for (nm in slot_names) {
                st <- .assemble_track(slot_tracks[[nm]], media_dir, NULL)
                if (is.null(st)) {
                    warning("render_timeline(): slot track '", nm,
                            "' has no clips; rendering without composition",
                            call. = FALSE)
                    ok <- FALSE
                    break
                }
                if (length(st$temps) > 0) {
                    on.exit(unlink(st$temps), add = TRUE)
                }
                srcs[[nm]] <- st$file
            }
        }
        if (ok) {
            composed <- tempfile(fileext = ".mp4")
            on.exit(unlink(composed), add = TRUE)
            compose_layout(srcs, composed, layout, reference = "visual")
            base_video <- composed
            framing <- NULL
        }
    }

    # Framing (scale + pad) and an optional caption burn share one filter chain.
    vf <- .framing_vf(framing)

    sub_file <- .caption_file(ctracks, media_dir)
    if (!is.null(sub_file)) {
        sub_file <- normalizePath(sub_file, mustWork = TRUE)
        vf <- c(vf, .subtitles_filter(sub_file))
    }

    # Audio: a separate audio track if present, otherwise from the video itself.
    audio_file <- NULL
    if (length(atracks) > 0) {
        aclips <- .clip_urls(atracks[[1]], media_dir)
        if (length(aclips) > 0) {
            audio_file <- normalizePath(aclips[[1]], mustWork = TRUE)
        }
    }

    # The narration is the ground truth: when an audio bed is mapped, the
    # video is padded (last frame held) or cut to EXACTLY the audio's
    # duration, so the two streams always match. Spoken words are never
    # clipped to a too-short video; surplus silent video never trails the
    # voice.
    n_frames <- NULL
    if (!is.null(audio_file)) {
        adur <- as.numeric(probe(audio_file, "duration"))
        vfps <- .video_fps(base_video)
        n_frames <- as.integer(round(adur * vfps))
        vf <- c(vf, "tpad=stop_mode=clone:stop=-1")
    }

    args <- c(
        if (overwrite) "-y",
              "-i", base_video,
        if (!is.null(audio_file)) c("-i", audio_file),
        if (length(vf) > 0) c("-vf", paste(vf, collapse = ",")),
              "-map", "0:v:0",
        if (!is.null(audio_file)) c("-map", "1:a:0") else c("-map", "0:a:0?"),
        if (!is.null(n_frames)) c("-frames:v", n_frames),
        if (length(vf) > 0) {
            c("-c:v", "libx264", "-preset", "fast")
        } else {
            c("-c:v", "copy")
        },
              "-c:a", "aac",
              "-movflags", "+faststart",
              output
    )

    if (dry_run) {
        return(.run_ffmpeg(args, dry_run = TRUE))
    }
    .run_ffmpeg(args)
    invisible(output)
}

Try the compost package in your browser

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

compost documentation built on Aug. 4, 2026, 5:11 p.m.