Multi‑Axis Funscript Merger

Resource

Click me

Then copy the text and save it as merge_axes.py (or any name you prefer).

#!/usr/bin/env python3
import os
import re
import json
import argparse
from pathlib import Path

# 轴后缀 → 轴 ID(L0 为主文件,不在此映射中)
AXIS_MAP = {
    "surge": "L1",
    "sway": "L2",
    "twist": "R0",
    "roll": "R1",
    "pitch": "R2",
    "vib": "V0",
    "pump": "V1",
    "valve": "A0",
    "suck": "A1",
    "lube": "A2",
}

# 输出顺序(可自定义)
AXIS_ORDER = ["L1", "L2", "R0", "R1", "R2", "V0", "V1", "A0", "A1", "A2"]

def compact_actions_in_json(json_str):
    """
    将 JSON 字符串中所有的 "actions": [ ... ] 数组压缩为单行(紧凑格式)。
    假设数组内元素不包含嵌套的方括号(本场景下均为简单对象)。
    """
    pattern = r'("actions":\s*\[)([^\]]*)(\])'
    def repl(match):
        prefix = match.group(1)
        content = match.group(2)
        suffix = match.group(3)
        # 删除所有换行和多余空白,只保留必要的空格
        compressed = re.sub(r'\s+', ' ', content).strip()
        return prefix + compressed + suffix
    return re.sub(pattern, repl, json_str, flags=re.DOTALL)

def merge_funscripts(main_file_path, output_dir=None):
    main_path = Path(main_file_path)
    if not main_path.exists():
        raise FileNotFoundError(f"主文件不存在: {main_file_path}")

    base_name = main_path.stem
    directory = main_path.parent

    # 读取主文件 (L0)
    with open(main_path, 'r', encoding='utf-8') as f:
        main_data = json.load(f)

    # 构建合并数据结构
    merged = {
        "version": main_data.get("version", "1.1"),
        "range": main_data.get("range", 100),
        "metadata": main_data.get("metadata", {}),
        "actions": main_data.get("actions", []),   # L0
        "axes": []
    }

    # 扫描目录,查找所有轴文件
    axis_files = {}
    for file in directory.glob(f"{base_name}.*.funscript"):
        # 提取后缀,例如 ".roll" -> "roll"
        suffix = file.stem[len(base_name):]
        if suffix.startswith('.'):
            suffix = suffix[1:]
        if suffix in AXIS_MAP:
            axis_id = AXIS_MAP[suffix]
            axis_files[axis_id] = file

    # 按顺序添加轴(L1, L2, ...)
    for axis_id in AXIS_ORDER:
        if axis_id in axis_files:
            with open(axis_files[axis_id], 'r', encoding='utf-8') as f:
                axis_data = json.load(f)
            merged["axes"].append({
                "id": axis_id,
                "actions": axis_data.get("actions", [])
            })

    # 生成带缩进的 JSON 字符串
    json_str = json.dumps(merged, indent=2, ensure_ascii=False)

    # 压缩所有 actions 数组为单行
    json_str = compact_actions_in_json(json_str)

    # 确定输出路径(当前工作目录)
    if output_dir is None:
        output_dir = os.getcwd()
    output_path = Path(output_dir) / f"{base_name}_merged.funscript"

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(json_str)

    print(f"合并完成,输出文件: {output_path}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="合并主 .funscript 及其轴文件")
    parser.add_argument("main", help="主 .funscript 文件路径")
    parser.add_argument("-o", "--output-dir", help="输出目录(默认为当前目录)", default=None)
    args = parser.parse_args()
    merge_funscripts(args.main, args.output_dir)

Introduce

Multi‑Axis Funscript Merger is a lightweight Python script that automatically collects all related axis .funscript files from the same directory as the main file and merges them into a single, well‑structured JSON(funscript). The result preserves the main action track (L0) and appends up to ten auxiliary axes (L1L2, R0R2, V0V1, A0A2), making it easier to manage and share multi‑dimensional scripts.

Usage

(Python 3.6+ required. If you’re new to Python, just Google it or ask an AI.)

python merge_axes.py <main.funscript> [-o <output_directory>]
  • <main.funscript> – path to the primary .funscript file (the L0 track).
  • -o, --output-dir – (optional) directory where the merged file will be saved; defaults to the current working directory.

The output file name will just like main_merged.funscript. You need remove _merged by youself

All axis files must reside in the same folder as the main file and be named as main_name.suffix.funscript (e.g., example.roll.funscript). The script automatically ignores files whose suffix is not recognised.

Key Features and How It Works

Key Features

  • Automatic axis discovery – scans the main file’s directory for any matching *.suffix.funscript files.
  • Broad axis support – recognises suffixes surge, sway, twist, roll, pitch, vib, pump, valve, suck, lube and maps them to standard IDs (L1, L2, R0, …).
  • Fixed output order – axes are always written in the sequence L1, L2, R0, R1, R2, V0, V1, A0, A1, A2, regardless of the order in which files are found.
  • Compact yet readable output – the top‑level JSON is indented, while each actions array is condensed to a single line, significantly reducing file size without sacrificing structure.
  • Metadata preservationversion, range, and any custom metadata from the main file are carried over.

How It Works

  1. Parse the main file – read the primary .funscript, extract its actions (the L0 track), version, range, and metadata.
  2. Discover axis files – use a glob pattern {stem}.*.funscript to find all candidates; for each, strip the stem to isolate the suffix (e.g., roll). Map the suffix to an axis ID via AXIS_MAP.
  3. Load axis data – for each recognised axis, read its .funscript and extract the actions array. Unknown suffixes are silently skipped.
  4. Build the merged object – create a dictionary with actions (from the main file) and an axes list. Populate the list in the predefined AXIS_ORDER, preserving the ID and actions of each axis found.
  5. Compact the JSON – serialise with json.dumps(indent=2), then apply a regex to collapse every "actions": [...] block onto a single line. This keeps the file both human‑inspectable and compact.
  6. Write the output – save the result as {stem}_merged.funscript in the chosen directory (or the current working directory by default).

Comparison of Results

Before
Multiple separate files in the same folder:

example.funscript            ← main (L0)
example.surge.funscript      ← L1
example.sway.funscript       ← L2
example.roll.funscript       ← R1
example.pitch.funscript      ← R2
example.vib.funscript        ← V0

After
A single, self‑contained file: example_merged.funscript

{
  "version": "1.1",
  "range": 100,
  "metadata": { … },
  "actions": [{"at":0,"pos":50},{"at":500,"pos":100}, …],
  "axes": [
    {"id": "L1", "actions": [{"at":0,"pos":30}, …]},
    {"id": "L2", "actions": [{"at":200,"pos":80}, …]},
    {"id": "R1", "actions": [{"at":100,"pos":20}, …]},
    …
  ]
}

All axis tracks are neatly organised inside the axes array, and each actions list is written as a single line for a clean, compact layout.