diff options
| author | Dennis Fink | 2026-05-30 21:16:51 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-05-30 21:16:51 +0200 |
| commit | 8d98f988a52dbfd7edc074725c628f24c20c270a (patch) | |
| tree | 8899c3567e0cf0808fedf85a750b61ad3b375580 | |
| parent | 0d2d0535c9eada6beb928e57ac8f992801f00602 (diff) | |
| download | transcode.sh-8d98f988a52dbfd7edc074725c628f24c20c270a.tar.gz transcode.sh-8d98f988a52dbfd7edc074725c628f24c20c270a.zip | |
feat(output): add no-replacement output mode
Add --output-dir and [processing].output_dir so encoded files can be
written to an existing output directory without replacing, moving, or
deleting originals.
Preserve input paths below the output directory, avoid overwriting
existing outputs with numeric suffixes, and keep --backup-dir compatible
with --output-dir.
Update finalization, backup, and size-report handling so replacement
mode and output-dir mode share the same output path flow.
Document the new mode in the README and man page, add bash completion
for the new option, and expand the size report schema with input and
output paths.
Closes #7.
| -rw-r--r-- | README.md | 39 | ||||
| -rwxr-xr-x | transcode.sh | 361 | ||||
| -rw-r--r-- | transcode.sh.1 | 67 | ||||
| -rw-r--r-- | transcode.sh.bash-completion.sh | 11 |
4 files changed, 360 insertions, 118 deletions
@@ -15,7 +15,8 @@ Revision Date: <!-- revision-date -->2026-05-23<!-- /revision-date --> small shell snippets that supply the ffmpeg arguments. It probes each input file to detect the video codec and pixel format, skips files whose codec is on the skip list, selects an appropriate output pixel format automatically, and -replaces the original atomically on success. +replaces the original atomically on success, unless `--output-dir` is used to +write encoded copies elsewhere. ## Features @@ -34,6 +35,8 @@ replaces the original atomically on success. video stream is probed for preset helper variables. - **Backup original** (`--backup-dir DIR`) — copy originals to an existing backup directory before replacing them. +- **No-replacement output mode** (`--output-dir DIR`) — write encoded files to + a separate existing directory without replacing or moving originals. - **Nice** — runs ffmpeg at niceness 19 by default to avoid starving other processes; configurable with `-N`. - **Respects [NO\_COLOR](https://no-color.org/)**. @@ -169,6 +172,7 @@ video_stream = "v:0" [processing] skip_codecs = ["av1", "hevc"] backup_dir = "backups" +output_dir = "encoded" only_if_smaller = false verify_output = true continue = false @@ -190,6 +194,7 @@ quiet = false | `[encoding]` | `video_stream` | string | `--video-stream` | Selects the input video stream that is probed for preset helper variables. It defaults to `"v:0"`. Invalid config values are ignored with a warning and the default is used. This setting does not change ffmpeg stream mapping in presets. | | `[processing]` | `skip_codecs` | array of strings | `-S` / `--skip-codec-override` | Codecs defined here and codecs defined using `--skip-codec` from the CLI are **merged** by default. Use `--skip-codec-override` to ignore configured skip codecs and use only codecs provided through `--skip-codec` for that run. | | `[processing]` | `backup_dir` | string | `--backup-dir` | Must point to an existing directory. | +| `[processing]` | `output_dir` | string | `--output-dir` | Must point to an existing directory. Encoded files are written below this directory instead of replacing originals. | | `[processing]` | `only_if_smaller` | boolean | `--only-if-smaller` / `--no-only-if-smaller` | | | `[processing]` | `verify_output` | boolean | `--verify-output` / `--no-verify-output` | | | `[processing]` | `continue` | boolean | `--continue` / `--no-continue` | | @@ -231,7 +236,8 @@ transcode.sh [OPTION] [--] FILE... | `--skip-codec-override` | Use only codecs from `--skip-codec`, ignoring `[processing].skip_codecs` from config | | `-n`, `--dry-run` | Show what would be done; don't run ffmpeg | | `--backup-dir DIR`, `--backup-dir=DIR` | Copy originals to an existing backup directory before replacing them | -| `-l`, `--only-if-smaller` | Only replace original if new file is smaller | +| `--output-dir DIR`, `--output-dir=DIR` | Write encoded files to an existing output directory instead of replacing originals | +| `-l`, `--only-if-smaller` | Only keep the encoded output if it is smaller than the original | | `--no-only-if-smaller` | Do replace original even if new file is bigger | | `--verify-output` | Probe output with ffprobe before replacing original (default) | | `--no-verify-output` | Skip the post-encode integrity check | @@ -278,6 +284,9 @@ transcode.sh --dry-run --verbose my_movie.mkv # Keep originals in an existing backup directory after successful encodes transcode.sh --backup-dir ~/transcode-backups *.mp4 +# Write encoded copies elsewhere without replacing originals +transcode.sh --preset av1 --output-dir ./encoded video.mp4 + # Use a custom preset, only keep result if smaller, skip AV1 inputs transcode.sh --preset av1_fast --only-if-smaller --skip-codec av1 /media/films/*.mkv @@ -299,18 +308,34 @@ the source file's absolute path below that directory and existing backup files are not overwritten; numeric suffixes such as `.1` and `.2` are appended when needed. +When `--output-dir` is used, the output directory must already exist. Originals +are never replaced, moved, or deleted. Output paths preserve the input path below +the output directory: relative inputs keep their relative path (for example +`movies/a.mp4` becomes `encoded/movies/a.mp4`), while absolute inputs are stored +without the leading slash. Existing output files are not overwritten; numeric +suffixes such as `.1` and `.2` are appended when needed. `--backup-dir` remains +compatible with `--output-dir`; when both are set, originals are copied to the +backup directory and encoded files are written to the output directory. + +With `--only-if-smaller`, output-dir mode keeps the same size policy: if the +encoded file is larger than the original, the new output is deleted and no output +copy is kept. + ## Size report TSV When `--size-report` is enabled, the TSV log uses this schema: ```tsv -status filename original_bytes new_bytes saved_pct +status input_path output_path original_bytes new_bytes saved_pct ``` -The `status` column is one of `encoded`, `skipped_codec`, `skipped_larger`, -or `failed`. Failed rows may leave `new_bytes` and `saved_pct` empty when no -encoded output exists. `skipped_larger` rows record the temporary output size -and therefore usually have a negative `saved_pct`; the original file is kept. +The `status` column is one of `encoded`, `skipped_codec`, `skipped_larger`, or +`failed`. `input_path` is always the original input path. `output_path` is the +final path for successful encodes. For size-based skips, it records the planned +encoded path. It may be empty when no encoded output exists. Failed rows may +leave `new_bytes` and `saved_pct` empty when no encoded output exists. +`skipped_larger` rows record the temporary output size and therefore usually +have a negative `saved_pct`; the original file is kept. For runs with more than one input file, `--size-report` also prints an end summary with encoded/skipped/failed counts, total bytes saved, and the overall diff --git a/transcode.sh b/transcode.sh index e7c0a99..0dfded6 100755 --- a/transcode.sh +++ b/transcode.sh @@ -39,6 +39,7 @@ # # [processing] # skip_codecs = ["av1", "hevc"] +# output_dir = "encoded" # # [size_report] # enabled = false @@ -113,6 +114,8 @@ set -o errtrace set -o nounset # Make pipelines fail if any command in the pipeline fails set -o pipefail +# Enable extended glob patterns (e.g. *(...) for repetition) +shopt -s extglob ############################################################################### # SCRIPT METADATA @@ -148,6 +151,7 @@ QUIET=0 VERBOSE=0 BACKUP_DIR="" +OUTPUT_DIR="" CONTINUE_ON_FAIL=0 DRY_RUN=0 ENCODE_FILE="" @@ -173,6 +177,7 @@ CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/config.toml" # Sentinel flags: set to 1 by the CLI option parser so that load_config() knows # which values have already been provided and must not be overridden. _CLI_BACKUP_DIR=0 +_CLI_OUTPUT_DIR=0 _CLI_CONFIG_FILE=0 _CLI_CONTINUE=0 _CLI_FFMPEG_LOGLEVEL=0 @@ -295,9 +300,10 @@ ${BOLD}${BLUE}Processing options:${ALL_OFF} ${BOLD}${YELLOW}-S, --skip-codec${ALL_OFF} ${BOLD}${GREEN}LIST${ALL_OFF} skip files with these codecs; ${BOLD}${GREEN}LIST${ALL_OFF} is comma, space, or colon separated ${BOLD}${YELLOW}--skip-codec-override${ALL_OFF} use only codecs from ${BOLD}${YELLOW}--skip-codec${ALL_OFF}, ignoring ${BOLD}${CYAN}[processing].skip_codecs${ALL_OFF} from config ${BOLD}${YELLOW}-n, --dry-run${ALL_OFF} show what would be done, do not run ffmpeg - ${BOLD}${YELLOW}--backup-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} backup original files to DIR instead of overwriting them - ${BOLD}${YELLOW}-l, --only-if-smaller${ALL_OFF} only replace original if new file is smaller - ${BOLD}${YELLOW}--no-only-if-smaller${ALL_OFF} do replace original even if new file is bigger + ${BOLD}${YELLOW}--backup-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} backup original files to DIR before replacing them + ${BOLD}${YELLOW}--output-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} write encoded files to DIR without replacing originals + ${BOLD}${YELLOW}-l, --only-if-smaller${ALL_OFF} only keep the encoded output if it is smaller + ${BOLD}${YELLOW}--no-only-if-smaller${ALL_OFF} keep the encoded output even if it is larger ${BOLD}${YELLOW}--verify-output${ALL_OFF} probe output with ffprobe before replacing original (default) ${BOLD}${YELLOW}--no-verify-output${ALL_OFF} skip the post-encode integrity check ${BOLD}${YELLOW}-c, --continue${ALL_OFF} continue with the next file even if ffmpeg fails @@ -418,12 +424,119 @@ function format_filesize() { numfmt --to=iec -- "$1" } -# Return PATH without a leading slash so it can safely be placed below another -# directory while still preserving its absolute path structure. -function strip_leading_slash() { - local path="$1" - path="${path#/}" - printf '%s\n' "$path" +# Return an unused path. Existing files are never overwritten; numeric suffixes +# are appended until a free name is found. +function unique_path() { + local target="$1" + + if [[ ! -e "$target" ]]; then + printf '%s\n' "$target" + return $EXIT_OK + fi + + local candidate counter + counter=1 + while :; do + candidate="${target}.${counter}" + if [[ ! -e "$candidate" ]]; then + printf '%s\n' "$candidate" + return $EXIT_OK + fi + ((counter++)) + done +} + +# Print TARGET_PATH relative to BASE_DIR. +# +# Uses GNU realpath --relative-to when available. Falls back to Bash path +# manipulation for BSD/macOS systems where realpath may not support +# --relative-to. +function relative_path_from() { + local base_dir="$1" + local target_path="$2" + local resolved_base resolved_target + resolved_base=$(resolve_path "$base_dir") || return $EXIT_USAGE_ERROR + resolved_target=$(resolve_path "$target_path") || return $EXIT_USAGE_ERROR + + # Short-circuit for identical paths. + if [[ "$resolved_target" == "$resolved_base" ]]; then + printf '.\n' + return $EXIT_OK + fi + + local realpath_output + if realpath_output=$(realpath --relative-to="$resolved_base" -- "$resolved_target" 2>/dev/null); then + printf '%s\n' "$realpath_output" + return $EXIT_OK + fi + + # Walk base up until it is a true prefix of target (on a component boundary). + local prefix="" + while [[ "$resolved_base" != "/" && "$resolved_target" != "$resolved_base"/* && "$resolved_target" != "$resolved_base" ]]; do + resolved_base="${resolved_base%/*}" + if [[ -z "$resolved_base" ]]; then + resolved_base="/" + fi + prefix="../$prefix" + done + + local rel + if [[ "$resolved_target" == "$resolved_base" ]]; then + rel="." + elif [[ "$resolved_base" == "/" ]]; then + rel="${resolved_target#/}" + else + rel="${resolved_target#"$resolved_base"/}" + fi + + printf '%s%s\n' "$prefix" "$rel" +} + +# Build a path below DEST_DIR by re-rooting SOURCE_PATH relative to +# DEST_DIR's parent. This preserves the diverging portion of SOURCE_PATH +# while anchoring it under DEST_DIR. Leading ".." components are stripped +# so the result cannot escape DEST_DIR. +function path_under_directory() { + local dest_dir="$1" + local source_path="$2" + + local resolved_dest dest_parent relative_path + + resolved_dest=$(resolve_path "$dest_dir") || { + return $EXIT_USAGE_ERROR + } + + dest_parent=$(dirname -- "$resolved_dest") + + relative_path=$(relative_path_from "$dest_parent" "$source_path") || { + return $EXIT_USAGE_ERROR + } + + relative_path="${relative_path##*([.][.]/)}" + + if [[ -z "$relative_path" || "$relative_path" == "." ]]; then + return $EXIT_USAGE_ERROR + fi + + printf '%s/%s\n' "$resolved_dest" "$relative_path" +} + +function validate_existing_directory_option() { + local option_name="$1" + local path_value="$2" + + if [[ ! -e "$path_value" ]]; then + error "$option_name directory does not exist:" "$path_value" + exit $EXIT_USAGE_ERROR + fi + if [[ ! -d "$path_value" ]]; then + error "$option_name path is not a directory:" "$path_value" + exit $EXIT_USAGE_ERROR + fi + if [[ ! -w "$path_value" || ! -x "$path_value" ]]; then + error "$option_name directory is not writable/executable:" "$path_value" + exit $EXIT_USAGE_ERROR + fi } ############################################################################### @@ -588,6 +701,15 @@ function load_config() { fi fi + # [processing] output_dir + if [[ $_CLI_OUTPUT_DIR -eq 0 ]]; then + val=$(_tomlq_get '.processing.output_dir // empty') + if [[ -n "$val" ]]; then + debug "Config: processing.output_dir =" "$val" + OUTPUT_DIR="$val" + fi + fi + # [processing] only_if_smaller if [[ $_CLI_ONLY_IF_SMALLER -eq 0 ]]; then val=$(_tomlq_get '.processing.only_if_smaller // empty') @@ -909,20 +1031,21 @@ function append_size_report_row() { fi local status="$1" - local filename="$2" - local original_bytes="${3:-}" - local new_bytes="${4:-}" - local saved_pct="${5:-}" + local input_path="$2" + local output_path="${3:-}" + local original_bytes="${4:-}" + local new_bytes="${5:-}" + local saved_pct="${6:-}" if [[ ! -f "$SIZE_REPORT_FILE" ]]; then - printf "status\tfilename\toriginal_bytes\tnew_bytes\tsaved_pct\n" >"$SIZE_REPORT_FILE" + printf "status\tinput_path\toutput_path\toriginal_bytes\tnew_bytes\tsaved_pct\n" >"$SIZE_REPORT_FILE" fi if [[ -n "$saved_pct" ]]; then saved_pct="${saved_pct}%" fi - printf "%s\t%s\t%s\t%s\t%s\n" "$status" "$filename" "$original_bytes" "$new_bytes" "$saved_pct" >>"$SIZE_REPORT_FILE" + printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$status" "$input_path" "$output_path" "$original_bytes" "$new_bytes" "$saved_pct" >>"$SIZE_REPORT_FILE" } # Print per-file size feedback after an encode attempt that produced an output @@ -974,94 +1097,113 @@ function print_size_report_summary() { } ############################################################################### -# BACKUP HANDLING +# OUTPUT FINALIZATION ############################################################################### -# Return an unused backup path. Existing backups are never overwritten; numeric -# suffixes are appended until a free name is found. -function unique_backup_path() { - local target="$1" +# Copy the original file to BACKUP_DIR, preserving the source path below that +# directory. Existing backup files are never overwritten. +function backup_original() { + local input_file="$1" - if [[ ! -e "$target" ]]; then - printf '%s\n' "$target" + if [[ -z "$BACKUP_DIR" ]]; then return $EXIT_OK fi - local candidate counter - counter=1 - while :; do - candidate="${target}.${counter}" - if [[ ! -e "$candidate" ]]; then - printf '%s\n' "$candidate" - return $EXIT_OK - fi - ((counter++)) - done -} - -# Copy the original file to BACKUP_DIR, preserving the absolute source path -# below that directory, then move the encoded temporary output into the original -# location. The original remains in place until the final replacement step. -function backup_then_replace_original() { - local input_file="$1" - local temporary_output_file="$2" - - debug "Preparing backup replacement for:" "$input_file" - - local resolved_input relative_backup_path backup_target backup_parent + debug "Preparing backup for:" "$input_file" + local resolved_input backup_target backup_parent resolved_input=$(resolve_path "$input_file") debug "Resolved input path:" "$resolved_input" - relative_backup_path=$(strip_leading_slash "$resolved_input") - debug "Relative backup path:" "$relative_backup_path" - - backup_target=$(unique_backup_path "$BACKUP_DIR/$relative_backup_path") + backup_target=$(unique_path "$(path_under_directory "$BACKUP_DIR" "$resolved_input")") debug "Selected backup target:" "$backup_target" backup_parent=$(dirname -- "$backup_target") - - mkdir -p -- "$backup_parent" || { + mkdir --parents -- "$backup_parent" || { error "Could not create backup parent directory:" "$backup_parent" return $EXIT_RUNTIME_FAILURE } - if ! cp -p -- "$input_file" "$backup_target"; then + if ! cp --preserve=mode,ownership,timestamps -- "$input_file" "$backup_target"; then error "Could not copy original to backup directory:" "$backup_target" return $EXIT_RUNTIME_FAILURE - else - debug "Original copied to backup:" "$backup_target" fi if [[ ! -r "$backup_target" ]]; then error "Backup copy is not readable:" "$backup_target" return $EXIT_RUNTIME_FAILURE - else - verbose "Backed up original to:" "$backup_target" fi - if mv -f -- "$temporary_output_file" "$input_file"; then - msg "Replaced:" "$input_file" + verbose "Backed up original to:" "$backup_target" + return $EXIT_OK +} + +# Return the final encoded output path for INPUT_FILE. In replacement mode this +# is the original path. In --output-dir mode it is a unique path below +# OUTPUT_DIR with the source path preserved. +function final_output_path() { + local input_file="$1" + + if [[ -z "$OUTPUT_DIR" ]]; then + printf '%s\n' "$input_file" return $EXIT_OK fi - error "Could not move encoded output:" "$input_file" - error "Original file was left in place:" "$input_file" - return $EXIT_RUNTIME_FAILURE + local output_source_path base directory stem extension target + output_source_path="$input_file" + base="${output_source_path##*/}" + directory=$(dirname -- "$output_source_path") + + if [[ "$base" == *.* ]]; then + stem="${base%.*}" + extension="${base##*.}" + else + stem="$base" + extension="" + fi + + if [[ "$directory" == "." ]]; then + target=$(path_under_directory "$OUTPUT_DIR" "$stem${extension:+.$extension}") + else + target=$(path_under_directory "$OUTPUT_DIR" "$directory/$stem${extension:+.$extension}") + fi + unique_path "$target" } -# Replace the original with the encoded output. If BACKUP_DIR is set, preserve -# the original instead of deleting it. -function replace_original() { +# Finalize an encoded temporary output. This is the single handoff point used by +# encode_one() for replacement mode, backup mode and --output-dir mode. +function finalize_encoded_output() { local input_file="$1" local temporary_output_file="$2" + local final_output_file="$3" + + if [[ -n "$OUTPUT_DIR" ]]; then + if ! backup_original "$input_file"; then + return $EXIT_RUNTIME_FAILURE + fi + + local final_parent + final_parent=$(dirname -- "$final_output_file") + mkdir --parents -- "$final_parent" || { + error "Could not create output parent directory:" "$final_parent" + return $EXIT_RUNTIME_FAILURE + } - if [[ -n "$BACKUP_DIR" ]]; then - backup_then_replace_original "$input_file" "$temporary_output_file" - return $? + if mv -- "$temporary_output_file" "$final_output_file"; then + msg "Wrote output:" "$final_output_file" + return $EXIT_OK + fi + + error "Could not move encoded output to:" "$final_output_file" + error "Original file was left in place:" "$input_file" + return $EXIT_RUNTIME_FAILURE fi - if mv -f -- "$temporary_output_file" "$input_file"; then + if ! backup_original "$input_file"; then + return $EXIT_RUNTIME_FAILURE + fi + + if mv --force -- "$temporary_output_file" "$input_file"; then msg "Replaced:" "$input_file" return $EXIT_OK fi @@ -1119,28 +1261,18 @@ function encode_one() { return $EXIT_OK fi - # filename (path without extension) is used only for the size report log. - # Note: only the last extension is stripped, so multi-dot names like - # "my.show.s01e01.mkv" are logged as "my.show.s01e01". - local filename - if [[ "$input_file" == *.* ]]; then - filename="${input_file%.*}" - else - filename="$input_file" - fi - msg "Processing [$current_file_index/$amount_total_files]:" "$input_file" if [[ ! -f "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" + append_size_report_row "failed" "$input_file" error "Not found:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ ! -r "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" + append_size_report_row "failed" "$input_file" error "File is not readable:" "$input_file" return $EXIT_RUNTIME_FAILURE fi @@ -1172,28 +1304,28 @@ function encode_one() { if [[ -z "$input_codec" ]]; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" "$(filesize "$input_file")" + append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input video codec:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ -v SKIP_CODECS["$input_codec"] ]]; then ((_REPORT_SKIPPED_CODEC++)) - append_size_report_row "skipped_codec" "$filename" "$(filesize "$input_file")" + append_size_report_row "skipped_codec" "$input_file" "" "$(filesize "$input_file")" msg "Skipping (codec excluded):" "$input_codec" return $EXIT_OK fi if [[ -z "$input_pixel_format" ]]; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" "$(filesize "$input_file")" + append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input pixel format:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ -z "$input_frame_rate" ]]; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" "$(filesize "$input_file")" + append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input frame rate:" "$input_file" return $EXIT_RUNTIME_FAILURE fi @@ -1262,7 +1394,7 @@ function encode_one() { # Build temporary output path and ffmpeg command # --------------------------------------------------------------------------- - local extension directory temporary_output_file base stem + local extension directory temporary_output_file base stem final_output_file base="${input_file##*/}" if [[ "$base" == *.* ]]; then @@ -1283,6 +1415,9 @@ function encode_one() { TEMPORARY_FILES+=("$temporary_output_file") debug "Temporary output path:" "$temporary_output_file" + final_output_file=$(final_output_path "$input_file") + debug "Final output path:" "$final_output_file" + # Build ffmpeg command: # - Map all streams, metadata, and chapters # - Default to stream copy for everything @@ -1317,6 +1452,7 @@ function encode_one() { msg "DRY RUN:" "Would encode with preset $PRESET_NAME" encode_one_print_metadata verbose "Command:" "${encode_cmd[*]}" + verbose "Final output path:" "$final_output_file" return $EXIT_OK fi @@ -1338,7 +1474,7 @@ function encode_one() { debug "Verifying output integrity:" "$temporary_output_file" if ! ffprobe -v error "file:$temporary_output_file" >/dev/null 2>&1; then ((_REPORT_INTEGRITY_FAILED++)) - append_size_report_row "failed" "$filename" "$(filesize "$input_file")" "$(filesize "$temporary_output_file")" + append_size_report_row "failed" "$input_file" "$temporary_output_file" "$(filesize "$input_file")" "$(filesize "$temporary_output_file")" error "Output failed integrity check:" "$temporary_output_file" rm -f -- "$temporary_output_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then @@ -1362,17 +1498,17 @@ function encode_one() { # If requested, keep the original if the new file is larger. if [[ $ONLY_IF_SMALLER -eq 1 && $new_filesize -gt $original_filesize ]]; then ((_REPORT_SKIPPED_LARGER++)) - append_size_report_row "skipped_larger" "$filename" "$original_filesize" "$new_filesize" "$filesize_percentage" + append_size_report_row "skipped_larger" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "skipped_larger" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" - msg "Did not replace (new file is larger):" "$input_file" + msg "Did not keep output (new file is larger):" "$final_output_file" rm -f -- "$temporary_output_file" return $EXIT_OK fi - if ! replace_original "$input_file" "$temporary_output_file"; then + if ! finalize_encoded_output "$input_file" "$temporary_output_file" "$final_output_file"; then ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" "$original_filesize" "$new_filesize" - error "Replacement FAILED:" "$input_file" + append_size_report_row "failed" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" + error "Output finalization FAILED:" "$final_output_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE else @@ -1381,7 +1517,7 @@ function encode_one() { fi fi - append_size_report_row "encoded" "$filename" "$original_filesize" "$new_filesize" "$filesize_percentage" + append_size_report_row "encoded" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "encoded" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" ((_REPORT_SUCCEEDED++)) @@ -1390,7 +1526,7 @@ function encode_one() { _REPORT_TOTAL_SAVED_BYTES=$((_REPORT_TOTAL_SAVED_BYTES + filesize_difference)) else ((_REPORT_ENCODING_FAILED++)) - append_size_report_row "failed" "$filename" "$(filesize "$input_file")" + append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Encode FAILED:" "$input_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE # fail this but keep going @@ -1629,6 +1765,28 @@ while [[ $# -gt 0 ]]; do _CLI_BACKUP_DIR=1 shift ;; + --output-dir) + if [[ $# -lt 2 ]]; then + error "Missing value for $1" + exit $EXIT_USAGE_ERROR + fi + if [[ -z "$2" ]]; then + error "Value for $1 must not be empty" + exit $EXIT_USAGE_ERROR + fi + OUTPUT_DIR="$2" + _CLI_OUTPUT_DIR=1 + shift 2 + ;; + --output-dir=*) + if [[ -z "${1#*=}" ]]; then + error "Value for --output-dir must not be empty" + exit $EXIT_USAGE_ERROR + fi + OUTPUT_DIR="${1#*=}" + _CLI_OUTPUT_DIR=1 + shift + ;; -l | --only-if-smaller) ONLY_IF_SMALLER=1 _CLI_ONLY_IF_SMALLER=1 @@ -1795,6 +1953,7 @@ debug " skip-codecs:" "${joined# }" debug " skip-codec-override" "$_CLI_SKIP_CODECS_OVERRIDE" debug " dry-run:" "$DRY_RUN" debug " backup-dir:" "$BACKUP_DIR" +debug " output-dir:" "$OUTPUT_DIR" debug " only-if-smaller:" "$ONLY_IF_SMALLER" debug " verify-output:" "$VERIFY_OUTPUT" debug " continue:" "$CONTINUE_ON_FAIL" @@ -1824,21 +1983,15 @@ if [[ $_CLI_SIZE_REPORT_FILE -eq 1 && $SIZE_REPORT -eq 0 ]]; then fi if [[ -n "$BACKUP_DIR" ]]; then - if [[ ! -e "$BACKUP_DIR" ]]; then - error "Backup directory does not exist:" "$BACKUP_DIR" - exit $EXIT_USAGE_ERROR - fi - if [[ ! -d "$BACKUP_DIR" ]]; then - error "Backup path is not a directory:" "$BACKUP_DIR" - exit $EXIT_USAGE_ERROR - fi - if [[ ! -w "$BACKUP_DIR" || ! -x "$BACKUP_DIR" ]]; then - error "Backup directory is not writable/executable:" "$BACKUP_DIR" - exit $EXIT_USAGE_ERROR - fi + validate_existing_directory_option "Backup" "$BACKUP_DIR" BACKUP_DIR=$(resolve_path "$BACKUP_DIR") fi +if [[ -n "$OUTPUT_DIR" ]]; then + validate_existing_directory_option "Output" "$OUTPUT_DIR" + OUTPUT_DIR=$(resolve_path "$OUTPUT_DIR") +fi + if [[ ! "$NICE_VALUE" =~ ^-?[0-9]+$ ]]; then error "Invalid nice value (must be an integer):" "$NICE_VALUE" exit $EXIT_USAGE_ERROR diff --git a/transcode.sh.1 b/transcode.sh.1 index 7093397..7d39eca 100644 --- a/transcode.sh.1 +++ b/transcode.sh.1 @@ -11,7 +11,8 @@ transcode.sh \- batch transcode helper for media files using ffmpeg is a batch transcoding wrapper around .BR ffmpeg (1) that processes one or more media files in place, replacing each with a -re-encoded version. +re-encoded version unless no-replacement output mode is enabled with +.BR \-\-output\-dir . .PP Encoding parameters are not hard-coded; instead they are supplied by .IR presets , @@ -153,10 +154,23 @@ Copy originals to \fIDIR\fR before replacing them after successful encoding. not. Backups preserve the source file's absolute path below \fIDIR\fR, and existing backup files are not overwritten. .TP +.BR \-\-output\-dir " \fIDIR\fR, " \-\-output\-dir= \fIDIR\fR +Write encoded files below \fIDIR\fR instead of replacing originals. +\fIDIR\fR must already exist; the script exits with a usage error if it does +not. Relative input paths are preserved below \fIDIR\fR, while absolute input +paths are stored without their leading slash. Existing output files are not +overwritten; numeric suffixes such as +.B .1 +or +.B .2 +are appended when needed. +.TP .BR \-l ", " \-\-only\-if\-smaller -After encoding, replace the original only when the new file is strictly -smaller. If the transcoded file is larger the temporary file is removed -and the original is kept unchanged. +After encoding, keep the new output only when it is strictly smaller than the +original. In replacement mode, a larger output is removed and the original is +kept unchanged. In +.BR \-\-output\-dir +mode, a larger output is removed and no encoded copy is kept. .TP .BR \-\-no\-only\-if\-smaller After encoding, replace the original also if the new file is bigger. @@ -275,7 +289,7 @@ which prints every command as it is executed. .B XDG_CONFIG_HOME Base directory for user configuration. Defaults to .BR $HOME/.config . -.SH BACKUPS +.SH BACKUPS AND OUTPUT DIRECTORIES When .BR \-\-backup\-dir is used, backups are only created after @@ -292,6 +306,25 @@ already exists, a numeric suffix such as or .B .2 is appended. +.PP +When +.BR \-\-output\-dir +is used, originals are never replaced, moved, or deleted. Encoded files are +written below the existing output directory. Relative input paths are preserved +(for example, +.I movies/a.mp4 +becomes +.I encoded/movies/a.mp4 +when the output directory is +.IR encoded ), +and absolute input paths are stored without their leading slash. Existing output +files are not overwritten; numeric suffixes are appended as needed. +.PP +.BR \-\-backup\-dir +and +.BR \-\-output\-dir +are compatible. When both are set, the original is copied to the backup +directory and the encoded file is written to the output directory. .SH SIZE REPORT TSV When .BR \-\-size\-report @@ -299,7 +332,7 @@ is enabled, the TSV log uses this schema: .PP .RS .nf -status filename original_bytes new_bytes saved_pct +status input_path output_path original_bytes new_bytes saved_pct .fi .RE .PP @@ -309,7 +342,13 @@ The status column is one of .BR skipped_larger or .BR failed . -Failed rows may leave output-size fields empty when no encoded output exists. +The +.B input_path +field records the original input. The +.B output_path +field records the final encoded path for successful encodes and size-based +skips, and may be empty when no encoded output exists. Failed rows may leave +output-size fields empty when no encoded output exists. .PP .SH FILES .TP @@ -461,6 +500,12 @@ encoding and before replacement. Equivalent to .BR \-\-backup\-dir . CLI \fB--backup-dir\fR takes precedence. .TP +.B [processing] output_dir +String. Existing directory where encoded files are written without replacing +originals. Equivalent to +.BR \-\-output\-dir . +CLI \fB--output-dir\fR takes precedence. +.TP .B [processing] only_if_smaller Boolean. Equivalent to .BR \-\-only\-if\-smaller / \-\-no\-only\-if\-smaller . @@ -547,6 +592,14 @@ transcode.sh \-n \-v my_movie.mkv .fi .RE .PP +Write encoded copies elsewhere without replacing originals: +.PP +.RS +.nf +transcode.sh \-\-preset av1 \-\-output\-dir ./encoded video.mp4 +.fi +.RE +.PP Use a custom preset and only keep the result if it is smaller: .PP .RS diff --git a/transcode.sh.bash-completion.sh b/transcode.sh.bash-completion.sh index 6e55970..4a419cc 100644 --- a/transcode.sh.bash-completion.sh +++ b/transcode.sh.bash-completion.sh @@ -87,6 +87,11 @@ function _transcode_sh() { _filedir -d return ;; + --output-dir=*) + cur="${cur#*=}" + _filedir -d + return + ;; --size-report-file=*) cur="${cur#*=}" _filedir @@ -141,6 +146,10 @@ function _transcode_sh() { _filedir -d return ;; + --output-dir) + _filedir -d + return + ;; --size-report-file) _filedir return @@ -177,6 +186,8 @@ function _transcode_sh() { --dry-run --backup-dir --backup-dir= + --output-dir + --output-dir= --only-if-smaller --no-only-if-smaller --verify-output |
