fix funscript selector
This commit is contained in:
parent
c169405bfe
commit
ea6c5b5bd7
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "futureporn",
|
"name": "futureporn-our",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -46,15 +46,7 @@ class FunscriptMenuItem extends VideoJsMenuItem {
|
|||||||
this.on('click', () => {
|
this.on('click', () => {
|
||||||
if (!this.actuator || !this.onAssign) return;
|
if (!this.actuator || !this.onAssign) return;
|
||||||
|
|
||||||
// Assign this funscript
|
// Use centralized assignment method
|
||||||
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
|
|
||||||
this.onAssign?.(this, this.actuator, this.funscript);
|
this.onAssign?.(this, this.actuator, this.funscript);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -82,23 +74,19 @@ export default class FunscriptPlayer {
|
|||||||
this.player = player;
|
this.player = player;
|
||||||
this.debug = debug;
|
this.debug = debug;
|
||||||
this.funscriptCache = new Map(); // key: url, value: parsed funscript
|
this.funscriptCache = new Map(); // key: url, value: parsed funscript
|
||||||
// Normalize funscripts: ensure they’re always { name, url }
|
|
||||||
this.funscripts = (funscripts || []).map(f => {
|
this.funscripts = (funscripts || []).map(f => {
|
||||||
if (typeof f === 'string') {
|
if (typeof f === 'string') {
|
||||||
// Extract filename from URL
|
|
||||||
const url = f;
|
const url = f;
|
||||||
const name = url.split('/').pop().split('?')[0]; // remove query params
|
const name = url.split('/').pop().split('?')[0];
|
||||||
return { name, url };
|
return { name, url };
|
||||||
}
|
}
|
||||||
// Already in { name, url } format
|
|
||||||
return f;
|
return f;
|
||||||
});
|
});
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
|
||||||
this.menuButton = null;
|
this.menuButton = null;
|
||||||
this.devices = []; // track connected devices
|
this.devices = [];
|
||||||
|
|
||||||
// refresh menu when devices change
|
|
||||||
player.on('toyConnected', () => this.refreshMenu());
|
player.on('toyConnected', () => this.refreshMenu());
|
||||||
player.on('toyDisconnected', () => this.refreshMenu());
|
player.on('toyDisconnected', () => this.refreshMenu());
|
||||||
player.on('pause', () => this.stopAllDevices());
|
player.on('pause', () => this.stopAllDevices());
|
||||||
@ -124,7 +112,6 @@ export default class FunscriptPlayer {
|
|||||||
await this.client.connect(connector);
|
await this.client.connect(connector);
|
||||||
await this.client.startScanning();
|
await this.client.startScanning();
|
||||||
this.initVideoListeners();
|
this.initVideoListeners();
|
||||||
|
|
||||||
this.debug && console.log("[buttplug] Connected and scanning");
|
this.debug && console.log("[buttplug] Connected and scanning");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[buttplug] Connection error:", err);
|
console.error("[buttplug] Connection error:", err);
|
||||||
@ -145,9 +132,6 @@ export default class FunscriptPlayer {
|
|||||||
getFunscriptValueAtTime(funscriptData, timeMs) {
|
getFunscriptValueAtTime(funscriptData, timeMs) {
|
||||||
if (!funscriptData?.actions?.length) return null;
|
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;
|
let lastAction = null;
|
||||||
for (const action of funscriptData.actions) {
|
for (const action of funscriptData.actions) {
|
||||||
if (action.at <= timeMs) {
|
if (action.at <= timeMs) {
|
||||||
@ -159,37 +143,29 @@ export default class FunscriptPlayer {
|
|||||||
return lastAction ? lastAction.pos / 100 : null; // normalize 0–1
|
return lastAction ? lastAction.pos / 100 : null; // normalize 0–1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
initVideoListeners() {
|
initVideoListeners() {
|
||||||
|
|
||||||
this.player.on('pause', () => {
|
this.player.on('pause', () => {
|
||||||
this.debug && console.log('Video paused. Stopping devices...');
|
this.debug && console.log('Video paused. Stopping devices...');
|
||||||
this.stopAllDevices();
|
this.stopAllDevices();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
this.player.on('timeupdate', () => {
|
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;
|
||||||
|
|
||||||
const currentTime = this.player.currentTime() * 1000; // ms
|
|
||||||
// this.debug && console.log(`timeupdate currentTime=${currentTime}`)
|
|
||||||
|
|
||||||
this.devices.forEach(device => {
|
this.devices.forEach(device => {
|
||||||
device.actuators.forEach(actuator => {
|
device.actuators.forEach(actuator => {
|
||||||
const fun = actuator.assignedFunscript;
|
const fun = actuator.assignedFunscript;
|
||||||
if (!fun) return;
|
if (!fun) return;
|
||||||
// this.debug && console.log(`name=${fun.name} url=${fun.url}`)
|
|
||||||
|
|
||||||
const funscriptData = this.funscriptCache.get(fun.url);
|
const funscriptData = this.funscriptCache.get(fun.url);
|
||||||
// console.log(funscriptData)
|
|
||||||
if (!funscriptData) return;
|
if (!funscriptData) return;
|
||||||
|
|
||||||
const position = this.getFunscriptValueAtTime(funscriptData, currentTime);
|
const position = this.getFunscriptValueAtTime(funscriptData, currentTime);
|
||||||
// this.debug && console.log(`position=${position}`)
|
this.debug && console.log(`${fun.name} position=${position}`);
|
||||||
|
|
||||||
if (position !== null) {
|
if (position !== null) {
|
||||||
this.debug && console.log(`actuator.name=${actuator.name}, position=${position}`)
|
|
||||||
this.sendToDevice(device, actuator, position);
|
this.sendToDevice(device, actuator, position);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -215,13 +191,9 @@ export default class FunscriptPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async sendToDevice(device, actuator, position) {
|
async sendToDevice(device, actuator, position) {
|
||||||
try {
|
try {
|
||||||
// console.log()
|
this.client.devices[device.index].vibrate(position);
|
||||||
// await this.client.devices[device.index].scalar(actuator.index, position);
|
|
||||||
this.client.devices[device.index].vibrate(position)
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`[buttplug] Sent to ${actuator.name}:`, position);
|
console.log(`[buttplug] Sent to ${actuator.name}:`, position);
|
||||||
}
|
}
|
||||||
@ -230,7 +202,6 @@ export default class FunscriptPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ------------------------- Device Tracking ------------------------- */
|
/* ------------------------- Device Tracking ------------------------- */
|
||||||
|
|
||||||
addDevice(device) {
|
addDevice(device) {
|
||||||
@ -239,13 +210,9 @@ export default class FunscriptPlayer {
|
|||||||
const actuators = scalarCmds.map((cmd, i) => {
|
const actuators = scalarCmds.map((cmd, i) => {
|
||||||
const assignedFunscript = pickInitialFunscript(cmd.ActuatorType, this.funscripts);
|
const assignedFunscript = pickInitialFunscript(cmd.ActuatorType, this.funscripts);
|
||||||
|
|
||||||
// kick off preload asynchronously, but don’t block UI
|
|
||||||
if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) {
|
if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) {
|
||||||
this.loadFunscript(assignedFunscript).then(data => {
|
this.loadFunscript(assignedFunscript).then(data => {
|
||||||
if (data) this.funscriptCache.set(assignedFunscript.url, 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,
|
type: cmd.ActuatorType,
|
||||||
index: cmd.Index,
|
index: cmd.Index,
|
||||||
assignedFunscript,
|
assignedFunscript,
|
||||||
|
deviceIndex: this.devices.length, // track device index
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -262,77 +230,65 @@ export default class FunscriptPlayer {
|
|||||||
index: this.devices.length,
|
index: this.devices.length,
|
||||||
actuators,
|
actuators,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.debug && console.log("Processed device:", device._deviceInfo.DeviceName, "actuators:", actuators);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
removeDevice(device) {
|
removeDevice(device) {
|
||||||
this.devices = this.devices.filter(d => d.name !== device.name);
|
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 ------------------------- */
|
/* ------------------------- Menu Management ------------------------- */
|
||||||
|
|
||||||
createMenuButtons() {
|
createMenuButtons() {
|
||||||
// Remove existing menu buttons
|
|
||||||
if (this.menuButtons?.length) {
|
if (this.menuButtons?.length) {
|
||||||
this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn));
|
this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn));
|
||||||
}
|
}
|
||||||
this.menuButtons = [];
|
this.menuButtons = [];
|
||||||
|
|
||||||
const actuators = this.devices.flatMap(device =>
|
this.devices.forEach(device => {
|
||||||
device.actuators.map(actuator => ({
|
device.actuators.forEach(actuator => {
|
||||||
...actuator,
|
const button = new ActuatorMenuButton(this.player, {
|
||||||
deviceName: device.name,
|
actuator,
|
||||||
deviceIndex: device.index,
|
funscripts: this.funscripts,
|
||||||
}))
|
onAssign: async (item, act, funscript) => {
|
||||||
);
|
await this.assignFunscript(device.index, act.index, funscript);
|
||||||
|
|
||||||
actuators.forEach(actuator => {
|
const menuItems = item.parentComponent_.children()
|
||||||
const button = new ActuatorMenuButton(this.player, {
|
.filter(c => c instanceof FunscriptMenuItem);
|
||||||
actuator,
|
menuItems.forEach(i => {
|
||||||
funscripts: this.funscripts,
|
i.selected(i.funscript.url === funscript.url);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update UI
|
const placementIndex = Math.max(
|
||||||
const menuItems = item.parentComponent_.children()
|
0,
|
||||||
.filter(c => c instanceof FunscriptMenuItem);
|
this.options?.placementIndex ?? (this.player.controlBar.children().length - 2)
|
||||||
|
);
|
||||||
|
this.player.controlBar.addChild(button, { componentClass: 'funscriptSelector' }, placementIndex);
|
||||||
|
|
||||||
menuItems.forEach(i => {
|
setTimeout(() => {
|
||||||
i.selected(i.funscript.url === funscript.url);
|
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() {
|
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 funscripts = collectFunscripts()
|
||||||
const funscriptsOptions = {
|
const funscriptsOptions = {
|
||||||
buttplugClientName: "future.porn",
|
buttplugClientName: "future.porn",
|
||||||
debug: false,
|
debug: true,
|
||||||
funscripts,
|
funscripts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,86 +7,166 @@ import { getS3Client, uploadFile } from "../utils/s3";
|
|||||||
import { inference } from "../utils/vibeui";
|
import { inference } from "../utils/vibeui";
|
||||||
import { buildFunscript } from "../utils/funscripts";
|
import { buildFunscript } from "../utils/funscripts";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { generateS3Path } from '../utils/formatters';
|
import { generateS3Path } from "../utils/formatters";
|
||||||
|
|
||||||
interface Payload {
|
interface Payload {
|
||||||
vodId: string;
|
vodId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const prisma = new PrismaClient().$extends(withAccelerate());
|
const prisma = new PrismaClient().$extends(withAccelerate());
|
||||||
|
|
||||||
|
|
||||||
function assertPayload(payload: any): asserts payload is Payload {
|
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 !== "object" || !payload)
|
||||||
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVodReady(vod: Awaited<ReturnType<typeof getVod>>) {
|
||||||
|
|
||||||
|
|
||||||
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 } });
|
|
||||||
|
|
||||||
if (vod.funscriptVibrate && vod.funscriptThrust) {
|
if (vod.funscriptVibrate && vod.funscriptThrust) {
|
||||||
logger.info(`Doing nothing-- vod ${vodId} already has funscripts.`);
|
logger.info(`Doing nothing-- vod ${vod.id} already has funscripts.`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vod.sourceVideo) {
|
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);
|
logger.warn(msg);
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadVideo(sourceVideo: string) {
|
||||||
const s3Client = getS3Client();
|
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}`);
|
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);
|
const predictionOutputPath = await inference(videoFilePath);
|
||||||
logger.info(`prediction output ${predictionOutputPath}`);
|
logger.info(`Prediction output at ${predictionOutputPath}`);
|
||||||
|
return predictionOutputPath;
|
||||||
|
}
|
||||||
|
|
||||||
const slug = vod.vtubers[0].slug
|
async function buildFunscripts(
|
||||||
if (!slug) throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`);
|
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.`)
|
async function uploadFunscripts(
|
||||||
const funscriptVibrateFilePath = await buildFunscript(predictionOutputPath, videoFilePath, 'vibrate')
|
s3Client: ReturnType<typeof getS3Client>,
|
||||||
const funscriptThrustFilePath = await buildFunscript(predictionOutputPath, videoFilePath, 'thrust')
|
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`);
|
logger.info(`Uploaded funscriptVibrate to S3: ${vibrateUrl}`);
|
||||||
const s3VibrateUrl = await uploadFile(s3Client, env.S3_BUCKET, s3KeyFunscriptVibrate, funscriptVibrateFilePath, "application/json");
|
logger.info(`Uploaded funscriptThrust to S3: ${thrustUrl}`);
|
||||||
|
|
||||||
const s3KeyFunscriptThrust = generateS3Path(slug, vod.streamDate, vod.id, `funscripts/thrust.funscript`);
|
return { vibrateKey, thrustKey };
|
||||||
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}`);
|
|
||||||
|
|
||||||
|
async function saveToDatabase(
|
||||||
|
vodId: string,
|
||||||
|
funscriptKeys: { vibrateKey: string; thrustKey: string }
|
||||||
|
) {
|
||||||
await prisma.vod.update({
|
await prisma.vod.update({
|
||||||
where: { id: vodId },
|
where: { id: vodId },
|
||||||
data: {
|
data: {
|
||||||
funscriptVibrate: s3KeyFunscriptVibrate,
|
funscriptVibrate: funscriptKeys.vibrateKey,
|
||||||
funscriptThrust: s3KeyFunscriptThrust,
|
funscriptThrust: funscriptKeys.thrustKey,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Funscripts saved to database for vod ${vodId}`);
|
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;
|
export default createFunscript;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// src/utils/funscripts.ts
|
||||||
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { writeJson } from "fs-extra";
|
import { writeJson } from "fs-extra";
|
||||||
import { env } from "../config/env";
|
import { env } from "../config/env";
|
||||||
@ -27,6 +29,8 @@ export interface ClassPositionMap {
|
|||||||
|
|
||||||
export type FunscriptType = 'vibrate' | 'thrust';
|
export type FunscriptType = 'vibrate' | 'thrust';
|
||||||
|
|
||||||
|
export const intervalMs = 50; // 20Hz
|
||||||
|
|
||||||
export const classPositionMap: ClassPositionMap = {
|
export const classPositionMap: ClassPositionMap = {
|
||||||
RespondingTo: 5,
|
RespondingTo: 5,
|
||||||
ControlledByTipper: 50,
|
ControlledByTipper: 50,
|
||||||
@ -51,54 +55,55 @@ export const classPositionMap: ClassPositionMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates patterned Funscript actions for a segment.
|
* Returns deterministic waveform positions for a segment.
|
||||||
*
|
* All patterns are preset waveforms; no randomness.
|
||||||
* @param startMs - Start time in milliseconds.
|
*/
|
||||||
* @param durationMs - Duration of the segment in milliseconds.
|
function getPatternPosition(progress: number, className: string, type: FunscriptType): number {
|
||||||
* @param className - Detection class name.
|
if (type === 'thrust') {
|
||||||
* @param fps - Video frames per second.
|
// pick frequency based on class
|
||||||
* @param type - Motion type ('vibrate' or 'thrust').
|
let cycles = 2; // default = 2 full oscillations over the segment
|
||||||
* @returns Array of Funscript actions.
|
|
||||||
|
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(
|
export function generatePatternPositions(
|
||||||
startMs: number,
|
startMs: number,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
className: string,
|
className: string,
|
||||||
fps: number,
|
|
||||||
type: FunscriptType
|
type: FunscriptType
|
||||||
): FunscriptAction[] {
|
): FunscriptAction[] {
|
||||||
if (!type) throw new Error(`generatePatternPositions requires type, one of 'vibrate' or 'thrust'`);
|
|
||||||
|
|
||||||
const actions: FunscriptAction[] = [];
|
const actions: FunscriptAction[] = [];
|
||||||
const intervalMs = 100;
|
|
||||||
|
|
||||||
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
|
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
|
||||||
const progress = timeMs / durationMs;
|
const progress = timeMs / durationMs;
|
||||||
let pos = 0;
|
const pos = getPatternPosition(progress, className, type);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.push({ at: startMs + timeMs, pos });
|
actions.push({ at: startMs + timeMs, pos });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,14 +111,7 @@ export function generatePatternPositions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates Funscript actions from detection segments.
|
* Generates actions for the whole video.
|
||||||
*
|
|
||||||
* @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.
|
|
||||||
*/
|
*/
|
||||||
export function generateActions(
|
export function generateActions(
|
||||||
totalDurationMs: number,
|
totalDurationMs: number,
|
||||||
@ -122,78 +120,66 @@ export function generateActions(
|
|||||||
classPositionMap: ClassPositionMap,
|
classPositionMap: ClassPositionMap,
|
||||||
type: FunscriptType
|
type: FunscriptType
|
||||||
): FunscriptAction[] {
|
): FunscriptAction[] {
|
||||||
const intervalMs = 100;
|
const actionMap = new Map<number, number>();
|
||||||
const actions: FunscriptAction[] = [];
|
|
||||||
|
|
||||||
// Generate static position actions
|
|
||||||
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
|
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
|
||||||
const frameIndex = Math.floor((timeMs / 1000) * fps);
|
const frameIndex = Math.floor((timeMs / 1000) * fps);
|
||||||
let position = 0;
|
let pos: number | undefined = 50; // default mid-point
|
||||||
|
|
||||||
for (const segment of detectionSegments) {
|
for (const segment of detectionSegments) {
|
||||||
if (frameIndex >= segment.startFrame && frameIndex <= segment.endFrame) {
|
if (frameIndex >= segment.startFrame && frameIndex <= segment.endFrame) {
|
||||||
const className = segment.className;
|
const className = segment.className;
|
||||||
if (typeof classPositionMap[className] === 'number') {
|
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
|
||||||
position = classPositionMap[className];
|
// will be handled by pattern later
|
||||||
break;
|
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) {
|
for (const segment of detectionSegments) {
|
||||||
const className = segment.className;
|
const className = segment.className;
|
||||||
if (classPositionMap[className] === 'pattern') {
|
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
|
||||||
const startMs = Math.floor((segment.startFrame / fps) * 1000);
|
const startMs = Math.floor((segment.startFrame / fps) * 1000);
|
||||||
const durationMs = Math.floor(((segment.endFrame - segment.startFrame + 1) / fps) * 1000);
|
const durationMs = Math.floor(((segment.endFrame - segment.startFrame + 1) / fps) * 1000);
|
||||||
const patternActions = generatePatternPositions(startMs, durationMs, className, fps, type);
|
const patternActions = generatePatternPositions(startMs, durationMs, className, type);
|
||||||
actions.push(...patternActions);
|
for (const action of patternActions) {
|
||||||
|
actionMap.set(action.at, action.pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and deduplicate
|
const actions: FunscriptAction[] = Array.from(actionMap.entries())
|
||||||
actions.sort((a, b) => a.at - b.at);
|
.map(([at, pos]) => ({ at, pos }))
|
||||||
const uniqueActions: FunscriptAction[] = [];
|
.sort((a, b) => a.at - b.at);
|
||||||
let lastTime = -1;
|
|
||||||
for (const action of actions) {
|
|
||||||
if (action.at !== lastTime) {
|
|
||||||
uniqueActions.push(action);
|
|
||||||
lastTime = action.at;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueActions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a Funscript file to disk in JSON format.
|
* Write JSON funscript file.
|
||||||
*
|
|
||||||
* @param outputPath - Destination file path.
|
|
||||||
* @param actions - Array of Funscript actions.
|
|
||||||
*/
|
*/
|
||||||
export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) {
|
export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) {
|
||||||
const funscript: Funscript = { version: '1.0', actions };
|
const funscript: Funscript = { version: '1.0', actions };
|
||||||
await writeJson(outputPath, funscript);
|
await writeJson(outputPath, funscript);
|
||||||
logger.debug(`Funscript generated: ${outputPath} (${actions.length} actions)`);
|
logger.debug(`Funscript generated: ${outputPath} (${actions.length} actions)`);
|
||||||
logger.debug(funscript);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Funscript file from YOLO prediction output and video metadata.
|
* Build funscript from YOLO detections + 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.
|
|
||||||
*/
|
*/
|
||||||
export async function buildFunscript(
|
export async function buildFunscript(
|
||||||
predictionOutput: string,
|
predictionOutput: string,
|
||||||
videoPath: string,
|
videoPath: string,
|
||||||
type: FunscriptType,
|
type: FunscriptType
|
||||||
): Promise<string> {
|
): 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 labelDir = join(predictionOutput, 'labels');
|
||||||
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);
|
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);
|
||||||
|
@ -248,8 +248,7 @@
|
|||||||
|
|
||||||
{{#if (hasRole "supporterTier1" user)}}
|
{{#if (hasRole "supporterTier1" user)}}
|
||||||
<p>
|
<p>
|
||||||
<a id="funscript-vibrate" data-url="{{getCdnUrl vod.funscriptVibrate}}"
|
<a id="funscript-vibrate" data-url="{{getCdnUrl vod.funscriptVibrate}}" data-file-name="vibrate.funscript"
|
||||||
data-file-name="{{basename vod.funscriptThrust}}"
|
|
||||||
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
|
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
|
||||||
href="{{getCdnUrl vod.funscriptVibrate}}" alt="{{this.vtuber.displayName}} funscript vibrate file">{{icon
|
href="{{getCdnUrl vod.funscriptVibrate}}" alt="{{this.vtuber.displayName}} funscript vibrate file">{{icon
|
||||||
"download" 24}}
|
"download" 24}}
|
||||||
@ -257,8 +256,7 @@
|
|||||||
vibrate.funscript</a>
|
vibrate.funscript</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a id="funscript-thrust" data-url="{{getCdnUrl vod.funscriptThrust}}"
|
<a id="funscript-thrust" data-url="{{getCdnUrl vod.funscriptThrust}}" data-file-name="thrust.funscript"
|
||||||
data-file-name="{{basename vod.funscriptThrust}}"
|
|
||||||
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
|
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)"
|
||||||
href="{{getCdnUrl vod.funscriptThrust}}" alt="{{this.vtuber.displayName}} funscript thrust file">{{icon
|
href="{{getCdnUrl vod.funscriptThrust}}" alt="{{this.vtuber.displayName}} funscript thrust file">{{icon
|
||||||
"download" 24}}
|
"download" 24}}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user