/
domahes
/
audio_visualizer_improved
Обзор
Документация
Войти
/
domahes
/
audio_visualizer_improved
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
cli.py
347 строк
16 KB
Евгений Харченко
Optimize visualizer: implement AudioAnalysis and ImageFrames caching, and integrate model enums
02 июн 2026, 11:49
02 июн 2026, 11:49
b7b0145
Код
Авторство
О чём код?
""" CLI module for Audio Visualizer Pro. Extracted in Phase 5: contains argument parser, main dispatch, _tk_runtime_available helper, _expand_inputs (glob support), and the logic that either launches GUI (when no --audio and Tk available) or runs headless render / batch / merge. Re-exports / is imported by the thin audio_visualizer_improved.py shim so that "python audio_visualizer_improved.py ..." and "from audio_visualizer_improved import main, parse_args" continue to work. GUI launch imports AudioVisualizerApp lazily/conditionally from gui module. """ from __future__ import annotations import os import sys import json import argparse import glob import subprocess from typing import List # Flat-layout sibling import support (for direct python cli.py or during early refactor) if __package__ in (None, ""): _here = os.path.dirname(os.path.abspath(__file__)) if _here not in sys.path: sys.path.insert(0, _here) from availability import ( TK_AVAILABLE, tk, RenderCancelled, # not directly used here but for consistency / future ) from models import RenderSettings from pipeline import render_video from batch import render_batch from primitives import STYLE_REGISTRY # for --style choices in parser (str keys) from models import ( VisualStyle, ColorPalette, BackgroundMode, Quality, ClockType, ClockStyle, ) # GUI class (conditional). We import it so main() can reference the name even if TK is absent (it will be None). # When TK is present the class is the real one; otherwise the guard in main() + _tk_runtime_available prevents use. try: from gui import AudioVisualizerApp except Exception: AudioVisualizerApp = None # type: ignore def _tk_runtime_available() -> bool: if not TK_AVAILABLE: return False try: _r = tk.Tk() _r.withdraw() _r.update_idletasks() _r.destroy() return True except Exception: return False def parse_args(argv=None): p = argparse.ArgumentParser(description="Audio Visualizer Pro — CLI/GUI hybrid") p.add_argument('--preset', default=None, help='Path to a preset JSON file to load settings from') p.add_argument('--audio', nargs='+', help='Path(s) to input audio file(s). Globs allowed (quoted).') p.add_argument('--out-dir', default='', help='Output directory (default: alongside first audio)') p.add_argument('--resolution', default='1280x720', help='WIDTHxHEIGHT, e.g. 1920x1080') p.add_argument('--fps', type=int, default=30) p.add_argument('--style', default=VisualStyle.BARS.value, choices=[s.value for s in VisualStyle]) p.add_argument('--palette', default=ColorPalette.RAINBOW.value, choices=[p.value for p in ColorPalette]) p.add_argument('--background', default=BackgroundMode.BLACK.value, choices=[b.value for b in BackgroundMode]) p.add_argument('--quality', default=Quality.HIGH.value, choices=[q.value for q in Quality]) p.add_argument('--glow', action='store_true') p.add_argument('--glow-intensity', type=int, default=3) p.add_argument('--blur', action='store_true') p.add_argument('--blur-amount', type=int, default=5) p.add_argument('--particles', action='store_true') p.add_argument('--particle-count', type=int, default=60) p.add_argument('--mirror', action='store_true') p.add_argument('--no-beat-react', dest='beat_react', action='store_false', help='Disable beat reaction') p.set_defaults(beat_react=True) # Logo image options p.add_argument('--logo-img', dest='logo_img_path', default=None) p.add_argument('--logo-pos', default='tr', choices=['tl','tr','bl','br']) p.add_argument('--logo-scale', type=float, default=1.0) p.add_argument('--logo-opacity', type=float, default=0.8) p.add_argument('--logo-start', type=float, default=0.0) p.add_argument('--logo-end', type=float, default=None) p.add_argument('--logo-follow-progress', action='store_true') p.add_argument('--logo-text', default=None) p.add_argument('--bitrate', default='192k') p.add_argument('--codec', default='h264', choices=['h264','h265','vp9']) p.add_argument('--draft-start', type=float, default=0.0, help='Draft render start (sec)') p.add_argument('--draft-duration', type=float, default=None, help='Draft render duration (sec)') # overlays p.add_argument('--progress-bar', action='store_true', help='Enable video progress bar overlay') p.add_argument('--progress-pos', default='bottom', choices=['top','bottom']) p.add_argument('--progress-height', type=int, default=10) p.add_argument('--progress-countdown', dest='clock_countdown', action='store_true', help='Legacy alias: show reverse countdown next to the clock') p.add_argument('--clock-countdown', dest='clock_countdown', action='store_true', help='Show reverse countdown next to the clock') p.add_argument('--no-clock-countdown', dest='clock_countdown', action='store_false', help='Hide reverse countdown next to the clock') p.set_defaults(clock_countdown=True) p.add_argument('--clock', default=ClockType.DIGITAL.value, choices=[c.value for c in ClockType] + ['none']) p.add_argument('--clock-mode', default='elapsed', choices=['system','elapsed']) p.add_argument('--clock-style', default=ClockStyle.DARK.value, choices=[c.value for c in ClockStyle]) p.add_argument('--clock-pos', default='tr', choices=['tl','tr','bl','br']) p.add_argument('--clock-scale', type=float, default=1.0) # batch p.add_argument('--merge', dest='merge', action='store_true', help='Merge multiple inputs into one output with same settings') p.add_argument('--no-merge', dest='merge', action='store_false', help='Render multiple inputs separately') p.set_defaults(merge=True) p.add_argument('--chapters', dest='chapters', action='store_true', help='Add chapters from file names on merged output') p.add_argument('--no-chapters', dest='chapters', action='store_false') p.set_defaults(chapters=True) p.add_argument('--gap-duration', type=float, default=0.0, help='Gap/slate duration between tracks (seconds). 0 disables') p.add_argument('--gap-slate', dest='gap_slate', action='store_true', help='Show a slate with next track title during the gap') p.add_argument('--no-gap-slate', dest='gap_slate', action='store_false') p.set_defaults(gap_slate=True) p.add_argument('--gap-label-format', default='Next: {title}', help='Label template for slate. Vars: {index} (1-based next track), {title}') p.add_argument('--workers', type=int, default=1, help='Parallel workers for per-file rendering') p.add_argument('--cleanup-intermediates', action='store_true', help='Delete per-track videos after merging') # Track title watermark p.add_argument('--track-title-watermark', dest='track_title_watermark', action='store_true', help='Show track title as watermark at start of each track') p.add_argument('--no-track-title-watermark', dest='track_title_watermark', action='store_false') p.set_defaults(track_title_watermark=True) p.add_argument('--track-title-duration', type=float, default=4.0, help='Duration (seconds) to show track title watermark') p.add_argument('--track-title-pos', default='cc', choices=['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br'], help='Track title watermark position') p.add_argument('--track-title-offset-x', type=int, default=0, help='Track title watermark X offset in pixels') p.add_argument('--track-title-offset-y', type=int, default=0, help='Track title watermark Y offset in pixels') p.add_argument('--track-metadata-watermark', dest='track_metadata_watermark', action='store_true', help='Show track metadata watermark (artist/album/genre/year)') p.add_argument('--no-track-metadata-watermark', dest='track_metadata_watermark', action='store_false') p.set_defaults(track_metadata_watermark=False) p.add_argument('--track-metadata-duration', type=float, default=4.0, help='Track metadata watermark duration (sec). 0 or less = full track') p.add_argument('--track-metadata-pos', default='bl', choices=['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br'], help='Track metadata watermark position') p.add_argument('--track-metadata-offset-x', type=int, default=18, help='Track metadata watermark X offset in pixels') p.add_argument('--track-metadata-offset-y', type=int, default=18, help='Track metadata watermark Y offset in pixels') # Font settings for Unicode/Emoji support p.add_argument('--font', dest='font_path', default=None, help='Path to TrueType font file (.ttf) for Unicode/Emoji support') p.add_argument('--font-size', type=int, default=32, help='Base font size in pixels (default: 32)') # Background image settings p.add_argument('--bg-image', dest='bg_image_path', default=None, help='Background image (PNG/GIF with alpha support)') p.add_argument('--bg-opacity', type=float, default=1.0, help='Background image opacity (0.0-1.0)') p.add_argument('--bg-pattern', action='store_true', help='Tile background image as pattern') # Foreground image settings p.add_argument('--fg-image', dest='fg_image_path', default=None, help='Foreground overlay image (PNG/GIF with alpha support)') p.add_argument('--fg-opacity', type=float, default=1.0, help='Foreground image opacity (0.0-1.0)') p.add_argument('--run-tests', action='store_true', help='Run unit tests and exit') return p.parse_args(argv) def _expand_inputs(inputs: List[str]) -> List[str]: out: List[str] = [] for item in inputs: if any(ch in item for ch in ['*', '?', '[']): out.extend(sorted(glob.glob(item))) else: out.append(item) out = [p for p in out if os.path.isfile(p)] if not out: raise FileNotFoundError("No matching input files found") return out def main(argv=None): args = parse_args(argv) if args.preset: if not os.path.isfile(args.preset): print(f"Error: preset file not found: {args.preset}") sys.exit(2) try: with open(args.preset, 'r', encoding='utf-8') as f: preset_data = json.load(f) settings = preset_data.get("settings", preset_data) for k, v in settings.items(): if k == 'clock_enable': if not v: args.clock = 'none' elif k == 'logo_follow': args.logo_follow_progress = bool(v) elif k == 'bg_image_path': args.bg_image_path = v elif k == 'bg_image_opacity': args.bg_opacity = v elif k == 'bg_image_pattern': args.bg_pattern = v elif k == 'fg_image_path': args.fg_image_path = v elif k == 'fg_image_opacity': args.fg_opacity = v elif hasattr(args, k): default_val = getattr(args, k) if default_val is not None: try: setattr(args, k, type(default_val)(v)) except Exception: setattr(args, k, v) else: setattr(args, k, v) except Exception as e: print(f"Error loading preset JSON: {e}") sys.exit(2) if args.run_tests: import unittest unittest.main(module='audio_visualizer_improved', argv=[sys.argv[0]], exit=False) return # If no audio provided and Tk usable — launch GUI, else headless if (not args.audio) and _tk_runtime_available(): if AudioVisualizerApp is None: print("Error: GUI requested but AudioVisualizerApp is not available (Tkinter missing at import time?)") sys.exit(2) root = tk.Tk() app = AudioVisualizerApp(root) root.mainloop() return if not args.audio: print("Error: --audio is required in headless mode (Tk not available or GUI cannot start)") print("Tip: run with --run-tests to verify environment, or install a desktop Tk (python3-tk) to use the GUI.") sys.exit(2) try: w, h = map(int, args.resolution.lower().split('x')) except Exception: print("Invalid --resolution. Use WIDTHxHEIGHT, e.g. 1280x720") sys.exit(2) clock_val = None if (args.clock == 'none') else args.clock inputs = _expand_inputs(args.audio) base_settings = RenderSettings( audio_path=inputs[0], out_dir=args.out_dir, resolution=(w, h), fps=int(args.fps), style=args.style, palette=args.palette, background=args.background, quality=args.quality, glow=bool(args.glow), glow_intensity=int(args.glow_intensity), blur=bool(args.blur), blur_amount=int(args.blur_amount), particles=bool(args.particles), particle_count=int(args.particle_count), mirror=bool(args.mirror), beat_react=bool(args.beat_react), logo_text=(args.logo_text if args.logo_text else None), logo_img_path=(args.logo_img_path if args.logo_img_path else None), logo_pos=args.logo_pos, logo_scale=float(args.logo_scale), logo_opacity=float(args.logo_opacity), logo_start=float(args.logo_start), logo_end=(float(args.logo_end) if args.logo_end is not None else None), logo_follow_progress=bool(args.logo_follow_progress), bitrate=args.bitrate, codec=args.codec, draft_start=float(args.draft_start), draft_duration=(float(args.draft_duration) if args.draft_duration is not None else None), progress_bar=bool(args.progress_bar), progress_pos=args.progress_pos, progress_height=int(args.progress_height), clock=clock_val, clock_mode=args.clock_mode, clock_style=args.clock_style, clock_pos=args.clock_pos, clock_scale=float(args.clock_scale), clock_countdown=bool(args.clock_countdown), clock_offset_x=int(getattr(args, 'clock_offset_x', 18)), clock_offset_y=int(getattr(args, 'clock_offset_y', 18)), # Track title watermark track_title_watermark=bool(args.track_title_watermark), track_title_duration=float(args.track_title_duration), track_title_pos=str(args.track_title_pos), track_title_offset_x=int(args.track_title_offset_x), track_title_offset_y=int(args.track_title_offset_y), track_metadata_watermark=bool(args.track_metadata_watermark), track_metadata_duration=float(args.track_metadata_duration), track_metadata_pos=str(args.track_metadata_pos), track_metadata_offset_x=int(args.track_metadata_offset_x), track_metadata_offset_y=int(args.track_metadata_offset_y), workers=int(args.workers), cleanup_intermediates=bool(args.cleanup_intermediates), # Font settings for Unicode/Emoji support font_path=args.font_path, font_size=int(args.font_size), # Background/Foreground image settings bg_image_path=(args.bg_image_path if args.bg_image_path else None), bg_image_opacity=float(getattr(args, 'bg_opacity', 1.0)), bg_image_pattern=bool(getattr(args, 'bg_pattern', False)), fg_image_path=(args.fg_image_path if args.fg_image_path else None), fg_image_opacity=float(getattr(args, 'fg_opacity', 1.0)), ) try: if len(inputs) == 1: render_video(base_settings)[0] else: if not args.merge: outs = render_batch(inputs, base_settings, merge=False) for p in outs: print(p) else: final = render_batch(inputs, base_settings, merge=True, add_chapters=bool(args.chapters), gap_duration=float(args.gap_duration), gap_slate=bool(args.gap_slate), gap_label_format=str(args.gap_label_format)) print(final) except subprocess.CalledProcessError as e: print("ffmpeg failed:", e) sys.exit(1) except Exception as e: print(f"Render failed: {e}") sys.exit(1)