UNPKG

@eroscripts/funlib

Version:

A library for working with .funscript files

230 lines (203 loc) 8.56 kB
import type { Funscript } from '.' import type { axisPairs } from './types' import { oklch2hex } from 'colorizr' export function timeSpanToMs(timeSpan: timeSpan): ms { if (typeof timeSpan !== 'string') { throw new TypeError('timeSpanToMs: timeSpan must be a string') } const sign = timeSpan.startsWith('-') ? -1 : 1; if (sign < 0) timeSpan = timeSpan.slice(1) const split = timeSpan.split(':').map(e => Number.parseFloat(e)) while (split.length < 3) split.unshift(0) const [hours, minutes, seconds] = split return Math.round(sign * (hours * 60 * 60 + minutes * 60 + seconds) * 1000) } export function msToTimeSpan(ms: ms): timeSpan { const sign = ms < 0 ? -1 : 1; ms *= sign const seconds = Math.floor(ms / 1000) % 60 const minutes = Math.floor(ms / 1000 / 60) % 60 const hours = Math.floor(ms / 1000 / 60 / 60) ms = ms % 1000 return `${sign < 0 ? '-' : ''}${ hours.toFixed(0).padStart(2, '0')}:${ minutes.toFixed(0).padStart(2, '0')}:${ seconds.toFixed(0).padStart(2, '0')}.${ ms.toFixed(0).padStart(3, '0')}` } export function secondsToDuration(seconds: seconds): string { seconds = Math.round(seconds) if (seconds < 3600) { return `${Math.floor(seconds / 60)}:${ Math.floor(seconds % 60).toFixed(0).padStart(2, '0')}` } return `${Math.floor(seconds / 60 / 60)}:${ Math.floor(seconds / 60 % 60).toFixed(0).padStart(2, '0')}:${ Math.floor(seconds % 60).toFixed(0).padStart(2, '0')}` } export function rawToValue(raw: axisRaw, axis?: axis): axisValue { if (raw === undefined || raw === null) { throw new Error('rawToValue: raw value is required') } // axisValue is [0, 100] // L0 is [0, 1]; R0 is [-60, 60]; R1, R2 is [-30, 30]; L1/L2 is throw let norm: axisNorm = -999 if (axis === 'L0') norm = raw + 0 if (axis === 'R0') norm = raw / 120 + 0.5 if (axis === 'R1') norm = raw / 60 + 0.5 if (axis === 'R2') norm = raw / 60 + 0.5 if (axis === 'L1') norm = raw / 60 + 0.5 if (axis === 'L2') norm = raw / 60 + 0.5 if (norm === -999) throw new Error(`rawToValue: ${axis} is not supported`) return norm * 100 } export function valueToRaw(value: axisValue, axis?: axis): axisRaw { // axisValue is [0, 100] // L0 is [0, 1]; R0 is [-60, 60]; R1, R2 is [-30, 30]; L1/L2 is throw const norm: axisNorm = value / 100 if (axis === 'L0') return norm if (axis === 'R0') return (norm - 0.5) * 120 if (axis === 'R1') return (norm - 0.5) * 60 if (axis === 'R2') return (norm - 0.5) * 60 throw new Error(`valueToRaw: ${axis} is not supported`) } export function roundAxisValue(value: axisValue): axisValue { return +value.toFixed(2) } export function orderTrimJson(that: Record<string, any>, order: Record<string, any>, empty: Record<string, any>): Record<string, any> { const copy: Record<string, any> = { ...order, ...that } for (const [k, v] of Object.entries(empty)) { if (!(k in copy)) continue const copyValue = (copy as any)[k] if (copyValue === v) delete (copy as any)[k] if (Array.isArray(v) && Array.isArray(copyValue) && copyValue.length === 0) { delete (copy as any)[k] } else if ( typeof v === 'object' && v !== null && Object.keys(v).length === 0 && typeof copyValue === 'object' && copyValue !== null && Object.keys(copyValue).length === 0 ) { delete (copy as any)[k] } } for (const k of Object.keys(copy)) { if (k.startsWith('__')) { delete (copy as any)[k] } } return copy } function fromEntries<A extends [any, any][]>(a: A): { [K in A[number] as K[0]]: K[1] } { return Object.fromEntries(a) } // eslint-disable-next-line ts/no-redeclare const axisPairs: axisPairs = [['L0', 'stroke'], ['L1', 'surge'], ['L2', 'sway'], ['R0', 'twist'], ['R1', 'roll'], ['R2', 'pitch']] export const axisToNameMap: Record<axis, axisName> = fromEntries(axisPairs) export const axisNameToAxisMap: Record<axisName, axis> = fromEntries(axisPairs.map(([a, b]) => [b, a])) export const axisIds: axis[] = axisPairs.map(e => e[0]) export const axisNames: axisName[] = axisPairs.map(e => e[1]) export const axisLikes: axisLike[] = axisPairs.flat() export function axisNameToAxis(name?: axisName): axis { if (name && name in axisNameToAxisMap) return axisNameToAxisMap[name] throw new Error(`axisNameToAxis: ${name} is not supported`) } export function axisToName(axis?: axis): axisName { if (axis && axis in axisToNameMap) return (axisToNameMap as any)[axis] throw new Error(`axisToName: ${axis} is not supported`) } export function axisLikeToAxis(axisLike?: axisLike | 'singleaxis'): axis { if (!axisLike) return 'L0' if (axisIds.includes(axisLike as any)) return axisLike as any if (axisNames.includes(axisLike as any)) return axisNameToAxisMap[axisLike as axisName] if (axisLike === 'singleaxis') return 'L0' throw new Error(`axisLikeToAxis: ${axisLike} is not supported`) } export function orderByAxis(a: Funscript, b: Funscript) { return axisLikes.indexOf(a.axis!) - axisLikes.indexOf(b.axis!) } export function fileNameToInfo(filePath?: string) { const parts = filePath?.split('.') ?? [] if (parts.at(-1) === 'funscript') parts.pop() let axisLike = parts.at(-1) if (axisLikes.includes(axisLike as any)) { parts.pop() } else if (axisLike === 'singleaxis') { parts.pop() } else { axisLike = undefined } const fileName = parts.join('.') return { filePath, fileName, primary: !axisLike || axisLike === 'singleaxis', title: fileName.split(/[\\/]/).pop()!, axis: axisLike ? axisLikeToAxis(axisLike as any) : undefined, } } export function formatJson(json: string, { lineLength = 100, maxPrecision = 1 }: { lineLength?: number, maxPrecision?: number } = {}): string { function removeNewlines(s: string) { return s.replaceAll(/ *\n\s*/g, ' ') } const inArrayRegex = /(?<=\[)([^[\]]+)(?=\])/g json = json.replaceAll(/\{\s*"(at|time|startTime)":[^{}]+\}/g, removeNewlines) // all `at` values in array should have same length json = json.replaceAll(inArrayRegex, (s) => { // Round numbers to maxPrecision s = s.replaceAll(/(?<="(at|pos)":\s*)(-?\d+\.?\d*)/g, num => Number(num).toFixed(maxPrecision).replace(/\.?0+$/, '')) // "at": -123.456, const atValues = s.match(/(?<="at":\s*)(-?\d+\.?\d*)/g) ?? [] if (atValues.length === 0) return s const maxAtLength = Math.max(0, ...atValues.map(e => e.length)) s = s.replaceAll(/(?<="at":\s*)(-?\d+\.?\d*)/g, s => s.padStart(maxAtLength, ' ')) const posValues = s.match(/(?<="pos":\s*)(-?\d+\.?\d*)/g) ?? [] const posDot = Math.max(0, ...posValues.map(e => e.split('.')[1]) .filter(e => e) .map(e => e.length + 1)) s = s.replaceAll(/(?<="pos":\s*)(-?\d+\.?\d*)/g, (s) => { if (!s.includes('.')) return s.padStart(3) + ' '.repeat(posDot) const [a, b] = s.split('.') return `${a.padStart(3)}.${b.padEnd(posDot - 1, ' ')}` }) const actionLength = '{ "at": , "pos": 100 },'.length + maxAtLength + posDot let actionsPerLine = 10 while (6 + (actionLength + 1) * actionsPerLine - 1 > lineLength) actionsPerLine-- let i = 0 s = s.replaceAll(/\n(?!\s*$)\s*/g, s => (i++ % actionsPerLine === 0) ? s : ' ') return s }) return json } export function speedToOklch(speed: speed): [l: number, c: number, h: number] { const clamp = (min: number, want: number, max: number) => Math.max(min, Math.min(want, max)) const lerp = (min: number, max: number, t: number) => min + t * (max - min) const unlerp = (min: number, max: number, t: number) => (t - min) / (max - min) function clamplerp( outMin: number, outMax: number, inMin: number, inMax: number, t: number, ) { return lerp(outMin, outMax, clamp(0, unlerp(inMin, inMax, t), 1)) } function roll(value: number, cap: number) { return (value % cap + cap) % cap } return [ clamplerp(0.8, 0.4, 500, 700, speed), clamplerp(0.4, 0.1, 500, 800, speed), roll(210 - speed / 2.4, 360), ] } /** * in css: * oklch( max(40%, min(80%, calc(80% + (var(--speed) - 500) / 250 * -40%))) max(10%, min(40%, calc(40% + (var(--speed) - 500) / 300 * -30%))) calc(210 - var(--speed) / 2.4)) */ export function speedToHex(speed: speed) { const [l, c, h] = speedToOklch(speed) return oklch2hex({ l, c, h }) }