Upload 23 files
Browse files- load_image_from_url.py +63 -0
- standalone_video_combine/.claude/settings.local.json +11 -0
- standalone_video_combine/__init__.py +8 -0
- standalone_video_combine/__pycache__/__init__.cpython-312.pyc +0 -0
- standalone_video_combine/__pycache__/logger.cpython-312.pyc +0 -0
- standalone_video_combine/__pycache__/nodes.cpython-312.pyc +0 -0
- standalone_video_combine/__pycache__/utils.cpython-312.pyc +0 -0
- standalone_video_combine/logger.py +36 -0
- standalone_video_combine/nodes.py +693 -0
- standalone_video_combine/utils.py +136 -0
- standalone_video_combine/video_formats/16bit-png.json +9 -0
- standalone_video_combine/video_formats/8bit-png.json +7 -0
- standalone_video_combine/video_formats/ProRes.json +22 -0
- standalone_video_combine/video_formats/av1-webm.json +16 -0
- standalone_video_combine/video_formats/ffmpeg-gif.json +8 -0
- standalone_video_combine/video_formats/ffv1-mkv.json +17 -0
- standalone_video_combine/video_formats/gifski.json +12 -0
- standalone_video_combine/video_formats/h264-mp4.json +15 -0
- standalone_video_combine/video_formats/h265-mp4.json +17 -0
- standalone_video_combine/video_formats/nvenc_av1-mp4.json +15 -0
- standalone_video_combine/video_formats/nvenc_h264-mp4.json +15 -0
- standalone_video_combine/video_formats/nvenc_hevc-mp4.json +16 -0
- standalone_video_combine/video_formats/webm.json +16 -0
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 |
+
}
|