5 Commits
Author SHA1 Message Date
PozzettiAndrea 077af5ce5c comfy-env-root.toml: [node_reqs] is now [node_packs]
The section never held nodes -- every entry is a whole pack repo. Read
literally, `[node_reqs]` says "the requirements of my node", which is pip
packages, and that section genuinely exists one file over as [dependencies]
in comfy-env.toml. `[node_packs]` names the unit and sits parallel to the
existing [cuda_packages].

Requires comfy-env >= 0.4.27, which renames the key with no shim: the root
file has a closed schema, so an old comfy-env rejects [node_packs] by name
and a new one rejects [node_reqs] by name. Neither fails silently.
2026-08-24 15:48:51 +02:00
PozzettiAndrea a00a4f1186 Drop the dead WEB_DIRECTORY: pyproject is the single web declaration
The frontend directory is declared once, in pyproject.toml as
`[tool.comfy] web = "javascript"`. This `WEB_DIRECTORY = "./web"` pointed at
a directory that no longer exists -- it was left behind when the JS moved to
javascript/ -- so it is dead today. It is also a trap.

ComfyUI registers the web folder in TWO independent code paths, and they key
the same dict differently (nodes.py):

    pyproject [tool.comfy] web  ->  EXTENSION_WEB_DIRS[project.name]   (:2280)
    WEB_DIRECTORY               ->  EXTENSION_WEB_DIRS[module_name]    (:2289)

They are separate `if` blocks guarded by os.path.isdir(), not a fallback
chain, and project.name is rarely the directory name. So the moment a `web/`
directory exists again -- a revert, a merge, a stray file -- BOTH paths
register the same folder under two different keys. GET /extensions then lists
every file twice, the browser imports each twice, and app.registerExtension
runs twice with the same name: the collision the javascript test level exists
to catch, and invisible to it, because the duplication is in registration
rather than in source.

Removing the stale declaration makes that unreachable instead of dormant.
2026-08-24 15:31:59 +02:00
PozzettiAndrea 4888e4886e Vendor viewer JS, drop comfy-3d-viewers
Vendor the viewer JavaScript into javascript/ and drop the
comfy-3d-viewers dependency. The viewers were previously copied into web/ at
every startup by prestartup_script.py, which made the served frontend depend on
a second package resolving at runtime.

Bundles are vendored as .mjs so ComfyUI's **/*.js auto-import glob skips them:
they are iframe-only and must never enter the shared main browser realm. Only
the widget entry points stay .js.

Also replace comfy_env.copy_files with a local helper. Its two extra features
(frame-derived source dir, silent no-op on a missing source) were unused here,
and the asset seeding now resolves the input directory through
folder_paths.get_input_directory() so it follows ComfyUI Desktop and
--input-directory instead of writing into the code tree.

comfy-test.toml moves to the current schema: [test.workflows] cuda (there is no
"gpu" key -- the accelerator is named by backend, and the old spelling parsed to
an empty list, so CUDA runners silently ran the full unfiltered set), an
explicit [test.platforms] platforms allowlist (per-platform booleans are no
longer supported), and the javascript level.

