Initial commit
This commit is contained in:
		
						commit
						1c62f36fb3
					
				
					 4 changed files with 1340 additions and 0 deletions
				
			
		
							
								
								
									
										12
									
								
								Earthfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Earthfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| VERSION 0.7 | ||||
| 
 | ||||
| server: | ||||
|     FROM node:lts-hydrogen | ||||
|     RUN npm -g install pnpm | ||||
|     WORKDIR /app | ||||
|     COPY package.json pnpm-lock.yaml ./ | ||||
|     RUN pnpm install | ||||
|     COPY . . | ||||
|     CMD ["pnpm", "start"] | ||||
|     SAVE IMAGE ksx-snapshot-server localhost:5000/ksx-snapshot-server | ||||
|     SAVE IMAGE --push albedo.lan:5000/ksx-snapshot-server | ||||
							
								
								
									
										183
									
								
								index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,183 @@ | |||
| import ffmpeg, { FfprobeData } from "fluent-ffmpeg"; | ||||
| import { promisify } from "util"; | ||||
| import express from "express"; | ||||
| import schedule from "node-schedule"; | ||||
| import { mkdir } from "fs-extra"; | ||||
| import path from "path"; | ||||
| import { program } from "commander"; | ||||
| import winston from "winston"; | ||||
| import { readdir, stat } from "node:fs/promises"; | ||||
| import { createHash } from "node:crypto"; | ||||
| 
 | ||||
| const ffprobeAsync: (file: string) => Promise<FfprobeData> = promisify( | ||||
|   ffmpeg.ffprobe, | ||||
| ); | ||||
| 
 | ||||
| interface VideoInfo { | ||||
|   path: string; | ||||
|   duration: number; | ||||
| } | ||||
| 
 | ||||
| const logger = winston.createLogger({ | ||||
|   level: "debug", | ||||
|   format: winston.format.combine( | ||||
|     winston.format.timestamp(), | ||||
|     winston.format.printf(({ timestamp, level, message }) => { | ||||
|       return `${timestamp} [${level}]: ${message}`; | ||||
|     }), | ||||
|   ), | ||||
|   transports: [new winston.transports.Console()], | ||||
| }); | ||||
| 
 | ||||
| async function getVideoInfo(paths: string[]): Promise<VideoInfo[]> { | ||||
|   const videos: VideoInfo[] = []; | ||||
|   for (const videoPath of paths) { | ||||
|     const metadata = await ffprobeAsync(videoPath); | ||||
|     const duration = metadata.format.duration; | ||||
|     if (!duration) { | ||||
|       logger.warn(`Failed to get duration for ${videoPath}`); | ||||
|       continue; | ||||
|     } | ||||
|     videos.push({ path: videoPath, duration }); | ||||
|   } | ||||
|   return videos; | ||||
| } | ||||
| 
 | ||||
