Skip to content

Commit 49a1098

Browse files
committed
Add sparse attention methods to H3 Sparse AAttention
1 parent 3d630b3 commit 49a1098

6 files changed

Lines changed: 111 additions & 19 deletions

File tree

‎NODES.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,7 +1962,7 @@ Pick a frame cache written by Video Cache or H3 Decode Video and use it as a cli
19621962
| Name | Type | Required | Default | Choices | What it does |
19631963
|---|---|---|---|---|---|
19641964
| `cache` | `COMBO` | Yes | | | Which frame cache to read, as `frame_cache/scene_00001/cache.wasframes [output]`. A cache in temp is gone after a restart. |
1965-
| `delete_after_save` | `BOOLEAN` | Yes | False | | `true` = the cache is deleted once a save has written all of it; `false` = it is kept for the next save. |
1965+
| `delete_after_save` | `BOOLEAN` | Yes | False | | `true` = the cache's own files are deleted once a save has written all of it, for a cache in output, temp or a `paths.allow_write` folder; `false` = it is kept for the next save. |
19661966

19671967
**Outputs**
19681968

@@ -2088,7 +2088,7 @@ Keep frames on disk as a clip instead of holding them in memory, for a long rend
20882088
| `name` | `STRING` | Yes | frame_cache/clip | | The cache's name below root, numbered on each run, as `frame_cache/scene` for `frame_cache/scene_00001`. |
20892089
| `delete_after_save` | `BOOLEAN` | Yes | False | | `true` = the cached frames are deleted once a save has written all of them, and the node runs again on every queue; `false` = they are kept. |
20902090
| `audio` | `AUDIO` | No | | | Sound for the frames, added after any already in the cache. |
2091-
| `append_to` | `VIDEO` | No | | | A clip from Video Cache, H3 Decode Video or Load Video Cache to add these frames to, as a loop's carried value. Left empty, a new cache is made. |
2091+
| `append_to` | `VIDEO` | No | | | A clip from Video Cache, H3 Decode Video or Load Video Cache to add these frames to, as a loop's carried value, for a cache in output, temp or a `paths.allow_write` folder. Left empty, a new cache is made. |
20922092
| `depth` | `COMBO` | No | auto | `auto`, `16 bit` | How frames are kept: 'auto' = 8-bit codes, half floats for a batch outside 0 to 1; '16 bit' = half floats always, for a 10-bit save. |
20932093

20942094
**Outputs**

