新增 汽车360度全景展示移动端Web应用

- 实现触摸滑动旋转查看汽车360度全景
- 添加惯性滑动效果和自动旋转展示功能
- 实现全屏沉浸式体验和精美Loading动画
- 添加Python视频帧提取脚本和浏览器端提取工具
- 包含本地HTTP服务器和完整项目文档
This commit is contained in:
2026-03-29 00:54:34 +08:00
commit 26defd9d69
345 changed files with 1513 additions and 0 deletions

456
extract.html Normal file
View File

@@ -0,0 +1,456 @@
<!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>