# Changelog All notable changes to MuxMaster will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). ## [Unreleased] ## [1.5.1] - 2026-07-06 ### Security - **Config-chain privilege crossing and installer/trust hardening for the system + user tiers (RV3-04)** — closes a privilege-*elevation* gap distinct from (and without regressing) the accepted same-privilege CWD `./.muxmrc` model (H1). Four changes: (1) when muxm runs as root **via sudo/su with the invoking user's `$HOME` preserved** (`sudo -E`, `env_keep += HOME`, or `su` without `-`), the `$HOME/.muxmrc` tier is now skipped — root no longer sources an unprivileged user's config as root (a genuine root login, with no `SUDO_USER`/`SUDO_UID`, still sources root's own `~/.muxmrc`). The decision is factored into a small predicate that takes the read-only `$EUID` by value, so the real check is un-spoofable. (2) The `/etc/.muxmrc` and `$HOME/.muxmrc` tiers now reject a **group- or world-writable** config file with a clear warning (not a silent skip), mirroring ssh/sudo's own config-file trust checks, so another local user can't inject shell that runs as this user. The CWD `./.muxmrc` tier is deliberately **not** gated (H1 WONTFIX, unchanged). (3) `_install_man` now writes the man page **atomically** (`mktemp` + `chmod 644` + `mv -f`, mirroring `_install_completions`/`_create_config`) instead of a `>`/`sudo tee` redirect — on a shared, user-writable Homebrew man directory a pre-placed symlink at the target is now replaced rather than silently followed and written through (arbitrary-file overwrite). (4) `_man_target_dir` validates that `brew --prefix` produced a sane absolute, existing directory before building the (possibly sudo'd) install target, dying cleanly instead of constructing a root-level `/share/man/man1` from a broken/wrapped `brew`. New `config`/`setup` regression tests cover the elevation predicate, the writable-tier rejection (with a 0644 control), the atomic symlink-replacement + no-stray-temp behavior, and the broken-`brew` die. - **`--create-config` multi-profile path no longer lets override values inject shell into the generated `.muxmrc` (RF1)** — the M1 fix (v1.5.1) escaped override values in the single-profile emitter's `_V` helper but not in `_create_config_emit_multi`, which kept the pre-M1 unquoted-ish `printf '%s="%s"'`. A comma-separated `--profile` plus a crafted string override (e.g. `--x265-params '$(…)'`) therefore emitted an unescaped line that executed on every later `. .muxmrc` source (and `--create-config system` writes `/etc/.muxmrc`). Both emitters now share one `_cc_shell_escape` helper and always emit the escaped, quoted form. - **Embedded track metadata can no longer inject terminal escape sequences into output or the log (RF8)** — attacker-controlled `title`/`language` tags from a source file were printed (and tee'd to the persistent log) without control-character neutralization, enabling output spoofing on terminals that honor escape sequences. `_audio_stream_info` and `_sub_stream_info` now strip the full C0 control range plus DEL from titles and languages at extraction, covering every display and log site (matching the existing control-char rejection for filenames). - **Generated `.muxmrc` now gets an explicit `chmod 644` instead of inheriting the invoking process's umask (RV2-01)** — `--create-config`/`--force-create-config` wrote the generated config via a plain redirect with no permission normalization. Low severity for `user`/`project` scope (a non-secret personal config), but **medium for `system` scope**: `--create-config system` writes `/etc/.muxmrc`, sourced by every user's `muxm` invocation on the box under the accepted CWD-`.muxmrc` trust model (H1) — a permissive umask (e.g. `002` in some CI/cron contexts) could have left it group- or world-writable, letting any user on the box inject shell that executes as every other user. The write is also now atomic (`mktemp` + `chmod` + `mv -f`, mirroring `_install_completions`'s existing pattern), so a crash or disk-full event mid-write can no longer leave a partially-written config in place. This is about the generator's file-creation mode, not a change to the accepted H1 sourcing trust model itself. ### Fixed - **Pre-release logic-bug sweep (2026-07-05 multi-agent review)** — 38 correctness fixes found by a full-script review the day before release; each was reproduced, then re-verified fixed. None are v1.5.1 regressions (the RF1/RF9 fixes were audited and hold). Grouped: - **Silent-failure / data-loss (high):** (H-1) **embedded-PGS OCR via pgsrip now works** — pgsrip derives its language *from the input filename*, so a workdir `sub.N.sup` parsed as `und` and pgsrip's `--language eng` filter (default) excluded it, ripping *nothing* on every embedded PGS. muxm now feeds pgsrip a language-named input (`sub.N..sup`) and drops the `--language` flag, and globs for pgsrip's alpha-2 output; (H-2) **untrapped SIGHUP/SIGQUIT/SIGPIPE no longer promote a truncated file as "Build SUCCEEDED"** — only INT/TERM were trapped, so a dropped SSH session mid-mux ran the EXIT trap with `FAILED=0`, moving a partial `TMP_OUT` over `$OUT` and checksumming it; HUP/QUIT/PIPE now set `FAILED=1`; (H-3) **multi-track MP4/MOV preflight now rejects FLAC/PCM/DTS-core/Opus** (not just TrueHD/DTS-HD MA) by reusing the single-track `_sii_audio_is_container_safe` whitelist, so a container-unsafe track fails fast instead of at `mux_final` (older ffmpeg) or shipping a poorly-compatible file. - **Encode correctness:** (M-1) `--audio-force-codec libmp3lame` on ≥3ch no longer dies (`_codec_max_channels` clamps mp3 to stereo); (M-4) HDR copy-compliance tonemap gate now matches `bt2020nc`/`hlg` (was shipping partially-tagged HDR un-tonemapped); (M-5) `--level` on **libx264** now actually applies (level+VBV routed to `-x264-params`, not the x265-only var it silently discarded into); (M-6) skip-if-ideal now honors `--skip-audio`, multi-track `--skip-subs`, and `MAX_AUDIO_CHANNELS` (was skip-shipping tracks the pipeline drops/caps). - **DV mux:** (M-8) a copy-from-source DV MP4 → MKV now gets the `dvh1→hvc1` tag override (was dying 41 "Tag dvh1 incompatible"); (M-9) chapters + global metadata are now mapped explicitly from the source on the DV raw-ES/pre-wrap path (were silently lost while reported "preserved"); (L-13) `detect_dv` matches only DV-bearing fields, not stream `.tags` — a DV-stripped remux with "DoVi" in its title no longer false-triggers RPU extraction. - **Config / CLI / startup:** (M-12) a `.muxmrc` ending in a false test (`[[ -d /x ]] && VAR=…`) no longer silently kills *every* invocation before the traps exist; (M-13) the root/sudo `$HOME/.muxmrc` elevation guard now keys on `$HOME` **ownership** (catches `su` without `-`, stops false-firing on plain `sudo`); (M-14) `--create-config --profile a,b` emits a multi-profile config instead of dropping all but the first; (M-15) `--create-config … --checksum-algo ` is rejected at create time (was persisting a config that bricked every later run); (M-16) negative SVT-AV1 presets (`--preset -2`) are accepted; (M-17) `--output-ext ""` resolves the container instead of producing `movie.` muxed as MP4; (L-2) override flags placed *before* `--create-config` now warn instead of being silently dropped; (L-4) `.muxmrc` `HW_ACCEL_QUALITY`/`THREADS` are re-validated at startup; (L-5) collision auto-versioning no longer mangles extensionless output paths. - **Multi-track subtitles:** `_sub_candidate_undeliverable` now accounts for `SUB_PRESERVE_BITMAP` and multi-track mode (a text sidecar was deduped against an embedded PGS that was then dropped → zero subtitles); (L-17) a 3-letter external sidecar (`movie.eng.sup`) OCRs and relocates correctly (was misreported as failed and littered the source dir). - **Cross-platform / robustness:** (M-7) `df -Pk` parsing keys off the capacity column so an SMB/NAS source with a space (`//host/My Share`) no longer reads the wrong column (silent under-warn or arithmetic abort); (M-11) multi-profile passthrough maps an unsupported source container to mkv instead of dying 11; (M-18) the post-`--install-man` "not on your MANPATH" warning compares man *roots* (was firing after every successful install with wrong advice); (M-19) the installed zsh completion runs `compinit` before `bashcompinit` (was erroring `compdef: command not found` on minimal `.zshrc`s); (L-1) completion adds `-o default` so `--ext-subs-dir ` completes paths; (L-16) the output-lock grace window and atomic pid-write are hardened against a concurrent-writer race under load. - **Diagnostics:** (M-3) the audio-scoring docstring no longer overclaims that preferred language *always* wins; (L-7) DEBUG jq stderr is actually captured (`$(/dev/null)` read nothing); (L-11) no more `mv x x` "same file" noise on successful OCR; (L-12) the dead vobsub-muxer fast path is documented; (L-14) a failed dry-run no longer prints a "Diagnostics:" path it never creates; (L-15) the re-encode disk estimate no longer collapses to ~1s when a bit_rate is tagged but duration is undeterminable; (L-3) multi-profile `--print-effective-config` states it shows the first profile only; (L-6) the per-profile "unusual output container" advisories (e.g. "MOV may work on Apple TV but mp4 is recommended") now also fire for a PASSTHROUGH profile whose container resolves only during output resolution — previously skipped because `OUTPUT_EXT` was still empty at conflict-check time. The advisories were centralized into `_emit_container_advisories` (one source of truth) and made per-message one-shot, then fired in both phases (mirroring the existing `_warn_export_external_mkv` twin-call pattern). Reviewed-and-kept-as-designed: forced-AAC uses `STEREO_BITRATE` regardless of channel count (intentional RV3-08 behavior, locked by `_test_audio_forceaac_stereo_bitrate`); the prefer-stereo scan's `und`-handling (mirrors `_build_audio_keep_list`); and the native-stereo fallback's container-fitness copy gate. - **Remaining edge cases (RV3-16)** — six low-severity fixes. (1) When source duration is undeterminable, `disk_free_warn` no longer substitutes `dur=1` into the audio-size estimate (which collapsed it to ~1s worth); it now omits the audio term entirely and logs a caveat, resting the estimate on the source-size-derived video term (a `dur_known` flag; `dur=1` is still used for the video synthesis, where duration cancels out). (2) `_acquire_out_lock`'s (and `_check_src_replace_lock`'s) stale-lock reclaim no longer trusts `kill -0` alone — a new `_pid_is_muxm` verifies via `ps -ww … -o command=` that the live PID is actually a muxm run, so an unrelated process that recycled a dead muxm's PID no longer reads as a live lock. Safe-biased: any uncertainty (ps absent/empty) treats the owner as live (refuse), never as reclaimable, so the guard can never cause two writers on one output. (3) The control-character filename guards (`SRC_ABS`, `OUT_ABS`, external-subtitle paths) are now `LC_ALL=C`-pinned (via a subshell, since `[[` can't take an env prefix) so `[[:cntrl:]]` classifies by byte deterministically regardless of the ambient locale — matching every other locale-sensitive check in the file. (4) `_get_source_duration_secs` uses an empty-string sentinel for "not yet determined" (distinct from a legitimate rounded-to-zero duration), so a genuinely sub-second source no longer falls through to later detection tiers and adopts a different (wrong) value. (5) `_cleanup_workdir`'s dead `keep_on_failure && KEEP_TEMP` branch is removed (unreachable given `on_exit`'s call sites, which only reach the failure-path call when `KEEP_TEMP=0`). (6) `_probe_field`'s pre-cache `ffprobe` fallback now passes `-select_streams v:0`, matching the cached branch, so `head -n1` can't return a non-video stream's blank value. New `edge`/`unit` tests cover the undeterminable-duration audio-term omission (with a known-duration control), a recycled non-muxm PID being reclaimed (plus the M4/M5 collision tests updated to spawn a genuinely muxm-identifiable live owner), the `LC_ALL=C` guard firing under a UTF-8 locale, the sub-second no-fallthrough case, and the `_probe_field` video-stream filtering. - **CLI/output correctness odds and ends (RV3-13)** — six fixes. (1) `--checksum-algo` no longer force-enables checksums as an unconditional side effect: an explicit `--no-checksum` now wins regardless of the order the two flags are typed (a new `_CLI_NO_CHECKSUM_EXPLICIT` tracker, mirroring the existing `_CLI_*_EXPLICIT` convention), so `--no-checksum --checksum-algo X` keeps checksums off; the same order-independence was applied to the `--create-config` override collector. (2) `--print-effective-config` now runs Section 15's `OUTPUT_EXT` validation (extracted into a shared `_validate_output_ext_or_die`) *before* its early exit, so a bogus `--output-ext` is reported as the same `die 11` a real run would hit instead of being printed as "accepted"; the single- and multi-profile diagnostic paths both reject it. (3) `_gb`'s integer fallback for a failed/absent `bc` is fixed: the old one-liner piped `bc` straight into `printf '%.1f'`, which on failure still emitted `0.0` *and* returned non-zero, so the `|| echo ` fallback also ran and concatenated into a garbled value like `0.05`; the bc result is now captured, validated, and the fallback is mutually exclusive (and `set -e`-safe). (4) The post-mux compact stream summary prints `Audio: none` for a zero-audio output instead of a bogus blank single-track line (a here-string over the empty audio-metadata variable yielded one empty-line iteration; the loop now skips it). (5) `_acquire_out_lock` now drops a `replacing-src` marker when a `--replace-source` run renames its output onto the source, and every run checks the source's own lock (`_check_src_replace_lock`) before reading — so a concurrent run refuses (exit 11) rather than silently reading a file another live run is replacing; two ordinary reads of the same source into different outputs still don't block each other (best-effort, documented window). (6) The pre-run "▶ Plan" banner derives its bit-depth label from the pixfmt's real trailing depth rather than treating "8-bit" as the catch-all — a 12-bit target (`yuv420p12le`) is no longer mislabeled "8-bit". New `toggles`/`cli`/`unit`/`output`/`collision`/`hdr` tests cover checksum-flag order-independence, the print-config output-ext rejection, the `_gb` failing-bc fallback, the zero-audio summary, the source-replace lock (reader-refuses vs two-readers-OK), and the 8/10/12/16-bit label. - **CLI/multi-profile argument-handling correctness (RV3-12)** — three fixes. (1) `--create-config` now enum-validates the `--video-codec` and `--preset` overrides against the CLI's own `is_valid_video_codec` / `is_valid_preset` and dies (`exit 11`) *before* writing the config — previously any string was persisted verbatim into the generated `.muxmrc`, so a typo (`--video-codec bogus`) only surfaced as a failure on the *next* run. To make this possible during the pre-scan, `is_valid_video_codec` was added and `is_valid_preset` moved into the early function cluster (both run before `_create_config_prescan`). (2) `_probe_profile_ext` now normalizes a deprecated profile alias to its canonical name (via `PROFILE_ALIASES`) *before* dispatching to `apply_profile_`, so an un-normalized alias (e.g. `dv-archival`) resolves to the real profile's container (`mkv`) instead of silently falling through to the wrong `mp4` default. (3) `_run_multi_profiles` reconstructs each child's flag list by dropping the source/output positionals by their **parser-recorded index** (new `_POSITIONAL_ARG_IDXS` / `_MAIN_ARGC0` tracking) rather than by value-matching the last N args — so a flag *value* byte-identical to the source or output path (e.g. `--sub-lang-pref out.mkv … out.mkv`) is no longer mistaken for a positional and dropped, which previously corrupted the child flags into a "Too many arguments" abort. New `cli` tests assert the create-config enum rejection writes no `.muxmrc` (and that valid overrides still persist); new `multi_profile` tests cover the alias→container resolution and both the output- and source-value positional collisions. - **Video/color/DV pipeline correctness (RV3-11)** — five fixes. (1) `decide_color_and_pixfmt`'s SDR chroma-preservation branch now derives the bit-depth suffix from the already-decided target (`tgt_pix`), not the source's own bit depth — so `--sdr-force-10bit` no longer silently loses effect on an 8-bit non-4:2:0 source (a chroma-preserved 4:4:4 source now correctly lands at `yuv444p10le` instead of 8-bit `yuv444p`). (2) `resolve_video_encoder`'s NVENC-specific fallback message now fires *before* the macOS-only OS guard, so a non-macOS user requesting `--hw-accel nvenc` (the one non-macOS backend) gets the accurate "NVENC not supported in this build" reason instead of a misleading "switch to macOS" message. (3) The RPU/frame-count verify now reports "unverified" when a count is the documented `0 = counting failed` sentinel (via a new inner `else`), instead of silently passing the desync check. (4) The sixth "give up on DV" branch (total RPU-extraction failure) is now folded into the shared `_dv_give_up_to_base` helper like the other five — restoring the `OUTPUT_HAS_DV=0` reset the hand-duplicated sequence omitted (RV2-06). (5) The three near-identical "wrap a raw HEVC ES into a timestamped MP4" ffmpeg invocations (DV pre-wrap block) are collapsed into one `_dv_ffmpeg_wrap_mp4` helper (tag-driven `-strict unofficial` for dvh1; array-safe source fps). New `hdr`/`hw_accel`/`unit` tests cover the 4:4:4 10-bit target, the NVENC message ordering (host-independent extraction), the frame-count "unverified" sentinel, the six give-up branches, and the wrap helper's arg reproduction. - **Error handling & trap coverage for multi-profile dispatch and the ERR trap (RV3-10)** — three fixes. (1) `_run_multi_profiles` runs *before* the ERR/EXIT/INT/TERM traps are armed (Sections 17/18 belong to single-profile runs), so an unexpected `set -e` abort inside it (e.g. a failing `dirname`/`basename`/`_probe_profile_ext` in the child-flag reconstruction) would exit raw — no diagnostic, no exit code. The call site now runs it in a subshell and routes any non-zero result through `die()`, so a failure in that un-trapped window surfaces muxm's own error + exit code instead of a bare message-less abort. (2) `on_error()` now mirrors its failure message into `_LOG_BUFFER` when the §17 tee isn't yet live (`_LOG_READY=0`), matching `die()` — a defensive symmetry fix so an ERR-trap failure that fires before the logfile exists isn't lost from the persisted log. (3) Corrected a stale comment at the multi-profile dispatch that claimed the `on_exit` EXIT trap fires there (it is not armed until Section 17, which only single-profile runs reach). New `multi_profile`/`unit` tests cover the diagnosed dispatch failure and the `on_error` buffer-mirroring (both `_LOG_READY` states). - **Cross-platform encoder/dependency preflight: forced audio encoders fail fast, and `--setup` no longer reports success with required tools missing (RV3-09)** — two fixes. (1) `--audio-force-codec` now gets the same Section-14 fail-fast availability preflight the video encoders already have (`ffmpeg_has_encoder`): a platform-locked encoder (`aac_at` on macOS, `libfdk_aac` from a special build, or an absent `libopus`/`libvorbis`/`libmp3lame` on a minimal ffmpeg) is caught up front with a clear `die 10`, instead of being discovered late as a cryptic `die 43` after the whole video pipeline finished (hours of wasted encode). Native encoders (aac/ac3/eac3/flac) always pass; skipped under `--skip-audio`. (2) `_dep_check_only` now exits non-zero when a **required** tool (ffmpeg/ffprobe/jq/bc) is missing — previously it always `exit 0`, so `_setup_all`'s per-step status check misread it as success and `muxm --setup` printed "Setup complete — ready" on a Homebrew-less box with no ffmpeg. An only-recommended-tools-missing check (dovi_tool/mp4box/tesseract/pgsrip) keeps the advisory `exit 0`. New `setup`/`audio` tests cover the forced-encoder fast-fail and the required-vs-advisory exit status. - **Multi-track container-safety preflight now catches the force-keep fallback; Vorbis copy + forced-AAC-variant bitrate fixes (RV3-08)** — four audio fixes. (1) `_check_multitrack_container_safety` now derives its kept tracks from `_build_audio_keep_list` itself (the single source of truth) instead of a hand-maintained inline copy of the language/commentary filter, and — when that filter drops **every** track but the source has audio — evaluates `run_audio_pipeline_multi`'s force-keep fallback candidate (the best-scored track that will actually ship) against the container rule too. So a foreign-language, lossless-only (TrueHD/DTS-HD MA) source into MP4/MOV now fails the preflight **fast** (exit 11, "use `--output-ext mkv`") before the long re-encode, instead of late at `mux_final` (`die 41`). (2) `_audio_copy_ext` gained a `vorbis → ogg` mapping (Vorbis has no bare `.vorbis` muxer) — the copy-path twin of the L4 `libfdk_aac`/`aac_at` fix — so a stream-copied Vorbis source no longer names its intermediate `audio_primary.vorbis` and dies 43. (3) The forced-AAC `STEREO_BITRATE` override now keys on the **native** codec (`_encoder_to_codec`), so it also fires for the `libfdk_aac`/`aac_at` AAC encoders (not just the literal `"aac"`) — consistent with L4. (4) The Section-27 header comment now lists `A_PRIMARY_CODEC`/`A_STEREO_CODEC` among the globals `mux_final` consumes. New `containers`/`audio`/`unit` tests cover the foreign-language force-keep hard-stop, the Vorbis copy path, the AAC-variant bitrate override on a 6ch source, and the `vorbis → ogg` mapping. - **Subtitle codec deliverability & dry-run fidelity: VobSub/DVB no longer causes zero subtitles, and `--dry-run` no longer fabricates unsupported-codec successes (RV3-07)** — four related subtitle fixes. (1) `_sub_candidate_undeliverable` is now codec-aware: `_prepare_subtitle` has **no arm** for `dvd_subtitle` (VobSub) / `dvb_subtitle` (no stream-copy, no OCR — they hit its unsupported-codec catch-all and return empty), yet the predicate's blanket MKV/OCR short-circuit reported them *deliverable*, so a matching text `.srt` sidecar got deduped away and the embedded bitmap was then silently dropped at prepare time — leaving the output with **zero subtitles**. VobSub/DVB are now reported undeliverable in every config (PGS keeps its genuine MKV-stream-copy / OCR deliverability), so the usable text sidecar survives (and the bitmap is skipped in single-track selection). (2) `_prepare_subtitle` gained a codec-support gate ahead of its `DRY_RUN` branch, so a `--dry-run` preview and the real run agree on which codecs are deliverable — the dry-run branch no longer fabricates a fake `.srt` success for a codec the real run drops. (3) `_pick_direct_text_sub_relidx`'s last-resort language filter is now gated on `-n "$SUB_LANG_PREF"` (matching every sibling call site), so an empty `SUB_LANG_PREF` ("keep all languages") matches any language instead of finding no candidate. (4) The forced-subtitle branch of single-track selection now carries the `SUB_MAX_TRACKS` guard the full/sdh branches already had, and the direct-map fallback is gated on `SUB_MAX_TRACKS > 0`, so `SUB_MAX_TRACKS=0` suppresses a forced subtitle too. The existing `_test_unit_rf6_subtitle_fallback` was updated (it had encoded the pre-fix assumption that VobSub is MKV/OCR-deliverable) and now covers the corrected truth table; new `subs` tests cover the dry-run fidelity, the empty-`SUB_LANG_PREF` fallback, and a real `SUB_MAX_TRACKS=0`-suppresses-forced encode. - **x265 HDR/PQ key strip now anchors correctly, so a sole `--x265-params hdr10=1` can't bake HDR into an SDR/HLG encode (RV3-06)** — `build_x265_params`' strip of baked HDR10/PQ color keys (`hdr10`, `hdr10-opt`, `hdr-opt`, `colorprim`, `transfer`, `colormatrix`, `range`) used two patterns per key (`:key=…` global + `^key=…:` once), which together miss the case where the `key=value` pair is the **entire** string — no leading colon for the first pattern, no trailing colon for the second. `--x265-params hdr10=1` (or `transfer=smpte2084`, etc.) as the whole param string therefore survived the strip and signalled PQ/HDR on non-PQ content, misrendering on any player that honours it. The strip is factored into a new `_x265_strip_hdr_keys` helper that wraps the string in sentinel colons first, so start / middle / end / sole-content all collapse to one uniform `:key=value:` shape removed by a single pattern per key (no per-key whole-string variant needed). New `unit` coverage asserts every key is stripped in all four positions (and the HDR10-profile path still re-adds its signaling); a new `hdr` real-encode test confirms a sole `--x265-params transfer=smpte2084` leaves no PQ transfer in the SDR output. - **Empty-array `set -u` crash on bash < 4.4 for six more keep-list/iteration arrays (RV3-05)** — the RF9 sweep (v1.5.1) applied the array-safe `${arr[@]+"${arr[@]}"}` idiom to the command-arg arrays but did not reach several genuinely-empty-prone globals expanded with the bare, unguarded `"${arr[@]}"` form: `COMPLETED_STEPS`, `_MULTI_PROFILES`, `_ORIG_FILTERED_ARGS`, and `SII_AUDIO_INDICES` (Finding #4), which raise `unbound variable` under `set -u` on any bash before 4.4 (the documented floor is 4.3). Extended the RF9 idiom to all four, plus the two same-class siblings a whole-file sweep surfaced: `SII_SUB_INDICES` (beside `SII_AUDIO_INDICES` in the skip-if-ideal remux) and `AUDIO_MT_INDICES` (the multi-track audio keep-list). The `_test_unit_empty_array_safe` static guard now enforces the safe form for all of them by name, and its existing old-bash coverage confirms the idiom works on a genuine pre-4.4 interpreter. The bash floor stays at 4.3; `shopt -s inherit_errexit` (a 4.4+ feature) is documented as a best-effort no-op on 4.3 (the plan's accepted resolution) rather than forcing a floor bump. - **Numeric CLI/config validation family: rate flags, the copy-bitrate ceiling, `--hw-accel-quality`, `--audio-track`, and generated-config `THREADS` (RV3-03)** — closes a cluster of "numeric input isn't fully validated before use" gaps. `--max-copy-bitrate` and `--audio-force-bitrate` now run `_validate_rate_arg` at their parse arm and at the Section-15 post-config re-check, matching every sibling rate flag (a `.muxmrc` can no longer smuggle garbage past the guard). `_video_is_copy_compliant` parses `MAX_COPY_BITRATE` through the shared `_RE_BITRATE` grammar (new `_rate_to_kbps` helper) instead of a bare lowercase-`k` strip — so `80M`/`80000K` actually enforce the ceiling instead of silently disabling it, and an unparseable rate no longer risks aborting the run under `set -e` before the graceful-skip guard. `--hw-accel-quality` reads its range check in base 10, so a leading-zero value (e.g. `080`) is 80, not a raw `value too great for base` octal crash (and normalizes to the decimal form). `--audio-track` coerces its index to base 10 before the bounds check, so a zero-padded value (e.g. `010`) can no longer pass a bounds check against octal-8 while the decimal jq lookup and ffmpeg `-map` index resolve to 10 (a silently-wrong or out-of-range track). `--create-config`'s `THREADS` override is validated as a strict positive integer (not the `_RE_BITRATE` grammar, which wrongly accepted `4k`/`1.5`) and normalized to decimal, so the generated config always carries a value the runtime's `^[1-9][0-9]*$` guard accepts. New `cli`/`unit` regression tests cover each (parse + config-bypass rejection, `_rate_to_kbps` conversion across the k/M/G grammar, the copy-bitrate ceiling for `80M`/`80000K`, zero-padded `--audio-track` resolution, and `THREADS` normalization/rejection). - **`--skip-if-ideal` no longer ships extra/wrong tracks on the default single-track path (RV3-02)** — for a single-track profile (`AUDIO_MULTI_TRACK=0`/`SUB_MULTI_TRACK=0`, true for nearly every profile), `check_skip_if_ideal` approximated the "primary" audio track with stream index 0, had **no** subtitle-ideality check at all (no `else` arm), and built its keep-list through the deliberately MKV-permissive `_sii_audio_is_container_safe`. A source/profile that the real pipeline would reduce to 1 audio + a filtered subtitle set could therefore be declared "ideal" and shipped verbatim with a duplicate audio track and a wrong-language subtitle the profile was supposed to drop (live-reproduced: 2 audio + 3 subs shipped vs 1 audio + 2 subs from the real pipeline). Now: single-track audio ideality requires exactly one audio stream (so index 0 *is* the selected track — no approximation) that is `audio_is_direct_play_copyable` (or container-muxable under lossless passthrough); a new single-track subtitle-ideality arm rejects a skip when subtitles are disabled/capped, when more than one subtitle is present, or when the lone subtitle fails the real language/type filter; and the single-track keep-list is gated through `audio_is_direct_play_copyable` (not the MKV-permissive predicate) except under lossless passthrough. Any of these disqualifiers only makes the shortcut *stricter*, so it can never ship more tracks than the real pipeline would. New `output`/`audio`/`subs` regression tests cover the previously-uncovered single-track path (2-audio + 3-sub convergence, multi-audio non-ideal, and both multi-subtitle and lone-non-preferred-language-subtitle non-ideality). - **`--crf` with a leading-zero value no longer octal-coerces, throws a raw bash arithmetic error, and silently drops every flag typed after it (RV3-01)** — `CRF_VALUE` carried bash's `declare -i` attribute, so the CLI arm's plain `CRF_VALUE="$_crf_in"` re-triggered octal interpretation *after* the base-10 bounds check had already validated the value. A malformed-but-plausible `--crf 08` threw an untrapped `value too great for base` arithmetic error that unwound the entire CLI parse loop, silently discarded every subsequent flag (including `--dry-run` and `--no-disk-check`), and fell through to help text with exit 0 — while a real encode still ran and wrote output to disk. `CRF_VALUE` is no longer `declare -i` (so a config-file value survives as a raw string instead of octal-coercing or hard-erroring at *source* time); the CLI arm now assigns `$((10#$_crf_in))`, a base-10 normalization runs before `print_effective_config` (so a `.muxmrc` `CRF_VALUE="010"` reports decimal 10, not a silently-wrong octal 8), and the Section-15 post-config re-validation forces `10#` in its range check. The audit of every other `declare -i` global assigned from raw CLI/config input found one further site of the same hazard class — the `AUDIO_SCORE_LANG_BONUS_ENG → AUDIO_SCORE_LANG_BONUS` deprecation bridge — now likewise base-10-normalized. New `cli`/`config`/`dryrun` regression tests assert clean base-10 accept, clean `die()` on out-of-range, and that a flag placed after a leading-zero `--crf` is honored rather than dropped. - **`--create-config` no longer lets override values inject shell into the generated `.muxmrc` (M1)** — The single-profile config emitter's `_V` helper had an unquoted branch that wrote override values raw (`printf '%s=%s\n'`). Because `--create-config` override values arrive from the CLI unvalidated, a value such as `--output-ext 'mp4; echo INJECTED'` emitted a bare `OUTPUT_EXT=mp4; echo INJECTED` line that then executed on every subsequent `muxm` invocation sourcing that config (and `--create-config system` writes `/etc/.muxmrc`, sourced by every user). `_V` now escapes the shell-special characters (`\ " $ \``) and emits the quoted `VAR="value"` form **unconditionally** for every variable (`VAR=value` and `VAR="value"` are identical when sourced, so no existing config changes meaning). Complementing this, the numeric-only overrides (`CRF_VALUE`, `THREADS`, `STEREO_BITRATE`, `AUDIO_FORCE_BITRATE`, `MAX_COPY_BITRATE`, `LEVEL_VALUE`, `AV1_MAXRATE`, `AV1_BUFSIZE`) are now validated against `^[0-9]+(\.[0-9]+)?[kKmMgG]?$` at parse time and rejected with `die 11` on mismatch — failing injection attempts fast instead of quoting them into the file. - **Multi-profile mode: deprecated `dv-archival` alias derived the wrong container (M2)** — A `--profile dv-archival,` run validated the alias (`_is_valid_profile` accepts it) but never normalized it before use. `_probe_profile_ext "dv-archival"` called the non-existent `apply_profile_dv_archival`, swallowed the failure, and fell back to its local `OUTPUT_EXT="mp4"` default — forcing the `archive` profile (which sets `OUTPUT_EXT="mkv"`) into MP4. For a lossless/PGS/ASS source this tripped a spurious `die 11`; otherwise it produced a wrong-container `.dv-archival.mp4`. The multi-profile prescan now runs each list element through `_normalize_profile` in place, so the canonical name (`archive`) feeds the extension probe, the per-profile output filename, and the child's `--profile` flag. - **`--skip-if-ideal` no longer silently drops a lossless audio track on the single-track MP4 path (L1)** — With `AUDIO_LOSSLESS_PASSTHROUGH=1` and a single audio track, `check_skip_if_ideal` evaluated ideality on video + container alone (both audio-ideality arms were skipped). The skip-path keep-list then retained only codecs passing the MP4/MOV whitelist (`aac|ac3|eac3|alac`), so a lossless codec (FLAC, DTS-HD MA, …) in an `.mp4` source was excluded with only a `log` line and the remux shipped video-only. `check_skip_if_ideal` now adds a single-track lossless-passthrough arm that sets `ideal=0` when the primary audio codec is not container-safe, forcing the normal pipeline (which transcodes/handles the track) instead of dropping it. - **Multi-track subtitles: external sidecars are now de-duplicated against each other (L3)** — `merge_subtitle_sources` compared each external subtitle only against embedded tracks. In multi-track mode (where `_build_subtitle_keep_list` keeps every passing track), two sidecars normalizing to the same `(lang, type)` pair — e.g. `movie.en.srt` + `movie.eng.srt` (both → `eng`/`full`) — both survived as duplicate subtitle tracks (capped only by `SUB_MAX_TRACKS`). The merge loop now also tracks already-kept external `(lang, type)` pairs and skips collisions, mirroring the existing embedded-track dedup. (Single-track mode was unaffected — first match wins.) - **Forced AAC transcode to `libfdk_aac` / `aac_at` no longer crashes late with `die 43` (L4)** — `_audio_codec_ext` mapped only the native `aac` encoder, so `--audio-force-codec libfdk_aac` (or the macOS AudioToolbox `aac_at`) named the intermediate after the raw encoder (`audio_primary.libfdk_aac`); ffmpeg could not choose a muxer and the run failed with `die 43` after track selection/scanning. Both encoder variants now resolve to a `.m4a` intermediate (mirroring the `alac → m4a` mapping). Additionally, `--audio-force-codec` now validates its argument at parse time against the set of encoders muxm can actually produce an intermediate for (`libopus, libmp3lame, libvorbis, aac, libfdk_aac, aac_at, ac3, eac3, flac`) and rejects anything else with `die 11` — so a typo fails fast with a clear message instead of a cryptic mid-run crash. **Note:** this converts the previously open-ended `--audio-force-codec` argument into a closed allow-list on the CLI; `alac` is intentionally rejected (it is lossless-copyable but has no transcode-intermediate mapping, so forcing a transcode to it would also have crashed). Profile- and `.muxmrc`-supplied values bypass the CLI check, matching the existing rate/level validators. - **Output-lock empty-pid race window closed (L2)** — `_acquire_out_lock` wrote the owner pid just *after* `mkdir`-ing the lock dir, so a second run whose `mkdir` lost the race could read an empty `pid` file, judge the lock stale, `rm -rf` it, and proceed against the same output as the holder. The lock now treats an empty/unreadable pid as "holder hasn't written yet" and re-checks briefly before reclaiming; a genuinely stale lock (dead numeric pid, or a non-numeric/corrupt pid) is still reclaimed immediately. - **Progress bar handles ffmpeg's `out_time_ms` field (L5)** — older ffmpeg builds emit only `out_time_ms` in `-progress` output; the bar parsed only `out_time_us` and so never advanced (snapping to 100% at `progress=end`). Both fields are now handled. (ffmpeg's `out_time_ms` is a long-standing misnomer that actually carries microseconds — identical to `out_time_us` — so both route to the same math; cosmetic only.) - **`--profile` after `--` is now treated as a positional (L10)** — the `--profile` pre-scan didn't honor the `--` end-of-options marker, so `muxm -- --profile x src` wrongly interpreted the post-`--` `--profile` as the flag. The pre-scan now stops interpreting `--profile` at `--` (mirroring the `--create-config` pre-scan) and passes `--` plus everything after it through untouched. - **Forced subtitles are no longer silently dropped when burning into a copy-compliant source (RF2)** — with `--sub-burn-forced` and a video stream eligible for stream-copy (e.g. `--profile atv-directplay-hq` on a compliant HEVC), the copy path skipped the burn-in filter, the plan had routed the forced track to the burn path (leaving the soft-embed empty), the direct-map fallback was suppressed, and the plan was not rebuilt — so the forced subtitle reached neither the video nor a soft track, with no warning. `_video_is_copy_compliant` now forces a re-encode when `SUB_BURN_FORCED` is set (also fixing the milder skip-if-ideal variant, since `check_skip_if_ideal` delegates to it). It likewise forces a re-encode when chroma downsampling is required (`FORCE_CHROMA_420` + non-4:2:0 source) (RF2b). - **Primary audio selection now respects `disposition` flags (RF3)** — `_score_audio_stream` consulted only the title for commentary/description; a track flagged `disposition.comment` (or `visual_impaired`/`hearing_impaired`) with an empty or non-English title escaped the penalty and, under the default codec preference (where lossless mains rank last), could outscore the main feature and be stream-copied as the primary audio. `_audio_stream_info` now carries the disposition flags and the scorer penalizes any of them (also removing the duplicate disposition jq probes in the prefer-stereo and native-stereo paths). - **`--prefer-stereo` now respects `AUDIO_LANG_PREF` (RF4)** — the prefer-stereo pre-scan picked the highest-scoring native 2-ch track regardless of language, so an English-preferring `universal` encode of an English-5.1 + foreign-2.0 source chose the foreign stereo. It now skips non-preferred-language stereo tracks and downmixes the preferred-language track when no native preferred stereo exists. - **Multi-profile `--output-ext` no longer produces files whose extension lies about their container (RF5)** — a CLI `--output-ext` combined with a comma-separated `--profile` was carried into each child after the injected per-profile `--output-ext`, so the user's value won the container while the auto-generated filename kept the profile's natural extension (e.g. an `.archive.mkv` that was actually MP4). muxm now drops the redundant user flag from the child invocation and names every child by the resolved extension — when `--output-ext` is explicit, that extension applies to all profiles (matching single-profile behavior, where an explicit `--output-ext` overrides the profile's container) — so name and container always agree. - **Single-track subtitles: a usable text sidecar is no longer dropped when the selected embedded bitmap can't be prepared (RF6)** — for MP4/MOV output with OCR unavailable, an embedded PGS/VobSub track that deduped away a matching `.srt` sidecar and then failed to prepare left the output with no subtitles. The merge now keeps the text sidecar when its only embedded match is an *undeliverable* bitmap (non-MKV container, OCR off), and single-track selection skips such an undeliverable bitmap so the container-usable sidecar is chosen instead — matching the multi-track path's "never silently lose a track" guarantee. - **External VobSub `.idx` OCR output is now relocated like `.sup` (RF7)** — a successful VobSub OCR wrote its `.srt` next to the source, not the workdir, so it was misreported as failure and dropped. The `.idx` branch now relocates the result into the workdir before checking it (mirroring the `.sup` branch). - **DV/mp4box mux no longer aborts on bash 4.3 when the source frame rate is undeterminable (RF9)** — an empty `fps_arg` array expanded as `"${fps_arg[@]}"` under `set -u` raised `unbound variable` on bash < 4.4 (the documented 4.3 floor) when `SRC_FPS` was empty. It now uses the array-safe `${fps_arg[@]+"${fps_arg[@]}"}` form (which omits the argument entirely when empty, adding no spurious empty arg). The same audit applied the idiom to the other conditionally-built command-arg arrays reachable empty under `set -u`: `_ts_fps`, `fps_in`, `_ocr_lang`, `thread_args`, `_child_flags`, and `_cc_override_args`. - **Single-track preserved PGS subtitles no longer desync (shift earlier) in the output (Subs_Fix)** — a selected PGS bitmap was extracted to a standalone `.sup` elementary stream and re-imported as a separate ffmpeg `-i` input. Without `-copyts`, ffmpeg rebases each input file's timeline so its earliest packet maps to t=0; a `.sup` carries only subtitle packets, so its first cue (e.g. a 5 s lead-in) was pulled to 0 and the whole track shifted earlier by the lead-in gap — progressively misaligning the subtitles against the stream-copied audio/video. Embedded preserved subtitles are now mapped straight from the source (`-map $src:s:N`) — the same mechanism multi-track mode has always used (and which carries the source's t=0 anchor) — so the `.sup` is never round-tripped and the rebase cannot occur; the single-track direct-map text fallback folds into the same source-mapping path. The structural guards are `_test_unit_sub_emitter`'s **T6** (an embedded PGS track ⇒ `-map src:s:N` with **no** `.sup` input; always runnable, no media) and `_test_unit_sub_plan_add_embed` (a preserved PGS `.sup` ⇒ an `embedded:N` track) — both verified RED against the pre-fix binary (the unified model and emitter don't exist there) and GREEN after. The end-to-end PTS-preservation test (**T11**, `--suite subs`) runs when a PGS source is supplied via `MUXM_PGS_FIXTURE` and SKIPs otherwise (ffmpeg cannot synthesize a PGS fixture from text), mirroring the `dv_vt`/`dv_sw` fixture-gating. - **Man-page installer no longer hangs or fails cryptically when `sudo` is unavailable, and no longer reports success on a failed write/remove (RV2-02)** — `_install_man`/`_uninstall_man`/`_refresh_mandb` invoked `sudo` unconditionally whenever the target man directory wasn't writable; on a system with no `sudo` binary (common on locked-down containers) this surfaced as a raw "command not found" or, in some environments, a hanging password prompt. Separately, neither installer checked the exit status of the write/remove itself before printing "✅ Installed"/"✅ Removed" — a false-positive `-w` check, a filled quota, or a failing `sudo tee`/`sudo rm` would still report success. A new `_require_sudo_for` helper checks `command -v sudo` before every privileged call site and `die`s with an actionable message if absent; both installers now check the write/remove exit status and `die` with an accurate message on failure. `--install-man` also now warns (via a new `_warn_if_not_on_manpath`) if the install target isn't on the resolved `manpath`/`$MANPATH`, instead of silently assuming `man muxm` will find it. - **`--install-completions` idempotency check no longer treats a commented-out source line as "already configured" (RV2-03)** — the RC-file check matched `muxm-completion.bash` anywhere in the line, including inside a `#`-commented line. A user who manually commented out the source line to disable completions found that re-running `--install-completions` silently left them disabled (reported "already configured" and did nothing). The check now excludes commented lines before matching. - **`$HOME` unset no longer crashes every `muxm` invocation, including `--help` (RV2-04)** — the config chain unconditionally sourced `"$HOME/.muxmrc"` under `set -u` at script startup, before argument parsing even runs, so an unset `$HOME` raised bash's raw `HOME: unbound variable` on *any* command. Guarded with `[[ -n "${HOME:-}" ]]` so that tier of the config chain is simply skipped when `$HOME` is unset (it has no meaningful path anyway); `_install_completions`, `_uninstall_completions`, and `_create_config`'s `user` scope now separately `die` with a muxm-specific diagnostic when they specifically need `$HOME` and it's unset, instead of relying on the same raw bash error. - **`--install-dependencies`'s mp4box/gpac install check no longer leaks its resolved binary path into every other tool's "already installed" line (RV2-05)** — while collapsing the hand-duplicated mp4box/gpac block into the shared `_brew_ensure` helper (see Changed), the detected `$MP4BOX_CMD` global (a side effect of `_detect_mp4box`) was read unconditionally for every tool's status line, not just mp4box's — so e.g. `tesseract`'s "already installed" line displayed mp4box's path instead of its own. Caught during manual testing before any test was written; the lookup is now gated to the detect-override call site only. - **A latent `OUTPUT_HAS_DV` inconsistency in the DV give-up-to-base fallback paths is now structurally impossible (RV2-06)** — of the five "give up on DV, fall back to base video" branches in `run_video_pipeline` (RPU validation failure, inject failure, inject-empty, `dovi_tool convert` failure, RPU/frame-count mismatch), only two explicitly reset `OUTPUT_HAS_DV=0`; three omitted it. Not a live bug (the flag was still 0 at every one of those points in the prior control flow), but a latent trap for any future change reaching them with it already 1. Extracting a shared `_dv_give_up_to_base` helper (see Changed) that all five branches now call means the reset can no longer be selectively missing from a subset of branches. - **`--checksum-algo` is now validated at parse time, matching every sibling flag (RV2-07)** — an invalid value (e.g. `--checksum-algo bogus`) was previously only caught by a separate `case` statement after the entire CLI parse loop finished, unlike `--level`/`--av1-maxrate`/`--audio-force-codec` etc., which `die 11` immediately at their own parse site. A new `_validate_checksum_algo_arg` helper is now called both inline at `--checksum-algo`'s parse arm and at the existing post-config-load re-check (closing the sourced-`.muxmrc` bypass), so a typo fails fast with a message consistent in shape with every other flag validator. - **Two new helper functions could abort a run under `set -eEuo pipefail` on ordinary inputs (RV2-08)** — `_ocr_lang_flags` (subtitle OCR language-flag selection) and `_audio_pretty_line` (audio track display formatting), both introduced in this release, ended with a bare `cond && action` as their *last* statement. Under bash's `set -e` semantics, when such a construct is a function's final statement and `cond` evaluates false, the function's own exit status becomes nonzero and propagates to the (unprotected) call site — aborting the whole script. This meant every real `pgsrip`/custom-OCR-tool run with no `SUB_OCR_LANG` set, and every audio track display of a 0-bitrate or untitled track (both ordinary cases, not edge cases), would have crashed. Caught via the `cli` suite's existing `DEBUG=1` trace smoke test before either function shipped; fixed with an explicit `return 0` in both, and the discovering test was strengthened to run under `set -e` going forward so this exact regression class can't reappear silently. - **New post-mux audio/subtitle stream-count cross-check, and two more `set -e`/`pipefail` crash bugs closed while building it (RV2-09)** — `_validate_media_file` gained an optional Step 3 (gated to `--verbose`/`DEBUG=1`, so it costs nothing on an ordinary run) that compares the audio/subtitle stream counts ffmpeg was actually told to `-map` against what ffprobe reports landed in the muxed output — defense-in-depth on top of the existing pre-encode guards, since a real partial `-map` failure inside ffmpeg is rare but not theoretically impossible. Building it surfaced two more instances of the same `set -e` bug class as RV2-08: a bare `(( count++ ))` post-increment (whose exit status reflects the *pre*-increment value) evaluating false the moment the very first audio/subtitle stream was counted (0→1), and a `grep -c .` returning exit 1 on a legitimate zero-match count (e.g. a video with no subtitles) — both would have aborted every real encode the instant this feature shipped. Fixed with plain arithmetic-expansion assignment and an `awk 'END{print NR}'` count respectively, both caught via manual end-to-end testing before any automated test was written. - **Multi-track commentary tracks no longer lose their "Commentary" label (RV2-10)** — with the default `INCLUDE_AUDIO_TITLES=1` and a kept commentary track (`AUDIO_KEEP_COMMENTARY=1`, multi-track mode), the track's title was unconditionally overwritten with a bare codec/channel descriptor (e.g. "Stereo (AC-3)") indistinguishable from any other track — a listener browsing tracks by title in a player had no way to identify the commentary track without playing it. A new `AUDIO_MT_COMMENTARY[]` array, populated in the same keep-list pass that already computes the commentary predicate (so it can't drift from the keep/drop decision), now drives an unconditional `" — Commentary"` suffix on the descriptive title (e.g. "Stereo (AC-3) — Commentary") — no new flag; a genuine alternate-language non-commentary track is unaffected, and `--no-audio-titles` still passes the original source title through verbatim (unchanged code path). - **AV1/software-encoder preflight false-fails "encoder not available" on a standard two-ffmpeg Homebrew setup (RC1)** — `ffmpeg_has_encoder()` memoizes `ffmpeg -encoders` into `_FFMPEG_ENCODERS_CACHE` on first call (never refreshed by its own `_FFMPEG_ENCODERS_CACHED` guard). `detect_hw_accel` is the first thing in a normal run to call it, locking the cache in from whatever incomplete `ffmpeg` is on `$PATH` at that point; `_prefer_complete_ffmpeg` runs later, detects the incomplete on-PATH `ffmpeg`, and prepends the complete keg-only `ffmpeg-full` bin — but the AV1 encoder checks (`libsvtav1`/`libaom-av1`) still read the stale cache from the old ffmpeg, so `--video-codec libaom-av1` (a documented, man-page-listed flag, and what `av1-hq`/`streaming-av1` are built around) died with a false "encoder not available" immediately after muxm announced it had switched to the correct ffmpeg. `_prefer_complete_ffmpeg` now clears `_FFMPEG_ENCODERS_CACHE`/`_FFMPEG_ENCODERS_CACHED` right when it switches `PATH`, so the next `ffmpeg_has_encoder` call re-probes against the newly-preferred complete build. Reproduced verbatim on a real two-ffmpeg Homebrew install (pre-fix: exit 10; post-fix: proceeds past the encoder check). New `hw_accel` regression test mocks an incomplete on-PATH ffmpeg plus a complete keg-only `ffmpeg-full` and asserts the AV1 preflight succeeds instead of falsely failing. - **Empty audio/subtitle keep-list crash on bash < 4.4, a gap in RV3-05's sweep (RC2)** — `_build_audio_keep_list` and `_build_subtitle_keep_list` both ended with a bare `echo "${kept[*]}"`; on the documented-floor bash 4.3 down through macOS's stock bash 3.2, referencing `${kept[*]}` under `set -u` when every track is filtered out (an ordinary, documented outcome — e.g. a multi-track source whose only audio/subtitle tracks are all a non-preferred language) throws "unbound variable" and aborts the whole invocation. RV3-05's whole-file empty-array sweep this same release only checked the double-quoted `"${arr[@]}"` (AT) form and missed the `"${arr[*]}"` (STAR) form entirely. Both sites now use `(( ${#kept[@]} > 0 )) && echo "${kept[*]}" || echo ""`. `_test_unit_empty_array_safe` gained a new static STAR-form check closing this gap for good, plus a genuine behavioral regression test that reproduces the crash on a real pre-4.4 bash and confirms the fixed functions now degrade gracefully to an empty result. - **`detect_hw_accel` has the same empty-array STAR-form crash for `HW_ACCEL_AVAILABLE` (RC3)** — `HW_ACCEL_AVAILABLE` can legitimately resolve empty (any host with no VideoToolbox and no NVENC-capable ffmpeg — the common case on Linux/Intel-Mac/most stock ffmpeg builds), and the three membership checks against it (`auto`, explicit `videotoolbox`/`nvenc`) were bare `[[ " ${HW_ACCEL_AVAILABLE[*]} " == *"..."* ]]` expansions with the same bash <4.4 `set -u` crash as RC2. `muxm --hw-accel auto` (or an explicit backend) on such a host with an old bash previously aborted instead of falling back to software encoding. Each comparison is now guarded with `(( ${#HW_ACCEL_AVAILABLE[@]} > 0 )) && ...`. New `hw_accel` regression test forces an empty `HW_ACCEL_AVAILABLE` under a real pre-4.4 bash and confirms the existing software-fallback note fires instead of a crash; `_test_unit_empty_array_safe` gained a companion static membership-test STAR-form check. - **PGS OCR silently no-oped after the documented `--install-dependencies` setup, because the default `SUB_OCR_TOOL` names a tool the installer never installs (RC4)** — `--install-dependencies` installs `pgsrip` (via `_pipx_ensure`), never `sub2srt`, but the compiled-in default `SUB_OCR_TOOL="sub2srt"` was left untouched for any user who followed the documented brew-install + `--install-dependencies` setup without a separate `--create-config` run — PGS OCR warned "not in PATH" and silently skipped on every run. The Section-14 OCR-validation check now auto-switches `SUB_OCR_TOOL` from the untouched default to `pgsrip` when `sub2srt` is absent but `pgsrip`+`tesseract` are present (mirroring `_create_config`'s own auto-detection), emitting a `note` explaining the switch. A new `_CLI_OCR_TOOL_EXPLICIT` tracker (mirroring the existing `_CLI_*_EXPLICIT` convention) ensures an explicit `--ocr-tool sub2srt` on the CLI is never silently overridden; a `.muxmrc` that explicitly writes `SUB_OCR_TOOL="sub2srt"` is indistinguishable from the untouched default and will also be upgraded when sub2srt is missing — accepted, since the fallback only ever activates when sub2srt genuinely isn't installed, so the practical effect is OCR that works rather than silently skipping. New `config` suite test (`_test_config_ocr_pgsrip_fallback`) covers both the auto-fallback and the explicit-CLI-choice guard via a real `--dry-run` invocation. ### Changed - **Efficiency cleanup (RV3-15)** — three bash-orchestration (not encode-time) redundancies routed through existing caches, no behavior change. (1) `detect_dv`'s stream-level check now greps the already-populated `METADATA_CACHE` (the first video stream's JSON, for the same `dvhe`/`dvh1` codec tags + DOVI side data) instead of spawning a fresh `ffprobe -show_streams` — the frame-level probe stays (frame side data isn't cached). It runs well after the cache is populated, so there's no staleness. (2) `_audio_codec`/`_audio_channels` read the memoized `_AUDIO_STREAM_INFO_CACHE` (via `_audio_stream_info`) rather than forking `jq` per call — several selection/display loops call these per track per pass. (3) The rare direct-map subtitle fallback (`_pick_direct_text_sub_relidx` and its track-add in `build_subtitle_plan`) reads the already-collected `ALL_SUB_LANGS[]`/`ALL_SUB_CODECS[]`/`ALL_SUB_SOURCES[]` (populated by `merge_subtitle_sources`, which runs first; embedded entries are in order so the array index is the source relative index) instead of re-forking `jq` via `_sp_sub_lang`/`_sp_sub_field`/`list_sub_indices`. New `video`/`audio`/`subs` tests assert the optimization actually happened: a probe-count check (DV cache → 0 fresh ffprobe, non-DV → 1 frame probe), a jq-fork-count check (seeded cache → 0 forks, with an empty-cache control that does fork), and a structural check that the picker/track-add read `ALL_SUB_*` and no longer probe. - **Readability & DRY cleanup (RV3-14)** — pure refactors, no behavior change (verified: every touched suite passes identically, and a P8/HLG DV fixture produces byte-identical verbose pipeline output before/after). (1) The DV portion of `run_video_pipeline` is decomposed into three named helpers mirroring the existing `_dv_give_up_to_base`/`_dv_mp4box_wrap` precedent — `_dv_extract_rpu` (piped-primary / on-demand-ES-fallback RPU extraction), `_dv_analyze_profile` (the P8-passthrough / P7·P5→P8-convert / unknown decision tree, returning the convert verdict as its exit code), and `_dv_verify_frame_count` (RPU↔video frame-count check). Each helper returns a plain verdict; the `_dv_give_up_to_base`/`die`/`return` control-flow seam stays in the caller, so all six give-up branches remain in `run_video_pipeline`. (2) The `.sup`/`.idx` OCR arms of `_prepare_ext_subtitle` — ~20 duplicated lines — are factored into a shared `_ocr_ext_subtitle`. (3) `_audio_lang_matches`/`_sub_lang_matches`, previously byte-identical apart from the global they read, are now thin wrappers over a parametrized `_lang_matches`. (4) The audio/subtitle multi-track "is this index in the keep list?" linear scans are unified on a shared `_int_in_list`. (5) 13 lines of trailing whitespace (all whitespace-only blank lines, all outside the man/completions/config heredocs) trimmed. Test-harness structural/extraction guards were updated to track the relocated code (the `_extract_muxm_fns` helper now auto-pulls `_lang_matches` for the wrappers). - **On a kept failure (`-k`/`-K`/`DEBUG=1`), diagnostics are now the renamed workdir — no duplication (Adjustments Phase 3)** — previously *every* failure copied a small KB bundle (the run log + all `*.err`/`*.log`) into a visible `.muxm-debug/` beside the output, and `-k`/`-K` *additionally* kept the entire workdir under its **hidden** `.muxm.tmp.XXXX` name. So a kept failure left two copies of the text logs (one in the hidden workdir, one in the visible bundle) and buried the full artifacts in a hidden dir the user wouldn't think to open. Now, when artifacts are going to be kept anyway, muxm **renames the hidden workdir in place** to a visible `muxm-debug.XXXX` (reusing the workdir's unique suffix) and **skips the separate copy-out** — one user-visible set of artifacts, no duplication. The rename is a cheap same-fs operation that needs no extra disk (a real win when the failure itself is "output disk full"), and it stays on the **workdir's own volume**: without `--workdir` it lands beside the output as before; with `--workdir` it lands on the roomy volume the user chose, never dragging multi-GB intermediates across to the output disk. The **non-keep** path is unchanged (small KB `.muxm-debug/` bundle beside the output, then the workdir is removed). The `Build FAILED … Diagnostics:` breadcrumb points at whichever location applies. - **Behavior change to note:** with `-k`/`-K` on failure, the kept artifacts now include the full workdir — i.e. the **multi-GB binary intermediates** — under a visible name, where before the *visible* bundle was KB-only and the large files sat hidden. This is the intended outcome of asking to keep artifacts; if you only want the small text logs, run without `-k`/`-K`. With `--workdir` + `-k`/`-K`, the visible artifacts land on the `--workdir` volume rather than beside the output. - **The output lock is now a hidden file beside the output (Adjustments Phase 2)** — the advisory lock that stops two concurrent runs from clobbering the same `$OUT` was a plainly visible `${OUT}.lock` directory (e.g. `Movie.mkv.lock`) sitting next to the output. It is now a hidden `…/..lock` directory (e.g. `.Movie.mkv.lock`) — same directory, still keyed to the **output** (not the source), still the atomic `mkdir`-directory + inner `pid`-file mechanism (portable where `flock(1)` is unavailable); only the **name** changed. The computed path is stored in a new `OUT_LOCK_PATH` global so the acquire site (`_acquire_out_lock`) and the release site (`on_exit`) can never drift. The `die` messages that print the lock path now show the hidden name. Verified: a live concurrent run is still refused with the holder's PID and the new path; the hidden lock appears during a run and is released on every exit path including SIGINT/SIGTERM; a stale lock (dead owner PID) is still reclaimed. (Also dropped a stale code-comment reference to a non-existent `run_parallel.sh` stale-dir sweep.) - **Config-surface cleanup: `DRY_RUN` and `PROFILE_COMMENT` no longer leak into generated configs (Adjustments Phase 1)** — `--create-config` templates no longer emit a `DRY_RUN` line (a per-invocation mode; a persisted `DRY_RUN=1` silently makes every encode produce nothing) or a `PROFILE_COMMENT` line (documenting the profile-tagline easter egg in a file users routinely open spoils the surprise). Both were removed from `CONFIG_TRACKED_VARS` and from the hand-maintained emit lines in `_create_config_emit`. `PROFILE_COMMENT` is additionally dropped from the `--print-effective-config` dump; `DRY_RUN` is deliberately **kept** in that dump (its runtime state is worth showing). The `--dry-run` CLI flag, the runtime `--profile-comment` / `--no-profile-comment` opt-out flags, and the easter egg itself are all unchanged and fully functional. Because `PROFILE_COMMENT` is no longer a config-generation surface, the now-meaningless `--create-config --profile-comment` / `--no-profile-comment` config-override arms were removed (they would otherwise be silently dropped — the exact bug the `_test_cli_flag_drift` Assertion-B guard rejects); passing them to `--create-config` now fails fast with `die 11` instead of being a no-op. - **Internal cleanup (L6–L9 + cosmetics), no behavior change** — Hoisted the bitrate/level validation patterns to single `readonly _RE_BITRATE`/`_RE_LEVEL` constants used by both the CLI-parse validators and the post-config re-validation (L7); added an `_is_av1_codec` helper replacing the `[[ $VIDEO_CODEC == libsvt-av1 || … == libaom-av1 ]]` membership test repeated across seven sites (L8); deleted three orphaned helpers (`_audio_lang`, `_audio_bitrate`, `_sp_sub_dispo_forced`, L9); removed an unreachable `--profile) shift 2` parser arm and a dead `p010le` clause in `build_videotoolbox_params`; dropped the never-passed 4th parameter of `_warn_container_unusual`; added belt-and-suspenders numeric guards on the mp4box `:dvp=` hint inputs; and corrected/clarified several comments (the cross-filesystem `mv` of the DV fallback output, the subshell-PID of the piped RPU extraction, and a stale line-number citation). The `_create_config` `die 1`/`die 11` exit-code normalization (Informational) was deliberately deferred — changing the exit taxonomy risks scripters who depend on the current codes. - **Corrected the §5 `grep` portability audit and removed a redundant non-POSIX `-m1` (RF10)** — the audit comment listed only `-i, -E, -o, -q, -v` while the code also used `-x` and `-F`, and the two `grep -m1 -qiE` DV-probe calls used `-m` (a non-POSIX GNU/BSD extension that is redundant with `-q`). The `-m1` is removed; the comment now reads `-i, -E, -o, -q, -v, -x, -F` (all POSIX); and a new unit test (`_test_unit_grep_flags`) enforces the grep-flag allow-list so a non-POSIX flag can't be reintroduced unnoticed. - **Internal cleanup (RF12), no behavior change** — narrowed the two audio `SYNC` comments to name exactly what must stay in sync (only the per-stream `pretty=` line construction); removed a dead `streaming` alias arm in `_profile_comment` (unreachable because `apply_profile` normalizes `PROFILE_NAME` to the canonical name first); dropped a redundant `head -n1` in `_probe_stream_field` (the `.[$idx].$field` filter already yields a single scalar, E4); armed the `EXIT` trap immediately after the workdir is created — instead of just before the output-lock acquisition — to close a sub-second window in which a signal would leak the workdir; and `hevc_videotoolbox` now rejects an unsupported 4:2:2/4:4:4 target pixel format with a clean error (`die 11`) instead of forwarding it into an encode that would fail cryptically. *Deferred as out-of-scope cosmetic/immaterial:* `_`-prefixing the five internal audio helpers (referenced by ~30 test assertions, purely cosmetic) and the E1/E3/E5 efficiency restructurings (negligible vs. transcode cost, behavior-preserving). - **Single-track and multi-track subtitle handling unified onto one model + emitter (Subs_Fix), no behavior change beyond the PGS desync fix** — both subtitle selectors (`build_subtitle_plan` for single-track, `run_subtitle_pipeline_multi` for multi-track) now populate one parallel-array model (`SUB_TRACKS_*`) consumed by a single emitter (`_emit_sub_tracks` in `mux_final`), which produces every kept track's `-i` / `-map` / `-c:s` / language / title / disposition in one pass. The two modes now differ **only** in *selection* (single-track ranks and picks ≤1 per type, OCR'ing bitmaps when the container can't carry them; multi-track keeps everything stream-copyable); the old per-mode input-registration, mapping, and tagging loops in `mux_final`, plus the nested `_sub_codec_for_file`, were deleted — folding the former two codec decisions into one effective-codec rule. Multi-track output is **byte-for-byte unchanged** (verified by a direct command-line diff against the pre-refactor binary). Supporting changes: `mux_final` now logs its assembled command (`ffmpeg mux command: …`, mirroring the encode step; persisted to `.muxm.log` under `--keep-log`), and the now-dead `SRT_DIRECT_MAP` flag plus several unused `mux_final` locals were removed (grep-verified zero readers). - **Prepared text subtitle files are now stream-copied (`-c:s copy`) instead of re-encoded (`-c:s srt`) (Subs_Fix), output-equivalent** — under the unified emitter a single-track text subtitle prepared as a workdir or external-sidecar file (and the direct-map text fallback) tags `-c:s copy` rather than the previous `-c:s srt`. Both yield identical SubRip in an MKV (and both still convert to `mov_text` for MP4/MOV), so the output stream is unchanged — only the ffmpeg command token differs, now matching how multi-track mode has always copied the same content. (The direct-map fallback additionally drops a redundant trailing `?` from its `-map` selector and now carries language/title/disposition metadata.) - **Second review pass: DRY extractions and micro-efficiency batching across the installer, DV, subtitle-OCR, and audio pipelines, no behavior change (RV2-11)** — collapsed five near-identical "give up on DV, fall back to base video" blocks in `run_video_pipeline` into a shared `_dv_give_up_to_base` helper, and the duplicated mp4box `dvp_hint`/fps-argument/invocation block (`verify_dv_container_record` + `run_video_pipeline`'s pre-wrap) into `_dv_mp4box_wrap`; collapsed the mp4box/gpac install-check block in `_dep_install_dependencies` into the shared `_brew_ensure` helper (now accepts an optional detection-function override for tools like mp4box/MP4Box whose binary name varies by platform); collapsed the triplicated OCR language-flag `case` statement (`_prepare_subtitle`, `_prepare_ext_subtitle`) into `_ocr_lang_flags`, and the duplicated OCR spawn/track-PID/spinner/wait sequence (4 call sites) into `_run_ocr`; collapsed the duplicated per-stream audio display-line construction (`run_audio_pipeline`, `run_audio_pipeline_multi`) into `_audio_pretty_line`, removing the `SYNC:` comment that used to warn editors to keep the two copies in step — there's only one copy now. Separately, batched the 4 separate `_probe_field` color-decision jq forks (`decide_color_and_pixfmt`, `_output_pixfmt_is_10bit`) into one `_probe_video_color_fields` call; memoized `_audio_stream_info` per track index via a new `_prefetch_audio_stream_info` pass (cutting what was O(tracks × ~3 passes) redundant jq forks to one per track, done once); and merged `_uninstall_completions`'s two `_sed_inplace` passes into one. All behavior-preserving; protected by existing suite coverage plus new structural (M-DRY-style) unit tests confirming each former call site now delegates to its shared helper. - **`audio-forceaac-stereo-bitrate` no longer needs a real `libfdk_aac`-capable ffmpeg to run pass/fail (RC9), test-only, no behavior change** — the RV3-08 regression test for the forced-AAC `STEREO_BITRATE` override previously SKIPped its `libfdk_aac` case on any machine without a nonfree ffmpeg build (Homebrew's `ffmpeg`/`ffmpeg-full` formulas both exclude it by license policy). The override decision itself (`_encoder_to_codec`'s codec-family mapping, keying the bitrate choice) is pure bash that runs before ffmpeg is ever invoked, so the test now drives it with a fake ffmpeg wrapper that only needs to answer `-encoders` truthfully (mirroring the RC1 mock pattern) — real ffmpeg is still delegated to for the actual encode and may legitimately reject the faked codec name afterward, which is fine since the log line under test already landed by then. Runs deterministically pass/fail on any machine now, no nonfree dependency required. (Also fixed, while building this: the muxm invocation wasn't guarded with `|| true`, and the log lookup used a glob that only matched the success-path workdir shape, not the visible `muxm-debug.*` rename an intentionally-failing run gets under `-K` per Adjustments Phase 3 — both are fixed too.) - **Removed the `subs` suite's end-to-end PGS desync regression test (RC10), test-only, no behavior change** — it could only ever run given a real, externally-supplied PGS (Blu-ray bitmap subtitle) source file via `MUXM_PGS_FIXTURE`, since ffmpeg cannot synthesize embedded PGS from text; on every machine without one it permanently SKIPped, so it could never contribute a real pass/fail signal. Removed as unautomatable. The same bug (Subs_Fix's PGS timestamp-rebase fix) keeps its real, always-runnable regression coverage from `_test_unit_sub_emitter`'s T6 (embedded PGS maps straight from source, never through a `.sup`) and the sub-plan-add unit test (single-track PGS → `embedded:N`), both fixture-free and both verified RED against the pre-fix binary. ### Docs - **Man-page `.TH` date is now generated from a `RELEASE_DATE` constant, not hardcoded (RF11)** — the date was a literal buried in the man-page heredoc that the docs-parity guard couldn't see drifting (both the committed `docs/muxm.1` and `muxm --emit-man` derive from the same source, so a stale date matched itself). `_man_emit` now substitutes a `__DATE__` token from a `RELEASE_DATE` constant co-located with `VERSION` (so a release bump updates both together), and `tools/gen-docs.sh` asserts the substitution produced a real ISO date — so the date can't silently rot. Regenerated `docs/muxm.1` shows `2026-07-06` (the final RELEASE_DATE for this release). - **Clarified three areas that were correct but under-documented (RV2-12)** — `apply_profile_archive`'s `VIDEO_CODEC="libx265"` now has an inline comment noting it's the re-encode *fallback* used only if the compliance-copy check fails, not the primary path (the surrounding `VIDEO_COPY_IF_COMPLIANT=1` made this non-obvious in isolation); `_create_config_prescan` now has a comment enumerating the exact argument-index layout relative to the flag's own position, for future-edit safety; and the man page's "Audio Track Selection" section plus `README.md`'s "Audio Pipeline" section now document that multichannel scoring is based on channel *count* only, not `channel_layout` — a discrete 7.1 track and a 5.1.4 Atmos-in-TrueHD track with the same channel count score identically (an accepted simplification, not a bug). - **Man-page `--keep-log` entry now describes the current Phase-3 failure-diagnostics behavior (RC5)** — the `MANPAGE_EOF` heredoc's `--keep-log` prose still described the pre-1.5.1 behavior (a failure always writes a small KB bundle; "unlike `-k`/`-K`, which keep the whole workdir, this keeps only the small log and diagnostics"), contradicting this same release's Adjustments Phase 3 change: on a kept failure (`-k`/`-K`/`DEBUG=1`), muxm instead renames the entire workdir (including the multi-GB video/audio intermediates) in place to a visible `muxm-debug./` on the workdir's own volume and skips the small-bundle copy entirely — only the non-keep default path still writes the small `.muxm-debug/` bundle. The heredoc text now matches the real `on_exit` control flow; `docs/muxm.1` was regenerated via `tools/gen-docs.sh` (`completions/muxm-completion.bash` regeneration is an unaffected no-op). - **Stale comment count in `_dv_give_up_to_base` (RC6)** — the helper's header comment still said it collapsed "five" give-up blocks and enumerated only five reasons, left over from before RV3-11(4) added a 6th call site (`_dv_give_up_to_base "DV extract failed"`). Updated the comment to say "six" throughout and added "DV extract failed" to the enumerated give-up reasons; comment-only, no behavior change. - **Fixed invalid `hevc-hq` profile reference in AV1 calibration docs (RC7)** — `docs/AV1_CALIBRATION.md`'s `av1-hq` recommendation section referenced a nonexistent `hevc-hq` profile in backticks right next to the real `hdr10-hq` (there is no `hevc-hq` in `VALID_PROFILES`; a repo-wide grep found only this one occurrence), reading as if it were a second valid `--profile` value. Removed the invented name, leaving the real `hdr10-hq` baseline reference intact. - **README: corrected Homebrew `bc` dependency claim and flagged the missing DV setup step in Quick Start (RC8)** — the Installation section claimed Homebrew installs `bc` as a required dependency, but the live tap formula only depends on bash/jq/ffmpeg (`bc` ships with macOS/most Linux distros and was never Homebrew-managed); reworded to stop misattributing it. Also added a one-line pointer to `--install-dependencies` directly under the Quick Start block, since a bare `brew install` + `--profile atv-directplay-hq` copy-paste has no Dolby Vision tooling (dovi_tool/gpac) despite DV Profile 7→8.1 conversion being that profile's headline feature. ## [1.5.0] - 2026-06-25 ### Added - **Single source of truth for the man page and tab completions** — `docs/muxm.1` and `completions/muxm-completion.bash` are now *generated* from embedded heredocs in `muxm` (the `MANPAGE_EOF` and `COMPLETIONS_EOF` blocks) rather than hand-maintained, ending the recurring drift between the installed and committed copies. New hidden, maintainer-only `--emit-man` / `--emit-completions` flags emit each source; `tools/gen-docs.sh` regenerates both committed files from them (the man page gets a prepended `.\"` "do not edit" banner; the completion carries its banner inside the heredoc, so its committed copy is byte-identical to `muxm --emit-completions`). A new `docs` test suite (`tests/test_docs_parity.sh` + `tests/test_completions_parity.sh`) fails if either committed file diverges from its heredoc, and `_test_cli_flag_drift` gained a parser→man-page reverse sweep so every CLI flag must be documented. `--emit-man` / `--emit-completions` are deliberately absent from `--help`, tab completion, and the man page, and are allow-listed via a `hidden_flags` array so the drift guards still cover every other flag. See `CLAUDE.md` for the workflow. - **`tests/run_parallel.sh` — parallel test driver** — Runs the suites concurrently to cut wall-clock without modifying `test_muxm.sh`. It fans out one `test_muxm.sh --suite NAME` process per suite, each in its own `$TMPDIR`, so the per-run `mktemp` `$TESTDIR` / isolated `$HOME` and the suite's `auto_cleanup` never collide (this also sidesteps the narrow startup window between `mktemp` and the `.muxm-test.lock` write). Suites run in **two phases** — the config/CLI/unit suites fully parallel, then the real-encode suites at bounded concurrency (`CAP`, default cores/2) — because mixing the CPU-saturating encodes with the latency-sensitive `--print-effective-config` probes starves the latter and produces intermittent false "output missing" failures; running them in separate phases avoids that (the config suites parallelize cleanly among themselves, as do the encode suites). Each phase shows muxm's **spinner** (ported verbatim — same frames/cadence/`[ done ]`) on a TTY, falling back to a plain per-phase line when piped/CI. A **drift guard** cross-checks its suite list against `test_muxm.sh`'s `run_suite_tracked` calls and **fails loudly** rather than silently skipping a newly-added suite. Hardened to the harness's own bar: `-h/--help` plus `--muxm`/`--cap` flags (each overriding the matching env var), positive-integer validation of `CAP`, a bash 4.3+ guard (it relies on `wait -n`), `INT`/`TERM` handling, and colour/`set` idioms aligned with `test_muxm.sh`. The work directory is removed on success but **kept on failure or interrupt** (like `test_muxm.sh` keeps its `$TESTDIR`) so the per-suite logs the failure report points at survive; a **liveness-aware startup sweep** then reclaims those stale dirs on a later run, skipping any still owned by a running instance (mirrors `auto_cleanup_test_dirs`). Exit status: 0 if every suite passes, 1 if any fails, 2 on a config/usage error. Combined with the tee-drain fix below, the full suite completes in **~50 s** (≈26× the sequential baseline) with zero failures across repeated runs. Usage: `./tests/run_parallel.sh [--muxm PATH] [--cap N]` (or env `MUXM=…`/`CAP=…`). - **Warning when H.264 silently drops Dolby Vision (C6)** — A Dolby Vision source encoded with H.264 (`--video-codec libx264`, or `h264_videotoolbox`) had its DV RPU silently discarded — H.264 cannot carry DV, and the RPU-injection path only accepts HEVC. muxm now emits a deferred, post-probe warning (`_warn_h264_drops_dv`, fired once the source is probed and `IS_DV` is known) telling the user the DV metadata will be dropped and pointing them to `--video-codec libx265` (or `--no-dv` to silence it). Gated on `!DISABLE_DV`, so a deliberate `--no-dv` stays quiet; AV1 + DV is unaffected (it already warns and auto-disables DV upstream). Warn-never-block. Covered by `--suite unit` (`_test_unit_h264_drops_dv`, deterministic predicate) and a `--suite dv_vt` end-to-end case gated on `MUXM_DV_FIXTURE`; the man page's Dolby Vision section documents the codec requirement. - **Warnings for silently-ignored CLI flags (C1–C5, C7–C10)** — When a flag the user **explicitly typed** is then dropped by the resolved backend or codec, muxm now surfaces a config-time warning (visible before `--print-effective-config` exits) instead of silently ignoring it. Consistent with the "warn, never block" philosophy, and gated on the `_CLI_*_EXPLICIT` trackers so a value supplied by a profile or config file never warns. A new `_warn_ignored_knobs` (run after `detect_hw_accel`) covers: **C1–C4** the VideoToolbox backend ignoring `--crf`/`--preset`/`--x265-params`/`--x264-params`; **C5** `--hw-accel-quality` with no hardware backend; **C7** `--av1-params`/`--av1-maxrate`/`--av1-bufsize` on a non-AV1 codec; **C8** `--x265-params`/`--x264-params` on the wrong codec; **C9** `--level` on an AV1 encode. Separately, **C10** adds the missing §13 profile-conflict arms for `av1-hq` (warns on `--output-ext mp4/mov`, `--video-codec` away from AV1, `--no-audio-lossless-passthrough`, `--tonemap`) and `streaming-av1` (`--video-codec` away from AV1, `--output-ext mkv`, `--tonemap`) — the only two profiles that previously had none. Builds on the Phase-8 `_CLI_*_EXPLICIT` infrastructure. (The H.264+Dolby-Vision drop warning, C6, is tracked separately.) Covered by new `--suite unit` (`_test_unit_ignored_knobs`, all C1–C9 incl. VideoToolbox cases that can't resolve off-macOS) and `--suite conflicts` (C5/C7/C8/C9/C10 end-to-end, C1–C4 gated on a VideoToolbox host) tests; `docs/HW_ACCEL.md §4` documents the behavior. - **Explicit-tracking for the advanced encode-param flags (`_CLI_*_EXPLICIT`)** — Foundational, no behavior change. `--x265-params`, `--x264-params`, `--av1-params`, and `--level`/`-l` now record whether the user typed them on the CLI (setting `_CLI_X265_PARAMS_EXPLICIT` / `_CLI_X264_PARAMS_EXPLICIT` / `_CLI_AV1_PARAMS_EXPLICIT` / `_CLI_LEVEL_EXPLICIT` in their parse arms), mirroring the existing `_CLI_CRF_EXPLICIT`. A value supplied by a profile or config file does **not** flip these — they record *intent*, not value. The trackers are surfaced in a new `[CLI — Explicit Flags]` section of `--print-effective-config` (which also lists the pre-existing CRF/preset/hw-accel/output-ext trackers) for diagnosability and testing. Nothing reads these flags yet; they are the prerequisite for the upcoming "silently-ignored flag" conflict warnings. Covered by new `--suite toggles` assertions (each flag flips only its own tracker; a config-set value leaves it 0). - **`--workdir DIR`** — Relocate the `.muxm.tmp.*` working directory (which holds all multi-GB video intermediates) to a volume other than the output's, so large encodes don't fill the output disk. Defaults to the output directory; the directory must exist and be writable (else exit 11). The final output temp stays in the output directory so the closing rename remains atomic. Registered in tab completions; covered by the `WORKDIR` regression test (`--suite regression_p5`). - **`atv-directplay-animation` and `youtube-upload` e2e test coverage** — Both profiles are now included in the `test_profile_e2e` matrix. Previously had zero end-to-end encode coverage; each entry validates container extension, video codec, and profile-specific properties (10-bit pixel format for `atv-directplay-animation`, H.264 + AAC for `youtube-upload`). - **`--hw-accel-allow-sw` / `--no-hw-accel-allow-sw` toggle tests** — Added to `TOGGLE_CASES` in `test_toggles`; the flags were parsed by the CLI but absent from the toggle coverage suite. - **`--sub-preserve-bitmap` / `--no-sub-preserve-bitmap` toggle tests** — Added to `TOGGLE_CASES` in `test_toggles` alongside the existing `--sub-preserve-format` pair. - **`--x264-params` value-flag test** — Verifies that `X264_PARAMS_BASE` is set correctly in the effective config; the flag had toggle coverage in the config suite but no direct value assertion. - **Hardware acceleration foundation (Phase 1)** — Plumbing for the upcoming VideoToolbox (v1.5.0) and NVENC (v1.6.0) backends. Introduces the `--hw-accel` CLI flag (and matching `HW_ACCEL` variable in `.muxmrc`) accepting `none`, `auto`, `videotoolbox`, or `nvenc`. Detection probes populate `HW_ACCEL_AVAILABLE` from `ffmpeg -encoders`; `auto` resolves to the best available backend. Compatibility gates identify incompatible combinations (Dolby Vision, `libaom-av1`, AV1 on VideoToolbox, pre-Ada NVENC for AV1) and fall back to software with a recorded reason. Explicit backends that aren't available fail fast with a precise error. No encode behavior changes yet: the existing software encoders remain in use. - **`docs/HW_ACCEL.md`** — Architecture document describing the resolver flow, gates, and forward plan for Phase 2 (VideoToolbox calibration) and Phase 3 (NVENC calibration). - **`hw_accel` test suite** (`tests/test_muxm.sh --suite hw_accel`) — Config-only regression coverage: CLI parsing, `.muxmrc` loading, CLI-over-rc precedence, rejection of invalid values, explicit-backend strict check, profile-level compatibility warnings, and `auto` resolution on platforms without hardware encoders. - **Non-AAC native stereo regression tests** (`tests/test_muxm.sh --suite audio`) — Two new cases in `_test_audio_native_stereo` guard the dropped-stereo fix below: EAC3 5.1 + FLAC 2.0 → MKV, and EAC3 5.1 + AC3 2.0 → MP4. Unlike the existing native-stereo test (which used an AAC track and only asserted the `"Native stereo track found"` log line, emitted before the copy), these probe the actual output streams — asserting the stereo track survives (2 audio streams) with the expected channel count and stream-copied codec (`flac`/`ac3`). Verified to fail against the pre-fix binary (`got 1` stream) and pass after the fix. Documented as cases 136–137 in `docs/TESTING_PLAN.md`, which also backfills the previously-undocumented native-stereo cases 133–135. - **Pure mapping/parse helper unit tests** (`tests/test_muxm.sh --suite unit`, new `_test_unit_mapping_helpers`) — 36 assertions covering seven previously-untested deterministic helpers: `_audio_codec_ext` (encoder → intermediate-file extension), `_ext_sub_codec_from_ext` (subtitle extension → ffprobe codec), `_norm_lang_code` (ISO 639-1 → 639-2/T), `_normalize_codec_lang` (codec/lang lowercasing + default fill), `_container_supports_bitmap_subs`, `_parse_ext_sub_filename` (filename → language/type), and `ffmpeg_has_muxer`. A wrong mapping in these surfaces downstream as cryptic `Unable to choose output format` / `Unknown encoder` errors. Documented as cases 219a–219g in `docs/TESTING_PLAN.md`. - **`--sdr-force-10bit` pixel-format verification** (`tests/test_muxm.sh --suite video`) — Encodes an 8-bit (`yuv420p`) SDR source and probes the output: `--sdr-force-10bit` produces `yuv420p10le`, `--no-sdr-force-10bit` stays `yuv420p`. Promotes the SDR 10-bit forcing check (M71–M74) from manual-only to automated. Cases 115–116. - **`DEBUG=1` trace smoke and bash 4.3+ version guard tests** (`tests/test_muxm.sh --suite cli`, new `_test_cli_robustness`) — Verifies a `DEBUG=1` dry-run still exits 0 with `set -x` enabled and that the trace lands on stderr (not stdout), and that running muxm under an old bash (macOS `/bin/bash` 3.2) is rejected with `requires bash 4.3+` and a nonzero exit. The bash-guard test skips where no bash < 4.3 is available. Cases 18–19. - **`_detect_mp4box` and `_validate_media_file` coverage** — `_detect_mp4box` PATH-mock unit tests (`MP4Box` present → `MP4BOX_CMD=MP4Box`, neither binary → rc 1/empty; the lowercase-`mp4box` fallback is a Linux-only assertion, skipped on case-insensitive filesystems), case 219h. `_validate_media_file` stream-layout tests in the `edge` suite (video-only file accepted; audio-only file rejected with exit 41 `no video stream`), cases 164a–164b. - **`--no-stereo-fallback` toggle test** — Added to `TOGGLE_CASES` in `test_toggles`; only the positive `--stereo-fallback` (`ADD_STEREO_IF_MULTICH=1`) was covered, leaving the negative polarity (`=0`) untested. - **`dv_vt` gated VideoToolbox + Dolby Vision regression suite** (`tests/test_muxm.sh --suite dv_vt`) — Substantiates the long-standing code comment claiming a VT-encoded HEVC base round-trips a Dolby Vision RPU bit-perfect: `resolve_video_encoder` has no DV gate and the raw-ES path is enabled for `hevc_videotoolbox` exactly as for `libx265`, so VT encodes an HDR10 base, `dovi_tool` injects the source RPU post-compression (encoder-agnostic), and MP4Box muxes the headerless ES. The test encodes a real DV source with `--hw-accel videotoolbox` and asserts the output still carries a Dolby Vision configuration record (RPU survived) with frame-count parity. Heavily gated — it **skips** (never fails) unless all of: macOS, an ffmpeg with `hevc_videotoolbox`, `dovi_tool` and MP4Box on PATH, and a real DV fixture supplied via `MUXM_DV_FIXTURE=/path/to/source` — so CI stays green without DV assets or Apple hardware. Generates no synthetic fixtures (classified media-free). ### Changed - **Test-suite efficiency cleanups (no coverage change)** — Two harness micro-optimizations that change neither what is exercised nor any assertion: (1) `assert_contains` now matches with a here-string (`grep -qiF -- "$needle" <<<"$haystack"`) instead of `echo "$haystack" | grep …`, dropping an `echo` subshell + pipe on each of its ~460 calls; (2) `test_dryrun`'s container-passthrough check no longer runs the identical `--profile atv-directplay-hq … --dry-run` invocation twice — a single capture feeds all of its assertions. Both surfaced in a performance audit of the test suite alongside the tee-drain fix below. - **`av1-hq` CRF is now resolution/HDR-aware (was a flat CRF 20)** — The profile hardcoded `CRF_VALUE=20`, calibrated for the 4K-HDR parity worst case but applied flat to all content, which oversized 1080p-SDR encodes and contradicted `docs/AV1_CALIBRATION.md` (CRF 28 @1080p SDR / 24 @4K HDR). `av1-hq` now bases at **CRF 28** (the 1080p-SDR transparency point, VMAF 97.91) and drops to **CRF 24** (VMAF 92.54 at 4K HDR — the documented calibrated default) for ≥4K or HDR sources, after the source is probed. The streaming-av1 resolution logic was generalized into a shared `_apply_av1_resolution_crf` helper that handles both AV1 profiles (`streaming-av1` 30→28 unchanged). The 4K/HDR target is a single named constant `AV1_HQ_HDR_CRF` (default 24; set to 20 for measured HEVC-CRF-18 parity, larger files), and an explicit `--crf` always wins via `_CLI_CRF_EXPLICIT` — so the prior behavior is one flag away (`--crf 20`). **Behavior change:** `av1-hq` output sizes differ from the old flat CRF 20 — smaller 1080p-SDR files, unchanged 4K/HDR fidelity. README/man/`--help`/`docs/config_profile.md` updated; covered by new `--suite unit` (helper, all profile×resolution×`--crf` cases) and `--suite video` (end-to-end res-aware) tests. - **Disk preflight now falls back to the source file size when bitrate metadata is missing** — `disk_free_warn` estimates the encoded size from `source bitrate × duration × CRF-ratio × preset`. When neither the per-stream nor the container `bit_rate` is present — common for **MKV** inputs, muxm's primary format — `vbr` was 0 and the whole estimate collapsed to 0, silently defeating the guard. It now `stat`s the source once and synthesizes an effective bitrate (`size × 8 / dur`) so the same CRF-ratio/preset math runs; copy/remux estimates are additionally floored at the source size (a `max`, so audio is never double-counted), and a degenerate re-encode estimate that underflows to 0 is recovered from the source size. The 25 % safety margin, the eager `_reclaim` calls, and the `peak_factor` copy-count axis (1× stream-copy / 2× non-DV re-encode / 3× DV) are all unchanged — this only fixes the per-copy *size* estimate. Net effect: the guard now fires on near-full-disk MKV jobs that previously slipped through (the safe direction); still overridable with `--no-disk-check` / `--workdir`. Covered by new `--suite unit` tests for all branches (bitrate present, bitrate missing → source-size estimate, neither → floor, copy-mode source floor, and the unchanged 2×/3× `peak_factor`). The man page `--disk-check` description notes the fallback. - **Removed the dead `--skip-video` flag** — It parsed and printed a `[Quick Test]` note but then always aborted with `die 42` (a video output container requires a video stream), so it never did anything useful. Dropping the video stream only makes sense for audio-only containers, which muxm doesn't target; the genuinely useful adjacent capability — process audio/subtitles only while leaving the video untouched — already exists as `--video-copy-if-compliant` (which *copies* rather than drops the video). Removed the flag everywhere: the `SKIP_VIDEO` declaration, the parse arm, the `[Quick Test]` note, both `die 42` guards (the second `if (( SKIP_VIDEO )); then die … else run_video_pipeline` collapses to an unconditional `run_video_pipeline`), and the entry in the embedded completion-flag list / `completions/muxm-completion.bash`. `--skip-audio` and `--skip-subs` are unchanged and still work. `muxm --skip-video …` now fails fast with exit 11 `Unknown option: --skip-video`. The `edge`-suite test that asserted the old exit-42 behavior now asserts the unknown-option rejection (`docs/TESTING_PLAN.md` case 173 updated accordingly). - **Documented that multi-profile cost scales linearly** — `--profile a,b,…` re-invokes the full pipeline once per profile from the original source, so there is no shared decode or amortization between passes (the isolation is intentional). Added a one-line note to `--help` (the `--profile` option now shown as `NAME[,NAME...]`) and the multi-profile section of the man page (embedded heredoc in `muxm` and `docs/muxm.1`) so users don't expect a multi-profile run to be cheaper than N single runs. Documentation-only. - **Removed the MKV/Apple TV "won't Direct Play" conflict warning from the `atv-directplay-hq` and `atv-directplay-animation` profiles** — Forcing `--output-ext mkv` on either profile previously emitted `⚠ Forcing MKV output. MKV Direct Plays on ATV via Plex/Infuse but not the native TV app.` MKV Direct Plays to Apple TV via Plex/Infuse, so an MKV output is a fully supported, legitimate choice, not a footgun — the warning trained users to ignore ⚠ for no benefit. Both `_warn_container_unusual … "mkv" …` calls are removed; forcing MKV still works exactly as before, just silently. (The native TV.app still requires MP4, but that is not a conflict worth warning on.) The `--suite conflicts` assertions for `atv-directplay-hq + mkv` and `atv-directplay-animation + mkv` now assert the warning does **not** fire, and a stale example in `docs/config_profile.md` claiming "MKV does not Direct Play on Apple TV" was dropped. Keeps the §13 "warn, never block" surface focused on genuine footguns. - **`streaming-av1` CRF is now resolution/HDR-aware (was a flat CRF 30)** — CRF 30 is visually transparent at 1080p SDR (VMAF 97.7) but the "+10 CRF vs HEVC" rule breaks down at UHD/HDR: at 4K HDR, CRF 30 scores VMAF 90.65, below the ~93 perceptual-transparency line (see `docs/AV1_CALIBRATION.md`, Test 2). `main()` now drops `streaming-av1` to **CRF 28 for ≥4K or HDR sources** (measured VMAF 91.24 at 4K HDR) after the source is probed, keeping CRF 30 for the common ≤1080p-SDR case. This is a deliberate streaming bandwidth/quality trade-off — full transparency (CRF ~20) is `av1-hq`'s job, not a streaming profile's; CRF 28 nudges quality up for a small size cost. The override is gated on `! _CLI_CRF_EXPLICIT`, so an explicit `--crf` always wins. (The ≥4K trigger also covers 4K SDR and the HDR trigger covers 1080p HDR — extrapolations beyond the single measured 4K-HDR data point, chosen as the safer higher-quality default.) `docs/AV1_CALIBRATION.md`, the README profile table/section, the man page, and `--help` updated. Covered by a new `--suite video` test (4K → CRF 28; 1080p SDR → CRF 30; explicit `--crf 25` on a 4K source wins; 1080p HDR → CRF 28), verified to fail on the pre-fix binary and pass after. - **Refactor: consolidated the duplicated `on_exit` tee-drain into a `_drain_tee` helper (A3)** — the success and failure paths of `on_exit` repeated the same byte-identical FD-restore + tee-drain + 5-second watchdog block (which already folds in the L9 ERR-trap guard). Extracted into one `_drain_tee` function called from both paths. No behavior change; covered by the existing dry-run/multi-profile suites that exercise both `on_exit` paths. - **`archive` profile now always outputs MKV (was container passthrough)** — `apply_profile_archive` sets `OUTPUT_EXT="mkv"` instead of `""`. MKV is the only container that universally holds TrueHD/DTS-HD MA, PGS/VobSub, and native ASS/SSA losslessly, so a copy-only archive of *any* source container (mp4, mov, ts, …) is now preserved without conversion — and `archive` can no longer be asked to copy a container-illegal codec into MP4/MOV (it drops out of the multi-track container-safety guard's blast radius; a user who still forces `--output-ext mp4` overrides the explicit profile default and the guard backstops it). README/man/`--help` updated ("Always outputs MKV" is now accurate). Covered by `--suite containers` (archive on an MP4 source → MKV with bit-identical copied streams) and `--suite profiles` (archive `OUTPUT_EXT=mkv`). - **Removed dead code: `run_with_spinner` and `_CLI_SUB_BURN_FORCED`** — `run_with_spinner` had zero call sites; `_CLI_SUB_BURN_FORCED` was set on `--sub-burn-forced` but never read. Both removed. (The `_CLI_HW_ACCEL_EXPLICIT` / `_CLI_HW_ACCEL_QUALITY_EXPLICIT` trackers — also set-but-unread — are *kept* and now commented as reserved foundation for the upcoming VideoToolbox/NVENC backend-precedence logic, mirroring `_CLI_CRF_EXPLICIT`.) - **Peak intermediate disk usage cut from ~5× to ~2–3× of the source on the DV path** — Previously the demuxed source ES, base, injected, mixed, and pre-wrapped video were all created and kept until the run ended. Two changes: (1) the raw HEVC source ES is now demuxed *lazily* (`_ensure_src_es`) — only if piped DV RPU extraction fails and the file fallback is needed; previously a full source-sized copy (tens of GB on a 4K title) was always written and usually never read. (2) Dead intermediates are reclaimed mid-run as each stage completes (`_reclaim`), sequenced around the DV-fallback alias so the base encode survives until the RPU/frame-count checks pass. Reclaim runs **even under `-k`/`-K`** — those still preserve the workdir and all logs/`.err`/RPU files; only the multi-GB video files (never useful for post-mortem) are removed. Covered by new `DVMKV` assertions. - **Disk preflight is now a hard stop (was warn-only)** — `disk_free_warn()` aborts with exit 11 when a volume has less free space than its estimated requirement, instead of printing a warning and continuing into a mid-run `ENOSPC` failure. The estimate is peak-aware: the workdir volume is checked against the peak working set (≈ `peak_factor` × encoded video — 3 for DV re-encodes, 2 for other re-encodes, 1 for stream-copy), and the output volume against the final-file estimate. For re-encodes the per-volume requirement is floored at `DISK_FREE_WARN_GB` (default 5 GB), so a re-encode won't start with less than that free; pure stream-copies/remuxes are gated by their own (small) estimate only, so a trivial remux is not blocked on a near-full disk. Override the whole guard with `--no-disk-check`. Covered by the `DISKSTOP` regression test. - **`docs/TESTING_PLAN.md` coverage gap analysis refreshed** — The §6 gap table and "Untested Areas" candidate list were cross-referenced against the current `tests/test_muxm.sh` and corrected to reality. Most areas previously marked ❌/⚠️ (toggle-flag completeness, multi-track audio/subtitle filtering, conflict warnings, and several pure-helper unit tests) are now covered. Added a dated audit note documenting the newly-added coverage and the `ffmpeg_has_muxer` fix below, and split the remaining-work list into automatable (fixture/mock-dependent) vs. genuinely-manual (real content / hardware / human judgment). Full suite at the time of writing: 1097 passed / 0 failed / 2 skipped. - **Forced subtitle burn-in narrowed to `universal` only** — `atv-directplay-hq`, `atv-directplay-animation`, and `youtube-upload` no longer burn forced subtitles by default. Forced foreign-dialogue subs are now soft (mov_text in MP4, native format in MKV). `universal` remains the only profile that burns by default. Any profile can opt in via `--sub-burn-forced`. - **Stereo downmix fallback removed from all profiles except `universal`** — `hdr10-hq`, `atv-directplay-hq`, `atv-directplay-animation`, `streaming-hevc`, `av1-hq`, `streaming-av1`, and `animation` no longer add a second AAC stereo track when the primary is multichannel (`ADD_STEREO_IF_MULTICH=0`). Each profile now keeps the single best-scored audio track. `archive` is unchanged (multi-track copy). `universal` produces one stereo track — the best native 2ch track when present (new `AUDIO_PREFER_STEREO=1`), otherwise a downmix of the best-scored track via `MAX_AUDIO_CHANNELS=2`. Toggle with `--prefer-stereo` / `--no-prefer-stereo`. `youtube-upload` no longer force-downmixes or forces AAC — the best-scored track (surround allowed) is kept as the cleanest source for YouTube's server-side re-encode. - **`--x265-params` test strengthened** — Previously only asserted encode success. Now captures the workdir log path from muxm output and greps for the custom param value (`aq-mode=4`) in the logged ffmpeg command, confirming the flag is forwarded to the encoder and not silently dropped. - **`--video-copy-if-compliant` test now verifies the copy path** — The existing test passed `--preset ultrafast`, which sets `_CLI_PRESET_EXPLICIT=1` and always forces a re-encode, making the copy path unreachable. A second pass now runs with isolated `HOME` and no explicit CRF or preset, then asserts the "will copy directly from source" message appears in muxm output. - **`--skip-video` assertion tightened** — Replaced a broad keyword grep (`skip|video|warn|error|cannot|invalid|disabled`) with `assert_exit 42` plus a check for the specific error message `"incompatible with producing a valid output file"`, matching muxm's documented exit code for this incompatible flag. ### Fixed - **Skip-if-ideal mislabeled a `.mov` output as a renamed Matroska/MP4 (2026-06-21 review: C1, headline correctness fix)** — `check_skip_if_ideal`'s container `case` had `mp4|m4v)` and `mkv)` arms but **no `mov)` arm and no default**, so with `--output-ext mov` a copy-compliant `.mkv`/`.mp4` source fell through with `ideal=1` and was hardlinked **verbatim** into a `.mov`-named output — shipping a Matroska/MP4 byte stream inside a `.mov` container while reporting *"Source already matches … skipping"*, exactly the strict-container mislabel that breaks the Apple-TV Direct-Play targets the tool exists to serve. Added a `mov)` arm (skip only a genuine `.mov` source) plus a defensive `*)` default that forces a remux for any future/unknown `OUTPUT_EXT` — a non-`.mov` source requested as `.mov` is now re-containerized to a real MOV. Covered by a new `--suite output` case (a compliant `.mkv`→`.mov` request must not skip and must yield `format_name=mov,…`, not `matroska,webm`) with perturb `MUT-SII-MOV`. Verified RED on the pre-fix binary and GREEN after. - **Collapsing `IFS=$'\t' read` corrupted audio metadata for untagged tracks (2026-06-21 review: C2, headline correctness fix)** — `_audio_stream_info` emits `codec⇥channels⇥language⇥bitrate⇥title`; because tab is IFS-whitespace, a plain `read` **collapses an empty middle field**, so an untagged-language track shifted its bitrate into the `language` slot. In multi-track audio (`run_audio_pipeline_multi` → `AUDIO_MT_LANGS` → `mux_final`) this stamped a bogus `language=` onto the output — silently wrong output on ordinary foreign-film/disc-rip remuxes — and also skewed the scorer and the disk estimate. Introduced a non-collapsing N-field splitter `_split_tab` (the `%%`/`#` technique, preserving empty middle fields), reimplemented `_parse_audio_record` on it, and routed **all** audio-record parse sites through it — the 10 sites the review enumerated plus two follow-ups (`_check_multitrack_container_safety`, where a lost commentary title wrongly `die 11`-blocked an MP4 encode; and the post-encode verify-summary, where an empty bitrate shifted the layout into the title) and the subtitle classifier `merge_subtitle_sources` (where an untagged forced subtitle was mis-stored and mis-classified `full`). Removed the now-dead `describe_sub_stream`. Covered by new `--suite audio`/`--suite subs` real-encode cases (untagged tracks stay untagged — never `language=`; commentary still detected; titles preserved) with perturb `MUT-C2-MTLANG`/`MUT-C2-CSAFETY`/`MUT-C2-VERIFY`/`MUT-C2-SUBCLASS`. Verified RED on the pre-fix binary and GREEN after. - **`universal` re-embedded a contradictory soft subtitle over burn + export (2026-06-21 review: H2)** — `build_subtitle_plan`'s direct-map fallback fired when the three *embed* variables were empty, reading that as "nothing prepared". On a burn+export profile (`universal`: `SUB_BURN_FORCED=1`, `SUB_EXPORT_EXTERNAL=1`) a forced sub routes to burn and a full sub to external export, leaving those vars empty even though subtitles **were** prepared — so the fallback re-embedded a soft `mov_text` track contradicting the burn/export. The guard now also requires `SRT_FORCED_BURN_PATH` empty **and** `EXTERNAL_SRT_PATHS` empty ("nothing *prepared*"). Bidirectionally covered by `--suite subs` (universal → no soft sub re-embedded, full sub still exported; an embed-style profile's legitimate direct-map fallback still fires) with perturb `MUT-H2-REEMBED`. - **`sort -z` broke external-subtitle discovery on stock-macOS BSD sort (2026-06-21 review: H3)** — `discover_external_subtitles` piped `find -print0` through GNU-only `LC_ALL=C sort -z`; BSD `sort` (older macOS) has no `-z`, and because the failure was inside a process substitution `set -e` never caught it — the substitution yielded empty output and **every** sidecar was silently dropped. Now probes `sort -z` support once (mirroring `_sed_inplace`'s GNU/BSD detection) and falls back to `cat` (find's single-dir order suffices), keeping null-delimiting intact on both branches; also added the missing `-type f`. Covered by a `--suite ext_subs` case that shims a BSD `sort` (rejecting `-z`) on `PATH` and asserts the sidecar is still discovered, with perturb `MUT-H3-SORTZ`. - **Medium robustness & config-correctness fixes (2026-06-21 review: M1–M8, M-DRY)** — **M1** `--` end-of-options now folds remaining positionals back (`muxm -- -dashname.mkv` works). **M2** `on_exit` disarms the `ERR` trap (`trap '' INT TERM ERR EXIT`) so a failing final `mv`/checksum no longer aborts cleanup and leaks the multi-GB workdir. **M3** every backgrounded DV/OCR/native-stereo child is now registered in `_ACTIVE_FFMPEG_PID` and cleared only *after* `wait`, so Ctrl-C can't orphan a child or hang the tee drain. **M4** `_dep_install_dependencies` guards `_ensure_ffmpeg_full` so the installer still prints its summary on failure. **M5** the `--create-config` sub-parser bounds-checks each value flag (`_cc_need_val`), so a trailing value flag exits with a clean `die 11` instead of an unbound-variable crash. **M6** the `REPLACE_SOURCE`-via-`.muxmrc` prompt treats `read` EOF (non-interactive stdin) as a decline instead of crashing under the ERR trap. **M7** the `AUDIO_SCORE_LANG_BONUS_ENG` deprecation bridge applies the legacy value only when the new var was not explicitly set (compared against the `_MUXM_PRE_CONFIG` snapshot), so setting both no longer lets the legacy alias clobber the new value. **M8** `_pipx_resolve_bin_dir` falls back to parsing `pipx environment` when `--value` is unavailable (pipx <1.0). **M-DRY** extracted `_dv_probe_has_config_record` (dvcC/dvvC probe ×4), `_refresh_mandb` (×2), and a `_VALID_LOGLEVEL_STR` constant (loglevel value-list ×4). Each functional fix carries a `--suite cli/unit/config` test and a perturb anchor (`MUT-M1-DASHDASH` … `MUT-MDRYC-DRIFT`). - **Low/Nit polish (2026-06-21 review)** — force-AAC honors `STEREO_BITRATE` instead of a hardcoded 256k; `_prepare_subtitle` returns `""`+rc0 on a vanished workdir (warn-and-skip, no `set -e` abort); the JSON-report "written" note is conditional on the write succeeding; the disk preflight emits an explicit "skipped: df unavailable" note instead of failing open silently; `--create-config` escapes emitted string values so an embedded `"` round-trips faithfully; a defensive `die` guards an empty external-subtitle map index; the completion installer writes atomically (temp + `mv`) with mode 644 (it is sourced, not executed); the dependency table notes pacman/zypper; `realpath_fallback` skips the doomed `realpath -m` on macOS; and several smaller portability/style touch-ups (`_man_target_dir` stderr suppression, `_audio_lang_matches` local array, the `SUB_BURN_FORCED`+multi-track demotion elevated to `warn`, and reworded `_probe_profile_ext`/`on_exit` comments). The correctness-leaning items carry `--suite audio/unit/config` tests and perturb anchors (`MUT-L-FORCEAAC`/`MUT-L-DISKNOTE`/`MUT-L-SUBWD`/`MUT-L-CCESCAPE`). _(The internal `is_valid_*`/`is_apple_silicon` predicate `_`-prefix rename was deliberately deferred by maintainer decision — pure convention churn across ~45 cross-coupled sites.)_ - **Supporting tooling & DV test-coverage backfill (2026-06-21 review)** — `tools/av1_compare.sh` and `tools/hw_compare.sh` now honor `${TMPDIR:-/tmp}` for VMAF temp files, and `av1_compare.sh` guards its `realpath` call with a portable `cd … && pwd` fallback for stock macOS. Added a real **P7→P8.1 Dolby Vision convert-success** encode test (`--suite dv_sw`, gated on `dovi_tool`+`MP4Box`+`libx265`): the prior suite only mocked `dovi_tool convert` to **fail**, so the success path was never exercised — the new test fabricates a Profile-7 source and asserts muxm runs the real conversion (success marker, not the fallback) and emits a DV-bearing output, with perturb `MUT-DVSW-CONVERT`. Also repaired three pre-existing drifted perturb anchors (`MUT-NVENC-1`/`MUT-REP-1`/`MUT-VCC-1`) so the full mutation gate is green again. - **Skip-if-ideal shipped a non-compliant file verbatim under a Direct-Play profile (C1, release-blocker)** — On the skip-if-ideal fast path, `_video_is_copy_compliant`'s Dolby-Vision-profile gate and 10-bit pixel-format gate read `IS_DV` / `DV_SRC_PROFILE` / `TARGET_PIXFMT`, but `check_skip_if_ideal` runs *before* `detect_dv` / `detect_dv_info` / `decide_color_and_pixfmt`, so those globals still held their defaults (`IS_DV=0`, `DV_SRC_PROFILE=""`, `TARGET_PIXFMT="yuv420p"`) and both gates were dead code on that path. A mainstream `muxm --profile atv-directplay-hq .mkv` therefore hardlinked/copied the **P7 dual-layer** source verbatim to the output under a profile that contractually produces **single-layer P8.1** — with `OUTPUT_HAS_DV` never set and **no warning**; an 8-bit source under a 10-bit profile was the companion case (shipped 8-bit). Both gates are now self-sufficient, mirroring the adjacent tone-map arm that was already fixed for this exact problem: a new `_output_pixfmt_is_10bit` derives the intended output bit depth from the profile/SDR config (`HDR_TARGET_PIXFMT`, `SDR_FORCE_10BIT`, `SDR_USE_10BIT_IF_SRC_10BIT`) plus freshly-probed source color metadata, and a new read-only `_source_dv_profile` reads the source's DV profile from the metadata cache. The DV gate is **global-first** — it trusts the already-detected `IS_DV` / `DV_SRC_PROFILE` on the normal pipeline path (preserving frame-probe / text-fallback detection) and falls back to the direct cache probe only on the stale-globals skip path. Covered by new `--suite dv_sw` end-to-end cases (a fabricated DV-Profile-7 source and an 8-bit HDR-tagged source must **not** stream-copy under `atv-directplay-hq`, citing the specific reject reason; a genuinely-compliant Profile-8 10-bit source **still** skips — no over-correction) plus `--suite unit` coverage of the two helpers and the rewritten gates. Verified to fail on the pre-fix binary (both companion cases reported "already matches … skipping") and pass after. - **Embedded stream language tags were never normalized 2→3 letter, so the wrong audio/subtitle could be selected (H2)** — `_audio_lang_matches` compared the raw lowercased stream tag with each `AUDIO_LANG_PREF` entry by exact equality, and the only 2→3-letter mapper (`_norm_lang_code`) was applied **solely** to external sidecar filenames. Embedded tracks tagged with 2-letter codes (`en`, `ja`, `fr` — common from many remuxers) therefore never matched the 3-letter defaults: an `en`-tagged English audio track lost its `AUDIO_SCORE_LANG_BONUS` (+150) and could be outscored by a higher-channel foreign track and become the primary, and an embedded `en` subtitle was dropped by the `,$SUB_LANG_PREF,` filters — while an external `movie.en.srt` (parsed to `eng`) was kept, an embedded-vs-external asymmetry. Both `_audio_lang_matches` and a new symmetric `_sub_lang_matches` now run **both** the stream tag and each pref entry through `_norm_lang_code` before comparing, and all four subtitle language-filter sites (`_pick_direct_text_sub_relidx`, `_build_subtitle_keep_list`, `build_subtitle_plan`, the multi-track container-safety preflight) route through `_sub_lang_matches`. `_norm_lang_code` was extended with the ISO 639-2/B→/T bibliographic variants (`fre→fra`, `ger→deu`, `dut→nld`, `gre→ell`, `chi→zho`, `cze→ces`, `slo→slk`, `rum→ron`) for the languages it already covered, so `en`↔`eng`, `ja`↔`jpn`, and `fre`↔`fra` all match symmetrically. Covered by `--suite unit` (matcher matrix including the primary-selection flip: an `en`-tagged track now wins the language bonus over a 6-channel foreign track) and `--suite subs` (an embedded `en` subtitle survives the `eng` filter). Verified RED on the pre-fix binary and GREEN after. - **Multi-track audio whose language filter matched nothing silently produced a file with no audio (H3)** — `_build_audio_keep_list` drops every track whose language isn't in `AUDIO_LANG_PREF`; when that emptied the keep-list, `run_audio_pipeline_multi` set `HAS_AUDIO=0` and the run continued to a **video-only** output with only a `note`. Single-track selection was already protected (it defaults to track 0 when nothing matches); multi-track was not. `run_audio_pipeline_multi` now detects an empty keep-list while the source still has audio streams and falls back to the best-scored track (`select_best_audio`), emitting a loud `warn` — mirroring the single-track "default to track 0" guard, so a file is never silently shipped without audio. Covered by a new `--suite audio` case (a multi-track source tagged `jpn`/`spa` under `--audio-lang-pref eng` → output keeps ≥1 audio track **and** a warning is emitted). Verified RED on the pre-fix binary (0 audio tracks, no warning) and GREEN after. - **SDH/forced subtitle title heuristics over-matched, risking accidental burn-in (M2)** — `_is_forced_title` (`forced|signs|foreign`) and the non-`sdh`/`hi` arms of `_is_sdh_title` (`hearing|impaired|cc|caption`) were bare substring alternatives, so "Designs"/"Foreigner"/"Cosigns" were tagged **forced** — and under `SUB_BURN_FORCED` a "forced" subtitle is permanently burned into the video — while "Soccer"/"vaccine"/"Account"/"Succession" (all containing `cc`) were tagged **SDH**. The `_is_sdh_title` doc-comment also falsely claimed it matched "cc, caption as whole words." Every alternative in both predicates is now word-anchored (`(^|[^a-z])(…)([^a-z]|$)`), and the comment is corrected. Covered by new `--suite unit` false-positive guards (the words above no longer match; genuine "Forced"/"SDH"/"CC"/"Hearing Impaired" titles still do). Verified RED on the pre-fix binary and GREEN after. - **No de-duplication between embedded and external subtitles (M3)** — `merge_subtitle_sources` concatenated all embedded streams then all external sidecars with no overlap check, despite its docstring claiming a "unified" list, so in multi-track subtitle mode an embedded `eng` subtitle and a sidecar `movie.en.srt` (same language/type) both survived `_build_subtitle_keep_list` into **two duplicate tracks**. When appending an external entry, `merge_subtitle_sources` now skips it if an embedded track already provides the same **normalized language + type** (comparing via `_norm_lang_code`, so `en`↔`eng` dedups), and reports how many duplicate sidecars were skipped. Covered by a new `--suite ext_subs` case (embedded `eng` + sidecar `.en.srt` → exactly **one** English subtitle track). Verified RED on the pre-fix binary (2 tracks) and GREEN after (1). - **`_audio_descriptive_title` titled DTS tracks "(dca)" (L6)** — ffprobe reports DTS as the codec name `dca`, which the codebase handles as DTS elsewhere, but `_audio_descriptive_title` had only a `dts)` case, so a `dca` track's descriptive title read "(dca)" instead of "DTS". Added `dts|dca)`. Cosmetic (title metadata only); covered by a new `--suite unit` assertion. - **The `--report-json` output could be made invalid JSON by hostile path/metadata (M1)** — `write_json_report` emitted its `tool`/`profile`/`source`/`output` fields with a bare `printf '%s'`, so a double-quote in a source path broke the `"source"` key, and `report_add`'s escaper handled only backslash/quote/`\n`/`\r`/`\t` — any other ASCII control byte (e.g. a BEL `0x07` in a track title) passed through raw, which is invalid in a JSON string per RFC-8259 and makes the whole document unparseable. A new shared `_json_escape` helper now does the full escape — backslash first, then the double-quote, the short forms `\n`/`\r`/`\t`, then **every remaining byte `< 0x20` as `\uXXXX`** — and both `report_add` (key and value) and the four `write_json_report` string fields route through it. Covered by `--suite output` (a source path containing `"`, run with `--report-json`, must produce a `jq`-parseable report) and an extended `--suite unit` 2.5 case (a control byte round-trips through `jq`). Verified RED on the pre-fix binary (`jq` rejected the report) and GREEN after. - **Concurrent runs targeting the same output clobbered each other (M4)** — `WORKDIR`/`TMP_OUT` are `mktemp`-based and collision-safe, but the durable paths are deterministic from `$OUT`: `on_exit` does `mv -f "$TMP_OUT" "$OUT"`, and the report/log/debug-bundle paths are all `${OUT%.ext}.*`. Two `muxm same.mkv` runs in the same directory both renamed onto `same.mp4` (a reader could observe a torn file mid-rename) and overwrote each other's `same.report.json` / `same.muxm.log` / `same.muxm-debug/`. muxm now takes an advisory lock on `$OUT` before the heavy work — an atomic `mkdir "${OUT}.lock"`, chosen for portability since `flock(1)` is util-linux and absent on stock macOS — and **refuses** a second run whose lock is held by a still-live owner (exit 11, with a message telling the user how to clear a stale lock). A pid file inside the lock dir identifies the owner so a lock **orphaned by a SIGKILLed run** (where `on_exit` can't run) is detected as stale and reclaimed, mirroring `run_parallel.sh`'s liveness sweep — so the lock can never permanently brick an output path. The lock is acquired only after the EXIT trap is armed (guaranteeing release) and the print-config / multi-profile early exits are past, is released in `on_exit` gated on a `_OUT_LOCK_HELD` flag (so a refused run or the multi-profile parent never removes someone else's lock), and is skipped entirely under `--dry-run` (which writes nothing). Covered by `--suite collision` (a live lock refuses with exit 11; a stale dead-owner lock is reclaimed, the run proceeds, **and** releases its own lock on success). Verified RED on the pre-fix binary (the second run clobbered the output, exit 0) and GREEN after. (Portable by construction — `mkdir`-as-lock works on macOS and Linux; exercised on macOS, with Linux coverage to land via CI.) - **Software video encoders weren't preflighted like the AV1/hardware encoders (M5)** — the video preflight in `main` failed fast with `die 10` and an install hint when a requested AV1 (`libsvtav1`/`libaom-av1`) or hardware encoder was missing, but there was **no equivalent check for the default `libx265`/`libx264`**: on a stripped ffmpeg build lacking libx265, the run proceeded all the way to the encode and failed mid-stream at `die 40`. A new `_preflight_sw_video_encoder` (called alongside the existing `ffmpeg_has_encoder` checks) verifies the software x26x encoder when the run will actually use it — `VIDEO_CODEC` is `libx265`/`libx264` and no hardware backend resolved (`HW_ACCEL_RESOLVED=none`; the HW path uses `hevc_videotoolbox`/`hevc_nvenc` instead) — and `die 10`s with the same install-hint style. Covered by a new `--suite unit` test exercising the helper across codec × availability × hardware-backend. - **`--level` and the rate flags weren't validated, so bad values failed mid-encode (L3)** — `-l`/`--level`, `--av1-maxrate`, `--av1-bufsize`, and `--stereo-bitrate` were stored without a format check, and unlike `CRF_VALUE` (re-validated after config load) the resolved values were never re-checked — so a typo failed deep in the encode at `die 40` instead of at parse. Framing is validation-consistency, not security (the user owns their own command line). Each flag is now validated at parse time to its real format — `--level` as a numeric level (`5.1`), the three rate flags as ffmpeg bitrate strings (`20000k`, `5M`, or a bare integer) — via `_validate_level_arg`/`_validate_rate_arg` (`die 11`), and `LEVEL_VALUE`/`AV1_MAXRATE`/`AV1_BUFSIZE`/`STEREO_BITRATE` are re-validated after config load (mirroring the `CRF_VALUE` re-check), since a sourced `.muxmrc` assigns the globals directly and bypasses the CLI guard. Covered by `--suite cli` (garbage rejected at parse, valid `5.1`/`5000k`/`256k` accepted, and a `.muxmrc`-set garbage value rejected after load — asserting the **specific** validation message, since a missing source also exits 11). Verified RED on the pre-fix binary and GREEN after. - **Discovered external-subtitle paths skipped the control-character check (L2)** — `discover_external_subtitles` added every `find`-matched sidecar without the `[[ … =~ [[:cntrl:]] ]]` reject that `SRC_ABS`/`OUT` get, so a newline/tab-bearing sidecar name could corrupt a downstream log or report line. The loop now rejects such paths — warning (with the control bytes rendered as `?` so the warning itself stays clean) and skipping, never muxing them. Covered by a new `--suite ext_subs` case (a sidecar with a BEL `0x07` in its name is skipped with a warning, 0 subtitle tracks muxed). Verified RED on the pre-fix binary (the sidecar was muxed, no warning) and GREEN after. (The review also suggested a `--` end-of-options terminator before the OCR tool's positional path; that was intentionally **not** added — the discovered paths are absolute so dash-injection is moot, and a bare `--` breaks OCR tools, such as the tesseract family, that don't accept it.) - **Exit codes 44 and 45 were emitted but undocumented (L4)** — the man page EXIT STATUS section listed every code through 43 plus 130/143 but omitted **44** (Dolby Vision RPU profile-conversion or RPU/frame desync failure with fallback disabled) and **45** (frame-rate integrity check failure), both of which the code already `die`s with. Added both to the `MANPAGE_EOF` heredoc and regenerated `docs/muxm.1` via `tools/gen-docs.sh` (the man page is a generated artifact — not hand-edited). Enforced by the `--suite docs` parity guard. - **The audio copy/transcode failure path omitted the disk-full hint (L5)** — the video (`die 40`) and final-mux (`die 41`) failure paths run `_check_disk_full` on the ffmpeg `.err` log to surface an `ENOSPC` as the likely cause, but the audio path's two `.err`-bearing `die 43` sites in `run_audio_pipeline` did not. Added `_check_disk_full "$WORKDIR/audio_primary.err"` before each, matching the other two paths. Covered by a `--suite audio` source-parity assertion (every `.err`-bearing `die 43` is preceded by the check). - **Corrected the `TMP_OUT` TOCTOU comment, which overstated the temp name's safety (L1)** — the comment called the `mktemp`-then-`rm`-then-reuse name "unpredictable… not guessable," implying the randomness was the protection. In reality an attacker with write access to `OUT_DIR` doesn't guess — they read the name from a directory listing and, in the gap between the `rm` and ffmpeg's open, plant a symlink that ffmpeg's `-y` writes through; the real precondition is a shared/world-writable `OUT_DIR` (honest severity Low), and the same observe-then-symlink applies to `write_checksum`'s deterministic `${file}.${algo}` target. Reworded to state that precondition plainly. Comment-only (the optional "write into the `mktemp`-created file" hardening drags in extension/`-f` format-inference handling for a Low finding and was left out). - **Reconciled the documentation with the implementation (man page, completions, README)** — A documentation audit found the man page and README had drifted from the script. The man page now documents the previously-undocumented `--checksum-algo`, `--sub-preserve-bitmap` / `--no-sub-preserve-bitmap`, the hardware-acceleration flags (`--hw-accel`, `--hw-accel-quality`, `--hw-accel-allow-sw` / `--no-hw-accel-allow-sw`), and `--workdir`; the phantom `--skip-video` entry (the flag had already been removed from the code) is gone; and stale text was corrected — the `archive` profile is described as "always MKV" rather than container-passthrough, the `av1-hq` example shows the resolution-aware CRF 28/24 instead of a flat CRF 20, and the multi-track container note says a kept-but-unmuxable stream is a "hard error" rather than "silently skipped". The committed completion regained the missing `--workdir`. In the README, the deprecated `streaming` alias was replaced with `streaming-hevc` in the live examples, macOS VideoToolbox acceleration is surfaced as a feature (not just a Compatibility footnote), Additional-Features implementation minutia was trimmed to one-line benefits, the table of contents was reordered to match document order, and `docs/config_profile.md` is now linked. The man page's `.TH` revision date was bumped to 2026-06-16. These corrections are enforced going forward by the new generated-artifact parity suite and the flag-drift guards (see the matching Added entry). - **The tee-logger drain watchdog fired on every run, adding ~5 s of dead wait per invocation** — muxm mirrors stdout/stderr to its log file via `exec > >(tee …)`. At exit, `_drain_tee` waits for tee to flush before returning, but the shell's *unnamed* write-end to the process substitution lingered until the shell exited, so tee never saw EOF and `wait "$TEE_PID"` blocked until the 5-second kill-backstop (added earlier this cycle as a hang-forever guard) force-killed it. The backstop was meant to fire only if some untracked child still held the pipe; in practice it fired on **every** run — real encodes and `--dry-run` alike — because the process-substitution write-end itself was never closed (`--print-effective-config` / `--create-config` / `--help` were unaffected: they exit before the logger is set up). The fix holds the tee on an explicit descriptor (**fd 4**) that `_drain_tee` closes before waiting, so tee gets a clean EOF and `wait` returns at once. The backstop is kept purely as insurance but is now latency-free: it is `kill -KILL`'d the instant the wait returns — a plain `kill` was ignored because the forked subshell inherits `on_exit`'s `trap '' TERM` — and it redirects its own FDs to `/dev/null` and closes 3/4 so neither it nor an orphaned `sleep` can hold the pipe (or the caller's captured stdout) open. A fixed fd number (not the bash-4.1 `{var}>` form) preserves bash 3.2 portability. Net effect: every file-bearing invocation drops ~5 s, real users no longer hit a ~5 s pause at the end of each run, and the full test suite falls from ~1295 s to ~238 s (5.45×) with an identical 1283/0/4 verdict and complete (un-truncated) log files. Tee's terminal-display path is unchanged — verified rendering exactly once under a PTY. - **Disk preflight under-estimated AV1 output size (could fail to warn before running out of space)** — The `_crf_ratio` AV1 entries (`libsvt-av1`/`libaom-av1`) were 28–80% too low versus the measured table in `docs/AV1_CALIBRATION.md §6`, so `disk_free_warn` predicted an AV1 encode would be much smaller than it actually is and could skip the low-space warning. Corrected the AV1 ratio table to the calibrated values (CRF 20→320, 22→260, 24→211, 26→189, 28→178, 30→166, 32→153, 34→130, 36→108, 38→88, 40→70), and raised the out-of-range clamps from 320/28 to 360/55 so they stay monotonic with the in-range values. Data-only change to the lookup table — the bitrate × duration × ratio × preset estimation logic, `peak_factor`, and the 25% safety margin are unchanged. New `--suite unit` assertions cover the AV1 ratios and both clamps; `docs/AV1_CALIBRATION.md §6` updated to mark the values as applied. - **`youtube-upload` audio was documented as "AAC stereo" but actually keeps the best-scored source track (surround allowed)** — The profile passes the cleanest source audio through to YouTube's server-side re-encoder rather than forcing an AAC stereo downmix (the man page already described this correctly). The stale claim in the `--help` profile summary (`AAC stereo, soft forced subs`) and the audio column of the `docs/config_profile.md` profile table (`best-scored track`) are corrected to `best-scored source track (surround allowed)`. Documentation-only; no code change (code behavior was already correct). - **Documentation said VideoToolbox could not do Dolby Vision — the code has supported it all along** — `docs/HW_ACCEL.md` listed VT · DV as `❌ (DV RPU requires libx265)` and described a `resolve_video_encoder` "Source is Dolby Vision → software" gate that does not exist in the code; `docs/VIDEOTOOLBOX_CALIBRATION.md` said VT + DV was "tracked separately" / untested. In reality `resolve_video_encoder` has no DV gate and the raw-ES path is enabled for `hevc_videotoolbox` just like `libx265`: VT encodes an HDR10 base and the DV RPU is injected post-compression by `dovi_tool` (encoder-agnostic), then muxed with MP4Box. Corrected the backend-support table (now `✅ RPU injected post-compression via dovi_tool`), removed the phantom DV gate from the resolver gate list (renumbering the remaining gates to match the code), and updated the calibration doc to state VT + DV is supported and now covered by the gated `dv_vt` regression test (only DV *quality* over VT remains uncharacterised). No code change — docs and a new test brought in line with existing behavior. - **`streaming-av1` re-encoded Opus audio it should have stream-copied (and mislabeled the title)** — The "source already matches the target codec" checks compared the source codec (ffprobe name, e.g. `opus`) against `AUDIO_FORCE_CODEC`, which holds an *encoder* name (`libopus`). The two never matched, so an Opus source under `streaming-av1` was needlessly transcoded, and the descriptive audio title read `(libopus)` instead of `(Opus)`. A new `_encoder_to_codec` helper normalizes encoder→codec names (`libopus`→`opus`, `libfdk_aac`→`aac`, …) at both comparison sites (the audio copy decision and the skip-if-ideal compliance check) and where the title's codec is recorded. Guarded by a new `--suite audio` test (Opus source + `streaming-av1` → `-c:a copy`, title `… (Opus)`), verified to fail on the pre-fix binary and pass after. - **`--create-config` could emit a stale config default that had drifted from the real one** — 14 variables (the `AUDIO_SCORE_*` weights, `TONEMAP_FILTER`, `DISK_FREE_WARN_GB`, `TAG_LANGUAGE_DEFAULT`, `KEEP_TEMP*`, `REPLACE_SOURCE`, `FORCE_REPLACE_SOURCE`, `FFMPEG_HIDE_BANNER`, `FFMPEG_OVERWRITE`, `DRY_RUN`) were emitted in the config template as hardcoded literals (`printf '#DISK_FREE_WARN_GB=5'`) that silently drifted if the Section-4 default changed. They are now in `CONFIG_TRACKED_VARS` and emitted via `_V`, so the generated config always shows the live default (`TONEMAP_FILTER` round-trips with `_V … quoted`). Also corrected the `CONFIG_TRACKED_VARS` comment (it referenced a nonexistent `_snap_vars` and overstated auto-sync — the `_V` calls are hand-maintained). Guarded by a `--suite config` test that changes a default in a copy of the script and asserts the generated config follows. - **`--uninstall-man` left a dangling man-page symlink behind** — The "is it installed?" check used `[[ ! -f "$target" ]]`, which is *false* for a broken symlink (its target is gone), so the uninstaller reported "nothing to remove" and orphaned the link. Now uses `[[ ! -e "$target" && ! -L "$target" ]]` (catches the dangling link) and refreshes the man database after removal, mirroring the installer. Guarded by a `--suite setup` test (plant a dangling `muxm.1` symlink → it gets removed). - **`--` end-of-options marker was ignored by the action pre-scans** — `muxm -- --install-man file` ran the man-page installer instead of treating `--install-man` as a positional, because the `--install-dependencies` / `--install-man` / `--install-completions` / `--setup` / `--create-config` pre-scans walked all of `"$@"` without stopping at `--`. Each now breaks at `--`. (The multi-profile pre-scan is intentionally left as-is to preserve the M3 child-argument fix.) Guarded by a `--suite cli` test. - **`archive` warned about an ignored CRF set via `.muxmrc`, and stayed silent on an explicit `--crf 18`** — The copy-only-CRF conflict warning gated on `_PROFILE_SOURCE == cli && CRF_VALUE != 18` instead of "was `--crf` actually typed?". It now gates on `_CLI_CRF_EXPLICIT`, so a CRF set in `.muxmrc` (no `--crf`) no longer warns, and an explicit `--crf 18` does. Guarded by `--suite conflicts`. - **Spurious `❌ ERROR: Command failed at line …: kill "$TEE_PID"` on teardown** — The `on_exit` tee-drain backstop `( sleep 5; kill "$TEE_PID" ) &` runs under `set -E`, so when the watchdog's `kill` failed (tee already gone) it fired `on_error` from inside the subshell — most visibly two stray "Command failed" lines per multi-profile `--dry-run`. The watchdog now `trap '' ERR`s and `|| true`s the kill. Guarded by a `--suite multi_profile` assertion (multi-profile `--dry-run` emits no such message). - **Corrected a stale ERR-trap comment** (`ffmpeg_progress_bar`) that claimed restoring the ERR trap before `wait "$pid"` lets real ffmpeg failures fire it — impossible, since the function always runs under an `if ! … ` chain (errexit/ERR disabled); failures surface via the return status. Comment-only. - **Multi-track copy into MP4/MOV could silently drop or degrade a kept stream, or waste a full encode before failing at the mux** — In multi-track mode (`atv-directplay-animation` uses passthrough, so an MP4/MOV *source* yields MP4/MOV output; `animation` or any profile can also reach a non-MKV container via `--output-ext`; `archive` now always uses MKV and is out of scope) every kept audio/subtitle track is stream-copied verbatim. MP4/MOV can't carry lossless HD audio (TrueHD, DTS-HD MA), bitmap subtitles (PGS, VobSub), or styled ASS/SSA (which flattens to plain `mov_text`). Previously bitmap subs were silently dropped, ASS silently flattened, and lossless audio reached `mux_final` only to abort after a long encode. A new pre-encode check (`_check_multitrack_container_safety`, run before `run_video_pipeline`) now hard-stops in seconds with a clear "use `--output-ext mkv`" message listing exactly which kept stream(s) can't be preserved — considering only the tracks that pass the same language/type/commentary keep filters, so it never blocks a stream you weren't keeping. On the proceed path, the multi-track subtitle mux arm now converts plain-text subs (subrip/webvtt) to `mov_text` for MP4/MOV instead of an invalid `-c copy` (which failed with "Subtitle codec … not supported"). MKV output is unaffected — it carries everything losslessly. **Behaviour change:** the previously-documented "bitmap subs silently skipped in MP4" is now a hard stop (README updated). Guarded by new `--suite containers` tests (TrueHD/ASS into MP4 → hard stop before any encode; subrip into MP4 → converted to `mov_text`, mux succeeds; MKV → all streams copied), verified to fail on the pre-fix binary and pass after. - **A value-flag given as the final argument crashed with `$2: unbound variable` instead of a clean error** — `_require_val` only rejected a value that *looked* like a flag (`-…`); a genuinely absent value slipped through and the call site then read a bare `$2`, tripping `set -u` (e.g. `muxm --threads` with nothing after it). `_require_val` now also receives the remaining argument count (`$#`) and dies with exit 11 "… requires a value" when no value follows, across all ~27 value-flag call sites. Guarded by a new `--suite cli` test (a representative set of value-flags as the final token → exit 11 with "requires a value", and the string "unbound variable" never appears), verified to fail on the pre-fix binary and pass after. - **Multi-profile (`--profile a,b`) mis-built each child's arguments when the source wasn't the last token** — The per-profile child command was assembled by slicing off the *last* `${#POSITIONALS[@]}` arguments, assuming positionals always trail. `muxm --crf 20 --profile a,b` (source first) therefore stripped a flag *value* instead of the source path, so each child saw an extra positional and died with "Too many arguments". Child flags are now built by removing the known positional *values* from the original argument list (the source and explicit output are re-appended after the injected `--profile`/`--output-ext`), so both flags-before-source and source-before-flags orders work. Guarded by a new `--suite multi_profile` test (positional-before-flags dry-run runs both children, no "Too many arguments"), verified to fail on the pre-fix binary and pass after. - **Forced-subtitle burn-in failed when the output path contained an apostrophe or backslash** — The burn filter embedded the absolute staged-subtitle path (which lives under the output directory) into the ffmpeg filtergraph as `subtitles=filename='…/burn.srt'`. An apostrophe in the output directory name (e.g. `It's A Test/`) closed the single quote and ffmpeg aborted with "Unable to open …". The encode now references the staged file by its bare relative name (`burn.srt`) and runs ffmpeg from inside `WORKDIR` — `burn.srt` contains no filtergraph metacharacters, so no user path needs escaping, and the TOCTOU-safe staging (an unpredictable `mktemp -d` workdir) is preserved. The cwd change is threaded through `_ffmpeg_run_with_ui` via a subshell that `exec`s ffmpeg, so `_ACTIVE_FFMPEG_PID` still tracks the real ffmpeg process for SIGINT handling. Guarded by a new `--suite subs` test (a forced-burn encode into an `It's A Test/` directory completes), verified to fail on the pre-fix binary and pass after. - **An unguarded `brew install pipx` could abort `--install-dependencies` mid-run** — When bootstrapping `pipx` (needed for the `pgsrip` subtitle OCR tool), a failing `brew install pipx` was unguarded; under `set -eEuo pipefail` the ERR trap fired and killed the whole dependency installer. It is now wrapped in the same `if … then … else` guard used by the sibling installs (`_brew_ensure`, `pipx install`): on failure it reports "could not install pipx — skipping" and continues with the remaining tools. - **`--create-config` leaked the user's `.muxmrc` into the generated config** — `_create_config_emit` applies the chosen profile on top of the *live* configuration variables, which at that point already carry whatever the system/user/project `.muxmrc` layers set when muxm started up. The "which lines to uncomment" diff (live value vs. the `_MUXM_PRE_CONFIG` snapshot of script defaults) therefore flagged *any* variable differing from its built-in default — including one the profile never touches but that the user's `.muxmrc` happened to change — and emitted it uncommented, as if the profile owned it. Example: a user `~/.muxmrc` containing `MAX_AUDIO_CHANNELS=4` made `muxm --create-config project streaming-hevc` write `MAX_AUDIO_CHANNELS=4` uncommented (streaming-hevc never sets it), instead of the intended commented default `#MAX_AUDIO_CHANNELS=8`. `_create_config_emit` now resets every `CONFIG_TRACKED_VARS` entry to its `_MUXM_PRE_CONFIG` default *before* applying the profile, so the diff captures only the profile's own changes, independent of the sourced layers (`--create-config` exits right after emit, so the in-place reset is safe). Guarded by a new `--suite config` regression test (a profile-untouched var set in the user `.muxmrc` stays commented at its script default; the profile-owned `EAC3_BITRATE_5_1=448k` stays uncommented), verified to fail on the pre-fix binary and pass after. - **Dolby Vision frame-count-mismatch fallback mislabeled the output as Dolby Vision** — After RPU injection, muxm compares the RPU frame count against the encoded video's. On a mismatch (>2 frames) with `ALLOW_DV_FALLBACK=1` it falls back to plain base video — setting `V_MIXED=V_BASE` and `OUTPUT_HAS_DV=0` — but, unlike its three sibling give-up branches (RPU-validation / inject / convert failure), this branch was missing the `return 0`. Execution fell through into the dvcC/`dvh1` pre-wrap and the closing `OUTPUT_HAS_DV=1`, so the base (non-DV) video was tagged `dvh1` and the run reported `DV: present` — shipping a file advertised as Dolby Vision whose RPU never matched the picture. Added the `return 0` so the fallback gives up cleanly like its siblings: no pre-wrap, `OUTPUT_HAS_DV=0`, plain HEVC output (MP4 codec tag `hev1`, not `dvh1`). Guarded by a new `--suite regression_p5` test (H2) that induces a frame-count mismatch via a cp-passthrough `dovi_tool` reporting a bogus RPU count and asserts the MP4 output is not DV-tagged and no DV pre-wrap ran, verified to fail on the pre-fix binary and pass after. *(The follow-up noted here originally — DV re-encode + MKV output + give-up fallback aborting the mux — is now fixed in the next entry.)* - **Dolby Vision give-up fallback aborted the final mux for re-encoded MKV output (`Can't write packet with unknown timestamp`)** — When DV processing gives up and falls back to base video (RPU extraction/validation/injection failure, empty injection, P5/P7→P8 convert failure, or the H2 frame-count mismatch), it aliases `V_MIXED` to the base encode and `return`s before the dvcC/timestamp pre-wrap. For a re-encoded DV source the base is a raw HEVC Annex B elementary stream (`*.hevc`) with no container timestamps; MP4/MOV output muxes it fine (ffmpeg reconstructs pts/dts from HEVC VUI/SPS and mux_final stamps `-r SRC_FPS`), but the Matroska muxer rejects timestamp-less packets, so MKV output aborted the whole encode with exit 234 and no file. (This is the same failure class the success-path pre-wrap already fixed for live-DV MKV; the give-up branches skip that pre-wrap by design, to avoid re-adding DV signalling.) A new `_dv_fallback_timestamp_wrap` helper, called on all six give-up branches, wraps the raw base ES into a timestamped MP4 — HEVC stream-copied losslessly with an `hvc1` tag (which Matroska accepts; the rejected `dvh1` tag is live-DV-only) and **no Dolby Vision signalling** (`OUTPUT_HAS_DV` stays 0) — and repoints `V_MIXED` at it. It is a precise no-op for MP4/MOV output, non-raw-ES (`.mkv`) intermediates, and dry runs, and fails soft (warns and leaves the raw ES for mux_final) on any wrap error. Consistent with the pipeline's mid-run disk hygiene, the now-superseded raw base ES is `_reclaim`ed once `V_MIXED` is repointed at the wrapped MP4 (so peak storage isn't inflated by keeping both). Relatedly, the frame-count-mismatch give-up branch now also reclaims the fully-injected DV video it abandons — orphaned when `V_MIXED` is aliased to the base encode (a full-size intermediate on a real title), for **both** MP4 and MKV output. Both reclaims run even under `-k`/`-K`, which retain logs but not multi-GB video intermediates. Guarded by a new `--suite regression_p5` test (DVMKVFB) that drives a DV re-encode to MKV through an induced give-up and asserts a valid, non-DV Matroska file is produced and the wrap ran, verified to fail on the pre-fix binary (exit 234) and pass after. - **Dolby Vision encodes silently dropped the source frame rate (e.g. 23.976 → 25 fps), causing progressive audio desync** — On the DV path the video is encoded to a raw HEVC Annex B elementary stream, which carries no container-level timing. When that stream was wrapped into MP4 (mp4box) or remuxed (ffmpeg) with no frame rate specified, mp4box defaulted headerless ES to **25 fps** and ffmpeg's raw-hevc demuxer to 25/50 fps. The mismatch was most visible with `--hw-accel auto` (VideoToolbox), whose encoder omits VUI timing that mp4box would otherwise read. Because the audio is stream-copied at its original timing, the video then ran ~4.3% fast and drifted out of sync over the runtime. The source frame rate is now captured once (`init_src_fps` → `SRC_FPS`, kept as the exact `num/den` rational for zero rounding drift) and passed explicitly at every raw-ES→container boundary: both mp4box pre-wrap paths get `-fps "$SRC_FPS"`, and both ffmpeg fallbacks plus the raw-ES final-mux get an input-side `-r "$SRC_FPS"`. A post-mux frame-rate integrity guard (1% relative tolerance) now hard-fails (`die 45`) if output fps ever diverges from the source, so a future regression can't silently ship a desynced file. Covered by `_fps_to_decimal` unit tests (`--suite unit`) and an end-to-end 24000/1001 preservation + stream-sync test (`--suite video`); verified on a real Profile 7 source where the output is now 24000/1001 with aligned video/audio end-times. - **Dolby Vision + MKV output failed at the final mux with `Can't write packet with unknown timestamp`** — DV sources are re-encoded to a raw HEVC Annex B elementary stream (dovi_tool needs Annex B), but a raw ES carries no container timestamps and the Matroska muxer rejects timestamp-less packets. The timestamp-providing pre-wrap was gated to MP4/MOV output only, so MKV output fed the raw ES straight to the final mux and aborted the whole encode (the error surfaced on a subtitle stream, but the timestamp-less video was the cause; it reproduces with video+audio alone). The pre-wrap now also runs for MKV output, and `mux_final` overrides the pre-wrapped MP4's `dvh1` fourcc — which Matroska rejects (`Tag dvh1 incompatible with output codec id`) — with `hvc1`. DV is preserved: the RPU is carried in-band by `-c copy` always, and the dvcC record is carried into the Matroska `BlockAdditionMapping` when mp4box produced it. Guarded by the `DVMKV` regression test (`--suite regression_p5`), verified to fail on the pre-fix binary and pass after. - **`ffmpeg_has_muxer()` always returned false (broke the PGS→VobSub fast-path)** — The capability probe matched `awk '$1==m'`, but in `ffmpeg -muxers` output `$1` is the `D./.E` capability-flag column and the muxer name is `$2`, so the comparison never matched any muxer. Live impact: the PGS subtitle → VobSub conversion fast-path (gated by `if ffmpeg_has_muxer vobsub`) was never taken even on ffmpeg builds that have the `vobsub` muxer, and a spurious `ffmpeg lacks the 'vobsub' muxer` warning could fire on capable builds. Fixed to `awk '$2==m'`. Guarded by a new `ffmpeg_has_muxer` unit test (matroska/mp4/mov → present, bogus → absent). - **Native stereo fallback silently dropped for non-AAC source tracks** — When `ADD_STEREO_IF_MULTICH=1`, the primary track was surround, and a qualifying native stereo track was stream-copied (any codec into MKV; AAC/AC3/EAC3 into MP4/M4V/MOV), the copy targeted the hardcoded `audio_stereo.aac` intermediate. ffmpeg selects the muxer from the file extension, and the `.aac` (ADTS) muxer accepts only AAC — copying a FLAC, AC3, or EAC3 stereo track failed with `adts muxer supports only codec aac`, leaving a 0-byte file. muxm then warned and continued without the track, so the expected stereo track was missing from the output. The stream-copy path now derives the intermediate's extension from the native codec via `_audio_copy_ext` (matching how the primary track is named), so the copy succeeds and the stereo track is muxed in. The synthetic-downmix and native-transcode paths were unaffected (they always produce real AAC). - **Terminal hung after Ctrl+C — repeated ^C presses had no effect** — When the user interrupted an encode with Ctrl+C, `on_exit` printed the failure message and then blocked indefinitely at `wait "$TEE_PID"`. Root cause: every backgrounded ffmpeg process inherits bash's stdout (the write end of the tee logger pipe), so tee cannot reach EOF until all writers close FD 1. The INT trap masked further signals immediately (`trap '' INT TERM EXIT`), so subsequent Ctrl+C presses were silently swallowed. Additionally, by the time `on_exit` ran, ffmpeg's PID was no longer reachable — it was stored in a local variable (`_ffui_pid`) inside `_ffmpeg_run_with_ui` that went out of scope when the INT trap unwound the call stack. Fix: a global `_ACTIVE_FFMPEG_PID` tracks the current background process across all backgrounded call sites (`_ffmpeg_run_with_ui`, `_ensure_src_es`, the DV fallback remux, audio stream copy, all subtitle extraction paths, the PGS OCR subprocess, the final mux, and the skip-if-ideal metadata remux); each site sets it before launching ffmpeg and clears it after `wait` returns. On the failure path of `on_exit`, the tracked PID is sent SIGKILL (not SIGTERM — ffmpeg ignores both SIGINT and SIGTERM after receiving more than 3 of them, and may deadlock in its async signal handler during VideoToolbox init) before `_cleanup_workdir`, ensuring the tee pipe drains immediately and cleanup races are avoided. - **SIGINT still hung on DV, OCR, and stereo-copy paths — seven spinner call sites were not tracked in `_ACTIVE_FFMPEG_PID`** — The previous fix covered the primary encode and several subtitle extraction paths, but seven background processes that display a spinner were never registered in `_ACTIVE_FFMPEG_PID`, so `on_exit` had no PID to SIGKILL. On interrupt, those processes continued holding the tee pipe write end open and `wait "$TEE_PID"` blocked indefinitely. Untracked sites: the DV RPU integrity-validation subshell (`rpu_val_pid`), the DV RPU piped-extraction subshell (`rpu_pipe_pid`), the native stereo stream-copy ffmpeg process (`_ns_pid` — only stderr was redirected; FD 1 inherited the tee pipe), the PGS OCR fallback subshell (`ocr_sup_pid`), the external PGS OCR subshell (`ocr_pid`), the external VobSub OCR subshell (`ocr_pid`), and the skip-if-ideal `cp` fallback (`_sii_cp_pid` — no redirects at all). Each site now sets `_ACTIVE_FFMPEG_PID` immediately before its spinner call and clears it after. A 5-second kill-backstop is also added to both drain paths of `on_exit` (`( sleep 5; kill "$TEE_PID" ) &` launched after FDs are restored to the terminal, so the watchdog cannot itself hold the pipe): if any future untracked site still holds the pipe at cleanup time, tee is force-killed after 5 seconds rather than hanging forever. - **Spurious `❌ ERROR: Command failed … IFS='=' read -r key val` printed on Ctrl+C** — With `set -eE` active, pressing Ctrl+C while `ffmpeg_progress_bar` is blocked reading the ffmpeg progress pipe (`while IFS='=' read -r key val 2>/dev/null`) caused bash to fire the ERR trap against the interrupted `read` (exit 1) before processing the pending INT signal. This printed a false "Command failed" error that looked like a genuine encode failure but was purely a side effect of signal delivery order — the read was interrupted, not broken. The ERR trap is now suppressed with `trap '' ERR` immediately before the read loop and restored with the original handler immediately after `done < "$pipe"`, so `wait "$pid"` still triggers ERR on genuine ffmpeg failures while the signal-interrupted read does not. ## [1.4.0] - 2026-04-11 ### Added - **Table of contents comment block** — 30-section TOC with line numbers added to the top of the script for navigability. - **Clarifying comments** — Added at `.muxmrc` sourcing, cross-platform detection, error handling strategy, FD 3 lifecycle, and other key locations throughout the script. - **`_check_disk_full()` helper** — Detects `ENOSPC` (disk-full) conditions in encode/mux failure output and surfaces a targeted error message. - **`_repeat_char()` pure-bash helper** — Replaces the external `seq` dependency for character repetition. - **`_normalize_codec_lang()`, `_log_dropped_tracks()`, `_source_label()`, `_check_filter_ideality()` DRY helpers** — Extracted repeated logic into named helpers to reduce duplication across pipeline paths. - **Debug `jq` logging (`DEBUG=1`)** — When `DEBUG=1`, logs the jq filter, exit code, and first 300 characters of output for every jq invocation. - **Post-install AV1 encoder advisory** — `--install-dependencies` now prints brew commands for AV1 encoders after ffmpeg installation. - **Runtime validation for `libaom-av1` encoder selection** — Exits early with a clear error when `libaom-av1` is selected but not available in the installed ffmpeg build. - **Two-step output verification in `_validate_media_file`** — Checks file existence first, then runs an ffprobe stream check, so each failure mode produces a distinct error. - **`MAX_COPY_BITRATE` arithmetic guard** — Guards against non-numeric values before using `MAX_COPY_BITRATE` in arithmetic expressions. - **`AUDIO_FORCE_BITRATE` variable and `--audio-force-bitrate` flag** — Sets a fixed bitrate for all non-lossless audio output (e.g., `AUDIO_FORCE_BITRATE="256k"`). Overrides codec-specific bitrate variables (`EAC3_BITRATE_5_1`, `EAC3_BITRATE_7_1`, `STEREO_BITRATE`) when set. Used by `streaming-av1` to pin Opus surround at 256k. - **AV1 (SVT-AV1) codec support** — `--video-codec libsvt-av1` enables full AV1 pipeline integration: CRF, preset, encoder params, conflict detection, disk space estimation, and config generation. Companion CLI flags: `--av1-params STR`, `--av1-maxrate KBPS`, `--av1-bufsize KBPS`. - **`--checksum-algo` flag** — Selects the checksum algorithm: `sha256`, `blake2b`, or `auto`. Specifying an algorithm implies `--checksum`; `auto` prefers `b2sum` and falls back to `sha256`. - **BLAKE2b checksum support** — `write_checksum()` now dispatches to `b2sum` when selected, writing `.b2` sidecar files alongside the output. `auto` mode uses BLAKE2b when available. - **`SUB_PRESERVE_BITMAP` flag** (default `1`) — Stream-copies PGS bitmap subtitles in MKV output instead of OCR'ing to text. Controlled via `--sub-preserve-bitmap` / `--no-sub-preserve-bitmap`. Backed by a new `_container_supports_bitmap_subs()` helper for container-aware subtitle handling. - **`tools/av1_compare.sh`** — HEVC vs AV1 quality/size benchmarking script with optional VMAF scoring. - **`docs/AV1_CALIBRATION.md`** — Documents the encode comparison methodology and CRF equivalence findings. - **`av1-hq` profile** — High-quality AV1 encode via SVT-AV1: CRF 20, preset 6, MKV container, lossless audio passthrough, SHA-256 checksum enabled by default. Dolby Vision is auto-disabled (AV1 pipeline does not support DV muxing). `SVT_AV1_PARAMS_BASE` is emitted uncommented by `--create-config`. - **`streaming-av1` profile** — AV1 streaming encode via SVT-AV1: CRF 30, preset 6, MP4 container, Opus audio at 192k with AAC stereo fallback. Targets modern clients with AV1 decode support (Fire TV Stick 4K Max, Chromecast with Google TV, web browsers). Strips DV; HDR10 preserved. ### Changed - **Standardize blank-line output** — 132 `echo` calls used solely to print a blank line replaced with `printf '\n'`. - **Rename `$p` variables** — Cryptic `$p` variables renamed to `$file_path` / `$sub_path` in `write_checksum` and `build_subtitle_plan` for clarity. - **Guard-clause refactor** — `disk_free_warn`, `_video_is_copy_compliant`, `run_video_pipeline`, `run_audio_pipeline`, `build_subtitle_plan`, and `_ensure_ffmpeg_full` refactored to early-return guard-clause style. - **Void-context arithmetic** — 70+ `$((...))` expressions used for their side-effects (no value captured) converted to `((...))` form. - **Batch `jq` calls in `detect_dv_info`** — 4 sequential `jq` invocations merged into a single call, reducing subprocess overhead. - **Associative-array cache for `_audio_codec_rank`** — Codec rank lookups are now O(1) via a pre-built associative array instead of a linear scan. - **Cache `dovi_tool` availability at startup** — `command -v dovi_tool` is now checked once at startup and cached; repeated inline checks removed. - **`streaming` renamed to `streaming-hevc`** — The existing HEVC streaming profile is now canonically named `streaming-hevc`. The old name `streaming` is retained as a deprecated backwards-compatible alias — existing scripts and `.muxmrc` files will continue to work but will log a deprecation notice. - **Single-track subtitle mode preserves PGS bitmap subs** — When the output container supports bitmap subtitles (MKV), PGS tracks are stream-copied rather than OCR'd. OCR is used only when the container requires text subtitles (MP4) or the user explicitly disables preservation via `--no-sub-preserve-bitmap`. - **`write_checksum()` rewritten with algorithm dispatch** — Supports BLAKE2b, SHA-256, and auto-detection in a unified function replacing the previous single-algorithm implementation. ### Fixed - **`spinner()` false ERR trap killing encodes** — `(( i = (i+1) % 4 ))` evaluates to 0 (falsy) every fourth iteration when the index wraps to zero, returning exit code 1 and triggering the ERR trap under `set -eEuo pipefail`. Any encode step where the spinner ran in the main shell context (most visibly the final mux) would be killed with a spurious `Command failed` error. Fixed by using the assignment form `i=$(( (i+1) % 4 ))`, which always exits 0. - **SHA-256 sidecar test skipping when `CHECKSUM_ALGO` is overridden via `.muxmrc`** — The `--checksum` encode test did not pass `--checksum-algo sha256` explicitly, so a `.muxmrc` that sets `CHECKSUM_ALGO=blake2b` caused the test to produce a `.b2` sidecar while looking for `.sha256`, silently skipping rather than failing. The algorithm flag is now passed explicitly. - **Positive toggle tests passing trivially when `.muxmrc` pre-sets the value** — `--checksum`, `--skip-if-ideal`, `--sub-burn-forced`, and `--video-copy-if-compliant` were in the single-flag `TOGGLE_CASES` loop, where they passed even when the flag was broken because `~/.muxmrc` or the default profile already set those variables to `1`. Moved to explicit two-flag tests (`--no-X` followed by `--X`) that establish a known-zero baseline before asserting the positive flag takes effect. - **`--help` and `--version` silently exiting** — `_prescan_args` used post-increment (`counter++`) from zero, which evaluates to `0` under `set -e` and triggered the ERR trap before the help/version output could print. Replaced with `counter=$(( counter + 1 ))`. - **Post-increment bug in config-override parser** — The same `(( flag++ ))` from-zero pattern in the boolean config-override parser caused silent exits when a boolean flag appeared at argument position 0. Fixed with `$(( flag + 1 ))` assignment form. - **`report_add` producing invalid JSON** — Values containing newlines, tabs, or carriage returns were written literally into the JSON string, producing unparseable output. Values are now escaped before insertion. - **Deprecated aliases not normalized in comma-separated multi-profile lists** — Deprecated profile aliases (e.g., `dv-archival`) were not resolved when passed as part of a comma-separated `--profile` list (e.g., `dv-archival,streaming`), causing an unknown-profile error. Alias normalization now runs before per-profile validation. - **False "DOVI record missing" warning when ffprobe failed** — When `ffprobe` itself failed (e.g., unsupported file), the empty output was being checked for a DOVI record, producing a spurious warning. The check is now skipped when ffprobe exits non-zero. - **RPU copy failure silently swallowed** — Errors from the `dovi_tool` RPU copy step were not surfaced; a failed RPU copy would silently produce a corrupt or incomplete output. The failure is now detected and propagated as a fatal error. - **SII remux/copy exit code silently discarded** — The exit code from the secondary-image-interleave remux and copy paths was not checked. Both paths now validate exit code and ffprobe the output before continuing. - **`--install-dependencies` not verifying AV1 encoder after ffmpeg install** — After installing ffmpeg, `--install-dependencies` did not confirm that the installed build included an AV1 encoder, so a missing encoder would only surface at encode time. The post-install check now verifies AV1 encoder availability and prints an advisory if absent. - **`libaom-av1` receiving wrong ffmpeg flags** — The encoder flag dispatch was passing `-svtav1-params` and `-preset` to `libaom-av1` encodes. `libaom-av1` uses `-aom-params` and `-cpu-used` instead; the dispatch now routes each flag set to the correct encoder. - **Opus multichannel bitrate using EAC3 values in `streaming-av1`** — The surround audio pass for `streaming-av1` was pulling `EAC3_BITRATE_5_1` / `EAC3_BITRATE_7_1` instead of an Opus-appropriate bitrate. `streaming-av1` now sets `AUDIO_FORCE_BITRATE="256k"`, which the audio pipeline prefers over codec-specific variables when set. - **Working file extension derived from encoder name instead of format** — Intermediate audio copy files were using the codec/encoder name (e.g., `libopus`) as the file extension instead of the container-appropriate format extension (e.g., `ogg`). The new `_audio_codec_ext()` helper maps encoder names to correct extensions, preventing "Unable to choose output format" errors for Opus and similar non-obvious codec/extension pairs. - **`--create-config` profile variable detection** — Snapshot baseline now captures script defaults before config file loading, preventing values set in `.muxmrc` from masking profile-owned variables in the generated config. - **`--checksum-algo` test assertions** — Moved from the boolean toggle array to explicit value-flag tests. ### Security - **`_BURN_SRT_FILTER` temp file moved to `WORKDIR`** — The subtitle filter file was previously written to a PID-predictable `/tmp` path, making it vulnerable to symlink attacks. It is now created inside the mktemp-based `WORKDIR`, which is only accessible to the running process. ### Docs - **`docs/config_profile.md` cross-references synced** — All 10 profile entries now include accurate script section cross-references matching the current TOC. - **`completions/muxm-completion.bash`** — Added missing `--x264-params` to the tab-completion flag list. ## [1.3.0] - 2026-03-29 ### Added - **New profile `youtube-upload`** — H.264 high-profile master-quality encode for YouTube ingestion. CRF 16, preset `slow`, x264 params `profile=high:rc-lookahead=60:aq-mode=2:aq-strength=1.0`. Forces AAC stereo at 256 k, burns forced subtitles, exports full subtitles as external SRT sidecars, strips non-essential metadata, keeps chapters. No tone-mapping (YouTube processes HDR natively); HDR10 metadata is preserved as-is. Container: MP4. DV layer disabled. Registered in `--help`, embedded and installed man pages, tab completions, `--create-config`, and conflict warnings (warns on `--audio-lossless-passthrough` and `--output-ext mkv`). - **`X264_PARAMS_BASE`** — New configuration variable (default empty) for advanced x264 parameter tuning, analogous to `X265_PARAMS_BASE`. The `youtube-upload` profile sets it to `profile=high:rc-lookahead=60:aq-mode=2:aq-strength=1.0`. Passed to ffmpeg via `-x264-params` when non-empty. Registered in `--print-effective-config`, `--create-config` template, and the new `--x264-params` CLI flag. - **`--x264-params STR`** — CLI flag to override `X264_PARAMS_BASE` at the command line, matching the existing `--x265-params` flag. Registered in the man page. - **Multi-profile via comma-separated `--profile`** — `--profile youtube-upload,streaming` runs both profiles sequentially from the same source, each as a full independent pipeline pass. All profile names are validated upfront before any work begins; an unknown name exits immediately with a helpful error. Output files are automatically suffixed with the profile name: `source.mkv` → `source.youtube-upload.mp4` and `source.streaming.mp4`. Single-profile invocations are unaffected. Per-profile success/failure is reported at the end; a failure in one profile logs a warning and continues with remaining profiles. Each pass prints a `━━━ Profile N/M: name ━━━` header. CLI flags are forwarded to each sub-invocation, so `--crf 14` applies to every profile in the list. - **`--create-config` CLI overrides** — `--create-config` now accepts CLI flags after the scope and profile to pre-populate config values. For example, `muxm --create-config user atv-directplay-hq --crf 20 --preset medium` generates a `.muxmrc` with `CRF_VALUE=20` and `PRESET_VALUE="medium"` uncommented. Supported overrides include `--crf`, `--preset`, `--output-ext`, `--level`, `--video-codec`, `--stereo-bitrate`, `--sub-lang-pref`, `--audio-lang-pref`, and common boolean toggles. Unrecognized flags produce an error. - **Output filename extension inference** — When the user provides an explicit output filename with a recognized extension (`.mp4`, `.m4v`, `.mov`, `.mkv`), muxm now infers `OUTPUT_EXT` from that extension, as if `--output-ext` had been passed. This prevents mismatched container formats (e.g., writing MKV data to a `.mp4` filename). Only applies in single-profile mode; `--output-ext` still wins if explicitly passed. - **Container compatibility warnings** — Early warnings when ASS/SSA subtitle preservation is requested but output is MP4 (formatting will be lost), and when lossless audio passthrough is enabled but output is MP4 (limited playback support). - **`_CLI_CRF_EXPLICIT` and `_CLI_PRESET_EXPLICIT` tracking variables** — Set when `--crf` or `--preset` are passed on the CLI, used by skip-if-ideal and video copy compliance checks. ### Changed - **Native stereo track preference in stereo fallback** — When `ADD_STEREO_IF_MULTICH=1` and the primary track is surround (>2ch), muxm now scans all audio streams for a native stereo track before creating a synthetic downmix. A qualifying native stereo track must have exactly 2 channels, the same language as the primary track (`und` matches anything), no commentary/descriptive-audio title keywords, and no `visual_impaired` or `hearing_impaired` disposition flags. When a qualifying track is found it is copied directly (for container-compatible codecs: AAC, AC3, EAC3 into MP4/M4V/MOV; any codec into MKV) or transcoded to AAC at `STEREO_BITRATE` otherwise. If no qualifying native track exists, the existing downmix path is used unchanged. Primary track selection and all other behavior are unaffected. - **Multi-profile output naming honors user's filename** — When using comma-separated `--profile` with an explicit output filename, the user's stem and directory are used as the base for auto-suffixed names (e.g., `muxm --profile youtube-upload,streaming source.mkv /nas/my_video.mp4` → `my_video.youtube-upload.mp4`, `my_video.streaming.mp4` in `/nas/`). A warning listing all output paths is printed before encoding starts. When no output filename is provided, the source stem is used as before. - **`--create-config` single-profile variable output** — For single-profile configs, variables owned by the selected profile are now emitted uncommented (active) rather than commented out. CLI overrides that differ from the profile's own default value have `# Manually adjusted` appended to mark them as user customizations. ### Fixed - **`check_skip_if_ideal` now checks for external subtitle files** — Skip-if-ideal logic now inspects `EXT_SUB_PATHS[]` before declaring a source "ideal." Previously, when sidecar subtitle files had been discovered, `SKIP_IF_IDEAL=1` would copy the source as-is without muxing them in, silently dropping the discovered subtitles. - **`check_skip_if_ideal` now respects `VIDEO_COPY_IF_COMPLIANT=0`** — Profiles that require re-encoding (e.g., `animation`) are no longer silently skipped when `SKIP_IF_IDEAL=1`. The check now gates on `VIDEO_COPY_IF_COMPLIANT` so a source that is otherwise stream-copy eligible is still re-encoded when the profile demands it. - **`check_skip_if_ideal` now checks `ADD_STEREO_IF_MULTICH`** — A surround-audio source is no longer declared "ideal" when a stereo downmix track is required. Previously `SKIP_IF_IDEAL=1` with a multi-channel source would skip encoding and omit the requested stereo track. - **`_video_is_copy_compliant` tonemap detection** — Replaced a stale `PROFILE_DESC` string-match with a direct probe of source color metadata (`color_primaries`, `color_transfer`). Previously, `--tonemap` combined with an HDR source and `SKIP_IF_IDEAL=1` would silently skip tone-mapping because the profile description check no longer matched the internal variable layout. - **En-dash in `assert_stream_count` fail message** — The en-dash character (U+2013) used as a separator in the assertion failure message caused an unbound variable crash under `set -u`. Replaced with an ASCII hyphen. - **Collision test isolation** — Added explicit `--output-ext mp4` to the collision test to prevent a user's `.muxmrc` passthrough config from changing the output container and invalidating the test scenario. - **`--create-config` override values not applied** — CLI override values passed after the profile name were being ignored; the generated config did not reflect them. Fixed. - **DV + unsupported output container** — muxm now errors early (exit 11) when Dolby Vision is detected but the output container (e.g., MOV) is not supported for DV muxing by ffmpeg. Previously the encode would complete and fail at the mux step. The error message directs the user to `--output-ext mkv`, `--output-ext mp4`, or `--no-dv`. - **`check_skip_if_ideal` ignores explicit `--crf` and `--preset`** — when CRF or preset are explicitly set on the CLI, skip-if-ideal and video-copy-if-compliant now correctly force a re-encode instead of stream-copying or skipping. - **`--create-config` with multi-profile** — comma-separated profile names now generate a minimal config containing only `PROFILE_NAME` and explicit overrides, instead of a full template seeded from one profile's defaults. ### Tests - **`--cleanup` flag for standalone cleanup** — `--cleanup` now runs as a standalone mode: prints each stale temp directory with its size on disk, removes them, and prints a total freed summary. No longer tied to ending a test run. - **Auto-cleanup of stale `muxm-test.*` directories at run start** — The test runner now purges leftover `muxm-test.*` temp directories at the beginning of every run (not the end), so failures from a prior aborted run do not pollute the next one. - **Reverted parallel test runner** — `--parallel`, `--no-parallel`, and `-j N` flags have been removed due to process management issues that caused intermittent hangs. Suites run sequentially again. - **New `multi_profile` test suite** — Tests for comma-separated profile parsing, invalid profile name rejection (exits before any work begins), single-profile invocation remaining unchanged, and output filename auto-suffixing. - **`youtube-upload` profile variable assertions** — New tests verify that the profile sets the expected values for `VIDEO_CRF`, `VIDEO_PRESET`, `X264_PARAMS_BASE`, `AUDIO_CODEC`, `AUDIO_BITRATE`, `ADD_STEREO_IF_MULTICH`, `SUB_BURN_FORCED`, `DISABLE_DV`, and `TONEMAP`. - **`youtube-upload` conflict warning tests** — Assertions that `--output-ext mkv` and `--audio-lossless-passthrough` each trigger the expected conflict warning when used with the `youtube-upload` profile. ## [1.2.0] - 2026-03-26 Smart disk space preflight: `disk_free_warn` now estimates expected output size from source video bitrate, CRF, codec, preset, audio tracks, and duration instead of using a static free-space floor. Adds `--no-disk-check` / `--disk-check` to suppress or re-enable the check at the CLI. External subtitle discovery: muxm now automatically finds and muxes sidecar subtitle files (.srt, .ass, .ssa, .vtt, .sup, .idx/.sub) alongside the source. Language codes in filenames are normalized to ISO 639-2/T and routed through all existing subtitle filters. Internal refactors (no user-facing behavior changes) also included. Container passthrough: `archive` and `atv-directplay-hq` now derive the output container from the source file extension (`mkv→mkv`, `mp4→mp4`) instead of hardcoding it, with a fallback to `.mkv` for unsupported source containers. When `atv-directplay-hq` produces MKV output, it automatically enables native ASS/SSA subtitle preservation and disables forced-subtitle burn-in (both overrideable via CLI flags). Profile rename: `dv-archival` has been renamed to `archive`. The old name is retained as a deprecated backwards-compatible alias — existing scripts and `.muxmrc` files using `PROFILE_NAME="dv-archival"` will continue to work but will log a deprecation notice. New profile `atv-directplay-animation`: combines `atv-directplay-hq` ATV Direct Play constraints with `animation`'s quality-first encoding settings. Ideal for anime/cartoon sources destined for Apple TV. Lossless audio is transcoded to E-AC-3 (ATV cannot Direct Play TrueHD/DTS-HD MA). Multi-track ASS/SSA + PGS subtitle preservation. Passthrough container with MKV-output subtitle adjustment (same as `atv-directplay-hq`). ### Added - **Smart disk-space preflight** — `disk_free_warn()` now estimates encoded output size before encoding begins rather than just checking a fixed free-space floor. Estimation uses per-codec CRF-to-bitrate-ratio tables (`_crf_ratio`, with a baked-in 1.3× light grain pessimism factor) and preset-size multipliers (`_preset_multiplier`). When `VIDEO_COPY_IF_COMPLIANT=1`, the source video bitrate is used directly (no CRF reduction). Audio estimation uses source bitrate for passthrough codecs (eac3, ac3, aac, dts, truehd, mlp, flac) and 64 kbps × channel-count for transcode targets. A 5 MB subtitle overhead and a 1.25× safety margin are applied. `DISK_FREE_WARN_GB` acts as a minimum floor. Both the output volume and the temp/workdir volume (when on a separate device from output) are checked. Warning messages now include `Use --no-disk-check to suppress this warning.` - **`--disk-check` / `--no-disk-check`** — Enable or disable the smart disk preflight at the CLI. `DISK_CHECK=0` in `.muxmrc` has the same effect. Registered in `--print-effective-config`, `--create-config` template, tab completions, and man page. - **`DISK_CHECK`** config variable (default `1`) — controls the smart disk preflight. Added to `--print-effective-config` output under `[Pipeline Control]` and to the `--create-config` generated template. - **External subtitle discovery** (`EXT_SUB_ENABLED`) — muxm now automatically discovers sidecar subtitle files (.srt, .ass, .ssa, .vtt, .sup, .idx/.sub) in the same directory as the source file and muxes them as additional subtitle tracks. Filename parsing extracts language codes and type qualifiers (e.g., `movie.en.srt`, `movie.forced.en.srt`, `movie.sdh.srt`). 2-letter ISO 639-1 codes are normalized to 3-letter ISO 639-2/T codes. External subtitles pass through all existing subtitle filters (`SUB_LANG_PREF`, `SUB_INCLUDE_FORCED`/`FULL`/`SDH`, `SUB_MAX_TRACKS`) and work in both single-track and multi-track subtitle modes. - **`--ext-subs` / `--no-ext-subs`** — Enable or disable external subtitle discovery at the CLI. `--ext-subs-dir ` overrides the search directory (defaults to the source file's directory). - **`EXT_SUB_ENABLED` / `EXT_SUB_DIR`** — New config variables for external subtitle discovery. `--create-config` now includes these variables (commented out) in the generated template for all profiles. - **58 new external subtitle discovery tests** in a new `ext_subs` test suite. - **~430 additional tests** covering previously untested features (total test count now 702). - **Container passthrough for `archive` and `atv-directplay-hq`** — Both profiles now set `OUTPUT_EXT=""`, signalling container passthrough. After source validation, the passthrough resolution block (Section 15) derives `OUTPUT_EXT` from the source file extension: `mkv→mkv`, `mp4→mp4`, `m4v→m4v`, `mov→mov`. Sources with unsupported output containers (`.avi`, `.ts`, etc.) fall back to `.mkv` with an informational note. `--output-ext` on the CLI always wins (`_OUTPUT_EXT_EXPLICIT=1` skips the resolution block). `USAGE_SHORT` shows `[target.{src-ext}]` when `OUTPUT_EXT` is empty at parse time. - **`atv-directplay-hq` MKV-output subtitle adjustment** — When `atv-directplay-hq` resolves to MKV output, `SUB_BURN_FORCED` is set to `0` (soft subtitles preferred over burn-in) and `SUB_PRESERVE_TEXT_FORMAT` is set to `1` (native ASS/SSA preservation). `--sub-burn-forced` on the CLI prevents the burn-in override; ASS preservation is always enabled for MKV output. - **`_OUTPUT_EXT_EXPLICIT` tracking variable** — Set to `1` when `--output-ext` is passed on the CLI, distinguishing "user forced a container" from "container is being resolved from source." - **`_CLI_SUB_BURN_FORCED` tracking variable** — Set to `1` when `--sub-burn-forced` is passed on the CLI, preventing the `atv-directplay-hq` MKV subtitle adjustment from overriding an explicit user request. - **`SUB_SOLE_EXT_FALLBACK`** — When the language filter drops all subtitle candidates but there is exactly one external sidecar and zero embedded streams, that sidecar is included regardless of its language tag. Enabled by default; disable with `--no-sub-sole-ext-fallback` or `SUB_SOLE_EXT_FALLBACK=0`. - **`--sub-sole-ext-fallback` / `--no-sub-sole-ext-fallback`** — CLI flag pair registered in `--print-effective-config`, tab completions, and man page. - **New profile `atv-directplay-animation`** — Animation-quality encode shaped for Apple TV / Plex Direct Play. Takes `animation`'s quality-first settings (CRF 16, animation-tuned x265 params, HEVC 10-bit, multi-track ASS/SSA + PGS subtitle preservation) and layers on `atv-directplay-hq`'s ATV compatibility constraints (E-AC-3 audio, Level 5.1 VBV guardrails, passthrough container, copy-if-compliant with 50 Mbit/s ceiling). Lossless audio (TrueHD, DTS-HD MA, FLAC) is transcoded to E-AC-3 since ATV cannot Direct Play lossless codecs. Forced subtitles are burned for MP4 output (Direct Play requirement); for MKV output the MKV subtitle adjustment block (Section 15) switches to soft forced subs and confirms native ASS/SSA preservation. Registered in `--help`, man page, tab completions, `--create-config`, and conflict warnings. - **Profile rename: `dv-archival` → `archive`** — The `dv-archival` profile has been renamed to `archive`. The old name is preserved as a silent backwards-compatible alias: `--profile dv-archival` and `PROFILE_NAME="dv-archival"` in `.muxmrc` continue to work and now emit a deprecation notice. All user-facing surfaces (help text, man page, tab completions, `--create-config`, conflict warnings) use the new name. ### Fixed - **`(( counter++ ))` from zero exits under bash error handling** — The `SUB_SOLE_EXT_FALLBACK` loop used post-increment from `0`, which evaluates to `0` (false) and triggers `set -e` / ERR trap. Replaced with `counter=$(( counter + 1 ))`. - **`atv-directplay-hq` conflict warning false positive for passthrough-to-MKV** — Warning now requires `(( _OUTPUT_EXT_EXPLICIT ))` so it only fires for explicit `--output-ext mkv`. Updated message acknowledges Plex/Infuse MKV Direct Play support. - **ShellCheck SC2017 precision-loss warnings** — Integer-only `$(( ))` arithmetic was used in places where intermediate float values were expected, triggering SC2017. The affected expressions in the disk-preflight estimation helpers (`_crf_ratio`, `_preset_multiplier`) have been rewritten to use `awk` for floating-point math, eliminating all SC2017 warnings from a full `shellcheck` pass. ### Changed - **`disk_free_warn` call moved** — The function is now called inside `main()` after `cache_stream_metadata()`, so it uses the already-populated `METADATA_CACHE` (via `_jq_cache`, `_get_source_duration_secs`, and `_audio_stream_info`) without re-probing the source file. - **GB display format** — Available and estimated disk space in preflight warnings is now shown with one decimal place (e.g. `3.4GB`) instead of truncated integer GB. - **README** — Editorial polish pass: added `bc` to the Homebrew dependencies list, highlighted the live progress bar, documented disk space preflight, signal handling, and `DEBUG=1`, added a CHANGELOG link, fixed the table of contents, moved the "Why MuxMaster?" section after the usage section, corrected grammar throughout, and normalized all code fences to consistent backtick-triple style. - **Man page** (`docs/muxm.1` and embedded `--install-man` copy) — Updated with external subtitle discovery documentation covering new flags, config variables, filename parsing behavior, and filter interaction. This release additionally adds `--sub-sole-ext-fallback` / `--no-sub-sole-ext-fallback` flag documentation and `SUB_SOLE_EXT_FALLBACK` to the configuration variable reference. - **Tab completions** (`completions/muxm-completion.bash`) — `--sub-sole-ext-fallback` and `--no-sub-sole-ext-fallback` added to the subtitle flag group. `archive` and `atv-directplay-animation` added to profile completion lists; `dv-archival` removed (deprecated alias no longer advertised). - **Man page** (`docs/muxm.1` and embedded copy) — `archive` and `atv-directplay-animation` profiles added; `dv-archival` entry removed. `--output-ext` entry updated to describe passthrough behavior for passthrough profiles. `--sub-sole-ext-fallback` section in embedded copy synced to match `docs/muxm.1`. Multi-Track Audio and Multi-Track Subtitles sections updated to reflect the three multi-track profiles (`archive`, `animation`, `atv-directplay-animation`). Version header updated to v1.2.0 / 2026-03-24. Passthrough container language updated in `atv-directplay-hq` description. - **Extract `_create_config_prescan()`** — The `--create-config` pre-scan block was extracted into its own function. Its 6 temporary variables are now local to the function, eliminating the corresponding `unset` calls in the main flow. - **Extract `_cleanup_workdir()` from `on_exit`** — Deduplicated the WORKDIR removal safety guard into a single helper. `exec 3>&-` is now unconditionally issued before the success/failure branch so FD 3 is always closed in the same place regardless of exit path. - **Add `# SYNC:` cross-reference comments to duplicated audio stream display loops** — The parallel loops in `run_audio_pipeline` and `run_audio_pipeline_multi` now carry `# SYNC:` annotations pointing at each other, making the duplication intentional and visible to future editors. - **Extract `_ffmpeg_run_with_ui()`** — Consolidated the repeated pipe / progress-bar / spinner boilerplate that appeared across the video encode, audio transcode, and stereo fallback paths into a single shared helper. Call sites pass their ffmpeg arguments and a label; the helper owns the subprocess, UI wiring, and exit-code propagation. - **Consolidate `printf | sed` calls in `build_x265_params`** — Six separate `printf | sed` subprocess invocations have been merged into a single `sed` call with multiple `-e` expressions, reducing subprocess overhead and centralizing the parameter-sanitization logic. - **Eliminate double-scan in `select_best_audio`** — The previous two-pass implementation (one pass to build the score summary, a second to find the best stream) has been merged into a single loop that tracks the running best while accumulating the summary, halving the number of iterations over the stream list. - **Replace `wc -w` word counting with pure Bash array expansion** — Three-subprocess chains (`echo | wc | tr`) used to count whitespace-delimited tokens have been replaced with `read -r -a arr` followed by `${#arr[@]}`, eliminating subshells and external process forks for this operation. ### Tests - **Unit tests for `_crf_ratio` and `_preset_multiplier`** — New `test_disk_preflight` suite includes dedicated unit-test assertions for both helper functions, verifying correct ratio and multiplier values across all supported codecs, CRF values, and preset names, as well as boundary behaviour (unknown codec/preset fallback defaults). - **3 new assertions in `test_dryrun`**: `--no-disk-check` suppresses the warning, `DISK_CHECK=0` in config suppresses the warning, and `--video-copy-if-compliant` (copy mode) completes the disk preflight without error. - **24 new test assertions** across 5 suites: `profiles` (CLI override wins over passthrough), `conflicts` (passthrough doesn't fire MKV warning), `dryrun` (passthrough resolution logs, subtitle adjustment for MKV/MP4, CLI override), `containers` (real-encode passthrough MKV→MKV, MP4→MP4, M4V→M4V, AVI→MKV fallback, CLI override), `ext_subs` (sole-external fallback includes/excludes correctly). - **2 updated assertions**: `dv-archival` and `atv-directplay-hq` profile tests updated from hardcoded `OUTPUT_EXT` to empty (passthrough). - **Parallel test runner** — `test_muxm.sh` now supports `--parallel` / `--no-parallel` and `-j N` to control the number of concurrent test workers. Suites run sequentially by default; `--parallel` distributes suites across worker subshells (default concurrency: number of CPU cores as reported by `nproc`/`sysctl -n hw.logicalcpu`). Suite output is buffered and printed atomically when each suite finishes so interleaving is never visible. - **Test cleanup** — `--cleanup` removes all fixture output files and temporary directories generated during a test run. Auto-cleanup now runs at the end of every test run by default (previously the caller was responsible for cleanup). Pass `--no-cleanup` to suppress it for post-failure inspection. ## [1.1.0] - 2026-03-22 Multi-track audio and subtitles for `dv-archival` and `animation`: both profiles now keep all matching audio/subtitle tracks from the source instead of scoring and selecting one. Commentary/descriptive audio tracks are dropped by default in `dv-archival`. All surviving tracks are stream-copied (never transcoded). Configurable via `.muxmrc`. ### Added - **Multi-track audio pipeline** (`AUDIO_MULTI_TRACK=1`) — New audio mode that keeps all matching audio tracks instead of selecting a single best track. Audio streams are mapped directly from source with `-c:a copy` (no intermediate extraction, no transcoding, no temp files). Controlled by two new config variables: - `AUDIO_MULTI_TRACK` — `1` = keep all tracks that pass filters, `0` = single-track scoring (default, unchanged for all other profiles). - `AUDIO_KEEP_COMMENTARY` — `1` = keep commentary/descriptive tracks, `0` = drop them. Uses the existing `_audio_is_commentary()` heuristic. - **Multi-track subtitle pipeline** (`SUB_MULTI_TRACK=1`) — New subtitle mode that keeps all matching subtitle tracks instead of selecting one per type (forced/full/SDH). Subtitle streams are mapped directly from source with `-c:s copy` (no OCR, no format conversion, no intermediate files). Controlled by one new config variable: - `SUB_MULTI_TRACK` — `1` = keep all tracks that pass filters, `0` = single-track per-type selection (default, unchanged for all other profiles). - Uses existing `SUB_INCLUDE_FORCED`, `SUB_INCLUDE_FULL`, `SUB_INCLUDE_SDH` as type filters and `SUB_LANG_PREF` as language filter. `SUB_MAX_TRACKS` is respected as a cap. - Bitmap subtitles (PGS, VobSub) that cannot be muxed into the target container are silently skipped. MKV handles all formats. - **`dv-archival` profile updated** — Now sets `AUDIO_MULTI_TRACK=1`, `AUDIO_KEEP_COMMENTARY=0`, and `SUB_MULTI_TRACK=1`. Language filtering uses the existing `AUDIO_LANG_PREF` and `SUB_LANG_PREF` variables: when empty (the dv-archival default), all languages pass; when set (e.g., `eng,jpn`), only matching tracks are kept. - **`animation` profile updated** — Now sets `SUB_MULTI_TRACK=1` so all matching subtitle tracks (including PGS bitmap streams) are stream-copied from source without OCR or format conversion. Previously, PGS subtitles were routed through the single-track OCR pipeline and silently dropped when OCR tooling was unavailable, despite the output container (MKV) supporting PGS natively. `SUB_MAX_TRACKS` defaults to 6. - **Graceful demotion** — If `--audio-track` or `--audio-force-codec` is set alongside `AUDIO_MULTI_TRACK=1`, multi-track audio mode is automatically demoted to single-track with an informational note. If `--sub-burn-forced` is set alongside `SUB_MULTI_TRACK=1`, multi-track subtitle mode is demoted to single-track. The explicit CLI flag always wins. - **Conflict warnings** (Section 13) for `dv-archival` + `--audio-track`, `--audio-force-codec`, `--stereo-fallback`, `--sub-burn-forced`, and `--sub-export-external` when multi-track modes are active. - **`skip-if-ideal` updated** — When `AUDIO_MULTI_TRACK=1` or `SUB_MULTI_TRACK=1`, the ideal check verifies that every source audio/subtitle track would survive the respective filter. If any would be dropped, the source is not ideal and remuxing proceeds. - **Per-stream gating in skip-if-ideal remux** — `check_skip_if_ideal` now produces validated stream keep-lists (`SII_AUDIO_INDICES`, `SII_SUB_INDICES`) that the metadata remux uses to build explicit `-map 0:v:0 -map 0:a:N -map 0:s:N` flags instead of `-map 0`. Multi-track profiles delegate to `_build_audio_keep_list` / `_build_subtitle_keep_list`. Single-track profiles filter every stream against container compatibility, preventing incompatible codecs (e.g., TrueHD or PGS in MP4) from reaching the mux — even if a future profile change removes the implicit container gate. - **`_sii_audio_is_container_safe()` helper** — Checks whether an audio codec can be muxed into the target container. MKV passes all codecs; MP4/MOV rejects TrueHD, DTS/DCA, and raw PCM. Mirrors the existing `_is_text_sub_codec` pattern for subtitles. - **`dv-archival` profile now enables `CHECKSUM=1` by default** — SHA-256 integrity verification is a natural part of the archival workflow and was a missing default. Can be suppressed with `--no-checksum`. - **Shared source input in `mux_final`** — `VIDEO_COPY_FROM_SOURCE`, `AUDIO_COPY_FROM_SOURCE`, `SUB_COPY_FROM_SOURCE`, and direct subtitle mapping now share a single `-i "$SRC_ABS"` ffmpeg input via `_src_input_idx`, eliminating duplicate source file inputs. - New man page subsections "Multi-Track Audio (Archival)" and "Multi-Track Subtitles" under AUDIO OPTIONS and SUBTITLE OPTIONS, documenting filter behavior, config variables, demotion rules, and per-profile defaults for both `dv-archival` and `animation`. - `AUDIO_MULTI_TRACK`, `AUDIO_KEEP_COMMENTARY`, and `SUB_MULTI_TRACK` added to `--print-effective-config`, `--create-config` template, and man page CONFIGURATION variable groups. - 21 new test assertions in `test_muxm.sh` across `test_profiles`, `test_conflicts`, `test_dryrun`, `test_subs`, and `test_profile_e2e` suites validating animation profile multi-track subtitle behavior: profile variable assignment, conflict warnings (burn-forced demotion, export-external), dry-run announcements, language filtering, and a full e2e encode verifying all 5 subtitle tracks are preserved in output. ### Fixed - **`--no-sub-preserve-format` silently ignored in multi-track subtitle mode.** The multi-track pipeline used blanket `-c:s copy` for all streams, bypassing the `SUB_PRESERVE_TEXT_FORMAT` check entirely. ASS/SSA subtitles were always stream-copied regardless of the flag. The multi-track codec assignment in `mux_final` now makes per-stream decisions: ASS/SSA tracks are converted to SRT (MKV) or mov_text (MP4/MOV) when `SUB_PRESERVE_TEXT_FORMAT=0`, while all other codecs (PGS, SRT, VobSub) remain stream-copied. `run_subtitle_pipeline_multi` logs an informational note when ASS/SSA conversion will occur. - **Skip-if-ideal metadata remux silently dropped streams.** The ffmpeg copy-remux used to stamp audio titles had no `-map` flag, causing ffmpeg's default stream selection to keep only one stream per type. On a 39-stream source (video + TrueHD + AC-3 + PGS + 35 SRT tracks), `dv-archival` output retained only 3 streams — the AC-3 compatibility track, PGS SDH subtitle, and all non-first-selected SRT tracks were silently lost. The remux now uses explicit per-stream maps built from the validated keep-lists populated by `check_skip_if_ideal`. - **Audio title metadata misaligned when streams are filtered.** The skip-if-ideal remux referenced source audio indices for `-metadata:s:a:N` tags, but when streams are filtered out, output indices shift. Tags now use a sequential output counter, matching the proven pattern in `mux_final`. - **No visual feedback during skip-if-ideal remux.** The ffmpeg copy-remux, `cp` fallback, and SHA-256 checksum all ran in the foreground with no spinner, causing the CLI to appear hung for 10–30+ seconds on multi-GB files. All three now run in the background with `spinner` progress indicators. - **FD 3 closed before checksum in `on_exit`.** The raw-terminal file descriptor used by `spinner` was closed at the top of `on_exit`, before `write_checksum` could use it. The checksum spinner would write to a closed FD. FD 3 close is now deferred to after the checksum in both the success and failure paths. ### Changed - `dv-archival` profile description updated in man page, usage text, and `--help` output to reflect multi-track audio and subtitle behavior. - `animation` profile description updated in man page to reflect multi-track subtitle mode (ASS/SSA + PGS bitmap). MP4/MOV compatibility warnings now mention PGS bitmap subtitles alongside ASS/SSA. - Man page "Multi-Track Subtitles" section updated: ASS/SSA tracks are converted to SRT when `SUB_PRESERVE_TEXT_FORMAT=0`, even in multi-track mode. Previously stated "no format conversion" unconditionally. ## [1.0.2] - 2026-03-20 Enforce HEVC Level 5.1 VBV guardrails in `atv-directplay-hq` re-encodes to prevent bitrate spikes that cause stutter on Apple TV 4K. Fix crash when subtitle or audio stream titles contain literal pipe characters. Add ASS/SSA subtitle format preservation for MKV containers. Eliminate redundant multi-GB file copies in the video pipeline. Fix fatal ffmpeg muxer failure when stream-copying TrueHD or ALAC audio via lossless passthrough. Fix misleading "No Dolby Vision detected" log message when DV detection is skipped by a profile. ### Added - **`--sub-preserve-format` / `--no-sub-preserve-format`** — New CLI flag pair controlling whether text-based subtitles (ASS/SSA) are kept in their native format or converted to plain-text SRT. When enabled and the output container is MKV, ASS/SSA subtitles are stream-copied with full positioning, fonts, and typesetting intact. Ignored for MP4/MOV containers (which cannot carry ASS). Controllable via the `SUB_PRESERVE_TEXT_FORMAT` config variable in `.muxmrc`. - **`animation` profile now preserves ASS/SSA subtitles by default.** The profile sets `SUB_PRESERVE_TEXT_FORMAT=1`, fulfilling its documented promise of preserving styled ASS/SSA subtitles in MKV output. Previously, ASS subtitles were unconditionally converted to SRT regardless of profile or container, losing all positioning, styling, and typesetting data. - New conflict warning when `animation` profile is combined with `--no-sub-preserve-format`, alerting that ASS/SSA styling will be lost. - `SUB_PRESERVE_TEXT_FORMAT` added to `--print-effective-config`, `--create-config` template, man page, and tab completions. - New `ass_subs.mkv` test fixture and 10 new test assertions across `test_profiles`, `test_conflicts`, `test_dryrun`, `test_subs`, and `test_profile_e2e` suites validating ASS preservation, SRT conversion fallback, CLI override, and MP4 container limitation. - `probe_sub` helper added to `test_muxm.sh` for subtitle stream field inspection. - **`_audio_copy_ext()` helper** — Maps ffprobe codec names to file extensions that ffmpeg can actually mux when stream-copying intermediate audio. Covers `truehd→.thd`, `alac→.m4a`, `pcm_s*→.wav`, `dca→.dts`; all other codecs pass through unchanged. - `SYNC` cross-reference comments on `audio_is_direct_play_copyable()`, `audio_is_lossless()`, and `_audio_copy_ext()` documenting that these three codec lists must stay in sync — any codec added to either copy-eligible gate must have a valid mapping in `_audio_copy_ext()`. - 11 new unit test assertions for `_audio_copy_ext` covering all 5 mapped codecs and 6 passthrough codecs. - **`--dv` CLI flag** — Re-enables Dolby Vision handling after a profile disables it. Follows the existing `--flag` / `--no-flag` convention alongside `--no-dv`. Allows users to combine animation-tuned x265 parameters with DV preservation on live-action sources (e.g., `muxm --profile animation --dv Movie.mkv`). Added to man page, tab completions, and usage text. - **`_source_has_dv_metadata()` helper** — Lightweight check for DOVI configuration records in the already-populated metadata cache. Used to emit actionable warnings when DV detection is skipped on a source that actually contains Dolby Vision. ### Fixed - **`atv-directplay-hq` re-encodes now capped by Level 5.1 VBV.** Previously, the copy path was guarded by `MAX_COPY_BITRATE=50000k` but the re-encode path had no bitrate ceiling — a CRF 17 encode of complex scenes could spike beyond what the Apple TV 4K hardware decoder sustains without buffering. The profile now sets `LEVEL_VALUE="5.1"`, which activates the existing conservative VBV machinery (`vbv-maxrate=40000k`, `vbv-bufsize=80000k`). Can be overridden with `--level` or `--no-conservative-vbv`. - **Pipe characters in stream titles no longer break field parsing.** Subtitle titles such as `"Original | English"` or `"Original | English | (SDH)"` contain literal `|` which corrupted the pipe-delimited output of `_sub_stream_info` and the verify-block audio jq call. The `forced` variable would receive fragments like `" English|0"` instead of `0`, causing an arithmetic evaluation crash under `nounset`. Switched all internal field delimiters from `|` to `\t` (tab) across 4 jq producer functions, 10 consumer `read`/`cut`/parameter-expansion sites, and their fallback defaults. Tab is safe because it effectively never appears in media metadata. The audio pipeline (`_audio_stream_info`, `_score_audio_stream`, and their consumers) was not actively broken — the free-text `title` field happened to be last, absorbing extra pipes — but was migrated for consistency to prevent silent breakage if fields are ever reordered. - **ASS/SSA subtitles no longer silently converted to SRT.** The subtitle pipeline unconditionally funneled all text-based subtitles through SRT conversion via `_prepare_sub_to_srt`, destroying ASS positioning, fonts, and typesetting — even when the output container (MKV) natively supports ASS. The `--no-ocr` flag only gated PGS bitmap OCR, not text-format conversion. The function has been renamed to `_prepare_subtitle` and now checks `SUB_PRESERVE_TEXT_FORMAT` and the output container format before deciding whether to convert or stream-copy. The final mux stage (`mux_final`) has been updated from a blanket `-c:s srt` to per-stream codec assignment, so ASS and SRT tracks can coexist in the same output. - **Lossless audio passthrough no longer fails for TrueHD and ALAC codecs.** The audio pipeline's copy path wrote the intermediate file as `audio_primary.${codec}` using the raw ffprobe codec name as the extension. ffmpeg has no muxer registered for `.truehd` or `.alac`, causing a fatal "Unable to choose an output format" error before any data was written. This broke `--profile animation` (which enables `AUDIO_LOSSLESS_PASSTHROUGH=1`) for any source with a TrueHD Atmos track, and `--audio-lossless-passthrough` or `--profile dv-archival` for sources with ALAC audio. The same class of bug also affected `pcm_s16le`/`pcm_s24le`/`pcm_s32le` (no `.pcm_*` muxer) and `dca` (ffprobe name vs ffmpeg's `.dts` muxer). A new `_audio_copy_ext()` helper now maps each codec to a valid ffmpeg muxer extension. The transcode path was not affected (it already reassigns the extension from the target codec). - **Misleading "No Dolby Vision detected" message when DV is disabled by a profile.** Profiles that set `DISABLE_DV=1` (e.g., `animation`, `streaming`, `universal`) caused `detect_dv()` to bail out before probing, then the caller logged "No Dolby Vision detected" — identical to the message shown when a source genuinely lacks DV. For sources that do contain Dolby Vision (e.g., a Netflix 4K HDR rip with DV Profile 7), this was confusing and gave no indication that DV was being intentionally skipped. `detect_dv()` now returns a distinct exit code (2) when detection is skipped due to `DISABLE_DV`. The caller uses the new `_source_has_dv_metadata()` helper to check whether the source actually has DV, and emits one of two messages: a warning with `--dv` override guidance when DV is present but disabled, or a neutral note when the source has no DV and detection was simply unnecessary. ### Changed - `--create-config ... atv-directplay-hq` now emits `LEVEL_VALUE` and `CONSERVATIVE_VBV` as uncommented (active) variables, matching the profile's new defaults. - **Video pipeline no longer copies multi-GB intermediates on non-DV and DV-fallback paths.** Six `cp -f` operations that duplicated the encoded video from `V_BASE` to `V_MIXED` (or `V_INJECTED` to `V_MIXED`) have been replaced with variable reassignment. Downstream consumers (`mux_final`, DV container verification, DV pre-wrap) only read `V_MIXED` and never write to it, so an alias is functionally identical to a file copy. For a typical 2-hour 4K HEVC encode at CRF 17–18, this eliminates 8–25 GB of redundant disk I/O, saves 10–30 seconds of wall-clock time, and halves peak intermediate disk usage. The only user-visible change is that `--keep-temp-always` workdirs will no longer contain a separate `video_mixed` file on non-DV runs. ## [1.0.1] - 2026-03-09 Output file collisions now handled gracefully. Adds new flags `--replace-source` and `--force-replace-source`. ### Fixed - **Source/output collision no longer fatal.** When the derived output path matches the source file (e.g., `muxm movie.mp4` where the default output extension is also `.mp4`), muxm now auto-appends a version number instead of aborting: `movie(1).mp4`, `movie(2).mp4`, etc. The version number increments until a free filename is found. ### Added - **`--replace-source`** — Replace the original source file with the encoded output after an interactive confirmation prompt. Requires a TTY; rejected in non-interactive shells with a clear error directing the user to `--force-replace-source`. - **`--force-replace-source`** — Same as `--replace-source` but skips the confirmation prompt. Designed for scripting and automation. - Both flags registered in `--help`, `--print-effective-config`, tab completions, man page, and `.muxmrc` config generator. - New `collision` test suite in `test_muxm.sh` with 17 assertions covering auto-versioning, sequential incrementing, TTY rejection, in-place replacement, and no-collision passthrough. ### Changed - Existing tests in `test_edge` and `_test_cli_error_codes` updated to expect auto-versioning behavior instead of the previous fatal error. ## [1.0.0] - 2026-03-07 Initial public release. ### Core - Multi-stage encoding pipeline: source inspection → profile resolution → video → audio → subtitles → final mux → verification - Single-pass ffprobe metadata cache for all stream analysis - Layered configuration precedence: hardcoded defaults → `/etc/.muxmrc` → `~/.muxmrc` → `./.muxmrc` → `--profile` → CLI flags - 60+ CLI flags with `--help`, `man muxm`, and bash/zsh tab completion ### Format Profiles - **`dv-archival`** — Lossless Dolby Vision preservation. Copy video if compliant, lossless audio passthrough, skip-if-ideal, JSON reporting - **`hdr10-hq`** — High-quality HDR10 encoding. HEVC CRF 17, strip DV, lossless audio + stereo fallback, MKV - **`atv-directplay-hq`** — Apple TV 4K Direct Play via Plex. MP4, HEVC Main10, DV Profile 8.1 auto-conversion, E-AC-3 + AAC stereo, forced subtitle burn-in - **`streaming`** — Modern HEVC streaming for Plex/Jellyfin/Emby. CRF 20, E-AC-3 448k, AAC stereo, MP4 - **`animation`** — Optimized for anime and cartoons. CRF 16, keeps 10-bit for SDR sources (anti-banding), low psy-rd/psy-rdoq, lossless audio, ASS/SSA subtitle preservation, MKV - **`universal`** — Maximum compatibility. H.264 SDR with HDR tone-mapping, AAC stereo, burned forced subs, external SRT export, MP4 ### Video - Dolby Vision detection via stream metadata and frame-level side data - RPU extraction, profile conversion (P7 dual-layer → P8.1 single-layer), and injection via `dovi_tool` - DV container signaling verification via `MP4Box` - Color space detection (BT.2020 PQ, BT.2020 HLG, BT.709 SDR) with distinct HDR10, HLG, and SDR encoding paths and automatic x265 parameter selection - HDR-to-SDR tone-mapping via zscale + hable - Chroma subsampling normalization (4:2:2/4:4:4 → 4:2:0) for Direct Play compatibility - Video copy-if-compliant to skip re-encoding when source already matches target, with configurable bitrate ceiling to prevent blindly copying oversized streams - Conservative VBV guardrails per x265 level ### Audio - Weighted scoring system for automatic track selection (language, channels, surround bonus, codec preference, commentary penalty) - Configurable scoring weights via `.muxmrc` - Lossless passthrough for TrueHD, DTS-HD MA, and FLAC - Automatic AAC stereo fallback generation from surround sources - E-AC-3 transcoding at profile-specific bitrates (5.1 and 7.1) - Descriptive audio stream titling (e.g., "5.1 Surround (E-AC-3)") ### Subtitles - Track categorization: forced, full, and SDH - PGS bitmap subtitle OCR to SRT via `pgsrip` or `sub2srt` - Forced subtitle burn-in - External `.srt` export - Language preference filtering - SDH track exclusion ### Output & Reporting - MP4, MKV, M4V, and MOV container support - Chapter marker preservation and stripping - Metadata stripping - skip-if-ideal detection (avoids re-processing compliant files) - JSON reporting with full decision/warning/stream-mapping documentation - SHA-256 checksum generation - Dry-run mode (`--dry-run`) for previewing the full pipeline without encoding - Effective config display (`--print-effective-config`) showing resolved settings from all layers ### Setup & Tooling - `--setup` for one-command first-time installation (dependencies + man page + tab completion) - `--install-dependencies` with Homebrew and pipx detection - `--install-man` / `--uninstall-man` for system man page management - `--install-completions` / `--uninstall-completions` for bash/zsh tab completion - `--create-config` / `--force-create-config` for generating pre-seeded `.muxmrc` files - Conflict warnings for contradictory profile + flag combinations - Spinner and progress bar for long-running operations - Quick-test mode (`--skip-video`, `--skip-audio`, `--skip-subs`) for validating pipeline decisions without waiting for a full encode - Disk space preflight warning before encoding begins - Graceful signal handling (Ctrl-C / SIGTERM) with automatic temp file cleanup - Structured exit codes for scripting and automation (10 = missing tool, 11 = bad arguments, 12 = corrupt source, 40–43 = pipeline failures) - Comprehensive test harness (`test_muxm.sh`) with 18 test suites and ~165 assertions [1.5.1]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.5.1 [1.5.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.5.0 [1.4.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.4.0 [1.3.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.3.0 [1.2.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.2.0 [1.1.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.1.0 [1.0.2]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.0.2 [1.0.1]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.0.1 [1.0.0]: https://github.com/TheBluWiz/MuxMaster/releases/tag/v1.0.0