持久化历史中的临时媒体
This commit is contained in:
@@ -50,6 +50,8 @@ Edit `config.json` before starting ComfyUI.
|
||||
|
||||
Completed generation history is persisted in `history.sqlite3` and restored when ComfyUI restarts. The history database keeps the same 10,000-item limit as ComfyUI's in-memory history.
|
||||
|
||||
Temporary preview images, videos, and audio referenced by completed history are linked into the user's persistent output folder before the history entry is saved. This keeps history thumbnails and media available after ComfyUI clears its temp folder during a restart.
|
||||
|
||||
ComfyUI's optional Assets panel is separate from queue history and requires starting ComfyUI with `--enable-assets`.
|
||||
|
||||
## API Access
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
module_path = Path(__file__).parents[1] / "utils" / "history_assets.py"
|
||||
spec = importlib.util.spec_from_file_location("history_assets", module_path)
|
||||
history_assets = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(history_assets)
|
||||
persist_temp_assets = history_assets.persist_temp_assets
|
||||
|
||||
|
||||
class HistoryAssetsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.temp_root = self.root / "temp"
|
||||
self.output_root = self.root / "output"
|
||||
self.source_dir = self.temp_root / "alice" / "2026-07-17" / "alice"
|
||||
self.source_dir.mkdir(parents=True)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_temp_reference_is_made_durable_and_rewritten(self):
|
||||
source = self.source_dir / "preview.png"
|
||||
source.write_bytes(b"preview-data")
|
||||
item = {
|
||||
"outputs": {
|
||||
"1": {
|
||||
"images": [
|
||||
{
|
||||
"filename": "preview.png",
|
||||
"subfolder": "2026-07-17/alice",
|
||||
"type": "temp",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count = persist_temp_assets(
|
||||
item,
|
||||
str(self.temp_root),
|
||||
str(self.output_root),
|
||||
"alice",
|
||||
"2026-07-17/alice/history_assets/prompt-1",
|
||||
)
|
||||
|
||||
reference = item["outputs"]["1"]["images"][0]
|
||||
destination = self.output_root / reference["subfolder"] / reference["filename"]
|
||||
self.assertEqual(1, count)
|
||||
self.assertEqual("output", reference["type"])
|
||||
self.assertTrue(destination.is_file())
|
||||
self.assertEqual(b"preview-data", destination.read_bytes())
|
||||
|
||||
shutil.rmtree(self.temp_root)
|
||||
self.assertEqual(b"preview-data", destination.read_bytes())
|
||||
|
||||
def test_missing_temp_file_keeps_original_reference(self):
|
||||
reference = {
|
||||
"filename": "missing.mp4",
|
||||
"subfolder": "2026-07-17/alice",
|
||||
"type": "temp",
|
||||
}
|
||||
item = {"outputs": {"1": {"videos": [reference]}}}
|
||||
|
||||
count = persist_temp_assets(
|
||||
item,
|
||||
str(self.temp_root),
|
||||
str(self.output_root),
|
||||
"alice",
|
||||
"2026-07-17/alice/history_assets/prompt-2",
|
||||
)
|
||||
|
||||
self.assertEqual(0, count)
|
||||
self.assertEqual("temp", reference["type"])
|
||||
|
||||
def test_unowned_base_temp_path_is_not_copied(self):
|
||||
foreign_dir = self.temp_root / "2026-07-17" / "bob"
|
||||
foreign_dir.mkdir(parents=True)
|
||||
(foreign_dir / "foreign.png").write_bytes(b"foreign")
|
||||
reference = {
|
||||
"filename": "foreign.png",
|
||||
"subfolder": "2026-07-17/bob",
|
||||
"type": "temp",
|
||||
}
|
||||
item = {"outputs": {"1": {"images": [reference]}}}
|
||||
|
||||
count = persist_temp_assets(
|
||||
item,
|
||||
str(self.temp_root),
|
||||
str(self.output_root),
|
||||
"alice",
|
||||
"2026-07-17/alice/history_assets/prompt-3",
|
||||
)
|
||||
|
||||
self.assertEqual(0, count)
|
||||
self.assertEqual("temp", reference["type"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,7 @@ from server import PromptServer
|
||||
from execution import PromptQueue, MAXIMUM_HISTORY_SIZE
|
||||
|
||||
from .users_db import UsersDB
|
||||
from .history_assets import persist_temp_assets
|
||||
from .history_store import HistoryStore
|
||||
|
||||
|
||||
@@ -380,9 +381,27 @@ class AccessControl:
|
||||
"user_id": user_id,
|
||||
}
|
||||
self.__prompt_queue.history[prompt[1]].update(history_result)
|
||||
self._persist_history_assets(prompt[1], user_id)
|
||||
self._save_history_item(prompt[1])
|
||||
self.server.queue_updated()
|
||||
|
||||
def _persist_history_assets(self, prompt_id: str, user_id: str) -> None:
|
||||
if not user_id:
|
||||
return
|
||||
try:
|
||||
destination_subfolder = (
|
||||
f"{self._get_user_path_prefix(user_id)}/history_assets/{prompt_id}"
|
||||
)
|
||||
persist_temp_assets(
|
||||
self.__prompt_queue.history[prompt_id],
|
||||
self.__get_temp_directory(),
|
||||
self.__get_output_directory(),
|
||||
self._get_user_slug(user_id),
|
||||
destination_subfolder,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to persist temporary assets for %s", prompt_id)
|
||||
|
||||
def _save_history_item(self, prompt_id: str) -> None:
|
||||
if not self.__history_store:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
def _path_parts(path: str):
|
||||
normalized = str(path or "").replace("\\", "/").strip("/")
|
||||
parts = [part for part in normalized.split("/") if part]
|
||||
if not parts or any(part in (".", "..") for part in parts):
|
||||
return None
|
||||
return parts
|
||||
|
||||
|
||||
def _iter_temp_references(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("type") == "temp" and value.get("filename"):
|
||||
yield value
|
||||
for nested in value.values():
|
||||
yield from _iter_temp_references(nested)
|
||||
elif isinstance(value, list):
|
||||
for nested in value:
|
||||
yield from _iter_temp_references(nested)
|
||||
|
||||
|
||||
def persist_temp_assets(
|
||||
history_item: dict,
|
||||
temp_root: str,
|
||||
output_root: str,
|
||||
owner_slug: str,
|
||||
destination_subfolder: str,
|
||||
) -> int:
|
||||
"""Persist history-only temp references onto durable output paths."""
|
||||
owner_parts = _path_parts(owner_slug)
|
||||
destination_parts = _path_parts(destination_subfolder)
|
||||
if not owner_parts or len(owner_parts) != 1 or not destination_parts:
|
||||
return 0
|
||||
|
||||
owner_slug = owner_parts[0]
|
||||
destination_dir = os.path.join(output_root, *destination_parts)
|
||||
persisted = 0
|
||||
|
||||
for reference in _iter_temp_references(history_item):
|
||||
filename = str(reference.get("filename") or "")
|
||||
if not filename or filename != os.path.basename(filename):
|
||||
continue
|
||||
|
||||
subfolder_parts = _path_parts(reference.get("subfolder"))
|
||||
if not subfolder_parts:
|
||||
continue
|
||||
|
||||
owned_subfolder = (
|
||||
subfolder_parts[0] == owner_slug
|
||||
or (len(subfolder_parts) >= 2 and subfolder_parts[1] == owner_slug)
|
||||
)
|
||||
source_candidates = [
|
||||
os.path.join(temp_root, owner_slug, *subfolder_parts, filename)
|
||||
]
|
||||
if owned_subfolder:
|
||||
source_candidates.append(
|
||||
os.path.join(temp_root, *subfolder_parts, filename)
|
||||
)
|
||||
|
||||
source = next(
|
||||
(candidate for candidate in source_candidates if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
os.makedirs(destination_dir, exist_ok=True)
|
||||
destination = os.path.join(destination_dir, filename)
|
||||
if not os.path.exists(destination):
|
||||
try:
|
||||
os.link(source, destination)
|
||||
except OSError:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
reference["type"] = "output"
|
||||
reference["subfolder"] = "/".join(destination_parts)
|
||||
persisted += 1
|
||||
|
||||
return persisted
|
||||
Reference in New Issue
Block a user