video360-splitter/templates/index.html

512 lines
18 KiB
HTML
Raw 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="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>360° Video Splitter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 0.3rem;
color: #fff;
}
.subtitle {
color: #888;
margin-bottom: 2rem;
font-size: 0.95rem;
}
.card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #ccc;
}
.upload-zone {
border: 2px dashed #333;
border-radius: 8px;
padding: 3rem 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #5b8def;
background: rgba(91, 141, 239, 0.05);
}
.upload-zone .icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
.upload-zone p { color: #888; }
.upload-zone .filename {
color: #5b8def;
font-weight: 500;
margin-top: 0.5rem;
}
input[type="file"] { display: none; }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
font-size: 0.85rem;
color: #999;
font-weight: 500;
}
.field input, .field select {
background: #111;
border: 1px solid #333;
border-radius: 6px;
padding: 0.6rem 0.8rem;
color: #e0e0e0;
font-size: 0.95rem;
}
.field input:focus, .field select:focus {
outline: none;
border-color: #5b8def;
}
.field .hint {
font-size: 0.75rem;
color: #666;
}
.view-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.view-btn {
flex: 1;
padding: 0.7rem;
border: 1px solid #333;
border-radius: 8px;
background: #111;
color: #999;
cursor: pointer;
text-align: center;
transition: all 0.2s;
font-size: 0.9rem;
}
.view-btn:hover { border-color: #555; }
.view-btn.active {
border-color: #5b8def;
color: #5b8def;
background: rgba(91, 141, 239, 0.1);
}
.view-btn .num { font-size: 1.3rem; font-weight: 700; display: block; }
.preview-ring {
position: relative;
width: 200px;
height: 200px;
margin: 1rem auto;
}
.preview-ring svg { width: 100%; height: 100%; }
button.submit {
width: 100%;
padding: 0.9rem;
border: none;
border-radius: 8px;
background: #5b8def;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button.submit:hover { background: #4a7de0; }
button.submit:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
.progress-section { display: none; }
.progress-section.active { display: block; }
.progress-bar-bg {
width: 100%;
height: 8px;
background: #222;
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
height: 100%;
background: #5b8def;
border-radius: 4px;
transition: width 0.3s;
width: 0%;
}
.status-text {
font-size: 0.9rem;
color: #888;
}
.status-text .current {
color: #5b8def;
font-weight: 500;
}
.results { display: none; }
.results.active { display: block; }
.download-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.download-list li a {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.8rem 1rem;
background: #111;
border: 1px solid #2a2a2a;
border-radius: 8px;
color: #5b8def;
text-decoration: none;
transition: background 0.15s;
}
.download-list li a:hover {
background: #1a1a2a;
}
.download-list .dl-icon { font-size: 1.2rem; }
.error-msg {
color: #ef5b5b;
background: rgba(239, 91, 91, 0.1);
border: 1px solid rgba(239, 91, 91, 0.3);
border-radius: 8px;
padding: 1rem;
display: none;
}
.error-msg.active { display: block; }
</style>
</head>
<body>
<div class="container">
<h1>360° Video Splitter</h1>
<p class="subtitle">Split equirectangular 360° video into multiple flat perspective views</p>
<div class="card">
<h2>Upload Video</h2>
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
<div class="icon">🎬</div>
<p>Drop your 360° video here or click to browse</p>
<p class="filename" id="fileName"></p>
</div>
<input type="file" id="fileInput" accept="video/*">
</div>
<div class="card">
<h2>Number of Views</h2>
<div class="view-selector">
<div class="view-btn" data-views="2" onclick="setViews(2)">
<span class="num">2</span> Front / Back
</div>
<div class="view-btn" data-views="3" onclick="setViews(3)">
<span class="num">3</span> 120° each
</div>
<div class="view-btn active" data-views="4" onclick="setViews(4)">
<span class="num">4</span> Cardinal
</div>
<div class="view-btn" data-views="6" onclick="setViews(6)">
<span class="num">6</span> Cubemap
</div>
</div>
<svg id="previewSvg" viewBox="0 0 240 120" style="width:100%;max-width:500px;margin:0 auto;display:block">
<rect width="240" height="120" fill="#111" rx="4"/>
<!-- Equirect grid -->
<line x1="0" y1="60" x2="240" y2="60" stroke="#333" stroke-width="0.5"/>
<line x1="60" y1="0" x2="60" y2="120" stroke="#333" stroke-width="0.5"/>
<line x1="120" y1="0" x2="120" y2="120" stroke="#333" stroke-width="0.5"/>
<line x1="180" y1="0" x2="180" y2="120" stroke="#333" stroke-width="0.5"/>
<g id="previewSlices"></g>
</svg>
</div>
<div class="card">
<h2>Settings</h2>
<div class="settings-grid">
<div class="field">
<label>Horizontal FOV (°)</label>
<input type="number" id="hFov" value="90" min="30" max="180">
<span class="hint">Auto-set based on view count</span>
</div>
<div class="field">
<label>Vertical FOV (°)</label>
<input type="number" id="vFov" value="90" min="30" max="180">
</div>
<div class="field">
<label>Overlap (°)</label>
<input type="number" id="overlap" value="0" min="0" max="30">
<span class="hint">Extra degrees between views</span>
</div>
<div class="field">
<label>Output Resolution</label>
<select id="outputRes">
<option value="">Auto (from source)</option>
<option value="1920:1080">1920×1080</option>
<option value="1280:720">1280×720</option>
<option value="960:960">960×960</option>
<option value="1080:1080">1080×1080</option>
</select>
</div>
</div>
</div>
<button class="submit" id="submitBtn" onclick="startSplit()" disabled>
Select a video to start
</button>
<div class="card progress-section" id="progressSection">
<h2>Processing</h2>
<div class="progress-bar-bg">
<div class="progress-bar" id="progressBar"></div>
</div>
<p class="status-text">
<span id="statusText">Uploading...</span>
<span class="current" id="currentView"></span>
</p>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="card results" id="resultsSection">
<h2>Downloads</h2>
<ul class="download-list" id="downloadList"></ul>
</div>
</div>
<script>
let selectedFile = null;
let numViews = 4;
const colors = ['#5b8def','#ef5b8d','#8def5b','#efcf5b','#8d5bef','#5befc0'];
// Drag and drop
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('dragover', e => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
selectedFile = e.dataTransfer.files[0];
showFile();
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
selectedFile = fileInput.files[0];
showFile();
}
});
function showFile() {
document.getElementById('fileName').textContent = selectedFile.name;
const btn = document.getElementById('submitBtn');
btn.disabled = false;
btn.textContent = `Split into ${numViews} views`;
}
function setViews(n) {
numViews = n;
document.querySelectorAll('.view-btn').forEach(b => {
b.classList.toggle('active', parseInt(b.dataset.views) === n);
});
document.getElementById('hFov').value = Math.round(360 / n);
if (selectedFile) {
document.getElementById('submitBtn').textContent = `Split into ${n} views`;
}
updatePreview();
}
function updatePreview() {
const g = document.getElementById('previewSlices');
g.innerHTML = '';
const fov = parseFloat(document.getElementById('hFov').value) || (360/numViews);
const overlap = parseFloat(document.getElementById('overlap').value) || 0;
const effectiveFov = fov + overlap;
const step = 360 / numViews;
for (let i = 0; i < numViews; i++) {
const yaw = i * step;
const x = (yaw / 360) * 240;
const w = (effectiveFov / 360) * 240;
const color = colors[i % colors.length];
// Handle wrap-around
if (x + w > 240) {
const rect1 = document.createElementNS('http://www.w3.org/2000/svg','rect');
rect1.setAttribute('x', x);
rect1.setAttribute('y', 5);
rect1.setAttribute('width', 240 - x);
rect1.setAttribute('height', 110);
rect1.setAttribute('fill', color);
rect1.setAttribute('opacity', '0.2');
rect1.setAttribute('rx', '2');
g.appendChild(rect1);
const rect2 = document.createElementNS('http://www.w3.org/2000/svg','rect');
rect2.setAttribute('x', 0);
rect2.setAttribute('y', 5);
rect2.setAttribute('width', w - (240 - x));
rect2.setAttribute('height', 110);
rect2.setAttribute('fill', color);
rect2.setAttribute('opacity', '0.2');
rect2.setAttribute('rx', '2');
g.appendChild(rect2);
} else {
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
rect.setAttribute('x', x);
rect.setAttribute('y', 5);
rect.setAttribute('width', w);
rect.setAttribute('height', 110);
rect.setAttribute('fill', color);
rect.setAttribute('opacity', '0.2');
rect.setAttribute('rx', '2');
g.appendChild(rect);
}
// Label
const labelX = ((x + w/2) > 240) ? (x + w/2 - 240) : (x + w/2);
const text = document.createElementNS('http://www.w3.org/2000/svg','text');
text.setAttribute('x', Math.min(labelX, 235));
text.setAttribute('y', 65);
text.setAttribute('fill', color);
text.setAttribute('font-size', '10');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-weight', '600');
text.textContent = `${Math.round(yaw)}°`;
g.appendChild(text);
}
}
async function startSplit() {
if (!selectedFile) return;
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Uploading...';
document.getElementById('progressSection').classList.add('active');
document.getElementById('resultsSection').classList.remove('active');
document.getElementById('errorMsg').classList.remove('active');
document.getElementById('statusText').textContent = 'Uploading...';
document.getElementById('progressBar').style.width = '0%';
const formData = new FormData();
formData.append('video', selectedFile);
formData.append('num_views', numViews);
formData.append('h_fov', document.getElementById('hFov').value);
formData.append('v_fov', document.getElementById('vFov').value);
formData.append('overlap', document.getElementById('overlap').value);
formData.append('output_res', document.getElementById('outputRes').value);
try {
const resp = await fetch('/upload', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
showError(data.error);
return;
}
pollStatus(data.job_id);
} catch (e) {
showError('Upload failed: ' + e.message);
}
}
function pollStatus(jobId) {
const interval = setInterval(async () => {
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
document.getElementById('progressBar').style.width = data.progress + '%';
document.getElementById('statusText').textContent =
data.status === 'processing' ? 'Encoding: ' : data.status;
document.getElementById('currentView').textContent = data.current_view || '';
if (data.status === 'complete') {
clearInterval(interval);
showResults(jobId, data.output_files);
} else if (data.status === 'error') {
clearInterval(interval);
showError(data.error);
}
} catch (e) {
clearInterval(interval);
showError('Lost connection to server');
}
}, 1500);
}
function showResults(jobId, files) {
const btn = document.getElementById('submitBtn');
btn.disabled = false;
btn.textContent = `Split into ${numViews} views`;
document.getElementById('progressSection').classList.remove('active');
const results = document.getElementById('resultsSection');
results.classList.add('active');
const list = document.getElementById('downloadList');
list.innerHTML = '';
files.forEach(f => {
const li = document.createElement('li');
li.innerHTML = `<a href="/download/${jobId}/${f}">
<span class="dl-icon">⬇</span> ${f}
</a>`;
list.appendChild(li);
});
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.classList.add('active');
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = `Split into ${numViews} views`;
document.getElementById('progressSection').classList.remove('active');
}
// Init preview
updatePreview();
document.getElementById('hFov').addEventListener('input', updatePreview);
document.getElementById('overlap').addEventListener('input', updatePreview);
</script>
</body>
</html>