Files
swf-convertion/map_interactions.py
2026-03-24 18:11:39 -06:00

309 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Interaction Mapper GUI
A Tkinter-based tool that:
1. Launches a SWF file in Ruffle
2. Shows a transparent overlay with a timer
3. Records every click (timestamp + coordinates) as you interact
4. Saves the interactions to the config file
Run:
python3 map_interactions.py my_animation.swf
"""
import argparse
import json
import logging
import subprocess
import sys
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, simpledialog
from config_manager import ConfigManager
from recorder import find_binary, find_window_id, get_window_geometry, RUFFLE_CANDIDATES
log = logging.getLogger("map_interactions")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
class InteractionMapperGUI:
def __init__(self, swf_path: Path, config_path: str):
self.swf_path = swf_path
self.config_mgr = ConfigManager(config_path)
self.recorded_clicks = []
self.start_time = None
self.emulator_proc = None
self.ruffle_window_id = None
self._running = False
self.root = tk.Tk()
self.root.title(f"Interaction Mapper — {swf_path.name}")
self.root.configure(bg="#1a1a2e")
self.root.resizable(False, False)
self._build_ui()
def _build_ui(self):
root = self.root
PAD = 16
FONT_MONO = ("Courier New", 11)
FONT_LABEL = ("Helvetica", 10)
BG = "#1a1a2e"
FG = "#e2e8f0"
ACCENT = "#7c3aed"
GREEN = "#22c55e"
RED = "#ef4444"
CARD = "#16213e"
root.configure(bg=BG)
# Title
tk.Label(
root, text="⚡ SWF Interaction Mapper", font=("Helvetica", 14, "bold"),
bg=BG, fg=ACCENT
).pack(pady=(PAD, 4))
tk.Label(
root, text=f"File: {self.swf_path.name}", font=FONT_LABEL,
bg=BG, fg="#94a3b8"
).pack()
# Timer display
timer_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=PAD)
timer_frame.pack(fill="x", padx=PAD, pady=(PAD, 0))
tk.Label(timer_frame, text="Elapsed Time", font=FONT_LABEL, bg=CARD, fg="#94a3b8").pack()
self.timer_label = tk.Label(
timer_frame, text="00:00.000", font=("Courier New", 28, "bold"),
bg=CARD, fg=GREEN
)
self.timer_label.pack()
# Instructions
instr_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=8)
instr_frame.pack(fill="x", padx=PAD, pady=4)
instructions = (
"1. Click 'Launch & Start' to open the SWF\n"
"2. Click 'Record Click' each time an interaction fires\n"
"3. Enter coordinates from the Ruffle window\n"
"4. Click 'Save Config' when done"
)
tk.Label(instr_frame, text=instructions, font=("Helvetica", 9), bg=CARD, fg="#94a3b8",
justify="left").pack(anchor="w")
# Recorded clicks list
list_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=PAD)
list_frame.pack(fill="both", expand=True, padx=PAD, pady=4)
tk.Label(list_frame, text="Recorded Interactions", font=("Helvetica", 10, "bold"),
bg=CARD, fg=FG).pack(anchor="w")
self.clicks_listbox = tk.Listbox(
list_frame, font=FONT_MONO, bg="#0f172a", fg=FG,
selectbackground=ACCENT, height=10, width=52,
borderwidth=0, highlightthickness=1, highlightbackground=ACCENT
)
self.clicks_listbox.pack(fill="both", expand=True, pady=4)
# Buttons
btn_frame = tk.Frame(root, bg=BG)
btn_frame.pack(fill="x", padx=PAD, pady=PAD)
def btn(parent, text, cmd, color=ACCENT):
return tk.Button(
parent, text=text, command=cmd,
bg=color, fg="white", font=("Helvetica", 10, "bold"),
relief="flat", padx=12, pady=6, cursor="hand2",
activebackground=color, activeforeground="white",
)
btn(btn_frame, "▶ Launch & Start", self._launch_and_start, GREEN).pack(side="left", padx=2)
btn(btn_frame, "⊕ Record Click", self._record_click_dialog, ACCENT).pack(side="left", padx=2)
btn(btn_frame, "✕ Delete Selected", self._delete_selected, RED).pack(side="left", padx=2)
btn(btn_frame, "💾 Save Config", self._save_config, "#0ea5e9").pack(side="right", padx=2)
self.status_label = tk.Label(
root, text="Ready. Click 'Launch & Start' to begin.",
font=("Helvetica", 9), bg=BG, fg="#94a3b8"
)
self.status_label.pack(pady=(0, PAD))
root.protocol("WM_DELETE_WINDOW", self._on_close)
root.after(100, self._update_timer)
def _launch_and_start(self):
if self._running:
self._set_status("Already running!")
return
binary = find_binary(RUFFLE_CANDIDATES)
if not binary:
messagebox.showerror(
"Ruffle Not Found",
"Ruffle binary not found.\n\n"
"Download from: https://github.com/ruffle-rs/ruffle/releases\n"
"Place at: ~/.local/bin/ruffle"
)
return
self._set_status(f"Launching {self.swf_path.name}...")
self.emulator_proc = subprocess.Popen(
[binary, str(self.swf_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.start_time = time.monotonic()
self._running = True
self._set_status(f"Running. Record clicks as they happen in Ruffle.")
def _record_click_dialog(self):
if not self._running:
self._set_status("Launch the SWF first!")
return
elapsed = time.monotonic() - self.start_time
# Try to auto-detect mouse position in Ruffle window
auto_x, auto_y = None, None
if self.ruffle_window_id is None:
self.ruffle_window_id = find_window_id("Ruffle", timeout=1.0)
if self.ruffle_window_id:
try:
result = subprocess.run(
["xdotool", "getmouselocation", "--shell"],
capture_output=True, text=True, timeout=1.0
)
geo = get_window_geometry(self.ruffle_window_id)
if result.returncode == 0 and geo:
d = {}
for line in result.stdout.splitlines():
if "=" in line:
k, v = line.split("=", 1)
d[k.strip().lower()] = int(v.strip())
auto_x = d.get("x", 0) - geo.get("x", 0)
auto_y = d.get("y", 0) - geo.get("y", 0)
except Exception:
pass
# Dialog
dialog = tk.Toplevel(self.root)
dialog.title("Record Interaction")
dialog.configure(bg="#1a1a2e")
dialog.grab_set()
dialog.resizable(False, False)
tk.Label(dialog, text=f"Time: {elapsed:.2f}s", font=("Courier New", 12, "bold"),
bg="#1a1a2e", fg="#22c55e").pack(pady=(12, 4))
fields = {}
for label, default in [
("X coordinate", str(auto_x or 0)),
("Y coordinate", str(auto_y or 0)),
("Label", f"click_{len(self.recorded_clicks)+1}"),
]:
f = tk.Frame(dialog, bg="#1a1a2e")
f.pack(fill="x", padx=16, pady=2)
tk.Label(f, text=label, width=14, anchor="w", bg="#1a1a2e", fg="#e2e8f0",
font=("Helvetica", 10)).pack(side="left")
e = tk.Entry(f, bg="#0f172a", fg="#e2e8f0", insertbackground="white",
font=("Courier New", 10), width=20)
e.insert(0, default)
e.pack(side="left", padx=4)
fields[label] = e
def confirm():
try:
x = int(fields["X coordinate"].get())
y = int(fields["Y coordinate"].get())
label = fields["Label"].get().strip() or f"click_{len(self.recorded_clicks)+1}"
entry = {"t": round(elapsed, 3), "x": x, "y": y, "label": label}
self.recorded_clicks.append(entry)
self.clicks_listbox.insert(
tk.END,
f" t={entry['t']:7.3f}s x={x:4d} y={y:4d} [{label}]"
)
self._set_status(f"Recorded: {label} at t={elapsed:.2f}s ({x},{y})")
dialog.destroy()
except ValueError:
messagebox.showerror("Invalid Input", "X and Y must be integers.", parent=dialog)
tk.Button(
dialog, text="✓ Confirm", command=confirm,
bg="#7c3aed", fg="white", font=("Helvetica", 10, "bold"),
relief="flat", padx=12, pady=6
).pack(pady=12)
def _delete_selected(self):
sel = self.clicks_listbox.curselection()
if not sel:
return
idx = sel[0]
self.clicks_listbox.delete(idx)
del self.recorded_clicks[idx]
self._set_status(f"Deleted interaction {idx + 1}.")
def _save_config(self):
if not self.recorded_clicks:
if not messagebox.askyesno("No Interactions", "No interactions recorded. Save empty entry?"):
return
for click in self.recorded_clicks:
self.config_mgr.add_interaction(self.swf_path.name, click)
self.config_mgr.save()
self._set_status(f"✓ Saved {len(self.recorded_clicks)} interaction(s) to config.")
messagebox.showinfo(
"Saved",
f"Saved {len(self.recorded_clicks)} interaction(s) to:\n{self.config_mgr.config_path}"
)
def _update_timer(self):
if self._running and self.start_time:
elapsed = time.monotonic() - self.start_time
minutes = int(elapsed // 60)
seconds = elapsed % 60
self.timer_label.config(text=f"{minutes:02d}:{seconds:06.3f}")
self.root.after(50, self._update_timer)
def _set_status(self, msg: str):
self.status_label.config(text=msg)
log.info(msg)
def _on_close(self):
if self.emulator_proc:
try:
self.emulator_proc.terminate()
except Exception:
pass
self.root.destroy()
def run(self):
self.root.mainloop()
def main():
parser = argparse.ArgumentParser(
description="GUI tool for recording SWF interaction points."
)
parser.add_argument("swf", help="SWF file to map interactions for")
parser.add_argument(
"-c", "--config",
default="configs/swf_config.json",
help="Config file to save interactions to",
)
args = parser.parse_args()
swf_path = Path(args.swf)
if not swf_path.exists():
print(f"Error: SWF file not found: {swf_path}", file=sys.stderr)
sys.exit(1)
app = InteractionMapperGUI(swf_path, args.config)
app.run()
if __name__ == "__main__":
main()