mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-02-07 03:52:32 +08:00
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:
parent
873de5f37a
commit
0f259cabdd
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
5
nodes.py
5
nodes.py
@ -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,
|
||||||
|
|||||||
106
tests-unit/folder_paths_test/output_filename_test.py
Normal file
106
tests-unit/folder_paths_test/output_filename_test.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user