This commit is contained in:
Christian Byrne 2026-02-03 00:33:34 +01:00 committed by GitHub
commit 05a37fd2e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 133 additions and 13 deletions

View File

@ -146,8 +146,7 @@ class ImageSaveHelper:
metadata = ImageSaveHelper._create_png_metadata(cls) metadata = ImageSaveHelper._create_png_metadata(cls)
for batch_number, image_tensor in enumerate(images): for batch_number, image_tensor in enumerate(images):
img = ImageSaveHelper._convert_tensor_to_pil(image_tensor) img = ImageSaveHelper._convert_tensor_to_pil(image_tensor)
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level) img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level)
results.append(SavedResult(file, subfolder, folder_type)) results.append(SavedResult(file, subfolder, folder_type))
counter += 1 counter += 1
@ -176,7 +175,7 @@ class ImageSaveHelper:
) )
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images] pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
metadata = ImageSaveHelper._create_animated_png_metadata(cls) metadata = ImageSaveHelper._create_animated_png_metadata(cls)
file = f"{filename}_{counter:05}_.png" file = folder_paths.format_output_filename(filename, counter, "png")
save_path = os.path.join(full_output_folder, file) save_path = os.path.join(full_output_folder, file)
pil_images[0].save( pil_images[0].save(
save_path, save_path,
@ -220,7 +219,7 @@ class ImageSaveHelper:
) )
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images] pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
pil_exif = ImageSaveHelper._create_webp_metadata(pil_images[0], cls) pil_exif = ImageSaveHelper._create_webp_metadata(pil_images[0], cls)
file = f"{filename}_{counter:05}_.webp" file = folder_paths.format_output_filename(filename, counter, "webp")
pil_images[0].save( pil_images[0].save(
os.path.join(full_output_folder, file), os.path.join(full_output_folder, file),
save_all=True, save_all=True,
@ -284,8 +283,7 @@ class AudioSaveHelper:
results = [] results = []
for batch_number, waveform in enumerate(audio["waveform"].cpu()): for batch_number, waveform in enumerate(audio["waveform"].cpu()):
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = folder_paths.format_output_filename(filename, counter, format, batch_num=str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{format}"
output_path = os.path.join(full_output_folder, file) output_path = os.path.join(full_output_folder, file)
# Use original sample rate initially # Use original sample rate initially

View File

@ -642,7 +642,7 @@ class SaveGLB(IO.ComfyNode):
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x]) metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
for i in range(mesh.vertices.shape[0]): for i in range(mesh.vertices.shape[0]):
f = f"{filename}_{counter:05}_.glb" f = folder_paths.format_output_filename(filename, counter, "glb")
save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata) save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata)
results.append({ results.append({
"filename": f, "filename": f,

View File

@ -460,8 +460,7 @@ class SaveSVGNode(IO.ComfyNode):
for batch_number, svg_bytes in enumerate(svg.data): for batch_number, svg_bytes in enumerate(svg.data):
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = folder_paths.format_output_filename(filename, counter, "svg", batch_num=str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.svg"
# Read SVG content # Read SVG content
svg_bytes.seek(0) svg_bytes.seek(0)

View File

@ -36,7 +36,7 @@ class SaveWEBM(io.ComfyNode):
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
) )
file = f"{filename}_{counter:05}_.webm" file = folder_paths.format_output_filename(filename, counter, "webm")
container = av.open(os.path.join(full_output_folder, file), mode="w") container = av.open(os.path.join(full_output_folder, file), mode="w")
if cls.hidden.prompt is not None: if cls.hidden.prompt is not None:
@ -102,7 +102,7 @@ class SaveVideo(io.ComfyNode):
metadata["prompt"] = cls.hidden.prompt metadata["prompt"] = cls.hidden.prompt
if len(metadata) > 0: if len(metadata) > 0:
saved_metadata = metadata saved_metadata = metadata
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format)}" file = folder_paths.format_output_filename(filename, counter, Types.VideoContainer.get_extension(format))
video.save_to( video.save_to(
os.path.join(full_output_folder, file), os.path.join(full_output_folder, file),
format=Types.VideoContainer(format), format=Types.VideoContainer(format),

View File

@ -4,6 +4,7 @@ import os
import time import time
import mimetypes import mimetypes
import logging import logging
from datetime import datetime, timezone
from typing import Literal, List from typing import Literal, List
from collections.abc import Collection from collections.abc import Collection
@ -11,6 +12,46 @@ from comfy.cli_args import args
supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'} supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'}
def get_timestamp() -> str:
"""Generate a filesystem-safe timestamp string for output filenames.
Returns a UTC timestamp in the format YYYYMMDD-HHMMSS-ffffff (microseconds)
which is human-readable, lexicographically sortable, and Windows-safe.
"""
now = datetime.now(timezone.utc)
return now.strftime("%Y%m%d-%H%M%S-%f")
def format_output_filename(
filename: str,
counter: int,
ext: str,
*,
batch_num: str | None = None,
timestamp: str | None = None,
) -> str:
"""Format an output filename with counter and timestamp for cache-busting.
Args:
filename: The base filename prefix
counter: The numeric counter for uniqueness
ext: The file extension (with or without leading dot)
batch_num: Optional batch number to replace %batch_num% placeholder
timestamp: Optional timestamp string (defaults to current UTC time)
Returns:
Formatted filename like: filename_00001_20260131-123456-789012_.ext
"""
ext = ext.lstrip(".")
if timestamp is None:
timestamp = get_timestamp()
if batch_num is not None:
filename = filename.replace("%batch_num%", batch_num)
return f"{filename}_{counter:05}_{timestamp}_.{ext}"
folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {} folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {}
# --base-directory - Resets all default paths configured in folder_paths with a new base path # --base-directory - Resets all default paths configured in folder_paths with a new base path

View File

@ -1667,8 +1667,7 @@ class SaveImage:
for x in extra_pnginfo: for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x])) metadata.add_text(x, json.dumps(extra_pnginfo[x]))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
results.append({ results.append({
"filename": file, "filename": file,

View File

@ -0,0 +1,83 @@
"""Tests for folder_paths.format_output_filename and get_timestamp functions."""
import sys
import os
import unittest
# Add the ComfyUI root to the path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import folder_paths
class TestGetTimestamp(unittest.TestCase):
"""Tests for get_timestamp function."""
def test_returns_string(self):
"""Should return a string."""
result = folder_paths.get_timestamp()
self.assertIsInstance(result, str)
def test_format_matches_expected_pattern(self):
"""Should return format YYYYMMDD-HHMMSS-ffffff."""
result = folder_paths.get_timestamp()
# Pattern: 8 digits, hyphen, 6 digits, hyphen, 6 digits
pattern = r"^\d{8}-\d{6}-\d{6}$"
self.assertRegex(result, pattern)
def test_is_filesystem_safe(self):
"""Should not contain characters that are unsafe for filenames."""
result = folder_paths.get_timestamp()
unsafe_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ']
for char in unsafe_chars:
self.assertNotIn(char, result)
class TestFormatOutputFilename(unittest.TestCase):
"""Tests for format_output_filename function."""
def test_basic_format(self):
"""Should format filename with counter and timestamp."""
result = folder_paths.format_output_filename("test", 1, "png")
# Pattern: test_00001_YYYYMMDD-HHMMSS-ffffff_.png
pattern = r"^test_00001_\d{8}-\d{6}-\d{6}_\.png$"
self.assertRegex(result, pattern)
def test_counter_padding(self):
"""Should pad counter to 5 digits."""
result = folder_paths.format_output_filename("test", 42, "png")
self.assertIn("_00042_", result)
def test_extension_with_leading_dot(self):
"""Should handle extension with leading dot."""
result = folder_paths.format_output_filename("test", 1, ".png")
self.assertTrue(result.endswith("_.png"))
self.assertNotIn("..png", result)
def test_extension_without_leading_dot(self):
"""Should handle extension without leading dot."""
result = folder_paths.format_output_filename("test", 1, "webm")
self.assertTrue(result.endswith("_.webm"))
def test_batch_num_replacement(self):
"""Should replace %batch_num% placeholder."""
result = folder_paths.format_output_filename("test_%batch_num%", 1, "png", batch_num="3")
self.assertIn("test_3_", result)
self.assertNotIn("%batch_num%", result)
def test_custom_timestamp(self):
"""Should use provided timestamp instead of generating one."""
custom_ts = "20260101-120000-000000"
result = folder_paths.format_output_filename("test", 1, "png", timestamp=custom_ts)
self.assertIn(custom_ts, result)
def test_different_extensions(self):
"""Should work with various extensions."""
extensions = ["png", "webp", "webm", "svg", "glb", "safetensors", "latent"]
for ext in extensions:
result = folder_paths.format_output_filename("test", 1, ext)
self.assertTrue(result.endswith(f"_.{ext}"))
if __name__ == "__main__":
unittest.main()