Last active
March 17, 2025 11:01
-
-
Save tansautn/4bbce98f0d9908c647ba28e29bbf628a to your computer and use it in GitHub Desktop.
Concat multiple video file to one file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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