Skip to content

Instantly share code, notes, and snippets.

@tansautn
Last active March 17, 2025 11:01
Show Gist options
  • Save tansautn/4bbce98f0d9908c647ba28e29bbf628a to your computer and use it in GitHub Desktop.
Save tansautn/4bbce98f0d9908c647ba28e29bbf628a to your computer and use it in GitHub Desktop.
Concat multiple video file to one file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Build script for V-CONCAT
This script builds a standalone executable for the V-CONCAT tool using PyInstaller.
"""
import os
import sys
import subprocess
import shutil
import platform
import site
import glob
import json
def check_pyinstaller():
"""Check if PyInstaller is installed."""
try:
import PyInstaller
return True
except ImportError:
return False
def install_pyinstaller():
"""Install PyInstaller using pip."""
print("Installing PyInstaller...")
try:
subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"], check=True)
return True
except subprocess.CalledProcessError:
print("Failed to install PyInstaller.")
return False
def install_dependencies():
"""Install required dependencies."""
print("Installing required dependencies...")
try:
# Install shtab for command-line completion
subprocess.run([sys.executable, "-m", "pip", "install", "shtab"], check=False)
# We'll avoid pyreadline in the executable build as it causes issues
# Instead, we'll modify the script to handle tab completion differently
return True
except Exception as e:
print(f"Warning: Failed to install some dependencies: {str(e)}")
return False
def find_dlls():
"""Find required DLLs for PyInstaller."""
dll_paths = []
# Try to find DLLs in common locations
if platform.system() == "Windows":
# Check in Python's DLLs directory
python_dlls = os.path.join(os.path.dirname(sys.executable), "DLLs")
if os.path.exists(python_dlls):
dll_paths.append(python_dlls)
# Check in site-packages
for site_dir in site.getsitepackages():
dll_paths.append(site_dir)
# Check in Windows system directories
system32 = os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32")
if os.path.exists(system32):
dll_paths.append(system32)
return dll_paths
def build_executable():
"""Build the executable using PyInstaller."""
print("Building executable...")
# Get the directory of this script
script_dir = os.path.dirname(os.path.abspath(__file__))
# Path to vconcat.py
vconcat_path = os.path.join(script_dir, "vconcat.py")
# Check if vconcat.py exists
if not os.path.exists(vconcat_path):
print(f"Error: {vconcat_path} not found.")
return False
# Create a modified version without pyreadline for the build
build_script = os.path.join(script_dir, "vconcat_build.py")
with open(vconcat_path, "r", encoding="utf-8") as src_file:
content = src_file.read()
# Replace pyreadline imports with a simpler implementation
content = content.replace("import pyreadline3", "# import pyreadline3 - disabled for build")
content = content.replace("import pyreadline", "# import pyreadline - disabled for build")
# Simplify the tab completion code
tab_completion_start = content.find("def get_input_files_interactive():")
if tab_completion_start > 0:
tab_completion_end = content.find("def get_output_file_interactive():", tab_completion_start)
if tab_completion_end > 0:
simple_implementation = """def get_input_files_interactive():
\"\"\"Get input files interactively from user.\"\"\"
input_files = []
print("Enter video file paths (press Enter on an empty line when done):")
print("INFO: You can use TAB for filename completion in your terminal")
while True:
try:
file_input = input("> ").strip()
if not file_input:
break
# Remove quotes if present
file_input = file_input.strip('"\\\'')
if os.path.exists(file_input):
input_files.append(os.path.abspath(file_input))
else:
print(f"File not found: {file_input}")
except KeyboardInterrupt:
print("\\nInput interrupted.")
break
return input_files
"""
content = content[:tab_completion_start] + simple_implementation + content[tab_completion_end:]
# Write the modified script
with open(build_script, "w", encoding="utf-8") as dst_file:
dst_file.write(content)
# Find DLL paths
dll_paths = find_dlls()
dll_path_str = os.pathsep.join(dll_paths) if dll_paths else ""
# Set environment variables for PyInstaller
env = os.environ.copy()
if dll_path_str:
if "PATH" in env:
env["PATH"] = dll_path_str + os.pathsep + env["PATH"]
else:
env["PATH"] = dll_path_str
# PyInstaller command
cmd = [
"pyinstaller",
"--onefile", # Create a single executable file
"--name", "vconcat", # Name of the output file
"--icon", "NONE", # No icon
"--console", # Console application
"--clean", # Clean PyInstaller cache
"--noconfirm", # Overwrite output directory without confirmation
]
# Add paths to search for imports
for path in dll_paths:
cmd.extend(["--paths", path])
# Add hidden imports
cmd.extend(["--hidden-import", "argparse"])
cmd.extend(["--hidden-import", "json"])
cmd.extend(["--hidden-import", "tempfile"])
cmd.extend(["--hidden-import", "shutil"])
cmd.extend(["--hidden-import", "re"])
cmd.extend(["--hidden-import", "pathlib"])
cmd.extend(["--hidden-import", "platform"])
cmd.extend(["--hidden-import", "zipfile"])
cmd.extend(["--hidden-import", "urllib.request"])
cmd.extend(["--hidden-import", "collections"])
# Add the script
cmd.append(build_script)
try:
subprocess.run(cmd, check=True, env=env)
# Clean up the temporary build script
os.remove(build_script)
return True
except subprocess.CalledProcessError:
print("Failed to build executable.")
if os.path.exists(build_script):
os.remove(build_script)
return False
def create_launcher_script():
"""Create a launcher batch script for Windows."""
print("Creating launcher script...")
# Get the directory of this script
script_dir = os.path.dirname(os.path.abspath(__file__))
# Path to the dist directory
dist_dir = os.path.join(script_dir, "dist")
# Path to the executable
exe_path = os.path.join(dist_dir, "vconcat.exe")
# Check if the executable exists
if not os.path.exists(exe_path):
print(f"Error: {exe_path} not found.")
return False
# Create the launcher script
launcher_path = os.path.join(dist_dir, "vconcat.cmd")
with open(launcher_path, "w") as f:
f.write("@echo off\n")
f.write("setlocal enabledelayedexpansion\n\n")
f.write("echo.\n")
f.write("echo.\n")
f.write("echo M\"\"\"\"\"\"\"\"\"\"\"M dP \n")
f.write("echo Mmmmmm .M 88 \n")
f.write("echo MMMMP .MMM dP dP 88 .dP .d8888b. \n")
f.write("echo MMP .MMMMM 88 88 88888\" 88' `88 \n")
f.write("echo M' .MMMMMMM 88. .88 88 `8b. 88. .88 \n")
f.write("echo M M `88888P' dP `YP `88888P' \n")
f.write("echo MMMMMMMMMMM -*- Advanced Version -*- \n")
f.write("echo.\n")
f.write("echo * * * * * * * * * * * * * * * * * * * * * \n")
f.write("echo * - V C O N C A T . C M D - *\n")
f.write("echo * * * * * * * * * * * * * * * * * * * * * \n")
f.write("echo.\n")
f.write("echo.\n\n")
f.write("rem Get the directory where this script is located\n")
f.write("set \"SCRIPT_DIR=%~dp0\"\n\n")
f.write("echo Starting V-CONCAT Advanced Video Concatenation Tool...\n\n")
f.write("rem Check if files were dragged and dropped onto the script\n")
f.write("if \"%~1\"==\"\" (\n")
f.write(" rem No files provided, run in interactive mode\n")
f.write(" \"%SCRIPT_DIR%vconcat.exe\"\n")
f.write(") else (\n")
f.write(" rem Files were provided, pass them to the executable\n")
f.write(" set \"cmd_args=%SCRIPT_DIR%vconcat.exe\"\n")
f.write(" \n")
f.write(" rem Add each argument to the command\n")
f.write(" for %%i in (%*) do (\n")
f.write(" set \"cmd_args=!cmd_args! \"%%~i\"\"\n")
f.write(" )\n")
f.write(" \n")
f.write(" rem Execute the command with all arguments\n")
f.write(" !cmd_args!\n")
f.write(")\n\n")
f.write("if %errorlevel% neq 0 (\n")
f.write(" echo An error occurred while running the script.\n")
f.write(" pause\n")
f.write(")\n\n")
f.write("endlocal\n")
print(f"Launcher script created: {launcher_path}")
return True
def copy_readme():
"""Copy the README file to the dist directory."""
print("Copying README...")
# Get the directory of this script
script_dir = os.path.dirname(os.path.abspath(__file__))
# Path to the README file
readme_path = os.path.join(script_dir, "README.md")
# Path to the dist directory
dist_dir = os.path.join(script_dir, "dist")
# Check if the README file exists
if not os.path.exists(readme_path):
print(f"Warning: {readme_path} not found.")
return False
# Copy the README file
try:
shutil.copy2(readme_path, os.path.join(dist_dir, "README.md"))
return True
except Exception as e:
print(f"Error copying README: {str(e)}")
return False
def main():
"""Main function."""
print("V-CONCAT Build Script")
print("=====================")
# Install dependencies
install_dependencies()
# Check if PyInstaller is installed
if not check_pyinstaller():
print("PyInstaller is not installed.")
if not install_pyinstaller():
print("Failed to install PyInstaller. Please install it manually:")
print("pip install pyinstaller")
return 1
# Build the executable
if not build_executable():
print("Failed to build executable.")
return 1
# Create the launcher script
if not create_launcher_script():
print("Failed to create launcher script.")
return 1
# Copy the README file
copy_readme()
# Copy the config file if it exists
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "vconcat.conf")
dist_dir = os.path.join(script_dir, "dist")
if os.path.exists(config_path):
try:
shutil.copy2(config_path, os.path.join(dist_dir, "vconcat.conf"))
print(f"Config file copied to {os.path.join(dist_dir, 'vconcat.conf')}")
except Exception as e:
print(f"Error copying config file: {str(e)}")
else:
# Create a sample config file
sample_config = {
"prefer_h264": False,
"no_encode": False,
"comment": "This is a configuration file for V-CONCAT. Set prefer_h264 to true to always prefer H.264 codec with 29.97 fps when possible. Set no_encode to true to skip re-encoding completely."
}
try:
with open(os.path.join(dist_dir, "vconcat.conf"), 'w') as f:
json.dump(sample_config, f, indent=4)
print(f"Sample config file created at {os.path.join(dist_dir, 'vconcat.conf')}")
except Exception as e:
print(f"Error creating sample config file: {str(e)}")
print("\nBuild completed successfully!")
print("The executable and launcher script are in the 'dist' directory.")
return 0
if __name__ == "__main__":
sys.exit(main())
@echo off
setlocal enabledelayedexpansion
echo.
echo.
echo M""""""""`M dP
echo Mmmmmm .M 88
echo MMMMP .MMM dP dP 88 .dP .d8888b.
echo MMP .MMMMM 88 88 88888" 88' `88
echo M' .MMMMMMM 88. .88 88 `8b. 88. .88
echo M M `88888P' dP `YP `88888P'
echo MMMMMMMMMMM -*- Created by Zuko -*-
echo.
echo * * * * * * * * * * * * * * * * * * * * *
echo * - V C O N C A T . C M D - *
echo * * * * * * * * * * * * * * * * * * * * *
echo.
echo.
echo.
echo INFO: This tool help you quickly merge multiple videos into one, FASTEST !
echo Files tobe merging should have same ENCODING settings, otherwise output video might be strange, different from inputs.
echo.
echo For example: if you merging a 60fps with a 30fps videos, incorrect timestamp would be in output.
echo actually the tool just calling bellow command after prepared the input lists:
echo.
echo ffmpeg -f concat -safe 0 -i filelist.txt -c:v copy -c:a aac output.mp4
echo.
echo So, you should double check output everytime you use this tool.
echo INFO: "TAB" completion when entering filename is allowed
echo.
echo Author: Zuko [[email protected]]
echo.
pause
rem Check if ffmpeg exists in PATH
where ffmpeg >nul 2>nul
if %errorlevel% neq 0 (
echo FFmpeg not found. Downloading...
rem Download ffmpeg using PowerShell
powershell -Command "& {Invoke-WebRequest -Uri 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' -OutFile 'ffmpeg.zip'}"
rem Extract using PowerShell
powershell -Command "& {Expand-Archive -Path 'ffmpeg.zip' -DestinationPath '.' -Force}"
rem Find the ffmpeg binary directory (it's inside a nested folder)
for /f "delims=" %%a in ('dir /b /ad ffmpeg*') do set "ffmpeg_dir=%%a"
rem Move bin directory contents up
move "!ffmpeg_dir!\bin\*" "." >nul
rem Clean up
rmdir /s /q "!ffmpeg_dir!" >nul
del ffmpeg.zip >nul
rem Get current directory
set "current_dir=%CD%"
echo ""
echo ""
rem Add to PATH permanently for current user using PowerShell
rem Create PowerShell script file
(
echo $currentDir = '!current_dir!'
echo Write-Host "Current directory: $currentDir"
echo $oldPath = [Environment]::GetEnvironmentVariable('Path', 'User'^)
echo Write-Host "Current PATH: $oldPath"
echo if ($oldPath -notlike "*$currentDir*"^) {
echo Write-Host "Updating PATH..."
echo $newPath = "$oldPath;$currentDir"
echo [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'^)
echo Write-Host "PATH updated successfully"
echo } else {
echo Write-Host "Directory already in PATH"
echo }
echo Write-Host "Final PATH:" ([Environment]::GetEnvironmentVariable('Path', 'User'^)^)
) > update_path.ps1
echo Generated PowerShell script:
type update_path.ps1
echo.
echo ""
echo ""
REM pause
rem Execute the PowerShell script
powershell -ExecutionPolicy Bypass -File update_path.ps1
del filelist.txt 2>nul
REM powershell -Command "& {
REM $currentDir = '!current_dir!'
REM Write-Host 'Current directory in PowerShell:' $currentDir
REM $oldPath = [Environment]::GetEnvironmentVariable('Path', 'User')
REM Write-Host 'Current PATH:' $oldPath
REM if ($oldPath -notlike '*' + $currentDir + '*') {
REM Write-Host 'Updating PATH...'
REM $newPath = $currentDir + ';' + $oldPath
REM [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
REM Write-Host 'PATH updated successfully'
REM } else {
REM Write-Host 'Directory already in PATH'
REM }
REM Write-Host 'New PATH:' ([Environment]::GetEnvironmentVariable('Path', 'User'))
REM }"
rem Also update current session PATH
set "PATH=%CD%;%PATH%"
echo FFmpeg downloaded successfully and added to PATH permanently
del filelist.txt 2>nul
goto input_loop
)
rem Initialize temporary file to store file names
del filelist.txt 2>nul
goto input_loop
:input_loop
setlocal DisableDelayedExpansion
set "filename="
set /p "filename=Enter file name (or press Enter to start merge): "
rem Check if input is empty
if "%filename%"=="" goto process_files
rem Remove surrounding quotes if present
set "filename=%filename:"=%"
rem Remove ! char from file name
echo "%filename%" | findstr "!" >nul
if not defined errorlevel set /A "errorlevel=1"
if errorlevel EQU 0 (
if exist "%filename%" (
ren "%filename%" "%filename:!=%"
set "filename=%filename:!=%"
)
)
endlocal & set "filename=%filename%"
setlocal EnableDelayedExpansion
rem Check if file exists
if exist "%filename%" (
rem Convert to absolute path and handle spaces correctly
for %%F in ("%filename%") do set "abs_path=%%~fF"
echo file '!abs_path!' >> filelist.txt
endlocal & goto input_loop
) else (
echo File '%filename%' does not exist. Please try again.
endlocal & goto input_loop
)
:process_files
Check if filelist.txt exists and has content
if not exist filelist.txt (
echo No valid files were provided. Exiting.
goto end
)
rem Execute ffmpeg command
REM ffmpeg -f concat -safe 0 -i filelist.txt -c:v libx264 -c:a aac output.mp4
ffmpeg -f concat -safe 0 -i filelist.txt -c:v copy -c:a aac output.mp4
echo DONE
:end
endlocal
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
V-CONCAT | Advanced Video Concatenation Tool
This script helps concatenate multiple video files into one, handling different
encoding settings by re-encoding files that don't match the most common format.
Author: Based on lite version of cmd script by Zuko [[email protected]]
"""
import os
import sys
import json
import subprocess
import tempfile
import shutil
import traceback
from collections import Counter
import re
from pathlib import Path
import platform
import argparse
import zipfile
import urllib.request
import shtab
# ASCII Art Banner
BANNER = """
M\"\"\"\"\"\"\"\"`M dP
Mmmmmm .M 88
MMMMP .MMM dP dP 88 .dP .d8888b.
MMP .MMMMM 88 88 88888\" 88' `88
M' .MMMMMMM 88. .88 88 `8b. 88. .88
M M `88888P' dP `YP `88888P'
MMMMMMMMMMM -*- Advanced Version -*-
* * * * * * * * * * * * * * * * * * * * *
* - V C O N C A T . P Y - *
* * * * * * * * * * * * * * * * * * * * *
"""
DEBUG_MODE = False
def print_banner():
"""Print the application banner."""
print(BANNER)
print("\nThis tool helps you quickly merge multiple videos into one, handling different encodings!")
print("This advanced version will analyze all input videos and re-encode any that don't match the most common format.")
print("")
print("\nAuthor: Based on lite version of cmd script by Zuko [[email protected]]")
print()
def get_application_path():
"""Get the correct application path regardless of how the application is run."""
if getattr(sys, 'frozen', False):
# Running as compiled executable
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.abspath(__file__))
def load_config():
"""Load configuration from vconcat.conf if it exists."""
config = {}
config_path = os.path.join(get_application_path(), "vconcat.conf")
if os.path.exists(config_path):
try:
with open(config_path, 'r') as f:
config = json.load(f)
print(f"Loaded configuration from {config_path}")
except Exception as e:
print(f"Error loading configuration: {str(e)}")
if config.get('debug_mode', False):
DEBUG_MODE = True
return config
def download_ffmpeg():
"""Download and install FFmpeg if not already installed."""
print("FFmpeg not found. Downloading...")
# Create a temporary directory for downloading
with tempfile.TemporaryDirectory() as temp_dir:
# Download FFmpeg
ffmpeg_zip = os.path.join(temp_dir, "ffmpeg.zip")
ffmpeg_url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
print(f"Downloading FFmpeg from {ffmpeg_url}...")
try:
urllib.request.urlretrieve(ffmpeg_url, ffmpeg_zip)
except Exception as e:
print(f"Error downloading FFmpeg: {str(e)}")
return False
# Extract FFmpeg
print("Extracting FFmpeg...")
try:
with zipfile.ZipFile(ffmpeg_zip, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Find the FFmpeg directory
ffmpeg_dir = None
for item in os.listdir(temp_dir):
if os.path.isdir(os.path.join(temp_dir, item)) and item.startswith("ffmpeg"):
ffmpeg_dir = os.path.join(temp_dir, item)
break
if not ffmpeg_dir:
print("Could not find FFmpeg directory in the extracted files.")
return False
# Copy FFmpeg binaries to the application directory
bin_dir = os.path.join(ffmpeg_dir, "bin")
app_dir = get_application_path()
for file in os.listdir(bin_dir):
src = os.path.join(bin_dir, file)
dst = os.path.join(app_dir, file)
shutil.copy2(src, dst)
# Add application directory to PATH
os.environ["PATH"] = app_dir + os.pathsep + os.environ["PATH"]
# Add to PATH permanently for Windows
if platform.system() == "Windows":
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_ALL_ACCESS) as key:
path_value, _ = winreg.QueryValueEx(key, "Path")
if app_dir not in path_value:
new_path = path_value + os.pathsep + app_dir
winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path)
print("Added FFmpeg to PATH permanently.")
else:
print("FFmpeg already in PATH.")
except Exception as e:
print(f"Could not add FFmpeg to PATH permanently: {str(e)}")
print("FFmpeg will still work for this session.")
print("FFmpeg installed successfully.")
return True
except Exception as e:
print(f"Error extracting FFmpeg: {str(e)}")
return False
def ensure_ffmpeg():
"""Ensure ffmpeg and ffprobe are available."""
ffmpeg_found = False
ffprobe_found = False
# First check if ffmpeg and ffprobe are in PATH
try:
# Use subprocess.run with capture_output to suppress console output
result_ffmpeg = subprocess.run(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
ffmpeg_found = result_ffmpeg.returncode == 0
result_ffprobe = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
ffprobe_found = result_ffprobe.returncode == 0
if ffmpeg_found and ffprobe_found:
# print("FFmpeg and FFprobe found in PATH.")
return True
except FileNotFoundError:
# Not in PATH, continue to next checks
pass
# Check if ffmpeg is in the application directory
app_dir = get_application_path()
ffmpeg_path = os.path.join(app_dir, "ffmpeg.exe")
ffprobe_path = os.path.join(app_dir, "ffprobe.exe")
if os.path.exists(ffmpeg_path) and os.path.exists(ffprobe_path):
# Add application directory to PATH temporarily
os.environ["PATH"] = app_dir + os.pathsep + os.environ["PATH"]
# print(f"Using FFmpeg and FFprobe from application directory: {app_dir}")
return True
# If we get here, we need to download FFmpeg
print("FFmpeg and FFprobe not found. Attempting to download...")
if download_ffmpeg():
return True
print("FFmpeg and FFprobe are required but could not be installed automatically.")
print("Please download them manually from: https://ffmpeg.org/download.html")
return False
def get_video_info(video_path):
"""Get video codec and fps information using ffprobe."""
try:
cmd = [
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=codec_name,r_frame_rate",
"-of", "json",
video_path
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
info = json.loads(result.stdout)
if 'streams' in info and info['streams']:
stream = info['streams'][0]
codec = stream.get('codec_name', 'unknown')
# Parse frame rate (which might be in fraction form like "30000/1001")
r_frame_rate = stream.get('r_frame_rate', 'unknown')
if r_frame_rate != 'unknown':
if '/' in r_frame_rate:
num, den = map(int, r_frame_rate.split('/'))
fps = round(num / den, 3) if den != 0 else 0
else:
fps = float(r_frame_rate)
else:
fps = 0
return {
'codec': codec,
'fps': fps,
'original_fps': r_frame_rate,
'format_key': f"{codec}_{fps}" # Combined key for codec and fps
}
return None
except Exception as e:
print(f"Error analyzing {video_path}: {str(e)}")
return None
def find_most_common_format(video_infos, prefer_h264=False):
"""Find the most common codec and fps combination among the videos."""
if not video_infos:
return None, None
# Count combined codec_fps format keys
format_keys = Counter([info['format_key'] for info in video_infos if info])
if not format_keys:
return None, None
# Get the most common format key
most_common_format = format_keys.most_common(1)[0][0]
most_common_count = format_keys.most_common(1)[0][1]
# print(f"Most common format: {most_common_format} (count: {most_common_count})")
# Extract codec and fps from the format key
codec, fps_str = most_common_format.split('_')
fps = float(fps_str)
# Check if we should prefer H.264 with 29.97 fps
if prefer_h264 and codec != 'h264' and most_common_count >= 3:
# print("Prefer H.264 option is enabled. Using H.264 codec with 29.97 fps instead.")
return 'h264', 29.97
return codec, fps
def sanitize_filename(filename):
"""Sanitize filename to avoid issues with special characters."""
# Replace problematic characters with underscores
sanitized = re.sub(r'[\'!]', '_', filename)
return sanitized
def get_temp_filename(original_path, temp_dir):
"""Generate a temporary filename for re-encoded videos."""
base_name = os.path.basename(original_path)
sanitized_name = sanitize_filename(base_name)
return os.path.join(temp_dir, f"reencoded_{sanitized_name}")
def reencode_video(input_path, output_path, target_codec, target_fps):
"""Re-encode a video to match the target codec and fps."""
try:
cmd = [
"ffmpeg",
"-hide_banner",
"-i", input_path,
"-c:v", target_codec,
"-r", str(target_fps),
"-c:a", "aac", # Always use AAC for audio
"-y", # Overwrite output file if it exists
output_path
]
print(f"Re-encoding {os.path.basename(input_path)} to match common format...")
print(f"Command: {' '.join(cmd)}")
subprocess.run(cmd, check=True)
return True
except Exception as e:
print(f"Error re-encoding {input_path}: {str(e)}")
return False
def concatenate_videos(file_list, output_path):
"""Concatenate videos using ffmpeg's concat demuxer."""
try:
# Create a temporary file list
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_file:
for file_path in file_list:
# Escape single quotes in file paths
escaped_path = file_path.replace("'", "'\\''")
temp_file.write(f"file '{escaped_path}'\n")
temp_file_path = temp_file.name
# Run ffmpeg concat
cmd = [
"ffmpeg",
"-hide_banner",
"-f", "concat",
"-safe", "0",
"-i", temp_file_path,
"-c:v", "copy", # Use copy since all videos now have the same format
"-c:a", "aac", # Always use AAC for audio
"-y", # Overwrite output file if it exists
output_path
]
print(f"\nConcatenating {len(file_list)} videos into {output_path}...")
print(f"Command: {' '.join(cmd)}")
subprocess.run(cmd, check=True)
# Clean up
DEBUG_MODE and cleanup_temp_files(temp_file_path)
return True
except Exception as e:
print(f"Error concatenating videos: {str(e)}")
DEBUG_MODE and cleanup_temp_files(temp_file_path)
return False
def cleanup_temp_files(temp_file_path):
if os.path.exists(temp_file_path):
os.unlink(temp_file_path)
return True
def get_input_files_interactive():
"""Get input files interactively from user."""
input_files = []
print("Enter video file paths (press Enter on an empty line when done):")
# Setup Tab completion
try:
if platform.system() == "Windows":
try:
# Try to use pyreadline3
import pyreadline3
readline = pyreadline3.Readline()
except ImportError:
try:
# Try to use older pyreadline
import pyreadline
readline = pyreadline.Readline()
except ImportError:
print("Tab completion not available. Install pyreadline3 for this feature.")
readline = None
else:
# On Linux/macOS, use built-in readline
import readline
readline = readline
if readline:
def path_completer(text, state):
# Handle relative and absolute paths
if os.path.isabs(text):
base_dir = os.path.dirname(text) if text else os.path.sep
if not os.path.exists(base_dir):
base_dir = os.path.sep
search_text = os.path.basename(text)
else:
base_dir = "."
search_text = text
try:
files = os.listdir(base_dir)
matches = [f for f in files if f.startswith(search_text)]
if os.path.isabs(text):
matches = [os.path.join(os.path.dirname(text), m) for m in matches]
for i, match in enumerate(matches):
full_path = os.path.join(base_dir, match) if not os.path.isabs(text) else match
if os.path.isdir(full_path):
matches[i] = os.path.join(match, "")
if state < len(matches):
return matches[state]
return None
except Exception:
return None
if platform.system() == "Windows":
readline.set_completer(path_completer)
readline.parse_and_bind("tab: complete")
else:
readline.set_completer(path_completer)
readline.parse_and_bind("bind ^I rl_complete")
#print("Tab completion enabled. Press Tab to complete filenames.")
else:
#print("You can use Tab in terminal to complete filenames.")
pass
except Exception as e:
#print(f"Tab completion not available ({str(e)}). You can still enter file paths manually.")
print(traceback.print_exc())
# Get user input
while True:
try:
file_input = input("> ").strip()
if not file_input:
break
# Remove quotes if present
file_input = file_input.strip('"\'')
if os.path.exists(file_input):
input_files.append(os.path.abspath(file_input))
else:
print(f"File not found: {file_input}")
except KeyboardInterrupt:
print("\nInput interrupted.")
break
return input_files
def get_output_file_interactive():
"""Get output file path interactively from user."""
# output_file = input("\nEnter output file path (default: output.mp4): ").strip() or "output.mp4"
print("output file path default: output.mp4")
output_file = "output.mp4"
output_file = output_file.strip('"\'')
return output_file
def get_main_parser():
parser = argparse.ArgumentParser(prog="vconcat",description="V-CONCAT Advanced Video Concatenation Tool")
shtab.add_argument_to(parser, ["-s", "--print-completion"]) # magic!
# file & directory tab complete
return parser
def parse_arguments():
"""Parse command line arguments."""
parser = get_main_parser()
parser.add_argument("input_files", nargs="*", help="Input video files to concatenate").complete = shtab.FILE
parser.add_argument("-o", "--output", help="Output file path (default: output.mp4)")
parser.add_argument("-i", "--interactive", action="store_true", help="Use interactive mode even if files are provided")
parser.add_argument("--prefer-h264", "-ph4", action="store_true", help="Prefer H.264 codec with 29.97 fps when most common format is different")
parser.add_argument("--no-encode", action="store_true", help="Disable re-encoding completely, just concatenate files as they are")
return parser.parse_args()
def main():
"""Main function to run the video concatenation tool."""
print_banner()
if not ensure_ffmpeg():
input("Press Enter to exit...")
return
# Load configuration from file
config = load_config()
# Parse command line arguments
args = parse_arguments()
# Check if no-encode is enabled (command line takes precedence over config)
no_encode = args.no_encode or config.get('no_encode', False)
# Merge configuration with command line arguments
# Command line arguments take precedence over config file
# If no-encode is enabled, prefer_h264 is disabled
prefer_h264 = False if no_encode else (args.prefer_h264 or config.get('prefer_h264', False))
# Get input files
input_files = []
if args.input_files and not args.interactive:
# Use files provided as command line arguments
for file_path in args.input_files:
if os.path.exists(file_path):
input_files.append(os.path.abspath(file_path))
else:
print(f"File not found: {file_path}")
else:
# Get files interactively
input_files = get_input_files_interactive()
if not input_files:
print("No valid input files provided. Exiting.")
input("Press Enter to exit...")
return
# Get output file path
if args.output and not args.interactive:
output_file = args.output
else:
output_file = get_output_file_interactive()
# Analyze all videos
print("\nAnalyzing video files...")
video_infos = []
for file_path in input_files:
print(f"Analyzing {os.path.basename(file_path)}...")
info = get_video_info(file_path)
if info:
info['path'] = file_path
video_infos.append(info)
print(f" - Codec: {info['codec']}, FPS: {info['fps']}")
else:
print(f" - Failed to analyze {file_path}")
if not video_infos:
print("No valid video files to process. Exiting.")
input("Press Enter to exit...")
return
# Count format keys
format_keys = Counter([info['format_key'] for info in video_infos if info])
# Find most common format (even if we're not re-encoding, we need to know the target format)
most_common_format = format_keys.most_common(1)[0][0]
target_codec, target_fps_str = most_common_format.split('_')
target_fps = float(target_fps_str)
# Check if no-encode is enabled and there are different formats
if no_encode:
if len(format_keys) > 1:
if platform.system() == "Windows":
os.system('cls')
else:
os.system('clear')
print("\nWARNING: Multiple video formats detected but --no-encode is enabled.\n")
print(f"\033[34m============== Most common format =============\033[0m")
print(f" - Codec= \033[32m{target_codec}\033[0m")
print(f" - FPS= \033[32m{target_fps}\033[0m")
print(f"\n\033[33m======== Videos with different formats ========\033[0m\n")
# List videos with different formats
for info in video_infos:
if info['codec'] != target_codec or abs(info['fps'] - target_fps) > 0.001:
print(f" * \033[38;5;203m{os.path.basename(info['path'])}\033[0m << Codec = : \033[38;5;215m{info['codec']}\033[0m, FPS = \033[38;5;215m{info['fps']}\033[0m")
# Ask user if they want to continue
print("\nContinuing without re-encoding may cause playback issues.")
user_choice = input("Do you want to continue? (y/n): ").strip().lower()
if user_choice != 'y' and user_choice != 'yes':
print("Operation cancelled by user.")
return
print("\nContinuing with concatenation without re-encoding...")
else:
print("\nNo re-encoding needed. All videos have the same format.")
# Create a temporary file list for concatenation
with tempfile.TemporaryDirectory() as temp_dir:
# Concatenate all videos without re-encoding
if concatenate_videos([info['path'] for info in video_infos], output_file):
print(f"\nSuccess! Concatenated video saved to: {output_file}")
else:
print("\nFailed to concatenate videos.")
input("\nPress Enter to exit...")
return
# If we get here, we're re-encoding
# Find most common format with prefer_h264 option
target_codec, target_fps = find_most_common_format(video_infos, prefer_h264)
print(f"\nMost common format: Codec={target_codec}, FPS={target_fps}")
# Create temporary directory for re-encoded files
with tempfile.TemporaryDirectory() as temp_dir:
final_file_list = []
# Process each video
for info in video_infos:
if info['codec'] == target_codec and abs(info['fps'] - target_fps) < 0.001:
# No need to re-encode
print(f"{os.path.basename(info['path'])} already matches target format.")
final_file_list.append(info['path'])
else:
# Need to re-encode
print(f"{os.path.basename(info['path'])} needs re-encoding:")
print(f" - Current: Codec={info['codec']}, FPS={info['fps']}")
print(f" - Target: Codec={target_codec}, FPS={target_fps}")
temp_output = get_temp_filename(info['path'], temp_dir)
if reencode_video(info['path'], temp_output, target_codec, target_fps):
final_file_list.append(temp_output)
else:
print(f"Skipping {os.path.basename(info['path'])} due to re-encoding failure.")
# Concatenate all videos
if final_file_list:
if concatenate_videos(final_file_list, output_file):
print(f"\nSuccess! Concatenated video saved to: {output_file}")
else:
print("\nFailed to concatenate videos.")
else:
print("\nNo videos to concatenate after processing.")
input("\nPress Enter to exit...")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment