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
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
Recorder - Launches Ruffle, captures the window with FFmpeg, injects clicks,
and stops recording based on loop detection or configured duration.
"""
import logging
import os
import signal
import subprocess
import threading
import time
import tempfile
from pathlib import Path
from typing import List, Optional
from loop_detector import LoopDetector, StillnessDetector
from interaction import InteractionController
log = logging.getLogger("recorder")
# Default Ruffle binary locations to try (in order)
RUFFLE_CANDIDATES = [
"ruffle",
os.path.expanduser("~/.local/bin/ruffle"),
"/usr/local/bin/ruffle",
"/opt/ruffle/ruffle",
os.path.expanduser("~/ruffle"),
]
# Default Lightspark binary (fallback emulator)
LIGHTSPARK_CANDIDATES = [
"lightspark",
"/usr/local/bin/lightspark",
]
def find_binary(candidates: list) -> Optional[str]:
for c in candidates:
try:
result = subprocess.run(["which", c], capture_output=True, text=True)
if result.returncode == 0:
return c
except Exception:
pass
if os.path.isfile(c) and os.access(c, os.X_OK):
return c
return None
def find_window_id(title_fragment: str, timeout: float = 15.0) -> Optional[str]:
"""Poll xdotool until a window with the given title fragment appears."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
result = subprocess.run(
["xdotool", "search", "--name", title_fragment],
capture_output=True,
text=True,
timeout=2.0,
)
if result.returncode == 0 and result.stdout.strip():
wid = result.stdout.strip().splitlines()[0]
log.info(f"Found window '{title_fragment}': ID={wid}")
return wid
except Exception:
pass
time.sleep(0.5)
log.error(f"Window '{title_fragment}' not found within {timeout}s.")
return None
def get_window_geometry(window_id: str) -> Optional[dict]:
"""Return {'x': int, 'y': int, 'width': int, 'height': int} for a window."""
try:
result = subprocess.run(
["xdotool", "getwindowgeometry", "--shell", window_id],
capture_output=True, text=True, timeout=3.0,
)
if result.returncode == 0:
geo = {}
for line in result.stdout.splitlines():
if "=" in line:
k, v = line.split("=", 1)
geo[k.strip().lower()] = int(v.strip())
return geo
except Exception as e:
log.debug(f"getwindowgeometry failed: {e}")
return None
class Recorder:
"""
Manages a single SWF→MP4 conversion pass.
One Recorder is created per SWF file. `.record()` can be called multiple
times (once per interaction branch).
"""
def __init__(self, swf_path: Path, cfg: dict):
self.swf_path = swf_path
self.cfg = cfg
# Config values with sensible defaults
self.loop_grace = cfg.get("loop_grace", 5.0)
self.max_duration = cfg.get("max_duration", 600.0)
self.fps = cfg.get("fps", 30)
self.crf = cfg.get("crf", 18) # FFmpeg quality (lower = better)
self.window_size = cfg.get("window_size", "800x600")
self.emulator = cfg.get("emulator", "ruffle") # "ruffle" or "lightspark"
self.end_seconds = cfg.get("end_seconds") # Hard stop time, if known
self.end_frame = cfg.get("end_frame") # Hard stop frame, if known
self.loop_detection = cfg.get("loop_detection", "hash") # "hash", "freeze", or "none"
self.startup_delay = cfg.get("startup_delay", 3.0) # Wait after launch before recording
self.audio = cfg.get("audio", True)
def record(self, output_path: Path, clicks: list) -> bool:
"""
Run one recording pass.
Parameters
----------
output_path : Path
Where to save the resulting MP4.
clicks : list
List of click dicts {t, x, y, label, ...} to inject.
Returns
-------
bool
True on success.
"""
log.info(f"Starting recording pass: {output_path.name}")
output_path.parent.mkdir(parents=True, exist_ok=True)
# If using freeze detection, we record raw first then post-process
use_freeze_postprocess = (
self.loop_detection == "freeze"
and self.end_seconds is None
and self.end_frame is None
)
raw_path = output_path
if use_freeze_postprocess:
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
raw_path = Path(tmp.name)
tmp.close()
try:
return self._do_record(raw_path, output_path, clicks, use_freeze_postprocess)
except Exception as e:
log.exception(f"Recording failed: {e}")
return False
def _do_record(
self,
raw_path: Path,
final_path: Path,
clicks: list,
use_freeze_postprocess: bool,
) -> bool:
# --- Find emulator ---
if self.emulator == "lightspark":
binary = find_binary(LIGHTSPARK_CANDIDATES)
if not binary:
log.error("Lightspark not found. Falling back to Ruffle.")
binary = find_binary(RUFFLE_CANDIDATES)
else:
binary = find_binary(RUFFLE_CANDIDATES)
if not binary:
log.error(
"No Flash emulator found. Install Ruffle:\n"
" https://github.com/ruffle-rs/ruffle/releases\n"
" Place the binary at ~/.local/bin/ruffle"
)
return False
# --- Launch emulator ---
env = os.environ.copy()
env["DISPLAY"] = env.get("DISPLAY", ":0")
emulator_cmd = [binary, str(self.swf_path)]
log.info(f"Launching: {' '.join(emulator_cmd)}")
emulator_proc = subprocess.Popen(
emulator_cmd,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# --- Find window ---
window_id = find_window_id("Ruffle", timeout=15.0)
if not window_id:
window_id = find_window_id(self.swf_path.stem, timeout=5.0)
if not window_id:
log.error("Could not find emulator window. Is a display available?")
emulator_proc.kill()
return False
# --- Wait for startup ---
log.info(f"Waiting {self.startup_delay}s for emulator startup...")
time.sleep(self.startup_delay)
# Get window geometry for FFmpeg
geo = get_window_geometry(window_id)
if geo:
capture_size = f"{geo['width']}x{geo['height']}"
capture_offset = f"{geo['x']},{geo['y']}"
else:
capture_size = self.window_size
capture_offset = "0,0"
# --- Start FFmpeg recording ---
display = env.get("DISPLAY", ":0")
ffmpeg_cmd = self._build_ffmpeg_cmd(
display=display,
capture_size=capture_size,
capture_offset=capture_offset,
output_path=raw_path,
)
log.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
ffmpeg_proc = subprocess.Popen(
ffmpeg_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
recording_start = time.monotonic()
# --- Start click injector ---
interaction_ctrl = None
if clicks:
interaction_ctrl = InteractionController(window_id=window_id, clicks=clicks)
interaction_ctrl.start(recording_start_time=recording_start)
# --- Start loop detector (hash-based) ---
loop_detector = None
stop_event = threading.Event()
if self.loop_detection == "hash" and self.end_seconds is None:
loop_detector = LoopDetector(window_id=window_id)
loop_thread = threading.Thread(
target=loop_detector.start, daemon=True
)
loop_thread.start()
# --- Monitor recording ---
stop_reason = self._monitor_recording(
emulator_proc=emulator_proc,
ffmpeg_proc=ffmpeg_proc,
loop_detector=loop_detector,
recording_start=recording_start,
)
# --- Stop everything ---
log.info(f"Stopping recording. Reason: {stop_reason}")
if loop_detector:
loop_detector.stop()
if interaction_ctrl:
interaction_ctrl.stop()
# Send 'q' to FFmpeg to gracefully finalize the MP4
try:
ffmpeg_proc.stdin.write(b"q")
ffmpeg_proc.stdin.flush()
ffmpeg_proc.wait(timeout=10)
except Exception:
ffmpeg_proc.kill()
# Kill emulator
try:
emulator_proc.terminate()
emulator_proc.wait(timeout=5)
except Exception:
emulator_proc.kill()
# --- Post-process: freeze detection trim ---
if use_freeze_postprocess and raw_path.exists():
log.info("Running freeze detection post-process...")
freeze_t = StillnessDetector.find_freeze_start(
str(raw_path),
min_duration=3.0,
)
if freeze_t:
StillnessDetector.trim_at_freeze(
str(raw_path),
str(final_path),
freeze_t,
grace=self.loop_grace,
)
raw_path.unlink(missing_ok=True)
else:
log.info("No freeze detected; using full recording.")
raw_path.rename(final_path)
elif not use_freeze_postprocess and raw_path != final_path:
raw_path.rename(final_path)
success = final_path.exists() and final_path.stat().st_size > 0
if success:
duration = time.monotonic() - recording_start
log.info(f"Recording complete: {final_path} ({duration:.1f}s recorded)")
else:
log.error(f"Output file missing or empty: {final_path}")
return success
def _monitor_recording(
self,
emulator_proc,
ffmpeg_proc,
loop_detector: Optional[LoopDetector],
recording_start: float,
) -> str:
"""
Block until we decide to stop recording. Returns a string describing why.
"""
loop_grace_remaining = self.loop_grace
loop_stop_at = None
while True:
elapsed = time.monotonic() - recording_start
# Hard stop: configured end_seconds
if self.end_seconds is not None and elapsed >= self.end_seconds:
return f"end_seconds={self.end_seconds}"
# Hard stop: max_duration
if elapsed >= self.max_duration:
return f"max_duration={self.max_duration}"
# Emulator crashed
if emulator_proc.poll() is not None:
return "emulator_exited"
# FFmpeg crashed
if ffmpeg_proc.poll() is not None:
return "ffmpeg_exited"
# Loop detection
if loop_detector and loop_detector.loop_detected():
if loop_stop_at is None:
loop_stop_at = time.monotonic() + self.loop_grace
log.info(f"Loop confirmed; will stop in {self.loop_grace}s at t={elapsed + self.loop_grace:.1f}s")
if time.monotonic() >= loop_stop_at:
return f"loop_detected+{self.loop_grace}s_grace"
time.sleep(0.2)
def _build_ffmpeg_cmd(
self,
display: str,
capture_size: str,
capture_offset: str,
output_path: Path,
) -> list:
"""Build the FFmpeg command for x11grab capture."""
cmd = [
"ffmpeg", "-y",
# Input: X11 screen capture
"-f", "x11grab",
"-framerate", str(self.fps),
"-video_size", capture_size,
"-i", f"{display}+{capture_offset}",
]
if self.audio:
cmd += [
# Input: PulseAudio capture
"-f", "pulse",
"-i", "default",
]
cmd += [
# Video encoding
"-c:v", "libx264",
"-preset", "fast",
"-crf", str(self.crf),
"-pix_fmt", "yuv420p",
]
if self.audio:
cmd += ["-c:a", "aac", "-b:a", "128k"]
else:
cmd += ["-an"]
cmd += [str(output_path)]
return cmd