fix funscript selector
Some checks are pending
ci / build (push) Waiting to run
ci / test (push) Waiting to run

This commit is contained in:
CJ_Clippy 2025-09-02 18:53:26 -08:00
parent c169405bfe
commit ea6c5b5bd7
8 changed files with 540 additions and 223 deletions

View File

@ -1,7 +1,7 @@
{
"name": "futureporn",
"name": "futureporn-our",
"private": true,
"version": "2.5.0",
"version": "2.6.0",
"type": "module",
"scripts": {
"dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp",
@ -111,4 +111,4 @@
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
}

View File

@ -46,15 +46,7 @@ class FunscriptMenuItem extends VideoJsMenuItem {
this.on('click', () => {
if (!this.actuator || !this.onAssign) return;
// Assign this funscript
this.actuator.assignedFunscript = this.funscript;
// Update UI: only this item is selected
this.parentComponent_.children()
.filter(c => c instanceof FunscriptMenuItem)
.forEach(c => c.selected(c === this));
// Call external callback
// Use centralized assignment method
this.onAssign?.(this, this.actuator, this.funscript);
});
}
@ -82,23 +74,19 @@ export default class FunscriptPlayer {
this.player = player;
this.debug = debug;
this.funscriptCache = new Map(); // key: url, value: parsed funscript
// Normalize funscripts: ensure theyre always { name, url }
this.funscripts = (funscripts || []).map(f => {
if (typeof f === 'string') {
// Extract filename from URL
const url = f;
const name = url.split('/').pop().split('?')[0]; // remove query params
const name = url.split('/').pop().split('?')[0];
return { name, url };
}
// Already in { name, url } format
return f;
});
this.options = options;
this.menuButton = null;
this.devices = []; // track connected devices
this.devices = [];
// refresh menu when devices change
player.on('toyConnected', () => this.refreshMenu());
player.on('toyDisconnected', () => this.refreshMenu());
player.on('pause', () => this.stopAllDevices());
@ -124,7 +112,6 @@ export default class FunscriptPlayer {
await this.client.connect(connector);
await this.client.startScanning();
this.initVideoListeners();
this.debug && console.log("[buttplug] Connected and scanning");
} catch (err) {
console.error("[buttplug] Connection error:", err);
@ -145,9 +132,6 @@ export default class FunscriptPlayer {
getFunscriptValueAtTime(funscriptData, timeMs) {
if (!funscriptData?.actions?.length) return null;
this.debug && console.log(`getFunscriptValueAtTime timeMs=${timeMs}`)
// Find the latest action at or before current time
let lastAction = null;
for (const action of funscriptData.actions) {
if (action.at <= timeMs) {
@ -159,37 +143,29 @@ export default class FunscriptPlayer {
return lastAction ? lastAction.pos / 100 : null; // normalize 01
}
initVideoListeners() {
this.player.on('pause', () => {
this.debug && console.log('Video paused. Stopping devices...');
this.stopAllDevices();
});
this.player.on('timeupdate', () => {
if (this.player.paused() || this.player.ended()) return;
if (this.player.paused() || this.player.ended()) return; // exit early if not playing. necessary for seeking while paused
const currentTime = this.player.currentTime() * 1000; // ms
// this.debug && console.log(`timeupdate currentTime=${currentTime}`)
const currentTime = this.player.currentTime() * 1000;
this.devices.forEach(device => {
device.actuators.forEach(actuator => {
const fun = actuator.assignedFunscript;
if (!fun) return;
// this.debug && console.log(`name=${fun.name} url=${fun.url}`)
const funscriptData = this.funscriptCache.get(fun.url);
// console.log(funscriptData)
if (!funscriptData) return;
const position = this.getFunscriptValueAtTime(funscriptData, currentTime);
// this.debug && console.log(`position=${position}`)
this.debug && console.log(`${fun.name} position=${position}`);
if (position !== null) {
this.debug && console.log(`actuator.name=${actuator.name}, position=${position}`)
this.sendToDevice(device, actuator, position);
}
});
@ -215,13 +191,9 @@ export default class FunscriptPlayer {
}
}
async sendToDevice(device, actuator, position) {
try {
// console.log()
// await this.client.devices[device.index].scalar(actuator.index, position);
this.client.devices[device.index].vibrate(position)
this.client.devices[device.index].vibrate(position);
if (this.debug) {
console.log(`[buttplug] Sent to ${actuator.name}:`, position);
}
@ -230,7 +202,6 @@ export default class FunscriptPlayer {
}
}
/* ------------------------- Device Tracking ------------------------- */
addDevice(device) {
@ -239,13 +210,9 @@ export default class FunscriptPlayer {
const actuators = scalarCmds.map((cmd, i) => {
const assignedFunscript = pickInitialFunscript(cmd.ActuatorType, this.funscripts);
// kick off preload asynchronously, but dont block UI
if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) {
this.loadFunscript(assignedFunscript).then(data => {
if (data) this.funscriptCache.set(assignedFunscript.url, data);
this.debug && console.log("Preloaded funscript:", assignedFunscript.name);
}).catch(err => {
console.error("Error preloading funscript:", assignedFunscript.name, err);
});
}
@ -254,6 +221,7 @@ export default class FunscriptPlayer {
type: cmd.ActuatorType,
index: cmd.Index,
assignedFunscript,
deviceIndex: this.devices.length, // track device index
};
});
@ -262,77 +230,65 @@ export default class FunscriptPlayer {
index: this.devices.length,
actuators,
});
this.debug && console.log("Processed device:", device._deviceInfo.DeviceName, "actuators:", actuators);
}
removeDevice(device) {
this.devices = this.devices.filter(d => d.name !== device.name);
}
/* ------------------------- Central Assignment ------------------------- */
async assignFunscript(deviceIndex, actuatorIndex, funscript) {
const actuator = this.devices[deviceIndex]?.actuators[actuatorIndex];
if (!actuator) return;
actuator.assignedFunscript = funscript;
if (!this.funscriptCache.has(funscript.url)) {
const data = await this.loadFunscript(funscript);
if (data) this.funscriptCache.set(funscript.url, data);
}
this.debug && console.log(`Assigned ${funscript.name} to ${actuator.name}`);
}
/* ------------------------- Menu Management ------------------------- */
createMenuButtons() {
// Remove existing menu buttons
if (this.menuButtons?.length) {
this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn));
}
this.menuButtons = [];
const actuators = this.devices.flatMap(device =>
device.actuators.map(actuator => ({
...actuator,
deviceName: device.name,
deviceIndex: device.index,
}))
);
this.devices.forEach(device => {
device.actuators.forEach(actuator => {
const button = new ActuatorMenuButton(this.player, {
actuator,
funscripts: this.funscripts,
onAssign: async (item, act, funscript) => {
await this.assignFunscript(device.index, act.index, funscript);
actuators.forEach(actuator => {
const button = new ActuatorMenuButton(this.player, {
actuator,
funscripts: this.funscripts,
onAssign: async (item, act, funscript) => {
act.assignedFunscript = funscript;
this.debug && console.log(`assignedFunscript = ${funscript.name}`);
// Load and cache funscript
if (!this.funscriptCache.has(funscript.url)) {
const data = await this.loadFunscript(funscript);
console.log('load and cache funscript') // this is never being called by default.
if (data) this.funscriptCache.set(funscript.url, data);
const menuItems = item.parentComponent_.children()
.filter(c => c instanceof FunscriptMenuItem);
menuItems.forEach(i => {
i.selected(i.funscript.url === funscript.url);
});
}
});
// Update UI
const menuItems = item.parentComponent_.children()
.filter(c => c instanceof FunscriptMenuItem);
const placementIndex = Math.max(
0,
this.options?.placementIndex ?? (this.player.controlBar.children().length - 2)
);
this.player.controlBar.addChild(button, { componentClass: 'funscriptSelector' }, placementIndex);
menuItems.forEach(i => {
i.selected(i.funscript.url === funscript.url);
});
}
setTimeout(() => {
button.addClass('vjs-funscript-selector');
button.removeClass('vjs-hidden');
}, 0);
this.menuButtons.push(button);
});
const placementIndex = Math.max(
0,
this.options?.placementIndex ?? (this.player.controlBar.children().length - 2)
);
this.player.controlBar.addChild(button, { componentClass: 'funscriptSelector' }, placementIndex);
setTimeout(() => {
button.addClass('vjs-funscript-selector');
button.removeClass('vjs-hidden');
}, 0);
this.menuButtons.push(button);
});
if (this.debug) {
const actuatorNames = this.menuButtons.map(b => b.options_.title);
console.log("Menu buttons created for actuators:", actuatorNames);
}
}
refreshMenu() {

View File

@ -0,0 +1,216 @@
// ported from https://github.com/defucilis/funscript-utils/blob/main/src/funMapper.ts
import { getSpeed } from "./utils";
// Colors from Lucife
export const heatmapColors = [
[0, 0, 0],
[30, 144, 255],
[34, 139, 34],
[255, 215, 0],
[220, 20, 60],
[147, 112, 219],
[37, 22, 122],
];
/**
* Converts a three-element RGB array of colors into a CSS rgb color string
*/
export const formatColor = (c, alpha = 1) => {
return "rgb(" + c[0] + ", " + c[1] + ", " + c[2] + ", " + alpha + ")";
};
const getLerpedColor = (colorA, colorB, t) =>
colorA.map((c, index) => c + (colorB[index] - c) * t);
const getAverageColor = (colors) => {
const colorSum = colors.reduce(
(acc, c) => [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]],
[0, 0, 0]
);
return [
colorSum[0] / colors.length,
colorSum[1] / colors.length,
colorSum[2] / colors.length,
];
};
/**
* Converts a intensity/speed value into a heatmap color
*/
export const getColor = (intensity) => {
const stepSize = 120;
if (intensity <= 0) return heatmapColors[0];
if (intensity > 5 * stepSize) return heatmapColors[6];
intensity += stepSize / 2.0;
try {
return getLerpedColor(
heatmapColors[Math.floor(intensity / stepSize)],
heatmapColors[1 + Math.floor(intensity / stepSize)],
Math.min(
1.0,
Math.max(
0.0,
(intensity - Math.floor(intensity / stepSize) * stepSize) /
stepSize
)
)
);
} catch (error) {
return [0, 0, 0];
}
};
const defaultHeatmapOptions = {
showStrokeLength: true,
gapThreshold: 5000,
};
/**
* Renders a heatmap into a provided HTML5 Canvas
*/
export const renderHeatmap = (canvas, script, options) => {
options = options ? { ...defaultHeatmapOptions, ...options } : { ...defaultHeatmapOptions };
const width = canvas.width;
const height = canvas.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (options.background) {
ctx.fillStyle = options.background;
ctx.fillRect(0, 0, width, height);
} else {
ctx.clearRect(0, 0, width, height);
}
const msToX = width / script.actions.slice(-1)[0].at;
let colorAverageList = [];
let intensityList = [];
let posList = [];
let yMaxList = [script.actions[0].pos];
let yMinList = [script.actions[0].pos];
let yMin = 0;
let yMax = 0;
const yWindowSize = 15;
const xWindowSize = 50;
let lastX = 0;
for (let i = 1; i < script.actions.length; i++) {
const x = Math.floor(msToX * script.actions[i].at);
if (options.gapThreshold && script.actions[i].at - script.actions[i - 1].at > options.gapThreshold) {
colorAverageList = [];
intensityList = [];
posList = [];
yMaxList = [script.actions[i].pos];
yMinList = [script.actions[i].pos];
lastX = x;
continue;
}
const intensity = getSpeed(script.actions[i - 1], script.actions[i]);
intensityList.push(intensity);
colorAverageList.push(getColor(intensity));
posList.push(script.actions[i].pos);
if (intensityList.length > xWindowSize) intensityList = intensityList.slice(1);
if (colorAverageList.length > xWindowSize) colorAverageList = colorAverageList.slice(1);
if (posList.length > yWindowSize) posList = posList.slice(1);
const averageIntensity = intensityList.reduce((acc, cur) => acc + cur, 0) / intensityList.length;
const averageColor = getColor(averageIntensity);
const sortedPos = [...posList].sort((a, b) => a - b);
const bottomHalf = sortedPos.slice(0, sortedPos.length / 2);
const topHalf = sortedPos.slice(sortedPos.length / 2);
const averageBottom = bottomHalf.reduce((acc, cur) => acc + cur, 0) / bottomHalf.length;
const averageTop = topHalf.reduce((acc, cur) => acc + cur, 0) / topHalf.length;
yMaxList.push(script.actions[i].pos);
yMinList.push(script.actions[i].pos);
yMin = yMinList.reduce((acc, cur) => Math.min(acc, cur), 100);
yMax = yMaxList.reduce((acc, cur) => Math.max(acc, cur), 0);
if (yMinList.length > yWindowSize) yMinList = yMinList.slice(1);
if (yMaxList.length > yWindowSize) yMaxList = yMaxList.slice(1);
let y2 = height * (averageBottom / 100.0);
let y1 = height * (averageTop / 100.0);
ctx.fillStyle = formatColor(averageColor, 1);
if (options.showStrokeLength) {
ctx.fillRect(lastX, height - y2, x - lastX, y2 - y1);
} else {
ctx.fillRect(lastX, 0, x - lastX, height);
}
lastX = x;
}
};
const defaultActionsOptions = {
clear: true,
background: "#000",
lineColor: "#FFF",
lineWeight: 3,
startTime: 0,
onlyTimes: false,
onlyTimeColor: "rgba(255,255,255,0.1)",
offset: { x: 0, y: 0 },
};
/**
* Renders a funscript preview onto a provided HTML5 Canvas
*/
export const renderActions = (canvas, script, options) => {
const drawPath = (ctx, funscript, opt) => {
const position = opt.startTime || 0;
const duration = opt.duration || (script.metadata ? script.metadata.duration || 10 : 10);
const scriptDuration = funscript.actions.slice(-1)[0].at;
const min = Math.max(0, scriptDuration * position - duration * 0.5);
const max = min + duration;
ctx.beginPath();
let first = true;
funscript.actions
.filter((a, i) => {
const prev = i === 0 ? a : funscript.actions[i - 1];
const next = i === funscript.actions.length - 1 ? a : funscript.actions[i + 1];
return next.at > min && prev.at < max;
})
.forEach(action => {
const x = width * (action.at - min) / duration + (opt && opt.offset ? opt.offset.x : 0);
const y = height - (action.pos / 100) * height + (opt && opt.offset ? opt.offset.y : 0);
if (first) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
if (opt && opt.onlyTimes) ctx.fillRect(x - 1, 0, 2, height);
first = false;
});
if (!opt.onlyTimes) ctx.stroke();
};
const width = canvas.width;
const height = canvas.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
options = { ...defaultActionsOptions, ...options };
if (options.clear) ctx.clearRect(0, 0, width, height);
if (options.clear) {
ctx.fillStyle = options.background || "#000";
ctx.fillRect(0, 0, width, height);
}
ctx.lineWidth = options.lineWeight || 3;
ctx.strokeStyle = options.lineColor || "#FFF";
ctx.fillStyle = options.onlyTimeColor || "rgba(255,255,255,0.1)";
drawPath(ctx, script, options);
};

View File

@ -0,0 +1,81 @@
// ported from https://github.com/defucilis/funscript-io-2/blob/main/lib/funscript-utils/utils.ts
/**
* Splits a funscript action array into sensible 'groups' of actions, separated by pauses
*/
export const getActionGroups = (actions) => {
const actionGroups = [];
let index = -1;
let timeSinceLast = -1;
actions.forEach((action, i) => {
if (i === 0) {
actionGroups.push([action]);
index++;
return;
}
if (i === 1) {
actionGroups[index].push(action);
timeSinceLast = Math.max(250, action.at - actions[i - 1].at);
return;
}
const newTimeSinceLast = action.at - actions[i - 1].at;
if (newTimeSinceLast > 5 * timeSinceLast) {
actionGroups.push([action]);
index++;
} else {
actionGroups[index].push(action);
}
timeSinceLast = Math.max(250, newTimeSinceLast);
});
return actionGroups;
};
/**
* Takes in two actions and returns the speed value the transition between them represents.
* Measured in '0-100 movements per second'
*/
export const getSpeed = (firstAction, secondAction) => {
if (!firstAction || !secondAction) return 0;
if (firstAction.at === secondAction.at) return 0;
try {
if (secondAction.at < firstAction.at) {
const temp = secondAction;
secondAction = firstAction;
firstAction = temp;
}
return (
1000 *
(Math.abs(secondAction.pos - firstAction.pos) /
Math.abs(secondAction.at - firstAction.at))
);
} catch (error) {
console.error("Failed on actions", firstAction, secondAction, error);
return 0;
}
};
/**
* Ensures that an action is within range and doesn't have any decimals
*/
export const roundAction = (action) => {
const outputAction = {
at: Math.max(0, Math.round(action.at)),
pos: Math.max(0, Math.min(100, Math.round(action.pos))),
};
if (action.subActions) {
outputAction.subActions = action.subActions.map((subAction) =>
roundAction(subAction)
);
}
if (action.type) outputAction.type = action.type;
return outputAction;
};

View File

@ -21,7 +21,7 @@ player.ready(() => {
const funscripts = collectFunscripts()
const funscriptsOptions = {
buttplugClientName: "future.porn",
debug: false,
debug: true,
funscripts,
}

View File

@ -7,86 +7,166 @@ import { getS3Client, uploadFile } from "../utils/s3";
import { inference } from "../utils/vibeui";
import { buildFunscript } from "../utils/funscripts";
import logger from "../utils/logger";
import { generateS3Path } from '../utils/formatters';
import { generateS3Path } from "../utils/formatters";
interface Payload {
vodId: string;
}
const prisma = new PrismaClient().$extends(withAccelerate());
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
if (typeof payload !== "object" || !payload)
throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string")
throw new Error("invalid payload-- was missing vodId");
}
async function getVod(vodId: string) {
return prisma.vod.findFirstOrThrow({
where: { id: vodId },
include: { vtubers: true },
});
}
const createFunscript: Task = async (payload: any, helpers: Helpers) => {
assertPayload(payload);
const { vodId } = payload;
logger.info(`createFunscript called with vodId=${vodId}`)
const vod = await prisma.vod.findFirstOrThrow({ where: { id: vodId }, include: { vtubers: true } });
function ensureVodReady(vod: Awaited<ReturnType<typeof getVod>>) {
if (vod.funscriptVibrate && vod.funscriptThrust) {
logger.info(`Doing nothing-- vod ${vodId} already has funscripts.`);
return;
logger.info(`Doing nothing-- vod ${vod.id} already has funscripts.`);
return false;
}
if (!vod.sourceVideo) {
const msg = `Cannot create funscript: Vod ${vodId} is missing a source video.`;
const msg = `Cannot create funscript: Vod ${vod.id} is missing a source video.`;
logger.warn(msg);
throw new Error(msg);
}
return true;
}
async function downloadVideo(sourceVideo: string) {
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo);
const videoFilePath = await getOrDownloadAsset(
s3Client,
env.S3_BUCKET,
sourceVideo
);
logger.info(`Downloaded video to ${videoFilePath}`);
return { s3Client, videoFilePath };
}
logger.info(`Creating funscript for vod ${vodId}...`);
async function runInference(videoFilePath: string) {
logger.info(`Running inference on video...`);
const predictionOutputPath = await inference(videoFilePath);
logger.info(`prediction output ${predictionOutputPath}`);
logger.info(`Prediction output at ${predictionOutputPath}`);
return predictionOutputPath;
}
const slug = vod.vtubers[0].slug
if (!slug) throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`);
async function buildFunscripts(
predictionOutputPath: string,
videoFilePath: string
) {
logger.debug(`Building funscripts...`);
const vibratePath = await buildFunscript(
predictionOutputPath,
videoFilePath,
"vibrate"
);
const thrustPath = await buildFunscript(
predictionOutputPath,
videoFilePath,
"thrust"
);
logger.debug(
`Built funscripts: vibrate=${vibratePath}, thrust=${thrustPath}`
);
return { vibratePath, thrustPath };
}
logger.debug(`building funscripts.`)
const funscriptVibrateFilePath = await buildFunscript(predictionOutputPath, videoFilePath, 'vibrate')
const funscriptThrustFilePath = await buildFunscript(predictionOutputPath, videoFilePath, 'thrust')
async function uploadFunscripts(
s3Client: ReturnType<typeof getS3Client>,
slug: string,
streamDate: Date,
vodId: string,
funscripts: { vibratePath: string; thrustPath: string }
) {
const vibrateKey = generateS3Path(
slug,
streamDate,
vodId,
`funscripts/vibrate.funscript`
);
const vibrateUrl = await uploadFile(
s3Client,
env.S3_BUCKET,
vibrateKey,
funscripts.vibratePath,
"application/json"
);
logger.debug(`funscriptVibrateFilePath=${funscriptVibrateFilePath}, funscriptThrustFilePath=${funscriptThrustFilePath}`)
const thrustKey = generateS3Path(
slug,
streamDate,
vodId,
`funscripts/thrust.funscript`
);
const thrustUrl = await uploadFile(
s3Client,
env.S3_BUCKET,
thrustKey,
funscripts.thrustPath,
"application/json"
);
const s3KeyFunscriptVibrate = generateS3Path(slug, vod.streamDate, vod.id, `funscripts/vibrate.funscript`);
const s3VibrateUrl = await uploadFile(s3Client, env.S3_BUCKET, s3KeyFunscriptVibrate, funscriptVibrateFilePath, "application/json");
logger.info(`Uploaded funscriptVibrate to S3: ${vibrateUrl}`);
logger.info(`Uploaded funscriptThrust to S3: ${thrustUrl}`);
const s3KeyFunscriptThrust = generateS3Path(slug, vod.streamDate, vod.id, `funscripts/thrust.funscript`);
const s3ThrustUrl = await uploadFile(s3Client, env.S3_BUCKET, s3KeyFunscriptThrust, funscriptThrustFilePath, "application/json");
logger.info(`Uploaded funscriptVibrate to S3: ${s3VibrateUrl}`);
logger.info(`Uploaded funscriptThrust to S3: ${s3ThrustUrl}`);
return { vibrateKey, thrustKey };
}
async function saveToDatabase(
vodId: string,
funscriptKeys: { vibrateKey: string; thrustKey: string }
) {
await prisma.vod.update({
where: { id: vodId },
data: {
funscriptVibrate: s3KeyFunscriptVibrate,
funscriptThrust: s3KeyFunscriptThrust,
}
funscriptVibrate: funscriptKeys.vibrateKey,
funscriptThrust: funscriptKeys.thrustKey,
},
});
logger.info(`Funscripts saved to database for vod ${vodId}`);
}
const createFunscript: Task = async (payload: any, helpers: Helpers) => {
assertPayload(payload);
const { vodId } = payload;
logger.info(`createFunscript called with vodId=${vodId}`);
const vod = await getVod(vodId);
if (!ensureVodReady(vod)) return;
const { s3Client, videoFilePath } = await downloadVideo(vod.sourceVideo!);
const predictionOutputPath = await runInference(videoFilePath);
const slug = vod.vtubers[0].slug;
if (!slug)
throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`);
const funscripts = await buildFunscripts(
predictionOutputPath,
videoFilePath
);
const funscriptKeys = await uploadFunscripts(
s3Client,
slug,
vod.streamDate,
vod.id,
funscripts
);
await saveToDatabase(vodId, funscriptKeys);
};
export default createFunscript;

View File

@ -1,3 +1,5 @@
// src/utils/funscripts.ts
import { join } from "node:path";
import { writeJson } from "fs-extra";
import { env } from "../config/env";
@ -27,6 +29,8 @@ export interface ClassPositionMap {
export type FunscriptType = 'vibrate' | 'thrust';
export const intervalMs = 50; // 20Hz
export const classPositionMap: ClassPositionMap = {
RespondingTo: 5,
ControlledByTipper: 50,
@ -51,54 +55,55 @@ export const classPositionMap: ClassPositionMap = {
};
/**
* Generates patterned Funscript actions for a segment.
*
* @param startMs - Start time in milliseconds.
* @param durationMs - Duration of the segment in milliseconds.
* @param className - Detection class name.
* @param fps - Video frames per second.
* @param type - Motion type ('vibrate' or 'thrust').
* @returns Array of Funscript actions.
* Returns deterministic waveform positions for a segment.
* All patterns are preset waveforms; no randomness.
*/
function getPatternPosition(progress: number, className: string, type: FunscriptType): number {
if (type === 'thrust') {
// pick frequency based on class
let cycles = 2; // default = 2 full oscillations over the segment
switch (className) {
case 'LowLevel': cycles = 1; break; // slow
case 'MediumLevel': cycles = 2; break;
case 'HighLevel': cycles = 4; break; // faster
case 'UltraHighLevel': cycles = 8; break; // very fast
}
const raw = 50 + 50 * Math.sin(progress * cycles * 2 * Math.PI);
return Math.max(0, Math.min(100, Math.round(raw)));
}
// vibrate & other pattern classes unchanged
switch (className) {
case 'Pulse':
return Math.round(50 + 50 * Math.sin(progress * 2 * Math.PI));
case 'Wave':
return Math.round(50 + 50 * Math.sin(progress * 2 * Math.PI));
case 'Fireworks':
return Math.round(50 + 50 * Math.sin(progress * 4 * Math.PI));
case 'Earthquake':
return Math.round(50 + 40 * Math.sin(progress * 8 * Math.PI));
default:
return 50;
}
}
/**
* Generates actions for a segment that uses a pattern.
*/
export function generatePatternPositions(
startMs: number,
durationMs: number,
className: string,
fps: number,
type: FunscriptType
): FunscriptAction[] {
if (!type) throw new Error(`generatePatternPositions requires type, one of 'vibrate' or 'thrust'`);
const actions: FunscriptAction[] = [];
const intervalMs = 100;
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
const progress = timeMs / durationMs;
let pos = 0;
if (type === 'thrust') {
const cycles = 4;
pos = Math.round(50 + 30 * Math.sin(progress * cycles * 2 * Math.PI));
} else {
switch (className) {
case 'Pulse':
pos = Math.round(50 * Math.sin(progress * 2 * Math.PI));
break;
case 'Wave':
pos = Math.round(50 + 50 * Math.sin(progress * 2 * Math.PI));
break;
case 'Fireworks':
pos = Math.random() > 0.5 ? 80 : 0;
break;
case 'Earthquake':
pos = Math.round(90 * Math.sin(progress * 4 * Math.PI) + (Math.random() - 0.5) * 10);
pos = Math.max(0, Math.min(90, pos));
break;
default:
pos = 50;
}
}
const pos = getPatternPosition(progress, className, type);
actions.push({ at: startMs + timeMs, pos });
}
@ -106,14 +111,7 @@ export function generatePatternPositions(
}
/**
* Generates Funscript actions from detection segments.
*
* @param totalDurationMs - Total video duration in milliseconds.
* @param fps - Frames per second.
* @param detectionSegments - Array of detection segments.
* @param classPositionMap - Mapping from class names to positions or patterns.
* @param type - Motion type ('vibrate' or 'thrust').
* @returns Array of unique, time-sorted Funscript actions.
* Generates actions for the whole video.
*/
export function generateActions(
totalDurationMs: number,
@ -122,78 +120,66 @@ export function generateActions(
classPositionMap: ClassPositionMap,
type: FunscriptType
): FunscriptAction[] {
const intervalMs = 100;
const actions: FunscriptAction[] = [];
const actionMap = new Map<number, number>();
// Generate static position actions
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
const frameIndex = Math.floor((timeMs / 1000) * fps);
let position = 0;
let pos: number | undefined = 50; // default mid-point
for (const segment of detectionSegments) {
if (frameIndex >= segment.startFrame && frameIndex <= segment.endFrame) {
const className = segment.className;
if (typeof classPositionMap[className] === 'number') {
position = classPositionMap[className];
break;
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
// will be handled by pattern later
pos = undefined;
} else if (typeof classPositionMap[className] === 'number') {
pos = classPositionMap[className];
}
break;
}
}
actions.push({ at: timeMs, pos: position });
if (pos !== undefined) actionMap.set(timeMs, pos);
}
// Overlay pattern-based actions
// Overlay pattern-based positions
for (const segment of detectionSegments) {
const className = segment.className;
if (classPositionMap[className] === 'pattern') {
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
const startMs = Math.floor((segment.startFrame / fps) * 1000);
const durationMs = Math.floor(((segment.endFrame - segment.startFrame + 1) / fps) * 1000);
const patternActions = generatePatternPositions(startMs, durationMs, className, fps, type);
actions.push(...patternActions);
const patternActions = generatePatternPositions(startMs, durationMs, className, type);
for (const action of patternActions) {
actionMap.set(action.at, action.pos);
}
}
}
// Sort and deduplicate
actions.sort((a, b) => a.at - b.at);
const uniqueActions: FunscriptAction[] = [];
let lastTime = -1;
for (const action of actions) {
if (action.at !== lastTime) {
uniqueActions.push(action);
lastTime = action.at;
}
}
const actions: FunscriptAction[] = Array.from(actionMap.entries())
.map(([at, pos]) => ({ at, pos }))
.sort((a, b) => a.at - b.at);
return uniqueActions;
return actions;
}
/**
* Writes a Funscript file to disk in JSON format.
*
* @param outputPath - Destination file path.
* @param actions - Array of Funscript actions.
* Write JSON funscript file.
*/
export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) {
const funscript: Funscript = { version: '1.0', actions };
await writeJson(outputPath, funscript);
logger.debug(`Funscript generated: ${outputPath} (${actions.length} actions)`);
logger.debug(funscript);
}
/**
* Builds a Funscript file from YOLO prediction output and video metadata.
*
* @param predictionOutput - Path to YOLO prediction output directory.
* @param videoPath - Path to source video file.
* @param type - Motion type ('vibrate' or 'thrust').
* @returns Path to the generated Funscript file.
* Build funscript from YOLO detections + video metadata.
*/
export async function buildFunscript(
predictionOutput: string,
videoPath: string,
type: FunscriptType,
type: FunscriptType
): Promise<string> {
if (!type) throw new Error(`buildFunscript requires third param type, one of 'thrust' or 'vibrate'`);
if (!type) throw new Error("buildFunscript requires type: 'vibrate' or 'thrust'");
const labelDir = join(predictionOutput, 'labels');
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);

View File

@ -248,8 +248,7 @@
{{#if (hasRole "supporterTier1" user)}}
<p>
<a id="funscript-vibrate" data-url="{{getCdnUrl vod.funscriptVibrate}}"
data-file-name="{{basename vod.funscriptThrust}}"
<a id="funscript-vibrate" data-url="{{getCdnUrl vod.funscriptVibrate}}" data-file-name="vibrate.funscript"
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
href="{{getCdnUrl vod.funscriptVibrate}}" alt="{{this.vtuber.displayName}} funscript vibrate file">{{icon
"download" 24}}
@ -257,8 +256,7 @@
vibrate.funscript</a>
</p>
<p>
<a id="funscript-thrust" data-url="{{getCdnUrl vod.funscriptThrust}}"
data-file-name="{{basename vod.funscriptThrust}}"
<a id="funscript-thrust" data-url="{{getCdnUrl vod.funscriptThrust}}" data-file-name="thrust.funscript"
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
href="{{getCdnUrl vod.funscriptThrust}}" alt="{{this.vtuber.displayName}} funscript thrust file">{{icon
"download" 24}}