├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── banner.png ├── demo.gif ├── demo.png ├── icon.ico ├── logo.png └── segments.png ├── inno ├── build setup with ffmpeg.iss ├── build setup.iss └── environment.iss ├── preview.py ├── preview.spec ├── requirements.txt └── utils ├── commander.py ├── ffmpeg.py ├── glob.py └── utils.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Tetrax-10 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | ffmpeg 4 | previews 5 | Output 6 | dev 7 | __pycache__ 8 | *.mp4 9 | *.mkv 10 | *.avi 11 | *.mov 12 | *.m4a 13 | *.m4v 14 | *.mpg 15 | *.mpeg 16 | *.wmv 17 | *.webm 18 | *.flv -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/build": true, 4 | "**/ffmpeg": true, 5 | "**/dist": true, 6 | "**/inno": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 - present Raghavan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Batch Preview Generator 2 | 3 | **Generates preview videos and GIFs from videos using FFmpeg CLI in batch.** 4 | 5 | 6 | 7 | ## Installation 8 | 9 | Download and install the [Latest version](https://github.com/Tetrax-10/batch-preview-generator/releases/latest) from the releases page. Done 🎉. 10 | 11 | If you dont have [FFmpeg](https://ffmpeg.org/) installed then download the [FFmpeg included version](https://github.com/Tetrax-10/batch-preview-generator/releases/latest) or download FFmpeg from [here](https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-essentials.7z). 12 | 13 | ## Demo 14 | 15 | **[PSY - GANGNAM STYLE](https://www.youtube.com/watch?v=9bZkp7q19f0) music video to this 15 seconds preview 👇** 16 | 17 | ![demo preview](https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/main/assets/demo.gif) 18 | 19 |
20 | 21 | **Run `preview` in terminal without any arguments (flags) to initiate interactive prompts.** 22 | 23 | ![demo preview](https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/main/assets/demo.png) 24 | **Note:** This Screenshot reflects initial release and new changes may not be represented. 25 | 26 |
27 | 28 | ## CLI docs 29 | 30 | You can use this as a CLI by just giving a valid argument(s). 31 | 32 | The above **Gangnam Style** gif can be created with this command. 33 | 34 | ```powershell 35 | preview -p "Gangnam Style.mp4" -s 15 -sk 7.0 -sp -g 36 | ``` 37 | 38 | ### Arguments 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
argsFull argsDescriptionDefaultType
-p--pathPath of the video or folder for batch processingCWDstring
-o--outOutput folder for generated previewsCWDstring
-r--resolutionPreview video resolution360int
-s--segmentsNo. of segments in a preview video10 (check code)int
-sd--sdurationDuration of a segment1.0 (0.1 - n.n)float
-sk--skipSkips the first n seconds of a video, mainly used to skip intros and filler. For movies, set this value higher according to the intro duration20float
-sp--samepathWhen passing this argument, the output folder is set to the input folder. when using this the --out path should be relativepresent or not
-a--audioPreviews will be generated with audiopresent or not
-g--gifPreviews will be generated in the GIF format (takes more time & space)present or not
-f--fpsPreview video FPSmp4:24/gif:10int
-q--qualityPreview video quality (low, normal, high)normalstring
-c--compressionPreview video compression modes:
fast but low quality output and bigger file size.
slow gives good quality and reasonable size but little slower.
veryslow gives best quality and least file size but its very slow.
slowstring
-cli--cliRun as a CLI without changing default arguments. If no arguments are provided, the program will act in prompt mode. To prevent that, you can use this flagpresent or not
-cuda--cudaUses cuda cores for fast processing (Nvidia GPUs only)present or not
-v--versionPrints version infopresent or not
-h--helpLists all commands with its descriptionpresent or not
154 | 155 | If you want to run this as a CLI without providing or changing default arguments then just run 156 | 157 | ```sh 158 | preview -cli 159 | ``` 160 | 161 |
162 | 163 | ## FAQ 164 | 165 | ### 1. What are segments `-s`, `--segments`? 166 | 167 | Segments are small videos extracted from the input video with a duration specified by `--sduration`. For example, if you set `--segments` to **10** and `--sduration` to **2**, each segment will be **2** seconds long. Therefore, the total duration of the preview will be **20** seconds, as **10** segments each contribute **2** seconds. 168 | 169 | ### 2. From which part of the video are the segments extracted? 170 | 171 | Let's say you set `--segments` to **3** and `--sduration` to **5**. In this scenario, the input video is evenly split into **3** parts, and the first **5** seconds from each part are extracted for previews. Subsequently, these segments are concatenated and converted into a single video or gif. Thus the resulting preview will be of **5x3 = 15 seconds**. 172 | 173 | The red parts are extracted for previews 174 | 175 | ![segments](https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/main/assets/segments.png) 176 | 177 |
178 | 179 | ## Development 180 | 181 | ##### Environment setup 182 | 183 | ```sh 184 | git clone https://github.com/Tetrax-10/batch-preview-generator.git 185 | cd batch-preview-generator 186 | pip install -r requirements.txt 187 | ``` 188 | 189 | ##### Run 190 | 191 | ```sh 192 | python preview.py 193 | ``` 194 | 195 | ##### Build executable 196 | 197 | ```sh 198 | pyinstaller preview.spec 199 | ``` 200 | 201 | Make sure to add your "dist" folder to the PATH so that when you run preview, it refers to your "dist" executable. Additionally, also ensure that the path of the installed "preview.exe" is removed during development. 202 | 203 | The installer is compiled with the [Inno Setup Compiler](https://jrsoftware.org/isdl.php), and there's no need to perform this step during the development of Batch Preview Generator, as it is only used for distribution 204 | 205 |
206 | 207 | ### Known bugs 208 | 209 | 1. When this program is installed and uninstalled it leaves this string ";;" in PATH environmental variable, it's not an issue as it doesn't affect the env vars but its a bloat, So please help me fix this as I'm not good with Inno Setup Compiler 210 | 211 |
212 | 213 | ### Assist required 214 | 215 | 1. Help me implement H/W acceleration for Radeon graphics as I don't have an AMD gpu. 216 | 2. Help me to build/test executable for `linux` and `mac os`. 217 | 3. Help me fix the `known bugs`. 218 | 219 |
220 | 221 | ### Support 222 | 223 | Like This Tool? Gimme Some ❤️ by Liking this Repository. 224 | -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/banner.png -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/demo.gif -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/demo.png -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/icon.ico -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/logo.png -------------------------------------------------------------------------------- /assets/segments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tetrax-10/batch-preview-generator/dc0b1beb003075a3acd57922757ef6afaba50ba4/assets/segments.png -------------------------------------------------------------------------------- /inno/build setup with ffmpeg.iss: -------------------------------------------------------------------------------- 1 | #include "environment.iss" 2 | 3 | #define MyAppName "Batch Preview Generator" 4 | #define MyAppVersion "1.3" 5 | #define MyAppPublisher "Tetrax-10" 6 | #define MyURL "https://github.com/Tetrax-10" 7 | #define MyAppURL "https://github.com/Tetrax-10/batch-preview-generator" 8 | #define MyAppExeName "preview.exe" 9 | 10 | [Setup] 11 | AppId={{F46C7D5F-ED1C-4AFF-9027-23273D796FD3} 12 | AppName={#MyAppName} 13 | AppVersion={#MyAppVersion} 14 | AppPublisher={#MyAppPublisher} 15 | AppPublisherURL={#MyURL} 16 | AppSupportURL={#MyAppURL} 17 | AppUpdatesURL={#MyAppURL} 18 | DefaultDirName={code:GetProgramFiles}\Batch Preview generator 19 | DisableProgramGroupPage=yes 20 | LicenseFile=..\LICENSE 21 | OutputBaseFilename=Batch-Preview-Installer-FFmpeg-included-v{#MyAppVersion} 22 | SetupIconFile=..\assets\icon.ico 23 | Compression=lzma 24 | SolidCompression=yes 25 | WizardStyle=modern 26 | ChangesEnvironment=true 27 | 28 | [Languages] 29 | Name: "english"; MessagesFile: "compiler:Default.isl" 30 | Name: "armenian"; MessagesFile: "compiler:Languages\Armenian.isl" 31 | Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl" 32 | Name: "bulgarian"; MessagesFile: "compiler:Languages\Bulgarian.isl" 33 | Name: "catalan"; MessagesFile: "compiler:Languages\Catalan.isl" 34 | Name: "corsican"; MessagesFile: "compiler:Languages\Corsican.isl" 35 | Name: "czech"; MessagesFile: "compiler:Languages\Czech.isl" 36 | Name: "danish"; MessagesFile: "compiler:Languages\Danish.isl" 37 | Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl" 38 | Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl" 39 | Name: "french"; MessagesFile: "compiler:Languages\French.isl" 40 | Name: "german"; MessagesFile: "compiler:Languages\German.isl" 41 | Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" 42 | Name: "hungarian"; MessagesFile: "compiler:Languages\Hungarian.isl" 43 | Name: "icelandic"; MessagesFile: "compiler:Languages\Icelandic.isl" 44 | Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl" 45 | Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" 46 | Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl" 47 | Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" 48 | Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl" 49 | Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" 50 | Name: "slovak"; MessagesFile: "compiler:Languages\Slovak.isl" 51 | Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl" 52 | Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" 53 | Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl" 54 | Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" 55 | 56 | [Tasks] 57 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" 58 | 59 | [Files] 60 | Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion 61 | Source: "..\ffmpeg\*"; DestDir: "{app}"; Flags: ignoreversion 62 | 63 | [Icons] 64 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 65 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon 66 | 67 | [Run] 68 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent 69 | 70 | [Code] 71 | 72 | { if sys 32 then "C:\Program Files (x86)" else "C:\Program Files" } 73 | function GetProgramFiles(Param: string): string; 74 | begin 75 | if IsWin64 then Result := ExpandConstant('{commonpf64}') 76 | else Result := ExpandConstant('{commonpf32}') 77 | end; 78 | 79 | { add to path on install } 80 | procedure CurStepChanged(CurStep: TSetupStep); 81 | begin 82 | if CurStep = ssPostInstall 83 | then EnvAddPath(ExpandConstant('{app}')); 84 | end; 85 | 86 | { remove from path on uninstall } 87 | procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); 88 | begin 89 | if CurUninstallStep = usPostUninstall 90 | then EnvRemovePath(ExpandConstant('{app}')); 91 | end; -------------------------------------------------------------------------------- /inno/build setup.iss: -------------------------------------------------------------------------------- 1 | #include "environment.iss" 2 | 3 | #define MyAppName "Batch Preview Generator" 4 | #define MyAppVersion "1.3" 5 | #define MyAppPublisher "Tetrax-10" 6 | #define MyURL "https://github.com/Tetrax-10" 7 | #define MyAppURL "https://github.com/Tetrax-10/batch-preview-generator" 8 | #define MyAppExeName "preview.exe" 9 | 10 | [Setup] 11 | AppId={{F46C7D5F-ED1C-4AFF-9027-23273D796FD3} 12 | AppName={#MyAppName} 13 | AppVersion={#MyAppVersion} 14 | AppPublisher={#MyAppPublisher} 15 | AppPublisherURL={#MyURL} 16 | AppSupportURL={#MyAppURL} 17 | AppUpdatesURL={#MyAppURL} 18 | DefaultDirName={code:GetProgramFiles}\Batch Preview generator 19 | DisableProgramGroupPage=yes 20 | LicenseFile=..\LICENSE 21 | OutputBaseFilename=Batch-Preview-Installer-v{#MyAppVersion} 22 | SetupIconFile=..\assets\icon.ico 23 | Compression=lzma 24 | SolidCompression=yes 25 | WizardStyle=modern 26 | ChangesEnvironment=true 27 | 28 | [Languages] 29 | Name: "english"; MessagesFile: "compiler:Default.isl" 30 | Name: "armenian"; MessagesFile: "compiler:Languages\Armenian.isl" 31 | Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl" 32 | Name: "bulgarian"; MessagesFile: "compiler:Languages\Bulgarian.isl" 33 | Name: "catalan"; MessagesFile: "compiler:Languages\Catalan.isl" 34 | Name: "corsican"; MessagesFile: "compiler:Languages\Corsican.isl" 35 | Name: "czech"; MessagesFile: "compiler:Languages\Czech.isl" 36 | Name: "danish"; MessagesFile: "compiler:Languages\Danish.isl" 37 | Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl" 38 | Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl" 39 | Name: "french"; MessagesFile: "compiler:Languages\French.isl" 40 | Name: "german"; MessagesFile: "compiler:Languages\German.isl" 41 | Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" 42 | Name: "hungarian"; MessagesFile: "compiler:Languages\Hungarian.isl" 43 | Name: "icelandic"; MessagesFile: "compiler:Languages\Icelandic.isl" 44 | Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl" 45 | Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" 46 | Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl" 47 | Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" 48 | Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl" 49 | Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" 50 | Name: "slovak"; MessagesFile: "compiler:Languages\Slovak.isl" 51 | Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl" 52 | Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" 53 | Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl" 54 | Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" 55 | 56 | [Tasks] 57 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" 58 | 59 | [Files] 60 | Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion 61 | 62 | [Icons] 63 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 64 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon 65 | 66 | [Run] 67 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent 68 | 69 | [Code] 70 | 71 | { if sys 32 then "C:\Program Files (x86)" else "C:\Program Files" } 72 | function GetProgramFiles(Param: string): string; 73 | begin 74 | if IsWin64 then Result := ExpandConstant('{commonpf64}') 75 | else Result := ExpandConstant('{commonpf32}') 76 | end; 77 | 78 | { add to path on install } 79 | procedure CurStepChanged(CurStep: TSetupStep); 80 | begin 81 | if CurStep = ssPostInstall 82 | then EnvAddPath(ExpandConstant('{app}')); 83 | end; 84 | 85 | { remove from path on uninstall } 86 | procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); 87 | begin 88 | if CurUninstallStep = usPostUninstall 89 | then EnvRemovePath(ExpandConstant('{app}')); 90 | end; -------------------------------------------------------------------------------- /inno/environment.iss: -------------------------------------------------------------------------------- 1 | [Code] 2 | const EnvironmentKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; 3 | 4 | procedure EnvAddPath(Path: string); 5 | var 6 | Paths: string; 7 | begin 8 | { Retrieve current path (use empty string if entry not exists) } 9 | if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 10 | then Paths := ''; 11 | 12 | { Skip if string already found in path } 13 | if Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';') > 0 then exit; 14 | 15 | { App string to the end of the path variable } 16 | Paths := Paths + ';'+ Path +';' 17 | 18 | { Overwrite (or create if missing) path environment variable } 19 | if RegWriteStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 20 | then Log(Format('The [%s] added to PATH: [%s]', [Path, Paths])) 21 | else Log(Format('Error while adding the [%s] to PATH: [%s]', [Path, Paths])); 22 | end; 23 | 24 | procedure EnvRemovePath(Path: string); 25 | var 26 | Paths: string; 27 | P: Integer; 28 | begin 29 | { Skip if registry entry not exists } 30 | if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) then 31 | exit; 32 | 33 | { Skip if string not found in path } 34 | P := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';'); 35 | if P = 0 then exit; 36 | 37 | { Update path variable } 38 | Delete(Paths, P - 1, Length(Path) + 1); 39 | 40 | { Overwrite path environment variable } 41 | if RegWriteStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 42 | then Log(Format('The [%s] removed from PATH: [%s]', [Path, Paths])) 43 | else Log(Format('Error while removing the [%s] from PATH: [%s]', [Path, Paths])); 44 | end; -------------------------------------------------------------------------------- /preview.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from termcolor import colored 4 | from progress.bar import IncrementalBar 5 | 6 | import utils.commander as commander 7 | import utils.ffmpeg as ffmpeg 8 | import utils.glob as glob 9 | import utils.utils as utils 10 | import tempfile 11 | 12 | 13 | def process_previews(args, video_files): 14 | temp_folder_path = glob.join_path(tempfile.gettempdir(), "preview-temp") 15 | glob.delete_folder(temp_folder_path) 16 | glob.create_folder(temp_folder_path) 17 | 18 | if not args.samepath: 19 | glob.create_folder(args.out) 20 | 21 | is_invalid_duration = False 22 | 23 | if len(video_files): 24 | for index, video_file in enumerate(video_files): 25 | file_name = glob.get_file_name(video_file) 26 | file_extension = glob.get_file_name(video_file, "ext") 27 | relative_path = f"{file_name}.{file_extension}" if os.path.relpath(video_file, args.path) == "." else os.path.relpath(video_file, args.path) 28 | video_duration = ffmpeg.get_video_duration(video_file) 29 | 30 | expected_preview_duration = round(args.segments * args.sduration) 31 | if expected_preview_duration < round(video_duration): 32 | ratio = round(video_duration / args.segments, 3) 33 | temp_file_contents = "" 34 | 35 | video_segment_prog = IncrementalBar(utils.wrap_text(relative_path), max=round(args.segments) + 1, suffix="%(percent)d%%") 36 | 37 | count = 0 38 | v_bitrate = ffmpeg.get_video_bitrate(video_file) 39 | a_bitrate = ffmpeg.get_audio_bitrate(video_file) 40 | while count < round(args.segments): 41 | if count == 0: 42 | video_segment_prog.update() # prints the progress bar even before finishing this loop event 43 | start_time = args.skip if count == 0 else round(count * ratio, 3) 44 | ffmpeg.generate_preview_chunck(video_file, start_time, v_bitrate, a_bitrate, args, temp_folder_path, f"{index}-{count}") 45 | temp_file_contents += f"file '{index}-{count}.mp4'\n" 46 | video_segment_prog.next() 47 | count += 1 48 | 49 | temp_file_path = glob.join_path(temp_folder_path, f"{index}.txt") 50 | with open(temp_file_path, "w") as file: 51 | file.write(temp_file_contents) 52 | 53 | if args.samepath: 54 | out = glob.join_path(glob.get_dirname(video_file), args.out) 55 | else: 56 | out = args.out 57 | 58 | glob.create_folder(out) 59 | ffmpeg.generate_preview(temp_file_path, glob.join_path(out, f"{file_name} preview"), args) 60 | 61 | video_segment_prog.next() 62 | video_segment_prog.finish() 63 | else: 64 | if video_duration > 0: 65 | print(colored(f"skipped [Invalid duration ({round(video_duration)}:{expected_preview_duration})]: ", "yellow"), relative_path) 66 | else: 67 | print(colored("skipped [Unavailable duration]", "yellow"), relative_path) 68 | 69 | is_invalid_duration = True 70 | else: 71 | print(colored("No videos found in the specified folder", "red")) 72 | 73 | if is_invalid_duration: 74 | print() 75 | print(colored("Warning codes:", "yellow")) 76 | print(colored(" 1. Unavailable duration: Can't fetch the video duration", "yellow")) 77 | print(colored(" 2. Invalid duration (video duration:expected preview duration): Estimated preview video is equal or longer than the actual video", "yellow")) 78 | 79 | glob.delete_folder(temp_folder_path) 80 | 81 | 82 | if __name__ == "__main__": 83 | args = commander.init() 84 | commander.log_args(args) 85 | 86 | video_files = glob.get_all_video_files(args.path) 87 | if video_files == False: 88 | commander.exit_program() 89 | else: 90 | process_previews(args, video_files) 91 | 92 | commander.exit_program() 93 | -------------------------------------------------------------------------------- /preview.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['preview.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | ) 16 | pyz = PYZ(a.pure) 17 | 18 | exe = EXE( 19 | pyz, 20 | a.scripts, 21 | a.binaries, 22 | a.datas, 23 | [], 24 | name='preview', 25 | debug=False, 26 | bootloader_ignore_signals=False, 27 | strip=False, 28 | upx=True, 29 | upx_exclude=[], 30 | runtime_tmpdir=None, 31 | console=True, 32 | disable_windowed_traceback=False, 33 | argv_emulation=False, 34 | target_arch=None, 35 | codesign_identity=None, 36 | entitlements_file=None, 37 | icon='assets/icon.ico', 38 | ) 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | termcolor==2.5.0 2 | progress==1.6 3 | pyreadline3==3.5.4 4 | pyinstaller==6.11.0 -------------------------------------------------------------------------------- /utils/commander.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import readline 3 | import os 4 | import sys 5 | import glob as globpy 6 | 7 | from termcolor import colored 8 | 9 | import utils.glob as glob 10 | import utils.ffmpeg as ffmpeg 11 | 12 | shared = {"args": None} 13 | 14 | 15 | def init(): 16 | version = "1.3" 17 | 18 | default_path = glob.get_cwd() 19 | default_out = glob.join_path(glob.get_cwd(), "previews") 20 | default_resolution = 360 21 | default_segments = 10 22 | default_sduration = 1.0 23 | default_skip_first_n_sec = 20.0 24 | default_audio = False 25 | default_gif = False 26 | default_gif_fps = 15 27 | default_fps = 24 28 | default_quality = "normal" 29 | default_compression = "slow" 30 | 31 | parser = argparse.ArgumentParser(description=f'Generates preview videos and GIFs using FFmpeg CLI in batch. Visit {colored("https://github.com/Tetrax-10/batch-preview-generator#arguments", "blue")} to view detailed docs') 32 | parser.add_argument("-p", "--path", help="Path of the video or folder for batch processing", type=str, metavar="") 33 | parser.add_argument("-o", "--out", help="Output folder for generated previews", type=str, metavar="") 34 | parser.add_argument("-r", "--resolution", help="Preview video resolution", type=int, metavar="") 35 | parser.add_argument("-s", "--segments", help="No. of segments in a preview video", type=int, metavar="") 36 | parser.add_argument("-sd", "--sduration", help="Duration of a segment", type=float, metavar="") 37 | parser.add_argument("-sk", "--skip", help="Skip first n seconds", type=str, metavar="") 38 | parser.add_argument("-sp", "--samepath", help="The output will be generated in the input path folder", action="store_true") 39 | parser.add_argument("-a", "--audio", help="Previews will be generated with audio", action="store_true") 40 | parser.add_argument("-g", "--gif", help="Previews will be generated in the GIF format (takes more time & space)", action="store_true") 41 | parser.add_argument("-f", "--fps", help="Preview video FPS", type=int, metavar="") 42 | parser.add_argument("-q", "--quality", help="Preview quality (low/normal/high)", type=str, metavar="") 43 | parser.add_argument("-c", "--compression", help="Preview compression mode (fast/slow/veryslow)", type=str, metavar="") 44 | parser.add_argument("-cli", "--cli", help="Run as a CLI without changing default args", action="store_true") 45 | parser.add_argument("-cuda", "--cuda", help="Hardware acceleration: Uses Nvidia's cuda cores", action="store_true") 46 | parser.add_argument("-v", "--version", help="Prints version info", action="store_true") 47 | 48 | args = parser.parse_args() 49 | 50 | if any(value is not None and value is not False for value in vars(args).values()): 51 | args.cli = True 52 | 53 | shared["args"] = vars(args) 54 | 55 | if args.version == True: 56 | print(version) 57 | sys.exit() 58 | 59 | if not ffmpeg.check("ffmpeg -version") or not ffmpeg.check("ffprobe -version"): 60 | exit_program() 61 | 62 | if not any(value is not None and value is not False for value in vars(args).values()): 63 | 64 | def pathCompleter(text, state): 65 | matches = [] 66 | for x in globpy.glob(text + "*"): 67 | if not os.path.isfile(x): 68 | x += "/" 69 | matches.append(x.replace("\\", "/")) 70 | return matches[state] 71 | 72 | try: 73 | readline.set_completer_delims("\t") 74 | readline.parse_and_bind("tab: complete") 75 | readline.set_completer(pathCompleter) 76 | 77 | input_path = input(colored(f'Path of the video or folder ({colored(default_path, "yellow")}{colored("): ", "blue")}', "blue")).strip() 78 | args.path = glob.get_abs_path(glob.correct_path(input_path)) if input_path != "" else default_path 79 | 80 | if glob.is_file(args.path): 81 | default_out = glob.join_path(glob.get_dirname(args.path), "previews") 82 | else: 83 | default_out = glob.join_path(args.path, "previews") 84 | input_out = input(colored(f'Output folder ({colored(default_out, "yellow")}{colored("): ", "blue")}', "blue")).strip() 85 | args.out = glob.get_abs_path(glob.correct_path(input_out)) if input_out != "" else default_out 86 | 87 | readline.set_completer(None) 88 | 89 | input_resolution = input(colored(f'Resolution ({colored(default_resolution, "yellow")}{colored("): ", "blue")}', "blue")) 90 | args.resolution = int(input_resolution) if input_resolution != "" else default_resolution 91 | 92 | input_segments = input(colored(f'No. of segments ({colored(default_segments, "yellow")}{colored("): ", "blue")}', "blue")) 93 | args.segments = int(input_segments) if input_segments != "" else default_segments 94 | 95 | input_sduration = input(colored(f'Segment duration ({colored(default_sduration, "yellow")}{colored("): ", "blue")}', "blue")) 96 | args.sduration = float(input_sduration) if input_sduration != "" else default_sduration 97 | 98 | input_skip = input(colored(f'Skip first n seconds ({colored(default_skip_first_n_sec, "yellow")}{colored("): ", "blue")}', "blue")) 99 | args.skip = float(input_skip) if input_skip != "" else default_skip_first_n_sec 100 | 101 | input_audio = input(colored(f'Audio ({colored("false", "yellow")}{colored("): ", "blue")}', "blue")).strip() 102 | args.audio = True if input_audio.lower() == "true" else False 103 | 104 | if not args.audio: 105 | input_gif = input(colored(f'Gif ({colored("false", "yellow")}{colored("): ", "blue")}', "blue")).strip() 106 | args.gif = True if input_gif.lower() == "true" else False 107 | 108 | input_fps = input(colored(f'FPS ({colored(default_gif_fps if args.gif else default_fps, "yellow")}{colored("): ", "blue")}', "blue")).strip() 109 | args.fps = int(input_fps) if input_fps != "" else (default_gif_fps if args.gif else default_fps) 110 | 111 | input_quality = input(colored(f'Quality (low/normal/high) ({colored(default_quality, "yellow")}{colored("): ", "blue")}', "blue")).strip() 112 | args.quality = input_quality.lower() if input_quality != "" else default_quality 113 | 114 | input_compression = input(colored(f'Compression mode (fast/slow/veryslow) ({colored(default_compression, "yellow")}{colored("): ", "blue")}', "blue")).strip() 115 | args.compression = input_compression.lower() if input_compression != "" else default_compression 116 | 117 | input_hwacc = input(colored(f'Hardware acceleration (false/cuda) ({colored("false", "yellow")}{colored("): ", "blue")}', "blue")).strip() 118 | args.cuda = True if input_hwacc.lower() == "cuda" else False 119 | 120 | os.system("cls") 121 | except Exception as err: 122 | print() 123 | if "invalid literal for int()" in str(err): 124 | print(colored("Invalid input type, That field only accepts numbers", "red")) 125 | else: 126 | print(colored("Invalid input type", "red")) 127 | 128 | exit_program() 129 | 130 | else: 131 | args.path = glob.get_abs_path(glob.correct_path(args.path)) if args.path else default_path 132 | args.out = glob.correct_path(args.out) if args.out else default_out 133 | args.resolution = args.resolution if args.resolution else default_resolution 134 | args.segments = args.segments if args.segments else default_segments 135 | args.sduration = args.sduration if args.sduration else default_sduration 136 | args.skip = float(args.skip) if args.skip else default_skip_first_n_sec 137 | args.audio = args.audio if args.audio else default_audio 138 | args.gif = args.gif if args.gif else default_gif 139 | args.fps = args.fps if args.fps else (default_gif_fps if args.gif else default_fps) 140 | args.quality = args.quality.lower() if args.quality else default_quality 141 | args.compression = args.compression.lower() if args.compression else default_compression 142 | 143 | print() 144 | 145 | args.version = version 146 | 147 | if args.samepath: 148 | if default_out != args.out: 149 | if glob.is_abs(args.out): 150 | print(colored("Outpath should be a relative path when using --samepath", "red")) 151 | exit_program() 152 | else: 153 | args.out = "." 154 | else: 155 | args.out = glob.get_abs_path(args.out) 156 | 157 | if args.gif: 158 | args.audio = False 159 | 160 | args.quality = "high" if args.quality == "high" else ("low" if args.quality == "low" else default_quality) 161 | args.compression = "veryslow" if args.compression == "veryslow" else ("fast" if args.compression == "fast" else default_compression) 162 | 163 | shared["args"] = vars(args) 164 | return args 165 | 166 | 167 | def log_args(args): 168 | print(colored("Configuration:", "yellow", attrs=["bold", "underline"])) 169 | print() 170 | print(colored("Input Path:", "blue"), colored(args.path, "yellow")) 171 | print(colored("Output path:", "blue"), colored(args.out, "yellow")) 172 | print(colored("Resolution:", "blue"), colored(args.resolution, "yellow")) 173 | print(colored("No. of Segments:", "blue"), colored(args.segments, "yellow")) 174 | print(colored("Segment Duration:", "blue"), colored(args.sduration, "yellow")) 175 | print(colored("Skip first n sec:", "blue"), colored(args.skip, "yellow")) 176 | if not args.gif: 177 | print(colored("Audio:", "blue"), colored(args.audio, "yellow")) 178 | if not args.audio: 179 | print(colored("Gif:", "blue"), colored(args.gif, "yellow")) 180 | print(colored("FPS:", "blue"), colored(args.fps, "yellow")) 181 | print(colored("Quality:", "blue"), colored(args.quality, "yellow")) 182 | print(colored("Compression mode:", "blue"), colored(args.compression, "yellow")) 183 | print(colored("Hardware acceleration:", "blue"), colored("cuda" if args.cuda else "False", "yellow")) 184 | print() 185 | 186 | 187 | def get_args(): 188 | return shared["args"] 189 | 190 | 191 | def exit_program(): 192 | print() 193 | if shared["args"]["cli"] != True: 194 | input(colored("Press enter to exit...", "green")) 195 | sys.exit() 196 | -------------------------------------------------------------------------------- /utils/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from termcolor import colored 4 | 5 | 6 | def check(cmd): 7 | output = run_cmd(cmd) 8 | if "not recognized" in output.stderr: 9 | 10 | unavailable_tool = "" 11 | if "ffmpeg" in output.stderr: 12 | unavailable_tool = "FFmpeg" 13 | else: 14 | unavailable_tool = "FFprobe" 15 | 16 | print(colored(f"{unavailable_tool} not found!", "red")) 17 | print() 18 | print(f"Please install {unavailable_tool} or install the Batch Preview Generator (FFmpeg included) version from") 19 | print( 20 | colored( 21 | "https://github.com/Tetrax-10/batch-preview-generator/releases/latest", 22 | "blue", 23 | ) 24 | ) 25 | 26 | return False 27 | 28 | return True 29 | 30 | 31 | def run_cmd(cmd): 32 | output = subprocess.run( 33 | cmd, 34 | check=False, 35 | stdout=subprocess.PIPE, 36 | stderr=subprocess.PIPE, 37 | text=True, 38 | shell=True, 39 | ) 40 | return output 41 | 42 | 43 | def get_video_duration(file): 44 | ffprobe_cmd = f'ffprobe -v panic -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file}"' 45 | 46 | output = run_cmd(ffprobe_cmd).stdout 47 | 48 | if output: 49 | try: 50 | return round(float(output), 3) 51 | except Exception: 52 | return 0 53 | else: 54 | return 0 55 | 56 | 57 | def get_video_bitrate(file): 58 | ffprobe_cmd = f'ffprobe -v panic -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "{file}"' 59 | 60 | output = run_cmd(ffprobe_cmd).stdout.strip() 61 | 62 | if output.isnumeric(): 63 | return int(output) 64 | else: 65 | return 3500000 66 | 67 | 68 | def get_audio_bitrate(file): 69 | ffprobe_cmd = f'ffprobe -v panic -select_streams a:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "{file}"' 70 | 71 | output = run_cmd(ffprobe_cmd).stdout.strip() 72 | 73 | if output.isnumeric(): 74 | return int(output) 75 | else: 76 | return 128000 77 | 78 | 79 | def generate_preview_chunck(file, start_duration, v_bitrate, a_bitrate, args, temp_path, out_file_name): 80 | audio = "-an" 81 | video = "" 82 | crf = "22" 83 | hw_acc_pre_input = "" 84 | encoder = "libx264" 85 | scale = "scale" 86 | preset = args.compression 87 | 88 | if args.quality == "high": 89 | crf = "15" 90 | v_bitrate = v_bitrate * 2 91 | elif args.quality == "low": 92 | crf = "30" 93 | if a_bitrate > 128000: 94 | a_bitrate = 128000 95 | else: 96 | # normal 97 | if a_bitrate > 256000: 98 | a_bitrate = 256000 99 | v_bitrate = v_bitrate + 500000 100 | 101 | if args.audio: 102 | audio = f"-c:a aac -b:a {a_bitrate}" 103 | 104 | if args.cuda: 105 | hw_acc_pre_input = " -hwaccel cuda -hwaccel_output_format cuda" 106 | encoder = "h264_nvenc" 107 | scale = "scale_cuda" 108 | video = f" -b:v {v_bitrate}" 109 | 110 | if preset == "veryslow": 111 | preset = "p7" 112 | elif preset == "slow": 113 | preset = "p5" 114 | elif preset == "fast": 115 | preset = "p3" 116 | 117 | ffmpeg_cmd = f'ffmpeg -v panic -y -xerror{hw_acc_pre_input} -ss {start_duration} -i "{file}" -t {args.sduration} -max_muxing_queue_size 1024 -c:v {encoder} -vf {scale}=-2:{args.resolution}{video} -profile:v high -level 4.2 -preset {preset} -crf {crf} -r {args.fps} -strict -2 {audio} "{temp_path}/{out_file_name}.mp4"' 118 | 119 | run_cmd(ffmpeg_cmd) 120 | 121 | 122 | def generate_preview(temp_file_path, preview_file_path, args): 123 | ffmpeg_cmd = "" 124 | 125 | if args.gif: 126 | if args.quality == "high": 127 | ffmpeg_cmd = f'ffmpeg -v panic -y -f concat -i "{temp_file_path}" -max_muxing_queue_size 1024 -threads 4 -vf "fps={args.fps},scale=-2:{args.resolution}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -c:v gif -loop 0 -strict -2 "{preview_file_path}.gif"' 128 | else: 129 | ffmpeg_cmd = f'ffmpeg -v panic -y -f concat -i "{temp_file_path}" -max_muxing_queue_size 1024 -threads 4 -vf "fps={args.fps},scale=-2:{args.resolution}:flags=lanczos" -c:v gif -loop 0 -strict -2 "{preview_file_path}.gif"' 130 | else: 131 | ffmpeg_cmd = f'ffmpeg -v panic -y -f concat -i "{temp_file_path}" -max_muxing_queue_size 1024 -threads 4 -c:v copy -c:a copy -strict -2 "{preview_file_path}.mp4"' 132 | 133 | run_cmd(ffmpeg_cmd) 134 | -------------------------------------------------------------------------------- /utils/glob.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import shutil 4 | from pathlib import Path 5 | 6 | from termcolor import colored 7 | 8 | 9 | def get_cwd(): 10 | return os.getcwd() 11 | 12 | 13 | def join_path(*args): 14 | return os.path.join(*args) 15 | 16 | 17 | def get_dirname(path): 18 | return os.path.dirname(path) 19 | 20 | 21 | def is_file(path): 22 | p = Path(path) 23 | return p.is_file() 24 | 25 | 26 | def is_abs(path): 27 | return os.path.isabs(path) 28 | 29 | 30 | def get_abs_path(path): 31 | if path == ".": 32 | return get_cwd() 33 | 34 | path = os.path.normpath(path) 35 | path = path[1:] if path.startswith("\\") else path 36 | 37 | if not is_abs(path): 38 | path = join_path(get_cwd(), path) 39 | 40 | return path 41 | 42 | 43 | def correct_path(path): 44 | path = path[1:-1] if path.startswith('"') and path.endswith('"') else path 45 | path = path[1:-1] if path.startswith('"') and path.endswith('"') else path 46 | 47 | path = os.path.normpath(path) 48 | 49 | path = path[1:] if path.startswith("\\") else path 50 | 51 | return path 52 | 53 | 54 | def create_folder(path): 55 | if not os.path.exists(path): 56 | os.makedirs(path) 57 | 58 | 59 | def delete_folder(path): 60 | if os.path.exists(path): 61 | shutil.rmtree(path) 62 | 63 | 64 | def get_file_name(file, type="name"): 65 | if type == "ext": 66 | return Path(file).suffix[1:] 67 | else: 68 | return Path(file).stem 69 | 70 | 71 | def get_all_video_files(path): 72 | video_files = [] 73 | video_extensions = [ 74 | "mp4", 75 | "mkv", 76 | "avi", 77 | "mov", 78 | "m4a", 79 | "m4v", 80 | "mpg", 81 | "mpeg", 82 | "wmv", 83 | "webm", 84 | "flv", 85 | ] 86 | 87 | if os.path.isfile(path): 88 | video_files.append(Path(path)) 89 | elif os.path.isdir(path): 90 | for root, dirs, files in os.walk(path): 91 | for file in files: 92 | extension = get_file_name(file, "ext").lower() 93 | if extension in video_extensions: 94 | video_files.append(Path(root) / file) 95 | else: 96 | print(colored("Input path does not exist", "red")) 97 | return False 98 | 99 | return video_files 100 | -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def wrap_text(text): 5 | available_length = os.get_terminal_size().columns - 42 6 | if len(text) > available_length: 7 | half_value = (available_length - 3) // 2 8 | text = text[:half_value] + "..." + text[-half_value:] 9 | 10 | text = text + " " * (available_length - len(text)) 11 | 12 | return text 13 | --------------------------------------------------------------------------------