add buttplug vjs plugin
This commit is contained in:
parent
6d77138ebd
commit
c386e48dcf
0
services/our/Dockerfile
Normal file
0
services/our/Dockerfile
Normal file
48
services/our/docker-compose.production.yml
Normal file
48
services/our/docker-compose.production.yml
Normal 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:
|
@ -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
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
1
services/our/package-lock.json
generated
1
services/our/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
3
services/our/pnpm-lock.yaml
generated
3
services/our/pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Vod" ADD COLUMN "funscript" TEXT;
|
@ -80,6 +80,7 @@ model Vod {
|
||||
status VodStatus @default(pending)
|
||||
sha256sum String?
|
||||
cidv1 String?
|
||||
funscript String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
@ -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 }],
|
||||
},
|
||||
|
@ -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;
|
||||
|
5
services/our/src/assets/svg/download.svg
Normal file
5
services/our/src/assets/svg/download.svg
Normal 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 |
1
services/our/src/assets/svg/graph.svg
Normal file
1
services/our/src/assets/svg/graph.svg
Normal 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 |
5
services/our/src/assets/svg/processing.svg
Normal file
5
services/our/src/assets/svg/processing.svg
Normal 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 |
@ -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);
|
||||
|
@ -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' })
|
@ -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');
|
||||
|
@ -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,
|
||||
|
@ -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}`
|
||||
}
|
||||
|
@ -7,5 +7,4 @@ const cleanup: Task = async (_payload, helpers: Helpers) => {
|
||||
if (count > 0) helpers.logger.info(`Deleted ${count} old files.`);
|
||||
};
|
||||
|
||||
|
||||
export default cleanup;
|
||||
|
417
services/our/src/tasks/createFunscript.ts
Normal file
417
services/our/src/tasks/createFunscript.ts
Normal 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;
|
@ -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()
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
16
services/our/src/utils/icons.ts
Normal file
16
services/our/src/utils/icons.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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}}
|
@ -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}}
|
||||
|
@ -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',
|
||||
});
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user