Files
3D-pano/extract.html
袁涛 26defd9d69 新增 汽车360度全景展示移动端Web应用
- 实现触摸滑动旋转查看汽车360度全景
- 添加惯性滑动效果和自动旋转展示功能
- 实现全屏沉浸式体验和精美Loading动画
- 添加Python视频帧提取脚本和浏览器端提取工具
- 包含本地HTTP服务器和完整项目文档
2026-03-29 00:54:34 +08:00

457 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>