add buttplug vjs plugin
Some checks failed
ci / build (push) Failing after 1s
ci / Tests & Checks (push) Failing after 1s

This commit is contained in:
CJ_Clippy 2025-07-13 01:04:45 -08:00
parent 6d77138ebd
commit c386e48dcf
30 changed files with 989 additions and 96 deletions

0
services/our/Dockerfile Normal file
View File

View File

@ -0,0 +1,48 @@
services:
# caddy:
# image: caddy:alpine
# ports:
# - "8081:80"
# volumes:
# - ./public:/srv
# - ./Caddyfile:/etc/caddy/Caddyfile
postgres:
container_name: our-postgres
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
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
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:
image:
volumes:
pgdata:

View File

@ -14,8 +14,10 @@ const preset: GraphileConfig.Preset = {
crontabFile: "crontab",
concurrentJobs: 1,
fileExtensions: [".cjs"],
taskDirectory: path.join(__dirname, 'dist', 'tasks')
taskDirectory: path.join(__dirname, 'dist', 'tasks'),
// to log debug messages, set GRAPHILE_LOGGER_DEBUG=1 in env @see https://worker.graphile.org/docs/library/logger
},
};

View File

@ -54,6 +54,7 @@
"graphile-worker": "^0.16.6",
"handlebars": "4.7.8",
"jdenticon": "^3.3.0",
"js-yaml": "^4.1.0",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",

View File

@ -16,7 +16,7 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dev:worker": "tsx watch ./src/worker.ts",
"dev:worker": "GRAPHILE_LOGGER_DEBUG=1 tsx watch ./src/worker.ts",
"dev:build": "chokidar 'src/**/*.{js,ts}' -c tsup --clean",
"build": "tsup --clean",
"lint": "eslint .",
@ -92,6 +92,7 @@
"graphile-worker": "^0.16.6",
"handlebars": "4.7.8",
"jdenticon": "^3.3.0",
"js-yaml": "^4.1.0",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",

View File

@ -143,6 +143,9 @@ importers:
jdenticon:
specifier: ^3.3.0
version: 3.3.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0
keyv:
specifier: ^4.5.4
version: 4.5.4

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Vod" ADD COLUMN "funscript" TEXT;

View File