‎SECURITY.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ prefix `\??\`, checked after `~` is expanded. A link along a path is read withou
6363
and one pointing at another machine is refused the same way. ComfyUI's own path helpers resolve
6464
what they are given, so the pack calls them only through wrappers that refuse first.
6565

66-
A glob pattern, a file name prefix, and a name or folder typed below a folder the node has
67-
already chosen are refused before anything is listed or resolved when they name another
68-
machine, carry a drive, start at a root, or hold a segment of dots and spaces alone such as
69-
`..`. Whatever they match stays inside that folder.
66+
A glob pattern, a file name prefix, a frame cache name, and a name or folder typed below a
67+
folder the node has already chosen are refused before anything is listed or resolved when they
68+
name another machine, carry a drive, start at a root, or hold a segment of dots and spaces alone
69+
such as `..`. A frame cache name is refused as well when it holds a folder separator. Whatever
70+
they match stays inside that folder.
7071

7172
A node input naming a file or directory is resolved and checked against an allowlist before it
7273
is opened, listed or written, whatever value arrives: a menu offers labels, and a linked input
@@ -83,6 +84,25 @@ No node writes the pack's settings. `config.yaml`, `config.json`, the config fil
8384
names, and the `viewer-extensions/` folder are refused as write targets, even inside the state
8485
directory. [docs/CONFIG.md](docs/CONFIG.md#containment) has the table.
8586

87+
A frame cache picked from a folder no node writes to, such as `input/` or a `paths.allow_read`
88+
folder, is read and never added to or deleted.
89+
90+
### Deleting files
91+
92+
Files are deleted, moved or replaced in these modules and no others. Each removes files it
93+
wrote, by name. `shutil.rmtree` is called only on a checkpoint's download folder, which the
94+
fetch made inside the model folder.
95+
96+
| Module | What it removes |
97+
|---|---|
98+
| `modules/media/frame_cache.py` | A frame cache's own `cache.wasframes`, `frames.bin` and `audio.npy` once a save has written it, then its numbered folder if that is left empty. Only inside `output/`, `temp/` or a `paths.allow_write` folder, and never a permitted root itself. The manifest is replaced through a `.part` file |
99+
| `modules/media/video.py` | The video Write to Video adds a frame to, replaced by the copy holding that frame |
100+
| `modules/media/temporal_upscale.py` | The files in `temp/` a failed Video Temporal Upscale run was writing |
101+
| `modules/archive/save.py` | The archive Zip Save was writing, when the write fails |
102+
| `modules/latent/h3_clip_file.py` | The `.part` file H3 Save Clip writes, renamed over the clip once complete |
103+
| `modules/interface/preview.py` | Mask editor uploads, as the route table below states |
104+
| `modules/model/h3_latent_upscaler.py`, `modules/model/flow_networks.py` | The download folder a checkpoint lands in, once the checkpoint is moved out of it |
105+
86106
## HTTP routes
87107

88108
Every route the pack registers takes a key. None takes a path.

‎modules/media/frame_cache.py‎

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,28 @@
1212
import json
1313
import os
1414
import re
15-
import shutil
1615
from fractions import Fraction
16+
from pathlib import Path
1717

1818
import numpy as np
1919
import torch
2020
from comfy_api.latest import Input, InputImpl, Types
2121

2222
from .. import log
2323
from ..image import scratch
24+
from ..util import sandbox
2425

2526
__all__ = [
2627
"DATA",
2728
"DEPTHS",
2829
"EXTENSION",
2930
"MANIFEST",
31+
"OWN_FILES",
3032
"SOUND",
3133
"CachedVideo",
3234
"FrameCache",
3335
"cached",
36+
"writable",
3437
]
3538

3639
logger = log.get_logger("media.frame_cache")
@@ -60,16 +63,36 @@
6063
#: Folder names a cache takes, the name then a five digit number.
6164
FOLDER = re.compile(r"^(?P<name>.+)_(?P<number>\d{5})$")
6265

66+
#: The files a cache writes in its folder, the only ones deleting it removes.
67+
OWN_FILES = (MANIFEST, MANIFEST + ".part", DATA, SOUND)
68+
69+
70+
def writable(folder: str) -> bool:
71+
"""Whether a cache folder lies inside a folder nodes may write to, so it may be added to or deleted."""
72+
resolved = Path(os.path.abspath(folder)).resolve(strict=False)
73+
return any(sandbox.contains(root, resolved) for root in sandbox.write_roots())
74+
6375

6476
def _next_folder(parent: str, name: str) -> str:
65-
"""The next unused ``name_NNNNN`` folder below ``parent``."""
77+
"""The next unused ``name_NNNNN`` folder below ``parent``, inside a permitted write folder.
78+
79+
Raises:
80+
PathNotAllowed: ``name`` is not one folder name, or ``parent`` is outside every write root.
81+
"""
82+
reason = sandbox.leaves_folder(name)
83+
if reason is None and ("/" in name or "\\" in name):
84+
reason = "holds a folder separator"
85+
if reason is not None:
86+
raise sandbox.PathNotAllowed(
87+
f"refusing the frame cache name `{name}`: it is the name of one folder inside "
88+
f"{parent}, and this one {reason}. Use a plain name such as `clip`.")
6689
highest = 0
6790
with contextlib.suppress(OSError):
6891
for entry in os.listdir(parent):
6992
found = FOLDER.match(entry)
7093
if found and found.group("name") == name:
7194
highest = max(highest, int(found.group("number")))
72-
return os.path.join(parent, f"{name}_{highest + 1:05d}")
95+
return str(sandbox.resolve_write_file(parent, f"{name}_{highest + 1:05d}"))
7396

7497

7598
class FrameCache:
@@ -98,7 +121,12 @@ def create(cls, parent: str, name: str, rate, depth: str = "auto",
98121
99122
Returns:
100123
The :class:`FrameCache`.
124+
125+
Raises:
126+
PathNotAllowed: ``name`` is not one folder name, or the folder would land outside
127+
every permitted write folder.
101128
"""
129+
parent = str(sandbox.resolve_write(parent))
102130
os.makedirs(parent, exist_ok=True)
103131
folder = _next_folder(parent, name)
104132
os.makedirs(folder)
@@ -196,6 +224,19 @@ def _frame_bytes(self, dtype: str) -> int:
196224
width, height = self.size
197225
return width * height * self.channels * (1 if dtype == "uint8" else 2)
198226

227+
def _require_writable(self) -> None:
228+
"""Refuse to add to a cache outside every folder nodes may write to.
229+
230+
Raises:
231+
PathNotAllowed: The cache's folder is outside every permitted write folder.
232+
"""
233+
if not writable(self.folder):
234+
raise sandbox.PathNotAllowed(
235+
f"the frame cache at {self.folder} is outside every folder nodes write to "
236+
f"(output, temp and paths.allow_write), so nothing is added to it. Add to a "
237+
f"cache in output or temp, or start a new cache."
238+
)
239+
199240
def append(self, images, scene: bool = False, device=None) -> int:
200241
"""Write a batch of frames after the last.
201242
@@ -209,11 +250,13 @@ def append(self, images, scene: bool = False, device=None) -> int:
209250
210251
Raises:
211252
ValueError: The batch's size or channel count differs from the cache's.
253+
PathNotAllowed: The cache is outside every permitted write folder.
212254
"""
213255
count, height, width, channels = (int(side) for side in images.shape)
214256
first = self.frames
215257
if count == 0:
216258
return first
259+
self._require_writable()
217260
if first == 0:
218261
self.manifest.update(width=width, height=height, channels=channels)
219262
elif (width, height, channels) != (*self.size, self.channels):
@@ -263,9 +306,11 @@ def add_audio(self, audio) -> None:
263306
264307
Raises:
265308
ValueError: The sample rate or channel count differs from the sound kept.
309+
PathNotAllowed: The cache is outside every permitted write folder.
266310
"""
267311
if not isinstance(audio, dict) or audio.get("waveform") is None:
268312
return
313+
self._require_writable()
269314
wave = audio["waveform"][0].detach().float().cpu().numpy()
270315
rate = int(audio.get("sample_rate", 44100))
271316
held = self.manifest.get("audio")
@@ -349,9 +394,27 @@ def audio(self, start: int = 0, stop: int | None = None) -> dict | None:
349394
return {"waveform": piece.unsqueeze(0), "sample_rate": int(held["sample_rate"])}
350395

351396
def delete(self) -> None:
352-
"""Remove the cache's folder and everything in it."""
353-
shutil.rmtree(self.folder, ignore_errors=True)
354-
logger.info("deleted the frame cache at %s", self.folder)
397+
"""Remove the cache's own files and its numbered folder once empty; a cache outside every write folder stays."""
398+
folder = Path(os.path.abspath(self.folder)).resolve(strict=False)
399+
permitted = sandbox.write_roots()
400+
if not any(sandbox.contains(root, folder) for root in permitted):
401+
logger.warning(
402+
"the frame cache at %s was not deleted: it is outside every folder a node "
403+
"writes to (%s). Add its folder to paths.allow_write in config.yaml to let "
404+
"the cache be deleted after saving.", folder, ", ".join(map(str, permitted)))
405+
return
406+
for name in OWN_FILES:
407+
try:
408+
(folder / name).unlink()
409+
except FileNotFoundError:
410+
pass
411+
except OSError as error:
412+
logger.warning("could not delete %s: %s", folder / name, error)
413+
roots = {os.path.normcase(str(root)) for root in (*permitted, *sandbox.read_roots())}
414+
if FOLDER.match(folder.name) and os.path.normcase(str(folder)) not in roots:
415+
with contextlib.suppress(OSError):
416+
folder.rmdir()
417+
logger.info("deleted the frame cache at %s", folder)
355418

356419

357420
class _Frames:

‎nodes/io/load_video_cache.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ def define_schema(cls) -> io.Schema:
7070
"delete_after_save",
7171
default=False,
7272
tooltip=(
73-
"`true` = the cache is deleted once a save has written all of it; "
74-
"`false` = it is kept for the next save."
73+
"`true` = the cache's own files are deleted once a save has written "
74+
"all of it, for a cache in output, temp or a `paths.allow_write` "
75+
"folder; `false` = it is kept for the next save."
7576
),
7677
),
7778
],
@@ -98,7 +99,8 @@ def execute(cls, cache="", delete_after_save=False) -> io.NodeOutput:
9899
99100
Raises:
100101
ValueError: No cache is chosen, or the chosen one is no longer there.
101-
PathNotAllowed: The cache sits outside every folder this pack may read.
102+
PathNotAllowed: The cache sits outside every folder this pack may read, or
103+
``delete_after_save`` is on for a cache outside every folder it may write.
102104
"""
103105
path = _named(cache)
104106
if not path:
@@ -112,6 +114,12 @@ def execute(cls, cache="", delete_after_save=False) -> io.NodeOutput:
112114
f"{NODE_NAME} cannot find {cache}; it was deleted or moved. Pick another cache."
113115
)
114116
folder = os.path.dirname(str(manifest))
117+
if delete_after_save and not frame_cache.writable(folder):
118+
raise sandbox.PathNotAllowed(
119+
f"{NODE_NAME} deletes a cache after saving only from output, temp or a "
120+
f"paths.allow_write folder, and {folder} is in none of them. Turn "
121+
f"delete_after_save off to read it, or move the cache into output."
122+
)
115123
video = frame_cache.CachedVideo(folder, delete_after_save=bool(delete_after_save))
116124
rate = video.get_frame_rate()
117125
logger.info("opened %d cached frame(s) at %s", video.get_frame_count(), folder)

‎nodes/io/video_cache.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ def define_schema(cls) -> io.Schema:
8080
optional=True,
8181
tooltip=(
8282
"A clip from Video Cache, H3 Decode Video or Load Video Cache to add "
83-
"these frames to, as a loop's carried value. Left empty, a new cache "
84-
"is made."
83+
"these frames to, as a loop's carried value, for a cache in output, "
84+
"temp or a `paths.allow_write` folder. Left empty, a new cache is made."
8585
),
8686
),
8787
io.Combo.Input(
@@ -121,7 +121,8 @@ def execute(
121121
122122
Raises:
123123
ValueError: append_to is not a cached clip, or the frames differ from it in size.
124-
PathNotAllowed: root and name settle outside every permitted write folder.
124+
PathNotAllowed: root and name settle outside every permitted write folder, or
125+
append_to's cache is outside every one.
125126
"""
126127
if append_to is not None:
127128
if not frame_cache.cached(append_to):

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "was-node-suite-comfyui"
33
description = "WAS-NS reborn; Tools for image processing, filters, masking, text, logic, numbers, latents, files, 3D scenes, and animation."
4-
version = "3.5.2"
4+
version = "3.5.3"
55
license = { file = "LICENSE" }
66
requires-python = ">=3.10"
77

0 commit comments

Comments
 (0)