512 lines
18 KiB
HTML
512 lines
18 KiB
HTML
<!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>
|