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