服务器部分
bash
{
"name": "koa-vite-video",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@koa/cors": "^5.0.0",
"ffmpeg": "^0.0.4",
"fluent-ffmpeg": "^2.1.3",
"koa": "^2.15.3",
"koa-bodyparser": "^4.4.1",
"koa-router": "^12.0.1",
"koa-static": "^5.0.0",
"mysql": "^2.18.1",
"nodemon": "^3.1.4"
},
"devDependencies": {
"vite": "^5.3.5"
}
}
其中nodejs中使用 fluent-ffmpeg 要和ffmpeg软件一起使用。
主要功能就是在网页中能够看到视频图片的缩略图。
下载地址 https://ffmpeg.org/
bash
const Koa = require("koa");
const Router = require("koa-router");
const cors = require("@koa/cors");
const fs = require("fs");
const path = require("path");
const ffmpeg = require("fluent-ffmpeg");
// Set paths to ffmpeg and ffprobe executables
const ffmpegPath =
"D:\\Users\\lhl\\Desktop\\ffmpeg-master-latest-win64-gpl\\bin\\ffmpeg.exe";
const ffprobePath =
"D:\\Users\\lhl\\Desktop\\ffmpeg-master-latest-win64-gpl\\bin\\ffprobe.exe";
ffmpeg.setFfmpegPath(ffmpegPath);
ffmpeg.setFfprobePath(ffprobePath);
const app = new Koa();
const router = new Router();
app.use(cors());
const staticPath = path.join(__dirname, "public");
router.get("/files", async (ctx) => {
const files = fs.readdirSync(staticPath).map((file) => ({ name: file }));
ctx.body = files;
});
router.get("/thumbnail/:fileName", async (ctx) => {
const fileName = ctx.params.fileName;
const videoPath = path.resolve(staticPath, fileName);
const thumbnailPath = path.resolve(staticPath, `${fileName}.png`);
if (!fs.existsSync(videoPath)) {
ctx.status = 404;
ctx.body = "Video not found";
return;
}
// If thumbnail doesn't exist, generate it
if (!fs.existsSync(thumbnailPath)) {
try {
await new Promise((resolve, reject) => {
ffmpeg(videoPath)
.on("end", resolve)
.on("error", (err) => {
console.error(
`Error generating thumbnail for ${fileName}: ${err.message}`
);
reject(err);
})
.screenshots({
timestamps: [1], // Capture at 1 second instead of 0
filename: `${fileName}.png`,
folder: staticPath,
size: "200x150",
});
});
} catch (err) {
ctx.status = 500;
ctx.body = "Error generating thumbnail";
return;
}
}
ctx.type = "image/png";
ctx.body = fs.createReadStream(thumbnailPath);
});
router.get("/video/:fileName", async (ctx) => {
const range = ctx.headers.range;
const fileName = ctx.params.fileName;
if (!range) {
ctx.status = 400;
ctx.body = "Requires Range header";
return;
}
const videoPath = path.resolve(staticPath, fileName);
if (!fs.existsSync(videoPath)) {
ctx.status = 404;
ctx.body = "Video not found";
return;
}
const videoSize = fs.statSync(videoPath).size;
const CHUNK_SIZE = 1024 * 1024;
const startMatch = range.match(/bytes=(\d+)-/);
const start = startMatch ? Number(startMatch[1]) : 0;
if (start >= videoSize) {
ctx.status = 416;
ctx.set("Content-Range", `bytes */${videoSize}`);
return;
}
const end = Math.min(start + CHUNK_SIZE - 1, videoSize - 1);
const contentLength = end - start + 1;
const headers = {
"Content-Range": `bytes ${start}-${end}/${videoSize}`,
"Accept-Ranges": "bytes",
"Content-Length": contentLength,
"Content-Type": "video/mp4",
};
ctx.set(headers);
ctx.status = 206;
const videoStream = fs.createReadStream(videoPath, { start, end });
ctx.body = videoStream;
});
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
前端部分
bash
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Video Stream</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
margin: 0;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 10px;
padding: 10px;
width: 100%;
max-width: 1200px;
overflow: auto;
}
.grid-item {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #ddd;
padding: 10px;
cursor: pointer;
}
.grid-item video {
width: 100%;
height: auto;
}
.full-screen-video {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.full-screen-video video {
width: 80%;
height: auto;
}
.full-screen-video .close-btn {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.7);
border: none;
border-radius: 5px;
padding: 5px 10px;
cursor: pointer;
font-size: 16px;
}
</style>
</head>
<body>
<div class="grid-container" id="fileList"></div>
<script>
const fileList = document.getElementById("fileList");
// 获取文件列表并展示
function loadFiles() {
fetch("http://localhost:3000/files")
.then((response) => response.json())
.then((files) => {
fileList.innerHTML = "";
files.forEach((file) => {
const div = document.createElement("div");
div.className = "grid-item";
const video = document.createElement("video");
video.dataset.src = `http://localhost:3000/video/${file.name}`; // 使用 data-src 来存储真实的视频 URL
video.controls = true;
video.muted = true;
video.width = 300; // 控制缩略图大小
// IntersectionObserver 用于懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
video.src = video.dataset.src; // 进入视口时加载视频
observer.unobserve(entry.target); // 停止观察
}
});
}, {
rootMargin: '0px',
threshold: 0.1
});
observer.observe(video); // 开始观察视频元素
div.appendChild(video);
div.addEventListener("click", () => openFullScreenVideo(file.name));
fileList.appendChild(div);
});
})
.catch((error) => console.error("Error loading files:", error));
}
// 打开全屏视频播放
function openFullScreenVideo(fileName) {
const fullScreenContainer = document.createElement("div");
fullScreenContainer.className = "full-screen-video";
const video = document.createElement("video");
video.src = `http://localhost:3000/video/${fileName}`;
video.controls = true;
video.autoplay = true;
fullScreenContainer.appendChild(video);
const closeBtn = document.createElement("button");
closeBtn.textContent = "Close";
closeBtn.className = "close-btn";
closeBtn.addEventListener("click", () => {
document.body.removeChild(fullScreenContainer);
});
fullScreenContainer.appendChild(closeBtn);
document.body.appendChild(fullScreenContainer);
}
// 初始化文件列表
loadFiles();
</script>
</body>
</html>