Animate episode thumbnails on hover
This commit is contained in:
@@ -349,11 +349,15 @@ async function scanLibrary() {
|
||||
|
||||
async function attachVideoThumbnails(titleId, titlePath, videos) {
|
||||
for (const video of videos) {
|
||||
const outputName = `${titleId}-${video.id}.webp`;
|
||||
const output = path.join(VIDEO_THUMB_DIR, outputName);
|
||||
video.thumbnail = `/episode-thumbs/${outputName}`;
|
||||
const stillName = `${titleId}-${video.id}.jpg`;
|
||||
const animatedName = `${titleId}-${video.id}.webp`;
|
||||
const stillOutput = path.join(VIDEO_THUMB_DIR, stillName);
|
||||
const animatedOutput = path.join(VIDEO_THUMB_DIR, animatedName);
|
||||
video.thumbnail = `/episode-thumbs/${stillName}`;
|
||||
video.animatedThumbnail = `/episode-thumbs/${animatedName}`;
|
||||
try {
|
||||
await fsp.access(output, fs.constants.R_OK);
|
||||
await fsp.access(stillOutput, fs.constants.R_OK);
|
||||
await fsp.access(animatedOutput, fs.constants.R_OK);
|
||||
} catch {
|
||||
scheduleVideoThumbnail(titleId, titlePath, video);
|
||||
}
|
||||
@@ -376,13 +380,38 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
|
||||
const source = path.resolve(titlePath, video.relativePath);
|
||||
if (!isInside(titlePath, source)) throw new Error("Invalid video path");
|
||||
|
||||
const output = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
|
||||
const tmpOutput = `${output}.part`;
|
||||
const stillOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.jpg`);
|
||||
const animatedOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
|
||||
const tmpStill = `${stillOutput}.part`;
|
||||
const tmpAnimated = `${animatedOutput}.part`;
|
||||
try {
|
||||
await fsp.unlink(tmpOutput);
|
||||
await fsp.unlink(tmpStill);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
}
|
||||
try {
|
||||
await fsp.unlink(tmpAnimated);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn("ffmpeg", [
|
||||
"-y",
|
||||
"-ss", "00:00:12",
|
||||
"-i", source,
|
||||
"-frames:v", "1",
|
||||
"-vf", "scale=360:-1:flags=lanczos",
|
||||
"-f", "image2",
|
||||
tmpStill
|
||||
], { stdio: "ignore" });
|
||||
|
||||
ffmpeg.on("error", reject);
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error("ffmpeg could not generate a still thumbnail"));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn("ffmpeg", [
|
||||
@@ -394,7 +423,7 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
|
||||
"-loop", "0",
|
||||
"-an",
|
||||
"-f", "webp",
|
||||
tmpOutput
|
||||
tmpAnimated
|
||||
], { stdio: "ignore" });
|
||||
|
||||
ffmpeg.on("error", reject);
|
||||
@@ -404,7 +433,8 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
|
||||
});
|
||||
});
|
||||
|
||||
await fsp.rename(tmpOutput, output);
|
||||
await fsp.rename(tmpStill, stillOutput);
|
||||
await fsp.rename(tmpAnimated, animatedOutput);
|
||||
}
|
||||
|
||||
function groupSeasons(videos) {
|
||||
@@ -756,7 +786,8 @@ async function serveEpisodeThumbnail(res, name) {
|
||||
if (!isInside(VIDEO_THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" });
|
||||
try {
|
||||
const data = await fsp.readFile(filePath);
|
||||
res.writeHead(200, { "content-type": "image/webp", "cache-control": "private, max-age=86400" });
|
||||
const type = path.extname(filePath).toLowerCase() === ".webp" ? "image/webp" : "image/jpeg";
|
||||
res.writeHead(200, { "content-type": type, "cache-control": "private, max-age=86400" });
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });
|
||||
|
||||
Reference in New Issue
Block a user