完成视频的播放功能

This commit is contained in:
zhangkeyang 2024-04-20 13:52:25 +08:00
commit a9051eeb39
21 changed files with 2844 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv/
**/__pycache__/
**/.vscode/

14
config.py Normal file
View File

@ -0,0 +1,14 @@
import os
class ServerConfig(object):
# 视频文件配置
video_root_path : str = os.path.join('data', 'video')
video_play_path : str = os.path.join(video_root_path, 'play')
video_upload_path : str = os.path.join(video_root_path, 'upload')
video_chunk_size : int = 1024 * 1024 * 1024
# TODO
class ModelConfig(object):
# TODO
pass

BIN
data/video/play/123.mp4 Normal file

Binary file not shown.

59
main.py Normal file
View File

@ -0,0 +1,59 @@
import os
import io
import uvicorn
from pathlib import Path
from fastapi import FastAPI, Header
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from config import ServerConfig
app = FastAPI()
app.mount('/home', StaticFiles(directory="static", html=True), name="static")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
@app.get("/fetch/{id}")
async def fetch_file(id: str, range: str = Header(None)) -> StreamingResponse:
video_path: Path = Path(os.path.join(ServerConfig.video_play_path, id + '.mp4'))
video_size: int = video_path.stat().st_size
start = 0
end = video_size - 1
chunk_size = min(video_size, ServerConfig.video_chunk_size)
headers = {
'Content-Type': 'video/mp4',
'Content-Disposition': f'attachment; filename="{id}.mp4"',
}
if range:
start, end = range.replace('bytes=', '').split('-')
start = int(start)
end = int(end) if end else video_size - 1
chunk_size: int = min(end - start + 1, ServerConfig.video_chunk_size)
headers['Content-Range'] = f'bytes {start}-{end}/{video_size}'
headers['Accept-Ranges'] = 'bytes'
headers['Content-Disposition'] = 'inline'
def file_reader():
with open(video_path, 'rb') as video:
video.seek(start)
while True:
data = video.read(chunk_size)
if not data:
break
yield data
return StreamingResponse(file_reader(), status_code=206, headers=headers, media_type='video/mp4')
if __name__ == "__main__":
uvicorn.run(app=app)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
mypy==1.9.0
numpy==1.22.0
torch==2.1.0
torchvision==0.16.0
opencv-python==4.9.0.80
opencv-contrib-python==4.9.0.80
uvicorn==0.29.0
fastapi==0.110.2

7
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

32
static/css/index.css Normal file
View File

@ -0,0 +1,32 @@
/* 默认字体大小 */
.nav-item {
font-size: 25px;
}
/* 在大屏幕设备上增大字体大小 */
@media (min-width: 768px) {
.nav-item {
font-size: 25px;
}
}
/* 在更大屏幕设备上进一步增大字体大小 */
@media (min-width: 992px) {
.nav-item {
font-size: 20px;
}
}
/* 设置主面板的背景 */
.container-main {
min-height: 90vh; /* 铺满整个视口 */
background-image: url('../image/background.svg'); /* 背景图片路径 */
background-size: cover; /* 覆盖整个容器 */
background-position: center; /* 图片居中显示 */
background-repeat: no-repeat; /* 不重复图片 */
}
/* 设置状态表格的对齐方式为垂直居中对齐 */
#status-table td {
vertical-align: middle;
}