| async function takeSnapshot(path: string, timestamp: number, output: string) { | ||||
|   return new Promise<void>((resolve) => { | ||||
|     ffmpeg(path) | ||||
|       .seekInput(timestamp) | ||||
|       .outputOptions("-frames:v", "1") | ||||
|       .output(output) | ||||
|       .on("end", () => { | ||||
|         resolve(); | ||||
|       }) | ||||
|       .run(); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| async function processSnapshots( | ||||
|   videos: VideoInfo[], | ||||
|   snapshotsDir: string, | ||||
|   count: number, | ||||
| ) { | ||||
|   const totalDuration = videos.reduce((acc, video) => acc + video.duration, 0); | ||||
|   const currentHour = new Date().getHours(); | ||||
|   const currentDir = path.join(snapshotsDir, currentHour.toString()); | ||||
|   await mkdir(currentDir, { recursive: true }); | ||||
| 
 | ||||
|   for (let i = 0; i < count; i++) { | ||||
|     while (true) { | ||||
|       let size = 0; | ||||
|       const randomTimestamp = Math.random() * totalDuration; | ||||
|       let cumulativeDuration = 0; | ||||
|       const snapshotPath = path.join(currentDir, `${i}.webp`); | ||||
| 
 | ||||
|       for (const video of videos) { | ||||
|         if (cumulativeDuration + video.duration >= randomTimestamp) { | ||||
|           const timestampWithinVideo = randomTimestamp - cumulativeDuration; | ||||
| 
 | ||||
|           logger.info( | ||||
|             `Extracting snapshot ${i + 1}/${count} from ${ | ||||
|               video.path | ||||
|             } at ${timestampWithinVideo}s`,
 | ||||
|           ); | ||||
|           await takeSnapshot(video.path, timestampWithinVideo, snapshotPath); | ||||
| 
 | ||||
|           break; | ||||
|         } | ||||
|         cumulativeDuration += video.duration; | ||||
|       } | ||||
| 
 | ||||
|       const stats = await stat(snapshotPath); | ||||
|       size = stats.size; | ||||
|       if (size < 5 * 1024) { | ||||
|         logger.info( | ||||
|           `Snapshot ${ | ||||
|             i + 1 | ||||
|           }/${count} too small: ${size} bytes, trying again...`,
 | ||||
|         ); | ||||
|       } else { | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function setupSnapshotServer( | ||||
|   videoPaths: string[], | ||||
|   snapshotsDir: string, | ||||
|   count: number, | ||||
| ) { | ||||
|   const app = express(); | ||||
| 
 | ||||
|   try { | ||||
|     await mkdir(snapshotsDir, { recursive: true }); | ||||
|     const videos = await getVideoInfo(videoPaths); | ||||
| 
 | ||||
|     // noinspection ES6MissingAwait
 | ||||
|     processSnapshots(videos, snapshotsDir, count); | ||||
| 
 | ||||
|     schedule.scheduleJob("0 * * * *", () => | ||||
|       processSnapshots(videos, snapshotsDir, count), | ||||
|     ); | ||||
| 
 | ||||
|     app.get("/:magic?", async (req, res) => { | ||||
|       logger.debug(`REQ: /${req.params.magic || ""}`); | ||||
|       const { magic } = req.params; | ||||
|       const currentHour = new Date().getHours(); | ||||
|       const currentDir = path.resolve( | ||||
|         path.join(snapshotsDir, currentHour.toString()), | ||||
|       ); | ||||
| 
 | ||||
|       try { | ||||
|         const files = await readdir(currentDir); | ||||
| 
 | ||||
|         if (magic) { | ||||
|           const hash = createHash("md5").update(magic).digest("hex"); | ||||
|           const index = parseInt(hash.substring(0, 8), 16) % files.length; | ||||
|           // Cache until the next hour rolls over
 | ||||
|           res.sendFile(files[index], { | ||||
|             headers: { | ||||
|               "Cache-Control": `public, max-age=${ | ||||
|                 3600 - new Date().getMinutes() * 60 | ||||
|               }`,
 | ||||
|             }, | ||||
|             root: currentDir, | ||||
|           }); | ||||
|         } else { | ||||
|           res.sendFile(files[Math.floor(Math.random() * files.length)], { | ||||
|             headers: { | ||||
|               "Cache-Control": "public, max-age=0", | ||||
|             }, | ||||
|             root: currentDir, | ||||
|           }); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         logger.error("Failed to send file:", error); | ||||
|         res.status(500).send(`Error.`); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     app.listen(3000, () => | ||||
|       logger.info("Server running on http://localhost:3000"), | ||||
|     ); | ||||
|   } catch (error) { | ||||
|     logger.error("Failed to start the server:", error); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| program | ||||
|   .name("KSX Snapshot Server") | ||||
|   .argument("<paths...>", "Video file paths") | ||||
|   .option("-d, --directory <dir>", "Directory to store snapshots", "snapshots") | ||||
|   .option("-c, --count <count>", "Number of snapshots to take per hour", "32") | ||||
|   .action(async (videoPaths: string[], options) => { | ||||
|     await setupSnapshotServer( | ||||
|       videoPaths, | ||||
|       options.directory, | ||||
|       parseInt(options.count, 10), | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
| program.parse(); | ||||
							
								
								
									
										28
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| { | ||||
|   "name": "ksx-snapshot-server", | ||||
|   "version": "1.0.0", | ||||
|   "description": "", | ||||
|   "main": "index.js", | ||||
|   "scripts": { | ||||
|     "start": "tsx index.ts" | ||||
|   }, | ||||
|   "keywords": [], | ||||
|   "author": "", | ||||
|   "license": "ISC", | ||||
|   "dependencies": { | ||||
|     "commander": "^12.0.0", | ||||
|     "express": "^4.19.2", | ||||
|     "fluent-ffmpeg": "^2.1.2", | ||||
|     "fs-extra": "^11.2.0", | ||||
|     "node-schedule": "^2.1.1", | ||||
|     "tsx": "^4.7.1", | ||||
|     "winston": "^3.13.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/express": "^4.17.21", | ||||
|     "@types/fluent-ffmpeg": "^2.1.24", | ||||
|     "@types/fs-extra": "^11.0.4", | ||||
|     "@types/node": "^20.12.2", | ||||
|     "@types/node-schedule": "^2.1.6" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1117
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1117
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		
		Reference in a new issue