Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4b4e4afd5 | ||
|
|
ffde58f24c |
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to **ComfyUI-BFSNodes** are documented in this file.
|
||||
|
||||
## [1.121.2] - 2026-11-13
|
||||
|
||||
### Added
|
||||
- **Segment Picker / Video Segment Picker**: with an object selected, the brush edits that object's mask instead of adding a new object on top: a stroke grows the selected mask it starts on (or the nearest selected one), shift trims it. The chip shows ✎ and ↺ drops the edits. The brush stays on after ✓ Apply painted area.
|
||||
- **Video Segment Picker**: `clean_masks` (default 0 = off) cleans the masks where subjects touch: on every frame, and on the picked frames before tracking, any piece smaller than that % of its subject's area joins what surrounds it (stray specks of one color on another subject switch to it, holes are filled, stray bits on the background go away). A subject's largest piece always stays; overlaps elsewhere are left as they were.
|
||||
|
||||
## [1.121.1] - 2026-11-13
|
||||
|
||||
### Fixed
|
||||
- **Video Segment Picker**: a region painted with the brush no longer disappears when you switch to another color and back. Strokes not applied yet are applied to their color before the color or frame changes or a run starts, and the tool and brush size stay selected.
|
||||
|
||||
### Changed
|
||||
- **Video Segment Picker**: objects added on a frame (brush, points, text) are shared by every color there; each color selects its own. Removing an added object removes it for every color.
|
||||
- **Segment Picker / Video Segment Picker**: hold **shift** while painting to do the opposite of the tool: the brush erases from the mark, the eraser paints back. Erasing and painting over the same spot keeps whatever was painted last.
|
||||
|
||||
## [1.121.0] - 2026-11-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -268,7 +268,8 @@ segmented again on load.
|
||||
|
||||
Hand edits (1.118.0): **🖌 Brush** paints extra area into the mark (e.g. hair SAM left
|
||||
out), **➖ Remove** cuts out what SAM segments at the points you place, and **🧽 Eraser**
|
||||
paints what to cut out. Each becomes a chip; unselect it to switch it off. Brush and
|
||||
paints what to cut out; **shift** while painting does the opposite of the tool (the brush
|
||||
erases, the eraser paints back). Each becomes a chip; unselect it to switch it off. Brush and
|
||||
eraser strokes belong to the image they were painted on and are dropped when the image
|
||||
changes. Fixed: clicking inside the node no longer shrinks its canvas while the side
|
||||
panel is open.
|
||||
@@ -290,6 +291,23 @@ propagates by itself (**auto**). The **Whole video** scrubber shows every frame
|
||||
its masks, the lost stretches in red; **+ add this frame** puts any frame in the strip
|
||||
to fix it. A status line with a progress bar and a log shows what runs.
|
||||
|
||||
Hand fixes on a picked frame (1.121.1): **🖌 Brush** paints into the active color's
|
||||
mark and **shift** while painting erases from it (e.g. a stray piece of mask over a
|
||||
hand). Strokes not applied yet are applied when you change color or frame, or press
|
||||
Apply fix, so they are never lost. What any color adds on a frame (brush, points, text)
|
||||
is shared by every color there: each color selects from the same objects, and ✕ on an
|
||||
added object removes it for all of them.
|
||||
|
||||
With an object selected, the brush edits that object's own mask (1.121.2): a stroke grows
|
||||
the selected mask it starts on (or the nearest selected one) and shift trims it, with no
|
||||
new object on top. The chip shows ✎; ↺ drops the edits.
|
||||
|
||||
`clean_masks` cleans the subject masks where people touch (arms around each other, hands
|
||||
crossing): on every frame, and on the picked frames before tracking, any piece smaller
|
||||
than that % of its subject's area joins what surrounds it. A speck of blue on the red
|
||||
arm becomes red, a hole in a mask is filled, a stray bit on the background goes away;
|
||||
each subject's main body always stays. 0 = off; 0.5–2 is usually enough.
|
||||
|
||||
Outputs: `masks`, `marked` (tinted), `overlay`, `report`, and `hybrid`: the guide
|
||||
with each subject drawn in its color, by `mark_style`:
|
||||
- `normal map` — its MoGe normal map in gray detail, tinted (connect `normals`:
|
||||
|
||||
+67
-10
@@ -68,10 +68,11 @@ def parse_state(text: str | None) -> dict:
|
||||
if any(p["label"] for p in pts):
|
||||
added.append({"id": str(e["id"]), "points": pts})
|
||||
elif e.get("strokes"):
|
||||
strokes = [{"r": float(st["r"]), "pts": [[float(x), float(y)] for x, y in st["pts"]]}
|
||||
strokes = [{"r": float(st["r"]), "pts": [[float(x), float(y)] for x, y in st["pts"]], **({"erase": True} if st.get("erase") else {})}
|
||||
for st in e["strokes"] if isinstance(st, dict) and st.get("pts")]
|
||||
if strokes:
|
||||
added.append({"id": str(e["id"]), "image": str(e.get("image") or ""), "strokes": strokes})
|
||||
added.append({"id": str(e["id"]), "image": str(e.get("image") or ""), "strokes": strokes,
|
||||
**({"target": str(e["target"])} if e.get("target") else {})})
|
||||
if e.get("remove") and len(added) > n:
|
||||
added[-1]["remove"] = True
|
||||
selected = [str(i) for i in s.get("selected") or [] if isinstance(i, (str, int))]
|
||||
@@ -84,7 +85,7 @@ def split_concepts(text: str) -> list[str]:
|
||||
|
||||
def entry_key(entry: dict) -> str:
|
||||
"""What an added entry asks SAM for: an entry whose text or points change is segmented again."""
|
||||
return json.dumps({k: entry[k] for k in ("text", "points", "strokes", "remove") if k in entry}, sort_keys=True)
|
||||
return json.dumps({k: entry[k] for k in ("text", "points", "strokes", "remove", "target") if k in entry}, sort_keys=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------------------ geometry
|
||||
@@ -117,17 +118,19 @@ def compose(masks: list[torch.Tensor], h: int, w: int) -> tuple[torch.Tensor, to
|
||||
return (stack.amax(0, keepdim=True)), stack
|
||||
|
||||
|
||||
def paint(strokes: list[dict], h: int, w: int) -> torch.Tensor:
|
||||
"""Brush strokes (relative points, radius relative to the width) as a bool mask [h, w]."""
|
||||
img = Image.new("L", (w, h), 0)
|
||||
def paint(strokes: list[dict], h: int, w: int, base: torch.Tensor | None = None) -> torch.Tensor:
|
||||
"""Brush strokes (relative points, radius relative to the width) as a bool mask [h, w]; an "erase" stroke clears what
|
||||
the strokes before it painted (shift while painting in the panel). `base`: a mask to paint on (grow / trim it)."""
|
||||
img = Image.new("L", (w, h), 0) if base is None else Image.fromarray(base.cpu().numpy().astype(np.uint8) * 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for st in strokes:
|
||||
r = max(1., st["r"] * w)
|
||||
pts = [(x * w, y * h) for x, y in st["pts"]]
|
||||
fill = 0 if st.get("erase") else 255
|
||||
if len(pts) > 1:
|
||||
draw.line(pts, fill=255, width=round(2 * r), joint="curve")
|
||||
draw.line(pts, fill=fill, width=round(2 * r), joint="curve")
|
||||
for x, y in pts:
|
||||
draw.ellipse([x - r, y - r, x + r, y + r], fill=255)
|
||||
draw.ellipse([x - r, y - r, x + r, y + r], fill=fill)
|
||||
return torch.from_numpy(np.array(img) > 127)
|
||||
|
||||
|
||||
@@ -278,11 +281,49 @@ def ensure_entry(sess: dict, entry: dict) -> list[dict]:
|
||||
|
||||
def all_objects(sess: dict, state: dict) -> list[dict]:
|
||||
objs = list(sess["auto"])
|
||||
edits = []
|
||||
for e in state["added"]:
|
||||
if e.get("strokes") and e["image"] and e["image"] != sess["image_key"]:
|
||||
continue # painted on another image
|
||||
if e.get("target"):
|
||||
edits.append(e) # brush strokes on an existing object: applied after every object exists
|
||||
continue
|
||||
objs += ensure_entry(sess, e)
|
||||
return objs
|
||||
return apply_edits(sess, objs, edits)
|
||||
|
||||
|
||||
def apply_edits(sess: dict, objs: list[dict], edits: list[dict]) -> list[dict]:
|
||||
"""Brush entries with a "target" grow (or, erase strokes, trim) that object's own mask, in order, instead of adding an
|
||||
object of their own: the object keeps its id, so whatever selected it gets the edited mask."""
|
||||
if not edits:
|
||||
return objs
|
||||
cache = sess.setdefault("edited", {})
|
||||
out = []
|
||||
for o in objs:
|
||||
mine = [e for e in edits if e["target"] == o["id"]]
|
||||
if not mine:
|
||||
out.append(o)
|
||||
continue
|
||||
key = (o["id"], id(o["mask"]), json.dumps([e["strokes"] for e in mine]))
|
||||
if key not in cache:
|
||||
m = o["mask"]
|
||||
for e in mine:
|
||||
m = paint(e["strokes"], *m.shape, base=m)
|
||||
cache[key] = {**{k: v for k, v in o.items() if k != "_file"}, "mask": m, "edits": len(mine)}
|
||||
out.append(cache[key])
|
||||
return out
|
||||
|
||||
|
||||
def base_object(sess: dict, oid: str) -> dict | None:
|
||||
"""An object of the session as segmented, before any brush edit: automatic or from an added entry."""
|
||||
for o in sess["auto"]:
|
||||
if o["id"] == oid:
|
||||
return o
|
||||
for have in sess["entries"].values():
|
||||
for o in have["objects"]:
|
||||
if o["id"] == oid:
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------------------- panel files
|
||||
@@ -314,7 +355,7 @@ def panel_object(o: dict, size: tuple[int, int], h: int, w: int) -> dict:
|
||||
if "_file" not in o:
|
||||
o["_file"] = _save(rgba, f"m_{o['id']}")
|
||||
return {"id": o["id"], "label": o["label"], "source": o["source"], "entry": o.get("entry"), "score": o["score"],
|
||||
"remove": bool(o.get("remove")), "area": round(float(o["mask"].float().mean()), 5), "box": [round(x / w, 4), round(y / h, 4), round(bw / w, 4), round(bh / h, 4)],
|
||||
"remove": bool(o.get("remove")), "edits": int(o.get("edits") or 0), "area": round(float(o["mask"].float().mean()), 5), "box": [round(x / w, 4), round(y / h, 4), round(bw / w, 4), round(bh / h, 4)],
|
||||
"mask": o["_file"]}
|
||||
|
||||
|
||||
@@ -431,6 +472,22 @@ def _routes():
|
||||
size = _preview_size(h, w)
|
||||
return web.json_response({"objects": [panel_object(o, size, h, w) for o in objs]})
|
||||
|
||||
@PromptServer.instance.routes.post("/bfs/segpick/edit")
|
||||
async def edit(request):
|
||||
"""One object with every brush edit made on it ({session, target, edits: [brush entries]}; none = as segmented)."""
|
||||
body = await request.json()
|
||||
sess = _SESSIONS.get(str(body.get("session") or ""))
|
||||
if sess is None:
|
||||
return web.json_response({"error": "This image is no longer in memory: run the node once, then paint again."}, status=404)
|
||||
target = str(body.get("target") or "")
|
||||
o = base_object(sess, target)
|
||||
if o is None:
|
||||
return web.json_response({"error": f"Object {target} is not in this image any more: run the node once."}, status=404)
|
||||
edits = [e for e in parse_state(json.dumps({"added": body.get("edits") or []}))["added"]
|
||||
if e.get("target") == target and (not e["image"] or e["image"] == sess["image_key"])]
|
||||
h, w = sess["frame"].shape[1:3]
|
||||
return web.json_response({"objects": [panel_object(o2, _preview_size(h, w), h, w) for o2 in apply_edits(sess, [o], edits)]})
|
||||
|
||||
|
||||
_routes()
|
||||
|
||||
|
||||
@@ -689,6 +689,74 @@ def parse_frames(text):
|
||||
return out
|
||||
|
||||
|
||||
def clean_subject_masks(masks, max_frac):
|
||||
"""{subject: bool [n, h, w]} -> the same, cleaned frame by frame. Every piece of the label map (a subject's
|
||||
disconnected bit, or background enclosed by subjects) smaller than max_frac of its subject's area joins the label
|
||||
that surrounds it most: a speck of subject 2 on subject 1's arm becomes subject 1, a hole in a mask is filled. Each
|
||||
subject's largest piece always stays, and background touching the frame border is never filled."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
keys = list(masks)
|
||||
if not keys:
|
||||
return masks
|
||||
n, h, w = next(iter(masks.values())).shape
|
||||
out = {k: masks[k].clone().cpu() for k in keys}
|
||||
ring = np.ones((3, 3), np.uint8)
|
||||
for i in range(n):
|
||||
lab = np.zeros((h, w), np.int32)
|
||||
for k, key in enumerate(keys):
|
||||
lab[masks[key][i].cpu().numpy()] = k + 1
|
||||
orig = lab.copy()
|
||||
area = {k + 1: int((lab == k + 1).sum()) for k in range(len(keys))}
|
||||
if not any(area.values()):
|
||||
continue
|
||||
changed = False
|
||||
# subjects' stray pieces first, then the holes they leave or had
|
||||
for L in [k + 1 for k in range(len(keys))] + [0]:
|
||||
cnt, comp, stats, _ = cv2.connectedComponentsWithStats((lab == L).astype(np.uint8), connectivity=8 if L else 4)
|
||||
if cnt <= 1:
|
||||
continue
|
||||
biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) if L else -1
|
||||
for c in range(1, cnt):
|
||||
x, y, bw, bh, a = (int(v) for v in stats[c])
|
||||
if c == biggest or (L == 0 and (x == 0 or y == 0 or x + bw >= w or y + bh >= h)):
|
||||
continue
|
||||
x0, y0, x1, y1 = max(0, x - 1), max(0, y - 1), min(w, x + bw + 1), min(h, y + bh + 1)
|
||||
piece = comp[y0:y1, x0:x1] == c
|
||||
around = lab[y0:y1, x0:x1][cv2.dilate(piece.astype(np.uint8), ring).astype(bool) & ~piece]
|
||||
if not around.size:
|
||||
continue
|
||||
vals, counts = np.unique(around, return_counts=True)
|
||||
into = int(vals[np.argmax(counts)])
|
||||
if into == L:
|
||||
continue
|
||||
ref = area.get(L) if L else area.get(into, 0)
|
||||
if not ref or a > max_frac * ref:
|
||||
continue
|
||||
lab[y0:y1, x0:x1][piece] = into
|
||||
changed = True
|
||||
if changed: # only the moved pixels change: where two subjects overlapped elsewhere, both keep it
|
||||
moved = torch.from_numpy(lab != orig)
|
||||
for k, key in enumerate(keys):
|
||||
out[key][i] = (out[key][i] & ~moved) | (torch.from_numpy(lab == k + 1) & moved)
|
||||
return {k: out[k].to(masks[k].device) for k in keys}
|
||||
|
||||
|
||||
def shared_state(frame_state, key):
|
||||
"""One subject's selection on a frame, with the objects every subject added there: a region painted or found while
|
||||
another color was active can be selected by this one too. Its own entries win an id clash (older states)."""
|
||||
own = dict(frame_state.get(key) or {})
|
||||
added = list(own.get("added") or [])
|
||||
ids = {str(e.get("id")) for e in added if isinstance(e, dict)}
|
||||
for k, v in frame_state.items():
|
||||
for e in (v.get("added") or []) if k != key and isinstance(v, dict) else []:
|
||||
if isinstance(e, dict) and str(e.get("id")) not in ids:
|
||||
ids.add(str(e.get("id")))
|
||||
added.append(e)
|
||||
own["added"] = added
|
||||
return own
|
||||
|
||||
|
||||
def find_connected_picture_count(prompt, unique_id):
|
||||
"""Inspects the prompt workflow graph to find downstream nodes consuming this node's output,
|
||||
and returns the maximum index of connected picture inputs (e.g. 2 if only picture_1 and picture_2 are linked)."""
|
||||
@@ -787,6 +855,12 @@ class BFSVideoSegmentPicker:
|
||||
"tooltip": "Hatching line and contour thickness multiplier. 1 matches the training format."}),
|
||||
"hatch_spacing": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.05,
|
||||
"tooltip": "Hatching line spacing multiplier. 1 matches the training format."}),
|
||||
"clean_masks": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1,
|
||||
"tooltip": "Clean the subject masks on every frame (the picked frames before tracking too): any piece "
|
||||
"smaller than this, in % of the subject's area, joins what surrounds it. Stray specks of one "
|
||||
"color on another subject (e.g. where hands and arms cross) go to that subject, holes inside "
|
||||
"a mask are filled, and a subject's main body is never removed. 0 = off; 0.5-2 is usually "
|
||||
"enough. Too high can swallow a small real part, like a fingertip of the other person."}),
|
||||
}
|
||||
return {"required": req, "optional": opt, "hidden": {**t.get("hidden", {}), "prompt": "PROMPT"}}
|
||||
|
||||
@@ -805,14 +879,14 @@ class BFSVideoSegmentPicker:
|
||||
hybrid_grow=0.08, free_after=FREE_MODES[0], pose_region="person outline",
|
||||
pose_gray_opacity=1.0, max_subjects="auto", molmo_descriptions="", propagate_scope="fill only empty masks",
|
||||
propagation_method="Molmo2 (classic)", unique_id=None, prompt=None, protect_mask=None, edge_feather=0.01,
|
||||
hatch_width=1.0, hatch_spacing=1.0, **kw):
|
||||
hatch_width=1.0, hatch_spacing=1.0, clean_masks=0.0, **kw):
|
||||
try:
|
||||
return self._pick(image, sam3_model, sam3_clip, concepts, threshold, max_per_concept, min_area, mark_color, mark_opacity,
|
||||
selection, track, strip_frames, normals, normals_convention, hybrid_base, subject, mark_style,
|
||||
molmo_clip, pose_keypoint, hybrid_grow, unique_id, pose_region, pose_gray_opacity,
|
||||
max_subjects, molmo_descriptions, propagate_scope, propagation_method, prompt,
|
||||
protect_mask=protect_mask, edge_feather=edge_feather, point_model=point_model,
|
||||
hatch_width=hatch_width, hatch_spacing=hatch_spacing)
|
||||
hatch_width=hatch_width, hatch_spacing=hatch_spacing, clean_masks=clean_masks)
|
||||
finally:
|
||||
self._free(sam3_model, free_after, point_model or molmo_clip)
|
||||
|
||||
@@ -885,7 +959,7 @@ class BFSVideoSegmentPicker:
|
||||
pose_keypoint, hybrid_grow, unique_id, pose_region="person outline", pose_gray_opacity=1.0,
|
||||
max_subjects="auto", molmo_descriptions="", propagate_scope="fill only empty masks",
|
||||
propagation_method="Molmo2 (classic)", prompt=None, protect_mask=None, edge_feather=0.01,
|
||||
point_model=None, hatch_width=1.0, hatch_spacing=1.0):
|
||||
point_model=None, hatch_width=1.0, hatch_spacing=1.0, clean_masks=0.0):
|
||||
st = Status(unique_id)
|
||||
n = int(image.shape[0])
|
||||
st(f"start: {n} frames", 0.0, log=True)
|
||||
@@ -936,7 +1010,7 @@ class BFSVideoSegmentPicker:
|
||||
sid, sess = sp.session_for((sam3_model, sam3_clip), frame, spec)
|
||||
chosen_all = set()
|
||||
for sj in subjects:
|
||||
state = sp.parse_state(json.dumps(states.get(str(i), {}).get(str(sj), {})))
|
||||
state = sp.parse_state(json.dumps(shared_state(states.get(str(i), {}), str(sj))))
|
||||
objects = sp.all_objects(sess, state)
|
||||
by_id = {o["id"]: o for o in objects}
|
||||
chosen = [by_id[k] for k in state["selected"] if k in by_id]
|
||||
@@ -945,7 +1019,7 @@ class BFSVideoSegmentPicker:
|
||||
cut = torch.stack(cut).any(0) if cut else torch.zeros(h, w, dtype=torch.bool)
|
||||
mask, _ = sp.compose([o["mask"] & ~cut for o in chosen if not o.get("remove")], h, w)
|
||||
own[sj][i] = mask.reshape(h, w).float()
|
||||
lock_state = sp.parse_state(json.dumps(states.get(str(i), {}).get("lock", {})))
|
||||
lock_state = sp.parse_state(json.dumps(shared_state(states.get(str(i), {}), "lock")))
|
||||
lock_objects = sp.all_objects(sess, lock_state)
|
||||
lock_by_id = {o["id"]: o for o in lock_objects}
|
||||
lock_chosen = [lock_by_id[k] for k in lock_state["selected"] if k in lock_by_id]
|
||||
@@ -954,12 +1028,16 @@ class BFSVideoSegmentPicker:
|
||||
lock_cut = torch.stack(lock_cut).any(0) if lock_cut else torch.zeros(h, w, dtype=torch.bool)
|
||||
lock_mask, _ = sp.compose([o["mask"] & ~lock_cut for o in lock_chosen if not o.get("remove")], h, w)
|
||||
lock_mask = lock_mask.reshape(h, w) > 0.5
|
||||
if clean_masks > 0: # tracking starts from the cleaned picks
|
||||
cleaned = clean_subject_masks({sj: (own[sj][i] > 0.5)[None] for sj in subjects}, clean_masks / 100.0)
|
||||
for sj in subjects:
|
||||
own[sj][i] = cleaned[sj][0].float()
|
||||
selected_people = torch.stack([own[sj][i] > 0.5 for sj in subjects]).any(0)
|
||||
if bool(lock_mask.any()) and mask_overlap(lock_mask, selected_people)[0] >= 0.7:
|
||||
st(f"frame {i}: lock overlaps the selected subject; pick the object by text or add negative points", None, log=True)
|
||||
lock_mask = torch.zeros_like(lock_mask)
|
||||
own_locked[i] = lock_mask
|
||||
objects = sp.all_objects(sess, sp.parse_state(json.dumps({"added": [a for st in states.get(str(i), {}).values() for a in st.get("added", [])]})))
|
||||
objects = sp.all_objects(sess, sp.parse_state(json.dumps({"added": shared_state(states.get(str(i), {}), "1")["added"]})))
|
||||
panels.append(sp.panel_data(sid, sess, objects))
|
||||
overlays.append(sp.overlay(frame, objects, chosen_all))
|
||||
cuts = detect_cuts(image) if n > 1 else []
|
||||
@@ -997,7 +1075,7 @@ class BFSVideoSegmentPicker:
|
||||
ckey = ("v4", vkey, sj, json.dumps(mine, sort_keys=True), tuple(cuts), bool(track),
|
||||
(point_model if propagation_method == "MolmoPoint-8B" else molmo_clip) is not None,
|
||||
float(threshold),
|
||||
propagate_scope, propagation_method, active_frame)
|
||||
propagate_scope, propagation_method, active_frame, float(clean_masks))
|
||||
if (not segment_only and propagate_scope not in ("all frames", "fill only empty masks") and previous is not None
|
||||
and len(previous) > 2 and previous[2] == mine):
|
||||
per_subject[sj], report_s[sj] = previous[0].clone(), [dict(r) for r in previous[1]]
|
||||
@@ -1261,6 +1339,9 @@ class BFSVideoSegmentPicker:
|
||||
rep[:fix_frame] = [dict(r) for r in previous[1][:fix_frame]]
|
||||
per_subject[sj] = m > 0.5; report_s[sj] = rep
|
||||
_cache_track_result(ckey, vkey, sj, m, rep, mine, keep_last=not segment_only)
|
||||
if clean_masks > 0:
|
||||
per_subject.update(clean_subject_masks({sj: per_subject[sj] > 0.5 for sj in subjects}, clean_masks / 100.0))
|
||||
st(f"cleaned the masks: pieces under {clean_masks:g}% of a subject joined their surroundings", None, log=True)
|
||||
masks = torch.stack(list(per_subject.values())).amax(0).float()
|
||||
anchors = sorted({i for sj in subjects for i in strip if bool(own[sj][i].any())})
|
||||
report = [{"frame": i, "subjects": {str(sj): report_s[sj][i]["source"] for sj in subjects}} for i in range(n)]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "bfsnodes"
|
||||
description = "ComfyUI nodes for video workflows: SAM 3 segmentation and Molmo2 tracking; MiniMax H3 character swapping and sampling; shot planning and looping; LTX video controls and identity transfer; model loaders, LoRA tools, and image/video utilities."
|
||||
version = "1.121.0"
|
||||
version = "1.121.2"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* see it, click to select it. Objects the automatic pass missed are added by text (a SAM 3 prompt), by points
|
||||
* (keep / exclude), segmented at once by /bfs/segpick/add, or painted with the brush (to grow the mark by hand).
|
||||
* ➖ Remove (points) and 🧽 Eraser (brush) make the same kinds of objects, cut out of the mark instead of added to it.
|
||||
* Shift while painting does the opposite of the mode: with the brush it erases the mark, with the eraser it paints back.
|
||||
* With an object selected, the brush edits that object's own mask (grows it, shift trims it) instead of adding a new one:
|
||||
* the selected object the stroke starts on, otherwise the nearest selected one. ↺ on its chip drops the edits.
|
||||
*
|
||||
* All state lives in the hidden `selection` STRING widget (JSON: selected ids and the added entries), so a workflow
|
||||
* saves, reloads and shares the selection. The objects themselves come from the node's last run (onExecuted).
|
||||
@@ -109,11 +112,11 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
const loaded = new Map(); // object id -> loadMask result (not reactive: canvases and big arrays)
|
||||
const ready = ref(0); // bumps when masks finish loading, to redraw
|
||||
const hover = ref(null);
|
||||
const mode = ref("select"); // "select" | "points" | "brush" | "remove" (points) | "eraser" (brush)
|
||||
const mode = ref(options.mode || "select"); // "select" | "points" | "brush" | "remove" (points) | "eraser" (brush)
|
||||
const points = ref([]); // the point entry being built
|
||||
const strokes = ref([]); // the brush entry being built: [{r, pts: [[x, y], ...]}], relative to the image
|
||||
const brush = ref(.02); // brush radius, relative to the image width
|
||||
let painting = false, cursor = null;
|
||||
const brush = ref(options.brush || .02); // brush radius, relative to the image width
|
||||
let painting = false, cursor = null, shiftHeld = false;
|
||||
const text = ref("");
|
||||
const busy = ref(""); const error = ref("");
|
||||
const stageEl = ref(null); const canvasEl = ref(null);
|
||||
@@ -133,6 +136,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
function setMode(m) {
|
||||
if (m !== mode.value) { points.value = []; strokes.value = []; } // unfinished points / strokes mean something else in another mode
|
||||
mode.value = m; hover.value = null;
|
||||
options.onMode?.(m);
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -174,8 +178,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
}
|
||||
g.globalAlpha = 1;
|
||||
if (!brushing()) return;
|
||||
g.strokeStyle = g.fillStyle = cutting() ? "rgba(20,20,20,.6)" : "rgba(255,0,255,.55)"; g.lineCap = g.lineJoin = "round";
|
||||
g.lineCap = g.lineJoin = "round";
|
||||
for (const st of strokes.value) {
|
||||
g.strokeStyle = g.fillStyle = cutting() !== !!st.erase ? "rgba(20,20,20,.6)" : "rgba(255,0,255,.55)";
|
||||
const [x0, y0] = st.pts[0];
|
||||
g.beginPath(); g.arc(x0 * W, y0 * H, st.r * W, 0, 2 * Math.PI); g.fill();
|
||||
if (st.pts.length < 2) continue;
|
||||
@@ -184,7 +189,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
g.stroke();
|
||||
}
|
||||
if (cursor) {
|
||||
g.lineWidth = Math.max(1, W / 500); g.strokeStyle = "#fff";
|
||||
g.lineWidth = Math.max(1, W / 500); g.strokeStyle = cutting() !== shiftHeld ? "#f43f5e" : "#fff";
|
||||
g.beginPath(); g.arc(cursor.x * W, cursor.y * H, brush.value * W, 0, 2 * Math.PI); g.stroke();
|
||||
}
|
||||
}
|
||||
@@ -220,7 +225,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
if (brushing()) {
|
||||
canvasEl.value.setPointerCapture(e.pointerId);
|
||||
painting = true;
|
||||
strokes.value = [...strokes.value, { r: +brush.value.toFixed(4), pts: [[+p.x.toFixed(4), +p.y.toFixed(4)]] }];
|
||||
const st = { r: +brush.value.toFixed(4), pts: [[+p.x.toFixed(4), +p.y.toFixed(4)]] };
|
||||
if (e.shiftKey) st.erase = true; // shift: the opposite of the mode (erase with the brush, paint back with the eraser)
|
||||
strokes.value = [...strokes.value, st];
|
||||
return;
|
||||
}
|
||||
const o = objectAt(p);
|
||||
@@ -230,7 +237,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
}
|
||||
function onMove(e) {
|
||||
if (data.value && brushing()) {
|
||||
cursor = at(e);
|
||||
cursor = at(e); shiftHeld = e.shiftKey;
|
||||
const pts = strokes.value.at(-1)?.pts, last = pts?.at(-1);
|
||||
// a point every ~quarter radius keeps the stroke smooth without bloating the saved JSON
|
||||
if (painting && Math.hypot(cursor.x - last[0], cursor.y - last[1]) > brush.value / 4) pts.push([+cursor.x.toFixed(4), +cursor.y.toFixed(4)]);
|
||||
@@ -241,20 +248,44 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
hover.value = objectAt(at(e))?.id ?? null;
|
||||
}
|
||||
|
||||
// every brush edit on one object, in order: the video picker passes the ones every color made on the frame
|
||||
const editsOf = target => (options.allAdded?.() || state.added).filter(a => a.target === target);
|
||||
async function editObject(target, edits) {
|
||||
const r = await api.fetchApi("/bfs/segpick/edit", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: data.value.session, target, edits }) });
|
||||
const j = await r.json();
|
||||
if (!r.ok || j.error) throw new Error(j.error || r.statusText);
|
||||
const o = j.objects[0];
|
||||
data.value = { ...data.value, objects: data.value.objects.map(x => (x.id === o.id ? o : x)) };
|
||||
options.onObjects?.(data.value.objects);
|
||||
await loadAll();
|
||||
}
|
||||
async function add(entry) {
|
||||
if (!data.value) { error.value = "Run the node first (▶ Segment)."; return; }
|
||||
if (entry.target) {
|
||||
busy.value = "Painting on the mask…"; error.value = "";
|
||||
try {
|
||||
await editObject(entry.target, [...editsOf(entry.target).filter(a => a.id !== entry.id), entry]);
|
||||
state.added = [...state.added.filter(a => a.id !== entry.id), entry];
|
||||
state.next = Math.max(state.next, Number(entry.id.match(/(\d+)$/)?.[1] || 0) + 1);
|
||||
if (!isSel(entry.target)) state.selected = [...state.selected, entry.target];
|
||||
save();
|
||||
} catch (err) { error.value = String(err.message || err); } finally { busy.value = ""; }
|
||||
return;
|
||||
}
|
||||
busy.value = entry.text ? `Finding “${entry.text}”…` : entry.strokes ? "Adding the painted area…" : "Segmenting the points…"; error.value = "";
|
||||
try {
|
||||
const r = await api.fetchApi("/bfs/segpick/add", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: data.value.session, entry }) });
|
||||
const j = await r.json();
|
||||
if (!r.ok || j.error) throw new Error(j.error || r.statusText);
|
||||
if (!j.objects.length) { error.value = entry.text ? `Nothing found for “${entry.text}”: try other words or a lower threshold.` : "The points gave an empty mask."; return; }
|
||||
if (!j.objects.length) { error.value = entry.text ? `Nothing found for “${entry.text}”: try other words or a lower threshold.` : entry.strokes ? "The painted area is empty." : "The points gave an empty mask."; return; }
|
||||
state.added = [...state.added.filter(a => a.id !== entry.id), entry];
|
||||
state.next = Math.max(state.next, Number(entry.id.match(/(\d+)$/)?.[1] || 0) + 1);
|
||||
data.value = { ...data.value, objects: [...data.value.objects.filter(o => o.entry !== entry.id), ...j.objects] };
|
||||
state.selected = [...state.selected, ...j.objects.map(o => o.id).filter(id => !isSel(id))]; // what you add is what you want
|
||||
save();
|
||||
options.onObjects?.(data.value.objects);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
error.value = String(err.message || err);
|
||||
@@ -266,12 +297,46 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
await add(cutting() ? { id: entryId("r"), remove: true, points: points.value } : { id: entryId("p"), points: points.value });
|
||||
if (!error.value) setMode("select");
|
||||
};
|
||||
const addBrush = async () => {
|
||||
if (!strokes.value.length) return;
|
||||
const stroke = { image: data.value.image_key, strokes: strokes.value };
|
||||
await add(cutting() ? { id: entryId("e"), remove: true, ...stroke } : { id: entryId("b"), ...stroke });
|
||||
if (!error.value) setMode("select");
|
||||
// The strokes become up to two entries, in painting order: what they add (cut strokes painted as holes in it) and what
|
||||
// they cut (painting strokes after a cut painted back), so erasing and painting over the same spot ends as painted last.
|
||||
// The object a brush stroke edits: the selected one it starts on (the smallest), otherwise the nearest selected one
|
||||
const targetFor = st => {
|
||||
const [x, y] = st.pts[0];
|
||||
const sel = objects.value.filter(o => isSel(o.id) && !o.remove && loaded.get(o.id));
|
||||
if (!sel.length) return null;
|
||||
const under = sel.filter(o => { const m = loaded.get(o.id); return m.hit[Math.min(m.h - 1, Math.floor(y * m.h)) * m.w + Math.min(m.w - 1, Math.floor(x * m.w))]; });
|
||||
if (under.length) return under.sort((a, b) => a.area - b.area)[0].id;
|
||||
const dist = o => { const [bx, by, bw, bh] = o.box || [0, 0, 1, 1]; return Math.hypot(Math.max(bx - x, 0, x - bx - bw), Math.max(by - y, 0, y - by - bh)); };
|
||||
return sel.sort((a, b) => dist(a) - dist(b))[0].id;
|
||||
};
|
||||
const commitStrokes = async () => {
|
||||
const all = strokes.value;
|
||||
if (!all.length || !data.value) return;
|
||||
const target = mode.value === "brush" ? targetFor(all[0]) : null;
|
||||
if (target) { // one edit of that object's mask, the strokes in painting order
|
||||
strokes.value = [];
|
||||
await add({ id: entryId("g"), image: data.value.image_key, target, strokes: all.map(st => (st.erase ? { r: st.r, pts: st.pts, erase: true } : { r: st.r, pts: st.pts })) });
|
||||
return;
|
||||
}
|
||||
const cutOf = st => cutting() !== !!st.erase;
|
||||
const part = cutPart => {
|
||||
const k = all.findIndex(st => cutOf(st) === cutPart);
|
||||
return k < 0 ? [] : all.slice(k).map(st => (cutOf(st) === cutPart ? { r: st.r, pts: st.pts } : { r: st.r, pts: st.pts, erase: true }));
|
||||
};
|
||||
const keep = part(false), cut = part(true), image = data.value.image_key;
|
||||
strokes.value = [];
|
||||
if (keep.length) await add({ id: entryId("b"), image, strokes: keep });
|
||||
if (cut.length) await add({ id: entryId("e"), remove: true, image, strokes: cut });
|
||||
};
|
||||
const addBrush = () => commitStrokes(); // the brush stays on: painting usually goes on
|
||||
async function resetEdits(o) { // ↺: the object as segmented, without the brush edits
|
||||
const ids = editsOf(o.id).map(a => a.id);
|
||||
state.added = state.added.filter(a => !ids.includes(a.id));
|
||||
save();
|
||||
ids.forEach(id => options.onRemoveEntry?.(id, { keepSelection: true }));
|
||||
busy.value = "Restoring the mask…"; error.value = "";
|
||||
try { await editObject(o.id, []); } catch (err) { error.value = String(err.message || err); } finally { busy.value = ""; }
|
||||
}
|
||||
function removeEntry(entryId) {
|
||||
state.added = state.added.filter(a => a.id !== entryId);
|
||||
const gone = objects.value.filter(o => o.entry === entryId).map(o => o.id);
|
||||
@@ -279,6 +344,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
if (data.value) data.value = { ...data.value, objects: data.value.objects.filter(o => o.entry !== entryId) };
|
||||
gone.forEach(i => loaded.delete(i));
|
||||
save(); ready.value++;
|
||||
options.onRemoveEntry?.(entryId);
|
||||
if (data.value) options.onObjects?.(data.value.objects);
|
||||
}
|
||||
|
||||
async function runNode() {
|
||||
@@ -307,6 +374,7 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
nextTick(() => { relayout(); loadAll(); });
|
||||
},
|
||||
reload: () => { Object.assign(state, parseState(io.get())); ready.value++; },
|
||||
commit: commitStrokes, // paint strokes not added yet become entries (the video picker calls it before it remounts)
|
||||
});
|
||||
|
||||
let ro;
|
||||
@@ -352,9 +420,9 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
] : null,
|
||||
brushing() ? [
|
||||
h("input", { type: "range", min: .003, max: .1, step: .001, value: brush.value, title: "Brush size",
|
||||
onInput: e => { brush.value = +e.target.value; } }),
|
||||
onInput: e => { brush.value = +e.target.value; options.onBrush?.(brush.value); } }),
|
||||
h("button", { class: "pri", disabled: !strokes.value.length || !!busy.value, onClick: addBrush },
|
||||
`${cutting() ? "✓ Erase painted area" : "✓ Add painted area"} (${strokes.value.length})`),
|
||||
`${cutting() ? "✓ Erase painted area" : "✓ Apply painted area"} (${strokes.value.length})`),
|
||||
h("button", { disabled: !strokes.value.length, onClick: () => { strokes.value = strokes.value.slice(0, -1); } }, "Undo"),
|
||||
h("button", { disabled: !strokes.value.length, onClick: () => { strokes.value = []; } }, "Clear"),
|
||||
] : null,
|
||||
@@ -370,8 +438,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
hovered ? h("div", { class: "tip" }, `${hovered.id} · ${hovered.label}${hovered.score != null ? ` · ${hovered.score}` : ""}`) : null,
|
||||
mode.value === "points" ? h("div", { class: "tip" }, "click = keep · right-click or shift+click = exclude · click a point to remove it") : null,
|
||||
mode.value === "remove" ? h("div", { class: "tip" }, "click on what to cut out of the mark · right-click or shift+click = keep it in · then ✓ Remove this") : null,
|
||||
mode.value === "brush" ? h("div", { class: "tip" }, "paint what the mark should also cover · then ✓ Add painted area") : null,
|
||||
mode.value === "eraser" ? h("div", { class: "tip" }, "paint what to cut out of the mark · then ✓ Erase painted area") : null,
|
||||
mode.value === "brush" ? h("div", { class: "tip" }, "paint to grow the selected mask the stroke starts on (or the nearest) · shift = erase from it · then ✓ Apply painted area") : null,
|
||||
mode.value === "eraser" ? h("div", { class: "tip" }, "paint what to cut out of the mark · shift = paint back · then ✓ Erase painted area") : null,
|
||||
] : h("div", { class: "empty" }, ["Connect an image and press ▶ Segment (or run the workflow).", h("br"),
|
||||
"Then click objects to select them; add what was missed by text or 📍 points."])),
|
||||
busy.value ? h("div", { class: "hint" }, busy.value) : null,
|
||||
@@ -381,7 +449,8 @@ export function Panel(io, options = {}) { // exported: BFS Video Segment Picke
|
||||
onClick: () => toggle(o.id), onPointerenter: () => { hover.value = o.id; }, onPointerleave: () => { hover.value = null; },
|
||||
}, [
|
||||
h("span", { class: "sw", style: `background:${rgb(colorOf(o.id))}` }),
|
||||
`${o.remove ? "➖ " : ""}${o.id} ${o.label}`,
|
||||
`${o.remove ? "➖ " : ""}${o.id} ${o.label}${o.edits ? " ✎" : ""}`,
|
||||
o.edits ? h("span", { class: "x", title: "undo the brush edits on this object", onClick: e => { e.stopPropagation(); resetEdits(o); } }, "↺") : null,
|
||||
o.entry ? h("span", { class: "x", title: "remove this added object", onClick: e => { e.stopPropagation(); removeEntry(o.entry); } }, "×") : null,
|
||||
]))) : null,
|
||||
d ? h("div", { class: "hint" }, "The outputs follow the selection on the next run.") : null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* BFS Video Segment Picker: a strip with every frame of the batch on top; click a frame to show it in the image
|
||||
* Segment Picker's canvas (the same Panel, one selection per frame). The strip marks each frame: ✓ picked by hand,
|
||||
* ↔ tracked from a picked frame, ⚠ lost or nothing. State: the hidden `selection` widget, {"frames": {"i": selection}}.
|
||||
* What a color adds on a frame (brush, points, text) is shared by every color there: each one selects from the same objects.
|
||||
*/
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
@@ -157,8 +158,11 @@ app.registerExtension({
|
||||
|
||||
// the image picker's Panel for the active frame, remounted when the frame changes
|
||||
let panelApp = null, handle = {};
|
||||
const ui = { mode: "select", brush: .02 }; // the tool and brush size survive a color or frame change
|
||||
const panelHost = document.createElement("div");
|
||||
panelHost.className = "host";
|
||||
// paint strokes not applied yet are applied (to the color they were painted with) before the panel changes
|
||||
const switchPanel = async change => { try { await handle.commit?.(); } catch { /* shown in the panel */ } change(); mountPanel(); };
|
||||
const mountPanel = () => {
|
||||
panelApp?.unmount(); panelHost.innerHTML = ""; handle = {};
|
||||
const i = run.data?.indices?.[active.value] ?? active.value; // the real frame index of the strip entry
|
||||
@@ -176,8 +180,23 @@ app.registerExtension({
|
||||
if (JSON.stringify(frames.v[String(i)]?.[sj]?.selected || []) !== before) autoPropagate();
|
||||
},
|
||||
expose: o => Object.assign(handle, o),
|
||||
}, { showEraser: false, showRemove: false, autoPointOnClick: sj === "lock", entryPrefix: sj === "lock" ? "lock_" : "",
|
||||
maskColor: sj === "lock" ? [251, 191, 36] : null }) });
|
||||
}, { showEraser: false, showRemove: false, autoPointOnClick: sj === "lock",
|
||||
// ids stay unique on the frame, since every color sees the objects the others added
|
||||
entryPrefix: sj === "lock" ? "lock_" : sj === "1" ? "" : `s${sj}_`,
|
||||
maskColor: sj === "lock" ? [251, 191, 36] : null,
|
||||
mode: sj === "lock" ? "select" : ui.mode, brush: ui.brush,
|
||||
onMode: m => { if (sj !== "lock") ui.mode = m; }, onBrush: b => { ui.brush = b; },
|
||||
// added objects live in the run's frame data too, so the next mount (another color, back here) still has them
|
||||
onObjects: objs => { const fd = run.data?.frames?.[active.value]; if (fd) run.data.frames[active.value] = { ...fd, objects: objs }; },
|
||||
allAdded: () => Object.values(frames.v[String(i)] || {}).flatMap(v => (v && v.added) || []),
|
||||
onRemoveEntry: (id, how) => { // an added object goes for every color: no color keeps selecting a gone object
|
||||
for (const v of Object.values(frames.v[String(i)] || {})) {
|
||||
if (!v || typeof v !== "object") continue;
|
||||
if (v.added) v.added = v.added.filter(a => a.id !== id);
|
||||
if (v.selected && !how?.keepSelection) v.selected = v.selected.filter(s => s !== id && !s.startsWith(`${id}.`));
|
||||
}
|
||||
save();
|
||||
} }) });
|
||||
panelApp.mount(el);
|
||||
const d = run.data?.frames?.[active.value];
|
||||
if (d) setTimeout(() => handle.executed?.(d), 0);
|
||||
@@ -206,11 +225,13 @@ app.registerExtension({
|
||||
wrapQueue();
|
||||
const segmentOnly = async () => { // a picking run: the new frame is segmented, nothing is tracked
|
||||
busy.value = "segmenting…";
|
||||
try { await handle.commit?.(); } catch { /* shown in the panel */ }
|
||||
try { const p = await app.graphToPrompt(); await api.queuePrompt(0, p, { partialExecutionTargets: [String(node.id)] }); }
|
||||
catch (e) { busy.value = String(e.message || e); }
|
||||
};
|
||||
const propagate = async () => {
|
||||
busy.value = "propagating…";
|
||||
try { await handle.commit?.(); } catch { /* shown in the panel */ }
|
||||
try {
|
||||
const p = await app.graphToPrompt();
|
||||
propagating.add(String(node.id));
|
||||
@@ -264,7 +285,7 @@ app.registerExtension({
|
||||
h("div", { class: "subj" }, [...SUBJECTS.map(([k, col, name]) => h("span", {
|
||||
class: ["t", subject.value === k ? "on" : ""], style: subject.value === k ? { color: col } : null,
|
||||
title: `Subject ${k} (${name}) = Picture ${k}: clicks select this subject`,
|
||||
onClick: () => { if (subject.value !== k) { subject.value = k; mountPanel(); } } },
|
||||
onClick: () => { if (subject.value !== k) switchPanel(() => { subject.value = k; }); } },
|
||||
[
|
||||
h("i", { style: { background: col } }),
|
||||
h("span", { style: "color:#fff" }, `${k} · ${name}`),
|
||||
@@ -276,7 +297,7 @@ app.registerExtension({
|
||||
])), h("span", {
|
||||
class: ["t", "lock", subject.value === "lock" ? "on" : ""],
|
||||
title: "Everything outside the selected subject is already protected. Use this padlock only for an object inside that subject's mask; click it to segment with SAM 3.",
|
||||
onClick: () => { if (subject.value !== "lock") { subject.value = "lock"; mountPanel(); } },
|
||||
onClick: () => { if (subject.value !== "lock") switchPanel(() => { subject.value = "lock"; }); },
|
||||
}, [
|
||||
h("span", null, "🔒"),
|
||||
hasPicks("lock") ? h("span", {
|
||||
@@ -320,7 +341,7 @@ app.registerExtension({
|
||||
const [cls, mark] = status(i);
|
||||
return h("div", { class: ["fr", i === active.value ? "on" : ""], key: i, title: run.data.report?.[i]?.source || "",
|
||||
style: { backgroundImage: `url("${fileUrl(d.image)}")` },
|
||||
onClick: () => { if (active.value !== i) { active.value = i; focusFrame = run.data.indices?.[i] ?? i; save(); mountPanel(); } } },
|
||||
onClick: () => { if (active.value !== i) switchPanel(() => { active.value = i; focusFrame = run.data.indices?.[i] ?? i; save(); }); } },
|
||||
[h("span", { class: "b" }, `f${run.data.indices?.[i] ?? i}`), mark ? h("span", { class: ["s", cls] }, mark) : null,
|
||||
h("span", { class: "dots" }, pickedSubjects(i).map(k => k === "lock"
|
||||
? h("span", { title: "Protected object picked", style: "color:#fbbf24;background:#181818;border-radius:3px;padding:0 2px" }, "🔒")
|
||||
|
||||
Reference in New Issue
Block a user