Initial commit: 360° video splitter web app
Flask + FFmpeg app that splits equirectangular 360° video into multiple flat perspective views (2/3/4/6) with configurable FOV, overlap, and resolution. Deployed at 360split.jeffemmett.com. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
65f663a461
|
|
@ -0,0 +1,5 @@
|
|||
uploads/
|
||||
output/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
uploads/
|
||||
output/
|
||||
.env
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data/uploads /data/output
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "-w", "2", "--threads", "4", "-b", "0.0.0.0:5000", "--timeout", "600", "app:app"]
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["UPLOAD_FOLDER"] = "/data/uploads"
|
||||
app.config["OUTPUT_FOLDER"] = "/data/output"
|
||||
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 * 1024 # 10GB
|
||||
|
||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
|
||||
|
||||
# Track job status in memory
|
||||
jobs = {}
|
||||
|
||||
|
||||
def get_video_info(filepath):
|
||||
"""Get video resolution and duration using ffprobe."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", filepath
|
||||
],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
import json
|
||||
info = json.loads(result.stdout)
|
||||
video_stream = next(
|
||||
(s for s in info.get("streams", []) if s["codec_type"] == "video"), {}
|
||||
)
|
||||
return {
|
||||
"width": int(video_stream.get("width", 0)),
|
||||
"height": int(video_stream.get("height", 0)),
|
||||
"duration": float(info.get("format", {}).get("duration", 0)),
|
||||
"codec": video_stream.get("codec_name", "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def split_video(job_id, input_path, num_views, h_fov, v_fov, output_res, overlap):
|
||||
"""Run the actual FFmpeg splitting in a subprocess-friendly way."""
|
||||
try:
|
||||
jobs[job_id]["status"] = "processing"
|
||||
|
||||
job_output = os.path.join(app.config["OUTPUT_FOLDER"], job_id)
|
||||
os.makedirs(job_output, exist_ok=True)
|
||||
|
||||
effective_fov = h_fov + overlap
|
||||
step = 360 / num_views
|
||||
|
||||
input_info = get_video_info(input_path)
|
||||
jobs[job_id]["input_info"] = input_info
|
||||
|
||||
processes = []
|
||||
output_files = []
|
||||
|
||||
for i in range(num_views):
|
||||
yaw = i * step
|
||||
label = get_direction_label(yaw, num_views)
|
||||
output_file = os.path.join(job_output, f"{label}_{int(yaw)}deg.mp4")
|
||||
output_files.append(output_file)
|
||||
|
||||
vf = f"v360=equirect:flat:h_fov={effective_fov}:v_fov={v_fov}:yaw={yaw}"
|
||||
if output_res:
|
||||
vf += f",scale={output_res}"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-i", input_path,
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264", "-crf", "18", "-preset", "medium",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
output_file
|
||||
]
|
||||
|
||||
processes.append((label, yaw, cmd, output_file))
|
||||
|
||||
total = len(processes)
|
||||
for idx, (label, yaw, cmd, output_file) in enumerate(processes):
|
||||
jobs[job_id]["current_view"] = f"{label} ({int(yaw)}°)"
|
||||
jobs[job_id]["progress"] = int((idx / total) * 100)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = f"FFmpeg error on {label}: {result.stderr[-500:]}"
|
||||
return
|
||||
|
||||
jobs[job_id]["status"] = "complete"
|
||||
jobs[job_id]["progress"] = 100
|
||||
jobs[job_id]["output_files"] = [
|
||||
os.path.basename(f) for f in output_files
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
|
||||
|
||||
def get_direction_label(yaw, num_views):
|
||||
"""Get a human-readable label for a yaw angle."""
|
||||
if num_views == 4:
|
||||
labels = {0: "front", 90: "right", 180: "back", 270: "left"}
|
||||
return labels.get(int(yaw), f"view_{int(yaw)}")
|
||||
elif num_views == 3:
|
||||
labels = {0: "front", 120: "right", 240: "left"}
|
||||
return labels.get(int(yaw), f"view_{int(yaw)}")
|
||||
else:
|
||||
return f"view_{int(yaw)}"
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
if "video" not in request.files:
|
||||
return jsonify({"error": "No video file provided"}), 400
|
||||
|
||||
file = request.files["video"]
|
||||
if file.filename == "":
|
||||
return jsonify({"error": "No file selected"}), 400
|
||||
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
ext = Path(file.filename).suffix or ".mp4"
|
||||
input_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{job_id}{ext}")
|
||||
file.save(input_path)
|
||||
|
||||
num_views = int(request.form.get("num_views", 4))
|
||||
h_fov = float(request.form.get("h_fov", 360 / num_views))
|
||||
v_fov = float(request.form.get("v_fov", 90))
|
||||
overlap = float(request.form.get("overlap", 0))
|
||||
output_res = request.form.get("output_res", "").strip()
|
||||
|
||||
jobs[job_id] = {
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"input_file": file.filename,
|
||||
"num_views": num_views,
|
||||
"current_view": "",
|
||||
"output_files": [],
|
||||
}
|
||||
|
||||
# Run in a background thread
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=split_video,
|
||||
args=(job_id, input_path, num_views, h_fov, v_fov, output_res, overlap)
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
|
||||
@app.route("/status/<job_id>")
|
||||
def status(job_id):
|
||||
if job_id not in jobs:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
return jsonify(jobs[job_id])
|
||||
|
||||
|
||||
@app.route("/download/<job_id>/<filename>")
|
||||
def download(job_id, filename):
|
||||
job_dir = os.path.join(app.config["OUTPUT_FOLDER"], job_id)
|
||||
return send_from_directory(job_dir, filename, as_attachment=True)
|
||||
|
||||
|
||||
@app.route("/cleanup/<job_id>", methods=["POST"])
|
||||
def cleanup(job_id):
|
||||
"""Delete uploaded and output files for a job."""
|
||||
job_dir = os.path.join(app.config["OUTPUT_FOLDER"], job_id)
|
||||
if os.path.exists(job_dir):
|
||||
shutil.rmtree(job_dir)
|
||||
# Remove upload file
|
||||
for f in Path(app.config["UPLOAD_FOLDER"]).glob(f"{job_id}.*"):
|
||||
f.unlink()
|
||||
if job_id in jobs:
|
||||
del jobs[job_id]
|
||||
return jsonify({"status": "cleaned"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
services:
|
||||
video360-splitter:
|
||||
build: .
|
||||
container_name: video360-splitter
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- video-data:/data
|
||||
networks:
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.video360.rule=Host(`360split.jeffemmett.com`)"
|
||||
- "traefik.http.routers.video360.entrypoints=web"
|
||||
- "traefik.http.services.video360.loadbalancer.server.port=5000"
|
||||
# Allow large uploads
|
||||
- "traefik.http.middlewares.video360-body.buffering.maxRequestBodyBytes=10737418240"
|
||||
- "traefik.http.routers.video360.middlewares=video360-body"
|
||||
|
||||
volumes:
|
||||
video-data:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
<!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>
|
||||
Loading…
Reference in New Issue