EQX55 commited on
Commit
83ca7bc
·
verified ·
1 Parent(s): ea540a9

Upload 23 files

Browse files
load_image_from_url.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Improved LoadImageFromURL node for ComfyUI
3
+ Place this file in ComfyUI/custom_nodes/load_image_from_url.py
4
+ """
5
+ import torch
6
+ import requests
7
+ from PIL import Image
8
+ import numpy as np
9
+ import io
10
+
11
+ class LoadImageFromURL:
12
+ @classmethod
13
+ def INPUT_TYPES(cls):
14
+ return {
15
+ "required": {
16
+ "url": ("STRING", {"default": "https://example.com/image.jpg"}),
17
+ }
18
+ }
19
+
20
+ RETURN_TYPES = ("IMAGE", "MASK")
21
+ FUNCTION = "load_image"
22
+ CATEGORY = "image"
23
+
24
+ def load_image(self, url):
25
+ try:
26
+ # Download the image
27
+ response = requests.get(url, timeout=30, headers={
28
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
29
+ })
30
+ response.raise_for_status()
31
+
32
+ # Open the image
33
+ image = Image.open(io.BytesIO(response.content))
34
+
35
+ # Convert to RGB if needed
36
+ if image.mode != 'RGB':
37
+ image = image.convert('RGB')
38
+
39
+ # Convert to tensor
40
+ image_array = np.array(image).astype(np.float32) / 255.0
41
+ image_tensor = torch.from_numpy(image_array)[None,]
42
+
43
+ # Create mask with same dimensions as image (height, width)
44
+ h, w = image_array.shape[:2]
45
+ mask = torch.zeros((h, w), dtype=torch.float32, device="cpu")
46
+
47
+ return (image_tensor, mask)
48
+
49
+ except Exception as e:
50
+ print(f"Error loading image from URL: {e}")
51
+ # Return a blank 512x512 image if loading fails
52
+ blank_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32, device="cpu")
53
+ mask = torch.zeros((512, 512), dtype=torch.float32, device="cpu")
54
+ return (blank_image, mask)
55
+
56
+ # Node class mappings
57
+ NODE_CLASS_MAPPINGS = {
58
+ "LoadImageFromURL": LoadImageFromURL,
59
+ }
60
+
61
+ NODE_DISPLAY_NAME_MAPPINGS = {
62
+ "LoadImageFromURL": "Load Image From URL",
63
+ }
standalone_video_combine/.claude/settings.local.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(xcopy:*)",
5
+ "Bash(dir \"C:\\Users\\newec\\Documents\\ComfyUI\\custom_nodes\\standalone_video_combine_backup_20250113\")",
6
+ "Read(//c/Users/newec/Downloads/**)"
7
+ ],
8
+ "deny": [],
9
+ "ask": []
10
+ }
11
+ }
standalone_video_combine/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone Video Combine Node for ComfyUI
3
+ Extracted from ComfyUI-VideoHelperSuite
4
+ """
5
+
6
+ from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
7
+
8
+ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
standalone_video_combine/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (412 Bytes). View file
 
standalone_video_combine/__pycache__/logger.cpython-312.pyc ADDED
Binary file (1.75 kB). View file
 
standalone_video_combine/__pycache__/nodes.cpython-312.pyc ADDED
Binary file (33 kB). View file
 
standalone_video_combine/__pycache__/utils.cpython-312.pyc ADDED
Binary file (6.22 kB). View file
 