1
static/css/plyr.css Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<svg width="1389" height="1479" fill="none" xmlns="http://www.w3.org/2000/svg"><g opacity=".3"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="1389" height="1479"><path fill="#D9D9D9" d="M0 0h1389v1479H0z"/></mask><g mask="url(#a)"><ellipse opacity=".5" cy="1007.5" rx="160" ry="160.5" fill="url(#b)"/><circle opacity=".5" cx="857.242" cy="375.085" r="91.111" fill="url(#c)"/><rect opacity=".5" x="-.664" y="273.555" width="386.866" height="386.866" rx="24" transform="rotate(-45 -.664 273.555)" fill="url(#d)"/><rect opacity=".5" x="288.662" y="1179.43" width="718.993" height="424.487" rx="32" transform="rotate(-45 288.662 1179.43)" fill="url(#e)"/><circle opacity=".5" cx="1389.13" cy="530.129" r="220.13" fill="url(#f)"/><circle opacity=".5" cx="1205.72" cy="1387.95" r="91.111" fill="url(#g)"/></g></g><defs><linearGradient id="b" x1="-61.873" y1="861.062" x2=".372" y2="1167.92" gradientUnits="userSpaceOnUse"><stop stop-color="#86B3FE"/><stop offset=".993" stop-color="#F5F5FA"/></linearGradient><linearGradient id="c" x1="766.131" y1="250.722" x2="857.242" y2="466.196" gradientUnits="userSpaceOnUse"><stop stop-color="#86B3FE"/><stop offset="1" stop-color="#F5F5FA"/></linearGradient><linearGradient id="d" x1="117.967" y1="290.503" x2="192.769" y2="660.421" gradientUnits="userSpaceOnUse"><stop stop-color="#86B3FE"/><stop offset=".993" stop-color="#F5F5FA"/></linearGradient><linearGradient id="e" x1="616.714" y1="1247.46" x2="920.97" y2="1639.19" gradientUnits="userSpaceOnUse"><stop offset=".091" stop-color="#F5F5FA"/><stop offset=".948" stop-color="#86B3FE"/></linearGradient><linearGradient id="f" x1="1456.96" y1="604.614" x2="1389.13" y2="750.259" gradientUnits="userSpaceOnUse"><stop stop-color="#86B3FE"/><stop offset=".993" stop-color="#F5F5FA"/></linearGradient><linearGradient id="g" x1="1242.3" y1="1427.85" x2="1140.55" y2="1333.41" gradientUnits="userSpaceOnUse"><stop stop-color="#86B3FE"/><stop offset="1" stop-color="#F5F5FA"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
static/image/icdd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/image/leftarrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

BIN
static/image/rightarrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

86
static/index.html Normal file
View File

@ -0,0 +1,86 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/index.css">
<link rel="stylesheet" href="css/plyr.css">
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.bundle.min.js"></script>
<script src="js/plyr.js"></script>
<script src="js/util.js"></script>
<script src="js/index.js"></script>
<title>ICDD-视频摘要</title>
</head>
<body>
<!-- 顶部导航栏 -->
<nav class="navbar navbar-expand-sm bg-dark navbar-dark">
<ul class="navbar-nav" style="align-items: center;">
<a class="navbar-brand" href="#"><img src="image/icdd.png" alt="Logo"></a>
<li class="nav-item mr-4"><a class="nav-link" href="#">主页</a></li>
<li class="nav-item mr-4"><a class="nav-link" href="#">上传</a></li>
<li class="nav-item mr-4"><a class="nav-link" href="#">数据集</a></li>
</ul>
</nav>
<!-- 错误提示Modal (默认不显示) -->
<div class="modal fade" id="error-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title"></h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 视频展示Modal (默认不显示) -->
<div class="modal fade" id="video-modal">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title display-5"></h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<video class="plyr" id="video" playsinline controls muted="unmuted"></video>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" data-dismiss="modal">下载</button>
<button type="button" class="btn btn-danger" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 中心面板 -->
<div class="container-fluid container-main d-flex flex-column justify-content-center align-items-center">
<div class="container m-3 d-flex justify-content-center align-items-center">
<h1 id="caption" class="text-center display-4"><b>创建视频摘要</b></h1>
</div>
<div class="container m-3 d-flex justify-content-center align-items-center">
<button id="select-video" type="button" class="btn btn-dark btn-lg">选择视频</button>
<input type="file" id="upload-video" style="display: none;" multiple />
</div>
<div class="container m-3 d-flex justify-content-center align-items-center table-responsive">
<table id="status-table" class="table table-hover" style="display: none;">
<thead class="thead-dark">
<tr>
<th style="width: 25%;">文件名</th>
<th style="width: 10%;">文件格式</th>
<th style="width: 10%;">文件大小</th>
<th style="width: 10%;">状态</th>
<th style="width: 35%;">进度</th>
<th style="width: 10%;">操作</th>
</tr>
</thead>
<!-- 注意: 这里的空tr需要保留, 否则table-hover效果将失效 -->
<tr></tr>
</table>
</div>
</div>
</body>
</html>

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

