- 实现触摸滑动旋转查看汽车360度全景 - 添加惯性滑动效果和自动旋转展示功能 - 实现全屏沉浸式体验和精美Loading动画 - 添加Python视频帧提取脚本和浏览器端提取工具 - 包含本地HTTP服务器和完整项目文档
457 lines
15 KiB
HTML
457 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>视频序列帧提取工具</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
color: #fff;
|
||
}
|
||
|
||
.container {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
h1 {
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
background: linear-gradient(135deg, #00d4ff, #ff6b35);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.card {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 16px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.video-container {
|
||
position: relative;
|
||
width: 100%;
|
||
background: #000;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
video {
|
||
width: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.control-group {
|
||
flex: 1;
|
||
min-width: 150px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
input[type="number"] {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 8px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
}
|
||
|
||
input[type="number"]:focus {
|
||
outline: none;
|
||
border-color: #00d4ff;
|
||
}
|
||
|
||
.btn {
|
||
padding: 12px 24px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #00d4ff, #0099cc);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.progress-container {
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 8px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #00d4ff, #ff6b35);
|
||
width: 0%;
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
.progress-text {
|
||
text-align: center;
|
||
margin-top: 10px;
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.preview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||
gap: 10px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.preview-item {
|
||
position: relative;
|
||
aspect-ratio: 16/9;
|
||
background: #000;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.preview-item img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.preview-item .frame-num {
|
||
position: absolute;
|
||
bottom: 2px;
|
||
right: 2px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: #fff;
|
||
font-size: 10px;
|
||
padding: 1px 4px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.status {
|
||
text-align: center;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.status.success {
|
||
background: rgba(0, 200, 100, 0.2);
|
||
color: #00c864;
|
||
}
|
||
|
||
.status.error {
|
||
background: rgba(255, 100, 100, 0.2);
|
||
color: #ff6464;
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>视频序列帧提取工具</h1>
|
||
|
||
<div class="card">
|
||
<div class="video-container">
|
||
<video id="video" src="video.mp4" controls></video>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<div class="control-group">
|
||
<label>提取帧数</label>
|
||
<input type="number" id="frameCount" value="36" min="1" max="360">
|
||
</div>
|
||
<div class="control-group">
|
||
<label>输出宽度 (px)</label>
|
||
<input type="number" id="outputWidth" value="800" min="100" max="2000">
|
||
</div>
|
||
<div class="control-group">
|
||
<label>输出质量 (1-100)</label>
|
||
<input type="number" id="quality" value="90" min="1" max="100">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn btn-primary" id="extractBtn">开始提取</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="progress-container hidden" id="progressContainer">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="progressFill"></div>
|
||
</div>
|
||
<div class="progress-text" id="progressText">准备中...</div>
|
||
</div>
|
||
|
||
<div id="statusBox" class="status hidden"></div>
|
||
|
||
<div class="card hidden" id="previewCard">
|
||
<h3 style="margin-bottom: 15px;">提取预览</h3>
|
||
<div class="preview-grid" id="previewGrid"></div>
|
||
<div class="actions">
|
||
<button class="btn btn-secondary" id="downloadAllBtn">打包下载 (ZIP)</button>
|
||
<button class="btn btn-primary" id="useFramesBtn">应用到展示页</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||
<script>
|
||
class FrameExtractor {
|
||
constructor() {
|
||
this.video = document.getElementById('video');
|
||
this.canvas = document.createElement('canvas');
|
||
this.ctx = this.canvas.getContext('2d');
|
||
this.frames = [];
|
||
|
||
this.bindEvents();
|
||
}
|
||
|
||
bindEvents() {
|
||
document.getElementById('extractBtn').addEventListener('click', () => this.extract());
|
||
document.getElementById('downloadAllBtn').addEventListener('click', () => this.downloadAll());
|
||
document.getElementById('useFramesBtn').addEventListener('click', () => this.useFrames());
|
||
}
|
||
|
||
async extract() {
|
||
const frameCount = parseInt(document.getElementById('frameCount').value) || 36;
|
||
const outputWidth = parseInt(document.getElementById('outputWidth').value) || 800;
|
||
const quality = (parseInt(document.getElementById('quality').value) || 90) / 100;
|
||
|
||
this.frames = [];
|
||
this.video.currentTime = 0;
|
||
|
||
const duration = this.video.duration;
|
||
const interval = duration / frameCount;
|
||
|
||
// 设置 canvas 尺寸
|
||
const aspectRatio = this.video.videoHeight / this.video.videoWidth;
|
||
this.canvas.width = outputWidth;
|
||
this.canvas.height = Math.round(outputWidth * aspectRatio);
|
||
|
||
// 显示进度
|
||
document.getElementById('progressContainer').classList.remove('hidden');
|
||
document.getElementById('previewCard').classList.add('hidden');
|
||
this.updateProgress(0, '准备提取...');
|
||
|
||
try {
|
||
for (let i = 0; i < frameCount; i++) {
|
||
const time = i * interval;
|
||
await this.seekTo(time);
|
||
const frame = await this.captureFrame(i, quality);
|
||
this.frames.push(frame);
|
||
|
||
this.updateProgress(
|
||
((i + 1) / frameCount) * 100,
|
||
`提取中... ${i + 1}/${frameCount}`
|
||
);
|
||
}
|
||
|
||
this.showStatus('success', `成功提取 ${frameCount} 帧!`);
|
||
this.showPreview();
|
||
} catch (error) {
|
||
this.showStatus('error', `提取失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
seekTo(time) {
|
||
return new Promise((resolve) => {
|
||
this.video.currentTime = time;
|
||
this.video.onseeked = () => resolve();
|
||
});
|
||
}
|
||
|
||
captureFrame(index, quality) {
|
||
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
|
||
const dataUrl = this.canvas.toDataURL('image/jpeg', quality);
|
||
return { index, dataUrl };
|
||
}
|
||
|
||
updateProgress(percent, text) {
|
||
document.getElementById('progressFill').style.width = `${percent}%`;
|
||
document.getElementById('progressText').textContent = text;
|
||
}
|
||
|
||
showStatus(type, message) {
|
||
const box = document.getElementById('statusBox');
|
||
box.className = `status ${type}`;
|
||
box.textContent = message;
|
||
box.classList.remove('hidden');
|
||
}
|
||
|
||
showPreview() {
|
||
const grid = document.getElementById('previewGrid');
|
||
grid.innerHTML = '';
|
||
|
||
this.frames.forEach((frame, i) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'preview-item';
|
||
item.innerHTML = `
|
||
<img src="${frame.dataUrl}" alt="Frame ${i + 1}">
|
||
<span class="frame-num">${i + 1}</span>
|
||
`;
|
||
grid.appendChild(item);
|
||
});
|
||
|
||
document.getElementById('previewCard').classList.remove('hidden');
|
||
document.getElementById('progressContainer').classList.add('hidden');
|
||
}
|
||
|
||
async downloadAll() {
|
||
if (this.frames.length === 0) return;
|
||
|
||
this.updateProgress(0, '打包中...');
|
||
document.getElementById('progressContainer').classList.remove('hidden');
|
||
|
||
const zip = new JSZip();
|
||
const folder = zip.folder('images');
|
||
|
||
this.frames.forEach((frame, i) => {
|
||
const frameNum = String(i + 1).padStart(3, '0');
|
||
const base64 = frame.dataUrl.split(',')[1];
|
||
folder.file(`car_${frameNum}.jpg`, base64, { base64: true });
|
||
|
||
this.updateProgress(
|
||
((i + 1) / this.frames.length) * 50,
|
||
`打包中... ${i + 1}/${this.frames.length}`
|
||
);
|
||
});
|
||
|
||
this.updateProgress(50, '生成 ZIP 文件...');
|
||
|
||
const blob = await zip.generateAsync({ type: 'blob' }, (metadata) => {
|
||
this.updateProgress(50 + metadata.percent / 2, '生成 ZIP 文件...');
|
||
});
|
||
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = 'car_frames.zip';
|
||
link.click();
|
||
|
||
this.updateProgress(100, '下载完成!');
|
||
setTimeout(() => {
|
||
document.getElementById('progressContainer').classList.add('hidden');
|
||
}, 2000);
|
||
}
|
||
|
||
async useFrames() {
|
||
if (this.frames.length === 0) return;
|
||
|
||
this.showStatus('success', '正在保存数据...');
|
||
|
||
const framesData = this.frames.map(f => f.dataUrl);
|
||
|
||
// 直接使用 IndexedDB 存储
|
||
try {
|
||
await this.saveToIndexedDB(framesData);
|
||
this.showStatus('success', '已保存!正在跳转到展示页面...');
|
||
|
||
setTimeout(() => {
|
||
window.location.href = 'index.html';
|
||
}, 500);
|
||
} catch (e) {
|
||
console.error('IndexedDB save failed:', e);
|
||
this.showStatus('error', '保存失败,请尝试下载ZIP文件后手动解压到images文件夹');
|
||
}
|
||
}
|
||
|
||
saveToIndexedDB(frames) {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open('Car360DB', 1);
|
||
|
||
request.onerror = () => reject(request.error);
|
||
|
||
request.onupgradeneeded = (e) => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains('frames')) {
|
||
db.createObjectStore('frames', { keyPath: 'id' });
|
||
}
|
||
};
|
||
|
||
request.onsuccess = () => {
|
||
const db = request.result;
|
||
const tx = db.transaction('frames', 'readwrite');
|
||
const store = tx.objectStore('frames');
|
||
store.put({ id: 'frames', data: frames });
|
||
|
||
tx.oncomplete = () => {
|
||
console.log('IndexedDB save complete');
|
||
resolve();
|
||
};
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
new FrameExtractor();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|