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
This commit is contained in:
bymyself 2026-01-31 22:30:57 -08:00
parent 873de5f37a
commit 0f259cabdd
10 changed files with 163 additions and 20 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

@ -115,7 +115,7 @@ class LoraSave(io.ComfyNode):
if text_encoder_diff is not None: 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_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) output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=None) comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=None)

View File

@ -221,7 +221,7 @@ def save_checkpoint(model, clip=None, vae=None, clip_vision=None, filename_prefi
for x in extra_pnginfo: for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x]) 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) 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) 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) 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) output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
current_clip_sd = comfy.utils.state_dict_prefix_replace(current_clip_sd, replace_prefix) 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: for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x]) 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) output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.utils.save_torch_file(vae.get_sd(), output_checkpoint, metadata=metadata) comfy.utils.save_torch_file(vae.get_sd(), output_checkpoint, metadata=metadata)

View File

@ -1221,9 +1221,9 @@ class SaveLoRA(io.ComfyNode):
folder_paths.get_save_image_path(prefix, output_dir) folder_paths.get_save_image_path(prefix, output_dir)
) )
if steps is None: if steps is None:
output_checkpoint = f"{filename}_{counter:05}_.safetensors" output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
else: 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) output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
safetensors.torch.save_file(lora, output_checkpoint) safetensors.torch.save_file(lora, output_checkpoint)
return io.NodeOutput() return io.NodeOutput()

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

@ -508,7 +508,7 @@ class SaveLatent:
for x in extra_pnginfo: for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x]) 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: list[FileLocator] = []
results.append({ results.append({
@ -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,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)