From e3ed9e023e449d9d51e854784a386e31866c090e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 09:13:25 +0900 Subject: [PATCH] feat: add --preset scenario bundles for one-flag onboarding Add `--preset {voice,podcast,music,archive}` that applies sensible bundled defaults over the real tunable flags so non-expert users get good results with a single flag. New presets.py maps each scenario to overrides over the actual argparse dests (target_bytes, flac_all, silence_noise, silence_min_duration_seconds, max_duration_seconds): - voice: Opus + aggressive silence trimming (-30dB, 0.5s) - podcast: balanced Opus (-35dB, 1.0s) - music: --flac-all + gentler silence (-45dB, 3.0s) - archive: --flac-all + larger target (1.95GB) + conservative silence Precedence: explicit CLI flags always win over the preset; with no --preset, output is byte-identical to today. Implemented by seeding the argparse namespace with a sentinel for each preset-tunable dest so "unset" is distinguishable from "set to the default value". Tests cover each preset's overrides, explicit-flag precedence (including store_true and value flags), unchanged defaults with no preset, unknown preset rejection, and presets.py logic in isolation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CJRVbDrp1vGYkJgNHMGPpG --- media_shrinker.py | 25 +++++++- presets.py | 112 ++++++++++++++++++++++++++++++++++ tests/test_media_shrinker.py | 114 +++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 presets.py diff --git a/media_shrinker.py b/media_shrinker.py index d72a8d5..2488213 100644 --- a/media_shrinker.py +++ b/media_shrinker.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any, Iterable +import presets + SUPPORTED_EXTENSIONS = { ".3gp", @@ -1247,7 +1249,28 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Allow overwriting generated output paths", ) - return parser.parse_args(_normalize_argv(argv)) + parser.add_argument( + "--preset", + choices=presets.preset_names(), + default=None, + help=( + "Apply bundled defaults for a scenario. Explicit flags always win " + "over the preset." + ), + ) + + # Capture the tool's built-in defaults, then seed the namespace with a + # sentinel for every preset-tunable option so argparse leaves it untouched + # unless the user passes the flag. That lets apply_preset tell "unset" apart + # from "set to the default value" and give explicit flags precedence. + real_defaults = { + dest: parser.get_default(dest) for dest in presets.PRESET_TUNABLE_DESTS + } + namespace = argparse.Namespace() + for dest in presets.PRESET_TUNABLE_DESTS: + setattr(namespace, dest, presets._UNSET) + args = parser.parse_args(_normalize_argv(argv), namespace) + return presets.apply_preset(args, real_defaults) def _execute_conversions( diff --git a/presets.py b/presets.py new file mode 100644 index 0000000..f58c580 --- /dev/null +++ b/presets.py @@ -0,0 +1,112 @@ +"""Scenario presets for :mod:`media_shrinker`. + +A *preset* bundles sensible defaults for a common recording scenario so that a +non-expert user gets good results with a single ``--preset`` flag instead of +tuning a handful of low-level ffmpeg knobs by hand. + +Every value here is expressed as an override over the *real* command-line +options exposed by :func:`media_shrinker.parse_args`; the keys below are the +argparse ``dest`` names (``target_bytes``, ``flac_all``, ``silence_noise``, +``silence_min_duration_seconds``, ``max_duration_seconds``). A preset only +lists the options it wants to change -- anything it omits falls back to the +tool's built-in default, and any option the user sets explicitly on the command +line always wins over the preset (see :func:`apply_preset`). + +Preset design rationale (grounded in the tool's real defaults: +``silence_noise="-35dB"``, ``silence_min_duration_seconds=2.0``, +``flac_all=False``, ``target_bytes=1_900_000_000``): + +* ``voice`` -- speech, lectures, meetings, voice memos. Keeps the default Opus + path (``flac_all`` stays ``False``) and trims aggressively: a higher (less + negative) silence threshold and a short minimum silence duration make brief + pauses count as split boundaries. +* ``podcast`` -- conversational, edited audio. Balanced Opus settings that sit + between ``voice`` and ``music``: the default silence threshold with a moderate + minimum silence duration. +* ``music`` -- lossless-leaning audio where quiet passages matter. Enables + ``flac_all`` and uses a gentler (more negative) silence threshold with a + longer minimum duration so soft musical passages are never mistaken for + silence. +* ``archive`` -- maximum-fidelity long-term storage. Enables ``flac_all``, + raises ``target_bytes`` closer to the 2 GB size limit for more headroom, and + uses the most conservative silence detection so almost nothing is treated as a + split boundary. +""" + +from __future__ import annotations + +import argparse + +# Sentinel marking a preset-tunable option that the user did NOT set on the +# command line. ``parse_args`` pre-seeds the namespace with this value so that +# argparse leaves it untouched unless the user explicitly passes the flag; that +# lets :func:`apply_preset` distinguish "user did not set" from "user set it to +# the default value". +_UNSET = object() + +# argparse ``dest`` names that a preset is allowed to override. Kept in sync +# with the corresponding ``add_argument`` calls in ``media_shrinker.parse_args``. +PRESET_TUNABLE_DESTS: tuple[str, ...] = ( + "target_bytes", + "max_duration_seconds", + "flac_all", + "silence_noise", + "silence_min_duration_seconds", +) + +# Mapping of preset name -> {dest: override_value}. Only the options a preset +# intends to change are listed; omitted options fall back to the real default. +PRESETS: dict[str, dict[str, object]] = { + "voice": { + "flac_all": False, + "silence_noise": "-30dB", + "silence_min_duration_seconds": 0.5, + }, + "podcast": { + "flac_all": False, + "silence_noise": "-35dB", + "silence_min_duration_seconds": 1.0, + }, + "music": { + "flac_all": True, + "silence_noise": "-45dB", + "silence_min_duration_seconds": 3.0, + }, + "archive": { + "flac_all": True, + "target_bytes": 1_950_000_000, + "silence_noise": "-50dB", + "silence_min_duration_seconds": 4.0, + }, +} + + +def preset_names() -> tuple[str, ...]: + """Return the available preset names, in definition order.""" + return tuple(PRESETS) + + +def apply_preset( + args: argparse.Namespace, real_defaults: dict[str, object] +) -> argparse.Namespace: + """Resolve preset-tunable options on ``args`` in place and return it. + + ``args`` is expected to carry :data:`_UNSET` for every dest in + :data:`PRESET_TUNABLE_DESTS` that the user did not pass explicitly (see the + sentinel seeding in ``media_shrinker.parse_args``). ``real_defaults`` maps + each of those dests to the tool's built-in default value. + + Precedence, highest first: + + 1. A value the user set explicitly on the command line (not :data:`_UNSET`). + 2. A value supplied by ``args.preset`` (when a preset is selected). + 3. The tool's built-in default from ``real_defaults``. + + With no preset selected this is byte-identical to plain argparse defaults: + every unset dest simply receives its real default. + """ + overrides = PRESETS[args.preset] if getattr(args, "preset", None) else {} + for dest, default_value in real_defaults.items(): + if getattr(args, dest, _UNSET) is _UNSET: + setattr(args, dest, overrides.get(dest, default_value)) + return args diff --git a/tests/test_media_shrinker.py b/tests/test_media_shrinker.py index 0ab2397..bc21ec6 100644 --- a/tests/test_media_shrinker.py +++ b/tests/test_media_shrinker.py @@ -1,3 +1,4 @@ +import argparse import json import os import tempfile @@ -6,6 +7,7 @@ from pathlib import Path import media_shrinker +import presets from media_shrinker import ( ConversionPlan, MediaSegment, @@ -1700,6 +1702,118 @@ def test_attribute_copy_helpers_ignore_platform_errors(self) -> None: media_shrinker._copy_macos_creation_time(mock_stat, dest, "SetFile") mock_run.assert_called_once() +class PresetTests(unittest.TestCase): + """Scenario preset CLI wiring and precedence.""" + + def test_music_preset_applies_bundled_overrides(self) -> None: + """`--preset music` fills flac_all and gentler silence from the preset.""" + args = media_shrinker.parse_args(["root", "--preset", "music"]) + self.assertTrue(args.flac_all) + self.assertEqual(args.silence_noise, "-45dB") + self.assertEqual(args.silence_min_duration_seconds, 3.0) + # target_bytes is not overridden by music -> stays the real default. + self.assertEqual(args.target_bytes, media_shrinker.DEFAULT_TARGET_BYTES) + + def test_archive_preset_raises_target_and_prefers_flac(self) -> None: + """`--preset archive` bumps target_bytes and enables flac_all.""" + args = media_shrinker.parse_args(["root", "--preset", "archive"]) + self.assertTrue(args.flac_all) + self.assertEqual(args.target_bytes, 1_950_000_000) + self.assertEqual(args.silence_noise, "-50dB") + + def test_voice_preset_keeps_opus_and_trims_aggressively(self) -> None: + """`--preset voice` keeps Opus (flac_all False) with aggressive silence.""" + args = media_shrinker.parse_args(["root", "--preset", "voice"]) + self.assertFalse(args.flac_all) + self.assertEqual(args.silence_noise, "-30dB") + self.assertEqual(args.silence_min_duration_seconds, 0.5) + + def test_explicit_flag_overrides_preset(self) -> None: + """A value the user sets on the CLI wins over the preset override.""" + args = media_shrinker.parse_args( + ["root", "--preset", "music", "--silence-noise", "-99dB"] + ) + self.assertEqual(args.silence_noise, "-99dB") + # Unspecified options still come from the preset. + self.assertTrue(args.flac_all) + + def test_explicit_store_true_flag_overrides_preset(self) -> None: + """`--flac-all` set explicitly is honored even for a preset that omits it.""" + args = media_shrinker.parse_args(["root", "--preset", "voice", "--flac-all"]) + self.assertTrue(args.flac_all) + + def test_explicit_target_bytes_overrides_archive_preset(self) -> None: + """A user-set --target-bytes beats the archive preset's larger value.""" + args = media_shrinker.parse_args( + ["root", "--preset", "archive", "--target-bytes", "5"] + ) + self.assertEqual(args.target_bytes, 5) + + def test_no_preset_leaves_defaults_unchanged(self) -> None: + """Without --preset every tunable option equals the built-in default.""" + args = media_shrinker.parse_args(["root"]) + self.assertIsNone(args.preset) + self.assertFalse(args.flac_all) + self.assertEqual(args.silence_noise, media_shrinker.DEFAULT_SILENCE_NOISE) + self.assertEqual( + args.silence_min_duration_seconds, + media_shrinker.DEFAULT_SILENCE_MIN_DURATION_SECONDS, + ) + self.assertEqual(args.target_bytes, media_shrinker.DEFAULT_TARGET_BYTES) + self.assertEqual( + args.max_duration_seconds, + media_shrinker.DEFAULT_MAX_SEGMENT_DURATION_SECONDS, + ) + + def test_unknown_preset_is_rejected(self) -> None: + """An unknown preset name exits via argparse (SystemExit).""" + with self.assertRaises(SystemExit): + media_shrinker.parse_args(["root", "--preset", "bogus"]) + + +class PresetModuleTests(unittest.TestCase): + """Unit tests for the standalone presets module logic.""" + + def test_preset_names_matches_registry(self) -> None: + """preset_names reports every registered preset in definition order.""" + self.assertEqual( + presets.preset_names(), ("voice", "podcast", "music", "archive") + ) + + def test_apply_preset_without_preset_restores_real_defaults(self) -> None: + """No preset: every unset dest receives its real default.""" + args = argparse.Namespace(preset=None) + for dest in presets.PRESET_TUNABLE_DESTS: + setattr(args, dest, presets._UNSET) + defaults = {dest: f"D_{dest}" for dest in presets.PRESET_TUNABLE_DESTS} + presets.apply_preset(args, defaults) + for dest, value in defaults.items(): + self.assertEqual(getattr(args, dest), value) + + def test_apply_preset_override_beats_default_but_not_explicit(self) -> None: + """Preset override applies to unset dests; explicit values are untouched.""" + args = argparse.Namespace(preset="music") + # flac_all explicitly set by the user -> must be preserved. + args.flac_all = False + args.silence_noise = presets._UNSET + args.silence_min_duration_seconds = presets._UNSET + args.target_bytes = presets._UNSET + args.max_duration_seconds = presets._UNSET + defaults = {dest: None for dest in presets.PRESET_TUNABLE_DESTS} + presets.apply_preset(args, defaults) + self.assertFalse(args.flac_all) # explicit user value survives + self.assertEqual(args.silence_noise, "-45dB") # from music preset + self.assertIsNone(args.target_bytes) # not in preset -> default + + def test_apply_preset_returns_same_namespace(self) -> None: + """apply_preset mutates and returns the same namespace object.""" + args = argparse.Namespace(preset=None) + for dest in presets.PRESET_TUNABLE_DESTS: + setattr(args, dest, presets._UNSET) + result = presets.apply_preset(args, {d: 0 for d in presets.PRESET_TUNABLE_DESTS}) + self.assertIs(result, args) + + if __name__ == "__main__": unittest.main()