Skip to content

Commit 0e82da8

Browse files
committed
feat: Tasks 3+4+progress+FolderInc fixes
Task 3 - SplineMaskEditorMEC: - js/spline_mask_editor.js: module-level IMG_CACHE (LRU cap=32) for fast repeat loads; syncCanvasPx no longer resets _fitted on resize (kills bounce-back-to-fit); centripetal_alpha wired in catmullRom() + draw(); installEditor exported as window.__MEC_SPLINE_EDITOR__ - nodes/spline_mask_editor.py: centripetal_alpha FLOAT widget (0-1, step 0.05, default 0.5); threaded through _rasterize_splines() and execute() to _catmull_rom_sample(alpha=) Task 4 - SplinePathFlowMaskMEC: - js/spline_path_flow_mask.js: new extension; reuses shared installEditor to embed spline canvas when use_embedded_editor=True - nodes/spline_path_flow_mask.py: 13 patterns (ribbon/wave/flow/dust/river/ smoke + sawtooth/square/triangle/gaussian_pulse/fbm/curl_noise/lightning); 4 flow_direction modes (forward/reverse/bidirectional/oscillate); mod_decay slider for along-path amplitude fade; use_embedded_editor BOOL Terminal progress bar (_progress.py): - Equal-weight phase distribution when expected_phases known; bounded 8-slot fallback (no more 87% stall); tqdm bar_format shows %%|bar| elapsed<remaining only (removes misleading unit='%%'); unknown-total branch advances incrementally per item instead of freezing FolderIncrementer fix: - custom mode + empty custom_name now keeps source_filename value (was silently wiping to empty -> fell back to 'default' label); custom_name remains a higher-priority override when filled
1 parent c11c383 commit 0e82da8

7 files changed

Lines changed: 978 additions & 55 deletions

__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from .nodes.image_comparer import ImageComparerMEC
5050
from .nodes.video_frame_player import VideoFramePlayerMEC
5151
from .nodes.spline_mask_editor import SplineMaskEditorMEC
52+
from .nodes.spline_path_flow_mask import SplinePathFlowMaskMEC
5253
from .nodes.motion_mask_tracker import MotionMaskTrackerMEC
5354
from .nodes.vae_merge import VAEMergeMEC
5455
from .nodes.vae_latent_inspector import VAELatentInspectorMEC
@@ -148,6 +149,7 @@
148149
"ImageComparerMEC": ImageComparerMEC,
149150
"VideoFramePlayerMEC": VideoFramePlayerMEC,
150151
"SplineMaskEditorMEC": SplineMaskEditorMEC,
152+
"SplinePathFlowMaskMEC": SplinePathFlowMaskMEC,
151153
"MotionMaskTrackerMEC": MotionMaskTrackerMEC,
152154
"DrawShapeMEC": DrawShapeMEC,
153155
"VAEMergeMEC": VAEMergeMEC,
@@ -183,6 +185,7 @@
183185
"ImageComparerMEC": "Image Comparer (MEC)",
184186
"VideoFramePlayerMEC": "Video Frame Player (MEC)",
185187
"SplineMaskEditorMEC": "Spline Mask Editor (MEC)",
188+
"SplinePathFlowMaskMEC": "Spline Path Flow Mask (MEC)",
186189
"MotionMaskTrackerMEC": "Motion Mask Tracker (MEC)",
187190
"DrawShapeMEC": "Draw Shape (MEC)",
188191
"VAEMergeMEC": "VAE Merge (MEC)",

