From 19c6525d197321a23d0feb8b85ca35f03268bfbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 21:05:34 +0000 Subject: [PATCH] Old scene Source values open as the default; Generate refused while an upscale model runs; preset decodes with None without the Sol-H3 VAE; tour callout follows a redrawn button Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu --- CHANGELOG.md | 1 + app/e2e/viewports.mjs | 11 +++++++++++ app/ui/features/generate/hub.test.js | 12 ++++++++++++ app/ui/features/generate/index.js | 1 + app/ui/features/tour/index.js | 13 ++++++++++--- app/ui/features/upscale/index.js | 5 +++-- core/projects.py | 2 +- core/tests/test_projects.py | 8 ++++++++ modules/models/minimax_h3/pipeline.py | 11 ++++++++++- .../models/minimax_h3/tests/test_pipeline_preset.py | 13 ++++++++++++- 10 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137664b0..240d1a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed +- **A project opens with scene Source values that no longer exist (image, empty, mixed...) as the default**, so Continuity carries again for those scenes. **Generate is refused while an upscale model is running** (they share ComfyUI's model memory). **The Fast & Simple R2V preset decodes with None when the Sol-H3 VAE is not installed**, instead of being refused. **The tour callout follows its button when it is redrawn.** **Upscale "Always" tries again after a running generation, for the same project and render only.** **The timeline header is one row from 1024px wide.** - **Settings of a module that no longer exists are cleared from a project when it opens** (such as the removed Video detail), and saved at once, so the Log stops warning about them. Nothing is cleared while a module failed to load, and a module a model merely hides keeps its settings. - **Save Video carries the sampler's own last picture to the next shot** (it was carrying the Post-processed one). **Interpolate keeps the full length at 3 and 4 times** (it ended up to 75 ms short of the sound). **LoRA training refuses a model it cannot copy with the reason** (MXFP8 or int8 conversion in the loader), and a ticked MXFP8 that was skipped no longer blocks training. LoRA files that spell their matrices lora_down and lora_up load like lora_A and lora_B. - **Model Loader: no more reload on every run, and no half-way eviction.** Two loaders of one file in one graph no longer reload the model each run. When the same file comes back with other settings (SLA method, attention...), ComfyUI frees the earlier copy when the run ends instead of evicting it half way. A ComfyUI that cannot free the earlier copy is said in the Log. diff --git a/app/e2e/viewports.mjs b/app/e2e/viewports.mjs index 7e5e88f5..1169225f 100644 --- a/app/e2e/viewports.mjs +++ b/app/e2e/viewports.mjs @@ -45,6 +45,17 @@ async function walkTour(page, note) { const m = head.match(/(\d+) \/ (\d+)/); if (!m) break; seen.push(Number(m[1])); + if (/· Generate/.test(head)) { // the pointed-at button is redrawn (as when a run starts): the callout must follow the new one + await page.evaluate(() => { const b = [...document.querySelectorAll("button")].find((x) => (x.textContent || "").trim().startsWith("▶ Generate")); if (b) b.replaceWith(b.cloneNode(true)); window.dispatchEvent(new Event("resize")); }); + await page.waitForTimeout(400); + const gap = await page.evaluate(() => { + const b = [...document.querySelectorAll("button")].find((x) => (x.textContent || "").trim().startsWith("▶ Generate")), pop = document.querySelector(".cx-popover"); + if (!b || !pop) return null; + const r = b.getBoundingClientRect(), q = pop.getBoundingClientRect(); + return Math.abs(q.bottom - r.top); // above the button (the sideways place is clamped to the screen edge) + }); + if (gap === null || gap > 40) note(`the tour callout did not follow the redrawn Generate button (off by ${gap})`); + } const done = Number(m[1]) === Number(m[2]); await page.getByRole("button", { name: done ? "Done" : "Next", exact: true }).click(); if (done) break; diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index b219fc10..4b2d0b2e 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -255,3 +255,15 @@ test("Quick A/B ends with the clip from before and the new clip, labelled, side assert.match(text, /Before/); assert.match(text, /After: enhancements off, seed 5/); }); + +test("Generate does not start while an upscale model is running, and starts again after", async () => { + let queued = 0; + const { app } = rig("idle", { generate: async () => { queued += 1; return true; } }); + app.has = new Set(["upscale.model"]); + await app.runner.units(["a"], 1); + assert.equal(queued, 0); + assert.match(document.body.textContent, /upscale model is running/); + app.has.delete("upscale.model"); + await app.runner.units(["a"], 1); + assert.equal(queued, 1); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 1c9a3b79..efb30594 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -56,6 +56,7 @@ export default { // `once`: for a single run only - {seed} uses that seed on every seed input; onQueued runs once ComfyUI has taken (or refused) the run. async function runUnits(units, times = 1, once = {}) { if (busy) { tell("A run is already going: wait for it, or Stop it."); return; } + if (app.has && app.has.has("upscale.model")) { tell("An upscale model is running. It shares ComfyUI's model memory, so Generate when it has finished."); return; } busy = true; stopped = false; draw(); const pid = p.project.id; let made = 0; diff --git a/app/ui/features/tour/index.js b/app/ui/features/tour/index.js index 600d5948..d6314e5d 100644 --- a/app/ui/features/tour/index.js +++ b/app/ui/features/tour/index.js @@ -22,16 +22,23 @@ export default { mount: "menubar.menus", needs: ["project", "dom"], setup({ app }) { - let at = -1, pop = null; - const stop = () => { if (pop) { const p = pop; pop = null; p.close(); } at = -1; app.has.delete("tour.running"); }; // a flag the Help menu reads: features see copies of app, the Set is shared + let at = -1, pop = null, target = null, watcher = null, queued = false; + // The part pointed at can be redrawn (Generate becomes "Generating..."): the callout then follows the new node instead of a detached one. + const follow = () => { + if (queued || !pop || !target || target.isConnected) return; + queued = true; + requestAnimationFrame(() => { queued = false; if (pop && at >= 0 && target && !target.isConnected) show(at); }); + }; + const stop = () => { if (watcher) { watcher.disconnect(); watcher = null; } target = null; if (pop) { const p = pop; pop = null; p.close(); } at = -1; app.has.delete("tour.running"); }; // a flag the Help menu reads: features see copies of app, the Set is shared const shown = (i) => { const el = app.dom.landmark(STEPS[i].at); return el && el.getBoundingClientRect().width ? el : null; }; function show(i) { if (pop) { const p = pop; pop = null; p.close(); } - let target; + target = null; while (i >= 0 && i < STEPS.length && !(target = shown(i))) i += i >= at ? 1 : -1; // a part that is hidden right now is skipped if (i < 0 || i >= STEPS.length) return stop(); at = i; app.has.add("tour.running"); + if (!watcher && typeof MutationObserver === "function") { watcher = new MutationObserver(follow); watcher.observe(app.dom.body, { childList: true, subtree: true }); } const seen = STEPS.map((_, k) => k).filter((k) => k === i || shown(k)), place = seen.indexOf(i) + 1; // numbered among the parts on screen, so the count has no gaps const s = STEPS[i], last = i === STEPS.length - 1; const body = c.region.stack({ gap: "sm", children: [c.label.section({ text: `${place} / ${seen.length} · ${s.title}` }), c.text.sm({ text: s.text }), diff --git a/app/ui/features/upscale/index.js b/app/ui/features/upscale/index.js index d54d42b0..ddbcd480 100644 --- a/app/ui/features/upscale/index.js +++ b/app/ui/features/upscale/index.js @@ -12,7 +12,7 @@ export default { modules: ["render"], setup({ host, app }) { const p = app.project, sel = app.selection; - const put = inPlace(host), busy = new Set(), waited = new Map(); // waited: how often "Always" put a render off for a running generation + const put = inPlace(host), busy = new Set(), heavy = new Set(), waited = new Map(); // waited: how often "Always" put a render off for a running generation const render = (id) => ((p.project || {}).scene_renders || {})[id]; const target = () => { // the picked clip's generation unit root, when it has a render const open = p.project, sc = open && open.scenes.find((s) => s.id === sel.focus); @@ -52,6 +52,7 @@ export default { const model = rtxOn() ? RTX : p.pref("upscaleModel", ""); if (!model) return void (pressed ? suggest() : (gaveUp(media, "upscale"), c.toast.warn({ text: "Upscale: pick a model or NVIDIA RTX Super Resolution in Settings ▸ Editor." }))); c.toast.info({ text: `Upscaling with ${model}…` }); + if (model !== RTX) { heavy.add(key); app.has.add("upscale.model"); } // Generate waits: an upscale model shares ComfyUI's model memory const out = await upscale(app.api, media, model, undefined, rtxOn() ? rtxOpts() : undefined); derived(media, out, "upscale"); waited.delete(key); if (!waited.size) told = false; @@ -70,7 +71,7 @@ export default { } else { gaveUp(media, "upscale"); c.toast.warn({ text: `Upscale failed: ${err.message}` }); } } - finally { busy.delete(key); draw(); } + finally { busy.delete(key); heavy.delete(key); if (!heavy.size) app.has.delete("upscale.model"); draw(); } } function draw() { diff --git a/core/projects.py b/core/projects.py index 09dd9079..6de648b1 100644 --- a/core/projects.py +++ b/core/projects.py @@ -66,7 +66,7 @@ _TOKEN = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") #: What a free-form field (effects, tracks, pipeline settings) may weigh once it is JSON. MAX_BLOB = 4 * 1024 * 1024 FRAME_MODES = ("project", "timeline", "custom") -SOURCE_TYPES = ("carry", "empty", "image", "generated_frame", "mixed", "video", "v2v", "anchor_guide") +SOURCE_TYPES = ("carry", "video", "v2v") # the scene Source choice is gone: only the clips that play a file keep a type; every older value opens as "carry" def is_token(value) -> bool: diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py index aba743f2..e8265a4b 100644 --- a/core/tests/test_projects.py +++ b/core/tests/test_projects.py @@ -628,3 +628,11 @@ def test_a_pinned_source_image_survives_a_round_trip_and_a_bad_one_is_dropped(): p = projects.Project.from_dict({"fixed_source_image": "111111111111"}) assert projects.Project.from_dict(p.to_dict()).fixed_source_image == "111111111111" assert projects.Project.from_dict({"fixed_source_image": "bad"}).fixed_source_image is None + + +def test_a_scene_source_type_that_no_longer_exists_opens_as_carry(): + from core import projects + for old in ("image", "empty", "generated_frame", "mixed", "anchor_guide", "nonsense"): + assert projects._clean_source({"type": old})["type"] == "carry" + for kept in ("carry", "video", "v2v"): + assert projects._clean_source({"type": kept})["type"] == kept diff --git a/modules/models/minimax_h3/pipeline.py b/modules/models/minimax_h3/pipeline.py index 590e1710..99fc85a9 100644 --- a/modules/models/minimax_h3/pipeline.py +++ b/modules/models/minimax_h3/pipeline.py @@ -165,6 +165,15 @@ FAST_SIMPLE_PREFS = { } +def _sol_vae() -> str: + """The Sol-H3 VAE when it is installed; else "None" (the model's own VAE), so the preset is not refused for a missing file.""" + try: + import folder_paths + return "sol_h3_vae.safetensors" if "sol_h3_vae.safetensors" in folder_paths.get_filename_list("vae") else "None" + except Exception: # noqa: BLE001 -- no ComfyUI folders to ask: the preset's own choice + return "sol_h3_vae.safetensors" + + def h3_fast_simple_r2v(): """Reference to Video set up for speed with few settings to think about: MXFP8 weights, block-sparse attention that ramps from 0.7 to 0.9, a DMAD LoRA slot, five steps on the Turbo sampler with the first three on a smaller @@ -182,7 +191,7 @@ def h3_fast_simple_r2v(): "model": {"weight_dtype": "default", "compute_dtype": "default", "attention": "default", "mxfp8": True, "sla": True, "sla_method": "sla", "sla_engine": "comfy_kitchen", "sla_sparsity": 0.9, "sla_sparsity_start": 0.7, "sla_dense_steps": "0", "sla_dense_last_steps": 0}, - "vae": {"decode_vae_name": "sol_h3_vae.safetensors"}, + "vae": {"decode_vae_name": _sol_vae()}, "lora": {"strength_model": 0.5, "strength_clip": 0.0}, "sampler": {"steps": 5, "sampler_name": "funui_turbo", "scheduler": "ddim_uniform"}, "settings": {"settings": json.dumps(settings)}, diff --git a/modules/models/minimax_h3/tests/test_pipeline_preset.py b/modules/models/minimax_h3/tests/test_pipeline_preset.py index c7533015..cd399010 100644 --- a/modules/models/minimax_h3/tests/test_pipeline_preset.py +++ b/modules/models/minimax_h3/tests/test_pipeline_preset.py @@ -179,8 +179,10 @@ def test_the_preset_takes_its_size_from_the_aspect_size_alone(): assert {"at": "project.resolution_basis", "input": "basis"} in slots["aspect"]["roles"] -def test_fast_simple_preset_carries_its_settings_and_builds_clean(registered): +def test_fast_simple_preset_carries_its_settings_and_builds_clean(registered, monkeypatch): import json + import folder_paths + monkeypatch.setattr(folder_paths, "get_filename_list", lambda kind: ["sol_h3_vae.safetensors"] if kind == "vae" else []) from core import graph from modules.models.minimax_h3.pipeline import h3_fast_simple_r2v @@ -201,3 +203,12 @@ def test_fast_simple_preset_names_its_render_steps(): prefs = next(p for p in presets() if p["id"] == "minimax_h3_fast_simple_r2v")["prefs"] assert prefs["interpolateMode"] == "always" and prefs["interpolateOpts"] == {"mode": "MEDIUM", "multiplier": 2} assert prefs["upscaleMode"] == "button" and prefs["upscaleRtx"] == {"quality": "DENOISE_MEDIUM"} + + +def test_fast_simple_preset_decodes_with_none_when_the_sol_vae_is_not_installed(monkeypatch): + import folder_paths + from modules.models.minimax_h3 import pipeline + monkeypatch.setattr(folder_paths, "get_filename_list", lambda kind: []) + assert pipeline._sol_vae() == "None" + monkeypatch.setattr(folder_paths, "get_filename_list", lambda kind: ["sol_h3_vae.safetensors"]) + assert pipeline._sol_vae() == "sol_h3_vae.safetensors"