From ea6c5b5bd7142d35b78af31a748ee3ac1a02b97e Mon Sep 17 00:00:00 2001 From: CJ_Clippy Date: Tue, 2 Sep 2025 18:53:26 -0800 Subject: [PATCH] fix funscript selector --- services/our/package.json | 6 +- .../client/videojs-funscripts/Funscripts.js | 134 ++++------- .../lib/funscript-utils/funMapper.js | 216 ++++++++++++++++++ .../lib/funscript-utils/utils.js | 81 +++++++ services/our/src/client/vod.js | 2 +- services/our/src/tasks/createFunscript.ts | 170 ++++++++++---- services/our/src/utils/funscripts.ts | 148 ++++++------ services/our/src/views/vod.hbs | 6 +- 8 files changed, 540 insertions(+), 223 deletions(-) create mode 100644 services/our/src/client/videojs-funscripts/lib/funscript-utils/funMapper.js create mode 100644 services/our/src/client/videojs-funscripts/lib/funscript-utils/utils.js diff --git a/services/our/package.json b/services/our/package.json index bfd0f3f..cee3122 100644 --- a/services/our/package.json +++ b/services/our/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/services/our/src/client/videojs-funscripts/Funscripts.js b/services/our/src/client/videojs-funscripts/Funscripts.js index 22a7dac..14d81d8 100644 --- a/services/our/src/client/videojs-funscripts/Funscripts.js +++ b/services/our/src/client/videojs-funscripts/Funscripts.js @@ -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() { diff --git a/services/our/src/client/videojs-funscripts/lib/funscript-utils/funMapper.js b/services/our/src/client/videojs-funscripts/lib/funscript-utils/funMapper.js new file mode 100644 index 0000000..8d434fa --- /dev/null +++ b/services/our/src/client/videojs-funscripts/lib/funscript-utils/funMapper.js @@ -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); +}; diff --git a/services/our/src/client/videojs-funscripts/lib/funscript-utils/utils.js b/services/our/src/client/videojs-funscripts/lib/funscript-utils/utils.js new file mode 100644 index 0000000..8b47a5f --- /dev/null +++ b/services/our/src/client/videojs-funscripts/lib/funscript-utils/utils.js @@ -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; +}; diff --git a/services/our/src/client/vod.js b/services/our/src/client/vod.js index af445da..9987435 100644 --- a/services/our/src/client/vod.js +++ b/services/our/src/client/vod.js @@ -21,7 +21,7 @@ player.ready(() => { const funscripts = collectFunscripts() const funscriptsOptions = { buttplugClientName: "future.porn", - debug: false, + debug: true, funscripts, } diff --git a/services/our/src/tasks/createFunscript.ts b/services/our/src/tasks/createFunscript.ts index 3852ad3..53674d3 100644 --- a/services/our/src/tasks/createFunscript.ts +++ b/services/our/src/tasks/createFunscript.ts @@ -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>) { 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, + 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; diff --git a/services/our/src/utils/funscripts.ts b/services/our/src/utils/funscripts.ts index 4768f8d..c293c38 100644 --- a/services/our/src/utils/funscripts.ts +++ b/services/our/src/utils/funscripts.ts @@ -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(); - // 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 { - 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`); diff --git a/services/our/src/views/vod.hbs b/services/our/src/views/vod.hbs index e816344..716fa01 100644 --- a/services/our/src/views/vod.hbs +++ b/services/our/src/views/vod.hbs @@ -248,8 +248,7 @@ {{#if (hasRole "supporterTier1" user)}}

- {{icon "download" 24}} @@ -257,8 +256,7 @@ vibrate.funscript

- {{icon "download" 24}}