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:
Jeff Emmett 2026-03-16 19:15:05 +00:00
commit 65f663a461
7 changed files with 751 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
uploads/
output/
__pycache__/
*.pyc
.git

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
uploads/
output/
.env

17
Dockerfile Normal file
View File

@ -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"]

187
app.py Normal file
View File

@ -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)

24
docker-compose.yml Normal file
View File

@ -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

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask==3.1.0
gunicorn==23.0.0

511
templates/index.html Normal file
View File

@ -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>