feat: add http vedio test

This commit is contained in:
alivender
2025-06-18 18:39:58 +08:00
parent 81f91b2b71
commit 4c14ada97b
8 changed files with 1365 additions and 13 deletions

View File

@@ -2189,3 +2189,144 @@ function throwException(message: string, status: number, response: string, heade
else
throw new ApiException(message, status, response, headers, null);
}
// VideoStreamClient - 手动添加用于HTTP视频流控制
export class VideoStreamClient {
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
private baseUrl: string;
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
this.http = http ? http : window as any;
this.baseUrl = baseUrl ?? "http://localhost:5000";
} /**
* 获取HTTP视频流服务状态
* @return HTTP视频流服务状态信息
*/
status(): Promise<any> {
let url_ = this.baseUrl + "/api/VideoStream/Status";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processStatus(_response);
});
}
protected processStatus(response: Response): Promise<any> {
const status = response.status;
let _headers: any = {};
if (response.headers && response.headers.forEach) {
response.headers.forEach((v: any, k: any) => _headers[k] = v);
}
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
return result200;
});
} else if (status === 500) {
return response.text().then((_responseText) => {
throw new Error(_responseText);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
throw new Error("未知的响应状态码: " + status + ", 响应文本: " + _responseText);
});
}
return Promise.resolve<any>(null as any);
} /**
* 获取HTTP视频流信息
* @return HTTP视频流信息
*/
streamInfo(): Promise<any> {
let url_ = this.baseUrl + "/api/VideoStream/StreamInfo";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processStreamInfo(_response);
});
}
protected processStreamInfo(response: Response): Promise<any> {
const status = response.status;
let _headers: any = {};
if (response.headers && response.headers.forEach) {
response.headers.forEach((v: any, k: any) => _headers[k] = v);
}
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
return result200;
});
} else if (status === 500) {
return response.text().then((_responseText) => {
throw new Error(_responseText);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
throw new Error("未知的响应状态码: " + status + ", 响应文本: " + _responseText);
});
}
return Promise.resolve<any>(null as any);
} /**
* 测试HTTP视频流连接
* @return 测试结果
*/
testConnection(): Promise<boolean> {
let url_ = this.baseUrl + "/api/VideoStream/TestConnection";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processTestConnection(_response);
});
}
protected processTestConnection(response: Response): Promise<boolean> {
const status = response.status;
let _headers: any = {};
if (response.headers && response.headers.forEach) {
response.headers.forEach((v: any, k: any) => _headers[k] = v);
}
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
return result200;
});
} else if (status === 500) {
return response.text().then((_responseText) => {
throw new Error(_responseText);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
throw new Error("未知的响应状态码: " + status + ", 响应文本: " + _responseText);
});
}
return Promise.resolve<boolean>(false);
}
}

View File

@@ -39,8 +39,7 @@
</svg>
工程界面
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
</li> <li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/test" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
@@ -49,6 +48,14 @@
测试功能
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/video-stream" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
HTTP视频流 </router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/markdown-test" class="text-base font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" viewBox="0 0 24 24" fill="none"

View File

@@ -6,6 +6,7 @@ import ProjectView from '../views/ProjectView.vue'
import TestView from '../views/TestView.vue'
import UserView from '../views/UserView.vue'
import AdminView from '../views/AdminView.vue'
import VideoStreamView from '../views/VideoStreamView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -16,7 +17,7 @@ const router = createRouter({
{path: '/project',name: 'project',component: ProjectView},
{path: '/test', name: 'test', component: TestView},
{path: '/user', name: 'user', component: UserView},
{path: '/admin', name: 'admin', component: AdminView}]
{path: '/admin', name: 'admin', component: AdminView}, {path: '/video-stream',name: 'video-stream',component: VideoStreamView}]
})
export default router

View File