standalone_video_combine/logger.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import copy
3
+ import logging
4
+
5
+
6
+ class ColoredFormatter(logging.Formatter):
7
+ COLORS = {
8
+ "DEBUG": "\033[0;36m", # CYAN
9
+ "INFO": "\033[0;32m", # GREEN
10
+ "WARNING": "\033[0;33m", # YELLOW
11
+ "ERROR": "\033[0;31m", # RED
12
+ "CRITICAL": "\033[0;37;41m", # WHITE ON RED
13
+ "RESET": "\033[0m", # RESET COLOR
14
+ }
15
+
16
+ def format(self, record):
17
+ colored_record = copy.copy(record)
18
+ levelname = colored_record.levelname
19
+ seq = self.COLORS.get(levelname, self.COLORS["RESET"])
20
+ colored_record.levelname = f"{seq}{levelname}{self.COLORS['RESET']}"
21
+ return super().format(colored_record)
22
+
23
+
24
+ # Create a new logger
25
+ logger = logging.getLogger("VideoHelperSuite")
26
+ logger.propagate = False
27
+
28
+ # Add handler if we don't have one.
29
+ if not logger.handlers:
30
+ handler = logging.StreamHandler(sys.stdout)
31
+ handler.setFormatter(ColoredFormatter("[%(name)s] - %(levelname)s - %(message)s"))
32
+ logger.addHandler(handler)
33
+
34
+ # Configure logger
35
+ loglevel = logging.INFO
36
+ logger.setLevel(loglevel)
standalone_video_combine/nodes.py ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import subprocess
5
+ import numpy as np
6
+ import re
7
+ import datetime
8
+ from typing import List
9
+ import torch
10
+ from PIL import Image, ExifTags
11
+ from PIL.PngImagePlugin import PngInfo
12
+ from pathlib import Path
13
+ from string import Template
14
+ import itertools
15
+ import functools
16
+
17
+ import folder_paths
18
+ from .logger import logger
19
+ from .utils import ffmpeg_path, requeue_workflow, \
20
+ gifski_path, imageOrLatent, BIGMAX, merge_filter_args, ENCODE_ARGS, floatOrInt, cached, \
21
+ ContainsAll
22
+ from comfy.utils import ProgressBar
23
+
24
+ if 'VHS_video_formats' not in folder_paths.folder_names_and_paths:
25
+ folder_paths.folder_names_and_paths["VHS_video_formats"] = ((),{".json"})
26
+ if len(folder_paths.folder_names_and_paths['VHS_video_formats'][1]) == 0:
27
+ folder_paths.folder_names_and_paths["VHS_video_formats"][1].add(".json")
28
+
29
+ def flatten_list(l):
30
+ ret = []
31
+ for e in l:
32
+ if isinstance(e, list):
33
+ ret.extend(e)
34
+ else:
35
+ ret.append(e)
36
+ return ret
37
+
38
+ def iterate_format(video_format, for_widgets=True):
39
+ """Provides an iterator over widgets, or arguments"""
40
+ def indirector(cont, index):
41
+ if isinstance(cont[index], list) and (not for_widgets
42
+ or len(cont[index])> 1 and not isinstance(cont[index][1], dict)):
43
+ inp = yield cont[index]
44
+ if inp is not None:
45
+ cont[index] = inp
46
+ yield
47
+ for k in video_format:
48
+ if k == "extra_widgets":
49
+ if for_widgets:
50
+ yield from video_format["extra_widgets"]
51
+ elif k.endswith("_pass"):
52
+ for i in range(len(video_format[k])):
53
+ yield from indirector(video_format[k], i)
54
+ if not for_widgets:
55
+ video_format[k] = flatten_list(video_format[k])
56
+ else:
57
+ yield from indirector(video_format, k)
58
+
59
+ base_formats_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "video_formats")
60
+ @cached(5)
61
+ def get_video_formats():
62
+ format_files = {}
63
+ for format_name in folder_paths.get_filename_list("VHS_video_formats"):
64
+ format_files[format_name] = folder_paths.get_full_path("VHS_video_formats", format_name)
65
+ for item in os.scandir(base_formats_dir):
66
+ if not item.is_file() or not item.name.endswith('.json'):
67
+ continue
68
+ format_files[item.name[:-5]] = item.path
69
+ formats = []
70
+ format_widgets = {}
71
+ for format_name, path in format_files.items():
72
+ with open(path, 'r') as stream:
73
+ video_format = json.load(stream)
74
+ if "gifski_pass" in video_format and gifski_path is None:
75
+ #Skip format
76
+ continue
77
+ widgets = list(iterate_format(video_format))
78
+ formats.append("video/" + format_name)
79
+ if (len(widgets) > 0):
80
+ format_widgets["video/"+ format_name] = widgets
81
+ return formats, format_widgets
82
+
83
+ def apply_format_widgets(format_name, kwargs):
84
+ if os.path.exists(os.path.join(base_formats_dir, format_name + ".json")):
85
+ video_format_path = os.path.join(base_formats_dir, format_name + ".json")
86
+ else:
87
+ video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name)
88
+ with open(video_format_path, 'r') as stream:
89
+ video_format = json.load(stream)
90
+ for w in iterate_format(video_format):
91
+ if w[0] not in kwargs:
92
+ if len(w) > 2 and 'default' in w[2]:
93
+ default = w[2]['default']
94
+ else:
95
+ if type(w[1]) is list:
96
+ default = w[1][0]
97
+ else:
98
+ #NOTE: This doesn't respect max/min, but should be good enough as a fallback to a fallback to a fallback
99
+ default = {"BOOLEAN": False, "INT": 0, "FLOAT": 0, "STRING": ""}[w[1]]
100
+ kwargs[w[0]] = default
101
+ logger.warn(f"Missing input for {w[0]} has been set to {default}")
102
+ wit = iterate_format(video_format, False)
103
+ for w in wit:
104
+ while isinstance(w, list):
105
+ if len(w) == 1:
106
+ #TODO: mapping=kwargs should be safer, but results in key errors, investigate why
107
+ w = [Template(x).substitute(**kwargs) for x in w[0]]
108
+ break
109
+ elif isinstance(w[1], dict):
110
+ w = w[1][str(kwargs[w[0]])]
111
+ elif len(w) > 3:
112
+ w = Template(w[3]).substitute(val=kwargs[w[0]])
113
+ else:
114
+ w = str(kwargs[w[0]])
115
+ wit.send(w)
116
+ return video_format
117
+
118
+ def tensor_to_int(tensor, bits):
119
+ tensor = tensor.cpu().numpy() * (2**bits-1) + 0.5
120
+ return np.clip(tensor, 0, (2**bits-1))
121
+ def tensor_to_shorts(tensor):
122
+ return tensor_to_int(tensor, 16).astype(np.uint16)
123
+ def tensor_to_bytes(tensor):
124
+ return tensor_to_int(tensor, 8).astype(np.uint8)
125
+
126
+ def ffmpeg_process(args, video_format, video_metadata, file_path, env):
127
+
128
+ res = None
129
+ frame_data = yield
130
+ total_frames_output = 0
131
+ if video_format.get('save_metadata', 'False') != 'False':
132
+ os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
133
+ metadata = json.dumps(video_metadata)
134
+ metadata_path = os.path.join(folder_paths.get_temp_directory(), "metadata.txt")
135
+ #metadata from file should escape = ; # \ and newline
136
+ metadata = metadata.replace("\\","\\\\")
137
+ metadata = metadata.replace(";","\\;")
138
+ metadata = metadata.replace("#","\\#")
139
+ metadata = metadata.replace("=","\\=")
140
+ metadata = metadata.replace("\n","\\\n")
141
+ metadata = "comment=" + metadata
142
+ with open(metadata_path, "w") as f:
143
+ f.write(";FFMETADATA1\n")
144
+ f.write(metadata)
145
+ m_args = args[:1] + ["-i", metadata_path] + args[1:] + ["-metadata", "creation_time=now"]
146
+ with subprocess.Popen(m_args + [file_path], stderr=subprocess.PIPE,
147
+ stdin=subprocess.PIPE, env=env) as proc:
148
+ try:
149
+ while frame_data is not None:
150
+ proc.stdin.write(frame_data)
151
+ #TODO: skip flush for increased speed
152
+ frame_data = yield
153
+ total_frames_output+=1
154
+ proc.stdin.flush()
155
+ proc.stdin.close()
156
+ res = proc.stderr.read()
157
+ except BrokenPipeError as e:
158
+ err = proc.stderr.read()
159
+ #Check if output file exists. If it does, the re-execution
160
+ #will also fail. This obscures the cause of the error
161
+ #and seems to never occur concurrent to the metadata issue
162
+ if os.path.exists(file_path):
163
+ raise Exception("An error occurred in the ffmpeg subprocess:\n" \
164
+ + err.decode(*ENCODE_ARGS))
165
+ #Res was not set
166
+ print(err.decode(*ENCODE_ARGS), end="", file=sys.stderr)
167
+ logger.warn("An error occurred when saving with metadata")
168
+ if res != b'':
169
+ with subprocess.Popen(args + [file_path], stderr=subprocess.PIPE,
170
+ stdin=subprocess.PIPE, env=env) as proc:
171
+ try:
172
+ while frame_data is not None:
173
+ proc.stdin.write(frame_data)
174
+ frame_data = yield
175
+ total_frames_output+=1
176
+ proc.stdin.flush()
177
+ proc.stdin.close()
178
+ res = proc.stderr.read()
179
+ except BrokenPipeError as e:
180
+ res = proc.stderr.read()
181
+ raise Exception("An error occurred in the ffmpeg subprocess:\n" \
182
+ + res.decode(*ENCODE_ARGS))
183
+ yield total_frames_output
184
+ if len(res) > 0:
185
+ print(res.decode(*ENCODE_ARGS), end="", file=sys.stderr)
186
+
187
+ def gifski_process(args, dimensions, video_format, file_path, env):
188
+ frame_data = yield
189
+ with subprocess.Popen(args + video_format['main_pass'] + ['-f', 'yuv4mpegpipe', '-'],
190
+ stderr=subprocess.PIPE, stdin=subprocess.PIPE,
191
+ stdout=subprocess.PIPE, env=env) as procff:
192
+ with subprocess.Popen([gifski_path] + video_format['gifski_pass']
193
+ + ['-W', f'{dimensions[0]}', '-H', f'{dimensions[1]}']
194
+ + ['-q', '-o', file_path, '-'], stderr=subprocess.PIPE,
195
+ stdin=procff.stdout, stdout=subprocess.PIPE,
196
+ env=env) as procgs:
197
+ try:
198
+ while frame_data is not None:
199
+ procff.stdin.write(frame_data)
200
+ frame_data = yield
201
+ procff.stdin.flush()
202
+ procff.stdin.close()
203
+ resff = procff.stderr.read()
204
+ resgs = procgs.stderr.read()
205
+ outgs = procgs.stdout.read()
206
+ except BrokenPipeError as e:
207
+ procff.stdin.close()
208
+ resff = procff.stderr.read()
209
+ resgs = procgs.stderr.read()
210
+ raise Exception("An error occurred while creating gifski output\n" \
211
+ + "Make sure you are using gifski --version >=1.32.0\nffmpeg: " \
212
+ + resff.decode(*ENCODE_ARGS) + '\ngifski: ' + resgs.decode(*ENCODE_ARGS))
213
+ if len(resff) > 0:
214
+ print(resff.decode(*ENCODE_ARGS), end="", file=sys.stderr)
215
+ if len(resgs) > 0:
216
+ print(resgs.decode(*ENCODE_ARGS), end="", file=sys.stderr)
217
+ #should always be empty as the quiet flag is passed
218
+ if len(outgs) > 0:
219
+ print(outgs.decode(*ENCODE_ARGS))
220
+
221
+ def to_pingpong(inp):
222
+ if not hasattr(inp, "__getitem__"):
223
+ inp = list(inp)
224
+ yield from inp
225
+ for i in range(len(inp)-2,0,-1):
226
+ yield inp[i]
227
+
228
+ class VideoCombine:
229
+ @classmethod
230
+ def INPUT_TYPES(s):
231
+ ffmpeg_formats, format_widgets = get_video_formats()
232
+ format_widgets["image/webp"] = [['lossless', "BOOLEAN", {'default': True}]]
233
+ return {
234
+ "required": {
235
+ "images": (imageOrLatent,),
236
+ "frame_rate": (
237
+ floatOrInt,
238
+ {"default": 8, "min": 1, "step": 1},
239
+ ),
240
+ "loop_count": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
241
+ "filename_prefix": ("STRING", {"default": "AnimateDiff"}),
242
+ "filename_suffix": ("STRING", {"default": ""}),
243
+ "save_path": ("STRING", {"default": "", "multiline": False}),
244
+ "format": (["image/gif", "image/webp"] + ffmpeg_formats, {'formats': format_widgets}),
245
+ "pingpong": ("BOOLEAN", {"default": False}),
246
+ "save_output": ("BOOLEAN", {"default": True}),
247
+ },
248
+ "optional": {
249
+ "audio": ("AUDIO",),
250
+ "meta_batch": ("VHS_BatchManager",),
251
+ "vae": ("VAE",),
252
+ },
253
+ "hidden": ContainsAll({
254
+ "prompt": "PROMPT",
255
+ "extra_pnginfo": "EXTRA_PNGINFO",
256
+ "unique_id": "UNIQUE_ID"
257
+ }),
258
+ }
259
+
260
+ RETURN_TYPES = ("VHS_FILENAMES",)
261
+ RETURN_NAMES = ("Filenames",)
262
+ OUTPUT_NODE = True
263
+ CATEGORY = "EQX/Video"
264
+ FUNCTION = "combine_video"
265
+
266
+ def combine_video(
267
+ self,
268
+ frame_rate: int,
269
+ loop_count: int,
270
+ images=None,
271
+ latents=None,
272
+ filename_prefix="AnimateDiff",
273
+ filename_suffix="",
274
+ save_path="",
275
+ format="image/gif",
276
+ pingpong=False,
277
+ save_output=True,
278
+ prompt=None,
279
+ extra_pnginfo=None,
280
+ audio=None,
281
+ unique_id=None,
282
+ manual_format_widgets=None,
283
+ meta_batch=None,
284
+ vae=None,
285
+ **kwargs
286
+ ):
287
+ if latents is not None:
288
+ images = latents
289
+ if images is None:
290
+ return ((save_output, []),)
291
+ if vae is not None:
292
+ if isinstance(images, dict):
293
+ images = images['samples']
294
+ else:
295
+ vae = None
296
+
297
+ if isinstance(images, torch.Tensor) and images.size(0) == 0:
298
+ return ((save_output, []),)
299
+ num_frames = len(images)
300
+ pbar = ProgressBar(num_frames)
301
+ if vae is not None:
302
+ downscale_ratio = getattr(vae, "downscale_ratio", 8)
303
+ width = images.size(-1)*downscale_ratio
304
+ height = images.size(-2)*downscale_ratio
305
+ frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
306
+ #Python 3.12 adds an itertools.batched, but it's easily replicated for legacy support
307
+ def batched(it, n):
308
+ while batch := tuple(itertools.islice(it, n)):
309
+ yield batch
310
+ def batched_encode(images, vae, frames_per_batch):
311
+ for batch in batched(iter(images), frames_per_batch):
312
+ image_batch = torch.from_numpy(np.array(batch))
313
+ yield from vae.decode(image_batch)
314
+ images = batched_encode(images, vae, frames_per_batch)
315
+ first_image = next(images)
316
+ #repush first_image
317
+ images = itertools.chain([first_image], images)
318
+ #A single image has 3 dimensions. Discard higher dimensions
319
+ while len(first_image.shape) > 3:
320
+ first_image = first_image[0]
321
+ else:
322
+ first_image = images[0]
323
+ images = iter(images)
324
+ # get output information
325
+ logger.info("💾 Saving video...")
326
+ if save_path and save_path.strip():
327
+ # Use custom save path if provided
328
+ save_path_clean = save_path.strip()
329
+ logger.info(f"📁 Path: {save_path_clean}")
330
+ # Expand user path and environment variables
331
+ save_path_clean = os.path.expanduser(save_path_clean)
332
+ save_path_clean = os.path.expandvars(save_path_clean)
333
+ # Make absolute path if relative
334
+ if not os.path.isabs(save_path_clean):
335
+ save_path_clean = os.path.abspath(save_path_clean)
336
+ # Create directory if it doesn't exist
337
+ os.makedirs(save_path_clean, exist_ok=True)
338
+ output_dir = save_path_clean
339
+ full_output_folder = output_dir
340
+ # For custom path, we'll handle the suffix differently
341
+ # Just use prefix and timestamp in the base filename
342
+ filename = filename_prefix
343
+ subfolder = ""
344
+ # Add timestamp to filename to avoid overwriting
345
+ import time
346
+ timestamp = int(time.time())
347
+ filename = f"{filename}_{timestamp:08d}"
348
+ # Note: suffix will be added later when creating the actual file
349
+ else:
350
+ # Use default ComfyUI output directory
351
+ output_dir = (
352
+ folder_paths.get_output_directory()
353
+ if save_output
354
+ else folder_paths.get_temp_directory()
355
+ )
356
+ logger.info(f"📁 Path: {output_dir}")
357
+ (
358
+ full_output_folder,
359
+ filename,
360
+ _,
361
+ subfolder,
362
+ _,
363
+ ) = folder_paths.get_save_image_path(filename_prefix, output_dir)
364
+ output_files = []
365
+
366
+ metadata = PngInfo()
367
+ video_metadata = {}
368
+ if prompt is not None:
369
+ metadata.add_text("prompt", json.dumps(prompt))
370
+ video_metadata["prompt"] = json.dumps(prompt)
371
+ if extra_pnginfo is not None:
372
+ for x in extra_pnginfo:
373
+ metadata.add_text(x, json.dumps(extra_pnginfo[x]))
374
+ video_metadata[x] = extra_pnginfo[x]
375
+ extra_options = extra_pnginfo.get('workflow', {}).get('extra', {})
376
+ else:
377
+ extra_options = {}
378
+ metadata.add_text("CreationTime", datetime.datetime.now().isoformat(" ")[:19])
379
+
380
+ if meta_batch is not None and unique_id in meta_batch.outputs:
381
+ (counter, output_process) = meta_batch.outputs[unique_id]
382
+ else:
383
+ # comfy counter workaround
384
+ max_counter = 0
385
+
386
+ # Loop through the existing files
387
+ # For custom path: {prefix}_{timestamp}_{counter}_{suffix}.ext or {prefix}_{timestamp}_{counter}.ext
388
+ # For default path: {filename}_{counter}.ext
389
+ if save_path and save_path.strip():
390
+ # Custom path pattern - look for counter after timestamp
391
+ if filename_suffix:
392
+ # Pattern: prefix_timestamp_COUNTER_suffix.ext
393
+ matcher = re.compile(f"{re.escape(filename_prefix)}_\\d{{8,10}}_(\\d{{5}})_{re.escape(filename_suffix)}\\..+", re.IGNORECASE)
394
+ else:
395
+ # Pattern: prefix_timestamp_COUNTER.ext
396
+ matcher = re.compile(f"{re.escape(filename_prefix)}_\\d{{8,10}}_(\\d{{5}})\\..+", re.IGNORECASE)
397
+ else:
398
+ # Default ComfyUI path pattern
399
+ matcher = re.compile(f"{re.escape(filename)}_(\\d+)\\D*\\..+", re.IGNORECASE)
400
+
401
+ for existing_file in os.listdir(full_output_folder):
402
+ # Check if the file matches the expected format
403
+ match = matcher.fullmatch(existing_file)
404
+ if match:
405
+ # Extract the numeric portion of the filename
406
+ file_counter = int(match.group(1))
407
+ # Update the maximum counter value if necessary
408
+ if file_counter > max_counter:
409
+ max_counter = file_counter
410
+
411
+ # Increment the counter by 1 to get the next available value
412
+ counter = max_counter + 1
413
+ output_process = None
414
+
415
+ # Skip saving first frame as png - only save video
416
+
417
+ format_type, format_ext = format.split("/")
418
+ if format_type == "image":
419
+ if meta_batch is not None:
420
+ raise Exception("Pillow('image/') formats are not compatible with batched output")
421
+ image_kwargs = {}
422
+ if format_ext == "gif":
423
+ image_kwargs['disposal'] = 2
424
+ if format_ext == "webp":
425
+ #Save timestamp information
426
+ exif = Image.Exif()
427
+ exif[ExifTags.IFD.Exif] = {36867: datetime.datetime.now().isoformat(" ")[:19]}
428
+ image_kwargs['exif'] = exif
429
+ image_kwargs['lossless'] = kwargs.get("lossless", True)
430
+ if filename_suffix:
431
+ file = f"{filename}_{counter:05}_{filename_suffix}.{format_ext}"
432
+ else:
433
+ file = f"{filename}_{counter:05}.{format_ext}"
434
+ file_path = os.path.join(full_output_folder, file)
435
+ if pingpong:
436
+ images = to_pingpong(images)
437
+ def frames_gen(images):
438
+ for i in images:
439
+ pbar.update(1)
440
+ yield Image.fromarray(tensor_to_bytes(i))
441
+ frames = frames_gen(images)
442
+ # Use pillow directly to save an animated image
443
+ next(frames).save(
444
+ file_path,
445
+ format=format_ext.upper(),
446
+ save_all=True,
447
+ append_images=frames,
448
+ duration=round(1000 / frame_rate),
449
+ loop=loop_count,
450
+ compress_level=4,
451
+ **image_kwargs
452
+ )
453
+ output_files.append(file_path)
454
+ logger.info(f"📝 File: {file}")
455
+ logger.info("✅ Video saved successfully!")
456
+ else:
457
+ # Use ffmpeg to save a video
458
+ if ffmpeg_path is None:
459
+ raise ProcessLookupError(f"ffmpeg is required for video outputs and could not be found.\nIn order to use video outputs, you must either:\n- Install imageio-ffmpeg with pip,\n- Place a ffmpeg executable in {os.path.abspath('')}, or\n- Install ffmpeg and add it to the system path.")
460
+
461
+ if manual_format_widgets is not None:
462
+ logger.warn("Format args can now be passed directly. The manual_format_widgets argument is now deprecated")
463
+ kwargs.update(manual_format_widgets)
464
+
465
+ has_alpha = first_image.shape[-1] == 4
466
+ kwargs["has_alpha"] = has_alpha
467
+ video_format = apply_format_widgets(format_ext, kwargs)
468
+ dim_alignment = video_format.get("dim_alignment", 2)
469
+ if (first_image.shape[1] % dim_alignment) or (first_image.shape[0] % dim_alignment):
470
+ #output frames must be padded
471
+ to_pad = (-first_image.shape[1] % dim_alignment,
472
+ -first_image.shape[0] % dim_alignment)
473
+ padding = (to_pad[0]//2, to_pad[0] - to_pad[0]//2,
474
+ to_pad[1]//2, to_pad[1] - to_pad[1]//2)
475
+ padfunc = torch.nn.ReplicationPad2d(padding)
476
+ def pad(image):
477
+ image = image.permute((2,0,1))#HWC to CHW
478
+ padded = padfunc(image.to(dtype=torch.float32))
479
+ return padded.permute((1,2,0))
480
+ images = map(pad, images)
481
+ dimensions = (-first_image.shape[1] % dim_alignment + first_image.shape[1],
482
+ -first_image.shape[0] % dim_alignment + first_image.shape[0])
483
+ logger.warn("Output images were not of valid resolution and have had padding applied")
484
+ else:
485
+ dimensions = (first_image.shape[1], first_image.shape[0])
486
+ if pingpong:
487
+ if meta_batch is not None:
488
+ logger.error("pingpong is incompatible with batched output")
489
+ images = to_pingpong(images)
490
+ if num_frames > 2:
491
+ num_frames += num_frames -2
492
+ pbar.total = num_frames
493
+ if loop_count > 0:
494
+ loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)]
495
+ else:
496
+ loop_args = []
497
+ if video_format.get('input_color_depth', '8bit') == '16bit':
498
+ images = map(tensor_to_shorts, images)
499
+ if has_alpha:
500
+ i_pix_fmt = 'rgba64'
501
+ else:
502
+ i_pix_fmt = 'rgb48'
503
+ else:
504
+ images = map(tensor_to_bytes, images)
505
+ if has_alpha:
506
+ i_pix_fmt = 'rgba'
507
+ else:
508
+ i_pix_fmt = 'rgb24'
509
+ # Check if audio will be added to determine filename
510
+ has_audio = False
511
+ if audio is not None:
512
+ try:
513
+ if audio.get('waveform') is not None:
514
+ has_audio = True
515
+ except:
516
+ pass
517
+
518
+ # If we have audio, create a temp name for the video-only file
519
+ if has_audio:
520
+ file = f"{filename}_{counter:05}_temp.{video_format['extension']}"
521
+ else:
522
+ if filename_suffix:
523
+ file = f"{filename}_{counter:05}_{filename_suffix}.{video_format['extension']}"
524
+ else:
525
+ file = f"{filename}_{counter:05}.{video_format['extension']}"
526
+ file_path = os.path.join(full_output_folder, file)
527
+ bitrate_arg = []
528
+ bitrate = video_format.get('bitrate')
529
+ if bitrate is not None:
530
+ bitrate_arg = ["-b:v", str(bitrate) + "M" if video_format.get('megabit') == 'True' else str(bitrate) + "K"]
531
+ args = [ffmpeg_path, "-v", "error", "-f", "rawvideo", "-pix_fmt", i_pix_fmt,
532
+ # The image data is in an undefined generic RGB color space, which in practice means sRGB.
533
+ # sRGB has the same primaries and matrix as BT.709, but a different transfer function (gamma),
534
+ # called by the sRGB standard name IEC 61966-2-1. However, video hosting platforms like YouTube
535
+ # standardize on full BT.709 and will convert the colors accordingly. This last minute change
536
+ # in colors can be confusing to users. We can counter it by lying about the transfer function
537
+ # on a per format basis, i.e. for video we will lie to FFmpeg that it is already BT.709. Also,
538
+ # because the input data is in RGB (not YUV) it is more efficient (fewer scale filter invocations)
539
+ # to specify the input color space as RGB and then later, if the format actually wants YUV,
540
+ # to convert it to BT.709 YUV via FFmpeg's -vf "scale=out_color_matrix=bt709".
541
+ "-color_range", "pc", "-colorspace", "rgb", "-color_primaries", "bt709",
542
+ "-color_trc", video_format.get("fake_trc", "iec61966-2-1"),
543
+ "-s", f"{dimensions[0]}x{dimensions[1]}", "-r", str(frame_rate), "-i", "-"] \
544
+ + loop_args
545
+
546
+ images = map(lambda x: x.tobytes(), images)
547
+ env=os.environ.copy()
548
+ if "environment" in video_format:
549
+ env.update(video_format["environment"])
550
+
551
+ if "pre_pass" in video_format:
552
+ if meta_batch is not None:
553
+ #Performing a prepass requires keeping access to all frames.
554
+ #Potential solutions include keeping just output frames in
555
+ #memory or using 3 passes with intermediate file, but
556
+ #very long gifs probably shouldn't be encouraged
557
+ raise Exception("Formats which require a pre_pass are incompatible with Batch Manager.")
558
+ images = [b''.join(images)]
559
+ os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
560
+ in_args_len = args.index("-i") + 2 # The index after ["-i", "-"]
561
+ pre_pass_args = args[:in_args_len] + video_format['pre_pass']
562
+ merge_filter_args(pre_pass_args)
563
+ try:
564
+ subprocess.run(pre_pass_args, input=images[0], env=env,
565
+ capture_output=True, check=True)
566
+ except subprocess.CalledProcessError as e:
567
+ raise Exception("An error occurred in the ffmpeg prepass:\n" \
568
+ + e.stderr.decode(*ENCODE_ARGS))
569
+ if "inputs_main_pass" in video_format:
570
+ in_args_len = args.index("-i") + 2 # The index after ["-i", "-"]
571
+ args = args[:in_args_len] + video_format['inputs_main_pass'] + args[in_args_len:]
572
+
573
+ if output_process is None:
574
+ if 'gifski_pass' in video_format:
575
+ format = 'image/gif'
576
+ output_process = gifski_process(args, dimensions, video_format, file_path, env)
577
+ audio = None
578
+ else:
579
+ args += video_format['main_pass'] + bitrate_arg
580
+ merge_filter_args(args)
581
+ output_process = ffmpeg_process(args, video_format, video_metadata, file_path, env)
582
+ #Proceed to first yield
583
+ output_process.send(None)
584
+ if meta_batch is not None:
585
+ meta_batch.outputs[unique_id] = (counter, output_process)
586
+
587
+ for image in images:
588
+ pbar.update(1)
589
+ output_process.send(image)
590
+ if meta_batch is not None:
591
+ requeue_workflow((meta_batch.unique_id, not meta_batch.has_closed_inputs))
592
+ if meta_batch is None or meta_batch.has_closed_inputs:
593
+ #Close pipe and wait for termination.
594
+ try:
595
+ total_frames_output = output_process.send(None)
596
+ output_process.send(None)
597
+ except StopIteration:
598
+ pass
599
+ if meta_batch is not None:
600
+ meta_batch.outputs.pop(unique_id)
601
+ if len(meta_batch.outputs) == 0:
602
+ meta_batch.reset()
603
+ else:
604
+ #batch is unfinished
605
+ #TODO: Check if empty output breaks other custom nodes
606
+ return {"ui": {"unfinished_batch": [True]}, "result": ((save_output, []),)}
607
+
608
+ # Re-check audio (we already checked above for filename)
609
+ a_waveform = None
610
+ if audio is not None:
611
+ try:
612
+ #safely check if audio produced by VHS_LoadVideo actually exists
613
+ a_waveform = audio['waveform']
614
+ except:
615
+ pass
616
+
617
+ if a_waveform is not None:
618
+ # Create audio file if input was provided
619
+ if filename_suffix:
620
+ output_file_with_audio = f"{filename}_{counter:05}_{filename_suffix}.{video_format['extension']}"
621
+ else:
622
+ output_file_with_audio = f"{filename}_{counter:05}.{video_format['extension']}"
623
+ output_file_with_audio_path = os.path.join(full_output_folder, output_file_with_audio)
624
+ if "audio_pass" not in video_format:
625
+ logger.warn("Selected video format does not have explicit audio support")
626
+ video_format["audio_pass"] = ["-c:a", "libopus"]
627
+
628
+
629
+ # FFmpeg command with audio re-encoding
630
+ #TODO: expose audio quality options if format widgets makes it in
631
+ #Reconsider forcing apad/shortest
632
+ channels = audio['waveform'].size(1)
633
+ min_audio_dur = total_frames_output / frame_rate + 1
634
+ if video_format.get('trim_to_audio', 'False') != 'False':
635
+ apad = []
636
+ else:
637
+ apad = ["-af", "apad=whole_dur="+str(min_audio_dur)]
638
+ mux_args = [ffmpeg_path, "-v", "error", "-y", "-i", file_path,
639
+ "-ar", str(audio['sample_rate']), "-ac", str(channels),
640
+ "-f", "f32le", "-i", "-", "-c:v", "copy"] \
641
+ + video_format["audio_pass"] \
642
+ + apad + ["-shortest", output_file_with_audio_path]
643
+
644
+ audio_data = audio['waveform'].squeeze(0).transpose(0,1) \
645
+ .numpy().tobytes()
646
+ merge_filter_args(mux_args, '-af')
647
+ try:
648
+ res = subprocess.run(mux_args, input=audio_data,
649
+ env=env, capture_output=True, check=True)
650
+ except subprocess.CalledProcessError as e:
651
+ raise Exception("An error occured in the ffmpeg subprocess:\n" \
652
+ + e.stderr.decode(*ENCODE_ARGS))
653
+ if res.stderr:
654
+ print(res.stderr.decode(*ENCODE_ARGS), end="", file=sys.stderr)
655
+ output_files.append(output_file_with_audio_path)
656
+ #Return this file with audio to the webui.
657
+ #It will be muted unless opened or saved with right click
658
+ file = output_file_with_audio
659
+ logger.info(f"📝 File: {output_file_with_audio}")
660
+ logger.info("✅ Video saved successfully!")
661
+ # Delete the intermediate video without audio
662
+ if os.path.exists(file_path):
663
+ os.remove(file_path)
664
+ else:
665
+ # Only add video without audio if no audio was provided
666
+ output_files.append(file_path)
667
+ logger.info(f"📝 File: {file}")
668
+ logger.info("✅ Video saved successfully!")
669
+ if extra_options.get('VHS_KeepIntermediate', True) == False:
670
+ for intermediate in output_files[1:-1]:
671
+ if os.path.exists(intermediate):
672
+ os.remove(intermediate)
673
+ preview = {
674
+ "filename": file,
675
+ "subfolder": subfolder,
676
+ "type": "output" if save_output else "temp",
677
+ "format": format,
678
+ "frame_rate": frame_rate,
679
+ "fullpath": output_files[-1] if output_files else "",
680
+ }
681
+ if num_frames == 1 and 'png' in format and '%03d' in file:
682
+ preview['format'] = 'image/png'
683
+ preview['filename'] = file.replace('%03d', '001')
684
+ return {"ui": {"gifs": [preview]}, "result": ((save_output, output_files),)}
685
+
686
+
687
+ NODE_CLASS_MAPPINGS = {
688
+ "SaveVideoEQX": VideoCombine,
689
+ }
690
+
691
+ NODE_DISPLAY_NAME_MAPPINGS = {
692
+ "SaveVideoEQX": "Save Video EQX",
693
+ }
standalone_video_combine/utils.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import os
3
+ from typing import Iterable
4
+ import shutil
5
+ import subprocess
6
+ import re
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Union
10
+ import functools
11
+ import torch
12
+ from torch import Tensor
13
+
14
+ import server
15
+ from .logger import logger
16
+ import folder_paths
17
+
18
+ BIGMIN = -(2**53-1)
19
+ BIGMAX = (2**53-1)
20
+
21
+ DIMMAX = 8192
22
+
23
+ ENCODE_ARGS = ("utf-8", 'backslashreplace')
24
+
25
+ def ffmpeg_suitability(path):
26
+ try:
27
+ version = subprocess.run([path, "-version"], check=True,
28
+ capture_output=True).stdout.decode(*ENCODE_ARGS)
29
+ except:
30
+ return 0
31
+ score = 0
32
+ #rough layout of the importance of various features
33
+ simple_criterion = [("libvpx", 20),("264",10), ("265",3),
34
+ ("svtav1",5),("libopus", 1)]
35
+ for criterion in simple_criterion:
36
+ if version.find(criterion[0]) >= 0:
37
+ score += criterion[1]
38
+ #obtain rough compile year from copyright information
39
+ copyright_index = version.find('2000-2')
40
+ if copyright_index >= 0:
41
+ copyright_year = version[copyright_index+6:copyright_index+9]
42
+ if copyright_year.isnumeric():
43
+ score += int(copyright_year)
44
+ return score
45
+
46
+ class MultiInput(str):
47
+ def __new__(cls, string, allowed_types="*"):
48
+ res = super().__new__(cls, string)
49
+ res.allowed_types=allowed_types
50
+ return res
51
+ def __ne__(self, other):
52
+ if self.allowed_types == "*" or other == "*":
53
+ return False
54
+ return other not in self.allowed_types
55
+ imageOrLatent = MultiInput("IMAGE", ["IMAGE", "LATENT"])
56
+ floatOrInt = MultiInput("FLOAT", ["FLOAT", "INT"])
57
+
58
+ class ContainsAll(dict):
59
+ def __contains__(self, other):
60
+ return True
61
+ def __getitem__(self, key):
62
+ return super().get(key, (None, {}))
63
+
64
+ if "VHS_FORCE_FFMPEG_PATH" in os.environ:
65
+ ffmpeg_path = os.environ.get("VHS_FORCE_FFMPEG_PATH")
66
+ else:
67
+ ffmpeg_paths = []
68
+ try:
69
+ from imageio_ffmpeg import get_ffmpeg_exe
70
+ imageio_ffmpeg_path = get_ffmpeg_exe()
71
+ ffmpeg_paths.append(imageio_ffmpeg_path)
72
+ except:
73
+ if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
74
+ raise
75
+ logger.warn("Failed to import imageio_ffmpeg")
76
+ if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
77
+ ffmpeg_path = imageio_ffmpeg_path
78
+ else:
79
+ system_ffmpeg = shutil.which("ffmpeg")
80
+ if system_ffmpeg is not None:
81
+ ffmpeg_paths.append(system_ffmpeg)
82
+ if os.path.isfile("ffmpeg"):
83
+ ffmpeg_paths.append(os.path.abspath("ffmpeg"))
84
+ if os.path.isfile("ffmpeg.exe"):
85
+ ffmpeg_paths.append(os.path.abspath("ffmpeg.exe"))
86
+ if len(ffmpeg_paths) == 0:
87
+ logger.error("No valid ffmpeg found.")
88
+ ffmpeg_path = None
89
+ elif len(ffmpeg_paths) == 1:
90
+ #Evaluation of suitability isn't required, can take sole option
91
+ #to reduce startup time
92
+ ffmpeg_path = ffmpeg_paths[0]
93
+ else:
94
+ ffmpeg_path = max(ffmpeg_paths, key=ffmpeg_suitability)
95
+ gifski_path = os.environ.get("VHS_GIFSKI", None)
96
+ if gifski_path is None:
97
+ gifski_path = os.environ.get("JOV_GIFSKI", None)
98
+ if gifski_path is None:
99
+ gifski_path = shutil.which("gifski")
100
+
101
+ def merge_filter_args(args, ftype="-vf"):
102
+ #TODO This doesn't account for filter_complex
103
+ #Will likely need to convert all filters to filter complex in the future
104
+ #But that requires source/output deduplication
105
+ try:
106
+ start_index = args.index(ftype)+1
107
+ index = start_index
108
+ while True:
109
+ index = args.index(ftype, index)
110
+ args[start_index] += ',' + args[index+1]
111
+ args.pop(index)
112
+ args.pop(index)
113
+ except ValueError:
114
+ pass
115
+
116
+ def cached(duration):
117
+ def dec(f):
118
+ cached_ret = None
119
+ cache_time = 0
120
+ def cached_func():
121
+ nonlocal cache_time, cached_ret
122
+ if time.time() > cache_time + duration or cached_ret is None:
123
+ cache_time = time.time()
124
+ cached_ret = f()
125
+ return cached_ret
126
+ return cached_func
127
+ return dec
128
+
129
+ # Stub for requeue_workflow - not needed in standalone
130
+ prompt_queue = server.PromptServer.instance.prompt_queue
131
+ requeue_guard = [None, 0, 0, {}]
132
+
133
+ def requeue_workflow(requeue_required=(-1,True)):
134
+ """Minimal implementation for standalone - just returns without doing anything"""
135
+ # In standalone mode, we don't need batch manager support
136
+ return
standalone_video_combine/video_formats/16bit-png.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n",
5
+ "-pix_fmt", "rgba64"
6
+ ],
7
+ "input_color_depth": "16bit",
8
+ "extension": "%03d.png"
9
+ }
standalone_video_combine/video_formats/8bit-png.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n"
5
+ ],
6
+ "extension": "%03d.png"
7
+ }
standalone_video_combine/video_formats/ProRes.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "prores_ks",
5
+ "-profile:v", [["$profile"]],
6
+ ["profile", {
7
+ "lt": [[]], "1": [[]], "standard": [[]], "2": [[]], "hq": [[]], "3": [[]],
8
+ "4": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
9
+ "False": [["-pix_fmt", "yuv444p10le"]]}],
10
+ "4444": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
11
+ "False": [["-pix_fmt", "yuv444p10le"]]}],
12
+ "4444xq": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
13
+ "False": [["-pix_fmt", "yuv444p10le"]]}]
14
+ }],
15
+ "-vf", "scale=out_color_matrix=bt709",
16
+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
17
+ ],
18
+ "fake_trc": "bt709",
19
+ "audio_pass": ["-c:a", "pcm_s16le"],
20
+ "extension": "mov",
21
+ "extra_widgets": [["profile", ["lt", "standard", "hq", "4444", "4444xq"], {"default": "hq"}]]
22
+ }
standalone_video_combine/video_formats/av1-webm.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "libsvtav1",
5
+ "-pix_fmt", ["pix_fmt", ["yuv420p10le", "yuv420p"]],
6
+ "-crf", ["crf","INT", {"default": 23, "min": 0, "max": 100, "step": 1}],
7
+ "-vf", "scale=out_color_matrix=bt709",
8
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
9
+ ],
10
+ "fake_trc": "bt709",
11
+ "audio_pass": ["-c:a", "libopus"],
12
+ "input_color_depth": ["input_color_depth", ["8bit", "16bit"]],
13
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
14
+ "extension": "webm",
15
+ "environment": {"SVT_LOG": "1"}
16
+ }
standalone_video_combine/video_formats/ffmpeg-gif.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n",
5
+ "-filter_complex", ["dither", ["bayer", "heckbert", "floyd_steinberg", "sierra2", "sierra2_4a", "sierra3", "burkes", "atkinson", "none"], {"default": "sierra2_4a"}, "[0:v] split [a][b]; [a] palettegen=reserve_transparent=on:transparency_color=ffffff [p]; [b][p] paletteuse=dither=$val"]
6
+ ],
7
+ "extension": "gif"
8
+ }
standalone_video_combine/video_formats/ffv1-mkv.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass": [
3
+ "-n",
4
+ "-c:v", "ffv1",
5
+ "-level", ["level", ["0", "1", "3"], {"default": "3"}],
6
+ "-coder", ["coder", ["0", "1", "2"], {"default": "1"}],
7
+ "-context", ["context", ["0", "1"], {"default": "1"}],
8
+ "-g", ["gop_size", "INT", {"default": 1, "min": 1, "max": 300, "step": 1}],
9
+ "-slices", ["slices", ["4", "6", "9", "12", "16", "20", "24", "30"], {"default": "16"}],
10
+ "-slicecrc", ["slicecrc", ["0", "1"], {"default": "1"}],
11
+ "-pix_fmt", ["pix_fmt", ["bgra", "rgba64le", "yuv420p", "yuv422p", "yuv444p", "yuva420p", "yuva422p", "yuva444p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv420p12le", "yuv422p12le", "yuv444p12le", "yuv420p14le", "yuv422p14le", "yuv444p14le", "yuv420p16le", "yuv422p16le", "yuv444p16le", "gray", "gray10le", "gray12le", "gray16le"], {"default": "bgra"}]
12
+ ],
13
+ "audio_pass": ["-c:a", "flac"],
14
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
15
+ "trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
16
+ "extension": "mkv"
17
+ }
standalone_video_combine/video_formats/gifski.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-pix_fmt", "yuv444p",
5
+ "-vf", "scale=out_color_matrix=bt709:out_range=pc",
6
+ "-color_range", "pc"
7
+ ],
8
+ "extension": "gif",
9
+ "gifski_pass": [
10
+ "-Q", ["quality","INT", {"default": 90, "min": 1, "max": 100, "step": 1}]
11
+ ]
12
+ }
standalone_video_combine/video_formats/h264-mp4.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "libx264",
5
+ "-pix_fmt", ["pix_fmt", ["yuv420p", "yuv420p10le"]],
6
+ "-crf", ["crf","INT", {"default": 19, "min": 0, "max": 100, "step": 1}],
7
+ "-vf", "scale=out_color_matrix=bt709",
8
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
9
+ ],
10
+ "fake_trc": "bt709",
11
+ "audio_pass": ["-c:a", "aac"],
12
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
13
+ "trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
14
+ "extension": "mp4"
15
+ }
standalone_video_combine/video_formats/h265-mp4.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "libx265",
5
+ "-vtag", "hvc1",
6
+ "-pix_fmt", ["pix_fmt", ["yuv420p10le", "yuv420p"]],
7
+ "-crf", ["crf","INT", {"default": 22, "min": 0, "max": 100, "step": 1}],
8
+ "-preset", "medium",
9
+ "-x265-params", "log-level=quiet",
10
+ "-vf", "scale=out_color_matrix=bt709",
11
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
12
+ ],
13
+ "fake_trc": "bt709",
14
+ "audio_pass": ["-c:a", "aac"],
15
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
16
+ "extension": "mp4"
17
+ }
standalone_video_combine/video_formats/nvenc_av1-mp4.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "av1_nvenc",
5
+ "-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
6
+ "-vf", "scale=out_color_matrix=bt709",
7
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
8
+ ],
9
+ "fake_trc": "bt709",
10
+ "audio_pass": ["-c:a", "aac"],
11
+ "bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
12
+ "megabit": ["megabit","BOOLEAN", {"default": true}],
13
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
14
+ "extension": "mp4"
15
+ }
standalone_video_combine/video_formats/nvenc_h264-mp4.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "h264_nvenc",
5
+ "-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
6
+ "-vf", "scale=out_color_matrix=bt709",
7
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
8
+ ],
9
+ "fake_trc": "bt709",
10
+ "audio_pass": ["-c:a", "aac"],
11
+ "bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
12
+ "megabit": ["megabit","BOOLEAN", {"default": true}],
13
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
14
+ "extension": "mp4"
15
+ }
standalone_video_combine/video_formats/nvenc_hevc-mp4.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n", "-c:v", "hevc_nvenc",
5
+ "-vtag", "hvc1",
6
+ "-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
7
+ "-vf", "scale=out_color_matrix=bt709",
8
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
9
+ ],
10
+ "fake_trc": "bt709",
11
+ "audio_pass": ["-c:a", "aac"],
12
+ "bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
13
+ "megabit": ["megabit","BOOLEAN", {"default": true}],
14
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
15
+ "extension": "mp4"
16
+ }
standalone_video_combine/video_formats/webm.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "main_pass":
3
+ [
4
+ "-n",
5
+ "-pix_fmt", ["pix_fmt",["yuv420p","yuva420p"]],
6
+ "-crf", ["crf","INT", {"default": 20, "min": 0, "max": 100, "step": 1}],
7
+ "-b:v", "0",
8
+ "-vf", "scale=out_color_matrix=bt709",
9
+ "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
10
+ ],
11
+ "fake_trc": "bt709",
12
+ "audio_pass": ["-c:a", "libvorbis"],
13
+ "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
14
+ "trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
15
+ "extension": "webm"
16
+ }