initial commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user