initial commit
This commit is contained in:
+390
@@ -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
|
||||
Reference in New Issue
Block a user