initial commit

This commit is contained in:
2026-03-24 18:11:39 -06:00
commit 352c8e0b73
11 changed files with 2576 additions and 0 deletions
+192
View File
@@ -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 051, 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 051.")
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)