Labia69Licker Here you go!
The assumption is that you can use Python
Tested with MultiFunPlayer.
funscript_v1.0_to_v1.1_converter.py
"""
Multi-Axis Funscript Converter (v1.0 -> v1.1)
=====================================================
Drag & Drop dependency:
- Uses tkinterdnd2 if available. If not installed, app still works without drag&drop.
Install Drag & Drop support: pip install tkinterdnd2
"""
from __future__ import annotations
import copy
import json
import os
import re
import tkinter as tk
from tkinter import filedialog, messagebox
from typing import Any, Dict, List, Optional, Set, Tuple
# Optional Drag & Drop support #
DND_AVAILABLE = False
DND_FILES = None
TkBase = tk.Tk # fallback
try:
from tkinterdnd2 import DND_FILES as _DND_FILES
from tkinterdnd2 import TkinterDnD as _TkinterDnD
DND_FILES = _DND_FILES
TkBase = _TkinterDnD.Tk # type: ignore
DND_AVAILABLE = True
except Exception:
DND_AVAILABLE = False
# Constants #
DEFAULT_AXES = ["L0: Stroke", "L1: Surge", "L2: Sway", "R0: Twist", "R1: Roll", "R2: Pitch"]
AXIS_KEYWORDS: Dict[str, str] = {
"stroke": "L0",
"surge": "L1",
"sway": "L2",
"twist": "R0",
"roll": "R1",
"pitch": "R2",
}
AXIS_ORDER = ["L0", "L1", "L2", "R0", "R1", "R2"]
BASE_AXIS_ID = "L0"
# Helper functions #
def axis_id_from_label(label: str) -> str:
"""'L0: Stroke' -> 'L0' ; 'R2: Pitch' -> 'R2' ; 'L0' -> 'L0'"""
if not isinstance(label, str):
return ""
parts = label.split(":", 1)
return parts[0].strip() if parts else label.strip()
def file_basename(path: str) -> str:
return os.path.basename(path or "")
def normalize_drop_event_data(data: str) -> List[str]:
"""
tkinterdnd2 may give:
- '{C:/path one.funscript} {C:/path two.funscript}'
- 'C:/path.funscript'
This parses both.
"""
if not data:
return []
matches = re.findall(r"\{([^}]*)\}", data)
if matches:
return [m.strip() for m in matches if m.strip()]
return [p.strip() for p in data.split() if p.strip()]
def is_funscript_path(path: str) -> bool:
lower = path.lower()
return lower.endswith(".funscript") or lower.endswith(".json")
def axis_keywords_in_filename(path: str) -> Set[str]:
name = file_basename(path).lower()
name = re.sub(r"\.(funscript|json)$", "", name)
hits: Set[str] = set()
for kw in AXIS_KEYWORDS.keys():
if re.search(rf"(^|[^a-z0-9]){re.escape(kw)}([^a-z0-9]|$)", name):
hits.add(kw)
else:
if kw in name:
hits.add(kw)
return hits
def classify_filename_axis(path: str) -> Tuple[str, Optional[str]]:
hits = axis_keywords_in_filename(path)
if not hits:
return ("UNSPECIFIED", "L0")
axis_ids = {AXIS_KEYWORDS[h] for h in hits}
if "stroke" in hits and len(hits) > 1:
return ("AMBIGUOUS", None)
if len(axis_ids) > 1:
return ("AMBIGUOUS", None)
axis_id = next(iter(axis_ids))
return ("OK", axis_id)
def axis_sort_key(aid: str) -> Tuple[int, str]:
if aid.startswith("L"):
return (0, aid)
if aid.startswith("R"):
return (1, aid)
return (2, aid)
# Logic functions #
def _is_funscript_like(obj: Any) -> bool:
return isinstance(obj, dict) and isinstance(obj.get("actions"), list)
def _validate_actions(actions: Any) -> Tuple[bool, str]:
if not isinstance(actions, list):
return False, "actions is not a list"
for i, a in enumerate(actions[:200]):
if not isinstance(a, dict):
return False, f"actions[{i}] is not an object"
if "at" not in a or "pos" not in a:
return False, f"actions[{i}] missing 'at' or 'pos'"
if not isinstance(a["at"], (int, float)) or not isinstance(a["pos"], (int, float)):
return False, f"actions[{i}] 'at'/'pos' must be numbers"
return True, ""
def load_funscript(path: str) -> Dict[str, Any]:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
raise ValueError(f"Failed to read JSON: {e}") from e
if not _is_funscript_like(data):
raise ValueError("Not a funscript-like JSON (missing root 'actions' list).")
ok, msg = _validate_actions(data.get("actions"))
if not ok:
raise ValueError(f"Invalid funscript actions: {msg}")
return data
def flatten_multi_axis(script: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]:
base = copy.deepcopy(script)
extracted: Dict[str, Dict[str, Any]] = {}
if isinstance(base.get("axes"), list):
for item in base["axes"]:
if not isinstance(item, dict):
continue
axis_id = item.get("id")
if not isinstance(axis_id, str) or not axis_id.strip():
continue
if not isinstance(item.get("actions"), list):
continue
ok, _ = _validate_actions(item.get("actions"))
if not ok:
continue
extracted[axis_id] = {
"metadata": item.get("metadata", {}) if isinstance(item.get("metadata"), dict) else {},
"actions": item.get("actions", []),
}
del base["axes"]
for key in ("tracks", "channels"):
if isinstance(base.get(key), dict):
for axis_id, sub in base[key].items():
if not isinstance(axis_id, str) or not axis_id.strip():
continue
if not isinstance(sub, dict):
continue
if not isinstance(sub.get("actions"), list):
continue
ok, _ = _validate_actions(sub.get("actions"))
if not ok:
continue
extracted[axis_id] = {
"metadata": sub.get("metadata", {}) if isinstance(sub.get("metadata"), dict) else {},
"actions": sub.get("actions", []),
}
del base[key]
return base, extracted
def merge_slr_compact_always_l0(
base_script: Dict[str, Any],
extra_axes: Dict[str, Dict[str, Any]],
) -> Dict[str, Any]:
base_clean, base_extracted = flatten_multi_axis(base_script)
merged = copy.deepcopy(base_clean)
if "metadata" not in merged or not isinstance(merged.get("metadata"), dict):
merged["metadata"] = {}
if "actions" not in merged or not isinstance(merged.get("actions"), list):
merged["actions"] = []
merged["version"] = "1.1" # critical
root_meta = merged.get("metadata", {}) if isinstance(merged.get("metadata"), dict) else {}
axis_map: Dict[str, Dict[str, Any]] = {}
axis_map.update(base_extracted)
for axis_id, script in extra_axes.items():
clean, extracted = flatten_multi_axis(script)
for k, v in extracted.items():
axis_map[k] = v
meta = clean.get("metadata", {}) if isinstance(clean.get("metadata"), dict) else {}
actions = clean.get("actions", []) if isinstance(clean.get("actions"), list) else []
ok, _ = _validate_actions(actions)
if ok:
axis_map[axis_id] = {"metadata": meta, "actions": actions}
axis_map.pop(BASE_AXIS_ID, None) # never duplicate L0
def compact_axis_metadata(axis_meta: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for k in ("title", "duration", "durationTime"):
if k in axis_meta:
out[k] = axis_meta[k]
elif k in root_meta:
out[k] = root_meta[k]
return out
axes_list = []
for axis_id in sorted(axis_map.keys(), key=axis_sort_key):
axis_obj = axis_map[axis_id]
axes_list.append({
"id": axis_id,
"metadata": compact_axis_metadata(
axis_obj.get("metadata", {}) if isinstance(axis_obj.get("metadata"), dict) else {}
),
"actions": axis_obj.get("actions", []) if isinstance(axis_obj.get("actions"), list) else [],
})
if axes_list:
merged["axes"] = axes_list
else:
merged.pop("axes", None)
return merged
# UI #
class AxisRow:
def __init__(self, parent: tk.Frame, axis_label: str, on_change_cb):
self.axis_label = axis_label
self.axis_id = axis_id_from_label(axis_label)
self.path: Optional[str] = None
self.on_change_cb = on_change_cb
self.frame = tk.Frame(parent)
self.frame.pack(fill="x", pady=2)
self.lbl_axis = tk.Label(self.frame, text=self.axis_label, width=10, anchor="w")
self.lbl_axis.pack(side="left")
self.lbl_path = tk.Label(self.frame, text="(no file)", anchor="w")
self.lbl_path.pack(side="left", fill="x", expand=True, padx=10)
self.btn_load = tk.Button(self.frame, text="Load…", command=self.on_load_clicked)
self.btn_load.pack(side="left", padx=2)
self.btn_clear = tk.Button(self.frame, text="Clear", command=self.clear)
self.btn_clear.pack(side="left", padx=2)
def set_path(self, path: Optional[str]):
self.path = path
self.lbl_path.config(text=path if path else "(no file)")
self.on_change_cb()
def clear(self):
self.set_path(None)
def on_load_clicked(self):
path = filedialog.askopenfilename(
title=f"Select funscript for {self.axis_label}",
filetypes=[("Funscript JSON", "*.funscript *.json"), ("All files", "*.*")]
)
if not path:
return
self.on_manual_file_selected(path)
def on_manual_file_selected(self, path: str):
try:
_ = load_funscript(path)
except Exception as e:
messagebox.showerror("Invalid file", f"{file_basename(path)}\n\n{e}")
return
self.on_change_cb(file_selected=(self.axis_id, path)) # type: ignore
# Core class #
class App(TkBase):
def __init__(self):
super().__init__()
self.title("Funscript v1.0 -> v1.1 converter")
self.geometry("980x560")
self.warn_popups_enabled = tk.BooleanVar(value=True)
dnd_note = "Drag & Drop: enabled" if DND_AVAILABLE else "Drag & Drop: disabled (install tkinterdnd2)"
self.lbl_dnd = tk.Label(self, text=dnd_note, anchor="w", padx=10)
self.lbl_dnd.pack(fill="x", pady=(0, 8))
self.rows_frame = tk.Frame(self, padx=10, pady=6)
self.rows_frame.pack(fill="x")
self.rows: Dict[str, AxisRow] = {}
for axis_label in DEFAULT_AXES:
row = AxisRow(self.rows_frame, axis_label, self.on_rows_changed)
self.rows[row.axis_id] = row
settings = tk.Frame(self, padx=10, pady=6)
settings.pack(fill="x")
self.chk_warn = tk.Checkbutton(
settings,
text="Show popup warnings.",
variable=self.warn_popups_enabled
)
self.chk_warn.pack(side="left")
status_frame = tk.LabelFrame(self, text="Pre-merge checks (live, current only)", padx=10, pady=8)
status_frame.pack(fill="both", expand=True, padx=10, pady=(6, 6))
self.status_text = tk.Text(status_frame, height=10, wrap="word")
self.status_text.pack(fill="both", expand=True)
self.status_text.configure(state="disabled")
btns = tk.Frame(self, padx=10, pady=10)
btns.pack(fill="x")
self.btn_merge = tk.Button(btns, text="Create Multi-Axis File…", command=self.on_merge)
self.btn_merge.pack(side="right")
self.btn_clear_all = tk.Button(btns, text="Clear All", command=self.clear_all)
self.btn_clear_all.pack(side="left")
self.btn_notes = tk.Button(btns, text="Format Notes", command=self.show_notes)
self.btn_notes.pack(side="left", padx=8)
if DND_AVAILABLE:
self.drop_target_register(DND_FILES) # type: ignore
self.dnd_bind("<<Drop>>", self.on_drop) # type: ignore
try:
self.status_text.drop_target_register(DND_FILES) # type: ignore
self.status_text.dnd_bind("<<Drop>>", self.on_drop) # type: ignore
except Exception:
pass
self.refresh_status_panel()
def on_rows_changed(self, file_selected=None):
if file_selected is not None:
axis_id, path = file_selected
self.handle_manual_load(axis_id, path)
return
self.refresh_status_panel()
def handle_manual_load(self, axis_id: str, path: str):
issues = self.evaluate_manual_load_issues(axis_id, path)
if issues and self.warn_popups_enabled.get():
msg = "This filename looks conflicting/ambiguous:\n\n" + "\n".join(f"- {x}" for x in issues)
msg += "\n\nContinue and place it anyway?"
if not messagebox.askyesno("Naming warning", msg):
self.refresh_status_panel()
return
self.rows[axis_id].set_path(path)
def evaluate_manual_load_issues(self, axis_id: str, path: str) -> List[str]:
status, detected_axis = classify_filename_axis(path)
issues: List[str] = []
if status == "AMBIGUOUS":
issues.append(f"{file_basename(path)} contains multiple axis keywords; auto-placement is unreliable.")
issues.append("Recommendation: rename the file or be very sure you are loading it into the correct slot.")
return issues
if status == "UNSPECIFIED":
if axis_id != "L0":
issues.append(f"{file_basename(path)} has no axis keyword; it usually belongs to L0 (Stroke).")
return issues
if detected_axis and detected_axis != axis_id:
issues.append(f"{file_basename(path)} filename suggests {detected_axis}, but you loaded it into {axis_id}.")
return issues
# Drag & Drop handling #
def on_drop(self, event):
paths = normalize_drop_event_data(getattr(event, "data", ""))
paths = [p for p in paths if is_funscript_path(p)]
if not paths:
messagebox.showinfo("Drop ignored", "No .funscript/.json files detected in the drop.")
return
plan, hard_issues = self.build_drop_plan(paths)
# Popup for drop conflicts/ambiguity
if hard_issues:
msg = "Some dropped files are conflicting/ambiguous and will NOT be auto-placed:\n\n"
msg += "\n".join(f"- {x}" for x in hard_issues)
msg += "\n\nNon-conflicting files will still be placed. Continue?"
if not messagebox.askyesno("Drop conflicts", msg):
return
# Apply safe placements, never overwrite a different file already in that slot
for axis_id, path in plan.items():
existing = self.rows[axis_id].path
if existing and os.path.abspath(existing) != os.path.abspath(path):
messagebox.showwarning(
"Slot already filled",
f"{axis_id} already has:\n{existing}\n\nIgnored dropped file:\n{path}"
)
continue
self.rows[axis_id].set_path(path)
self.refresh_status_panel()
def build_drop_plan(self, paths: List[str]) -> Tuple[Dict[str, str], List[str]]:
plan: Dict[str, str] = {}
hard_issues: List[str] = []
classified: List[Tuple[str, str, Optional[str]]] = []
for p in paths:
status, axis_id = classify_filename_axis(p)
classified.append((p, status, axis_id))
# Ambiguous files are hard issues
for p, status, _ in classified:
if status == "AMBIGUOUS":
hard_issues.append(f"{file_basename(p)} contains multiple axis keywords; use Load… manually.")
candidates: Dict[str, List[str]] = {aid: [] for aid in AXIS_ORDER}
unspecified_l0: List[str] = []
for p, status, axis_id in classified:
if status == "AMBIGUOUS":
continue
if status == "UNSPECIFIED":
unspecified_l0.append(p)
continue
if axis_id in candidates:
candidates[axis_id].append(p)
else:
hard_issues.append(f"{file_basename(p)} has an unrecognized axis; use Load… manually.")
# Multiple unspecified files -> hard conflict (multiple L0 candidates)
if len(unspecified_l0) > 1:
hard_issues.append(
"Multiple dropped files have no axis keyword (all treated as L0 candidates): "
+ ", ".join(file_basename(p) for p in unspecified_l0)
)
if unspecified_l0:
candidates["L0"].extend(unspecified_l0)
# Multiple candidates for same axis -> hard conflict
for aid, files in candidates.items():
if len(files) > 1:
hard_issues.append(f"Multiple dropped files look like {aid}: " + ", ".join(file_basename(p) for p in files))
# Choose one per axis. Preference for explicit 'stroke' -> L0.
for aid, files in candidates.items():
if not files:
continue
if aid == "L0" and len(files) > 1:
def l0_priority(p: str) -> int:
return 0 if "stroke" in axis_keywords_in_filename(p) else 1
files = sorted(files, key=l0_priority)
plan[aid] = files[0]
return plan, hard_issues
def compute_premerge_issues(self) -> Tuple[List[str], List[str]]:
fatal: List[str] = []
warn: List[str] = []
# Missing L0 is fatal
if not self.rows[BASE_AXIS_ID].path:
fatal.append("Missing L0 (Stroke). Load or drop a funscript for L0.")
# Validate current assignments with filename hints
for aid in AXIS_ORDER:
row = self.rows.get(aid)
if not row or not row.path:
continue
status, detected_axis = classify_filename_axis(row.path)
if status == "AMBIGUOUS":
warn.append(f"{aid} file is ambiguous (multiple axis keywords): {file_basename(row.path)}")
elif status == "UNSPECIFIED":
if aid != "L0":
warn.append(f"{aid} file has no axis keyword (usually L0): {file_basename(row.path)}")
else:
if detected_axis and detected_axis != aid:
warn.append(f"{aid} filename suggests {detected_axis}: {file_basename(row.path)}")
# Deduplicate warnings
seen = set()
warn_unique = []
for w in warn:
if w not in seen:
warn_unique.append(w)
seen.add(w)
return fatal, warn_unique
def refresh_status_panel(self):
fatal, warn = self.compute_premerge_issues()
lines: List[str] = []
if fatal:
lines.append("FATAL (must fix):")
lines.extend(f" - {x}" for x in fatal)
lines.append("")
if warn:
lines.append("WARNINGS (currently true):")
lines.extend(f" - {x}" for x in warn)
lines.append("")
if not fatal and not warn:
lines.append("No issues detected. Suspicious, but acceptable.")
self.status_text.configure(state="normal")
self.status_text.delete("1.0", "end")
self.status_text.insert("1.0", "\n".join(lines))
self.status_text.configure(state="disabled")
def on_merge(self):
fatal, warn = self.compute_premerge_issues()
self.refresh_status_panel()
if fatal:
messagebox.showerror("Cannot merge", "Fix these first:\n\n" + "\n".join(f"- {x}" for x in fatal))
return
if warn and self.warn_popups_enabled.get():
msg = "Potential naming conflicts/ambiguity detected (currently true):\n\n"
msg += "\n".join(f"- {x}" for x in warn)
msg += "\n\nContinue and create the multi-axis file anyway?"
if not messagebox.askyesno("Warnings before merge", msg):
return
base_path = self.rows[BASE_AXIS_ID].path
assert base_path is not None
extra_paths: Dict[str, str] = {}
for aid in AXIS_ORDER:
if aid == BASE_AXIS_ID:
continue
p = self.rows[aid].path
if p:
extra_paths[aid] = p
try:
base_script = load_funscript(base_path)
extra_scripts: Dict[str, Dict[str, Any]] = {}
for aid, p in extra_paths.items():
extra_scripts[aid] = load_funscript(p)
except Exception as e:
messagebox.showerror("Load error", str(e))
return
try:
merged = merge_slr_compact_always_l0(base_script, extra_scripts)
except Exception as e:
messagebox.showerror("Merge error", str(e))
return
default_name = os.path.splitext(os.path.basename(base_path))[0]
save_path = filedialog.asksaveasfilename(
title="Save merged multi-axis funscript",
defaultextension=".funscript",
initialfile=f"{default_name}_merged.funscript",
filetypes=[("Funscript", "*.funscript"), ("JSON", "*.json"), ("All files", "*.*")]
)
if not save_path:
return
try:
with open(save_path, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, separators=(",", ":"))
except Exception as e:
messagebox.showerror("Save error", f"Failed to save:\n\n{e}")
return
messagebox.showinfo("Done", f"Saved:\n{save_path}")
def clear_all(self):
for aid in AXIS_ORDER:
self.rows[aid].clear()
self.refresh_status_panel()
def show_notes(self):
note = (
"Auto-placement rules (filename-based):\n"
" - L0: stroke OR no axis keyword\n"
" - L1: surge\n"
" - L2: sway\n"
" - R0: twist\n"
" - R1: roll\n"
" - R2: pitch\n"
)
if not DND_AVAILABLE:
note += "Drag & Drop is disabled. Install with: pip install tkinterdnd2\n"
messagebox.showinfo("Format Notes", note)
if __name__ == "__main__":
App().mainloop()