@ -80,6 +80,7 @@ model Vod {
status VodStatus @default(pending)
sha256sum String?
cidv1 String?
funscript String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -1,15 +1,33 @@
import { randomInt } from 'crypto';
import { PrismaClient } from '../generated/prisma';
import { withAccelerate } from '@prisma/extension-accelerate';
import { VodSegment } from '../src/types';
const prisma = new PrismaClient().$extends(withAccelerate());
const statuses = ['pending', 'approved', 'rejected'] as const;
const vodSegments: VodSegment[] = [
{ key: 'usc/yoosu613liof5i64dt6dv4os.mp4', name: 'yoosu613liof5i64dt6dv4os.mp4' },
{ key: 'usc/ytsbbb1qkl77c8lnjxv60mib.mp4', name: 'ytsbbb1qkl77c8lnjxv60mib.mp4' },
{ key: 'usc/zscvyiikaqg5j9zg2ib2537l.mp4', name: 'zscvyiikaqg5j9zg2ib2537l.mp4' },
{ key: 'usc/krxi88rpk2znkm6b4jajb05l.mp4', name: 'krxi88rpk2znkm6b4jajb05l.mp4' },
{ key: 'usc/h5c2l1o47u2jaqv4vgze26le.mp4', name: 'h5c2l1o47u2jaqv4vgze26le.mp4' },
{ key: 'usc/aj5sktmd75b1jskx5rkmgxkd.mp4', name: 'aj5sktmd75b1jskx5rkmgxkd.mp4' },
];
function getRandomStatus() {
return statuses[Math.floor(Math.random() * statuses.length)];
}
function getRandomSegments(vodSegments: VodSegment[]): VodSegment[] {
const count = Math.floor(Math.random() * 3) + 1; // 1 to 3
const shuffled = [...vodSegments].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
async function main() {
console.log('🌱 Seeding database...');
@ -63,12 +81,12 @@ async function main() {
prisma.vod.create({
data: {
uploader: { connect: { id: user.id } },
segmentKeys: [`vod${i + 1}_part1.mp4`, `vod${i + 1}_part2.mp4`],
segmentKeys: getRandomSegments(vodSegments),
streamDate: new Date(),
notes: `Seeded VOD ${i + 1}`,
notes: `This is a vod I recorded using future.porn DVR 3000. This is my ${i + 1} vod I recorded and I believe it is archival quality.`,
status: getRandomStatus(),
hlsPlaylist: `https://cdn.example.com/hls/vod${i + 1}/index.m3u8`,
thumbnail: `https://placehold.co/320x180?text=VOD${i + 1}`,
hlsPlaylist: '',
thumbnail: '',
vtubers: {
connect: [{ id: vtubers[i % vtubers.length].id }],
},

View File

@ -28,6 +28,8 @@ import fastifyFlash from '@fastify/flash'
import { isModerator, hasRole } from './utils/privs'
import { signUrl } from './utils/cdn'
import { extractBasePath } from './utils/filesystem'
import { truncate } from './utils/formatters.ts'
import { icons } from './utils/icons.ts'
export function buildApp() {
const app = Fastify()
@ -80,6 +82,7 @@ export function buildApp() {
* @see https://github.com/video-dev/hls.js/issues/2152
*/
Handlebars.registerHelper('signedHlsUrl', function (s3Key) {
if (!s3Key) throw new Error(`signedHlsUrl called with falsy s3Key=${s3Key}`);
const pathAllowed = extractBasePath(s3Key)
const url = signUrl(`${env.CDN_ORIGIN}/${s3Key}`, {
securityKey: env.CDN_TOKEN_SECRET,
@ -94,11 +97,20 @@ export function buildApp() {
return basename(url)
})
Handlebars.registerHelper('trunc', function (str, length = 6) {
if (str && str.length > length) {
return new Handlebars.SafeString(str.substring(0, length)) + '…';
} else {
return str;
return truncate(str, length)
});
Handlebars.registerHelper('icon', function (name: string, size = 20) {
const svg = icons[name];
if (!svg) {
return new Handlebars.SafeString(`<!-- icon "${name}" not found -->`);
}
// Inject width/height if not already set
const sizedSvg = svg
.replace(/<svg([^>]*)>/, `<svg$1 width="${size}" height="${size}">`);
return new Handlebars.SafeString(sizedSvg);
});
const __dirname = import.meta.dirname;

View File

@ -0,0 +1,5 @@
<svg
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 15.575q-.2 0-.375-.062T11.3 15.3l-3.6-3.6q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L11 12.15V5q0-.425.288-.712T12 4t.713.288T13 5v7.15l1.875-1.875q.3-.3.713-.288t.712.313q.275.3.288.7t-.288.7l-3.6 3.6q-.15.15-.325.213t-.375.062M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20z" />
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path fill="currentColor" fill-rule="evenodd" d="M318.846 64H64v234.668h48.624l58.043-174.129l64 192zM64 341.334V448h169.512l-62.845-188.537l-27.291 81.871zM235.821 448H448V341.334h-79.376L320 195.463zM448 298.668V64H321.154l78.222 234.668z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24"
height="24" viewBox="0 0 2048 2048">
<path fill="currentColor"
d="M1930 630q0 22-2 43t-8 43l123 51l-49 118l-124-51q-46 74-120 120l51 125l-118 49l-52-124q-21 5-42 7t-43 3q-22 0-43-2t-43-8l-23 56l-111-67l16-39q-74-46-120-120l-125 51l-49-118l124-51q-5-21-7-42t-3-44q0-22 2-43t8-42l-124-52l49-118l125 52q23-37 53-67t67-54l-51-124l118-49l51 123q21-5 42-7t44-3q22 0 43 2t42 8l52-123l118 49l-51 124q74 46 120 120l124-51l49 118l-123 52q5 21 7 42t3 43m-384 256q53 0 99-20t82-55t55-81t20-100q0-53-20-99t-55-82t-81-55t-100-20q-53 0-99 20t-82 55t-55 81t-20 100q0 53 20 99t55 82t81 55t100 20m-577 220l139-58l44 106v15l-133 55q7 27 11 54t4 56q0 28-4 55t-11 55l133 55v15l-44 106l-139-58q-29 48-68 87t-87 69l58 139l-119 49l-57-139q-27 7-54 11t-56 4q-28 0-55-4t-55-11l-58 139l-118-49l58-140q-97-58-155-155l-140 58l-48-118l138-58q-7-27-11-54t-4-56q0-28 4-55t11-55l-138-57l48-119l140 58q58-97 155-155l-58-139l118-49l58 138q27-7 54-11t56-4q28 0 55 4t55 11l57-138l119 49l-58 139q97 58 155 155m-383 548q66 0 124-25t101-68t69-102t26-125t-25-124t-69-101t-102-69t-124-26t-124 25t-102 69t-69 102t-25 124t25 124t68 102t102 69t125 25m694 394v-896l747 448zm128-670v444l370-222z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -22,6 +22,7 @@ const EnvSchema = z.object({
CDN_ORIGIN: z.string(),
CDN_TOKEN_SECRET: z.string(),
CACHE_ROOT: z.string().default('/tmp/our'),
VIBEUI_DIR: z.string().default('/opt/futureporn/apps/vibeui'),
});
const parsed = EnvSchema.safeParse(process.env);

View File

@ -3,4 +3,5 @@ import { buildApp } from "./app.js";
const app = buildApp()
console.log(`app listening on port ${env.PORT}`)
app.listen({ port: env.PORT, host: '0.0.0.0' })

View File

@ -60,6 +60,9 @@ function rewriteMp4ReferencesWithSignedUrls(
cdnBasePath: string,
signFn: (path: string) => string
): string {
console.log(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`)
const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash
return playlistContent
@ -116,7 +119,7 @@ export default async function registerHlsRoute(app: FastifyInstance) {
// Otherwise, rewrite .mp4 references with signed URLs
const tokenPath = `/${dirname(vod.hlsPlaylist)}/`
console.log(`FUCKINGFUCKINGFUCKINGFUCKINGFUCKINGFUCKING tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`)
console.log(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`)
if (!tokenPath.startsWith('/')) {
throw new Error('tokenPath did not start with a forward slash');

View File

@ -26,7 +26,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
const cdnOrigin = env.CDN_ORIGIN;
const NODE_ENV = env.NODE_ENV;
const userId = request.session.get('userId');
console.log(`we are at the GET root (/) route, with userId=${userId}`);
// console.log(`we are at the GET root (/) route, with userId=${userId}`);
const vods = await prisma.vod.findMany({
@ -35,8 +35,8 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
createdAt: 'desc'
}
})
console.log('vods as follows')
console.log(vods)
// console.log('vods as follows')
// console.log(vods)
const vtubers = await prisma.vtuber.findMany({
take: 3,
@ -48,7 +48,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
// Guard: no user in session
if (!userId) {
const authPath = env.PATREON_AUTHORIZE_PATH
console.log(`either patreon_user or patreon_user.id was falsy. userId=${userId}`)
// console.log(`either patreon_user or patreon_user.id was falsy. userId=${userId}`)
return reply.viewAsync("index.hbs", {
user: { roles: [{ name: 'anon' }] },
cdnOrigin,

View File

@ -77,6 +77,7 @@ export default async function streamsRoutes(
}
function getS3Key(waifuName: string, filename: string, isWebp: boolean) {
console.log(`getS3Key called with ${waifuName} ${filename} ${isWebp}`)
const ext = (isWebp) ? 'webp' : filename.split('.').pop()?.toLowerCase();
return `img/${nanoid()}/${slug(waifuName).substring(0, 24)}.${ext}`
}

View File

@ -7,5 +7,4 @@ const cleanup: Task = async (_payload, helpers: Helpers) => {
if (count > 0) helpers.logger.info(`Deleted ${count} old files.`);
};
export default cleanup;

View File

@ -0,0 +1,417 @@
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());
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';
}
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);
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

@ -83,8 +83,10 @@ export async function createVariants(helpers: Helpers, inputFilePath: string): P
videoPath: outputPath,
bandwidth: bitrate,
resolution: `${width}x${height}`,
mimetype: 'video/mp4'
mimetype: 'video/mp4',
});
}
return outputPaths;
@ -101,26 +103,46 @@ export async function packageHls(
variants.sort((a, b) => b.bandwidth - a.bandwidth);
for (const variant of variants) {
const name = basename(variant.videoPath, '.mp4');
const videoOut = join(outputDir, `${name}_video.mp4`);
const audioOut = join(outputDir, `${name}_audio.mp4`);
const baseName = basename(variant.videoPath, '.mp4');
const name = variant.resolution; // "1920x1080", etc.
args.push(
`input=${variant.videoPath},stream=video,output=${videoOut}`,
`input=${variant.videoPath},stream=audio,output=${audioOut}`
);
const videoOut = join(outputDir, `${baseName}_video.mp4`);
const audioOut = join(outputDir, `${baseName}_audio.mp4`);
// Add video stream
args.push(`input=${variant.videoPath},stream=video,output=${videoOut},hls_name=${name},hls_group_id=video`);
// Add audio stream (with consistent group-id and friendly name)
args.push(`input=${variant.videoPath},stream=audio,output=${audioOut},hls_name=Audio ${name},hls_group_id=audio`);
}
const masterPlaylist = join(outputDir, 'master.m3u8');
args.push(`--hls_master_playlist_output=${masterPlaylist}`);
args.push('--generate_static_live_mpd'); // helps keep segments stable
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`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
helpers.logger.info(`PILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE\nPILLS HERE`)
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',
});
return masterPlaylist;
}
@ -152,7 +174,6 @@ export default async function createHlsPlaylist(payload: any, helpers: Helpers)
throw new Error(`Failed to create hlsPlaylist-- vod ${vodId} is missing a sourceVideo.`);
}
helpers.logger.info(`Creating HLS Playlist.`)
const s3Client = getS3Client()
const taskId = nanoid()

View File

@ -15,15 +15,15 @@ interface Payload {
vodId: string;
}
function getCid(output: string) {
const match = output.match(/bafy[a-z0-9]{50,}/);
function getCidFromStdout(output: string) {
// https://stackoverflow.com/questions/67176725/a-regex-json-schema-pattern-for-an-ipfs-cid
const match = output.match(/Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}/);
const cid = match ? match[0] : null;
return cid
}
async function hash(helpers: Helpers, inputFilePath: string) {
helpers.logger.debug(`createIpfsCid with inputFilePath=${inputFilePath}`)
helpers.logger.info(`createIpfsCid with inputFilePath=${inputFilePath}`)
if (!inputFilePath) {
@ -43,8 +43,8 @@ async function hash(helpers: Helpers, inputFilePath: string) {
// console.error(`vcsi failed with exit code ${exitCode}`);
// process.exit(exitCode);
// }
helpers.logger.debug(JSON.stringify(result))
return getCid(result.stdout)
helpers.logger.info(JSON.stringify(result))
return getCidFromStdout(result.stdout)
}
@ -81,14 +81,14 @@ export default async function createIpfsCid(payload: any, helpers: Helpers) {
// * [x] download video segments from pull-thru cache
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
console.log(`videoFilePath=${videoFilePath}`)
helpers.logger.info(`videoFilePath=${videoFilePath}`)
// * [x] run ipfs to get a CID
const cidv1 = await hash(helpers, videoFilePath)
if (!cidv1) throw new Error(`cidv1 ${cidv1} was falsy`);
console.log(`cidv1=${cidv1}`)
helpers.logger.info(`cidv1=${cidv1}`)
// * [x] update vod record
await prisma.vod.update({

View File

@ -139,10 +139,20 @@ export async function getFileSize(filePath: string): Promise<number> {
return stats.size;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string');
function isVodSegmentArray(value: unknown): value is VodSegment[] {
return Array.isArray(value) && value.every(
item => item && typeof item === 'object' && typeof item.key === 'string' && typeof item.name === 'string'
);
}
/**
getSourceVideo
Download its segments from S3.
(Optional) concatenate them using ffmpeg.
Upload the resulting video back to S3.
Update the VOD record in the database.
*/
const getSourceVideo: Task = async (payload: unknown, helpers) => {
assertPayload(payload);
const { vodId } = payload;
@ -160,23 +170,25 @@ const getSourceVideo: Task = async (payload: unknown, helpers) => {
},
});
// Skip if already processed
if (vod.sourceVideo) {
helpers.logger.debug(`VOD ${vodId} already has source video`);
helpers.logger.debug(`VOD ${vodId} already has a source video`);
return;
}
if (!isVodSegmentArray(vod.segmentKeys) || vod.segmentKeys.length === 0) {
throw new Error(`Invalid or missing segmentKeys array for VOD ${vodId}: ${JSON.stringify(vod.segmentKeys)}`);
}
// if (!isStringArray(vod.segmentKeys)) {
// throw new Error(`Invalid segmentKeys for VOD ${vodId}. segmentKeys=${JSON.stringify(vod.segmentKeys)}`);
// }
const segments: VodSegment[] = vod.segmentKeys;
// Validate segments
// await validateSegments(vod.segmentKeys, helpers);
// Log and validate the segments
await validateSegments(segments, helpers);
// Download all segments
const downloadedPaths = await downloadSegments(vod.segmentKeys, helpers);
const downloadedPaths = await downloadSegments(segments, helpers);
// Process segments
let sourceVideoPath: string;

View File

@ -45,6 +45,7 @@ const scheduleVodProcessing: Task = async (payload: unknown, helpers) => {
if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
if (!vod.asrVtt) jobs.push(helpers.addJob("createAsrVtt", { vodId }));
if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
if (!vod.funscript) jobs.push(helpers.addJob("createFunscript", { vodId }));
const changes = jobs.length;
if (changes > 0) {

View File

@ -19,6 +19,7 @@ export async function listFilesRecursive(dir: string): Promise<string[]> {
}
export function extractBasePath(filePath: string): string {
if (!filePath) throw new Error(`extractBasePath called with falsy filePath`);
const parts = filePath.split("/");
// Get first three parts (e.g. "package" and "00FtzMrsIJk9tyHoQE8Kw" and "hls")
const baseParts = parts.slice(0, 3);

View File

@ -15,3 +15,9 @@ export function slug(s: string) {
trim: true // trim leading and trailing replacement chars, defaults to `true`
})
}
export function truncate(text: string, n: number = 6) {
if (typeof text !== 'string') return '';
return text.length > n ? text.slice(0, n) + '…' : text;
}

View File

@ -0,0 +1,16 @@
// icons.ts
import fs from 'fs';
import path from 'path';
const __dirname = import.meta.dirname;
const iconsDir = path.resolve(__dirname, '../assets/svg');
export const icons: Record<string, string> = {};
for (const file of fs.readdirSync(iconsDir)) {
if (file.endsWith('.svg')) {
const name = path.basename(file, '.svg');
const content = fs.readFileSync(path.join(iconsDir, file), 'utf-8');
icons[name] = content;
}
}

View File

@ -1,9 +1,40 @@
{{#> main}}
<!-- Header -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.22.0/dist/video-js.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.23.3/dist/video-js.min.css">
<style>
.vjs-funscript-indicator {
color: #0f0;
font-weight: bold;
padding: 0 1em;
display: flex;
align-items: center;
}
.vjs-buttplug-indicator {
color: #0f0;
font-weight: bold;
padding: 0 1em;
display: flex;
align-items: center;
}
.vjs-funscript-indicator,
.vjs-buttplug-indicator {
color: white;
padding: 0 1em;
font-size: 0.9em;
}
.vjs-buttplug-indicator.disconnected,
.vjs-funscript-indicator.not-loaded {
color: red;
}
</style>
<header class="container">
{{> navbar}}
</header>
<!-- ./ Header -->
@ -26,9 +57,13 @@
</div>
{{else}}
<video id="player" class="hidden"></video>
<div class="pico">
<article>
<abbr title="HTTP Live Streaming" data-tooltip="HTTP Live Streaming">HLS</abbr> playback is still processing.
{{icon "processing" 24}} HTTP Live Streaming is processing.
</article>
</div>
{{/if}}
</section>
<section id="tables" class="pico">
@ -77,54 +112,17 @@
</article>
{{/if}}
{{!-- <h2>Thumbnail Image</h2>
{{#if vod.thumbnail}}
<img src="{{getCdnUrl vod.thumbnail}}" alt="{{this.vtuber.displayName}} thumbnail">
<div class="mx-5"></div>
{{else}}
<article>
Thumbnail is still processing.
</article>
{{/if}} --}}
<h2>Downloads</h2>
<h3>Video Source</h3>
{{#if vod.sourceVideo}}
<p><a data-source-video="{{getCdnUrl vod.sourceVideo}}" data-file-name="{{basename vod.sourceVideo}}"
x-on:click.prevent="download($el.dataset.sourceVideo, $el.dataset.fileName)"
href="{{getCdnUrl vod.sourceVideo}}" download="{{basename vod.sourceVideo}}" target="_blank"><svg
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 15.575q-.2 0-.375-.062T11.3 15.3l-3.6-3.6q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L11 12.15V5q0-.425.288-.712T12 4t.713.288T13 5v7.15l1.875-1.875q.3-.3.713-.288t.712.313q.275.3.288.7t-.288.7l-3.6 3.6q-.15.15-.325.213t-.375.062M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20z" />
</svg>Download</a>
</p>
<p>{{#if vod.sha256sum}}<span><b>sha256sum</b> {{vod.sha256sum}}</span>{{/if}}</p>
{{#if vod.cidv1}}
<p><b>IPFS cidv1</b> {{vod.cidv1}}</p>
{{else}}
<article>
IPFS CID is still processing.
</article>
{{/if}}
{{else}}
<article>
Video Source is still processing.
</article>
{{/if}}
<h3>Raw Recorded File Segments</h3>
{{#if vod.segmentKeys}}
<ul>
{{#each vod.segmentKeys}}
<li><a data-source-video="{{getCdnUrl this.key}}" data-file-name="{{this.name}}" target="_blank"
download="{{this.name}}" x-on:click.prevent="download($el.dataset.sourceVideo, $el.dataset.fileName)"
href="{{getCdnUrl this.key}}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 15.575q-.2 0-.375-.062T11.3 15.3l-3.6-3.6q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L11 12.15V5q0-.425.288-.712T12 4t.713.288T13 5v7.15l1.875-1.875q.3-.3.713-.288t.712.313q.275.3.288.7t-.288.7l-3.6 3.6q-.15.15-.325.213t-.375.062M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20z" />
</svg>{{this.name}}</a></li>
href="{{getCdnUrl this.key}}">{{icon "download" 24}} {{this.name}}</a>
</li>
{{/each}}
</ul>
{{else}}
@ -133,14 +131,71 @@
</article>
{{/if}}
<h3>Concatenated Video</h3>
{{#if vod.sourceVideo}}
<p><a data-source-video="{{getCdnUrl vod.sourceVideo}}" data-file-name="{{basename vod.sourceVideo}}"
x-on:click.prevent="download($el.dataset.sourceVideo, $el.dataset.fileName)"
href="{{getCdnUrl vod.sourceVideo}}" download="{{basename vod.sourceVideo}}"
target="_blank">{{icon "download" 24}} Download</a>
</p>
<p>{{#if vod.sha256sum}}<span><b>sha256sum</b> {{vod.sha256sum}}</span>{{/if}}</p>
{{#if vod.cidv1}}
<p><b>IPFS cidv1</b> {{vod.cidv1}}</p>
{{else}}
<article>
IPFS CID is processing.
</article>
{{/if}}
{{else}}
<article>
Video Source is processing.
</article>
{{/if}}
<h3>HLS Playlist</h3>
{{#if vod.hlsPlaylist}}
<a href="{{signedHlsUrl vod.hlsPlaylist}}">{{signedHlsUrl vod.hlsPlaylist}}</a>
{{else}}
<article>
HLS Playlist is processing.
</article>
{{/if}}
<h3>Thumbnail Image</h3>
{{#if vod.thumbnail}}
<img src="{{getCdnUrl vod.thumbnail}}" alt="{{this.vtuber.displayName}} thumbnail">
<div class="mx-5"></div>
{{else}}
<article>
Thumbnail is processing.
</article>
{{/if}}
<h3>Funscript (sex toy sync)</h3>
{{#if vod.funscript}}
<a id="funscript" data-url="{{getCdnUrl vod.funscript}}" data-file-name="{{basename vod.funscript}}"
x-on:click.prevent="download($el.dataset.url, $el.dataset.fileName)" href="{{getCdnUrl vod.funscript}}"
alt="{{this.vtuber.displayName}} funscript file">{{icon "download" 24}}
{{this.vtuber.displayName}}
Funscript</a>
<div class="mx-5"></div>
{{else}}
<article>
Funscript file is processing.
</article>
{{/if}}
{{#if (isModerator user)}}
<h2>Moderator Controls</h2>
<button hx-post="/vods/{{vod.id}}/process" hx-target="body"><svg xmlns="http://www.w3.org/2000/svg" width="24"
height="24" viewBox="0 0 2048 2048">
<path fill="currentColor"
d="M1930 630q0 22-2 43t-8 43l123 51l-49 118l-124-51q-46 74-120 120l51 125l-118 49l-52-124q-21 5-42 7t-43 3q-22 0-43-2t-43-8l-23 56l-111-67l16-39q-74-46-120-120l-125 51l-49-118l124-51q-5-21-7-42t-3-44q0-22 2-43t8-42l-124-52l49-118l125 52q23-37 53-67t67-54l-51-124l118-49l51 123q21-5 42-7t44-3q22 0 43 2t42 8l52-123l118 49l-51 124q74 46 120 120l124-51l49 118l-123 52q5 21 7 42t3 43m-384 256q53 0 99-20t82-55t55-81t20-100q0-53-20-99t-55-82t-81-55t-100-20q-53 0-99 20t-82 55t-55 81t-20 100q0 53 20 99t55 82t81 55t100 20m-577 220l139-58l44 106v15l-133 55q7 27 11 54t4 56q0 28-4 55t-11 55l133 55v15l-44 106l-139-58q-29 48-68 87t-87 69l58 139l-119 49l-57-139q-27 7-54 11t-56 4q-28 0-55-4t-55-11l-58 139l-118-49l58-140q-97-58-155-155l-140 58l-48-118l138-58q-7-27-11-54t-4-56q0-28 4-55t11-55l-138-57l48-119l140 58q58-97 155-155l-58-139l118-49l58 138q27-7 54-11t56-4q28 0 55 4t55 11l57-138l119 49l-58 139q97 58 155 155m-383 548q66 0 124-25t101-68t69-102t26-125t-25-124t-69-101t-102-69t-124-26t-124 25t-102 69t-69 102t-25 124t25 124t68 102t102 69t125 25m694 394v-896l747 448zm128-670v444l370-222z" />
</svg> Re-Schedule Vod Processing</button>
<button hx-post="/vods/{{vod.id}}/process" hx-target="body">{{icon "processing" 24}} Re-Schedule Vod
Processing</button>
{{/if}}
@ -155,7 +210,7 @@
{{>footer}}
</main>
<script src="https://cdn.jsdelivr.net/npm/video.js@8.22.0/dist/video.min.js"></script>
{{!-- <script src=" https://cdn.jsdelivr.net/npm/video.js@8.23.3/dist/video.min.js "></script> --}}
<script>
@ -184,6 +239,264 @@
}
</script>
<script src="https://cdn.jsdelivr.net/npm/video.js@8.23.3/dist/video.min.js"></script>
{{!--
Script 1: Load Buttplug.js from Skypack CDN and expose it to window.buttplug
--}}
<script type="module">
import {
ButtplugClient,
ButtplugBrowserWebsocketClientConnector
} from 'https://cdn.skypack.dev/buttplug';
window.buttplug = { ButtplugClient, ButtplugBrowserWebsocketClientConnector };
</script>
{{!--
Script 2: Define reusable utility components for funscript and buttplug indicators
--}}
<script type="module">
const Plugin = videojs.getPlugin('plugin');
const createIndicator = (Component, className, defaultText) => {
return class extends Component {
constructor(player, options) {
super(player, options);
this.addClass(className);
this.el().innerHTML = `<span>${defaultText}</span>`;
}
};
};
videojs.registerComponent(
'FunscriptIndicator',
createIndicator(videojs.getComponent('Component'), 'vjs-funscript-indicator', 'Funscript ...')
);
videojs.registerComponent(
'ButtplugIndicator',
createIndicator(videojs.getComponent('Component'), 'vjs-buttplug-indicator', 'Buttplug.js not connected')
);
</script>
{{!--
Script 3: Main ButtplugPlugin class — handles connection, syncing, and device control
--}}
<script type="module">
class ButtplugPlugin extends videojs.getPlugin('plugin') {
constructor(player, options) {
super(player, options);
this.funscript = null;
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.devicePaused = false;
this.client = new window.buttplug.ButtplugClient("future.porn");
this.funscriptIndicator = null;
this.buttplugIndicator = null;
if (options?.funscriptUrl) this.loadFunscript(options.funscriptUrl);
this.connectToIntiface();
this.showActiveIndicator();
this.on(player, 'timeupdate', this.handleTimeUpdate);
this.on(player, 'pause', this.handlePause);
this.on(player, 'playing', this.handlePlay);
this.on(player, 'seeking', this.handleSeek); // <-- new
this.on(player, 'ended', this.handleEnded); // <-- new
}
updateButtplugIndicator(connected = false) {
if (!this.buttplugIndicator) return;
this.buttplugIndicator.el().innerHTML = connected
? `<span>Buttplug.js Connected</span>`
: `<span>Buttplug.js Disconnected</span>`;
this.buttplugIndicator[connected ? 'removeClass' : 'addClass']('disconnected');
}
updateFunscriptIndicator(loaded = false) {
if (!this.funscriptIndicator) return;
this.funscriptIndicator.el().innerHTML = loaded
? `<span>Funscript Loaded</span>`
: `<span>Funscript Not Loaded</span>`;
this.funscriptIndicator[loaded ? 'removeClass' : 'addClass']('not-loaded');
}
async loadFunscript(url) {
try {
const res = await fetch(url);
const json = await res.json();
this.funscript = json.actions || [];
console.log('[buttplug] Loaded funscript with', this.funscript.length, 'actions');
this.updateFunscriptIndicator(true);
} catch (e) {
console.error('[buttplug] Failed to load funscript:', e);
}
}
async connectToIntiface() {
console.log(`[buttplug] connecting to intiface`)
try {
if (this.client.connected) {
console.log("[buttplug] Already connected");
return;
}
const connector = new window.buttplug.ButtplugBrowserWebsocketClientConnector("ws://localhost:12345");
await this.client.connect(connector);
this.updateButtplugIndicator(true);
console.log("[buttplug] Connected to Intiface");
this.client.devices.forEach((d) => {
console.log(`[buttplug] Device already connected: ${d.name}`);
});
this.client.addListener('disconnect', this.handleDisconnect);
this.client.addListener("deviceadded", (device) => {
console.log(`[buttplug] Device connected: ${device.name}`);
});
this.client.addListener("deviceremoved", (device) => {
const stillConnected = this.client.devices.size > 0;
this.updateButtplugIndicator(stillConnected);
console.log(`[buttplug] Device removed: ${device.name}`);
});
await this.client.startScanning();
console.log("[buttplug] Scanning for devices...");
} catch (e) {
console.error("[buttplug] Failed to connect to Intiface:", e);
}
}
handleDisconnect = () => {
console.log('[Buttplug] Disconnected from Intiface');
this.connected = false;
// Update your UI here
this.updateButtplugIndicator()
};
showActiveIndicator() {
const controlBar = this.player.getChild('controlBar');
const insertBeforeIndex = controlBar.children().length - 3;
this.funscriptIndicator = controlBar.addChild('FunscriptIndicator', {}, insertBeforeIndex);
this.buttplugIndicator = controlBar.addChild('ButtplugIndicator', {}, insertBeforeIndex);
}
handleTimeUpdate = () => {
if (!this.funscript?.length || this.devicePaused) return;
const time = this.player.currentTime() * 1000;
while (
this.currentActionIndex < this.funscript.length - 1 &&
this.funscript[this.currentActionIndex + 1].at <= time
) {
this.currentActionIndex++;
const skipped = this.funscript[this.currentActionIndex];
//console.log(`[buttplug] skipping to action: at=${skipped.at}, pos=${skipped.pos}`);
}
const action = this.funscript[this.currentActionIndex];
if (!action || action.pos === this.lastPositionSent) return;
this.lastPositionSent = action.pos;
//console.log(`[buttplug] sending action: at=${action.at}, pos=${action.pos}`);
this.sendToDevice(action.pos);
};
async sendToDevice(position) {
if (this.client.devices.size === 0) {
console.warn('No devices')
return;
}
if (!this.client) {
console.warn(`Intiface client is missing. Is intiface running?`);
return;
}
if (!this.client.connected) {
console.warn(`No connection`);
return;
}
try {
if (position === null) {
console.log('[buttplug] Stopping device');
await Promise.all([...this.client.devices].map((d) => d.stop()));
} else {
const pos = position / 100;
await Promise.all([...this.client.devices].map((d) => d.vibrate(pos)));
//console.log(`[buttplug] Sent vibrate: pos=${pos.toFixed(2)}`);
}
} catch (e) {
console.error("[buttplug] Failed to send to device:", e);
}
}
handlePause = () => {
this.devicePaused = true;
this.sendToDevice(null);
};
handlePlay = () => {
this.devicePaused = false;
};
handleEnded = () => {
this.currentActionIndex = 0;
this.lastPositionSent = null;
this.devicePaused = true;
this.sendToDevice(null);
};
handleSeek = () => {
const time = this.player.currentTime() * 1000;
// Recalculate the closest action index based on seeked time
this.currentActionIndex = 0;
for (let i = 0; i < this.funscript.length; i++) {
if (this.funscript[i].at > time) break;
this.currentActionIndex = i;
}
this.lastPositionSent = null; // Force re-send on next timeupdate
};
dispose() {
super.dispose();
if (this.client?.connected) {
this.client.disconnect().then(() => console.log("[buttplug] Client disconnected"));
}
}
}
videojs.registerPlugin('buttplug', ButtplugPlugin);
</script>
{{!--
Script 4: Initialize the plugin and pass in the funscript URL from the DOM
--}}
<script type="module">
const player = videojs('#player');
const funscriptElement = document.querySelector('#funscript');
if (funscriptElement) {
player.buttplug({
funscriptUrl: funscriptElement.dataset.url
});
} else {
console.error('Element with id "funscript" not found.');
}
</script>
{{/main}}

View File

@ -32,7 +32,7 @@
</td>
<td>{{formatDate this.stream.date}}</td>
<td>{{{identicon this.upload.user.id 24}}}</td>
<td>{{this.notes}}</td>
<td>{{#if this.notes }}yes{{else}}no{{/if}}</td>
<td>{{this.status}}</td>
</tr>
{{/each}}

View File

@ -1,11 +1,11 @@
// tsup.config.ts
import { defineConfig } from 'tsup';
// This is just for the worker instance, because of nuances with graphile-worker.
// The server doesn't get built-- it launches using tsx.
// This build step is just for the worker instance, because of nuances with graphile-worker.
// The main server app doesn't get built-- it launches using tsx.
export default defineConfig({
entry: ['src/tasks/**/*.ts'],
outDir: 'dist',
outDir: 'dist/tasks',
target: 'node20',
format: ['cjs'],
splitting: false,
@ -13,3 +13,5 @@ export default defineConfig({
external: ['@prisma/client', '.prisma/client'],
platform: 'node',
});