193
static/js/index.js Normal file
View File

@ -0,0 +1,193 @@
/**
* 当主页中的选择视频按钮被点击后, 执行此函数
*
* 1) 弹出一个文件选择框, 让用户选定一些本地文件
*/
function onSelectVideoButtonClicked() {
// 当用户选定了一些文件后, 函数 onUploadVideoChanged 将被执行.
$('#upload-video').click();
}
/**
* 当主页中的选择视频按钮被点击后, 且用户选定了一些文件时, 此函数将被调用.
*
* 1) 检查这些文件的格式. 目前可以处理的视频文件格式是*.mp4
* 2) 将按钮'select-video'隐藏
* 3) 将表格'status-table'显示
*
* 对于每个已选定的文件, 均执行以下异步流程:
*
* 1) 调用服务器的接口, 将文件上传到服务器
* 2) 将标题'caption'修改为 '正在处理{files.length}个文件, 请稍候查看结果'
* 3) 为表格'status-table'追加一行, 用于展示处理当前文件的状态与进度, 并提供用户可执行的操作
*
* @param {file} files 用户选择的文件列表.
*/
function onUploadVideoChanged(files) {
for (var index = 0; index < files.length; index++) {
var file = files[index];
console.log(index);
if (file.type != 'video/mp4') {
errorModal('错误', `文件${file.name}的格式错误`);
return;
}
}
$('#select-video').hide();
$('#status-table').show();
$('#caption').html('正在处理' + files.length + '个文件');
$.each(files, function(index, file) {
uploadFile(index, file);
appendRowToStatusTable(index, file);
});
}
/**
* 当文件完成部分上传时, 此函数将被回调, 用于更新部分页面组件.
*
* 1) 将表格 status-table 中进度栏的进度信息修改为 '${percent}%'
*
* @param {index} index 文件的索引
* @param {number} percent 文件的上传进度
*/
function onFileUploadStepped(index, percent) {
prog = $(`#stat-prog-${index}`);
prog.css({'width': `${percent}%`});
prog.html(`${percent}%`);
}
/**
* 当文件上传成功后, 此函数将被回调, 用于开始进行特征提取, 同时更新部分界面组件.
*
* 1) 将表格 status-table 中状态栏的信息改为 '正在提取'
* 2) 将表格 status-table 中进度栏的进度信息重置为 '0%'
* 3) 调用服务器接口, 开始执行推理
*
* @param {number} index 文件的索引
* @param {File} file 文件对象
* @param {object} data 服务器响应数据
*/
function onFileUploadFinished(index, file, data) {
$(`#stat-label-${index}`).html('正在提取');
$(`#stat-prog-${index}`).css({'width': '0%'});
$(`#stat-prog-${index}`).html('0%');
}
/**
* 当文件上传失败后, 此函数将被回调, 用于更新部分界面组件.
*
* 1) 将表格 status-table 中的状态栏信息改为 '上传失败'.
* 2) 将表格 status-table 中进度栏的进度信息重置为 '0%'
* 3) 将表格 status-table 中操作栏的按钮修改为 danger, 并激活以查看失败原因.
*
* @param {number} index 文件的索引
* @param {File} file 文件对象
* @param {error} err 失败原因
*/
function onFileUploadFailed(index, file, err) {
$(`#stat-label-${index}`).html('<div class="text-danger">上传失败</div>');
$(`#stat-prog-${index}`).css({'width': '0%'});
$(`#stat-prog-${index}`).html('0%');
$(`#stat-button-${index}`).removeClass('btn-dark disabled');
$(`#stat-button-${index}`).addClass('btn-danger');
$(`#stat-button-${index}`).find('span').remove();
$(`#stat-button-${index}`).text('查看');
$(`#stat-button-${index}`).on('click', function() {
errorModal('错误', `文件${file.name}上传失败: ${err}`);
});
}
/**
* 上传指定的文件到服务器的接口.
*
* 此函数会回调 onFileUploadStepped 函数, 告知文件的上传进度.
*
* @param {index} index 文件的索引
* @param {file} file 文件对象
*/
function uploadFile(index, file) {
var formData = new FormData();
formData.append('file', file);
$.ajax({
url: 'http://127.0.0.1:8000/upload',
type: 'POST',
data: formData,
processData: false, // 不处理发送的数据
contentType: false, // 不设置内容类型
success: function(data) {
onFileUploadFinished(index, file, data);
},
error: function(xhr, status, error) {
if (xhr.status == 0) {
error = '连接到服务器失败!';
} else {
var errorAjax = `AJAX error: ${status} : ${error}`;
var errorResponseText = `ResponseText: ${xhr.responseText}`;
var errorStatusText = `StatusText: ${xhr.statusText}`;
error = `${errorAjax} + \n\n + ${errorResponseText} + \n\n + ${errorStatusText}`;
}
onFileUploadFailed(index, file, error);
},
xhr: function() {
var xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', function(event) {
if (event.lengthComputable) {
var percent = event.loaded / event.total;
percent = parseInt(percent * 100);
onFileUploadStepped(index, percent);
}
}, false);
return xhr;
},
});
}
/**
* 在结果窗口中播放服务器中编号为id的视频.
*
* @param {string} id 服务器视频id
*/
function playFile(index, file, id) {
$('#video-modal').find('.modal-title').text(`预览${file ? file.name : '此视频'}的关键镜头`);
$('#video-modal').find('video').attr('src', `http://127.0.0.1:8000/fetch/${id}`);
var player = new Plyr('video');
$('#video-modal').modal();
$('#video-modal').on('hidden.bs.modal', function(event) {
player.pause();
});
}
/**
* 向表格'status-table'中增加一行, 向用户展示上传文件的信息与当前状态, 并提供必要的操作.
*
* @param {number} index 正在处理的文件编号.
* @param {file} file 正在处理的文件对象.
* @note 在执行此函数时, 该文件对象可能正在异步上传.
*/
function appendRowToStatusTable(index, file) {
var fileNameHtml = `<td>${file.name}</td>`;
var fileTypeHtml = `<td>${file.type}</td>`;
var fileSizeHtml = `<td>${getReadableSize(file.size)}</td>`;
var fileStatHtml = `<td id='stat-label-${index}'>正在上传</td>`;
var fileProgHtml = `<td><div class='progress'><div id='stat-prog-${index}' class='progress-bar' style='width:0%'>0%</div></div></td>`;
var fileOptrHtml = `<td><button id='stat-button-${index}' type='button' class='btn btn-dark btn-sm disabled'><span class="spinner-border spinner-border-sm"></span></button></td>`;
$('#status-table').append(
'<tr>' + fileNameHtml + fileTypeHtml + fileSizeHtml + fileStatHtml + fileProgHtml + fileOptrHtml + '</tr>'
);
}
// 注册按钮点击事件处理函数
$(document).ready(function() {
$('#select-video').click(onSelectVideoButtonClicked);
});
// 注册文件选择事件处理函数
$(document).ready(function() {
$('#upload-video').change(function() {
var files = $(this).prop('files');
onUploadVideoChanged(files);
});
});
$(document).ready(function() {
playFile(0, null, '123');
});

2
static/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
static/js/plyr.js Normal file

File diff suppressed because one or more lines are too long

1274
static/js/plyr.js.map Normal file

File diff suppressed because one or more lines are too long

30
static/js/util.js Normal file
View File

@ -0,0 +1,30 @@
/**
* 在页面上弹出错误信息.
*
* @param {string} errHeader 错误标题
* @param {string} errMsg 错误信息
*/
function errorModal(errHeader, errMsg) {
$('#error-modal').find('.modal-title').text(errHeader);
$('#error-modal').find('.modal-body').text(errMsg);
$('#error-modal').modal();
}
/**
* 将一个字节为单位的值转换为可读性更好的字符串
*
* @param {number} bytes 要转换的数值
* @returns 该值的字符串表示
*/
function getReadableSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
bytes = bytes / 1024;
return `${Math.floor(bytes)} KB`;
} else {
bytes = bytes / (1024 * 1024);
return `${Math.floor(bytes)} MB`;
}
}