Namespace every registerExtension name under sam3. and inline the spinner
styles: the stylesheet used to be injected into document.head, which is shared
state every other pack inherits and nobody can clean up. It is now inline on the
element with the rotation driven by the Web Animations API.
2026-08-22 19:44:03 +02:00
PozzettiAndrea 6b5c1bb1b1 new 2026-07-09 15:21:41 +02:00
PozzettiAndrea 2685495c65 bump 2026-05-19 17:51:58 +02:00
15 changed files with 105 additions and 158 deletions
+1 -2
View File
@@ -3,5 +3,4 @@ from comfy_env import register_nodes
NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS = register_nodes()
WEB_DIRECTORY = "./web"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+1 -1
View File
@@ -1,3 +1,3 @@
[node_reqs]
[node_packs]
ComfyUI-VideoHelperSuite = "Kosinkadink/ComfyUI-VideoHelperSuite"
ComfyUI-Multiband = "PozzettiAndrea/ComfyUI-Multiband"
+9 -18
View File
@@ -1,18 +1,9 @@
[test]
levels = ["syntax", "install", "registration", "instantiation", "execution"]
[test.workflows]
cpu = ["!video_point_prompt"]
gpu = "all"
[test.platforms]
linux = true
macos = true
windows = true
windows_portable = true
linux_gpu = true
windows_gpu = true
windows_portable_gpu = true
macos_desktop = true
windows_desktop = true
windows_desktop_gpu = true
[test]
levels = ["syntax", "javascript", "coverage", "install", "registration", "instantiation", "execution"]
[test.workflows]
cpu = ["!video_point_prompt"]
cuda = "all"
[test.platforms]
platforms = ["linux", "macos", "windows", "windows_portable", "macos_desktop", "windows_desktop", "linux_cuda", "windows_cuda", "windows_portable_cuda", "windows_desktop_cuda"]
@@ -41,7 +41,7 @@ function hideWidgetForGood(node, widget, suffix = '') {
}
app.registerExtension({
name: "Comfy.SAM3.SimpleBBoxCollector",
name: "sam3.simplebboxcollector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SAM3BBoxCollector") {
@@ -32,38 +32,31 @@ function hideWidgetForGood(node, widget, suffix = '') {
}
}
function ensureSpinnerCSS() {
if (document.getElementById("sam3-spinner-css")) return;
const style = document.createElement("style");
style.id = "sam3-spinner-css";
style.textContent = `
@keyframes sam3spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sam3-spinner {
display: inline-block;
width: 10px; height: 10px;
border: 2px solid rgba(136, 204, 255, 0.2);
border-top-color: #8cf;
border-radius: 50%;
animation: sam3spin 0.65s linear infinite;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
}
`;
document.head.appendChild(style);
// Spinner styling is inlined on the element and animated via the Web
// Animations API rather than an injected <style>. A stylesheet in
// document.head is shared-realm state every other extension inherits and
// nobody can clean up; this keeps the whole spinner inside our own DOM.
function makeSpinner() {
const spinner = document.createElement("span");
spinner.style.cssText =
"display: inline-block; width: 10px; height: 10px;" +
"border: 2px solid rgba(136, 204, 255, 0.2); border-top-color: #8cf;" +
"border-radius: 50%; vertical-align: middle; margin-left: 4px;" +
"flex-shrink: 0;";
spinner.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: 650, iterations: Infinity, easing: "linear" },
);
return spinner;
}
app.registerExtension({
name: "Comfy.SAM3.InteractiveCollector",
name: "sam3.interactivecollector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "SAM3InteractiveCollector") return;
console.log("[SAM3] Registering SAM3InteractiveCollector node");
ensureSpinnerCSS();
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
@@ -518,9 +511,7 @@ app.registerExtension({
// Running: full CSS circle spinner
if (this.canvasWidget.isProcessing && prompt.isRunning) {
const spinner = document.createElement("span");
spinner.className = "sam3-spinner";
tab.appendChild(spinner);
tab.appendChild(makeSpinner());
// Pending: waiting its turn in the queue
} else if (this.canvasWidget.isProcessing && prompt.isPending) {
const dots = document.createElement("span");
@@ -37,7 +37,7 @@ function hideWidgetForGood(node, widget, suffix = '') {
}
app.registerExtension({
name: "Comfy.SAM3.MultiRegionCollector",
name: "sam3.multiregioncollector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "SAM3MultiRegionCollector") return;
@@ -41,7 +41,7 @@ function hideWidgetForGood(node, widget, suffix = '') {
}
app.registerExtension({
name: "Comfy.SAM3.SimplePointCollector",
name: "sam3.simplepointcollector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SAM3PointCollector") {
@@ -193,7 +193,7 @@ function refreshNode(node) {
// Main extension registration
app.registerExtension({
name: "comfyui.sam3.video_dynamic",
name: "sam3.video_dynamic",
async nodeCreated(node) {
if (node.comfyClass === "SAM3VideoSegmentation") {
+20 -41
View File
@@ -276,49 +276,28 @@ class SAM3BBoxCollector(io.ComfyNode):
img_height, img_width = image.shape[1], image.shape[2]
log.info(f"Image dimensions: {img_width}x{img_height}")
def _to_center_box(bbox: dict) -> list:
"""Normalize a raw {x1,y1,x2,y2} canvas rect (pixel space) to
[center_x, center_y, width, height] in 0-1 range."""
x1, y1, x2, y2 = bbox['x1'], bbox['y1'], bbox['x2'], bbox['y2']
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
x1_norm, y1_norm = x1 / img_width, y1 / img_height
x2_norm, y2_norm = x2 / img_width, y2 / img_height
return [(x1_norm + x2_norm) / 2, (y1_norm + y2_norm) / 2, x2_norm - x1_norm, y2_norm - y1_norm]
# Convert to SAM3_BOXES_PROMPT format with boxes and labels
positive_boxes = []
positive_labels = []
negative_boxes = []
negative_labels = []
positive_boxes = [_to_center_box(b) for b in pos_bbox_list]
positive_labels = [True] * len(positive_boxes)
negative_boxes = [_to_center_box(b) for b in neg_bbox_list]
negative_labels = [False] * len(negative_boxes)
# Add positive bboxes (label = True)
for bbox in pos_bbox_list:
# Normalize bbox coordinates to 0-1 range
x1_norm = bbox['x1'] / img_width
y1_norm = bbox['y1'] / img_height
x2_norm = bbox['x2'] / img_width
y2_norm = bbox['y2'] / img_height
# Convert from [x1, y1, x2, y2] to [center_x, center_y, width, height]
# SAM3 expects boxes in center format
center_x = (x1_norm + x2_norm) / 2
center_y = (y1_norm + y2_norm) / 2
width = x2_norm - x1_norm
height = y2_norm - y1_norm
positive_boxes.append([center_x, center_y, width, height])
positive_labels.append(True) # Positive boxes
log.info(f" Positive BBox: ({bbox['x1']:.1f}, {bbox['y1']:.1f}, {bbox['x2']:.1f}, {bbox['y2']:.1f}) -> center=({center_x:.3f}, {center_y:.3f}) size=({width:.3f}, {height:.3f})")
# Add negative bboxes (label = False)
for bbox in neg_bbox_list:
# Normalize bbox coordinates to 0-1 range
x1_norm = bbox['x1'] / img_width
y1_norm = bbox['y1'] / img_height
x2_norm = bbox['x2'] / img_width
y2_norm = bbox['y2'] / img_height
# Convert from [x1, y1, x2, y2] to [center_x, center_y, width, height]
# SAM3 expects boxes in center format
center_x = (x1_norm + x2_norm) / 2
center_y = (y1_norm + y2_norm) / 2
width = x2_norm - x1_norm
height = y2_norm - y1_norm
negative_boxes.append([center_x, center_y, width, height])
negative_labels.append(False) # Negative boxes
log.info(f" Negative BBox: ({bbox['x1']:.1f}, {bbox['y1']:.1f}, {bbox['x2']:.1f}, {bbox['y2']:.1f}) -> center=({center_x:.3f}, {center_y:.3f}) size=({width:.3f}, {height:.3f})")
for b in positive_boxes:
log.info(f" Positive BBox: center=({b[0]:.3f}, {b[1]:.3f}) size=({b[2]:.3f}, {b[3]:.3f})")
for b in negative_boxes:
log.info(f" Negative BBox: center=({b[0]:.3f}, {b[1]:.3f}) size=({b[2]:.3f}, {b[3]:.3f})")
log.info(f"Output: {len(positive_boxes)} positive, {len(negative_boxes)} negative bboxes")
+9 -59
View File
@@ -59,9 +59,9 @@ class SAM3Grounding(io.ComfyNode):
io.String.Input("text_prompt", default="", multiline=False, optional=True,
tooltip="Describe what to segment using natural language (e.g., 'person', 'cat', 'red car', 'shoes')"),
io.Custom("SAM3_BOXES_PROMPT").Input("positive_boxes", optional=True,
tooltip="Optional box prompts to focus detection on specific regions. Connect from SAM3CombineBoxes node."),
tooltip="Optional box prompts to focus detection on specific regions. Connect from SAM3CreateBox."),
io.Custom("SAM3_BOXES_PROMPT").Input("negative_boxes", optional=True,
tooltip="Optional box prompts to exclude specific regions from detection. Connect from SAM3CombineBoxes node."),
tooltip="Optional box prompts to exclude specific regions from detection. Connect from SAM3CreateBox."),
io.Int.Input("max_detections", default=-1, min=-1, max=100, step=1, optional=True,
tooltip="Maximum number of detections to return (-1 for all)"),
],
@@ -229,7 +229,9 @@ class SAM3CreateBox(io.ComfyNode):
"""
Helper node to create a box prompt visually
Use sliders to define a bounding box for refinement.
Use sliders to define a bounding box for refinement. Outputs a ready-to-use
SAM3_BOXES_PROMPT (a single-box list) so it can be wired directly into
SAM3Grounding/SAM3Segmentation without a separate combine step.
"""
@classmethod
@@ -251,7 +253,7 @@ class SAM3CreateBox(io.ComfyNode):
tooltip="True for positive (include), False for negative (exclude)"),
],
outputs=[
io.Custom("SAM3_BOX_PROMPT").Output(display_name="box_prompt"),
io.Custom("SAM3_BOXES_PROMPT").Output(display_name="box_prompt"),
],
)
@@ -259,8 +261,8 @@ class SAM3CreateBox(io.ComfyNode):
def execute(cls, center_x, center_y, width, height, is_positive):
"""Create a box prompt"""
box_prompt = {
"box": [center_x, center_y, width, height],
"label": is_positive
"boxes": [[center_x, center_y, width, height]],
"labels": [is_positive],
}
return io.NodeOutput(box_prompt)
@@ -301,56 +303,6 @@ class SAM3CreatePoint(io.ComfyNode):
return io.NodeOutput(point_prompt)
class SAM3CombineBoxes(io.ComfyNode):
"""
Combine multiple box prompts into a single input
Connect multiple SAM3CreateBox nodes to combine them.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SAM3CombineBoxes",
display_name="SAM3 Combine Boxes",
category="SAM3/prompts",
inputs=[
io.Custom("SAM3_BOX_PROMPT").Input("box_1", optional=True,
tooltip="Connect box prompts from SAM3CreateBox nodes. Combines multiple boxes into a single prompt for SAM3Segmentation."),
io.Custom("SAM3_BOX_PROMPT").Input("box_2", optional=True,
tooltip="Connect box prompts from SAM3CreateBox nodes. Combines multiple boxes into a single prompt for SAM3Segmentation."),
io.Custom("SAM3_BOX_PROMPT").Input("box_3", optional=True,
tooltip="Connect box prompts from SAM3CreateBox nodes. Combines multiple boxes into a single prompt for SAM3Segmentation."),
io.Custom("SAM3_BOX_PROMPT").Input("box_4", optional=True,
tooltip="Connect box prompts from SAM3CreateBox nodes. Combines multiple boxes into a single prompt for SAM3Segmentation."),
io.Custom("SAM3_BOX_PROMPT").Input("box_5", optional=True,
tooltip="Connect box prompts from SAM3CreateBox nodes. Combines multiple boxes into a single prompt for SAM3Segmentation."),
],
outputs=[
io.Custom("SAM3_BOXES_PROMPT").Output(display_name="boxes_prompt"),
],
)
@classmethod
def execute(cls, **kwargs):
"""Combine multiple box prompts"""
boxes = []
labels = []
for i in range(1, 6):
box_key = f"box_{i}"
if box_key in kwargs and kwargs[box_key] is not None:
box_data = kwargs[box_key]
boxes.append(box_data["box"])
labels.append(box_data["label"])
combined = {
"boxes": boxes,
"labels": labels
}
return io.NodeOutput(combined)
class SAM3CombinePoints(io.ComfyNode):
"""
Combine multiple point prompts into a single input
@@ -439,7 +391,7 @@ class SAM3Segmentation(io.ComfyNode):
io.Custom("SAM3_POINTS_PROMPT").Input("negative_points", optional=True,
tooltip="Background points - exclude these areas from segmentation. Connect from SAM3CombinePoints or SAM3PointCollector."),
io.Custom("SAM3_BOXES_PROMPT").Input("box", optional=True,
tooltip="Box prompt to constrain segmentation region. Only first box is used. Connect from SAM3CombineBoxes."),
tooltip="Box prompt to constrain segmentation region. Only first box is used. Connect from SAM3CreateBox."),
io.Int.Input("refinement_iterations", default=0, min=0, max=10, optional=True,
tooltip="Number of refinement passes. Each pass feeds the mask back for cleaner edges."),
io.Boolean.Input("use_multimask", default=True, optional=True,
@@ -866,7 +818,6 @@ NODE_CLASS_MAPPINGS = {
"SAM3MultipromptSegmentation": SAM3MultipromptSegmentation,
"SAM3CreateBox": SAM3CreateBox,
"SAM3CreatePoint": SAM3CreatePoint,
"SAM3CombineBoxes": SAM3CombineBoxes,
"SAM3CombinePoints": SAM3CombinePoints,
}
@@ -876,6 +827,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SAM3MultipromptSegmentation": "SAM3 Multiprompt Segmentation",
"SAM3CreateBox": "SAM3 Create Box",
"SAM3CreatePoint": "SAM3 Create Point",
"SAM3CombineBoxes": "SAM3 Combine Boxes",
"SAM3CombinePoints": "SAM3 Combine Points",
}
+38 -3
View File
@@ -1,12 +1,47 @@
"""ComfyUI-SAM3 Prestartup Script."""
import logging
import shutil
from pathlib import Path
from comfy_env import setup_env, copy_files
import folder_paths
from comfy_env import setup_env
log = logging.getLogger("sam3")
setup_env()
# The CONFIGURED input directory, never the code-tree one. ComfyUI Desktop
# (--base-directory) and --input-directory both relocate it, and the load
# nodes only ever scan folder_paths.get_input_directory(). main.py runs
# apply_custom_paths() before prestartup scripts, so this is already resolved.
INPUT = Path(folder_paths.get_input_directory())
def copy_files(src: Path, dst: Path, pattern: str = "*") -> int:
"""Copy bundled assets into a directory. Returns files written.
Seeds rather than syncs: an existing file is left alone, so a user's
edited demo asset survives every relaunch. Raises if `src` is missing --
a typo'd asset directory is a packaging bug, and silence is how it stays
one.
"""
src, dst = Path(src), Path(dst)
if not src.is_dir():
raise FileNotFoundError(f"asset directory not found: {src}")
written = 0
for f in src.glob(pattern):
if not f.is_file():
continue
target = dst / f.relative_to(src)
if target.exists():
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, target)
written += 1
return written
SCRIPT_DIR = Path(__file__).resolve().parent
COMFYUI_DIR = SCRIPT_DIR.parent.parent
# Copy assets
copy_files(SCRIPT_DIR / "assets", COMFYUI_DIR / "input")
copy_files(SCRIPT_DIR / "assets", INPUT)
+2 -1
View File
@@ -1,12 +1,13 @@
[project]
name = "comfyui-sam3"
description = "Warning, uses experimental package `comfy-env` to attempt a one click isolated install. Will download and use pixi package manager. SAM 3 (Segment Anything Model 3) integration for ComfyUI. Image and video segmentation with text prompts, boxes, and points"
version = "0.1.21"
version = "0.1.22"
license = {file = "LICENSE"}
[tool.comfy]
PublisherId = "pznodes"
DisplayName = "SAM3"
web = "javascript"
Icon = "https://raw.githubusercontent.com/PozzettiAndrea/ComfyUI-SAM3/main/assets/icon.png"
[project.urls]
+1 -2
View File
@@ -1,2 +1 @@
comfy-env==0.3.89
comfy-3d-viewers==0.2.44
comfy-env==0.4.3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long