@@ -0,0 +1,505 @@
<template>
<div class="min-h-screen bg-base-100">
<!-- 标题栏 -->
<div class="bg-base-200 p-4 border-b border-base-300">
<h1 class="text-2xl font-bold text-base-content">HTTP 视频流</h1>
<p class="text-base-content/70 mt-1">FPGA WebLab 视频流传输功能</p>
</div>
<div class="container mx-auto p-6 space-y-6">
<!-- 控制面板 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"/>
</svg>
控制面板
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 服务状态 -->
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-primary">
<div class="badge" :class="statusInfo.isRunning ? 'badge-success' : 'badge-error'">
{{ statusInfo.isRunning ? '运行中' : '已停止' }}
</div>
</div>
<div class="stat-title">服务状态</div>
<div class="stat-value text-primary">HTTP</div>
<div class="stat-desc">端口: {{ statusInfo.serverPort }}</div>
</div>
</div>
<!-- 流信息 -->
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-secondary">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</div>
<div class="stat-title">视频规格</div>
<div class="stat-value text-secondary">{{ streamInfo.frameWidth }}×{{ streamInfo.frameHeight }}</div>
<div class="stat-desc">{{ streamInfo.frameRate }} FPS</div>
</div>
</div>
<!-- 连接数 -->
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-accent">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<div class="stat-title">连接数</div>
<div class="stat-value text-accent">{{ statusInfo.connectedClients }}</div>
<div class="stat-desc">
<div class="dropdown dropdown-hover">
<div tabindex="0" role="button" class="text-xs underline cursor-help">查看客户端</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<li v-for="(client, index) in statusInfo.clientEndpoints" :key="index" class="text-xs">
<a>{{ client }}</a>
</li>
<li v-if="!statusInfo.clientEndpoints || statusInfo.clientEndpoints.length === 0">
<a class="text-xs opacity-50">无活跃连接</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-actions justify-end mt-4">
<button
class="btn btn-outline btn-primary"
@click="refreshStatus"
:disabled="loading"
>
<svg v-if="loading" class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ loading ? '刷新中...' : '刷新状态' }}
</button>
<button
class="btn btn-primary"
@click="testConnection"
:disabled="testing"
>
<svg v-if="testing" class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ testing ? '测试中...' : '测试连接' }}
</button>
</div>
</div>
</div>
<!-- 视频预览区域 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
视频预览
</h2>
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 4/3;">
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div v-show="isPlaying" class="w-full h-full flex items-center justify-center">
<img
:src="currentVideoSource"
alt="视频流"
class="max-w-full max-h-full object-contain"
@error="handleVideoError"
@load="handleVideoLoad"
/>
</div>
<!-- 错误信息显示 -->
<div v-if="hasVideoError" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70">
<div class="card bg-error text-white shadow-lg w-full max-w-lg">
<div class="card-body">
<h3 class="card-title flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
视频流加载失败
</h3>
<p>无法连接到视频服务器请检查以下内容</p>
<ul class="list-disc list-inside">
<li>视频流服务是否已启动</li>
<li>网络连接是否正常</li>
<li>端口 {{ statusInfo.serverPort }} 是否可访问</li>
</ul>
<div class="card-actions justify-end mt-2">
<button class="btn btn-sm btn-outline btn-primary" @click="tryReconnect">重试连接</button>
</div>
</div>
</div>
</div>
<!-- 占位符 -->
<div
v-show="!isPlaying && !hasVideoError"
class="absolute inset-0 flex items-center justify-center text-white"
>
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<p class="text-lg opacity-75">{{ videoStatus }}</p>
<p class="text-sm opacity-60 mt-2">点击"播放视频流"按钮开始查看实时视频</p>
</div>
</div>
</div>
<!-- 视频控制 -->
<div class="flex justify-between items-center mt-4">
<div class="text-sm text-base-content/70">
流地址: <code class="bg-base-300 px-2 py-1 rounded">{{ streamInfo.mjpegUrl }}</code>
</div>
<div class="space-x-2">
<div class="dropdown dropdown-hover dropdown-top dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-outline btn-accent">
<svg class="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
更多功能
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<li><a @click="openInNewTab(streamInfo.htmlUrl)">在新标签打开视频页面</a></li>
<li><a @click="takeSnapshot">获取并下载快照</a></li>
<li><a @click="copyToClipboard(streamInfo.mjpegUrl)">复制MJPEG地址</a></li>
</ul>
</div>
<button
class="btn btn-success btn-sm"
@click="startStream"
:disabled="isPlaying"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
播放视频流
</button>
<button
class="btn btn-error btn-sm"
@click="stopStream"
:disabled="!isPlaying"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
停止视频流
</button>
</div>
</div>
</div>
</div>
<!-- 日志区域 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
操作日志
</h2>
<div class="bg-base-300 rounded-lg p-4 h-48 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="text-sm font-mono mb-1">
<span class="text-base-content/50">[{{ formatTime(log.time) }}]</span>
<span :class="getLogClass(log.level)">{{ log.message }}</span>
</div>
<div v-if="logs.length === 0" class="text-base-content/50 text-center py-8">
暂无日志记录
</div>
</div>
<div class="card-actions justify-end mt-2">
<button class="btn btn-outline btn-sm" @click="clearLogs">
清空日志
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { VideoStreamClient } from '@/APIClient'
// 状态管理
const loading = ref(false)
const testing = ref(false)
const isPlaying = ref(false)
const hasVideoError = ref(false)
const videoStatus = ref('点击"播放视频流"按钮开始查看实时视频')
// 数据
const statusInfo = ref({
isRunning: false,
serverPort: 8080,
streamUrl: '',
mjpegUrl: '',
snapshotUrl: '',
connectedClients: 0,
clientEndpoints: [] as string[]
})
const streamInfo = ref({
frameRate: 30,
frameWidth: 640,
frameHeight: 480,
format: 'MJPEG',
htmlUrl: '',
mjpegUrl: '',
snapshotUrl: ''
})
const currentVideoSource = ref('')
const logs = ref<Array<{time: Date, level: string, message: string}>>([])
// API 客户端
const videoClient = new VideoStreamClient()
// 添加日志
const addLog = (level: string, message: string) => {
logs.value.push({
time: new Date(),
level,
message
})
// 限制日志数量
if (logs.value.length > 100) {
logs.value.shift()
}
}
// 格式化时间
const formatTime = (time: Date) => {
return time.toLocaleTimeString()
}
// 获取日志样式
const getLogClass = (level: string) => {
switch (level) {
case 'error': return 'text-error'
case 'warning': return 'text-warning'
case 'success': return 'text-success'
default: return 'text-base-content'
}
}
// 清空日志
const clearLogs = () => {
logs.value = []
addLog('info', '日志已清空')
}
// 复制到剪贴板
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
addLog('success', '已复制到剪贴板');
})
.catch((err) => {
addLog('error', `复制失败: ${err}`);
});
}
// 在新标签中打开视频页面
const openInNewTab = (url: string) => {
window.open(url, '_blank');
addLog('info', `已在新标签打开视频页面: ${url}`);
}
// 获取并下载快照
const takeSnapshot = async () => {
try {
addLog('info', '正在获取快照...');
// 使用当前的快照URL
const snapshotUrl = streamInfo.value.snapshotUrl;
if (!snapshotUrl) {
addLog('error', '快照URL不可用');
return;
}
// 添加时间戳防止缓存
const urlWithTimestamp = `${snapshotUrl}?t=${new Date().getTime()}`;
// 创建一个临时链接下载图片
const a = document.createElement('a');
a.href = urlWithTimestamp;
a.download = `fpga-snapshot-${new Date().toISOString().replace(/:/g, '-')}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
addLog('success', '快照已下载');
} catch (error) {
addLog('error', `获取快照失败: ${error}`);
console.error('获取快照失败:', error);
}
}
// 刷新状态
const refreshStatus = async () => {
loading.value = true
try {
addLog('info', '正在获取服务状态...')
const status = await videoClient.status()
statusInfo.value = status
const info = await videoClient.streamInfo()
streamInfo.value = info
addLog('success', '服务状态获取成功')
} catch (error) {
addLog('error', `获取状态失败: ${error}`)
console.error('获取状态失败:', error)
} finally {
loading.value = false
}
}
// 测试连接
const testConnection = async () => {
testing.value = true
try {
addLog('info', '正在测试视频流连接...')
const result = await videoClient.testConnection()
if (result) {
addLog('success', '视频流连接测试成功')
} else {
addLog('error', '视频流连接测试失败')
}
} catch (error) {
addLog('error', `连接测试失败: ${error}`)
console.error('连接测试失败:', error)
} finally {
testing.value = false
}
}
// 视频错误处理
const handleVideoError = () => {
if (isPlaying.value) {
hasVideoError.value = true
addLog('error', '视频流加载失败')
}
}
// 视频加载成功处理
const handleVideoLoad = () => {
hasVideoError.value = false
addLog('success', '视频流加载成功')
}
// 尝试重新连接
const tryReconnect = () => {
addLog('info', '尝试重新连接视频流...')
hasVideoError.value = false
// 重新设置视频源,添加时间戳避免缓存问题
currentVideoSource.value = `${streamInfo.value.mjpegUrl}?t=${new Date().getTime()}`
}
// 启动视频流
const startStream = async () => {
try {
addLog('info', '正在启动视频流...')
videoStatus.value = '正在连接视频流...'
// 刷新状态
await refreshStatus()
// 设置视频源
currentVideoSource.value = streamInfo.value.mjpegUrl
// 设置播放状态
isPlaying.value = true
hasVideoError.value = false
addLog('success', '视频流已启动')
} catch (error) {
addLog('error', `启动视频流失败: ${error}`)
videoStatus.value = '启动视频流失败'
console.error('启动视频流失败:', error)
}
}
// 停止视频流
const stopStream = () => {
try {
addLog('info', '正在停止视频流...')
// 清除视频源
currentVideoSource.value = ''
// 更新状态
isPlaying.value = false
hasVideoError.value = false
videoStatus.value = '点击"播放视频流"按钮开始查看实时视频'
addLog('success', '视频流已停止')
} catch (error) {
addLog('error', `停止视频流失败: ${error}`)
console.error('停止视频流失败:', error)
}
}
// 生命周期
onMounted(async () => {
addLog('info', 'HTTP 视频流页面已加载')
await refreshStatus()
})
onUnmounted(() => {
stopStream()
})
</script>
<style scoped>
/* 自定义样式 */
.stats {
background-color: var(--b1);
color: var(--bc); /* 添加适配文本颜色 */
}
code {
font-size: 0.75rem;
}
img {
/* 确保视频流居中显示 */
margin: 0 auto;
}
* {
transition: all 500ms ease-in-out;
}
</style>