#!/usr/bin/env python3 """ Config Manager - Loads, validates, and merges per-SWF configuration. """ import json import logging from pathlib import Path from typing import Optional log = logging.getLogger("config_manager") # Schema documentation for config files CONFIG_SCHEMA = { # Per-file keys (all optional — omit to use global defaults) "emulator": "str: 'ruffle' or 'lightspark'", "loop_detection": "str: 'hash' | 'freeze' | 'none'", "loop_grace": "float: seconds to continue after loop detected (default 5.0)", "max_duration": "float: hard cap in seconds (default 600)", "end_seconds": "float: stop at this timestamp (overrides loop detection)", "end_frame": "int: stop at this frame number (overrides loop detection)", "fps": "int: capture FPS (default 30)", "crf": "int: FFmpeg quality 0–51, lower=better (default 18)", "window_size": "str: e.g. '800x600' fallback if geometry detection fails", "startup_delay": "float: seconds to wait after launching emulator (default 3.0)", "audio": "bool: capture audio (default true)", "interactions": [ { "t": "float: seconds from SWF start to fire click", "x": "int: X coordinate in window", "y": "int: Y coordinate in window", "label": "str: output file suffix (e.g. 'button_yes')", "button": "int: mouse button 1=left 2=mid 3=right (default 1)", "double": "bool: double-click (default false)", } ], } DEFAULT_CONFIG = { "emulator": "ruffle", "loop_detection": "hash", "loop_grace": 5.0, "max_duration": 600.0, "fps": 30, "crf": 18, "window_size": "800x600", "startup_delay": 3.0, "audio": True, "interactions": [], } class ConfigManager: """ Loads a JSON config file mapping SWF filenames to per-file settings. Config file format: { "_defaults": { ... global defaults ... }, "my_animation.swf": { "end_seconds": 42, "interactions": [ { "t": 12.5, "x": 320, "y": 240, "label": "button_yes" } ] }, "menu.swf": { "loop_detection": "none", "end_seconds": 30 } } """ def __init__(self, config_path: str = "configs/swf_config.json"): self.config_path = Path(config_path) self._data: dict = {} self._load() def _load(self): if self.config_path.exists(): try: with open(self.config_path) as f: self._data = json.load(f) log.info(f"Loaded config from {self.config_path}") except json.JSONDecodeError as e: log.error(f"Config JSON parse error: {e}. Using defaults.") self._data = {} else: log.info(f"Config file not found ({self.config_path}). Using defaults for all files.") self._data = {} def get(self, swf_filename: str, global_overrides: Optional[dict] = None) -> dict: """ Return the merged config for a SWF file. Priority (highest first): 1. Per-file config in JSON 2. _defaults section in JSON 3. global_overrides (from CLI args) 4. DEFAULT_CONFIG (hardcoded fallbacks) """ cfg = DEFAULT_CONFIG.copy() if global_overrides: cfg.update({k: v for k, v in global_overrides.items() if v is not None}) file_defaults = self._data.get("_defaults", {}) cfg.update(file_defaults) file_cfg = self._data.get(swf_filename, {}) cfg.update(file_cfg) self._validate(cfg, swf_filename) return cfg def _validate(self, cfg: dict, swf_filename: str): """Log warnings for obviously wrong config values.""" if cfg.get("crf") is not None and not (0 <= cfg["crf"] <= 51): log.warning(f"{swf_filename}: crf={cfg['crf']} is out of range 0–51.") if cfg.get("fps") is not None and not (1 <= cfg["fps"] <= 120): log.warning(f"{swf_filename}: fps={cfg['fps']} is unusual.") for i, interaction in enumerate(cfg.get("interactions", [])): for required in ("t", "x", "y"): if required not in interaction: log.warning( f"{swf_filename}: interaction[{i}] missing required key '{required}'." ) def generate_starter(self, swf_files: list, inspector=None) -> dict: """ Generate a starter config file for the given SWF files. Optionally uses SWFInspector to pre-populate FPS and estimated duration. """ config = { "_defaults": { "loop_detection": "hash", "loop_grace": 5.0, "max_duration": 600.0, "fps": 30, "audio": True, }, "_schema": CONFIG_SCHEMA, } for swf in swf_files: entry = {} if inspector: info = inspector.inspect(swf) if info.get("fps"): entry["fps"] = info["fps"] if info.get("frame_count"): estimated_seconds = round(info["frame_count"] / max(info.get("fps", 24), 1), 1) entry["_estimated_duration_seconds"] = estimated_seconds entry["_comment"] = ( f"SWF: {info.get('frame_count')} frames @ {info.get('fps')} fps " f"≈ {estimated_seconds}s. Set end_seconds to control stop time." ) entry["interactions"] = [ { "_comment": "Add click interactions here. Example:", "t": 10.0, "x": 400, "y": 300, "label": "example_click", } ] config[swf.name] = entry self.config_path.parent.mkdir(parents=True, exist_ok=True) with open(self.config_path, "w") as f: json.dump(config, f, indent=2) log.info(f"Starter config written to {self.config_path}") return config def save(self): with open(self.config_path, "w") as f: json.dump(self._data, f, indent=2) log.info(f"Config saved to {self.config_path}") def set_file_config(self, swf_filename: str, key: str, value): """Programmatically update a single config key for a SWF file.""" if swf_filename not in self._data: self._data[swf_filename] = {} self._data[swf_filename][key] = value def add_interaction(self, swf_filename: str, interaction: dict): """Append an interaction entry for a SWF file.""" if swf_filename not in self._data: self._data[swf_filename] = {} if "interactions" not in self._data[swf_filename]: self._data[swf_filename]["interactions"] = [] self._data[swf_filename]["interactions"].append(interaction)