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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user