folder_incrementer.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,14 +300,18 @@ def increment(self, prefix="v", padding=3, label="default",
300300
# downstream pipeline as a real filename so name_format,
301301
# extension preservation, and folder derivation all behave
302302
# identically.
303+
#
304+
# UX fix (May 2026): if custom_name is empty, fall through to
305+
# whatever is currently in source_filename — that field is
306+
# editable in the UI, so a user who types directly into it
307+
# while source_choice='custom' should NOT have their value
308+
# wiped to the "default" label. custom_name is now only a
309+
# *higher-priority override*, not the sole input.
303310
if str(source_choice).lower() == "custom":
304311
cn = (custom_name or "").strip()
305312
if cn:
306313
source_filename = cn
307-
else:
308-
# Empty custom_name -> behave like 'no source connected'
309-
# so we fall through to the label-based folder.
310-
source_filename = ""
314+
# else: keep source_filename as-is (manual entry honoured).
311315

312316
# ── 1. Derive names from source file ──────────────────────────
313317
if source_filename and source_filename.strip() and not _looks_like_input_file(source_filename.strip()):

js/spline_mask_editor.js

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ const COLOR = {
3535
const HISTORY_LIMIT = 80;
3636
const POINT_HIT_PX = 8;
3737

38+
// Module-level decoded-image cache keyed by URL.
39+
// Fixes "slow image load": when the user pans the graph or re-opens the
40+
// node, we re-use the already-decoded HTMLImageElement instead of issuing
41+
// a fresh fetch + decode every time the URL is re-set.
42+
const IMG_CACHE = new Map(); // url -> {img, w, h}
43+
const IMG_CACHE_MAX = 32;
44+
function _cacheGet(url) { return IMG_CACHE.get(url) || null; }
45+
function _cachePut(url, entry) {
46+
IMG_CACHE.set(url, entry);
47+
if (IMG_CACHE.size > IMG_CACHE_MAX) {
48+
const firstKey = IMG_CACHE.keys().next().value;
49+
if (firstKey !== undefined) IMG_CACHE.delete(firstKey);
50+
}
51+
}
52+
3853
class Editor {
3954
constructor(node) {
4055
this.node = node;
@@ -63,6 +78,7 @@ class Editor {
6378
this.splineType = "catmull_rom";
6479
this.closedDefault = true;
6580
this.samplesPerSegment = 20;
81+
this.centripetalAlpha = 0.5;
6682
}
6783

6884
snapshot() { return JSON.stringify({ s: this.shapes, a: this.active }); }
@@ -173,7 +189,30 @@ class Editor {
173189

174190
setRefImage(url, ow, oh) {
175191
if (url === this.refUrl && this.refImg && this.refImg.complete) return;
192+
const sameUrl = (url === this.refUrl);
176193
this.refUrl = url;
194+
195+
// Cache hit: zero-cost re-use; do NOT reset zoom/pan when the URL
196+
// didn't actually change (was the source of the bounce bug — every
197+
// re-render set refImg again, clearing _fitted, snapping the view).
198+
const cached = _cacheGet(url);
199+
if (cached?.img?.complete) {
200+
this.refImg = cached.img;
201+
const w = ow || cached.w;
202+
const h = oh || cached.h;
203+
if (w > 0 && h > 0 && (this.canvasW !== w || this.canvasH !== h)) {
204+
this.canvasW = w; this.canvasH = h;
205+
const wW = this.node.widgets?.find(x => x.name === "width");
206+
const hW = this.node.widgets?.find(x => x.name === "height");
207+
if (wW && +wW.value !== w) wW.value = w;
208+
if (hW && +hW.value !== h) hW.value = h;
209+
this._fitted = false; // dims changed — a fresh fit is correct
210+
}
211+
if (!sameUrl) this._fitted = false;
212+
this.onLoaded?.();
213+
return;
214+
}
215+
177216
const img = new Image();
178217
img.crossOrigin = "anonymous";
179218
img.onload = () => {
@@ -187,17 +226,15 @@ class Editor {
187226
if (wW && +wW.value !== w) wW.value = w;
188227
if (hW && +hW.value !== h) hW.value = h;
189228
}
229+
_cachePut(url, { img, w, h });
190230
this._fitted = false;
191231
this.onLoaded?.();
192232
};
193233
img.src = url;
194234
}
195235
}
196236

197-
// ─────────────────────────────────────────────────────────────────────
198-
// Spline sampling (Catmull-Rom, polyline) – used only for preview rendering
199-
// ─────────────────────────────────────────────────────────────────────
200-
function catmullRom(points, samplesPerSeg, closed) {
237+
function catmullRom(points, samplesPerSeg, closed, alpha) {
201238
const n = points.length;
202239
if (n < 2) return points.slice();
203240
if (n === 2) {
@@ -215,10 +252,11 @@ function catmullRom(points, samplesPerSeg, closed) {
215252

216253
const out = [];
217254
const segs = closed ? n : n - 1;
255+
// Centripetal Catmull-Rom — alpha is now configurable (0=uniform,
256+
// 0.5=centripetal, 1=chordal). 0.5 is the default and avoids cusps.
257+
const a = (typeof alpha === "number") ? Math.max(0, Math.min(1, alpha)) : 0.5;
218258
for (let i = 0; i < segs; i++) {
219259
const p0 = ext[i], p1 = ext[i + 1], p2 = ext[i + 2], p3 = ext[i + 3];
220-
// Centripetal alpha=0.5
221-
const a = 0.5;
222260
const t01 = Math.pow(Math.hypot(p1.x - p0.x, p1.y - p0.y), a) || 1e-6;
223261
const t12 = Math.pow(Math.hypot(p2.x - p1.x, p2.y - p1.y), a) || 1e-6;
224262
const t23 = Math.pow(Math.hypot(p3.x - p2.x, p3.y - p2.y), a) || 1e-6;
@@ -244,11 +282,11 @@ function catmullRom(points, samplesPerSeg, closed) {
244282
return out;
245283
}
246284

247-
function sampleShape(sh, samplesPerSeg) {
285+
function sampleShape(sh, samplesPerSeg, alpha) {
248286
const pts = sh.points;
249287
if (pts.length < 2) return pts.slice();
250288
if (sh.type === "polyline") return pts.slice();
251-
return catmullRom(pts, samplesPerSeg, sh.closed);
289+
return catmullRom(pts, samplesPerSeg, sh.closed, alpha);
252290
}
253291

254292
function draw(ed, ctx, vw, vh) {
@@ -278,7 +316,7 @@ function draw(ed, ctx, vw, vh) {
278316
const isActive = s === ed.active;
279317

280318
if (sh.points.length >= 2) {
281-
const sampled = sampleShape(sh, ed.samplesPerSegment);
319+
const sampled = sampleShape(sh, ed.samplesPerSegment, ed.centripetalAlpha);
282320
ctx.strokeStyle = c;
283321
ctx.lineWidth = (isActive ? 2.0 : 1.5) / z;
284322
ctx.fillStyle = c + "22";
@@ -370,11 +408,13 @@ function installEditor(node) {
370408
const sps = node.widgets?.find(x => x.name === "samples_per_segment");
371409
const w = node.widgets?.find(x => x.name === "width");
372410
const h = node.widgets?.find(x => x.name === "height");
411+
const ca = node.widgets?.find(x => x.name === "centripetal_alpha");
373412
if (t) ed.splineType = t.value;
374413
if (c) ed.closedDefault = !!c.value;
375414
if (sps) ed.samplesPerSegment = +sps.value || 20;
376415
if (w && +w.value > 0) ed.canvasW = +w.value;
377416
if (h && +h.value > 0) ed.canvasH = +h.value;
417+
if (ca && typeof ca.value === "number") ed.centripetalAlpha = ca.value;
378418
};
379419
syncFromWidgets();
380420
ed.load();
@@ -588,9 +628,15 @@ function installEditor(node) {
588628
canvas.width = w * dpr;
589629
canvas.height = h * dpr;
590630
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
591-
// Re-fit on every layout change — fixes "image pad zoom drift" when
592-
// the parent ComfyUI graph is zoomed (was: only fit once).
593-
if (sizeChanged || !ed._fitted) ed.fitView(w, h);
631+
// Auto-fit ONLY on first sizing or when a new image set _fitted=false.
632+
// Previously we re-fit on every resize → any container reflow snapped
633+
// the user's zoom/pan back to "fit", which is the "bouncy image"
634+
// complaint. Now we just clamp pan into the new viewport instead.
635+
if (!ed._fitted) {
636+
ed.fitView(w, h);
637+
} else {
638+
ed.clampPan(w, h);
639+
}
594640
return true;
595641
}
596642

@@ -784,7 +830,7 @@ function installEditor(node) {
784830
});
785831

786832
// React to widget edits
787-
for (const wn of ["spline_type", "closed", "samples_per_segment", "width", "height"]) {
833+
for (const wn of ["spline_type", "closed", "samples_per_segment", "width", "height", "centripetal_alpha"]) {
788834
const w = node.widgets?.find(x => x.name === wn);
789835
if (!w) continue;
790836
const orig = w.callback;
@@ -816,8 +862,12 @@ function installEditor(node) {
816862
const origExec = node.onExecuted;
817863
node.onExecuted = function (out) {
818864
origExec?.apply(this, arguments);
819-
if (out?.preview_b64?.[0]) {
820-
ed.setRefImage("data:image/jpeg;base64," + out.preview_b64[0]);
865+
// Server may emit either key depending on version; accept both.
866+
const b64 = out?.preview_b64?.[0] ?? out?.preview?.[0];
867+
if (b64) {
868+
// Bare base64 → prepend data-url prefix; full data URL → pass through.
869+
const url = b64.startsWith("data:") ? b64 : ("data:image/jpeg;base64," + b64);
870+
ed.setRefImage(url);
821871
} else {
822872
tryDiscoverRef();
823873
}
@@ -853,3 +903,8 @@ app.registerExtension({
853903
installEditor(node);
854904
},
855905
});
906+
907+
// Export for reuse by sibling editors (e.g. SplinePathFlowMaskMEC).
908+
// Stash on window so a separate module file can pick it up without a build step.
909+
window.__MEC_SPLINE_EDITOR__ = { installEditor, IMG_CACHE };
910+

js/spline_path_flow_mask.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// =====================================================================
2+
// SplinePathFlowMaskMEC frontend extension
3+
// =====================================================================
4+
// Adds an OPTIONAL embedded spline editor inside the SplinePathFlowMask
5+
// node, sharing the same canvas widget code as SplineMaskEditorMEC.
6+
//
7+
// Strategy:
8+
// - The Python node exposes a STRING widget `spline_data` and a BOOLEAN
9+
// widget `use_embedded_editor`.
10+
// - At node creation we check `use_embedded_editor`; if true we call
11+
// `window.__MEC_SPLINE_EDITOR__.installEditor(node)` to attach the
12+
// same DOM canvas + tool palette used by SplineMaskEditorMEC.
13+
// - Toggling `use_embedded_editor` later collapses / restores the
14+
// canvas (set widget computeSize -> 0 to hide).
15+
//
16+
// No HTTP / fetch calls. All DOM. Compatible with comfyui_qa_stress_test
17+
// v2 rules and reuses the SplineMaskEditor module (which itself respects
18+
// the centripetal_alpha widget for smooth no-cusp curves).
19+
// =====================================================================
20+
21+
import { app } from "/scripts/app.js";
22+
23+
const NODE_NAME = "SplinePathFlowMaskMEC";
24+
25+
app.registerExtension({
26+
name: "MEC.SplinePathFlowMask.EmbeddedEditor",
27+
28+
async nodeCreated(node) {
29+
if (!node || node.comfyClass !== NODE_NAME) return;
30+
31+
// Wait one tick so all widgets are realized.
32+
await new Promise((r) => setTimeout(r, 0));
33+
34+
const ed = window.__MEC_SPLINE_EDITOR__;
35+
if (!ed || typeof ed.installEditor !== "function") {
36+
console.warn(
37+
"[SplinePathFlowMask] SplineMaskEditor module not loaded; " +
38+
"embedded editor unavailable. Use the raw spline_data widget."
39+
);
40+
return;
41+
}
42+
43+
const useEditorW = node.widgets?.find?.((w) => w.name === "use_embedded_editor");
44+
const wantEditor = useEditorW ? !!useEditorW.value : true;
45+
46+
if (wantEditor) {
47+
try {
48+
ed.installEditor(node, { hostNode: NODE_NAME });
49+
} catch (err) {
50+
console.error("[SplinePathFlowMask] installEditor failed:", err);
51+
}
52+
}
53+
54+
// React to the toggle: if user flips it after creation, reload the
55+
// graph or hint. We avoid hot-swapping the canvas to keep state simple.
56+
if (useEditorW && useEditorW.callback === undefined) {
57+
useEditorW.callback = (v) => {
58+
if (v && !node.__mec_editor_installed) {
59+
try {
60+
ed.installEditor(node, { hostNode: NODE_NAME });
61+
} catch (e) {
62+
console.error(e);
63+
}
64+
}
65+
// Hiding the canvas mid-session is not supported; instruct
66+
// user to reload the workflow to fully remove it.
67+
node.setDirtyCanvas?.(true, true);
68+
};
69+
}
70+
},
71+
});

0 commit comments

Comments
 (0)