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