diff --git a/services/our/Dockerfile b/services/our/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/services/our/docker-compose.production.yml b/services/our/docker-compose.production.yml new file mode 100644 index 0000000..d283162 --- /dev/null +++ b/services/our/docker-compose.production.yml @@ -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: \ No newline at end of file diff --git a/services/our/graphile.config.ts b/services/our/graphile.config.ts index 228de5b..4d7fa3a 100644 --- a/services/our/graphile.config.ts +++ b/services/our/graphile.config.ts @@ -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 }, + }; diff --git a/services/our/package-lock.json b/services/our/package-lock.json index 5c6ff4b..08f3b5a 100644 --- a/services/our/package-lock.json +++ b/services/our/package-lock.json @@ -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", diff --git a/services/our/package.json b/services/our/package.json index dab974c..01230d3 100644 --- a/services/our/package.json +++ b/services/our/package.json @@ -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", diff --git a/services/our/pnpm-lock.yaml b/services/our/pnpm-lock.yaml index 8ef5e06..0f599e8 100644 --- a/services/our/pnpm-lock.yaml +++ b/services/our/pnpm-lock.yaml @@ -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 diff --git a/services/our/prisma/migrations/20250708041548_add_vod_funscript/migration.sql b/services/our/prisma/migrations/20250708041548_add_vod_funscript/migration.sql new file mode 100644 index 0000000..76906e6 --- /dev/null +++ b/services/our/prisma/migrations/20250708041548_add_vod_funscript/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Vod" ADD COLUMN "funscript" TEXT; diff --git a/services/our/prisma/schema.prisma b/services/our/prisma/schema.prisma index 85d7029..fecb944 100644 --- a/services/our/prisma/schema.prisma +++ b/services/our/prisma/schema.prisma @@ -80,6 +80,7 @@ model Vod { status VodStatus @default(pending) sha256sum String? cidv1 String? + funscript String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/services/our/prisma/seed.ts b/services/our/prisma/seed.ts index 481af41..3c375e3 100644 --- a/services/our/prisma/seed.ts +++ b/services/our/prisma/seed.ts @@ -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 }], }, diff --git a/services/our/src/app.ts b/services/our/src/app.ts index 575f4a8..8212725 100644 --- a/services/our/src/app.ts +++ b/services/our/src/app.ts @@ -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(``); } + + // Inject width/height if not already set + const sizedSvg = svg + .replace(/]*)>/, ``); + + return new Handlebars.SafeString(sizedSvg); }); const __dirname = import.meta.dirname; diff --git a/services/our/src/assets/svg/download.svg b/services/our/src/assets/svg/download.svg new file mode 100644 index 0000000..995f153 --- /dev/null +++ b/services/our/src/assets/svg/download.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/services/our/src/assets/svg/graph.svg b/services/our/src/assets/svg/graph.svg new file mode 100644 index 0000000..1438371 --- /dev/null +++ b/services/our/src/assets/svg/graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/our/src/assets/svg/processing.svg b/services/our/src/assets/svg/processing.svg new file mode 100644 index 0000000..abd98c4 --- /dev/null +++ b/services/our/src/assets/svg/processing.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/services/our/src/config/env.ts b/services/our/src/config/env.ts index 946a706..e86140f 100644 --- a/services/our/src/config/env.ts +++ b/services/our/src/config/env.ts @@ -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); diff --git a/services/our/src/index.ts b/services/our/src/index.ts index 5c32e67..2eea8f7 100644 --- a/services/our/src/index.ts +++ b/services/our/src/index.ts @@ -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' }) \ No newline at end of file diff --git a/services/our/src/plugins/hls.ts b/services/our/src/plugins/hls.ts index 4de4d7c..dd5b1a1 100644 --- a/services/our/src/plugins/hls.ts +++ b/services/our/src/plugins/hls.ts @@ -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'); diff --git a/services/our/src/plugins/index.ts b/services/our/src/plugins/index.ts index 2faa119..d883cb9 100644 --- a/services/our/src/plugins/index.ts +++ b/services/our/src/plugins/index.ts @@ -26,7 +26,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise { if (count > 0) helpers.logger.info(`Deleted ${count} old files.`); }; - export default cleanup; diff --git a/services/our/src/tasks/createFunscript.ts b/services/our/src/tasks/createFunscript.ts new file mode 100644 index 0000000..4191c54 --- /dev/null +++ b/services/our/src/tasks/createFunscript.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt')); + const detections: Map = 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)[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; diff --git a/services/our/src/tasks/createHlsPlaylist.ts b/services/our/src/tasks/createHlsPlaylist.ts index d48b748..f667506 100644 --- a/services/our/src/tasks/createHlsPlaylist.ts +++ b/services/our/src/tasks/createHlsPlaylist.ts @@ -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 Twitch’s 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() diff --git a/services/our/src/tasks/createIpfsCid.ts b/services/our/src/tasks/createIpfsCid.ts index b1df702..01cd49a 100644 --- a/services/our/src/tasks/createIpfsCid.ts +++ b/services/our/src/tasks/createIpfsCid.ts @@ -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({ diff --git a/services/our/src/tasks/getSourceVideo.ts b/services/our/src/tasks/getSourceVideo.ts index 2f76995..c9c6e50 100644 --- a/services/our/src/tasks/getSourceVideo.ts +++ b/services/our/src/tasks/getSourceVideo.ts @@ -139,10 +139,20 @@ export async function getFileSize(filePath: string): Promise { 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; diff --git a/services/our/src/tasks/scheduleVodProcessing.ts b/services/our/src/tasks/scheduleVodProcessing.ts index 1261d7e..baf45c7 100644 --- a/services/our/src/tasks/scheduleVodProcessing.ts +++ b/services/our/src/tasks/scheduleVodProcessing.ts @@ -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) { diff --git a/services/our/src/utils/filesystem.ts b/services/our/src/utils/filesystem.ts index bef55b4..0aed58b 100644 --- a/services/our/src/utils/filesystem.ts +++ b/services/our/src/utils/filesystem.ts @@ -19,6 +19,7 @@ export async function listFilesRecursive(dir: string): Promise { } 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); diff --git a/services/our/src/utils/formatters.ts b/services/our/src/utils/formatters.ts index 7b623b2..0e9ac0c 100644 --- a/services/our/src/utils/formatters.ts +++ b/services/our/src/utils/formatters.ts @@ -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; +} diff --git a/services/our/src/utils/icons.ts b/services/our/src/utils/icons.ts new file mode 100644 index 0000000..6cf78a2 --- /dev/null +++ b/services/our/src/utils/icons.ts @@ -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 = {}; + +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; + } +} diff --git a/services/our/src/views/vod.hbs b/services/our/src/views/vod.hbs index a3595fe..94a7536 100644 --- a/services/our/src/views/vod.hbs +++ b/services/our/src/views/vod.hbs @@ -1,9 +1,40 @@ {{#> main}} - + + +
{{> navbar}} +
@@ -26,9 +57,13 @@ {{else}} -
- HLS playback is still processing. -
+ +
+
+ + {{icon "processing" 24}} HTTP Live Streaming is processing. +
+
{{/if}}
@@ -77,54 +112,17 @@ {{/if}} - {{!--

Thumbnail Image

- {{#if vod.thumbnail}} - {{this.vtuber.displayName}} thumbnail -
- {{else}} -
- Thumbnail is still processing. -
- {{/if}} --}} -

Downloads

-

Video Source

- {{#if vod.sourceVideo}} -

- - Download -

-

{{#if vod.sha256sum}}sha256sum {{vod.sha256sum}}{{/if}}

- {{#if vod.cidv1}} -

IPFS cidv1 {{vod.cidv1}}

- {{else}} -
- IPFS CID is still processing. -
- {{/if}} - {{else}} -
- Video Source is still processing. -
- {{/if}} -

Raw Recorded File Segments

{{#if vod.segmentKeys}}
    {{#each vod.segmentKeys}}
  • - - {{this.name}}
  • + href="{{getCdnUrl this.key}}">{{icon "download" 24}} {{this.name}} + {{/each}}
{{else}} @@ -133,14 +131,71 @@ {{/if}} +

Concatenated Video

+ {{#if vod.sourceVideo}} +

{{icon "download" 24}} Download +

+

{{#if vod.sha256sum}}sha256sum {{vod.sha256sum}}{{/if}}

+ {{#if vod.cidv1}} +

IPFS cidv1 {{vod.cidv1}}

+ {{else}} +
+ IPFS CID is processing. +
+ {{/if}} + {{else}} +
+ Video Source is processing. +
+ {{/if}} + + + + +

HLS Playlist

+ {{#if vod.hlsPlaylist}} + {{signedHlsUrl vod.hlsPlaylist}} + {{else}} +
+ HLS Playlist is processing. +
+ {{/if}} + +

Thumbnail Image

+ {{#if vod.thumbnail}} + {{this.vtuber.displayName}} thumbnail +
+ {{else}} +
+ Thumbnail is processing. +
+ {{/if}} + + + +

Funscript (sex toy sync)

+ {{#if vod.funscript}} + {{icon "download" 24}} + {{this.vtuber.displayName}} + Funscript +
+ {{else}} +
+ Funscript file is processing. +
+ {{/if}} + + {{#if (isModerator user)}}

Moderator Controls

- + {{/if}} @@ -155,7 +210,7 @@ {{>footer}} - +{{!-- --}} + + +{{!-- + Script 1: Load Buttplug.js from Skypack CDN and expose it to window.buttplug +--}} + + + + +{{!-- + Script 2: Define reusable utility components for funscript and buttplug indicators +--}} + + + +{{!-- + Script 3: Main ButtplugPlugin class — handles connection, syncing, and device control +--}} + + +{{!-- + Script 4: Initialize the plugin and pass in the funscript URL from the DOM +--}} + {{/main}} \ No newline at end of file diff --git a/services/our/src/views/vods.hbs b/services/our/src/views/vods.hbs index 52706a4..c5fab82 100644 --- a/services/our/src/views/vods.hbs +++ b/services/our/src/views/vods.hbs @@ -32,7 +32,7 @@ {{formatDate this.stream.date}} {{{identicon this.upload.user.id 24}}} - {{this.notes}} + {{#if this.notes }}yes{{else}}no{{/if}} {{this.status}} {{/each}} diff --git a/services/our/tsup.config.ts b/services/our/tsup.config.ts index 8976c1b..53ed6e9 100644 --- a/services/our/tsup.config.ts +++ b/services/our/tsup.config.ts @@ -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', }); + +