diff --git a/comfy_api/latest/_ui.py b/comfy_api/latest/_ui.py index e238cdf3c..75be7ef2f 100644 --- a/comfy_api/latest/_ui.py +++ b/comfy_api/latest/_ui.py @@ -146,8 +146,7 @@ class ImageSaveHelper: metadata = ImageSaveHelper._create_png_metadata(cls) for batch_number, image_tensor in enumerate(images): img = ImageSaveHelper._convert_tensor_to_pil(image_tensor) - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.png" + file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number)) img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level) results.append(SavedResult(file, subfolder, folder_type)) counter += 1 @@ -176,7 +175,7 @@ class ImageSaveHelper: ) pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images] 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) pil_images[0].save( save_path, @@ -220,7 +219,7 @@ class ImageSaveHelper: ) pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images] 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( os.path.join(full_output_folder, file), save_all=True, @@ -284,8 +283,7 @@ class AudioSaveHelper: results = [] for batch_number, waveform in enumerate(audio["waveform"].cpu()): - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.{format}" + file = folder_paths.format_output_filename(filename, counter, format, batch_num=str(batch_number)) output_path = os.path.join(full_output_folder, file) # Use original sample rate initially diff --git a/comfy_extras/nodes_hunyuan3d.py b/comfy_extras/nodes_hunyuan3d.py index 5bb5df48e..16aff1fc3 100644 --- a/comfy_extras/nodes_hunyuan3d.py +++ b/comfy_extras/nodes_hunyuan3d.py @@ -642,7 +642,7 @@ class SaveGLB(IO.ComfyNode): metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x]) 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) results.append({ "filename": f, diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index cb4fb24a1..22e55d931 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -460,8 +460,7 @@ class SaveSVGNode(IO.ComfyNode): for batch_number, svg_bytes in enumerate(svg.data): - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.svg" + file = folder_paths.format_output_filename(filename, counter, "svg", batch_num=str(batch_number)) # Read SVG content svg_bytes.seek(0) diff --git a/comfy_extras/nodes_video.py b/comfy_extras/nodes_video.py index ccf7b63d3..005f90796 100644 --- a/comfy_extras/nodes_video.py +++ b/comfy_extras/nodes_video.py @@ -36,7 +36,7 @@ class SaveWEBM(io.ComfyNode): 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") if cls.hidden.prompt is not None: @@ -102,7 +102,7 @@ class SaveVideo(io.ComfyNode): metadata["prompt"] = cls.hidden.prompt if len(metadata) > 0: 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( os.path.join(full_output_folder, file), format=Types.VideoContainer(format), diff --git a/folder_paths.py b/folder_paths.py index 9c96540e3..80e5f8c7b 100644 --- a/folder_paths.py +++ b/folder_paths.py @@ -4,6 +4,7 @@ import os import time import mimetypes import logging +from datetime import datetime, timezone from typing import Literal, List 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'} + +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]]] = {} # --base-directory - Resets all default paths configured in folder_paths with a new base path diff --git a/nodes.py b/nodes.py index 1cb43d9e2..d716d61c5 100644 --- a/nodes.py +++ b/nodes.py @@ -1667,8 +1667,7 @@ class SaveImage: for x in extra_pnginfo: metadata.add_text(x, json.dumps(extra_pnginfo[x])) - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.png" + file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number)) img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) results.append({ "filename": file, diff --git a/tests-unit/folder_paths_test/output_filename_test.py b/tests-unit/folder_paths_test/output_filename_test.py new file mode 100644 index 000000000..b8e8fe441 --- /dev/null +++ b/tests-unit/folder_paths_test/output_filename_test.py @@ -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()