From 0f259cabdd41631f9813c1550202abd58d5f030f Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 31 Jan 2026 22:30:57 -0800 Subject: [PATCH] feat: add timestamp to output filenames for cache-busting Add get_timestamp() and format_output_filename() utilities to folder_paths.py that generate unique filenames with UTC timestamps. This eliminates the need for client-side cache-busting query parameters. New filename format: prefix_00001_20260131-220945-123456_.ext Updated all save nodes to use the new format: - nodes.py (SaveImage, SaveLatent, SaveImageWebsocket) - comfy_api/latest/_ui.py (UILatent) - comfy_extras/nodes_video.py (SaveWEBM, SaveAnimatedPNG, SaveAnimatedWEBP) - comfy_extras/nodes_images.py (SaveSVG) - comfy_extras/nodes_hunyuan3d.py (Save3D) - comfy_extras/nodes_model_merging.py (SaveCheckpointSimple) - comfy_extras/nodes_lora_extract.py (LoraSave) - comfy_extras/nodes_train.py (SaveEmbedding) Amp-Thread-ID: https://ampcode.com/threads/T-019c17e5-1c0a-736f-970d-e411aae222fc --- comfy_api/latest/_ui.py | 10 +- comfy_extras/nodes_hunyuan3d.py | 2 +- comfy_extras/nodes_images.py | 3 +- comfy_extras/nodes_lora_extract.py | 2 +- comfy_extras/nodes_model_merging.py | 6 +- comfy_extras/nodes_train.py | 4 +- comfy_extras/nodes_video.py | 4 +- folder_paths.py | 41 +++++++ nodes.py | 5 +- .../folder_paths_test/output_filename_test.py | 106 ++++++++++++++++++ 10 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 tests-unit/folder_paths_test/output_filename_test.py 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_lora_extract.py b/comfy_extras/nodes_lora_extract.py index fb89e03f4..152009be7 100644 --- a/comfy_extras/nodes_lora_extract.py +++ b/comfy_extras/nodes_lora_extract.py @@ -115,7 +115,7 @@ class LoraSave(io.ComfyNode): if text_encoder_diff is not None: output_sd = calc_lora_model(text_encoder_diff.patcher, rank, "", "text_encoders.", output_sd, lora_type, bias_diff=bias_diff) - output_checkpoint = f"{filename}_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors") output_checkpoint = os.path.join(full_output_folder, output_checkpoint) comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=None) diff --git a/comfy_extras/nodes_model_merging.py b/comfy_extras/nodes_model_merging.py index 5384ed531..95178b539 100644 --- a/comfy_extras/nodes_model_merging.py +++ b/comfy_extras/nodes_model_merging.py @@ -221,7 +221,7 @@ def save_checkpoint(model, clip=None, vae=None, clip_vision=None, filename_prefi for x in extra_pnginfo: metadata[x] = json.dumps(extra_pnginfo[x]) - output_checkpoint = f"{filename}_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors") output_checkpoint = os.path.join(full_output_folder, output_checkpoint) comfy.sd.save_checkpoint(output_checkpoint, model, clip, vae, clip_vision, metadata=metadata, extra_keys=extra_keys) @@ -297,7 +297,7 @@ class CLIPSave: full_output_folder, filename, counter, subfolder, filename_prefix_ = folder_paths.get_save_image_path(filename_prefix_, self.output_dir) - output_checkpoint = f"{filename}_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors") output_checkpoint = os.path.join(full_output_folder, output_checkpoint) current_clip_sd = comfy.utils.state_dict_prefix_replace(current_clip_sd, replace_prefix) @@ -333,7 +333,7 @@ class VAESave: for x in extra_pnginfo: metadata[x] = json.dumps(extra_pnginfo[x]) - output_checkpoint = f"{filename}_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors") output_checkpoint = os.path.join(full_output_folder, output_checkpoint) comfy.utils.save_torch_file(vae.get_sd(), output_checkpoint, metadata=metadata) diff --git a/comfy_extras/nodes_train.py b/comfy_extras/nodes_train.py index 024a89391..bbe1d0eba 100644 --- a/comfy_extras/nodes_train.py +++ b/comfy_extras/nodes_train.py @@ -1221,9 +1221,9 @@ class SaveLoRA(io.ComfyNode): folder_paths.get_save_image_path(prefix, output_dir) ) if steps is None: - output_checkpoint = f"{filename}_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors") else: - output_checkpoint = f"{filename}_{steps}_steps_{counter:05}_.safetensors" + output_checkpoint = folder_paths.format_output_filename(f"{filename}_{steps}_steps", counter, "safetensors") output_checkpoint = os.path.join(full_output_folder, output_checkpoint) safetensors.torch.save_file(lora, output_checkpoint) return io.NodeOutput() 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..1a136a989 100644 --- a/nodes.py +++ b/nodes.py @@ -508,7 +508,7 @@ class SaveLatent: for x in extra_pnginfo: metadata[x] = json.dumps(extra_pnginfo[x]) - file = f"{filename}_{counter:05}_.latent" + file = folder_paths.format_output_filename(filename, counter, "latent") results: list[FileLocator] = [] results.append({ @@ -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..e443df81d --- /dev/null +++ b/tests-unit/folder_paths_test/output_filename_test.py @@ -0,0 +1,106 @@ +"""Tests for folder_paths.format_output_filename and get_timestamp functions.""" + +import re +import sys +import os + +# 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: + """Tests for get_timestamp function.""" + + def test_returns_string(self): + """Should return a string.""" + result = folder_paths.get_timestamp() + assert isinstance(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}$" + assert re.match(pattern, result), f"Timestamp '{result}' does not match expected 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: + assert char not in result, f"Timestamp contains unsafe character: {char}" + + +class TestFormatOutputFilename: + """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$" + assert re.match(pattern, result), f"Filename '{result}' does not match expected pattern" + + def test_counter_padding(self): + """Should pad counter to 5 digits.""" + result = folder_paths.format_output_filename("test", 42, "png") + assert "_00042_" in result + + def test_extension_with_leading_dot(self): + """Should handle extension with leading dot.""" + result = folder_paths.format_output_filename("test", 1, ".png") + assert result.endswith("_.png") + assert "..png" not in result + + def test_extension_without_leading_dot(self): + """Should handle extension without leading dot.""" + result = folder_paths.format_output_filename("test", 1, "webm") + assert 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") + assert "test_3_" in result + assert "%batch_num%" not in 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) + assert custom_ts in 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) + assert result.endswith(f"_.{ext}") + + +if __name__ == "__main__": + # Simple test runner + import traceback + + test_classes = [TestGetTimestamp, TestFormatOutputFilename] + passed = 0 + failed = 0 + + for test_class in test_classes: + instance = test_class() + for method_name in dir(instance): + if method_name.startswith("test_"): + try: + getattr(instance, method_name)() + print(f"✓ {test_class.__name__}.{method_name}") + passed += 1 + except AssertionError as e: + print(f"✗ {test_class.__name__}.{method_name}: {e}") + failed += 1 + except Exception as e: + print(f"✗ {test_class.__name__}.{method_name}: {traceback.format_exc()}") + failed += 1 + + print(f"\n{passed} passed, {failed} failed") + sys.exit(0 if failed == 0 else 1)