193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
#!/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)
|