diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..5a8c42c --- /dev/null +++ b/cli.py @@ -0,0 +1,877 @@ +""" +cli.py — Adapter layer for funscript-tools. + +This is the ONLY file that imports from the upstream processing engine. +All other code (UI, tests, API) imports from here only. + +Processing engine by edger477: https://github.com/edger477/funscript-tools +All algorithm credit belongs to edger477 and contributors. + +Usage (command line): + python cli.py process path/to/file.funscript + python cli.py process path/to/file.funscript --output-dir /some/dir + python cli.py info path/to/file.funscript + python cli.py list-outputs path/to/dir stem_name + +Usage (Python): + from cli import load_file, process, get_default_config + from cli import preview_electrode_path, preview_frequency_blend, preview_pulse_shape +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, Optional + +import numpy as np + +# ── Upstream imports (isolated here) ───────────────────────────────────────── +# Everything upstream lives behind this boundary. + +_HERE = Path(__file__).parent +sys.path.insert(0, str(_HERE)) + + +def _upstream_load(path: Path): + """Load a funscript via the upstream Funscript class.""" + from funscript import Funscript # upstream + return Funscript.from_file(path) + + +def _upstream_process(input_path: Path, config: dict, on_progress=None) -> bool: + """Run the upstream RestimProcessor pipeline.""" + from processor import RestimProcessor # upstream + proc = RestimProcessor(config) + return proc.process(str(input_path), on_progress) + + +def _upstream_default_config() -> dict: + """Return upstream default config.""" + from config import DEFAULT_CONFIG # upstream + import copy + return copy.deepcopy(DEFAULT_CONFIG) + + +def _upstream_preview_path(algorithm: str, min_distance: float, + speed_threshold: float, n_points: int): + """Generate a 2D electrode path using a synthetic sine input.""" + from funscript import Funscript # upstream + from processing.speed_processing import convert_to_speed # upstream + from processing.funscript_1d_to_2d import generate_alpha_beta_from_main # upstream + + # Synthetic sinusoidal funscript (2 seconds, representative motion) + t = np.linspace(0, 2.0, n_points) + y = (np.sin(2 * np.pi * 1.5 * t) + 1) / 2 # 0-1 range, 1.5 Hz + synth = Funscript(t.tolist(), y.tolist()) + + speed = convert_to_speed(synth, window_size=5, interpolation_interval=0.05) + + alpha, beta = generate_alpha_beta_from_main( + synth, speed, + points_per_second=25, + algorithm=algorithm, + min_distance_from_center=min_distance, + speed_threshold_percent=speed_threshold, + ) + return alpha.y, beta.y # Both are 0-1 floats + + +# ── Public API ──────────────────────────────────────────────────────────────── + +ALGORITHMS = { + "circular": "Circular (0°–180°)", + "top-right-left": "Top-Right-Bottom-Left (0°–270°)", + "top-left-right": "Top-Left-Bottom-Right (0°–90°)", + "restim-original": "Restim Original (0°–360°)", +} + +ALGORITHM_DESCRIPTIONS = { + "circular": "Smooth semi-circle. Balanced, works well for most content.", + "top-right-left": "Wider arc. More variation, stronger contrast between strokes.", + "top-left-right": "Narrower arc. Subtle, good for slower content.", + "restim-original": "Full circle with random direction changes. Most unpredictable.", +} + + +def load_file(path: str) -> dict: + """ + Load a .funscript file and return metadata + waveform data. + + Returns: + { + name: str, + path: str, + actions: int, + duration_s: float, + duration_fmt: str, # "MM:SS" + pos_min: float, # 0–100 + pos_max: float, # 0–100 + x: list[float], # time in seconds + y: list[float], # position 0–100 + } + Raises: + ValueError if the file cannot be loaded. + """ + p = Path(path) + if not p.exists(): + raise ValueError(f"File not found: {path}") + if p.suffix.lower() != ".funscript": + raise ValueError(f"Expected a .funscript file, got: {p.suffix}") + + try: + fs = _upstream_load(p) + except Exception as e: + raise ValueError(f"Failed to read {p.name}: {e}") from e + + x = [float(v) for v in fs.x] + y = [float(v) * 100 for v in fs.y] # Convert 0-1 → 0-100 + duration = x[-1] if x else 0.0 + m, s = int(duration // 60), int(duration % 60) + + return { + "name": p.name, + "path": str(p), + "actions": len(x), + "duration_s": duration, + "duration_fmt": f"{m:02d}:{s:02d}", + "pos_min": round(min(y), 1) if y else 0.0, + "pos_max": round(max(y), 1) if y else 0.0, + "x": x, + "y": y, + } + + +def get_default_config() -> dict: + """Return the default processing configuration.""" + return _upstream_default_config() + + +def process(path: str, config: dict, on_progress: Optional[Callable] = None) -> dict: + """ + Run the full processing pipeline on a .funscript file. + + on_progress: optional callback(percent: int, message: str) + + Returns: + { + success: bool, + error: str | None, + outputs: list[{ suffix: str, path: str, size_bytes: int }] + } + """ + p = Path(path) + + try: + success = _upstream_process(p, config, on_progress) + except Exception as e: + return {"success": False, "error": str(e), "outputs": []} + + if not success: + return {"success": False, "error": "Processing failed — check logs.", "outputs": []} + + # Determine output directory + custom = config.get("advanced", {}).get("custom_output_directory", "").strip() + out_dir = Path(custom) if custom else p.parent + + outputs = list_outputs(str(out_dir), p.stem) + return {"success": True, "error": None, "outputs": outputs} + + +def list_outputs(directory: str, stem: str) -> list[dict]: + """ + Find all generated output files for a given stem in a directory. + + Returns: + list of { suffix: str, path: str, size_bytes: int } + """ + d = Path(directory) + if not d.exists(): + return [] + + results = [] + for p in sorted(d.glob(f"{stem}.*.funscript")): + suffix = p.name[len(stem) + 1: -len(".funscript")] + results.append({ + "suffix": suffix, + "path": str(p), + "size_bytes": p.stat().st_size, + }) + return results + + +# ── Preview functions (no file I/O, safe to call on every slider move) ──────── + +def preview_electrode_path( + algorithm: str = "circular", + min_distance_from_center: float = 0.1, + speed_threshold_percent: float = 50.0, + points: int = 200, +) -> dict: + """ + Return the 2D electrode path shape for visualization. + + Uses a synthetic sinusoidal input so shape depends only on the algorithm + and parameters — no real funscript needed. + + Returns: + { + alpha: list[float], # x-axis values 0–1 + beta: list[float], # y-axis values 0–1 + label: str, # algorithm display name + description: str, # plain-language description + } + """ + try: + alpha_y, beta_y = _upstream_preview_path( + algorithm, + min_distance_from_center, + speed_threshold_percent, + points, + ) + return { + "alpha": [float(v) for v in alpha_y], + "beta": [float(v) for v in beta_y], + "label": ALGORITHMS.get(algorithm, algorithm), + "description": ALGORITHM_DESCRIPTIONS.get(algorithm, ""), + } + except Exception as e: + # Return a fallback circle so the UI never crashes + t = np.linspace(0, np.pi, points) + return { + "alpha": (0.5 + 0.4 * np.cos(t)).tolist(), + "beta": (0.5 + 0.4 * np.sin(t)).tolist(), + "label": ALGORITHMS.get(algorithm, algorithm), + "description": f"Preview unavailable: {e}", + } + + +def preview_frequency_blend( + frequency_ramp_combine_ratio: float = 2.0, + pulse_frequency_combine_ratio: float = 3.0, +) -> dict: + """ + Return plain-language description of the frequency blend settings. + + The combine ratio R means: (R-1)/R of the first source + 1/R of the second. + + Returns: + { + frequency_ramp_pct: float, # % from ramp (slow build) + frequency_speed_pct: float, # % from speed (action intensity) + pulse_speed_pct: float, # % from speed in pulse freq + pulse_alpha_pct: float, # % from alpha in pulse freq + frequency_label: str, # "70% slow build + 30% scene energy" + pulse_label: str, + overall_label: str, # summary sentence + } + """ + def split(ratio): + r = max(1.0, float(ratio)) + left = round((r - 1) / r * 100, 1) + right = round(100 - left, 1) + return left, right + + ramp_pct, speed_pct = split(frequency_ramp_combine_ratio) + pulse_speed_pct, pulse_alpha_pct = split(pulse_frequency_combine_ratio) + + freq_label = f"{ramp_pct:.0f}% slow build + {speed_pct:.0f}% scene energy" + pulse_label = f"{pulse_speed_pct:.0f}% scene energy + {pulse_alpha_pct:.0f}% spatial position" + + if ramp_pct >= 60: + character = "gradual, builds slowly" + elif speed_pct >= 60: + character = "reactive, follows action closely" + else: + character = "balanced — responsive with a slow build" + + return { + "frequency_ramp_pct": ramp_pct, + "frequency_speed_pct": speed_pct, + "pulse_speed_pct": pulse_speed_pct, + "pulse_alpha_pct": pulse_alpha_pct, + "frequency_label": freq_label, + "pulse_label": pulse_label, + "overall_label": f"Frequency feel: {character}", + } + + +def preview_pulse_shape( + width_min: float = 0.1, + width_max: float = 0.45, + rise_min: float = 0.0, + rise_max: float = 0.80, +) -> dict: + """ + Return a representative pulse silhouette for visualization. + + Generates a trapezoidal pulse at the midpoint of the configured ranges, + showing the relationship between width and rise time. + + Returns: + { + x: list[float], # normalized time 0–1 + y: list[float], # normalized amplitude 0–1 + width: float, # midpoint width used + rise: float, # midpoint rise time used + label: str, # plain-language description + sharpness: str, # "sharp" / "medium" / "soft" + } + """ + width = (width_min + width_max) / 2 + rise = (rise_min + rise_max) / 2 + + # Build trapezoidal pulse: rise → hold → fall + # Normalized to 0-1 time window + rise_frac = min(rise, width / 2) # Can't rise longer than half the pulse + hold_frac = width - 2 * rise_frac + fall_frac = rise_frac + gap_frac = 1.0 - width + + x = [0.0, + rise_frac, + rise_frac + hold_frac, + rise_frac + hold_frac + fall_frac, + rise_frac + hold_frac + fall_frac + gap_frac] + y = [0.0, 1.0, 1.0, 0.0, 0.0] + + if rise < 0.1: + sharpness = "sharp — immediate onset" + elif rise < 0.4: + sharpness = "medium — smooth ramp" + else: + sharpness = "soft — gentle build" + + width_desc = "narrow" if width < 0.2 else ("wide" if width > 0.35 else "medium") + label = f"Pulse: {width_desc} width, {sharpness}" + + return { + "x": x, + "y": y, + "width": round(width, 3), + "rise": round(rise, 3), + "label": label, + "sharpness": sharpness.split(" — ")[0], + } + + +def preview_output( + source: dict, + config: dict, + output_type: str = "alpha", +) -> dict: + """ + Run a lightweight partial pipeline to preview one output type. + + Faster than full process() — skips file I/O, returns array data only. + Best effort: falls back gracefully if the output type isn't computable. + + Returns: + { + original_x: list[float], + original_y: list[float], # 0–100 + output_x: list[float], + output_y: list[float], # 0–100 + label: str, + available: bool, + } + """ + base = { + "original_x": source.get("x", []), + "original_y": source.get("y", []), + "output_x": [], + "output_y": [], + "label": output_type, + "available": False, + } + + try: + from funscript import Funscript # upstream + from processing.speed_processing import convert_to_speed # upstream + + x = np.array(source["x"]) + y = np.array(source["y"]) / 100.0 # back to 0-1 + main_fs = Funscript(x.tolist(), y.tolist()) + + ab_cfg = config.get("alpha_beta_generation", {}) + speed_fs = convert_to_speed( + main_fs, + config["general"]["speed_window_size"], + config["speed"]["interpolation_interval"], + ) + + if output_type in ("alpha", "beta"): + from processing.funscript_1d_to_2d import generate_alpha_beta_from_main # upstream + alpha, beta = generate_alpha_beta_from_main( + main_fs, speed_fs, + points_per_second=ab_cfg.get("points_per_second", 25), + algorithm=ab_cfg.get("algorithm", "circular"), + min_distance_from_center=ab_cfg.get("min_distance_from_center", 0.1), + speed_threshold_percent=ab_cfg.get("speed_threshold_percent", 50), + ) + out = alpha if output_type == "alpha" else beta + + elif output_type == "speed": + out = speed_fs + + elif output_type == "frequency": + from processing.combining import combine_funscripts # upstream + from processing.special_generators import make_volume_ramp # upstream + ramp = make_volume_ramp(main_fs, config.get("volume", {}).get("ramp_percent_per_hour", 15)) + out = combine_funscripts(ramp, speed_fs, config["frequency"]["frequency_ramp_combine_ratio"]) + + elif output_type == "volume": + from processing.combining import combine_funscripts # upstream + from processing.special_generators import make_volume_ramp # upstream + ramp = make_volume_ramp(main_fs, config.get("volume", {}).get("ramp_percent_per_hour", 15)) + out = combine_funscripts( + ramp, speed_fs, + config["volume"]["volume_ramp_combine_ratio"], + config["general"]["rest_level"], + config["general"]["ramp_up_duration_after_rest"], + ) + + else: + base["label"] = f"{output_type} (preview not available — run Process)" + return base + + base["output_x"] = [float(v) for v in out.x] + base["output_y"] = [float(v) * 100 for v in out.y] + base["available"] = True + base["label"] = output_type + + except Exception as e: + base["label"] = f"{output_type} (preview error: {e})" + + return base + + +# ── Command-line interface ───────────────────────────────────────────────────── + +def _cmd_algorithms(args): + for key, name in ALGORITHMS.items(): + desc = ALGORITHM_DESCRIPTIONS[key] + print(f" {key:<20} {name}") + print(f" {'':20} {desc}") + print() + + +def _cmd_config_show(args): + cfg = get_default_config() + if args.section: + if args.section not in cfg: + print(f"Error: unknown section '{args.section}'. " + f"Available: {', '.join(cfg.keys())}", file=sys.stderr) + sys.exit(1) + print(json.dumps(cfg[args.section], indent=2)) + else: + print(json.dumps(cfg, indent=2)) + + +def _cmd_config_save(args): + cfg = get_default_config() + out = Path(args.output) + if out.exists() and not args.force: + print(f"Error: {out} already exists. Use --force to overwrite.", file=sys.stderr) + sys.exit(1) + with open(out, "w") as f: + json.dump(cfg, f, indent=2) + print(f"Default config saved to: {out}") + print(f"Edit it, then use: python cli.py process --config {out}") + + +def _cmd_preview_electrode(args): + result = preview_electrode_path( + algorithm=args.algorithm, + min_distance_from_center=args.min_distance, + speed_threshold_percent=args.speed_threshold, + points=args.points, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"Algorithm: {result['label']}") + print(f"Description: {result['description']}") + print(f"Points: {len(result['alpha'])} alpha, {len(result['beta'])} beta") + print(f"Alpha range: {min(result['alpha']):.3f} – {max(result['alpha']):.3f}") + print(f"Beta range: {min(result['beta']):.3f} – {max(result['beta']):.3f}") + + +def _cmd_preview_frequency(args): + result = preview_frequency_blend( + frequency_ramp_combine_ratio=args.ramp_ratio, + pulse_frequency_combine_ratio=args.pulse_ratio, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f" {result['overall_label']}") + print(f" Frequency: {result['frequency_label']}") + print(f" Pulse: {result['pulse_label']}") + + +def _cmd_preview_pulse(args): + result = preview_pulse_shape( + width_min=args.width_min, + width_max=args.width_max, + rise_min=args.rise_min, + rise_max=args.rise_max, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f" {result['label']}") + print(f" Width (mid): {result['width']}") + print(f" Rise (mid): {result['rise']}") + print(f" Sharpness: {result['sharpness']}") + + +def _cmd_info(args): + try: + info = load_file(args.file) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"File: {info['name']}") + print(f"Actions: {info['actions']}") + print(f"Duration: {info['duration_fmt']}") + print(f"Range: {info['pos_min']:.0f} – {info['pos_max']:.0f}") + + +def _cmd_process(args): + config = get_default_config() + + if args.config: + with open(args.config) as f: + overrides = json.load(f) + _deep_merge(config, overrides) + + if args.output_dir: + config.setdefault("advanced", {})["custom_output_directory"] = args.output_dir + + def _progress(pct, msg): + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r[{bar}] {pct:3d}% {msg:<50}", end="", flush=True) + + print(f"Processing: {args.file}") + result = process(args.file, config, _progress) + print() # newline after progress bar + + if not result["success"]: + print(f"Error: {result['error']}", file=sys.stderr) + sys.exit(1) + + print(f"\nGenerated {len(result['outputs'])} file(s):") + for out in result["outputs"]: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:.1f} KB → {out['path']}") + + +def _cmd_list_outputs(args): + outputs = list_outputs(args.directory, args.stem) + if not outputs: + print(f"No outputs found for '{args.stem}' in {args.directory}") + return + for out in outputs: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:.1f} KB") + + +def _deep_merge(base: dict, override: dict): + for k, v in override.items(): + if isinstance(v, dict) and isinstance(base.get(k), dict): + _deep_merge(base[k], v) + else: + base[k] = v + + +def main(): + parser = argparse.ArgumentParser( + prog="cli.py", + description=( + "funscript-tools CLI — convert .funscript files into restim-ready output sets.\n\n" + "Processing engine by edger477: https://github.com/edger477/funscript-tools\n\n" + "Quick start:\n" + " python cli.py process my_scene.funscript\n\n" + "Tune settings:\n" + " python cli.py config save my_config.json\n" + " # edit my_config.json, then:\n" + " python cli.py process my_scene.funscript --config my_config.json\n\n" + "Explore without processing:\n" + " python cli.py algorithms\n" + " python cli.py preview electrode-path --algorithm circular\n" + " python cli.py preview frequency-blend --ramp-ratio 4\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "For full documentation see DESIGN.md or examples/README.md\n" + "Run any subcommand with --help for details: python cli.py process --help" + ), + ) + sub = parser.add_subparsers(dest="command", required=True, metavar="command") + + # ── info ───────────────────────────────────────────────────────────────── + p_info = sub.add_parser( + "info", + help="Show metadata about a .funscript file", + description="Load a .funscript file and display its metadata — action count, duration, and position range.", + epilog="Example:\n python cli.py info my_scene.funscript", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_info.add_argument("file", help="Path to the .funscript file") + + # ── process ────────────────────────────────────────────────────────────── + p_proc = sub.add_parser( + "process", + help="Run the full processing pipeline on a .funscript file", + description=( + "Process a .funscript file through the full restim pipeline, generating all\n" + "output files (alpha, beta, frequency, volume, pulse_width, etc.).\n\n" + "Outputs are written next to the input file by default.\n" + "Use --config to load a saved config (see: python cli.py config save).\n" + "Use --output-dir to redirect outputs to a different folder." + ), + epilog=( + "Examples:\n" + " python cli.py process my_scene.funscript\n" + " python cli.py process my_scene.funscript --output-dir ~/restim/\n" + " python cli.py process my_scene.funscript --config my_config.json\n" + " python cli.py process my_scene.funscript --config my_config.json --output-dir ~/restim/" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_proc.add_argument("file", help="Path to the .funscript file to process") + p_proc.add_argument( + "--config", + metavar="FILE", + help="JSON config file to use instead of defaults (see: python cli.py config save)" + ) + p_proc.add_argument( + "--output-dir", + metavar="DIR", + help="Directory for output files (default: same folder as input)" + ) + + # ── list-outputs ───────────────────────────────────────────────────────── + p_list = sub.add_parser( + "list-outputs", + help="List generated output files for a given input stem", + description=( + "Find and list all generated .funscript output files for a given input\n" + "filename stem (the filename without .funscript extension)." + ), + epilog=( + "Examples:\n" + " python cli.py list-outputs . my_scene\n" + " python cli.py list-outputs ~/restim/ my_scene" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_list.add_argument("directory", help="Directory to search for output files") + p_list.add_argument("stem", help="Input filename without extension (e.g. my_scene)") + + # ── algorithms ─────────────────────────────────────────────────────────── + sub.add_parser( + "algorithms", + help="List available 2D electrode path algorithms with descriptions", + description=( + "List all available algorithms for converting 1D funscript motion\n" + "into a 2D electrode path, with plain-language descriptions.\n\n" + "Use the algorithm key with: python cli.py preview electrode-path --algorithm \n" + "Or set it in your config: python cli.py config save my_config.json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # ── config ─────────────────────────────────────────────────────────────── + p_cfg = sub.add_parser( + "config", + help="Show or save the default processing configuration", + description=( + "Inspect or export the default configuration.\n\n" + "Workflow:\n" + " 1. Save defaults to a file: python cli.py config save my_config.json\n" + " 2. Edit my_config.json in any text editor\n" + " 3. Process with your config: python cli.py process file.funscript --config my_config.json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + cfg_sub = p_cfg.add_subparsers(dest="config_command", required=True, metavar="subcommand") + + p_cfg_show = cfg_sub.add_parser( + "show", + help="Print the default configuration as JSON", + description="Print the full default config, or a single section, as formatted JSON.", + epilog=( + "Examples:\n" + " python cli.py config show\n" + " python cli.py config show frequency\n" + " python cli.py config show pulse" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_cfg_show.add_argument( + "section", nargs="?", + help="Section to show: general, frequency, pulse, volume, alpha_beta_generation, " + "prostate_generation, advanced, options, positional_axes, speed. Omit for full config." + ) + + p_cfg_save = cfg_sub.add_parser( + "save", + help="Save the default configuration to a JSON file for editing", + description=( + "Write the full default configuration to a JSON file.\n" + "Edit the file to tune parameters, then use it with --config." + ), + epilog=( + "Examples:\n" + " python cli.py config save my_config.json\n" + " python cli.py config save configs/gentle.json\n" + " python cli.py config save my_config.json --force # overwrite existing" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_cfg_save.add_argument("output", help="Output file path (e.g. my_config.json)") + p_cfg_save.add_argument("--force", action="store_true", help="Overwrite if file already exists") + + # ── preview ────────────────────────────────────────────────────────────── + p_prev = sub.add_parser( + "preview", + help="Preview parameter effects without running the full pipeline", + description=( + "Fast parameter previews — no file I/O, no output files written.\n" + "Use these to understand what a setting does before committing to a full process run.\n" + "Add --json to pipe results to other tools." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + prev_sub = p_prev.add_subparsers(dest="preview_command", required=True, metavar="subcommand") + + p_elec = prev_sub.add_parser( + "electrode-path", + help="Show the 2D electrode path shape for an algorithm", + description=( + "Generate the 2D electrode path shape that a given algorithm produces.\n" + "Uses a synthetic sine-wave input so the shape reflects the algorithm only.\n\n" + "This is what the UI plots when you change the Algorithm dropdown.\n" + "Run 'python cli.py algorithms' to see available algorithm keys." + ), + epilog=( + "Examples:\n" + " python cli.py preview electrode-path\n" + " python cli.py preview electrode-path --algorithm circular\n" + " python cli.py preview electrode-path --algorithm top-right-left --min-distance 0.3\n" + " python cli.py preview electrode-path --json | python -m json.tool" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_elec.add_argument( + "--algorithm", default="circular", + choices=list(ALGORITHMS.keys()), + help="Algorithm to preview (default: circular)" + ) + p_elec.add_argument( + "--min-distance", type=float, default=0.1, + metavar="0.0-0.9", + help="Min distance from center (default: 0.1)" + ) + p_elec.add_argument( + "--speed-threshold", type=float, default=50.0, + metavar="0-100", + help="Speed threshold percent (default: 50)" + ) + p_elec.add_argument( + "--points", type=int, default=200, + help="Number of preview points (default: 200)" + ) + p_elec.add_argument( + "--json", action="store_true", + help="Output raw JSON (for piping to other tools)" + ) + + p_freq = prev_sub.add_parser( + "frequency-blend", + help="Show plain-language description of frequency blend settings", + description=( + "Translate the frequency combine ratios into plain English.\n" + "Shows what percentage of the output comes from the slow ramp vs scene energy,\n" + "and gives an overall character description.\n\n" + "The ramp ratio controls the primary frequency envelope.\n" + "The pulse ratio controls how pulse frequency tracks action intensity." + ), + epilog=( + "Examples:\n" + " python cli.py preview frequency-blend\n" + " python cli.py preview frequency-blend --ramp-ratio 4\n" + " python cli.py preview frequency-blend --ramp-ratio 1 --pulse-ratio 1\n" + " python cli.py preview frequency-blend --json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_freq.add_argument( + "--ramp-ratio", type=float, default=2.0, + metavar="1-10", + help="Frequency ramp combine ratio (default: 2.0)" + ) + p_freq.add_argument( + "--pulse-ratio", type=float, default=3.0, + metavar="1-10", + help="Pulse frequency combine ratio (default: 3.0)" + ) + p_freq.add_argument("--json", action="store_true", help="Output raw JSON") + + p_pulse = prev_sub.add_parser( + "pulse-shape", + help="Show pulse silhouette for given width and rise time settings", + description=( + "Describe the shape of a representative pulse given width and rise time settings.\n\n" + "Width controls how long each pulse lasts.\n" + "Rise time controls how quickly it ramps up — low values are sharp, high values are soft.\n\n" + "Both are expressed as 0.0–1.0 fractions. The preview uses the midpoint of each range." + ), + epilog=( + "Examples:\n" + " python cli.py preview pulse-shape\n" + " python cli.py preview pulse-shape --width-min 0.1 --width-max 0.5\n" + " python cli.py preview pulse-shape --rise-min 0.0 --rise-max 0.1 # sharp\n" + " python cli.py preview pulse-shape --rise-min 0.5 --rise-max 0.9 # soft\n" + " python cli.py preview pulse-shape --json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_pulse.add_argument("--width-min", type=float, default=0.1, metavar="0.0-1.0") + p_pulse.add_argument("--width-max", type=float, default=0.45, metavar="0.0-1.0") + p_pulse.add_argument("--rise-min", type=float, default=0.0, metavar="0.0-1.0") + p_pulse.add_argument("--rise-max", type=float, default=0.80, metavar="0.0-1.0") + p_pulse.add_argument("--json", action="store_true", help="Output raw JSON") + + # ── dispatch ───────────────────────────────────────────────────────────── + args = parser.parse_args() + + if args.command == "info": + _cmd_info(args) + elif args.command == "process": + _cmd_process(args) + elif args.command == "list-outputs": + _cmd_list_outputs(args) + elif args.command == "algorithms": + _cmd_algorithms(args) + elif args.command == "config": + if args.config_command == "show": + _cmd_config_show(args) + elif args.config_command == "save": + _cmd_config_save(args) + elif args.command == "preview": + if args.preview_command == "electrode-path": + _cmd_preview_electrode(args) + elif args.preview_command == "frequency-blend": + _cmd_preview_frequency(args) + elif args.preview_command == "pulse-shape": + _cmd_preview_pulse(args) + + +if __name__ == "__main__": + main() diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md new file mode 100644 index 0000000..4cd4382 --- /dev/null +++ b/docs/CLI_REFERENCE.md @@ -0,0 +1,336 @@ +# CLI Reference + +All commands go through `cli.py` — the adapter layer over edger477's processing engine. + +> Engine: https://github.com/edger477/funscript-tools — all algorithm credit to edger477. + +``` +python cli.py [options] +python cli.py --help # detailed help for any command +``` + +--- + +## Commands at a glance + +| Command | What it does | +|---------|-------------| +| `info` | Show metadata about a .funscript file | +| `process` | Run the full pipeline — generates all restim output files | +| `list-outputs` | List generated output files for a given input | +| `algorithms` | List 2D conversion algorithms with descriptions | +| `config show` | Print the default configuration as JSON | +| `config save` | Save default config to a file for editing | +| `preview electrode-path` | Show 2D electrode path shape for an algorithm | +| `preview frequency-blend` | Plain-language description of frequency blend settings | +| `preview pulse-shape` | Pulse silhouette for given width and rise time settings | + +--- + +## `info` + +Show metadata about a .funscript file without processing it. + +```bash +python cli.py info +``` + +```bash +# Example +python cli.py info examples/sample.funscript + +# Output +File: sample.funscript +Actions: 18 +Duration: 00:10 +Range: 0 – 100 +``` + +--- + +## `process` + +Run the full processing pipeline. Generates all restim output files. + +```bash +python cli.py process [--config FILE] [--output-dir DIR] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `file` | Path to the .funscript file to process | +| `--config FILE` | JSON config file (from `config save`). Uses defaults if omitted. | +| `--output-dir DIR` | Where to write output files. Default: same folder as input. | + +```bash +# Default settings, outputs next to input +python cli.py process my_scene.funscript + +# Custom output directory +python cli.py process my_scene.funscript --output-dir ~/restim/outputs/ + +# Saved config (see config save below) +python cli.py process my_scene.funscript --config my_config.json + +# Both +python cli.py process my_scene.funscript --config my_config.json --output-dir ~/restim/ + +# Batch — process every file in a folder +for f in ~/scenes/*.funscript; do + python cli.py process "$f" --output-dir ~/restim/ +done +``` + +**Output files generated:** + +| File | What it controls in restim | +|------|---------------------------| +| `*.alpha.funscript` | 2D electrode X position | +| `*.beta.funscript` | 2D electrode Y position | +| `*.alpha-prostate.funscript` | Prostate electrode X | +| `*.beta-prostate.funscript` | Prostate electrode Y | +| `*.frequency.funscript` | Pulse rate envelope | +| `*.pulse_frequency.funscript` | Pulse rate by action intensity | +| `*.volume.funscript` | Amplitude envelope | +| `*.volume-prostate.funscript` | Prostate amplitude | +| `*.pulse_width.funscript` | Pulse width | +| `*.pulse_rise_time.funscript` | Pulse sharpness | + +--- + +## `list-outputs` + +List generated output files for a given input stem. + +```bash +python cli.py list-outputs +``` + +```bash +# Example +python cli.py list-outputs . my_scene + +# Output + alpha 2.4 KB + beta 2.4 KB + frequency 1.2 KB + ... +``` + +--- + +## `algorithms` + +List all available 2D electrode path algorithms with plain-language descriptions. + +```bash +python cli.py algorithms + +# Output + circular Circular (0°–180°) + Smooth semi-circle. Balanced, works well for most content. + + top-right-left Top-Right-Bottom-Left (0°–270°) + Wider arc. More variation, stronger contrast between strokes. + + top-left-right Top-Left-Bottom-Right (0°–90°) + Narrower arc. Subtle, good for slower content. + + restim-original Restim Original (0°–360°) + Full circle with random direction changes. Most unpredictable. +``` + +Set your preferred algorithm in a saved config: + +```bash +python cli.py config save my_config.json +# Edit my_config.json → alpha_beta_generation.algorithm → "circular" +python cli.py process scene.funscript --config my_config.json +``` + +--- + +## `config show` + +Print the default configuration (or a single section) as formatted JSON. + +```bash +python cli.py config show [section] +``` + +```bash +# Full config +python cli.py config show + +# Just the frequency settings +python cli.py config show frequency + +# Just the pulse settings +python cli.py config show pulse +``` + +Available sections: `general`, `frequency`, `pulse`, `volume`, +`alpha_beta_generation`, `prostate_generation`, `advanced`, +`options`, `positional_axes`, `speed` + +--- + +## `config save` + +Save the default configuration to a JSON file so you can edit and reuse it. + +```bash +python cli.py config save [--force] +``` + +```bash +# Save defaults +python cli.py config save my_config.json + +# Overwrite existing file +python cli.py config save my_config.json --force + +# Typical workflow +python cli.py config save configs/gentle.json +# → edit gentle.json: lower frequency ratios, softer pulse rise +python cli.py process scene.funscript --config configs/gentle.json +``` + +**Key settings to tune:** + +| JSON path | What it changes | +|-----------|----------------| +| `alpha_beta_generation.algorithm` | 2D path algorithm (`circular`, `top-right-left`, etc.) | +| `alpha_beta_generation.min_distance_from_center` | How wide the electrode motion range is (0.1–0.9) | +| `frequency.frequency_ramp_combine_ratio` | Slow build vs scene energy blend (1–10) | +| `frequency.pulse_freq_min` / `pulse_freq_max` | Pulse rate output range (0.0–1.0) | +| `pulse.pulse_width_min` / `pulse_width_max` | Pulse width range | +| `pulse.pulse_rise_min` / `pulse_rise_max` | Pulse sharpness — low = sharp, high = soft | +| `volume.volume_ramp_combine_ratio` | Volume blend ratio | +| `options.overwrite_existing_files` | Whether to regenerate existing outputs | + +--- + +## `preview electrode-path` + +Show the 2D electrode path shape an algorithm produces — no processing required. + +```bash +python cli.py preview electrode-path [--algorithm ALGO] [--min-distance N] + [--speed-threshold N] [--points N] [--json] +``` + +```bash +# Default algorithm (circular) +python cli.py preview electrode-path + +# Output +Algorithm: Circular (0°–180°) +Description: Smooth semi-circle. Balanced, works well for most content. +Points: 200 alpha, 200 beta +Alpha range: 0.100 – 0.900 +Beta range: 0.500 – 0.900 + +# Compare algorithms +python cli.py preview electrode-path --algorithm top-right-left +python cli.py preview electrode-path --algorithm restim-original + +# Wider motion range +python cli.py preview electrode-path --algorithm circular --min-distance 0.3 + +# Machine-readable output (pipe to plotting tools) +python cli.py preview electrode-path --json +``` + +--- + +## `preview frequency-blend` + +Translate frequency combine ratios into plain English. + +```bash +python cli.py preview frequency-blend [--ramp-ratio N] [--pulse-ratio N] [--json] +``` + +```bash +# Default ratios +python cli.py preview frequency-blend + +# Output + Frequency feel: balanced — responsive with a slow build + Frequency: 50.0% slow build + 50.0% scene energy + Pulse: 66.7% scene energy + 33.3% spatial position + +# Ramp-heavy (slow, gradual build) +python cli.py preview frequency-blend --ramp-ratio 8 + +# Output + Frequency feel: gradual, builds slowly + Frequency: 87.5% slow build + 12.5% scene energy + +# Speed-heavy (reactive to action) +python cli.py preview frequency-blend --ramp-ratio 1 + +# Output + Frequency feel: reactive, follows action closely + Frequency: 0.0% slow build + 100.0% scene energy +``` + +--- + +## `preview pulse-shape` + +Describe the pulse silhouette for given width and rise time settings. + +```bash +python cli.py preview pulse-shape [--width-min N] [--width-max N] + [--rise-min N] [--rise-max N] [--json] +``` + +```bash +# Default settings +python cli.py preview pulse-shape + +# Output + Pulse: medium width, medium — smooth ramp + Width (mid): 0.275 + Rise (mid): 0.4 + Sharpness: medium + +# Sharp, narrow pulses +python cli.py preview pulse-shape --width-min 0.05 --width-max 0.2 --rise-min 0.0 --rise-max 0.05 + +# Output + Pulse: narrow width, sharp — immediate onset + +# Soft, wide pulses +python cli.py preview pulse-shape --width-min 0.3 --width-max 0.6 --rise-min 0.5 --rise-max 0.9 + +# Output + Pulse: wide width, soft — gentle build +``` + +--- + +## Using `--json` for scripting + +All `preview` commands and `config show` output valid JSON with `--json`, +making them pipeable to other tools: + +```bash +# Pipe to Python for further processing +python cli.py preview electrode-path --json | python -c " +import json, sys +data = json.load(sys.stdin) +print(f'Path covers {len(data[\"alpha\"])} points') +" + +# Pipe to jq +python cli.py config show frequency --json | jq '.pulse_freq_min' + +# Save preview data for plotting +python cli.py preview electrode-path --algorithm circular --json > path_circular.json +python cli.py preview electrode-path --algorithm top-right-left --json > path_trl.json +``` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..150e563 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,278 @@ +# User Guide + +> **Credit:** The processing engine is [edger477's funscript-tools](https://github.com/edger477/funscript-tools). +> This guide covers the CLI adapter layer built on top of it. + +--- + +## What this tool does + +You have a `.funscript` — a timeline of position movements, originally created for +a stroker device. This tool converts that file into a set of estim (e-stim) control +signals for use in [restim](https://github.com/edger477/restim). + +One `.funscript` in → ten output files out. Each output controls a different +dimension of what you feel. + +--- + +## The ten output files + +| File | What it controls | What you feel | +|------|-----------------|---------------| +| `*.alpha.funscript` | Electrode X position | Left–right movement of sensation | +| `*.beta.funscript` | Electrode Y position | Up–down movement of sensation | +| `*.alpha-prostate.funscript` | Prostate electrode X | Spatial position for prostate channel | +| `*.beta-prostate.funscript` | Prostate electrode Y | Spatial position for prostate channel | +| `*.frequency.funscript` | Pulse rate envelope | Overall intensity rhythm | +| `*.pulse_frequency.funscript` | Pulse rate by action | How directly action speed maps to sensation rate | +| `*.volume.funscript` | Amplitude envelope | Overall strength | +| `*.volume-prostate.funscript` | Prostate amplitude | Prostate channel strength | +| `*.pulse_width.funscript` | Pulse duration | Fullness vs sharpness of each pulse | +| `*.pulse_rise_time.funscript` | Pulse attack | Hard edge vs soft onset of each pulse | + +Think of the alpha/beta pair as *where* you feel it, frequency as *how fast*, pulse shape +as *what kind*, and volume as *how strong*. + +--- + +## The three creative decisions + +Most of the config is infrastructure you set once. Three things actually change +the character of the output: + +### 1. Algorithm — where the sensation moves + +The algorithm controls the path the electrode position traces as the source +funscript plays back. Different algorithms produce different spatial movement patterns. + +| Algorithm | Path shape | Character | +|-----------|------------|-----------| +| `circular` | Semi-circle (0°–180°) | Smooth, balanced, good for most content | +| `top-right-left` | Wide arc (0°–270°) | More variation, stronger contrast between strokes | +| `top-left-right` | Narrow arc (0°–90°) | Subtle, suited to slower, gentler content | +| `restim-original` | Full circle with random reversals | Unpredictable, most varied | + +**To explore algorithms before committing:** +```bash +python cli.py algorithms +python cli.py preview electrode-path --algorithm circular +python cli.py preview electrode-path --algorithm top-right-left +python cli.py preview electrode-path --algorithm restim-original +``` + +**What `--min-distance` does:** +The `min_distance_from_center` config setting (0.1–0.9) controls how far from center +the electrode can range. Higher = wider sweep, more pronounced movement. + +``` +low (0.1) ████░░░░░░ electrode stays near center — subtle +mid (0.5) ██░░░░░░░░ moderate range +high (0.9) █░░░░░░░░░ full range — strong, sweeping movement +``` + +--- + +### 2. Frequency blend — how sensation tracks the action + +The frequency output is a blend of two signals: + +- **Scene energy (ramp):** A slow-building intensity curve that rises and falls with + the overall pace of a scene. Think of it as the "mood arc." +- **Action speed:** Direct tracking of how fast the source funscript is moving. + Fast strokes → faster pulse rate, immediately. + +The `frequency_ramp_combine_ratio` (1–10) sets the blend: + +``` +ratio 1: ████████████ 100% action speed — highly reactive, follows every stroke +ratio 3: ██████░░░░░░ 75% action speed + 25% ramp +ratio 5: ████████░░░░ 50% / 50% ← default, balanced +ratio 8: ░░░░░░░░░░██ 12% action speed + 88% ramp — slow, gradual build +ratio 10: ░░░░░░░░░░░░ 100% ramp — ignores action speed entirely +``` + +**To hear what these mean before processing:** +```bash +python cli.py preview frequency-blend --ramp-ratio 1 +python cli.py preview frequency-blend --ramp-ratio 5 +python cli.py preview frequency-blend --ramp-ratio 8 +``` + +**Rule of thumb:** +- Fast, intense content → lower ratio (reactive) +- Slow, scene-building content → higher ratio (gradual build) +- Mixed content → default (5) + +--- + +### 3. Pulse shape — the character of each pulse + +Each electrical pulse has two physical dimensions: + +**Width** (how long each pulse lasts): +``` +narrow ▐█▌ short, sharp individual pulses +medium ▐███▌ +wide ▐███████▌ long, full pulses — more "filled in" sensation +``` + +**Rise time** (how the pulse attacks): +``` +sharp ▐█▌ ▐█▌ immediate onset, hard edge + ▐/▌ ▐\▌ +soft ▐ ▌▐ ▌ gradual build-in, rounded feel +``` + +The config sets a min and max for each — the output file sweeps between them based +on the source funscript's intensity. + +```bash +python cli.py preview pulse-shape --width-min 0.05 --width-max 0.2 --rise-min 0.0 --rise-max 0.05 +# → narrow width, sharp — immediate onset + +python cli.py preview pulse-shape --width-min 0.3 --width-max 0.6 --rise-min 0.5 --rise-max 0.9 +# → wide width, soft — gentle build +``` + +--- + +## Typical workflow + +### One-off: explore and process + +```bash +# 1. Inspect your file +python cli.py info my_scene.funscript + +# 2. Preview your creative settings (no file written) +python cli.py preview electrode-path --algorithm circular +python cli.py preview frequency-blend --ramp-ratio 5 +python cli.py preview pulse-shape + +# 3. Process with defaults +python cli.py process my_scene.funscript + +# 4. Check what was generated +python cli.py list-outputs . my_scene +``` + +### Iterative: save a config and tune it + +```bash +# Save defaults to a file +python cli.py config save configs/my_style.json + +# Edit the file — adjust algorithm, frequency blend, pulse shape +# (see Key settings below) + +# Process with your config +python cli.py process my_scene.funscript --config configs/my_style.json + +# Regenerate to refine +python cli.py process my_scene.funscript --config configs/my_style.json +``` + +### Toolchain: batch process a library + +```bash +# Process every scene with the same config +for f in ~/scenes/*.funscript; do + python cli.py process "$f" \ + --config configs/my_style.json \ + --output-dir ~/restim/outputs/ +done +``` + +Or from Python (e.g. in a build script or CI pipeline): + +```python +from cli import load_file, get_default_config, process, list_outputs +import json, pathlib + +config = json.loads(pathlib.Path("configs/my_style.json").read_text()) +scenes = pathlib.Path("~/scenes").expanduser().glob("*.funscript") + +for scene in scenes: + info = load_file(str(scene)) + print(f"Processing {info['name']} ({info['duration_fmt']})…") + result = process(str(scene), config) + if result["success"]: + for out in result["outputs"]: + print(f" ✓ {out['suffix']}") + else: + print(f" ✗ {result['error']}") +``` + +--- + +## Key config settings + +Run `python cli.py config save my_config.json` to get a full config template. +The settings you'll actually want to change: + +| Setting | Where | What to change it for | +|---------|-------|-----------------------| +| `algorithm` | `alpha_beta_generation` | Spatial movement character | +| `min_distance_from_center` | `alpha_beta_generation` | How wide the spatial sweep is | +| `frequency_ramp_combine_ratio` | `frequency` | Reactive vs gradual build | +| `pulse_freq_min` / `pulse_freq_max` | `frequency` | Overall pulse rate range | +| `pulse_width_min` / `pulse_width_max` | `pulse` | Pulse fullness range | +| `pulse_rise_min` / `pulse_rise_max` | `pulse` | Sharp vs soft attack range | +| `rest_level` | `general` | Baseline intensity during slow sections | +| `overwrite_existing_files` | `options` | Whether to regenerate existing outputs | + +--- + +## Automating with `--json` + +All `preview` commands return JSON when called with `--json`. This is designed +for toolchain use — pipe data to a plotter, feed it to a UI, or drive a CI +artifact check. + +```bash +# Capture electrode path data for plotting +python cli.py preview electrode-path --algorithm circular --json > circular.json +python cli.py preview electrode-path --algorithm restim-original --json > original.json + +# Compare frequency blend options +python cli.py preview frequency-blend --ramp-ratio 1 --json +python cli.py preview frequency-blend --ramp-ratio 8 --json + +# Pull a single config value +python cli.py config show frequency | python -c " +import json, sys +cfg = json.load(sys.stdin) +print(f'Pulse rate range: {cfg[\"pulse_freq_min\"]} – {cfg[\"pulse_freq_max\"]}') +" +``` + +--- + +## Named config profiles (recommended for toolchains) + +Save multiple profiles for different content types and call them by name: + +``` +configs/ + gentle.json # soft pulse, gradual build, narrow arc + reactive.json # sharp pulse, high action-tracking, wide arc + scene-builder.json # high ramp ratio, builds over time + default.json # baseline — good starting point +``` + +```bash +# In a Makefile or build script: +python cli.py process $SCENE --config configs/reactive.json --output-dir dist/ +``` + +--- + +## Next steps + +- **Visualizations:** The UI (coming in FunScriptForge) shows live before/after + waveform comparisons for every setting above. Every slider move updates the + preview in real time. +- **More docs:** See [CLI_REFERENCE.md](CLI_REFERENCE.md) for flag-level detail + on every command. +- **MkDocs:** This doc folder will be surfaced as a searchable site via GitHub Pages. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a78c242 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,242 @@ +# Examples + +These examples show the two ways to drive funscript-tools: + +- **Shell script** — drop into a build pipeline, file watcher, or batch job +- **Python script** — import `cli.py` directly for scripted or automated workflows + +Both use the same default settings and produce the same outputs. +They are also the foundation of the test suite — `tests/test_cli.py` runs +the same scenarios with assertions instead of printed output. + +> Processing engine by edger477: https://github.com/edger477/funscript-tools +> All algorithm credit belongs to edger477 and contributors. + +--- + +## Why you might want these + +| Scenario | Use | +|----------|-----| +| You have a config you like and just want to process new files quickly | bash script in a loop or file watcher | +| You're building a pipeline that generates restim-ready files as part of a larger workflow | Python import — call `cli.process()` programmatically | +| You want to understand what cli.py exposes before building a UI | Python script — it demos every public function | +| You want to verify your install is working | Run either script against `sample.funscript` | + +--- + +## Prerequisites + +```bash +# From the repo root +pip install -r requirements.txt +``` + +Python 3.10+ required. No other setup needed. + +--- + +## The sample file + +`sample.funscript` is a minimal 10-second funscript with realistic up/down +motion. It's used by both examples and by the test suite. You can replace it +with any `.funscript` file from your own library. + +**What's in it:** 18 actions over 10 seconds, position range 0–100. + +--- + +## process_default.sh + +Processes a funscript file using all default settings. +Outputs are written next to the input file. + +### What it does + +1. Shows file info (name, action count, duration, position range) +2. Runs the full processing pipeline with default config +3. Lists all generated output files with sizes + +### Run it + +```bash +# Against the included sample +bash examples/process_default.sh + +# Against your own file +bash examples/process_default.sh path/to/your/file.funscript + +# With a custom output directory +bash examples/process_default.sh path/to/file.funscript --output-dir ~/restim/outputs/ +``` + +### Expected output + +``` +======================================== + funscript-tools — default processing + Engine by edger477 +======================================== + +[ Info ] + File: sample.funscript + Actions: 18 + Duration: 00:10 + Range: 0 – 100 + +[ Processing ] + [████████████████████] 100% Processing complete! + +[ Outputs ] + alpha 2.4 KB + alpha-prostate 1.8 KB + beta 2.4 KB + beta-prostate 1.8 KB + frequency 1.2 KB + pulse_frequency 1.3 KB + pulse_rise_time 1.1 KB + pulse_width 1.2 KB + volume 1.1 KB + volume-prostate 1.0 KB + +Done. +``` + +File sizes will vary with input length and config. + +--- + +## process_default.py + +The Python equivalent — and a tour of every public `cli.py` function. + +In addition to processing the file, it also calls the **preview functions**: +fast computations that return visualization data without writing any files. +These are the same calls the UI makes to update plots on every slider move. + +### What it does + +1. `cli.load_file()` — loads the funscript, returns metadata + waveform arrays +2. `cli.get_default_config()` — shows the key creative settings +3. `cli.preview_electrode_path()` — 2D path shape data (no file I/O) +4. `cli.preview_frequency_blend()` — plain-language blend description +5. `cli.preview_pulse_shape()` — pulse silhouette data +6. `cli.process()` — runs the full pipeline, returns output file list +7. Shows all generated outputs with sizes + +### Run it + +```bash +# Against the included sample +python examples/process_default.py + +# Against your own file +python examples/process_default.py path/to/your/file.funscript + +# With a custom output directory +python examples/process_default.py path/to/file.funscript --output-dir ~/restim/outputs/ +``` + +### Expected output + +``` +================================================== + funscript-tools — default processing + Engine by edger477 +================================================== + +[ File Info ] + Name: sample.funscript + Actions: 18 + Duration: 00:10 + Range: 0 – 100 + +[ Default Config (key creative settings) ] + Algorithm: top-right-left + Min dist. center: 0.1 + Freq ramp blend: 2 + Pulse freq range: 0.4 – 0.95 + Pulse width range: 0.1 – 0.45 + +[ Previews ] + Electrode path: Top-Right-Bottom-Left (0°–270°) + Wider arc. More variation, stronger contrast between strokes. + 200 points generated + Frequency blend: Frequency feel: gradual, builds slowly + 50.0% slow build + 50.0% scene energy + Pulse shape: Pulse: medium width, medium — smooth ramp + +[ Processing ] + [████████████████████] 100% Processing complete! + +[ Outputs — 10 files ] + alpha 2.4 KB + alpha-prostate 1.8 KB + beta 2.4 KB + beta-prostate 1.8 KB + frequency 1.2 KB + pulse_frequency 1.3 KB + pulse_rise_time 1.1 KB + pulse_width 1.2 KB + volume 1.1 KB + volume-prostate 1.0 KB + +Done. +``` + +--- + +## Output files — what they are + +All outputs are `.funscript` files that restim reads to control estim parameters: + +| File | What it controls | +|------|-----------------| +| `alpha`, `beta` | 2D electrode position — *where* on the pad the stimulus sits | +| `alpha-prostate`, `beta-prostate` | Same for prostate electrode geometry | +| `frequency` | Pulse rate envelope — how fast pulses fire over time | +| `pulse_frequency` | Pulse rate modulated by action intensity | +| `volume` | Amplitude envelope — how strong the signal is | +| `volume-prostate` | Amplitude for the prostate channel | +| `pulse_width` | How wide each individual pulse is | +| `pulse_rise_time` | How fast each pulse ramps up — sharpness vs softness | + +--- + +## Using as a batch pipeline + +```bash +# Process every .funscript in a directory +for f in ~/scenes/*.funscript; do + bash examples/process_default.sh "$f" --output-dir ~/restim/ +done +``` + +```python +# Python batch processing +import cli + +config = cli.get_default_config() +# Tune config once here if you want non-default settings + +for path in Path("~/scenes").expanduser().glob("*.funscript"): + result = cli.process(str(path), config) + if result["success"]: + print(f"{path.name}: {len(result['outputs'])} files generated") + else: + print(f"{path.name}: FAILED — {result['error']}") +``` + +--- + +## Troubleshooting + +**`ModuleNotFoundError: No module named 'numpy'`** +Run `pip install -r requirements.txt` from the repo root. + +**`FileNotFoundError` or empty outputs** +Check that your input file ends in `.funscript` and is valid JSON. +Run `python cli.py info your_file.funscript` to verify it loads. + +**Outputs written to the wrong place** +By default, outputs go next to the input file. Use `--output-dir` to redirect. diff --git a/examples/process_default.py b/examples/process_default.py new file mode 100644 index 0000000..06b97aa --- /dev/null +++ b/examples/process_default.py @@ -0,0 +1,117 @@ +""" +examples/process_default.py + +Simplest possible usage: process a funscript with all defaults. +Also demonstrates the preview functions — the same calls the UI uses +to show visualizations without running the full pipeline. + +Usage: + python examples/process_default.py + python examples/process_default.py path/to/your/file.funscript + python examples/process_default.py path/to/file.funscript --output-dir /some/dir +""" + +import argparse +import sys +from pathlib import Path + +# cli.py is the only import needed — it wraps all upstream internals. +sys.path.insert(0, str(Path(__file__).parent.parent)) +import cli + + +def main(): + parser = argparse.ArgumentParser(description="Process a funscript with default settings") + parser.add_argument( + "file", + nargs="?", + default=str(Path(__file__).parent / "sample.funscript"), + help="Path to .funscript file (default: examples/sample.funscript)", + ) + parser.add_argument("--output-dir", help="Output directory (default: same as input)") + args = parser.parse_args() + + print("=" * 50) + print(" funscript-tools — default processing") + print(" Engine by edger477") + print("=" * 50) + + # ── Step 1: load and inspect the file ──────────────────────────────────── + print("\n[ File Info ]") + try: + info = cli.load_file(args.file) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f" Name: {info['name']}") + print(f" Actions: {info['actions']}") + print(f" Duration: {info['duration_fmt']}") + print(f" Range: {info['pos_min']:.0f} – {info['pos_max']:.0f}") + + # ── Step 2: inspect default config ─────────────────────────────────────── + print("\n[ Default Config (key creative settings) ]") + config = cli.get_default_config() + ab = config["alpha_beta_generation"] + fq = config["frequency"] + pu = config["pulse"] + print(f" Algorithm: {ab['algorithm']}") + print(f" Min dist. center: {ab['min_distance_from_center']}") + print(f" Freq ramp blend: {fq['frequency_ramp_combine_ratio']}") + print(f" Pulse freq range: {fq['pulse_freq_min']} – {fq['pulse_freq_max']}") + print(f" Pulse width range: {pu['pulse_width_min']} – {pu['pulse_width_max']}") + + # ── Step 3: preview without processing (UI uses these for live viz) ─────── + print("\n[ Previews ]") + + path_data = cli.preview_electrode_path( + algorithm=ab["algorithm"], + min_distance_from_center=ab["min_distance_from_center"], + ) + print(f" Electrode path: {path_data['label']}") + print(f" {path_data['description']}") + print(f" {len(path_data['alpha'])} points generated") + + blend = cli.preview_frequency_blend( + frequency_ramp_combine_ratio=fq["frequency_ramp_combine_ratio"], + pulse_frequency_combine_ratio=fq["pulse_frequency_combine_ratio"], + ) + print(f" Frequency blend: {blend['overall_label']}") + print(f" {blend['frequency_label']}") + + pulse = cli.preview_pulse_shape( + width_min=pu["pulse_width_min"], + width_max=pu["pulse_width_max"], + rise_min=pu["pulse_rise_min"], + rise_max=pu["pulse_rise_max"], + ) + print(f" Pulse shape: {pulse['label']}") + + # ── Step 4: run the full pipeline ───────────────────────────────────────── + print("\n[ Processing ]") + if args.output_dir: + config.setdefault("advanced", {})["custom_output_directory"] = args.output_dir + + def on_progress(pct, msg): + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r [{bar}] {pct:3d}% {msg:<45}", end="", flush=True) + + result = cli.process(args.file, config, on_progress=on_progress) + print() # newline after progress + + if not result["success"]: + print(f"\nError: {result['error']}", file=sys.stderr) + sys.exit(1) + + # ── Step 5: show outputs ────────────────────────────────────────────────── + print(f"\n[ Outputs — {len(result['outputs'])} files ]") + for out in result["outputs"]: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:6.1f} KB") + + print("\nDone.") + return result + + +if __name__ == "__main__": + main() diff --git a/examples/process_default.sh b/examples/process_default.sh new file mode 100644 index 0000000..4a9c6e1 --- /dev/null +++ b/examples/process_default.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# examples/process_default.sh +# +# Simplest possible usage: process a funscript with all defaults. +# Outputs land next to the input file. +# +# Usage: +# ./examples/process_default.sh +# ./examples/process_default.sh path/to/your/file.funscript +# ./examples/process_default.sh path/to/file.funscript --output-dir /some/dir + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +INPUT="${1:-$SCRIPT_DIR/sample.funscript}" +SHIFT_DONE=false +if [ $# -ge 1 ]; then shift; SHIFT_DONE=true; fi + +echo "========================================" +echo " funscript-tools — default processing" +echo " Engine by edger477" +echo "========================================" +echo + +# ── Step 1: show file info ──────────────────────────────────────────────────── +echo "[ Info ]" +python "$REPO_ROOT/cli.py" info "$INPUT" +echo + +# ── Step 2: process with defaults ──────────────────────────────────────────── +echo "[ Processing ]" +python "$REPO_ROOT/cli.py" process "$INPUT" "$@" +echo + +# ── Step 3: list what was generated ────────────────────────────────────────── +STEM="$(basename "$INPUT" .funscript)" +DIR="$(dirname "$INPUT")" + +echo "[ Outputs ]" +python "$REPO_ROOT/cli.py" list-outputs "$DIR" "$STEM" +echo +echo "Done." diff --git a/examples/sample.funscript b/examples/sample.funscript new file mode 100644 index 0000000..f80c2dd --- /dev/null +++ b/examples/sample.funscript @@ -0,0 +1,25 @@ +{ + "version": 1, + "inverted": false, + "range": 90, + "actions": [ + {"at": 0, "pos": 0}, + {"at": 500, "pos": 100}, + {"at": 1000, "pos": 5}, + {"at": 1400, "pos": 95}, + {"at": 1750, "pos": 10}, + {"at": 2000, "pos": 90}, + {"at": 2400, "pos": 0}, + {"at": 3000, "pos": 100}, + {"at": 3600, "pos": 5}, + {"at": 4000, "pos": 80}, + {"at": 4500, "pos": 15}, + {"at": 5000, "pos": 95}, + {"at": 5800, "pos": 0}, + {"at": 6500, "pos": 100}, + {"at": 7200, "pos": 5}, + {"at": 8000, "pos": 90}, + {"at": 9000, "pos": 10}, + {"at": 10000, "pos": 100} + ] +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4acad9d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +""" +conftest.py — pytest configuration for funscript-tools tests. + +All tests go through cli.py only — never import upstream internals directly. +""" +import sys +from pathlib import Path + +# Ensure repo root is on the path so `import cli` works +sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1fa89bb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,281 @@ +""" +tests/test_cli.py + +Tests for the cli.py adapter layer. + +These are the canonical tests — they call only cli.py, never upstream +internals directly. If these pass, the adapter contract is intact regardless +of what changed upstream. + +Run: + python -m pytest tests/test_cli.py -v + python tests/test_cli.py # without pytest +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import cli + +SAMPLE = Path(__file__).parent.parent / "examples" / "sample.funscript" + +# Minimal inline funscript for tests that don't need the full sample +MINIMAL_FUNSCRIPT = { + "version": 1, + "actions": [ + {"at": 0, "pos": 0}, + {"at": 500, "pos": 100}, + {"at": 1000, "pos": 5}, + {"at": 1500, "pos": 95}, + {"at": 2000, "pos": 10}, + {"at": 3000, "pos": 100}, + {"at": 4000, "pos": 0}, + {"at": 5000, "pos": 80}, + ], +} + + +def _write_temp_funscript(data=None) -> Path: + """Write a funscript to a temp file and return its path.""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".funscript", delete=False + ) + json.dump(data or MINIMAL_FUNSCRIPT, f) + f.close() + return Path(f.name) + + +class TestLoadFile(unittest.TestCase): + + def test_load_sample(self): + """load_file returns expected keys and sane values.""" + info = cli.load_file(str(SAMPLE)) + self.assertIn("name", info) + self.assertIn("actions", info) + self.assertIn("duration_s", info) + self.assertIn("duration_fmt", info) + self.assertIn("x", info) + self.assertIn("y", info) + self.assertGreater(info["actions"], 0) + self.assertGreater(info["duration_s"], 0) + self.assertGreater(len(info["x"]), 0) + self.assertEqual(len(info["x"]), len(info["y"])) + + def test_load_values_in_range(self): + """y values should be 0–100.""" + info = cli.load_file(str(SAMPLE)) + self.assertGreaterEqual(min(info["y"]), 0) + self.assertLessEqual(max(info["y"]), 100) + + def test_load_missing_file(self): + """load_file raises ValueError for missing files.""" + with self.assertRaises(ValueError): + cli.load_file("/nonexistent/path/file.funscript") + + def test_load_wrong_extension(self): + """load_file raises ValueError for non-.funscript files.""" + with self.assertRaises(ValueError): + cli.load_file("/some/file.txt") + + def test_load_temp_file(self): + """load_file works on a freshly written temp file.""" + p = _write_temp_funscript() + try: + info = cli.load_file(str(p)) + self.assertEqual(info["actions"], len(MINIMAL_FUNSCRIPT["actions"])) + finally: + p.unlink() + + +class TestGetDefaultConfig(unittest.TestCase): + + def test_returns_dict(self): + cfg = cli.get_default_config() + self.assertIsInstance(cfg, dict) + + def test_has_required_sections(self): + cfg = cli.get_default_config() + for section in ("general", "frequency", "volume", "pulse", + "alpha_beta_generation", "options"): + self.assertIn(section, cfg, f"Missing config section: {section}") + + def test_independent_copies(self): + """Each call returns an independent copy — mutations don't bleed.""" + cfg1 = cli.get_default_config() + cfg2 = cli.get_default_config() + cfg1["frequency"]["pulse_freq_min"] = 0.99 + self.assertNotEqual( + cfg2["frequency"]["pulse_freq_min"], 0.99, + "get_default_config() should return independent copies", + ) + + +class TestProcess(unittest.TestCase): + + def test_process_default(self): + """process() with defaults succeeds and returns output files.""" + p = _write_temp_funscript() + try: + config = cli.get_default_config() + # Write outputs next to the temp file + result = cli.process(str(p), config) + self.assertTrue(result["success"], msg=result.get("error")) + self.assertIsInstance(result["outputs"], list) + self.assertGreater(len(result["outputs"]), 0) + finally: + # Clean up temp file and any generated outputs + stem = p.stem + for f in p.parent.glob(f"{stem}*"): + f.unlink(missing_ok=True) + if p.parent.joinpath("funscript-temp").exists(): + import shutil + shutil.rmtree(p.parent / "funscript-temp", ignore_errors=True) + + def test_process_returns_known_suffixes(self): + """process() generates at least the core output types.""" + p = _write_temp_funscript() + try: + config = cli.get_default_config() + result = cli.process(str(p), config) + suffixes = {o["suffix"] for o in result["outputs"]} + for expected in ("alpha", "beta", "frequency", "volume"): + self.assertIn(expected, suffixes, f"Missing output: {expected}") + finally: + stem = p.stem + for f in p.parent.glob(f"{stem}*"): + f.unlink(missing_ok=True) + import shutil + shutil.rmtree(p.parent / "funscript-temp", ignore_errors=True) + + def test_process_missing_file(self): + """process() on a missing file returns success=False.""" + config = cli.get_default_config() + result = cli.process("/nonexistent/file.funscript", config) + self.assertFalse(result["success"]) + self.assertIsNotNone(result["error"]) + + +class TestListOutputs(unittest.TestCase): + + def test_empty_dir(self): + """list_outputs returns empty list when nothing matches.""" + with tempfile.TemporaryDirectory() as d: + outputs = cli.list_outputs(d, "nonexistent_stem") + self.assertEqual(outputs, []) + + def test_finds_files(self): + """list_outputs finds files matching the stem pattern.""" + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + (dp / "mystem.alpha.funscript").write_text("{}") + (dp / "mystem.beta.funscript").write_text("{}") + (dp / "other.alpha.funscript").write_text("{}") # should not match + + outputs = cli.list_outputs(d, "mystem") + suffixes = {o["suffix"] for o in outputs} + self.assertIn("alpha", suffixes) + self.assertIn("beta", suffixes) + self.assertNotIn("other", suffixes) + self.assertEqual(len(outputs), 2) + + +class TestPreviewElectrodePath(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_electrode_path() + self.assertIn("alpha", result) + self.assertIn("beta", result) + self.assertIn("label", result) + self.assertIn("description", result) + + def test_all_algorithms(self): + """All known algorithms return valid data.""" + for algo in cli.ALGORITHMS: + result = cli.preview_electrode_path(algorithm=algo) + self.assertGreater(len(result["alpha"]), 0, f"No data for {algo}") + self.assertEqual(len(result["alpha"]), len(result["beta"])) + + def test_values_in_range(self): + """Path values should stay within 0–1.""" + result = cli.preview_electrode_path() + for v in result["alpha"]: + self.assertGreaterEqual(v, -0.1) # small tolerance for edge algorithms + self.assertLessEqual(v, 1.1) + + +class TestPreviewFrequencyBlend(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_frequency_blend() + for key in ("frequency_ramp_pct", "frequency_speed_pct", + "pulse_speed_pct", "pulse_alpha_pct", + "frequency_label", "pulse_label", "overall_label"): + self.assertIn(key, result) + + def test_percentages_sum_to_100(self): + result = cli.preview_frequency_blend(2.0, 3.0) + self.assertAlmostEqual( + result["frequency_ramp_pct"] + result["frequency_speed_pct"], + 100.0, places=0 + ) + + def test_ratio_1_is_equal_split(self): + """Ratio of 1 means 100% of the second source.""" + result = cli.preview_frequency_blend(1.0, 1.0) + self.assertAlmostEqual(result["frequency_speed_pct"], 100.0, places=0) + + +class TestPreviewPulseShape(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_pulse_shape() + for key in ("x", "y", "width", "rise", "label", "sharpness"): + self.assertIn(key, result) + + def test_x_y_same_length(self): + result = cli.preview_pulse_shape() + self.assertEqual(len(result["x"]), len(result["y"])) + + def test_sharpness_values(self): + sharp = cli.preview_pulse_shape(rise_min=0.0, rise_max=0.05) + soft = cli.preview_pulse_shape(rise_min=0.5, rise_max=0.9) + self.assertEqual(sharp["sharpness"], "sharp") + self.assertEqual(soft["sharpness"], "soft") + + +class TestPreviewOutput(unittest.TestCase): + + def test_returns_original_data(self): + """preview_output always returns original waveform data.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + result = cli.preview_output(info, config, "alpha") + self.assertEqual(result["original_x"], info["x"]) + self.assertEqual(result["original_y"], info["y"]) + + def test_available_outputs(self): + """alpha, beta, speed, frequency, volume should all be previewable.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + for output_type in ("alpha", "beta", "speed", "frequency", "volume"): + result = cli.preview_output(info, config, output_type) + self.assertTrue( + result["available"], + f"preview_output failed for '{output_type}': {result['label']}" + ) + self.assertGreater(len(result["output_x"]), 0) + + def test_unknown_output_graceful(self): + """Unknown output type returns available=False, doesn't crash.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + result = cli.preview_output(info, config, "nonexistent_type") + self.assertFalse(result["available"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2)