Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from pathlib import Path
from typing import Any, Iterable

import presets


SUPPORTED_EXTENSIONS = {
".3gp",
Expand Down Expand Up @@ -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(
Expand Down
112 changes: 112 additions & 0 deletions presets.py
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions tests/test_media_shrinker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import argparse
import json
import os
import tempfile
Expand All @@ -6,6 +7,7 @@
from pathlib import Path

import media_shrinker
import presets
from media_shrinker import (
ConversionPlan,
MediaSegment,
Expand Down Expand Up @@ -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()

Expand Down
Loading