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
Normal file
1117
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue