I’ve been thinking of a uniform scaling for a while now.
For me, the best scaling to maintain the “original intent” of the scripter would be to change the distance traveled by a percentage (i.e. 80% scaling would mean that a distance of 10 would be changed to 8 and a distance of 70 would be changed to 56), while trying to stay near ‘the center of the original wave’ (i.e. if the original script was moving near the top, the transformed script should also be near that position).
Something like the red line here:
I came up with this (with some help of Claude 3.5 Sonnet):
using System;
using System.Collections.Generic;
using System.Linq;
namespace FunscriptToolbox.Core
{
public static class FunscriptAdjuster
{
public static FunscriptAction[] AdjustFunscript(ICollection<FunscriptAction> originalActions, double percentage)
{
var draft = AdjustFunscriptDraft(originalActions, percentage, 2);
return OptimizeFunscriptActions(originalActions, draft, percentage, 100, 5);
}
public static FunscriptAction[] AdjustFunscriptDraft(ICollection<FunscriptAction> originalActions, double percentage, int windowSize = 2)
{
if (originalActions.Count < 2)
{
return originalActions.ToArray();
}
var adjustedActions = new List<FunscriptAction>();
var movingAverage = CalculateMovingAverage(originalActions, windowSize);
int i = 0;
foreach (var original in originalActions)
{
var average = movingAverage[i];
// Calculate relative position (0 to 1) of the original action compared to the moving average
double relativePos = (original.Pos - average) / 100.0;
// Adjust the relative position based on the percentage
double adjustedRelativePos = relativePos * (percentage / 100.0);
// Calculate new position, ensuring it stays within 0-100 range
int newPos = (int)Math.Round(average + (adjustedRelativePos * 100));
newPos = Math.Max(0, Math.Min(100, newPos));
adjustedActions.Add(new FunscriptAction { At = original.At, Pos = newPos });
i++;
}
// Post-processing to preserve extreme points and overall shape
PreserveExtremePoints(adjustedActions, originalActions.ToList());
return adjustedActions.ToArray();
}
private static List<double> CalculateMovingAverage(ICollection<FunscriptAction> actions, int windowSize)
{
var positions = actions.Select(a => (double)a.Pos).ToList();
var movingAverage = new List<double>();
for (int i = 0; i < positions.Count; i++)
{
int start = Math.Max(0, i - windowSize / 2);
int end = Math.Min(positions.Count, i + windowSize / 2);
double average = positions.GetRange(start, end - start).Average();
movingAverage.Add(average);
}
return movingAverage;
}
private static void PreserveExtremePoints(List<FunscriptAction> adjustedActions, List<FunscriptAction> originalActions)
{
for (int i = 1; i < adjustedActions.Count - 1; i++)
{
var prev = adjustedActions[i - 1];
var current = adjustedActions[i];
var next = adjustedActions[i + 1];
var original = originalActions[i];
// Preserve local maxima
if (original.Pos > originalActions[i - 1].Pos && original.Pos > originalActions[i + 1].Pos)
{
current.Pos = Math.Max(current.Pos, Math.Max(prev.Pos, next.Pos));
}
// Preserve local minima
else if (original.Pos < originalActions[i - 1].Pos && original.Pos < originalActions[i + 1].Pos)
{
current.Pos = Math.Min(current.Pos, Math.Min(prev.Pos, next.Pos));
}
}
}
public static int[] CalculateDistanceErrors(ICollection<FunscriptAction> originalActions,
ICollection<FunscriptAction> adjustedActions,
double percentage)
{
if (originalActions.Count != adjustedActions.Count)
throw new ArgumentException("Original and adjusted actions must have the same count.");
var errors = new List<int>();
var originalList = originalActions.ToList();
var adjustedList = adjustedActions.ToList();
for (int i = 1; i < originalActions.Count; i++)
{
double originalDistance = Math.Abs(originalList[i].Pos - originalList[i - 1].Pos);
double expectedDistance = Math.Min(100, originalDistance * (percentage / 100.0)); // Cap the expected distance at 100
double actualDistance = Math.Abs(adjustedList[i].Pos - adjustedList[i - 1].Pos);
double error = actualDistance - expectedDistance;
errors.Add((int)error);
}
return errors.ToArray();
}
public static FunscriptAction[] OptimizeFunscriptActions(
ICollection<FunscriptAction> originalActions,
ICollection<FunscriptAction> adjustedActions,
double percentage,
int maxIterations = 1000,
int windowSize = 5)
{
List<FunscriptAction> optimizedActions = new List<FunscriptAction>(adjustedActions);
for (var iteration = 0; iteration < maxIterations; iteration++)
{
bool improved = false;
for (int i = windowSize; i < optimizedActions.Count - windowSize; i++)
{
double currentWindowError = CalculateWindowError(originalActions.ToList(), optimizedActions, percentage, i - windowSize, i + windowSize);
// Try nudging the point up and down
for (int direction = -1; direction <= 1; direction += 2)
{
int newPos = Clamp(optimizedActions[i].Pos + direction, 0, 100);
List<FunscriptAction> tempActions = new List<FunscriptAction>(optimizedActions);
tempActions[i] = new FunscriptAction { At = optimizedActions[i].At, Pos = newPos };
double newWindowError = CalculateWindowError(originalActions.ToList(), tempActions, percentage, i - windowSize, i + windowSize);
if (newWindowError < currentWindowError)
{
optimizedActions[i] = tempActions[i];
improved = true;
break; // Break the inner loop if improvement is found
}
}
}
if (!improved)
{
// If no improvement was made in this iteration, we can stop
break;
}
}
return optimizedActions.ToArray();
}
private static double CalculateWindowError(
List<FunscriptAction> originalActions,
List<FunscriptAction> adjustedActions,
double percentage,
int startIndex,
int endIndex)
{
double totalError = 0;
for (int i = startIndex; i < endIndex; i++)
{
double originalDistance = Math.Abs(originalActions[i + 1].Pos - originalActions[i].Pos);
double expectedDistance = Math.Min(100, originalDistance * (percentage / 100.0));
double adjustedDistance = Math.Abs(adjustedActions[i + 1].Pos - adjustedActions[i].Pos);
totalError += Math.Abs(adjustedDistance - expectedDistance);
}
return totalError;
}
// Custom Clamp method
private static int Clamp(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
}
Here an example of a script scaled to 50%, 80%, 100% (baseline), 120%, 150%, 200%:
And the same script during the more intense actions:
EveSweet-FindingTheSweetSpot.50.funscript (963.5 KB)
EveSweet-FindingTheSweetSpot.80.funscript (962.6 KB)
EveSweet-FindingTheSweetSpot.100.funscript (962.3 KB)
EveSweet-FindingTheSweetSpot.120.funscript (962.8 KB)
EveSweet-FindingTheSweetSpot.150.funscript (963.5 KB)
EveSweet-FindingTheSweetSpot.200.funscript (963.6 KB)
It’s far from perfect but its a starts.
For example, with a percentage of 50%, it seems to be limited to positions 15 to 85 instead of the full range (but it’s still better than 50% of the full range). I can also see that it can inverse the direction of some moves: