add funscripts tests
Some checks failed
ci / build (push) Failing after 0s
ci / Tests & Checks (push) Failing after 1s

This commit is contained in:
CJ_Clippy 2025-07-18 03:50:29 -08:00
parent 77b8fe75d9
commit 110565d536
324 changed files with 8923 additions and 434 deletions

View File

@ -55,4 +55,8 @@ In other words, pick something for a name and roll with the punches.
### Find what you love and let it kill you
> Find what you love and let it kill you. Let it drain you of your all. Let it cling onto your back and weigh you down into eventual nothingness. Let it kill you and let it devour your remains. For all things will kill you, both slowly and fastly, but it's much better to be killed by a lover.
> -- Charles Bukowski
> -- Charles Bukowski
### Do what you can, with what you have, where you are at
### Use it up, wear it out, make it do, or do without

View File

@ -1,3 +1,5 @@
src/test
node_modules
# Output

View File

@ -1,3 +1,5 @@
vibeui
generated
backup

View File

@ -13,8 +13,11 @@ RUN apt-get update -y && \
mktorrent \
python3 \
python3-pip \
python3-venv \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install IPFS Kubo
COPY --from=ipfs/kubo:v0.36.0 /usr/local/bin/ipfs /usr/local/bin/ipfs
# Copy and install dependencies
COPY package.json package-lock.json ./

View File

@ -5,7 +5,7 @@ services:
container_name: our-postgres
image: postgres:17
restart: unless-stopped
env_file: ./../../.env
env_file: ./../../.env.development
ports:
- "5432:5432"
volumes:
@ -17,47 +17,6 @@ services:
start_period: 10s
timeout: 10s
pgweb:
container_name: out-pgweb
image: sosedoff/pgweb
depends_on:
postgres:
condition: service_healthy
env_file: ./../../.env
ports:
- "8091:8081"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081"]
interval: 10s
retries: 5
start_period: 10s
timeout: 10s
# pgadmin:
# image: dpage/pgadmin4:latest
# environment:
# PGADMIN_LISTEN_PORT: 5050
# PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
# PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
# PGADMIN_DISABLE_POSTFIX: true
# ports:
# - "5050:5050"
our:
build:
context: .
dockerfile: Dockerfile
container_name: our-app
ports:
- "5000:5000"
env_file: ./../../.env ## @see ./src/config/env.ts for all env var names.
depends_on:
postgres:
condition: service_healthy
networks:
- default
entrypoint: >
sh -c "npm run deploy && npm run start:server"
volumes:
pgdata:

View File

@ -66,4 +66,3 @@ networks:
default: {}
## i need to mount /mnt/vfs/futureporn:/mnt/vfs/futureporn on both the server and worker

View File

@ -0,0 +1,53 @@
services:
postgres:
container_name: our-postgres
image: postgres:17
restart: unless-stopped
env_file: ./../../.env.development
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
retries: 5
start_period: 10s
timeout: 10s
server:
build:
context: .
dockerfile: Dockerfile
container_name: our-server
ports:
- "5000:5000"
env_file: ./../../.env.development ## @see ./src/config/env.ts for all env var names.
depends_on:
postgres:
condition: service_healthy
networks:
- default
entrypoint: >
sh -c "npm run deploy && npm run start:server"
worker:
build:
context: .
dockerfile: Dockerfile
container_name: our-worker
env_file: ./../../.env.development
depends_on:
postgres:
condition: service_healthy
networks:
- default
init: true
entrypoint: >
sh -c "npm run start:worker"
volumes:
pgdata:

View File

@ -3,9 +3,6 @@
"private": true,
"version": "2.0.1",
"type": "module",
"overrides": {
"@sinclair/typebox": "0.34.0"
},
"scripts": {
"dev": "concurrently npm:dev:serve npm:dev:build npm:dev:worker",
"dev:serve": "tsx watch ./src/index.ts",
@ -18,26 +15,21 @@
"build": "tsup --clean",
"lint": "eslint .",
"clean": "rm -rf node_modules && rm -rf pnpm-lock.yaml",
"deploy": "npx prisma migrate deploy"
"deploy": "npx prisma migrate deploy",
"test:watch": "npx vitest --watch",
"test": "npx vitest"
},
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@eslint/js": "^9.31.0",
"@fontsource/fira-mono": "^5.2.6",
"@neoconfetti/svelte": "^2.2.2",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"eslint": "^9.31.0",
"eslint-plugin-svelte": "^3.10.1",
"globals": "^16.3.0",
"nodemon": "^3.1.10",
"prisma": "6.8.2",
"svelte": "^5.35.6",
"svelte-check": "^4.2.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"vite": "^6.3.5"
"vitest": "^3.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
@ -45,13 +37,9 @@
]
},
"dependencies": {
"@auth0/auth0-spa-js": "^2.2.0",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/s3-request-presigner": "^3.844.0",
"@bogeychan/elysia-logger": "^0.1.8",
"@dotenvx/dotenvx": "^1.47.5",
"@elysiajs/static": "^1.3.0",
"@elysiajs/swagger": "^1.3.1",
"@fastify/flash": "^6.0.3",
"@fastify/formbody": "^8.0.2",
"@fastify/multipart": "^9.0.3",
@ -69,19 +57,10 @@
"@prisma/extension-accelerate": "^1.3.0",
"@types/node": "^22.16.3",
"@types/node-fetch": "^2.6.12",
"arctic": "^3.7.0",
"axios": "^1.10.0",
"cache-manager": "^7.0.1",
"chokidar-cli": "^3.0.0",
"concurrently": "^9.2.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.43.1",
"drizzle-typebox": "^0.3.3",
"ejs": "^3.1.10",
"elysia": "^1.3.5",
"elysia-clerk": "^0.9.10",
"elysia-connect-middleware": "^0.0.5",
"elysia-oauth2": "^2.1.0",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"fastify-sse-v2": "^4.2.1",
@ -96,8 +75,8 @@
"mime-types": "^3.0.1",
"nano-spawn": "^1.0.2",
"nanoid": "^5.1.5",
"nocodb-sdk": "^0.263.8",
"node-fetch": "^3.3.2",
"onnxruntime-node": "1.22.0-rev",
"rate-limiter-flexible": "^7.1.1",
"rimraf": "6.0.1",
"sharp": "^0.34.3",
@ -105,7 +84,7 @@
"ts-node": "^10.9.2",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"vitest": "^3.2.4",
"which": "^5.0.0",
"zod": "^3.25.76"
},
"prisma": {

7413
services/our/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,10 @@
// ./config/env.ts
import { z } from 'zod';
import '@dotenvx/dotenvx/config'
import dotenvx from '@dotenvx/dotenvx'
dotenvx.config({ path: ['../../.env.development'] })
// if (process.env.NODE_ENV === 'development') {
// }
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
@ -26,7 +30,7 @@ const EnvSchema = z.object({
});
const parsed = EnvSchema.safeParse(process.env);
console.log(parsed)
// console.log(parsed)
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);

View File

@ -1,370 +1,28 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { existsSync } from "node:fs";
import spawn from 'nano-spawn';
import { join, basename, extname } from "node:path";
import { readFile, writeFile, readdir } from 'node:fs/promises';
import yaml from 'js-yaml';
import { string } from "zod";
const prisma = new PrismaClient().$extends(withAccelerate());
import { buildFunscript } from '../utils/funscripts';
import { vibeuiInference } from "../utils/vibeui";
import { join } from "node:path";
interface Payload {
vodId: string;
}
interface Detection {
startFrame: number;
endFrame: number;
className: string;
}
interface DataYaml {
path: string;
train: string;
val: string;
names: Record<string, string>;
}
interface FunscriptAction {
at: number;
pos: number;
}
interface Funscript {
version: string;
actions: FunscriptAction[];
}
interface ClassPositionMap {
[className: string]: number | 'pattern';
}
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");
}
async function loadDataYaml(yamlPath: string): Promise<DataYaml> {
const yamlContent = await readFile(yamlPath, 'utf8');
return yaml.load(yamlContent) as DataYaml;
}
async function preparePython(helpers) {
const venvPath = join(env.VIBEUI_DIR, 'venv')
// Create venv if it doesn't exist
if (!existsSync(venvPath)) {
helpers.logger.info("Python venv not found. Creating one...");
await spawn("python", ["-m", "venv", "venv"], {
cwd: env.VIBEUI_DIR,
});
} else {
helpers.logger.info("Using existing Python venv.");
}
}
async function ffprobe(videoPath: string): Promise<{ fps: number; frames: number }> {
const { stdout } = await spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-count_frames',
'-show_entries', 'stream=nb_read_frames,r_frame_rate',
'-of', 'default=nokey=1:noprint_wrappers=1',
videoPath,
])
const [frameRateStr, frameCountStr] = stdout.trim().split('\n')
const [num, denom] = frameRateStr.trim().split('/').map(Number)
const fps = num / denom
const frames = parseInt(frameCountStr.trim(), 10)
return { fps, frames }
}
export async function buildFunscript(
helpers: Helpers,
predictionOutput: string,
videoPath: string
): Promise<string> {
const labelDir = join(predictionOutput, 'labels');
const yamlPath = join(predictionOutput, 'data.yaml');
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);
helpers.logger.info('Starting Funscript generation');
try {
const data = await loadDataYaml(join(env.VIBEUI_DIR, 'data.yaml'))
const classPositionMap = await loadClassPositionMap(data, helpers);
const { fps, totalFrames } = await loadVideoMetadata(videoPath, helpers);
const detectionSegments = await processLabelFiles(labelDir, helpers, data);
const totalDurationMs = Math.floor((totalFrames / fps) * 1000);
const actions = generateActions(totalDurationMs, fps, detectionSegments, classPositionMap);
await writeFunscript(outputPath, actions, helpers);
return outputPath;
} catch (error) {
helpers.logger.error(`Error generating Funscript: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
export async function inference(helpers: Helpers, videoFilePath: string): Promise<string> {
const modelPath = join(env.VIBEUI_DIR, 'runs/detect/vibeui/weights/best.pt')
// Generate a unique name based on video name + UUID
const videoExt = extname(videoFilePath) // e.g. '.mp4'
const videoName = basename(videoFilePath, videoExt) // removes the extension
const uniqueName = `${videoName}-${nanoid()}`
const customProjectDir = 'runs' // or any custom folder
const outputPath = join(env.VIBEUI_DIR, customProjectDir, uniqueName)
await spawn('./venv/bin/yolo', [
'predict',
`model=${modelPath}`,
`source=${videoFilePath}`,
'save=False',
'save_txt=True',
'save_conf=True',
`project=${customProjectDir}`,
`name=${uniqueName}`,
], {
cwd: env.VIBEUI_DIR,
stdio: 'inherit',
})
return outputPath // contains labels/ folder and predictions
}
async function loadClassPositionMap(data: DataYaml, helpers: Helpers): Promise<ClassPositionMap> {
try {
if (
!data ||
typeof data !== 'object' ||
!('names' in data) ||
typeof data.names !== 'object' ||
data.names === null ||
Object.keys(data.names).length === 0
) {
throw new Error('Invalid data.yaml: "names" field is missing, not an object, or empty');
}
const positionMap: ClassPositionMap = {
ControlledByTipper: 50,
ControlledByTipperHigh: 80,
ControlledByTipperLow: 20,
ControlledByTipperMedium: 50,
ControlledByTipperUltrahigh: 95,
Ring1: 30,
Ring2: 40,
Ring3: 50,
Ring4: 60,
Earthquake: 'pattern',
Fireworks: 'pattern',
Pulse: 'pattern',
Wave: 'pattern',
Pause: 0,
RandomTime: 70,
HighLevel: 80,
LowLevel: 20,
MediumLevel: 50,
UltraHighLevel: 95
};
const names = Object.values(data.names);
for (const name of names) {
if (typeof name !== 'string' || name.trim() === '') {
helpers.logger.info(`Skipping invalid class name: ${name}`);
continue;
}
if (!(name in positionMap)) {
helpers.logger.info(`No position mapping for class "${name}", defaulting to 0`);
positionMap[name] = 0;
}
}
helpers.logger.info(`Loaded class position map: ${JSON.stringify(positionMap)}`);
return positionMap;
} catch (error) {
helpers.logger.error(`Error loading data.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
function generatePatternPositions(startMs: number, durationMs: number, className: string, fps: number): FunscriptAction[] {
const actions: FunscriptAction[] = [];
const frameDurationMs = 1000 / fps;
const totalFrames = Math.floor(durationMs / frameDurationMs);
const intervalMs = 100;
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
const progress = timeMs / durationMs;
let pos = 0;
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;
}
actions.push({ at: startMs + timeMs, pos });
}
return actions;
}
async function loadVideoMetadata(videoPath: string, helpers: Helpers) {
const { fps, frames: totalFrames } = await ffprobe(videoPath);
helpers.logger.info(`Video metadata: fps=${fps}, frames=${totalFrames}`);
return { fps, totalFrames };
}
async function processLabelFiles(labelDir: string, helpers: Helpers, data: DataYaml): Promise<Detection[]> {
const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt'));
const detections: Map<number, Detection[]> = new Map();
const names = data.names;
for (const file of labelFiles) {
const match = file.match(/(\d+)\.txt$/);
if (!match) {
helpers.logger.info(`Skipping invalid filename: ${file}`);
continue;
}
const frameIndex = parseInt(match[1], 10);
if (isNaN(frameIndex)) {
helpers.logger.info(`Skipping invalid frame index from filename: ${file}`);
continue;
}
const content = await readFile(join(labelDir, file), 'utf8');
const lines = content.trim().split('\n');
const frameDetections: Detection[] = [];
let maxConfidence = 0;
let selectedClassIndex = -1;
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const classIndex = parseInt(parts[0], 10);
const confidence = parseFloat(parts[5]);
if (isNaN(classIndex) || isNaN(confidence)) continue;
if (confidence >= 0.7 && confidence > maxConfidence) {
maxConfidence = confidence;
selectedClassIndex = classIndex;
}
}
if (maxConfidence > 0) {
const className = (data.names as Record<string, string>)[selectedClassIndex.toString()];
if (className) {
frameDetections.push({ startFrame: frameIndex, endFrame: frameIndex, className });
}
}
if (frameDetections.length > 0) {
detections.set(frameIndex, frameDetections);
}
}
// Merge overlapping detections into continuous segments
const detectionSegments: Detection[] = [];
let currentDetection: Detection | null = null;
for (const [frameIndex, frameDetections] of detections.entries()) {
for (const detection of frameDetections) {
if (!currentDetection || currentDetection.className !== detection.className) {
if (currentDetection) detectionSegments.push(currentDetection);
currentDetection = { ...detection, endFrame: frameIndex };
} else {
currentDetection.endFrame = frameIndex;
}
}
}
if (currentDetection) detectionSegments.push(currentDetection);
return detectionSegments;
}
function generateActions(totalDurationMs: number, fps: number, detectionSegments: Detection[], classPositionMap: ClassPositionMap): FunscriptAction[] {
const intervalMs = 100;
const actions: FunscriptAction[] = [];
// Generate static position actions
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
const frameIndex = Math.floor((timeMs / 1000) * fps);
let position = 0;
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;
}
}
}
actions.push({ at: timeMs, pos: position });
}
// Overlay pattern-based actions
for (const segment of detectionSegments) {
const className = segment.className;
if (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);
actions.push(...patternActions);
}
}
// Sort actions by time and remove duplicates
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;
}
}
return uniqueActions;
}
async function writeFunscript(outputPath: string, actions: FunscriptAction[], helpers: Helpers) {
const funscript: Funscript = { version: '1.0', actions };
await writeFile(outputPath, JSON.stringify(funscript, null, 2));
helpers.logger.info(`Funscript generated: ${outputPath} (${actions.length} actions)`);
}
const createFunscript: Task = async (payload: any, helpers: Helpers) => {
assertPayload(payload);
@ -373,7 +31,6 @@ const createFunscript: Task = async (payload: any, helpers: Helpers) => {
const vod = await prisma.vod.findFirstOrThrow({ where: { id: vodId } });
await preparePython(helpers)
if (vod.funscript) {
helpers.logger.info(`Doing nothing-- vod ${vodId} already has a funscript.`);
@ -394,11 +51,12 @@ const createFunscript: Task = async (payload: any, helpers: Helpers) => {
helpers.logger.info(`Creating funscript for vod ${vodId}...`);
const predictionOutput = await inference(helpers, videoFilePath);
const modelPath = join(env.VIBEUI_DIR, 'vibeui.onnx')
const predictionOutput = await vibeuiInference(modelPath, videoFilePath);
helpers.logger.info(`prediction output ${predictionOutput}`);
const funscriptFilePath = await buildFunscript(helpers, predictionOutput, videoFilePath)
const funscriptFilePath = await buildFunscript(predictionOutput, videoFilePath)
const s3Key = `funscripts/${vodId}.funscript`;

View File

@ -0,0 +1,452 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { existsSync, rmSync } from "node:fs";
import { join, basename, extname } from "node:path";
import { readFile, writeFile, readdir } from 'node:fs/promises';
import yaml from 'js-yaml';
import { getNanoSpawn } from "../utils/nanoSpawn";
import which from "which";
import * as ort from 'onnxruntime-node';
interface Payload {
vodId: string;
}
interface Detection {
startFrame: number;
endFrame: number;
className: string;
}
interface DataYaml {
path: string;
train: string;
val: string;
names: Record<string, string>;
}
interface FunscriptAction {
at: number;
pos: number;
}
interface Funscript {
version: string;
actions: FunscriptAction[];
}
interface ClassPositionMap {
[className: string]: number | 'pattern';
}
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");
}
async function loadDataYaml(yamlPath: string): Promise<DataYaml> {
const yamlContent = await readFile(yamlPath, 'utf8');
return yaml.load(yamlContent) as DataYaml;
}
// async function preparePython(helpers) {
// const spawn = await getNanoSpawn();
// const venvPath = join(env.VIBEUI_DIR, "venv");
// // Determine Python executable
// let pythonCmd;
// try {
// pythonCmd = which.sync("python3");
// } catch {
// helpers.logger.error("Python is not installed or not in PATH.");
// throw new Error("Python not found in PATH.");
// }
// // If venv doesn't exist, create it
// if (!existsSync(venvPath)) {
// helpers.logger.info("Python venv not found. Creating one...");
// try {
// await spawn(pythonCmd, ["-m", "venv", "venv"], {
// cwd: env.VIBEUI_DIR,
// });
// helpers.logger.info("Python venv successfully created.");
// } catch (err) {
// helpers.logger.error("Failed to create Python venv:", err);
// // Clean up partially created venv if needed
// try {
// if (existsSync(venvPath)) {
// rmSync(venvPath, { recursive: true, force: true });
// helpers.logger.warn("Removed broken venv directory.");
// }
// } catch (cleanupErr) {
// helpers.logger.error("Error while cleaning up broken venv:", cleanupErr);
// }
// throw new Error("Python venv creation failed. Check if python3 and python3-venv are installed.");
// }
// } else {
// helpers.logger.info("Using existing Python venv.");
// }
// }
async function ffprobe(videoPath: string): Promise<{ fps: number; frames: number }> {
const spawn = await getNanoSpawn()
const { stdout } = await spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-count_frames',
'-show_entries', 'stream=nb_read_frames,r_frame_rate',
'-of', 'default=nokey=1:noprint_wrappers=1',
videoPath,
])
const [frameRateStr, frameCountStr] = stdout.trim().split('\n')
const [num, denom] = frameRateStr.trim().split('/').map(Number)
const fps = num / denom
const frames = parseInt(frameCountStr.trim(), 10)
return { fps, frames }
}
export async function buildFunscript(
helpers: Helpers,
predictionOutput: string,
videoPath: string
): Promise<string> {
const labelDir = join(predictionOutput, 'labels');
const yamlPath = join(predictionOutput, 'data.yaml');
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);
helpers.logger.info('Starting Funscript generation');
try {
const data = await loadDataYaml(join(env.VIBEUI_DIR, 'data.yaml'))
const classPositionMap = await loadClassPositionMap(data, helpers);
const { fps, totalFrames } = await loadVideoMetadata(videoPath, helpers);
const detectionSegments = await processLabelFiles(labelDir, helpers, data);
const totalDurationMs = Math.floor((totalFrames / fps) * 1000);
const actions = generateActions(totalDurationMs, fps, detectionSegments, classPositionMap);
await writeFunscript(outputPath, actions, helpers);
return outputPath;
} catch (error) {
helpers.logger.error(`Error generating Funscript: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
export async function inference(helpers: Helpers, videoFilePath: string): Promise<string> {
const spawn = await getNanoSpawn()
const modelPath = join(env.VIBEUI_DIR, 'runs/detect/vibeui/weights/best.pt')
// Generate a unique name based on video name + UUID
const videoExt = extname(videoFilePath) // e.g. '.mp4'
const videoName = basename(videoFilePath, videoExt) // removes the extension
const uniqueName = `${videoName}-${nanoid()}`
const customProjectDir = 'runs' // or any custom folder
const outputPath = join(env.VIBEUI_DIR, customProjectDir, uniqueName)
await spawn('./venv/bin/yolo', [
'predict',
`model=${modelPath}`,
`source=${videoFilePath}`,
'save=False',
'save_txt=True',
'save_conf=True',
`project=${customProjectDir}`,
`name=${uniqueName}`,
], {
cwd: env.VIBEUI_DIR,
stdio: 'inherit',
})
return outputPath // contains labels/ folder and predictions
}
async function loadClassPositionMap(data: DataYaml, helpers: Helpers): Promise<ClassPositionMap> {
try {
if (
!data ||
typeof data !== 'object' ||
!('names' in data) ||
typeof data.names !== 'object' ||
data.names === null ||
Object.keys(data.names).length === 0
) {
throw new Error('Invalid data.yaml: "names" field is missing, not an object, or empty');
}
const positionMap: ClassPositionMap = {
ControlledByTipper: 50,
ControlledByTipperHigh: 80,
ControlledByTipperLow: 20,
ControlledByTipperMedium: 50,
ControlledByTipperUltrahigh: 95,
Ring1: 30,
Ring2: 40,
Ring3: 50,
Ring4: 60,
Earthquake: 'pattern',
Fireworks: 'pattern',
Pulse: 'pattern',
Wave: 'pattern',
Pause: 0,
RandomTime: 70,
HighLevel: 80,
LowLevel: 20,
MediumLevel: 50,
UltraHighLevel: 95
};
const names = Object.values(data.names);
for (const name of names) {
if (typeof name !== 'string' || name.trim() === '') {
helpers.logger.info(`Skipping invalid class name: ${name}`);
continue;
}
if (!(name in positionMap)) {
helpers.logger.info(`No position mapping for class "${name}", defaulting to 0`);
positionMap[name] = 0;
}
}
helpers.logger.info(`Loaded class position map: ${JSON.stringify(positionMap)}`);
return positionMap;
} catch (error) {
helpers.logger.error(`Error loading data.yaml: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
function generatePatternPositions(startMs: number, durationMs: number, className: string, fps: number): FunscriptAction[] {
const actions: FunscriptAction[] = [];
const frameDurationMs = 1000 / fps;
const totalFrames = Math.floor(durationMs / frameDurationMs);
const intervalMs = 100;
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
const progress = timeMs / durationMs;
let pos = 0;
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;
}
actions.push({ at: startMs + timeMs, pos });
}
return actions;
}
async function loadVideoMetadata(videoPath: string, helpers: Helpers) {
const { fps, frames: totalFrames } = await ffprobe(videoPath);
helpers.logger.info(`Video metadata: fps=${fps}, frames=${totalFrames}`);
return { fps, totalFrames };
}
async function processLabelFiles(labelDir: string, helpers: Helpers, data: DataYaml): Promise<Detection[]> {
const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt'));
const detections: Map<number, Detection[]> = new Map();
const names = data.names;
for (const file of labelFiles) {
const match = file.match(/(\d+)\.txt$/);
if (!match) {
helpers.logger.info(`Skipping invalid filename: ${file}`);
continue;
}
const frameIndex = parseInt(match[1], 10);
if (isNaN(frameIndex)) {
helpers.logger.info(`Skipping invalid frame index from filename: ${file}`);
continue;
}
const content = await readFile(join(labelDir, file), 'utf8');
const lines = content.trim().split('\n');
const frameDetections: Detection[] = [];
let maxConfidence = 0;
let selectedClassIndex = -1;
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const classIndex = parseInt(parts[0], 10);
const confidence = parseFloat(parts[5]);
if (isNaN(classIndex) || isNaN(confidence)) continue;
if (confidence >= 0.7 && confidence > maxConfidence) {
maxConfidence = confidence;
selectedClassIndex = classIndex;
}
}
if (maxConfidence > 0) {
const className = (data.names as Record<string, string>)[selectedClassIndex.toString()];
if (className) {
frameDetections.push({ startFrame: frameIndex, endFrame: frameIndex, className });
}
}
if (frameDetections.length > 0) {
detections.set(frameIndex, frameDetections);
}
}
// Merge overlapping detections into continuous segments
const detectionSegments: Detection[] = [];
let currentDetection: Detection | null = null;
for (const [frameIndex, frameDetections] of detections.entries()) {
for (const detection of frameDetections) {
if (!currentDetection || currentDetection.className !== detection.className) {
if (currentDetection) detectionSegments.push(currentDetection);
currentDetection = { ...detection, endFrame: frameIndex };
} else {
currentDetection.endFrame = frameIndex;
}
}
}
if (currentDetection) detectionSegments.push(currentDetection);
return detectionSegments;
}
function generateActions(totalDurationMs: number, fps: number, detectionSegments: Detection[], classPositionMap: ClassPositionMap): FunscriptAction[] {
const intervalMs = 100;
const actions: FunscriptAction[] = [];
// Generate static position actions
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
const frameIndex = Math.floor((timeMs / 1000) * fps);
let position = 0;
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;
}
}
}
actions.push({ at: timeMs, pos: position });
}
// Overlay pattern-based actions
for (const segment of detectionSegments) {
const className = segment.className;
if (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);
actions.push(...patternActions);
}
}
// Sort actions by time and remove duplicates
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;
}
}
return uniqueActions;
}
async function writeFunscript(outputPath: string, actions: FunscriptAction[], helpers: Helpers) {
const funscript: Funscript = { version: '1.0', actions };
await writeFile(outputPath, JSON.stringify(funscript, null, 2));
helpers.logger.info(`Funscript generated: ${outputPath} (${actions.length} actions)`);
}
const createFunscript: Task = async (payload: any, helpers: Helpers) => {
assertPayload(payload);
const { vodId } = payload;
const vod = await prisma.vod.findFirstOrThrow({ where: { id: vodId } });
await preparePython(helpers)
if (vod.funscript) {
helpers.logger.info(`Doing nothing-- vod ${vodId} already has a funscript.`);
return;
}
if (!vod.sourceVideo) {
const msg = `Cannot create funscript: Vod ${vodId} is missing a source video.`;
helpers.logger.warn(msg);
throw new Error(msg);
}
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo);
helpers.logger.info(`Downloaded video to ${videoFilePath}`);
helpers.logger.info(`Creating funscript for vod ${vodId}...`);
const predictionOutput = await inference(helpers, videoFilePath);
helpers.logger.info(`prediction output ${predictionOutput}`);
const funscriptFilePath = await buildFunscript(helpers, predictionOutput, videoFilePath)
const s3Key = `funscripts/${vodId}.funscript`;
const s3Url = await uploadFile(s3Client, env.S3_BUCKET, s3Key, funscriptFilePath, "application/json");
helpers.logger.info(`Uploaded funscript to S3: ${s3Url}`);
await prisma.vod.update({
where: { id: vodId },
data: { funscript: s3Key }
});
helpers.logger.info(`Funscript saved to database for vod ${vodId}`);
};
export default createFunscript;

View File

@ -126,8 +126,6 @@ export async function packageHls(
args.push('--segment_duration=2'); // matches Twitchs chunk size
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
await spawn('packager', args, {
stdout: 'inherit',
stderr: 'inherit',

View File

@ -1,5 +1,4 @@
import type { Task, Helpers } from "graphile-worker";
import spawn from 'nano-spawn';
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
@ -7,6 +6,7 @@ import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
const prisma = new PrismaClient().$extends(withAccelerate());
@ -30,7 +30,7 @@ async function hash(helpers: Helpers, inputFilePath: string) {
throw new Error("inputFilePath is missing");
}
const spawn = await getNanoSpawn();
const result = await spawn('ipfs', [
'add',
'--cid-version=1',

View File

@ -1,5 +1,4 @@
import type { Task, Helpers } from "graphile-worker";
import spawn from 'nano-spawn';
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
@ -7,6 +6,7 @@ import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
const prisma = new PrismaClient().$extends(withAccelerate());
@ -25,6 +25,7 @@ async function createThumbnail(helpers: Helpers, inputFilePath: string) {
const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '-thumb.png';
const spawn = await getNanoSpawn();
const result = await spawn('vcsi', [
inputFilePath,
'--metadata-position', 'hidden',

View File

@ -6,11 +6,11 @@ import path from "node:path";
import { join } from "node:path";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import spawn from "nano-spawn";
import { nanoid } from "nanoid";
import { uploadFile } from "../utils/s3";
import { S3Client } from "@aws-sdk/client-s3";
import { VodSegment } from "../types";
import { getNanoSpawn } from "../utils/nanoSpawn";
const prisma = new PrismaClient();
const client = new S3Client({
@ -92,6 +92,7 @@ async function concatenateSegments(
helpers.logger.info(`Concatenating ${concatSpec.files.length} segments`);
try {
const spawn = await getNanoSpawn();
const proc = await spawn("ffmpeg", [
"-f", "concat",
"-safe", "0",

View File

@ -0,0 +1,25 @@
path: /home/cj/Documents/futureporn-monorepo/apps/vibeui/data
train: train/images
val: val/images
names:
0: ControlledByTipper
1: ControlledByTipperHigh
2: ControlledByTipperLow
3: ControlledByTipperMedium
4: ControlledByTipperUltrahigh
5: Earthquake
6: Fireworks
7: HighLevel
8: LowLevel
9: MediumLevel
10: Pause
11: Pulse
12: RandomTime
13: RespondingTo
14: Ring1
15: Ring2
16: Ring3
17: Ring4
18: UltraHighLevel
19: Wave

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Some files were not shown because too many files have changed in this diff Show More