新增 汽车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

515
app.js Normal file
View File

@@ -0,0 +1,515 @@
/**
* 汽车360°全景展示应用
* 支持触摸滑动、自动旋转、惯性效果
*/
class Car360Viewer {
constructor() {
// 配置
this.config = {
totalFrames: 336, // 总帧数
framesPerSwipe: 1, // 每像素滑动切换的帧数
inertiaFriction: 0.95, // 惯性摩擦系数
minVelocity: 0.1, // 最小速度阈值
autoRotateSpeed: 80, // 自动旋转速度ms/帧)
autoRotateDelay: 3000, // 停止操作后自动旋转延迟ms
imagePath: './images/', // 图片路径
imagePrefix: 'car_', // 图片前缀
imageExt: '.jpg' // 图片扩展名
};
// 状态
this.state = {
currentFrame: 0,
isDragging: false,
startX: 0,
lastX: 0,
velocity: 0,
images: [],
loaded: false,
animationId: null,
autoRotateTimer: null,
autoRotateInterval: null
};
// DOM元素
this.elements = {
canvas: document.getElementById('carCanvas'),
loadingScreen: document.getElementById('loadingScreen'),
loadingProgress: document.getElementById('loadingProgress'),
loadingText: document.getElementById('loadingText')
};
this.ctx = this.elements.canvas.getContext('2d');
this.init();
}
init() {
this.setupCanvas();
this.bindEvents();
this.loadImages();
}
setupCanvas() {
const rect = this.elements.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.elements.canvas.width = rect.width * dpr;
this.elements.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
// 监听窗口大小变化
window.addEventListener('resize', () => {
const newRect = this.elements.canvas.getBoundingClientRect();
this.elements.canvas.width = newRect.width * dpr;
this.elements.canvas.height = newRect.height * dpr;
this.ctx.scale(dpr, dpr);
this.renderFrame(this.state.currentFrame);
});
}
bindEvents() {
const viewer = this.elements.canvas;
// 触摸事件
viewer.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
viewer.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
viewer.addEventListener('touchend', this.handleEnd.bind(this), { passive: false });
}
handleStart(e) {
e.preventDefault();
this.stopInertia();
this.stopAutoRotate();
const touch = e.touches[0];
this.state.isDragging = true;
this.state.startX = touch.clientX;
this.state.lastX = touch.clientX;
this.state.velocity = 0;
}
handleMove(e) {
if (!this.state.isDragging) return;
e.preventDefault();
const touch = e.touches[0];
const deltaX = touch.clientX - this.state.lastX;
// 计算速度
this.state.velocity = deltaX;
// 根据滑动距离更新帧
const frameDelta = Math.round(deltaX / 5);
if (frameDelta !== 0) {
this.state.lastX = touch.clientX;
this.updateFrame(frameDelta);
}
}
handleEnd(e) {
if (!this.state.isDragging) return;
this.state.isDragging = false;
// 启动惯性动画
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
this.startInertia();
} else {
// 没有惯性,延迟启动自动旋转
this.scheduleAutoRotate();
}
}
startInertia() {
const animate = () => {
this.state.velocity *= this.config.inertiaFriction;
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
const frameDelta = Math.round(this.state.velocity / 5);
if (frameDelta !== 0) {
this.updateFrame(frameDelta);
}
this.state.animationId = requestAnimationFrame(animate);
} else {
// 惯性结束,启动自动旋转
this.scheduleAutoRotate();
}
};
this.state.animationId = requestAnimationFrame(animate);
}
stopInertia() {
if (this.state.animationId) {
cancelAnimationFrame(this.state.animationId);
this.state.animationId = null;
}
this.state.velocity = 0;
}
updateFrame(delta) {
let newFrame = this.state.currentFrame + delta;
// 循环处理
while (newFrame < 0) newFrame += this.config.totalFrames;
newFrame = newFrame % this.config.totalFrames;
this.state.currentFrame = newFrame;
this.renderFrame(newFrame);
}
async loadImages() {
// 首先尝试加载文件系统图片
try {
const images = [];
let loadedCount = 0;
this.updateLoadingProgress(0, '正在加载图片资源...');
for (let i = 0; i < this.config.totalFrames; i++) {
const img = new Image();
const frameNum = String(i + 1).padStart(3, '0');
img.src = `${this.config.imagePath}${this.config.imagePrefix}${frameNum}${this.config.imageExt}`;
await new Promise((resolve, reject) => {
img.onload = () => {
loadedCount++;
const progress = Math.round((loadedCount / this.config.totalFrames) * 100);
this.updateLoadingProgress(progress, `加载中 ${loadedCount}/${this.config.totalFrames}`);
resolve(img);
};
img.onerror = () => reject(new Error(`Failed to load frame ${i}`));
});
images.push(img);
}
this.updateLoadingProgress(100, '加载完成');
this.state.images = images;
this.state.loaded = true;
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
console.log('Loaded', images.length, 'frames from files');
return;
} catch (error) {
console.log('File load failed:', error.message);
}
// 尝试从 IndexedDB 加载
this.updateLoadingProgress(0, '正在从缓存加载...');
const indexedDBFrames = await this.loadFromIndexedDB();
if (indexedDBFrames && indexedDBFrames.length > 0) {
this.state.images = indexedDBFrames;
this.state.loaded = true;
this.config.totalFrames = indexedDBFrames.length;
this.updateLoadingProgress(100, '加载完成');
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
return;
}
// 演示模式
console.log('Using demo mode');
this.updateLoadingProgress(100, '进入演示模式');
setTimeout(() => {
this.hideLoading();
this.startDemoMode();
}, 500);
}
updateLoadingProgress(percent, text) {
if (this.elements.loadingProgress) {
this.elements.loadingProgress.style.width = `${percent}%`;
}
if (this.elements.loadingText) {
this.elements.loadingText.textContent = text;
}
}
hideLoading() {
if (this.elements.loadingScreen) {
this.elements.loadingScreen.classList.add('hidden');
}
// 加载完成后启动自动旋转
this.startAutoRotate();
}
scheduleAutoRotate() {
// 清除之前的定时器
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
}
// 延迟启动自动旋转
this.state.autoRotateTimer = setTimeout(() => {
this.startAutoRotate();
}, this.config.autoRotateDelay);
}
startAutoRotate() {
// 清除之前的自动旋转
this.stopAutoRotate();
// 启动新的自动旋转
this.state.autoRotateInterval = setInterval(() => {
this.updateFrame(1);
}, this.config.autoRotateSpeed);
}
stopAutoRotate() {
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
this.state.autoRotateTimer = null;
}
if (this.state.autoRotateInterval) {
clearInterval(this.state.autoRotateInterval);
this.state.autoRotateInterval = null;
}
}
loadFromIndexedDB() {
return new Promise((resolve) => {
const request = indexedDB.open('Car360DB', 1);
request.onerror = () => {
console.log('IndexedDB error');
resolve(null);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('frames')) {
db.createObjectStore('frames', { keyPath: 'id' });
}
};
request.onsuccess = async () => {
const db = request.result;
try {
if (!db.objectStoreNames.contains('frames')) {
db.close();
resolve(null);
return;
}
const tx = db.transaction('frames', 'readonly');
const store = tx.objectStore('frames');
const getRequest = store.get('frames');
getRequest.onsuccess = async () => {
if (getRequest.result && getRequest.result.data && getRequest.result.data.length > 0) {
console.log('Loading', getRequest.result.data.length, 'frames from IndexedDB');
const images = getRequest.result.data.map(dataUrl => {
const img = new Image();
img.src = dataUrl;
return img;
});
await Promise.all(images.map(img =>
new Promise(res => {
img.onload = () => res();
img.onerror = () => res();
})
));
db.close();
resolve(images);
} else {
console.log('No frames data in IndexedDB');
db.close();
resolve(null);
}
};
getRequest.onerror = () => {
console.log('Failed to get frames from IndexedDB');
db.close();
resolve(null);
};
} catch (e) {
console.log('IndexedDB exception:', e);
db.close();
resolve(null);
}
};
});
}
startDemoMode() {
// 演示模式:生成占位图形
this.state.loaded = true;
this.renderFrame(0);
}
renderFrame(frameIndex) {
const canvas = this.elements.canvas;
const ctx = this.ctx;
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
const img = this.state.images[frameIndex];
if (img && img.complete && img.naturalWidth > 0) {
// 绘制真实图片
const scale = Math.max(width / img.naturalWidth, height / img.naturalHeight);
const x = (width - img.naturalWidth * scale) / 2;
const y = (height - img.naturalHeight * scale) / 2;
ctx.drawImage(img, x, y, img.naturalWidth * scale, img.naturalHeight * scale);
} else {
// 演示模式:绘制占位图形
this.drawDemoFrame(ctx, width, height, frameIndex);
}
}
drawDemoFrame(ctx, width, height, frameIndex) {
const angle = (frameIndex / this.config.totalFrames) * Math.PI * 2;
// 背景渐变
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// 网格效果
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
ctx.lineWidth = 1;
const gridSize = 30;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// 绘制示意汽车简化的3D效果
const centerX = width / 2;
const centerY = height / 2;
// 车身主体
ctx.save();
ctx.translate(centerX, centerY);
// 阴影
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.ellipse(0, height * 0.25, width * 0.35, height * 0.08, 0, 0, Math.PI * 2);
ctx.fill();
// 车身渐变
const bodyGradient = ctx.createLinearGradient(0, -height * 0.3, 0, height * 0.2);
bodyGradient.addColorStop(0, '#4a4a5a');
bodyGradient.addColorStop(0.5, '#2a2a3a');
bodyGradient.addColorStop(1, '#1a1a2a');
// 车身形状(根据角度变化)
const perspective = Math.cos(angle);
const bodyWidth = width * 0.35 * Math.abs(perspective) + width * 0.15;
const bodyHeight = height * 0.18;
ctx.fillStyle = bodyGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth, -bodyHeight, bodyWidth * 2, bodyHeight * 2, 10);
ctx.fill();
// 车顶
const roofGradient = ctx.createLinearGradient(0, -height * 0.35, 0, -height * 0.15);
roofGradient.addColorStop(0, '#5a5a6a');
roofGradient.addColorStop(1, '#3a3a4a');
ctx.fillStyle = roofGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth * 0.7, -height * 0.35, bodyWidth * 1.4, height * 0.2, [15, 15, 5, 5]);
ctx.fill();
// 车窗
ctx.fillStyle = 'rgba(0, 212, 255, 0.3)';
ctx.beginPath();
const windowWidth = bodyWidth * 0.5;
ctx.roundRect(-windowWidth, -height * 0.32, windowWidth * 2, height * 0.12, 5);
ctx.fill();
// 车灯光效
const lightIntensity = 0.5 + Math.sin(angle * 2) * 0.3;
// 左车灯
const leftLightGradient = ctx.createRadialGradient(
-bodyWidth * 0.8, 0, 0,
-bodyWidth * 0.8, 0, 30
);
leftLightGradient.addColorStop(0, `rgba(255, 220, 100, ${lightIntensity})`);
leftLightGradient.addColorStop(1, 'rgba(255, 220, 100, 0)');
ctx.fillStyle = leftLightGradient;
ctx.beginPath();
ctx.arc(-bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 右车灯
const rightLightGradient = ctx.createRadialGradient(
bodyWidth * 0.8, 0, 0,
bodyWidth * 0.8, 0, 30
);
rightLightGradient.addColorStop(0, `rgba(255, 100, 100, ${lightIntensity})`);
rightLightGradient.addColorStop(1, 'rgba(255, 100, 100, 0)');
ctx.fillStyle = rightLightGradient;
ctx.beginPath();
ctx.arc(bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 车轮
ctx.fillStyle = '#1a1a2a';
ctx.strokeStyle = '#3a3a4a';
ctx.lineWidth = 3;
// 左轮
ctx.beginPath();
ctx.ellipse(-bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 右轮
ctx.beginPath();
ctx.ellipse(bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
// 帧号显示
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText(`${frameIndex + 1}/${this.config.totalFrames}`, centerX, height - 20);
// 演示模式提示
ctx.fillStyle = 'rgba(0, 212, 255, 0.6)';
ctx.font = '12px sans-serif';
ctx.fillText('演示模式 - 请添加实际序列帧图片到 images 文件夹', centerX, 30);
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.carViewer = new Car360Viewer();
});