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