UNPKG

@eroscripts/funlib

Version:

A library for working with .funscript files

427 lines (361 loc) 14.4 kB
import type { Funscript } from '.' import { FunAction } from '.' import { absSpeedBetween, lerp, listToSum, minBy, speedBetween, unlerp } from './misc' /** * Converts an array of actions into an array of lines with speed calculations */ export function actionsToLines(actions: FunAction[]) { return actions.map((e, i, a) => { const p = a[i - 1] if (!p) return null! const speed = speedBetween(p, e) return Object.assign([p, e, Math.abs(speed)] as [FunAction, FunAction, number], { speed, absSpeed: Math.abs(speed), speedSign: Math.sign(speed), dat: e.at - p.at, atStart: p.at, atEnd: e.at, }) }).slice(1).filter(e => e[0].at < e[1].at) } /** * Filters actions to create a zigzag pattern by removing actions with same direction changes */ export function actionsToZigzag(actions: FunAction[]) { return FunAction.cloneList(actions.filter(e => e.isPeak)) } /** * Merges line segments with similar speeds within a time limit */ export function mergeLinesSpeed(lines: ReturnType<typeof actionsToLines>, mergeLimit: number) { if (!mergeLimit) return lines let j = 0 for (let i = 0; i < lines.length - 1; i = j + 1) { for (j = i; j < lines.length - 1; j++) { if (lines[i].speedSign !== lines[j + 1].speedSign) break } const f = lines.slice(i, j + 1) if (i === j) continue if (listToSum(f.map(e => e.dat)) > mergeLimit) continue const avgSpeed = listToSum(f.map(e => e.absSpeed * e.dat)) / listToSum(f.map(e => e.dat)) f.map(e => e[2] = avgSpeed) } return lines } /** * Calculates weighted average speed from a set of lines */ export function calculateWeightedSpeed(lines: ReturnType<typeof actionsToLines>): number { if (lines.length === 0) return 0 return listToSum(lines.map(e => e.absSpeed * e.dat)) / listToSum(lines.map(e => e.dat)) } /** * Smooths out action positions using a moving average */ export function smoothActions(actions: FunAction[], windowSize: number = 3): FunAction[] { if (windowSize < 2) return actions return FunAction.cloneList(actions.map((action, i, arr) => { const start = Math.max(0, i - Math.floor(windowSize / 2)) const end = Math.min(arr.length, start + windowSize) const window = arr.slice(start, end) const avgPos = window.reduce((sum, a) => sum + a.pos, 0) / window.length return new FunAction({ at: action.at, pos: avgPos, }) })) } export function actionsAverageSpeed(actions: FunAction[]) { const zigzag = actionsToZigzag(actions) const fast = zigzag.filter(e => Math.abs(e.speedTo) > 30) return listToSum(fast.map(e => Math.abs(e.speedTo) * e.datNext)) / (listToSum(fast.map(e => e.datNext)) || 1) } /** * while the device speed may be lower then the script's max speed * the device doesn't have to actually reach it - it needs just enough so to reach the next peak fast enough */ export function actionsRequiredMaxSpeed(actions: FunAction[]): speed { if (actions.length < 2) return 0 const requiredSpeeds: [speed, ms][] = [] let nextPeak: FunAction | undefined = actions[0] for (const a of actions) { if (nextPeak === a) { nextPeak = nextPeak.nextAction while (nextPeak && !nextPeak.isPeak) nextPeak = nextPeak.nextAction } if (!nextPeak) break requiredSpeeds.push([Math.abs(speedBetween(a, nextPeak)), nextPeak.at - a.at]) } // sort by speed descending const sorted = requiredSpeeds.sort((a, b) => a[0] - b[0]).reverse() // return speed that is active for at least 50ms return sorted.find(e => e[1] >= 50)?.[0] ?? 0 } export function experimentalProcess(fun: Funscript): void { const axes = [fun, ...fun.axes] for (const axis of axes) { console.log(axis.filePath, actionsRequiredMaxSpeed(axis.actions)) } } /** * Smooths a 1D animation curve using a weighted moving average * @param curve - The original curve data points with time and position * @param timeRadius - Number of neighboring points to consider (odd number recommended) * @param iterations - Number of smoothing passes to apply * @param preserveEnds - Whether to keep the start/end points unchanged * @returns The smoothed curve (modified in place) */ export function smoothCurve( curve: FunAction[], timeRadius: ms = 50, iterations: number = 1, preserveEnds: boolean = false, ): FunAction[] { const radius = 5 const positions = curve.map(e => e.pos) for (let iter = 0; iter < iterations; iter++) { for (let i = 0; i < curve.length; i++) { if (preserveEnds && (i === 0 || i === curve.length - 1)) { continue } let sum = 0 let weightSum = 0 for (let j = -radius; j <= radius; j++) { const index = i + j if (index >= 0 && index < curve.length) { // triangular weight distribution const weight = Math.max(0, timeRadius - Math.abs(curve[index].at - curve[i].at)) sum += positions[index] * weight weightSum += weight } } curve[i].pos = positions[i] = sum / weightSum } } return FunAction.linkList(curve) } /** * Splits a curve into segments between peaks */ export function splitToSegments(actions: FunAction[]): FunAction[][] { const segments: FunAction[][] = [] let prevPeakIndex = -1 // Find segments between peaks for (let i = 0; i < actions.length; i++) { if (actions[i].isPeak !== 0) { if (prevPeakIndex !== -1) { segments.push(actions.slice(prevPeakIndex, i + 1)) } prevPeakIndex = i } } return segments } /** * Connects segments back into a single array of actions */ export function connectSegments(segments: FunAction[][]): FunAction[] { return FunAction.linkList( segments.flat() .filter((e, i, a) => e !== a[i - 1]), ) } /** * Removes redundant points from a curve where points lie on nearly straight lines */ export function simplifyLinearCurve( curve: FunAction[], threshold: number, ) { if (curve.length <= 2) { return FunAction.linkList(curve) // Nothing to simplify } const segments = splitToSegments(curve) const simplifiedSegments = segments.map((segment) => { // First check if the entire segment can be simplified to just endpoints if (lineDeviation(segment) <= threshold) { return [segment[0], segment.at(-1)!] } const result = [segment[0]] let startIdx = 0 // Examine each potential line segment while (startIdx < segment.length - 1) { let endIdx = startIdx + 2 // At least consider the next point // Try to extend the current line segment as far as possible while (endIdx <= segment.length - 1) { // Check if the current segment is straight enough if (lineDeviation(segment.slice(startIdx, endIdx + 1)) > threshold) { break } endIdx++ } // We've found the longest valid line segment endIdx = Math.max(startIdx + 1, endIdx - 1) // Add the endpoint of this segment to our result result.push(segment[endIdx]) // Move to the next segment startIdx = endIdx } return result }) return connectSegments(simplifiedSegments) } const HANDY_MAX_SPEED = 550 const HANDY_MIN_INTERVAL = 60 const HANDY_MAX_STRAIGHT_THRESHOLD = 3 /** * Handy has a max speed and a min interval between actions * This function will smooth the actions to fit those constraints */ export function handySmooth(actions: FunAction[]): FunAction[] { actions = FunAction.cloneList(actions) // pass 0: round at values actions.map(e => e.pos = Math.round(e.pos)) // pass 1: split into segments of [peak, ...nonpeaks, peak] const segments = splitToSegments(actions) // pass 2: remove non-peak actions that are too close to peaks function simplifySegment(segment: FunAction[]): FunAction[] { if (segment.length <= 2) return segment const first = segment[0], last = segment.at(-1)! let middle = segment.slice(1, -1) if (lineDeviation(segment) <= HANDY_MAX_STRAIGHT_THRESHOLD) return [first, last] if (absSpeedBetween(first, last) > HANDY_MAX_SPEED) return [first, last] // split to 2 parts cannot create too high speed middle = middle.filter((e) => { const speed = absSpeedBetween(first, e) const restSpeed = absSpeedBetween(e, last) return speed < HANDY_MAX_SPEED && restSpeed < HANDY_MAX_SPEED }) // middle cannot contain points too close to first or last middle = middle.filter((e) => { return e.at - first.at >= HANDY_MIN_INTERVAL && last.at - e.at >= HANDY_MIN_INTERVAL }) if (!middle.length) return [first, last] if (middle.length === 1) { return straigten([first, middle[0], last]) } const middleDuration = middle.at(-1)!.at - middle[0].at if (middleDuration < HANDY_MIN_INTERVAL) { // can place only a single point in the middle // find the point that is closest to the middle of the segment const middlePoint = minBy(middle, e => Math.abs(e.at - middleDuration / 2)) return straigten([first, middlePoint, last]) } function straigten(segment: FunAction[]): FunAction[] { if (segment.length <= 2) return segment if (lineDeviation(segment) <= HANDY_MAX_STRAIGHT_THRESHOLD) return [segment[0], segment.at(-1)!] return segment } return [first, ...simplifySegment(middle), last] } const filteredSegments = segments.map((segment) => { return simplifySegment(segment) }) let filteredActions = connectSegments(filteredSegments) // pass 3: merge points that are too close to each other for (let i = 1; i < filteredActions.length; i++) { // merge only poins that have <30 speed const current = filteredActions[i] const prev = filteredActions[i - 1] if (!current.isPeak && !prev.isPeak) continue const speed = absSpeedBetween(prev, current) if (speed > 10) continue prev.pos = lerp(prev.pos, current.pos, 0.5) prev.at = lerp(prev.at, current.at, 0.5) // remove current point filteredActions.splice(i, 1) i-- // // If points are too close, remove the current point and adjust the previous point // if (current.at - prev.at < HANDY_MIN_INTERVAL) { // // If points are too close, remove the current point and adjust the previous point // // to be at the weighted average position based on time // const nextPoint = filteredActions[i + 1] // if (!nextPoint) continue // Skip if this is the last point // const totalTime = nextPoint.at - prev.at // const t = (current.at - prev.at) / totalTime // prev.pos = lerp(prev.pos, nextPoint.pos, t) // // Remove the current point // filteredActions.splice(i, 1) // i-- // Adjust index since we removed an element // } } filteredActions = FunAction.linkList(filteredActions) // pass 4: if the speed between two points is too high, move them closer together filteredActions = limitPeakSpeed(filteredActions, HANDY_MAX_SPEED) // pass 5: simplify the curve filteredActions = simplifyLinearCurve(filteredActions, HANDY_MAX_STRAIGHT_THRESHOLD) // pass 6: round at and pos values filteredActions.forEach((e) => { e.at = Math.round(e.at) e.pos = Math.round(e.pos) }) return FunAction.linkList(filteredActions) } /** * Calculates maximum deviation of points from a straight line between endpoints */ export function lineDeviation(actions: FunAction[]): number { if (actions.length <= 2) return 0 const first = actions[0] const last = actions.at(-1)! let maxDeviation = 0 // Check each point's distance from the line between first and last for (let i = 1; i < actions.length - 1; i++) { const t = (actions[i].at - first.at) / (last.at - first.at) const expectedPos = first.pos + (last.pos - first.pos) * t const deviation = Math.abs(actions[i].pos - expectedPos) if (deviation > maxDeviation) maxDeviation = deviation } return maxDeviation } export function limitPeakSpeed(actions: FunAction[], maxSpeed: number): FunAction[] { const peaks = actionsToZigzag(actions) const poss = peaks.map(e => e.pos) for (let i = 0; i < 10; i++) { let retry = false // First calculate all changes const lchanges = Array.from({ length: poss.length }, () => 0) const rchanges = Array.from({ length: poss.length }, () => 0) for (let l = 0, r = 1; r < poss.length; l++, r++) { const left = peaks[l], right = peaks[r], absSpeed = Math.abs(left.speedFrom) if (absSpeed <= maxSpeed) continue const height = right.pos - left.pos const changePercent = (absSpeed - maxSpeed) / absSpeed const totalChange = height * changePercent // Split into left and right changes lchanges[l] += totalChange / 2 rchanges[r] -= totalChange / 2 } // Merge changes first const changes = Array.from({ length: poss.length }, (_, i) => { const lchange = lchanges[i] const rchange = rchanges[i] // If signs are different, use the max absolute value with original sign // If signs are same, sum them return Math.sign(lchange) === Math.sign(rchange) ? (Math.abs(lchange) > Math.abs(rchange) ? lchange : rchange) : lchange + rchange }) // Apply all changes at once for (let i = 0; i < poss.length; i++) { poss[i] += changes[i] peaks[i].pos = poss[i] } const speed = Math.max(...peaks.map(peak => Math.abs(peak.speedFrom))) if (speed > maxSpeed) { retry = true } if (!retry) break } const segments = splitToSegments(actions) for (let i = 0; i < segments.length; i++) { const newLeftPos = peaks[i].pos, newRightPos = peaks[i + 1].pos const segment = segments[i] const leftAt = segment[0].at, rightAt = segment.at(-1)!.at for (let j = 0; j < segment.length; j++) { segment[j].pos = lerp(newLeftPos, newRightPos, unlerp(leftAt, rightAt, segment[j].at)) } } return connectSegments(segments) }