fix funscript selector
This commit is contained in:
parent
c169405bfe
commit
ea6c5b5bd7
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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 they’re 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 0–1
|
||||
}
|
||||
|
||||
|
||||
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 don’t 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() {
|
||||
|
@ -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);
|
||||
};
|
@ -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;
|
||||
};
|
@ -21,7 +21,7 @@ player.ready(() => {
|
||||
const funscripts = collectFunscripts()
|
||||
const funscriptsOptions = {
|
||||
buttplugClientName: "future.porn",
|
||||
debug: false,
|
||||
debug: true,
|
||||
funscripts,
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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`);
|
||||
|
@ -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}}
|
||||
|
Loading…
x
Reference in New Issue
Block a user