├── .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 | 
18 |
19 |
20 |
21 | **Run `preview` in terminal without any arguments (flags) to initiate interactive prompts.**
22 |
23 | 
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 | args |
43 | Full args |
44 | Description |
45 | Default |
46 | Type |
47 |
48 |
49 | -p |
50 | --path |
51 | Path of the video or folder for batch processing |
52 | CWD |
53 | string |
54 |
55 |
56 | -o |
57 | --out |
58 | Output folder for generated previews |
59 | CWD |
60 | string |
61 |
62 |
63 | -r |
64 | --resolution |
65 | Preview video resolution |
66 | 360 |
67 | int |
68 |
69 |
70 | -s |
71 | --segments |
72 | No. of segments in a preview video |
73 | 10 (check code) |
74 | int |
75 |
76 |
77 | -sd |
78 | --sduration |
79 | Duration of a segment |
80 | 1.0 (0.1 - n.n) |
81 | float |
82 |
83 |
84 | -sk |
85 | --skip |
86 | Skips the first n seconds of a video, mainly used to skip intros and filler. For movies, set this value higher according to the intro duration |
87 | 20 |
88 | float |
89 |
90 |
91 | -sp |
92 | --samepath |
93 | When passing this argument, the output folder is set to the input folder. when using this the --out path should be relative |
94 | present or not |
95 |
96 |
97 | -a |
98 | --audio |
99 | Previews will be generated with audio |
100 | present or not |
101 |
102 |
103 | -g |
104 | --gif |
105 | Previews will be generated in the GIF format (takes more time & space) |
106 | present or not |
107 |
108 |
109 | -f |
110 | --fps |
111 | Preview video FPS |
112 | mp4:24/gif:10 |
113 | int |
114 |
115 |
116 | -q |
117 | --quality |
118 | Preview video quality (low, normal, high) |
119 | normal |
120 | string |
121 |
122 |
123 | -c |
124 | --compression |
125 | Preview 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. |
126 | slow |
127 | string |
128 |
129 |
130 | -cli |
131 | --cli |
132 | Run 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 flag |
133 | present or not |
134 |
135 |
136 | -cuda |
137 | --cuda |
138 | Uses cuda cores for fast processing (Nvidia GPUs only) |
139 | present or not |
140 |
141 |
142 | -v |
143 | --version |
144 | Prints version info |
145 | present or not |
146 |
147 |
148 | -h |
149 | --help |
150 | Lists all commands with its description |
151 | present or not |
152 |
153 |
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 | 
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 |
--------------------------------------------------------------------------------