SBS to 2D Video Converter with BGM — Big Update
It’s been about a year since I first posted this little tool back in April 2025, and it has quietly grown up. What started as a bare-bones FFmpeg frontend for flattening VR videos now has real camera controls, animated pans and zooms, modern codecs, near-lossless output, and a lot less jank.
I never expected to keep tinkering with it this much, but every time I used it I found one more thing I wanted, and here we are. Here’s what’s changed since the original post.
The two big ones
1. Output Projections — the framing finally looks right
This is the change I’m happiest about. In the original, everything came out as a flat rectilinear crop, which meant you were always fighting the same trade-off: narrow FOV cropped too much, wide FOV stretched everything at the edges. It bugged me for months.
The fix was to stop forcing a flat projection and let you choose how the sphere gets flattened. There’s now an Output Projection dropdown:
- Stereographic — my go-to. Keeps a subject’s proportions natural even at wide angles. Curves background lines slightly, but for VR180 where the subject is the point, it’s fantastic.
- Pannini — wide, but keeps vertical lines straight. Very natural-looking.
- Fisheye — grabs the most of the scene; obvious curvature.
- Cylindrical — straight verticals, panoramic feel.
- Flat — the old rectilinear behavior, still here for narrow-FOV shots.
If you’ve ever had a conversion make someone look short and wide, switch to Stereographic and drop the FOV a touch — problem gone.
2. Motion Paths (.vrpath) — animated pans and zooms
The original tool locked you to one fixed camera angle for the whole clip. Now you can animate the view over time — pans, tilts, and vertical glides.
I’ll be honest about where this one came from: after my first post, Rose replied and, in their characteristically no-nonsense way, pointed out that the real missing feature was transitions — pans and zooms — and that plain SBS-to-2D was never the daunting part to begin with. They were completely right, and it stuck with me. This feature exists because of that nudge. Thanks, Rose. ![]()
It works like subtitles. You write a small text file with the same name as your video (e.g. MyClip.mp4 → MyClip.vrpath), drop it next to the video, and the tool auto-detects it. The format is SRT-style:
1
00:00:00 --> 00:00:00
pitch: -10
yaw: 0
roll: 0
2
00:00:00 --> 00:00:10
yaw: 0 -> 30
3
00:00:10 --> 00:00:25
pitch: -10 -> 0
- A block whose start and end times match sets an instant pose (use the first block for your starting angles).
param: A -> Bsweeps smoothly from A to B across that block’s time span.param: Aholds a value; anything you don’t mention holds its last value; past the last keyframe everything holds.- You can animate pitch, yaw, roll, and the new h_offset / v_offset. For example,
v_offset: -0.2 -> 0.2slowly glides the frame vertically.
Because it’s all time-based, a short 10-second draft just previews the first 10 seconds of the move — so you can dial it in fast before committing to the full render. There’s a built-in “How to Use” page in the Help menu that documents the whole format.
Everything else that’s new
Encoding
- CPU encoding is finally here (it was on the to-do list). Tick “Use CPU encoder” and you no longer need an NVIDIA GPU at all.
- Codec choice: HEVC (H.265), AV1, or H.264. HEVC is the new default — same look as H.264 at roughly 40% smaller files. AV1 is there if your player supports it. H.264 stays for maximum compatibility.
- Constant-quality mode (CQ/CRF). Instead of guessing a bitrate, tick this and set a quality level (0–51, lower = better; ~18 is near-lossless). The encoder spends bits where the scene actually needs them. Great for keepers.
Camera controls
- Yaw and Roll added (also on the original to-do list), alongside Pitch.
- H Offset / V Offset — these shift the projection center without rotating the view. On a tall frame, a small V Offset often recenters a standing subject better than Pitch does. I had no idea these even existed when I started.
- FOV range widened to 60–179, since the new projections let you go wider without it falling apart.
Background music
- Multiple tracks now. Add as many as you like; they play in order and loop to fill the whole video, cut off at the end.
- Any common audio format works now (MP3, WAV, FLAC, OGG, M4A, AAC) — the old “probably AAC only” caveat is gone.
Quality of life
- Settings are remembered between sessions, including your last-used folders for the file pickers.
- Reworked progress window — an actual progress bar with percent, ETA, speed, and fps, plus a collapsible raw FFmpeg log for when something goes wrong.
- More input formats accepted (MKV, MOV, AVI, WEBM, M4V, not just MP4).
- Help menu now has a full “How to Use” manual and an updated Dependencies list.
- A pile of stability fixes under the hood (the old build had a couple of ways to crash on non-Windows machines and the occasional thread hiccup — those are handled now).
Updated usage note: aspect ratio
I have to walk back my original advice to “keep it 16:9.” A VR180 view is a tall, wide, roughly circular window — not widescreen — so 16:9 throws away a lot of the scene vertically. Taller output frames look far more natural. My current favorites:
- 1440 × 1600 — matches a single Valve Index eye. Looks excellent.
- 1920 × 1440 (4:3) — taller than widescreen, plays everywhere.
Pair one of those with Stereographic and it’s a night-and-day difference from the old 16:9 crops.
To-do list, revisited
Everything on the original to-do list is done (other audio formats, Yaw/Roll). New ideas I’m kicking around:
- Easing for motion paths (ease-in/out instead of purely linear moves)
- Animating the FOV for slow zooms (same system as the pans)
- 10-bit output for HDR-grade sources
- Windows taskbar progress
No promises on timing — this remains a nights-and-weekends thing.
Same disclaimer as always
I’m still not a professional Python developer, and I still don’t fully understand everything FFmpeg is doing under the hood. It works well for me, but I can’t guarantee it’ll do everything you need. It’s plain Python, so it’s fully editable if you want to tweak it, and I’ve tried to comment the code so you can find your way around.
And, as before, credit to Maechoon, whose music-backed compilations were the original inspiration for the whole tool.
Thanks for using it, and thanks especially to everyone who left feedback on the first post — it genuinely shaped where this went.
Convert_SBS_to_2D_Mix_BGM_GUI_v6.12.py
import os
import sys
import re
import json
import time
import shutil
import tempfile
import subprocess
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
# GUI application for converting SBS 3D videos to 2D with optional background music
class MotionPathError(Exception):
"""Raised when a .vrpath / .txt motion file can't be parsed."""
class VideoConverterApp:
# Friendly projection name -> v360 output token
PROJECTIONS = {
"Pannini": "pannini",
"Flat (rectilinear)": "flat",
"Cylindrical": "cylindrical",
"Stereographic": "sg",
"Fisheye": "fisheye",
}
# Friendly codec name -> internal token
CODECS = {
"HEVC (H.265)": "hevc",
"AV1": "av1",
"H.264": "h264",
}
# Motion-path settings
MOTION_PARAMS = ("pitch", "yaw", "roll", "h_offset", "v_offset")
MOTION_STEP_HZ = 30 # interpolation granularity for sendcmd
MOTION_EXTS = (".vrpath", ".txt") # auto-detect order (first match wins)
def __init__(self, master):
self.master = master
master.title("SBS to 2D Video Converter with BGM")
master.resizable(False, False)
# Create the menu bar
menubar = tk.Menu(master)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="Reset", command=self.reset_to_defaults)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self._on_close)
menubar.add_cascade(label="File", menu=file_menu)
# Help menu
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="How to Use", command=self.show_manual_dialog)
help_menu.add_command(label="Dependencies", command=self.show_help_dialog)
menubar.add_cascade(label="Help", menu=help_menu)
master.config(menu=menubar)
self.last_input_path = None
self.last_settings = None
self.bg_tracks = [] # ordered list of background audio file paths
self.last_video_dir = None # remembered folder for the video picker
self.last_audio_dir = None # remembered folder for the audio picker
# Default parameter values
self.default_fov = 125
self.default_pitch = 0
self.default_yaw = 0
self.default_roll = 0
self.default_h_offset = 0.0
self.default_v_offset = 0.0
self.default_duration = 10
self.default_volume = 5
self.default_tgt_suffix = "_2D_Converted"
self.default_width = 1920
self.default_height = 1080
self.default_bitrate = 6
self.default_quality = 18
self.default_projection = "Pannini"
self.default_codec = "HEVC (H.265)"
# UI state variables
self.eye_view = tk.StringVar(value="left") # Which half of SBS to use
self.append_settings = tk.BooleanVar(value=False) # Append settings to filename
self.use_cpu = tk.BooleanVar(value=False) # CPU (libx264) vs GPU (nvenc)
self.quality_mode = tk.BooleanVar(value=False) # Constant quality vs target bitrate
self.projection = tk.StringVar(value=self.default_projection)
self.codec = tk.StringVar(value=self.default_codec)
self.use_path = tk.BooleanVar(value=False) # Animate angles from a motion path
pad = {"padx": 6, "pady": 3}
r = 0
# Field of View
tk.Label(master, text="Field of View (60-179):").grid(row=r, sticky='e', **pad)
self.fov_entry = tk.Entry(master)
self.fov_entry.insert(0, str(self.default_fov))
self.fov_entry.grid(row=r, column=1, **pad); r += 1
# Pitch
tk.Label(master, text="Camera Pitch (-90 to +90):").grid(row=r, sticky='e', **pad)
self.pitch_entry = tk.Entry(master)
self.pitch_entry.insert(0, str(self.default_pitch))
self.pitch_entry.grid(row=r, column=1, **pad); r += 1
# Yaw
tk.Label(master, text="Camera Yaw (-180 to +180):").grid(row=r, sticky='e', **pad)
self.yaw_entry = tk.Entry(master)
self.yaw_entry.insert(0, str(self.default_yaw))
self.yaw_entry.grid(row=r, column=1, **pad); r += 1
# Roll
tk.Label(master, text="Camera Roll (-180 to +180):").grid(row=r, sticky='e', **pad)
self.roll_entry = tk.Entry(master)
self.roll_entry.insert(0, str(self.default_roll))
self.roll_entry.grid(row=r, column=1, **pad); r += 1
# Off-axis offsets (shift the projection centre without rotating the view)
tk.Label(master, text="H Offset (-1.0 to 1.0):").grid(row=r, sticky='e', **pad)
self.h_offset_entry = tk.Entry(master)
self.h_offset_entry.insert(0, str(self.default_h_offset))
self.h_offset_entry.grid(row=r, column=1, **pad); r += 1
tk.Label(master, text="V Offset (-1.0 to 1.0):").grid(row=r, sticky='e', **pad)
self.v_offset_entry = tk.Entry(master)
self.v_offset_entry.insert(0, str(self.default_v_offset))
self.v_offset_entry.grid(row=r, column=1, **pad); r += 1
# Motion path (animated pitch/yaw/roll). Overrides the static angles above.
tk.Checkbutton(master, text="Use motion path (animate angles; overrides Pitch/Yaw/Roll)",
variable=self.use_path).grid(row=r, column=0, columnspan=3, sticky='w', **pad); r += 1
tk.Label(master, text="Motion Path File:").grid(row=r, sticky='e', **pad)
self.path_entry = tk.Entry(master)
self.path_entry.grid(row=r, column=1, **pad)
tk.Button(master, text="Browse", command=self.browse_path).grid(row=r, column=2, **pad); r += 1
# Duration
tk.Label(master, text="Clip Duration (0-360s, 0 = full video):").grid(row=r, sticky='e', **pad)
self.duration_entry = tk.Entry(master)
self.duration_entry.insert(0, str(self.default_duration))
self.duration_entry.grid(row=r, column=1, **pad); r += 1
# Rate control: bitrate (target) OR quality (CQ/CRF)
tk.Label(master, text="Bitrate (1-80 Mbps):").grid(row=r, sticky='e', **pad)
self.bitrate_entry = tk.Entry(master)
self.bitrate_entry.insert(0, str(self.default_bitrate))
self.bitrate_entry.grid(row=r, column=1, **pad); r += 1
tk.Checkbutton(master, text="Constant-quality mode (CQ/CRF \u2014 ignores bitrate)",
variable=self.quality_mode, command=self._sync_rate_fields).grid(
row=r, column=0, columnspan=3, sticky='w', **pad); r += 1
tk.Label(master, text="Quality (0-51, lower = better):").grid(row=r, sticky='e', **pad)
self.quality_entry = tk.Entry(master)
self.quality_entry.insert(0, str(self.default_quality)) # insert while enabled...
self.quality_entry.grid(row=r, column=1, **pad); r += 1
self.quality_entry.config(state=tk.DISABLED) # ...then disable
# Video codec
tk.Label(master, text="Video Codec:").grid(row=r, sticky='e', **pad)
tk.OptionMenu(master, self.codec, *self.CODECS.keys()).grid(row=r, column=1, sticky='we', **pad); r += 1
# Output projection
tk.Label(master, text="Output Projection:").grid(row=r, sticky='e', **pad)
tk.OptionMenu(master, self.projection, *self.PROJECTIONS.keys()).grid(row=r, column=1, sticky='we', **pad); r += 1
# Eye view
tk.Label(master, text="Select Eye View:").grid(row=r, sticky='w', columnspan=2, **pad); r += 1
tk.Radiobutton(master, text="Left Eye", variable=self.eye_view, value="left").grid(row=r, column=0, sticky='w', **pad)
tk.Radiobutton(master, text="Right Eye", variable=self.eye_view, value="right").grid(row=r, column=1, sticky='w', **pad); r += 1
# Background audio tracks (multi-select, ordered, looped)
bgm_frame = tk.LabelFrame(master, text="Background Audio Tracks (play in order, loop to fill video)")
bgm_frame.grid(row=r, column=0, columnspan=3, sticky='we', padx=6, pady=6); r += 1
self.bg_listbox = tk.Listbox(bgm_frame, height=4, width=48, selectmode=tk.EXTENDED)
self.bg_listbox.grid(row=0, column=0, rowspan=5, padx=(6, 0), pady=6, sticky='we')
sb = tk.Scrollbar(bgm_frame, orient='vertical', command=self.bg_listbox.yview)
sb.grid(row=0, column=1, rowspan=5, sticky='ns', pady=6)
self.bg_listbox.config(yscrollcommand=sb.set)
tk.Button(bgm_frame, text="Add\u2026", width=10, command=self.add_tracks).grid(row=0, column=2, padx=6)
tk.Button(bgm_frame, text="Remove", width=10, command=self.remove_tracks).grid(row=1, column=2, padx=6)
tk.Button(bgm_frame, text="Move Up", width=10, command=lambda: self.move_track(-1)).grid(row=2, column=2, padx=6)
tk.Button(bgm_frame, text="Move Down", width=10, command=lambda: self.move_track(1)).grid(row=3, column=2, padx=6)
tk.Button(bgm_frame, text="Clear", width=10, command=self.clear_tracks).grid(row=4, column=2, padx=6)
# Background volume
tk.Label(master, text="Background Audio Volume %:").grid(row=r, sticky='e', **pad)
self.bg_volume = tk.Entry(master)
self.bg_volume.insert(0, str(self.default_volume))
self.bg_volume.grid(row=r, column=1, **pad); r += 1
# Target suffix
tk.Label(master, text="Target Video Suffix:").grid(row=r, sticky='e', **pad)
self.tgt_suffix = tk.Entry(master)
self.tgt_suffix.insert(0, self.default_tgt_suffix)
self.tgt_suffix.grid(row=r, column=1, **pad); r += 1
# Append settings
tk.Checkbutton(master, text="Append Conversion Settings", variable=self.append_settings).grid(row=r, column=0, columnspan=2, sticky='w', **pad); r += 1
# Output width / height
tk.Label(master, text="Output Width:").grid(row=r, sticky='e', **pad)
self.width_entry = tk.Entry(master)
self.width_entry.insert(0, str(self.default_width))
self.width_entry.grid(row=r, column=1, **pad); r += 1
tk.Label(master, text="Output Height:").grid(row=r, sticky='e', **pad)
self.height_entry = tk.Entry(master)
self.height_entry.insert(0, str(self.default_height))
self.height_entry.grid(row=r, column=1, **pad); r += 1
# CPU toggle
tk.Checkbutton(master, text="Use CPU encoder (libx264, no NVIDIA GPU needed)",
variable=self.use_cpu).grid(row=r, column=0, columnspan=3, sticky='w', **pad); r += 1
# Action buttons
tk.Button(master, text="Select Source Video", command=self.select_video).grid(row=r, columnspan=3, pady=(10, 0)); r += 1
self.run_again_button = tk.Button(master, text="Run Again", command=self.run_again, state=tk.DISABLED)
self.run_again_button.grid(row=r, columnspan=3, pady=(0, 10))
# Restore last-used settings, and save them when the window is closed.
self._load_settings()
master.protocol("WM_DELETE_WINDOW", self._on_close)
# ---------- Rate-control field enable/disable ----------
def _sync_rate_fields(self):
# Grey out whichever field doesn't apply, so it's obvious which one is live.
if self.quality_mode.get():
self.quality_entry.config(state=tk.NORMAL)
self.bitrate_entry.config(state=tk.DISABLED)
else:
self.quality_entry.config(state=tk.DISABLED)
self.bitrate_entry.config(state=tk.NORMAL)
# ---------- Background track list management ----------
def add_tracks(self):
kwargs = {"filetypes": [("Audio files", "*.aac *.mp3 *.wav *.m4a *.flac *.ogg"), ("All files", "*.*")]}
if self.last_audio_dir and os.path.isdir(self.last_audio_dir):
kwargs["initialdir"] = self.last_audio_dir
paths = filedialog.askopenfilenames(**kwargs)
if paths:
self.last_audio_dir = os.path.dirname(paths[0])
for p in paths:
self.bg_tracks.append(p)
self._refresh_tracks()
def remove_tracks(self):
for i in reversed(self.bg_listbox.curselection()):
del self.bg_tracks[i]
self._refresh_tracks()
def move_track(self, delta):
sel = self.bg_listbox.curselection()
if len(sel) != 1:
return
i = sel[0]
j = i + delta
if 0 <= j < len(self.bg_tracks):
self.bg_tracks[i], self.bg_tracks[j] = self.bg_tracks[j], self.bg_tracks[i]
self._refresh_tracks()
self.bg_listbox.selection_set(j)
def clear_tracks(self):
self.bg_tracks.clear()
self._refresh_tracks()
def _refresh_tracks(self):
self.bg_listbox.delete(0, tk.END)
for p in self.bg_tracks:
self.bg_listbox.insert(tk.END, os.path.basename(p))
# ---------- Video selection ----------
def select_video(self):
kwargs = {"filetypes": [("Video files", "*.mp4 *.mkv *.mov *.avi *.webm *.m4v"), ("All files", "*.*")]}
if self.last_video_dir and os.path.isdir(self.last_video_dir):
kwargs["initialdir"] = self.last_video_dir
path = filedialog.askopenfilename(**kwargs)
if path:
self.last_video_dir = os.path.dirname(path)
self.last_input_path = path
self.run_again_button.config(state=tk.NORMAL)
self._autodetect_motion_path(path)
self.process_video(path)
def _autodetect_motion_path(self, video_path):
# Look for a sidecar path file matching the video's base name (subtitle-style).
stem = os.path.splitext(video_path)[0]
for ext in self.MOTION_EXTS:
candidate = stem + ext
if os.path.isfile(candidate):
self.path_entry.delete(0, tk.END)
self.path_entry.insert(0, candidate)
self.use_path.set(True)
return
def browse_path(self):
kwargs = {"filetypes": [("Motion path", "*.vrpath *.txt"), ("All files", "*.*")]}
if self.last_video_dir and os.path.isdir(self.last_video_dir):
kwargs["initialdir"] = self.last_video_dir
path = filedialog.askopenfilename(**kwargs)
if path:
self.path_entry.delete(0, tk.END)
self.path_entry.insert(0, path)
self.use_path.set(True)
def get_video_duration(self, video_path):
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", video_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
return float(result.stdout.strip())
except Exception as e:
messagebox.showerror("Duration Error", f"Could not fetch video duration:\n{str(e)}")
return None
def has_audio_stream(self, video_path):
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries", "stream=index", "-of", "csv=p=0", video_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
return bool(result.stdout.strip())
except Exception:
return True # assume audio so we don't silently drop it
# ---------- Playlist building ----------
def build_playlist(self, tracks, append):
# Concatenate the selected tracks (in order) into a single temp file.
# Each track is normalized to a common format so concat never fails on
# mismatched sample rates / channel layouts.
fd, temp_path = tempfile.mkstemp(suffix=".m4a")
os.close(fd)
cmd = ['ffmpeg', '-y', '-hide_banner', '-nostdin']
for t in tracks:
cmd.extend(['-i', t])
n = len(tracks)
parts = [f"[{i}:a]aformat=sample_rates=48000:sample_fmts=fltp:channel_layouts=stereo[a{i}]"
for i in range(n)]
concat_in = "".join(f"[a{i}]" for i in range(n))
filt = ";".join(parts) + f";{concat_in}concat=n={n}:v=0:a=1[out]"
cmd.extend(['-filter_complex', filt, '-map', '[out]', '-c:a', 'aac', '-b:a', '192k', temp_path])
append(f"Building background playlist from {n} track(s)...\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if result.returncode != 0:
append(result.stdout or "")
raise RuntimeError("Failed to build the background audio playlist.")
append("Playlist ready.\n\n")
return temp_path
# ---------- Motion path (animated angles) ----------
@staticmethod
def _parse_timecode(tc):
parts = tc.strip().split(":")
try:
parts = [float(p) for p in parts]
except ValueError:
raise MotionPathError(f"Bad timecode: '{tc}'")
if not 1 <= len(parts) <= 3:
raise MotionPathError(f"Bad timecode: '{tc}'")
sec = 0.0
for p in parts:
sec = sec * 60 + p
return sec
def parse_motion_path(self, text):
# Parse SRT-style blocks into {param: [(t_seconds, value), ...]}.
motion = {}
blocks = [b for b in (blk.strip() for blk in text.replace("\r\n", "\n").split("\n\n")) if b]
if not blocks:
raise MotionPathError("The motion path file is empty.")
for blk in blocks:
lines = [ln.strip() for ln in blk.split("\n") if ln.strip()]
time_line = next((ln for ln in lines if "-->" in ln), None)
if not time_line:
raise MotionPathError(f"A block is missing its 'HH:MM:SS --> HH:MM:SS' line:\n{blk}")
start_s, end_s = [p.strip() for p in time_line.split("-->")]
t0, t1 = self._parse_timecode(start_s), self._parse_timecode(end_s)
if t1 < t0:
raise MotionPathError(f"End time is before start time in: {time_line}")
param_lines = [ln for ln in lines if ":" in ln and "-->" not in ln
and ln.split(":", 1)[0].strip().lower() in self.MOTION_PARAMS]
if not param_lines:
raise MotionPathError(f"A block has no pitch/yaw/roll lines:\n{blk}")
for ln in param_lines:
name, spec = ln.split(":", 1)
name, spec = name.strip().lower(), spec.strip()
try:
if "->" in spec:
a, b = [float(s.strip()) for s in spec.split("->")]
else:
a = b = float(spec)
except ValueError:
raise MotionPathError(f"Couldn't read the value in: {ln}")
pts = motion.setdefault(name, [])
if t1 == t0:
pts.append((t0, a)) # instant / set-pose
else:
pts.append((t0, a)) # segment start
pts.append((t1, b)) # segment end (== a for a hold)
for name in motion:
motion[name].sort(key=lambda x: x[0])
return motion
@staticmethod
def _motion_value_at(pts, t):
if t <= pts[0][0]:
return pts[0][1]
if t >= pts[-1][0]:
return pts[-1][1]
for (ta, va), (tb, vb) in zip(pts, pts[1:]):
if ta <= t <= tb:
if tb == ta:
return vb
return va + (vb - va) * ((t - ta) / (tb - ta))
return pts[-1][1]
def motion_base_pose(self, motion):
# Angle values at t=0, baked into the v360 filter so frame 0 is correct.
return {p: (self._motion_value_at(motion[p], 0.0) if p in motion else 0.0)
for p in self.MOTION_PARAMS}
def generate_sendcmd(self, motion):
# Stepped interpolation as a sendcmd script targeting the v360 filter.
step = 1.0 / self.MOTION_STEP_HZ
cmds = []
for param in self.MOTION_PARAMS:
if param not in motion:
continue
pts = motion[param]
t0, t1 = pts[0][0], pts[-1][0]
last = round(pts[0][1], 3) # seeded by the v360 initial pose
t = t0
while t <= t1 + 1e-9:
v = round(self._motion_value_at(pts, t), 3)
if v != last:
cmds.append((round(t, 3), param, v))
last = v
t += step
vend = round(pts[-1][1], 3)
if vend != last:
cmds.append((round(t1, 3), param, vend))
cmds.sort(key=lambda x: x[0])
return "\n".join(f"{t:.3f} v360 {p} {v};" for t, p, v in cmds)
# ---------- FFmpeg command ----------
# (codec, use_cpu) -> ffmpeg encoder name
ENCODER_TABLE = {
("hevc", False): "hevc_nvenc",
("av1", False): "av1_nvenc",
("h264", False): "h264_nvenc",
("hevc", True): "libx265",
("av1", True): "libsvtav1",
("h264", True): "libx264",
}
def build_video_encoder_args(self, codec, use_cpu, quality_mode, quality, bitrate):
# Constant-quality and target-bitrate are mutually exclusive. In CQ/CRF
# mode we must NOT also pass a bitrate target, or the encoder ignores the
# quality setting (nvenc) / behaves oddly (libx26x).
encoder = self.ENCODER_TABLE[(codec, use_cpu)]
is_nvenc = encoder.endswith("_nvenc")
args = ['-c:v', encoder]
if quality_mode:
if is_nvenc:
# nvenc constant quality: -rc vbr + -cq N + -b:v 0 (the -b:v 0 is essential)
args += ['-rc', 'vbr', '-cq', str(quality), '-b:v', '0']
else:
# libx264 / libx265 / libsvtav1 all take -crf
args += ['-crf', str(quality)]
else:
args += ['-b:v', f"{bitrate}M"]
args += ['-pix_fmt', 'yuv420p']
# HEVC in MP4 needs the hvc1 tag to play in QuickTime / Apple players.
if codec == "hevc":
args += ['-tag:v', 'hvc1']
return args
def build_ffmpeg_command(self, input_path, output_path, duration, fov, pitch, yaw, roll,
h_offset, v_offset, width, height, projection, bg_playlist,
bg_volume, bitrate, quality_mode, quality, codec, eye_view,
has_input_audio, use_cpu, motion_ref=None, motion_base=None):
command = ['ffmpeg', '-y', '-hide_banner', '-nostdin']
if not use_cpu:
command += ['-hwaccel', 'cuda']
command += ['-t', str(duration), '-i', input_path]
# The playlist is looped forever; amix/-shortest trims it to the video length.
if bg_playlist:
command += ['-stream_loop', '-1', '-i', bg_playlist]
filter_complex = None
if bg_playlist:
if has_input_audio:
filter_complex = (f"[1:a]volume={bg_volume}[bg];"
f"[0:a][bg]amix=inputs=2:duration=first[aout]")
audio_args = ['-map', '[aout]', '-c:a', 'aac', '-b:a', '192k']
else:
filter_complex = f"[1:a]volume={bg_volume}[aout]"
audio_args = ['-map', '[aout]', '-c:a', 'aac', '-b:a', '192k', '-shortest']
else:
if has_input_audio:
audio_args = ['-map', '0:a:0', '-c:a', 'aac', '-b:a', '192k']
else:
audio_args = ['-an']
# Motion path: bake the t=0 pose into v360 and drive angle changes via sendcmd.
# motion_ref is a bare filename read from the process working directory, so the
# filtergraph never contains a Windows path (colons/backslashes break parsing).
if motion_ref:
pitch = motion_base["pitch"]
yaw = motion_base["yaw"]
roll = motion_base["roll"]
h_offset = motion_base["h_offset"]
v_offset = motion_base["v_offset"]
sendcmd = f"sendcmd=f={motion_ref},"
else:
sendcmd = ""
crop_x = "0" if eye_view == "left" else "iw/2"
video_filter = (
f'crop=w=iw/2:h=ih:x={crop_x}:y=0,'
f'{sendcmd}'
f'v360=hequirect:{projection}:in_stereo=2d:out_stereo=2d:'
f'iv_fov=180:ih_fov=180:d_fov={fov}:pitch={pitch}:yaw={yaw}:roll={roll}:'
f'h_offset={h_offset}:v_offset={v_offset}:'
f'w={width}:h={height}:interp=lanczos:reset_rot=1'
)
command += ['-map', '0:v:0', '-vf', video_filter]
command += self.build_video_encoder_args(codec, use_cpu, quality_mode, quality, bitrate)
if filter_complex:
command += ['-filter_complex', filter_complex]
command += audio_args
command += ['-metadata', 'comment=SBS to 2D Video Converter with BGM', output_path]
return command
# ---------- Dialogs / console ----------
def show_success_dialog(self, output_path):
def open_file():
try:
os.startfile(output_path) # Windows only
except AttributeError:
opener = 'open' if sys.platform == 'darwin' else 'xdg-open'
subprocess.run([opener, output_path])
def convert_full_video():
if self.last_settings:
full_duration = self.get_video_duration(self.last_settings["input_path"])
if full_duration:
self.process_video(self.last_settings["input_path"], override_duration=full_duration)
success_win = tk.Toplevel(self.master)
success_win.title("Success")
tk.Label(success_win, text=f"Converted successfully: {output_path}").pack(padx=10, pady=10)
tk.Button(success_win, text="Open File", command=open_file).pack(pady=(0, 5))
tk.Button(success_win, text="Convert Full Video", command=convert_full_video).pack(pady=(0, 10))
# Regexes for pulling stats out of FFmpeg's progress lines
_RE_TIME = re.compile(r"time=(\d+):(\d+):([\d.]+)")
_RE_SPEED = re.compile(r"speed=\s*([\d.]+)x")
_RE_FPS = re.compile(r"fps=\s*([\d.]+)")
_RE_SIZE = re.compile(r"size=\s*(\S+)")
@staticmethod
def _fmt_hms(seconds):
seconds = int(max(0, seconds))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _create_console_window(self, total_seconds, output_name):
win = tk.Toplevel(self.master)
win.title("Converting")
win.resizable(True, True)
wrap = tk.Frame(win, padx=14, pady=12)
wrap.pack(fill='both', expand=True)
tk.Label(wrap, text=output_name, anchor='w', font=('Segoe UI', 10, 'bold')).pack(fill='x')
bar = ttk.Progressbar(wrap, maximum=100, mode='determinate')
bar.pack(fill='x', pady=(8, 4))
stat_var = tk.StringVar(value="Preparing\u2026")
tk.Label(wrap, textvariable=stat_var, anchor='w', fg='#444').pack(fill='x')
# Collapsible raw log
log_shown = {"on": False}
log_frame = tk.Frame(wrap)
log = tk.Text(log_frame, wrap='word', height=16, width=92,
font=('Consolas', 9), bg='#1e1e1e', fg='#d4d4d4', insertbackground='#d4d4d4')
log_scroll = tk.Scrollbar(log_frame, command=log.yview)
log.config(yscrollcommand=log_scroll.set)
log_scroll.pack(side='right', fill='y')
log.pack(side='left', fill='both', expand=True)
btns = tk.Frame(wrap)
btns.pack(fill='x', pady=(8, 0))
def toggle_log():
log_shown["on"] = not log_shown["on"]
if log_shown["on"]:
log_frame.pack(fill='both', expand=True, pady=(10, 0))
details_btn.config(text="Hide log \u25be")
else:
log_frame.pack_forget()
details_btn.config(text="Show log \u25b8")
details_btn = tk.Button(btns, text="Show log \u25b8", command=toggle_log, width=12)
details_btn.pack(side='left')
close_btn = tk.Button(btns, text="Close", command=win.destroy, width=12)
close_btn.pack(side='right')
state = {"total": float(total_seconds or 0), "start": time.time()}
def do_line(line):
if log.winfo_exists():
log.insert(tk.END, line)
log.see(tk.END)
tm = self._RE_TIME.search(line)
if tm and state["total"] > 0 and bar.winfo_exists():
h, m, s = tm.groups()
cur = int(h) * 3600 + int(m) * 60 + float(s)
pct = max(0.0, min(100.0, cur / state["total"] * 100.0))
bar['value'] = pct
parts = [f"{pct:4.1f}%"]
elapsed = time.time() - state["start"]
parts.append(f"elapsed {self._fmt_hms(elapsed)}")
sp = self._RE_SPEED.search(line)
speed = float(sp.group(1)) if sp else None
if speed and speed > 0:
eta = (state["total"] - cur) / speed
parts.append(f"ETA {self._fmt_hms(eta)}")
parts.append(f"{speed:.2f}x")
fp = self._RE_FPS.search(line)
if fp:
parts.append(f"{fp.group(1)} fps")
sz = self._RE_SIZE.search(line)
if sz and sz.group(1) not in ("N/A", "0KiB"):
parts.append(sz.group(1))
stat_var.set(" \u2022 ".join(parts))
def append(line):
# Safe to call from any thread; marshals onto the Tk main loop.
self.master.after(0, do_line, line)
def finish(returncode):
# Runs on the main thread (called from _on_conversion_done).
if not bar.winfo_exists():
return
if returncode == 0:
bar['value'] = 100
stat_var.set("Done \u2713 \u2022 " + self._fmt_hms(time.time() - state["start"]) + " elapsed")
else:
stat_var.set(f"Failed (exit code {returncode}) \u2014 see log for details")
if not log_shown["on"]:
toggle_log() # auto-reveal the log so the error is visible
return {"append": append, "finish": finish}
def _on_conversion_done(self, console, returncode, output_path):
console["finish"](returncode)
if returncode == 0:
self.show_success_dialog(output_path)
else:
messagebox.showerror("FFmpeg Error", f"Process failed with code {returncode}.")
def _run_conversion(self, params, console):
append = console["append"]
temp_playlist = None
motion_dir = None
motion_ref = None
returncode = -1
try:
if params["tracks"]:
temp_playlist = self.build_playlist(params["tracks"], append)
# Write the sendcmd script into its own temp dir; ffmpeg runs with that
# dir as cwd so the filtergraph can reference it by bare filename.
if params.get("motion"):
motion_dir = tempfile.mkdtemp(prefix="vrpath_")
motion_ref = "motion.cmd"
with open(os.path.join(motion_dir, motion_ref), "w", encoding="utf-8") as f:
f.write(self.generate_sendcmd(params["motion"]))
append("Applying motion path...\n\n")
command = self.build_ffmpeg_command(
params["input_path"], params["output_path"], params["duration"],
params["fov"], params["pitch"], params["yaw"], params["roll"],
params["h_offset"], params["v_offset"],
params["width"], params["height"], params["projection"],
temp_playlist, params["bg_volume"], params["bitrate"],
params["quality_mode"], params["quality"], params["codec"],
params["eye_view"], params["has_input_audio"], params["use_cpu"],
motion_ref=motion_ref, motion_base=params.get("motion_base")
)
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True, cwd=motion_dir
)
for line in iter(process.stdout.readline, ''):
if not line:
break
append(line)
process.stdout.close()
process.wait()
returncode = process.returncode
except Exception as e:
append(f"\nError: {e}\n")
finally:
if temp_playlist and os.path.exists(temp_playlist):
try:
os.remove(temp_playlist)
except OSError:
pass
if motion_dir and os.path.isdir(motion_dir):
try:
shutil.rmtree(motion_dir)
except OSError:
pass
self.master.after(0, self._on_conversion_done, console, returncode, params["output_path"])
# ---------- Reset / help ----------
def reset_to_defaults(self):
for entry, val in [
(self.fov_entry, self.default_fov),
(self.pitch_entry, self.default_pitch),
(self.yaw_entry, self.default_yaw),
(self.roll_entry, self.default_roll),
(self.h_offset_entry, self.default_h_offset),
(self.v_offset_entry, self.default_v_offset),
(self.duration_entry, self.default_duration),
(self.bitrate_entry, self.default_bitrate),
(self.bg_volume, self.default_volume),
(self.width_entry, self.default_width),
(self.height_entry, self.default_height),
]:
entry.delete(0, tk.END)
entry.insert(0, str(val))
# Quality field may be disabled; toggle to NORMAL to edit it, then re-sync.
self.quality_entry.config(state=tk.NORMAL)
self.quality_entry.delete(0, tk.END)
self.quality_entry.insert(0, str(self.default_quality))
self.tgt_suffix.delete(0, tk.END)
self.tgt_suffix.insert(0, self.default_tgt_suffix)
self.eye_view.set("left")
self.projection.set(self.default_projection)
self.codec.set(self.default_codec)
self.use_path.set(False)
self.path_entry.delete(0, tk.END)
self.append_settings.set(False)
self.use_cpu.set(False)
self.quality_mode.set(False)
self.clear_tracks()
self._sync_rate_fields()
def show_help_dialog(self):
messagebox.showinfo(
"Dependencies",
"This tool requires FFmpeg and FFprobe, both reachable on your system PATH.\n\n"
"GPU encoding (default) needs an NVIDIA CUDA-capable GPU. The encoder used "
"depends on the Video Codec:\n"
" HEVC \u2192 hevc_nvenc AV1 \u2192 av1_nvenc H.264 \u2192 h264_nvenc\n"
"AV1 hardware encoding needs an RTX 40-series card or newer.\n\n"
"CPU encoding (tick 'Use CPU encoder') needs the matching library compiled "
"into your FFmpeg build:\n"
" HEVC \u2192 libx265 AV1 \u2192 libsvtav1 H.264 \u2192 libx264\n\n"
"Constant-quality mode uses NVENC CQ (GPU) or CRF (CPU): 0-51, lower = better. "
"About 18 is near-lossless; the bitrate field is ignored in that mode."
)
MANUAL_TEXT = """\
SBS to 2D Video Converter with BGM \u2014 How to Use
==================================================
WHAT IT DOES
Converts side-by-side (SBS) 180\u00b0 VR video into a normal flat 2D video,
with optional background music. It is a friendly front-end for FFmpeg.
QUICK START
1. Set your options (start with the defaults \u2014 they are sensible).
2. Click "Select Source Video" and pick your file.
3. A short draft renders (Clip Duration, default 10s) so you can judge
the framing quickly.
4. If it looks good, click "Convert Full Video" in the Success dialog to
render the whole clip with the same settings.
5. If not, tweak the values and click "Run Again" for another draft.
FRAMING \u2014 THE PART THAT MATTERS MOST FOR VR180
Output Width / Height:
VR180 is a tall, wide window \u2014 NOT 16:9. Forcing 16:9 crops away a lot
of the scene. Taller frames look far more natural. Good starting points:
1440 x 1600 (matches a Valve Index eye \u2014 excellent)
1920 x 1440 (4:3 \u2014 taller than widescreen, plays everywhere)
Dimensions must be even numbers.
Output Projection:
How the sphere is flattened. This is the key to avoiding distortion.
Stereographic \u2014 keeps a subject's proportions natural at wide angles.
Great all-rounder; curves background lines slightly.
Pannini \u2014 wide but keeps verticals straight; natural-looking.
Fisheye \u2014 maximum scene in frame; obvious curvature.
Cylindrical \u2014 straight verticals, panoramic feel.
Flat \u2014 true rectilinear; only good at narrow FOV, stretches
badly when wide.
Field of View (60-179):
How much of the scene you see. Higher = more scene but more edge effect.
With Stereographic/Pannini you can push to 120-155 comfortably.
If a subject looks too wide, LOWER the FOV or use Stereographic.
Camera Pitch / Yaw / Roll:
Rotate the view. VR180 is usually near eye level, so Pitch 0 to -10 is
typical (negative tilts the view down toward the subject).
H Offset / V Offset (-1.0 to 1.0):
Shift the projection CENTRE without rotating (no perspective swing).
On a tall frame, a small positive V Offset can recentre a standing
subject better than Pitch. Default 0.
Select Eye View:
An SBS video carries two images, one from each camera lens; this picks
which one becomes your 2D output. It is more than a left/right preference:
performers rarely split their gaze evenly between the two lenses and
usually look straight into one of them, so eye contact reads correctly on
only that side. If the subject seems to be looking slightly off to one
side, switch eyes and it typically snaps into place.
QUALITY & FORMAT
Bitrate (1-80 Mbps):
Target bitrate mode. Fine for drafts (6 is default). For 8K sources,
finals look better at 12+ (1080p) up to 25-40 (4K).
Constant-quality mode (recommended for finals):
Tick it to encode by quality instead of bitrate. Value is 0-51, lower =
better; ~18 is near-lossless. File size varies with scene complexity.
The bitrate field is ignored while this is on.
Video Codec:
HEVC (H.265) \u2014 default. Same look as H.264 at ~40% smaller. Best choice.
AV1 \u2014 slightly smaller again; check your player supports it.
H.264 \u2014 maximum compatibility with old devices.
Use CPU encoder:
Renders on the CPU (no NVIDIA GPU needed). Slower, especially on 8K.
BACKGROUND MUSIC
Add one or more audio tracks. They play top-to-bottom in list order and
loop to fill the whole video, cut off at the end. Use Move Up/Down to set
the order. Background Audio Volume % sets their level; any source audio in
the video is mixed underneath.
Credit: this tool was inspired entirely by the work of Maechoon, whose VR
compilations \u2014 often set to background music \u2014 sparked the whole idea.
https://discuss.eroscripts.com/t/maechoons-jav-jav-vr-scripts-index/145024
APPEND CONVERSION SETTINGS
Tick this to stamp the settings (FOV, projection, codec, quality, etc.)
into the output filename \u2014 ideal for comparing different drafts side by side.
MOTION PATH (.vrpath) \u2014 ANIMATED CAMERA MOVES
Instead of fixed angles, you can animate the view over time.
AUTO-DETECT: When you select a video, the tool looks next to it for a file
with the SAME name and a .vrpath (or .txt) extension, e.g.
MyClip.mp4 -> MyClip.vrpath
If found, it loads automatically and ticks "Use motion path". You can also
Browse for one manually, or untick to ignore it.
A loaded path OVERRIDES the static Pitch/Yaw/Roll/Offset fields. Anything
the path does not mention stays at 0, so include a line for every value
you want held.
FILE FORMAT (SRT-style blocks, blank line between blocks):
1
00:00:00 --> 00:00:00
pitch: -10
yaw: 0
roll: 0
2
00:00:00 --> 00:00:10
yaw: 0 -> 30
3
00:00:10 --> 00:00:25
pitch: -10 -> 0
RULES:
- Timestamps are HH:MM:SS (MM:SS and SS also accepted).
- A block whose start and end times are equal is an INSTANT pose \u2014 use
the first block (00:00:00 --> 00:00:00) to set your starting angles.
- "param: A -> B" sweeps smoothly from A to B across the block's time.
- "param: A" (no arrow) holds that value for the block.
- Values not mentioned in a block hold their last value.
- After the final keyframe, everything holds (add a block if you want
the camera to return home).
ANIMATABLE PARAMETERS:
pitch, yaw, roll, h_offset, v_offset
(Example: "v_offset: -0.2 -> 0.2" glides the frame vertically.)
TIP: Because interpolation is time-based, a 10-second draft of a 25-second
path simply previews the first 10 seconds of the move.
FILE MENU
Reset \u2014 restores all fields to defaults (does nothing visible if you are
already at defaults).
Exit \u2014 saves your settings and closes. Settings and the last-used folders
are remembered between sessions automatically.
"""
def show_manual_dialog(self):
win = tk.Toplevel(self.master)
win.title("How to Use")
win.geometry("760x620")
frame = tk.Frame(win)
frame.pack(fill='both', expand=True)
text = tk.Text(frame, wrap='word', font=('Consolas', 10),
padx=12, pady=10, bg='#fbfbfb')
scroll = tk.Scrollbar(frame, command=text.yview)
text.config(yscrollcommand=scroll.set)
scroll.pack(side='right', fill='y')
text.pack(side='left', fill='both', expand=True)
text.insert(tk.END, self.MANUAL_TEXT)
text.config(state=tk.DISABLED)
tk.Button(win, text="Close", command=win.destroy).pack(pady=8)
def run_again(self):
if self.last_input_path:
self.process_video(self.last_input_path)
# ---------- Settings persistence ----------
def _config_path(self):
# Per-user config file in the home directory.
cfg_dir = os.path.join(os.path.expanduser("~"), ".sbs_to_2d_converter")
return os.path.join(cfg_dir, "settings.json")
def _set_entry(self, entry, value):
# Set an entry's text regardless of its current enabled/disabled state.
state = entry.cget("state")
entry.config(state=tk.NORMAL)
entry.delete(0, tk.END)
entry.insert(0, str(value))
entry.config(state=state)
def _collect_settings(self):
# Snapshot everything worth remembering. Entry values are stored as the
# exact strings currently shown, so they round-trip verbatim.
return {
"fov": self.fov_entry.get(),
"pitch": self.pitch_entry.get(),
"yaw": self.yaw_entry.get(),
"roll": self.roll_entry.get(),
"h_offset": self.h_offset_entry.get(),
"v_offset": self.v_offset_entry.get(),
"duration": self.duration_entry.get(),
"bitrate": self.bitrate_entry.get(),
"quality": self.quality_entry.get(),
"bg_volume": self.bg_volume.get(),
"tgt_suffix": self.tgt_suffix.get(),
"width": self.width_entry.get(),
"height": self.height_entry.get(),
"projection": self.projection.get(),
"codec": self.codec.get(),
"eye_view": self.eye_view.get(),
"append_settings": self.append_settings.get(),
"use_cpu": self.use_cpu.get(),
"quality_mode": self.quality_mode.get(),
"use_path": self.use_path.get(),
"tracks": list(self.bg_tracks),
"last_video_dir": self.last_video_dir,
"last_audio_dir": self.last_audio_dir,
}
def _apply_settings(self, data):
# Apply a loaded settings dict; missing/invalid keys are ignored so a
# partial or older config file never breaks startup.
entry_map = {
"fov": self.fov_entry, "pitch": self.pitch_entry, "yaw": self.yaw_entry,
"roll": self.roll_entry, "duration": self.duration_entry,
"h_offset": self.h_offset_entry, "v_offset": self.v_offset_entry,
"bitrate": self.bitrate_entry, "quality": self.quality_entry,
"bg_volume": self.bg_volume, "tgt_suffix": self.tgt_suffix,
"width": self.width_entry, "height": self.height_entry,
}
for key, entry in entry_map.items():
if key in data and data[key] is not None:
self._set_entry(entry, data[key])
if data.get("projection") in self.PROJECTIONS:
self.projection.set(data["projection"])
if data.get("codec") in self.CODECS:
self.codec.set(data["codec"])
if data.get("eye_view") in ("left", "right"):
self.eye_view.set(data["eye_view"])
if "append_settings" in data:
self.append_settings.set(bool(data["append_settings"]))
if "use_cpu" in data:
self.use_cpu.set(bool(data["use_cpu"]))
if "quality_mode" in data:
self.quality_mode.set(bool(data["quality_mode"]))
if "use_path" in data:
self.use_path.set(bool(data["use_path"]))
self.last_video_dir = data.get("last_video_dir") or self.last_video_dir
self.last_audio_dir = data.get("last_audio_dir") or self.last_audio_dir
# Restore only tracks that still exist on disk.
tracks = data.get("tracks") or []
self.bg_tracks = [t for t in tracks if isinstance(t, str) and os.path.isfile(t)]
self._refresh_tracks()
# Make the greyed/active rate field match the restored quality_mode.
self._sync_rate_fields()
def _save_settings(self):
try:
path = self._config_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(self._collect_settings(), f, indent=2)
except Exception:
pass # persistence is best-effort; never block on it
def _load_settings(self):
try:
path = self._config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
self._apply_settings(json.load(f))
except Exception:
pass # ignore a missing or corrupt config file
def _on_close(self):
self._save_settings()
self.master.destroy()
# ---------- Validation helper ----------
def _parse_int(self, entry, label):
try:
return int(entry.get())
except ValueError:
raise ValueError(label)
def _parse_float(self, entry, label):
try:
return float(entry.get())
except ValueError:
raise ValueError(label)
# ---------- Main entry ----------
def process_video(self, input_path, override_duration=None):
try:
if not shutil.which("ffmpeg"):
raise EnvironmentError("FFmpeg is not installed or not in PATH.")
if not shutil.which("ffprobe"):
raise EnvironmentError("FFprobe is not installed or not in PATH.")
fov = self._parse_int(self.fov_entry, "Field of View")
if not (60 <= fov <= 179):
raise ValueError("Field of View")
pitch = self._parse_int(self.pitch_entry, "Camera Pitch")
if not (-90 <= pitch <= 90):
raise ValueError("Camera Pitch")
yaw = self._parse_int(self.yaw_entry, "Camera Yaw")
if not (-180 <= yaw <= 180):
raise ValueError("Camera Yaw")
roll = self._parse_int(self.roll_entry, "Camera Roll")
if not (-180 <= roll <= 180):
raise ValueError("Camera Roll")
h_offset = self._parse_float(self.h_offset_entry, "H Offset")
if not (-1.0 <= h_offset <= 1.0):
raise ValueError("H Offset")
v_offset = self._parse_float(self.v_offset_entry, "V Offset")
if not (-1.0 <= v_offset <= 1.0):
raise ValueError("V Offset")
width = self._parse_int(self.width_entry, "Output Width")
height = self._parse_int(self.height_entry, "Output Height")
if width <= 0 or height <= 0:
raise ValueError("Output Width/Height")
if width % 2 != 0 or height % 2 != 0:
raise ValueError("Output Width/Height (must be even)")
if override_duration is not None:
duration = override_duration
else:
duration = self._parse_int(self.duration_entry, "Clip Duration")
if not (0 <= duration <= 360):
raise ValueError("Clip Duration")
if duration == 0:
full = self.get_video_duration(input_path)
if full is None:
return
duration = int(full)
try:
bg_volume = float(self.bg_volume.get()) / 100.0
except ValueError:
raise ValueError("Background Audio Volume")
# Rate control: exactly one of bitrate / quality is used. Only the
# active field is read and validated; the other gets a harmless
# default (it isn't used when building the command).
quality_mode = self.quality_mode.get()
if quality_mode:
quality = self._parse_int(self.quality_entry, "Quality")
if not (0 <= quality <= 51):
raise ValueError("Quality (0-51)")
bitrate = self.default_bitrate
else:
bitrate = self._parse_int(self.bitrate_entry, "Bitrate")
if not (1 <= bitrate <= 80):
raise ValueError("Bitrate")
quality = self.default_quality
tgt_suffix = self.tgt_suffix.get()
eye_view = self.eye_view.get()
append_settings = self.append_settings.get()
use_cpu = self.use_cpu.get()
projection = self.PROJECTIONS[self.projection.get()]
codec = self.CODECS[self.codec.get()]
# Motion path (optional). If enabled, it overrides the static angles.
motion = None
motion_base = None
if self.use_path.get():
path_file = self.path_entry.get().strip()
if not path_file:
raise MotionPathError("Motion path is enabled but no file is selected.")
if not os.path.isfile(path_file):
raise MotionPathError(f"Motion path file not found:\n{path_file}")
with open(path_file, "r", encoding="utf-8") as f:
motion = self.parse_motion_path(f.read())
motion_base = self.motion_base_pose(motion)
tracks = list(self.bg_tracks)
for t in tracks:
if not os.path.isfile(t):
raise FileNotFoundError(f"Background track not found:\n{t}")
has_input_audio = self.has_audio_stream(input_path)
source_folder = os.path.dirname(input_path)
converted_folder = os.path.join(source_folder, 'Converted')
os.makedirs(converted_folder, exist_ok=True)
base_filename = os.path.splitext(os.path.basename(input_path))[0] + tgt_suffix
if append_settings:
rate_tag = f"_CQ-{quality}" if quality_mode else f"_Bitrate-{bitrate}"
angle_tag = "_Motion" if motion else f"_Pitch-{pitch}_Yaw-{yaw}_Roll-{roll}"
offset_tag = ""
if not motion and (h_offset or v_offset):
offset_tag = f"_Hoff-{h_offset}_Voff-{v_offset}"
base_filename += (f"_FOV-{fov}{angle_tag}{offset_tag}"
f"_Time-{int(duration)}_View-{eye_view}"
f"_Proj-{projection}_{codec.upper()}{rate_tag}")
base_filename += ".mp4"
output_path = os.path.join(converted_folder, base_filename)
params = {
"input_path": input_path,
"output_path": output_path,
"duration": duration,
"fov": fov, "pitch": pitch, "yaw": yaw, "roll": roll,
"h_offset": h_offset, "v_offset": v_offset,
"width": width, "height": height,
"projection": projection,
"tracks": tracks,
"bg_volume": bg_volume,
"bitrate": bitrate,
"quality_mode": quality_mode,
"quality": quality,
"codec": codec,
"eye_view": eye_view,
"has_input_audio": has_input_audio,
"use_cpu": use_cpu,
"motion": motion,
"motion_base": motion_base,
}
self.last_settings = {"input_path": input_path}
self._save_settings()
console = self._create_console_window(duration, os.path.basename(output_path))
threading.Thread(target=self._run_conversion, args=(params, console), daemon=True).start()
except MotionPathError as mpe:
messagebox.showerror("Motion Path Error", str(mpe))
except ValueError as ve:
messagebox.showerror("Invalid Input", f"Please check your {ve} input.")
except FileNotFoundError as fnfe:
messagebox.showerror("File Error", str(fnfe))
except EnvironmentError as ee:
messagebox.showerror("Environment Error", str(ee))
except Exception as e:
messagebox.showerror("Error", f"Something went wrong:\n{str(e)}")
def launch_gui():
root = tk.Tk()
VideoConverterApp(root)
root.mainloop()
if __name__ == '__main__':
launch_gui()
