Review round 3: tour follows the renamed Generate button, upscale-model block also for the Training preview and a reloaded page, px/colour lint covers negatives and frame.css, tests for the load path and the flag

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu
This commit is contained in:
Claude
2026-11-08 21:52:16 +00:00
parent 19c6525d19
commit c873f4b318
21 changed files with 132 additions and 27 deletions
+1 -1
View File
@@ -20,7 +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.**
- **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 (and the Training preview) is refused while an upscale model is running**, also after a page reload or in a second tab (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 or renamed (Generate becomes Generating…).** **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.
+3 -3
View File
@@ -46,11 +46,11 @@ async function walkTour(page, note) {
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.evaluate(() => { const b = [...document.querySelectorAll("button")].find((x) => (x.textContent || "").trim().startsWith("▶ Generate")); if (b) { const n = b.cloneNode(true); n.textContent = "Generating…"; b.replaceWith(n); } 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 b = [...document.querySelectorAll("button")].find((x) => /^(▶ Generate|Generating…)/.test((x.textContent || "").trim())), pop = document.querySelector(".cx-popover");
if (!b || !pop || !/· Generate/.test(pop.textContent)) return null; // still the Generate callout, not skipped on to the next part
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)
});
+1 -1
View File
@@ -27,7 +27,7 @@
.cx-cell-badge, .cx-cell-duration {
position: absolute; bottom: var(--sp-1); font-size: var(--fs-8);
padding: 1px var(--sp-2); border-radius: var(--r-1);
background: color-mix(in srgb, var(--void) 70%, transparent); color: var(--hi, #fff);
background: color-mix(in srgb, var(--void) 70%, transparent); color: var(--on-media);
}
.cx-cell-badge { left: var(--sp-1); }
.cx-cell-duration { right: var(--sp-1); font-variant-numeric: tabular-nums; }
+1 -1
View File
@@ -29,7 +29,7 @@
.cx-modal-stacked {
box-shadow: 0 var(--sp-8) var(--shadow-blur-lg) color-mix(in srgb, var(--shadow-ink) 90%, transparent);
}
@keyframes cx-pop { from { opacity: 0; transform: translateY(-6px) scale(.99); } }
@keyframes cx-pop { from { opacity: 0; transform: translateY(calc(var(--sp-3) * -1)) scale(.99); } }
/* A modal that hosts SWITCHABLE content -- Settings, so far -- rather than
one fixed question: sizing to whichever section is showing means the frame
+1 -1
View File
@@ -17,7 +17,7 @@
}
.cx-range::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: var(--thumb-size); height: var(--thumb-size); margin-top: -5px;
width: var(--thumb-size); height: var(--thumb-size); margin-top: calc((var(--sp-2) - var(--thumb-size)) / 2);
border-radius: var(--r-pill); background: var(--elev-1);
border: var(--bw-2) solid var(--accent);
transition: transform var(--dur-1) var(--ease-standard);
+1 -1
View File
@@ -1,5 +1,5 @@
.cx-stage { position: absolute; pointer-events: none; overflow: hidden; z-index: 2; }
.cx-stage-item { position: absolute; pointer-events: auto; cursor: move; touch-action: none; }
.cx-stage-item img { display: block; }
.cx-stage-grip { position: absolute; inset-inline-end: -5px; inset-block-end: -5px; inline-size: var(--sp-5); block-size: var(--sp-5); background: var(--accent); border: var(--bw) solid #000; cursor: nwse-resize; border-radius: var(--r-1); }
.cx-stage-grip { position: absolute; inset-inline-end: calc(var(--sp-5) / -2); inset-block-end: calc(var(--sp-5) / -2); inline-size: var(--sp-5); block-size: var(--sp-5); background: var(--accent); border: var(--bw) solid var(--void); cursor: nwse-resize; border-radius: var(--r-1); }
:has(> .cx-stage) { position: relative; }
@@ -9,15 +9,26 @@ import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "elements");
const frame = join(dir, "..", "..", "frame.css"); // the page frame is held to the same scale
const sheets = () => [...readdirSync(dir).filter((n) => n.endsWith(".css")).map((n) => join(dir, n)), frame];
test("element CSS has no literal pixel sizes", () => {
let found = 0;
for (const f of readdirSync(dir).filter((n) => n.endsWith(".css"))) {
const css = readFileSync(join(dir, f), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const f of sheets()) {
const css = readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const decl of css.split(/[;{}]/)) {
if (/^\s*(--|@media|@container)/.test(decl)) continue; // a token definition is where a number belongs; a breakpoint cannot use one
found += (decl.match(/(?<![\w.-])(?:[2-9]|\d{2,})(?:\.\d+)?px\b/g) || []).length; // 1px lines and 0 are borders and resets
found += (decl.match(/(?<![\w.])-?(?:[2-9]|\d{2,})(?:\.\d+)?px\b/g) || []).length; // 1px lines and 0 are borders and resets
}
}
assert.equal(found, 0, `${found} literal px values in element CSS: use a --sp-* step or a component size from tokens.css`);
});
test("element CSS has no literal colours outside token definitions", () => {
const bad = [];
for (const f of sheets()) {
const css = readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const decl of css.split(/[;{}]/)) if (!/^\s*--/.test(decl) && /#[0-9a-fA-F]{3,8}\b/.test(decl)) bad.push(`${f}: ${decl.trim().slice(0, 60)}`);
}
assert.deepEqual(bad, [], "literal colours in element CSS: use a token from tokens.css");
});
+2
View File
@@ -148,6 +148,7 @@
--pink: rgb(255, 120, 180);
--void: #060606; /* media letterbox */
--on-media: #ffffff; /* text over a picture */
--shadow-ink: rgba(0, 0, 0, .85);
--backdrop: rgba(6, 5, 4, .66);
@@ -202,6 +203,7 @@
--pink: rgb(197, 45, 116);
--void: #0a0c0f;
--on-media: #ffffff;
--shadow-ink: rgba(24, 34, 48, .30);
--backdrop: rgba(24, 34, 48, .34);
+14
View File
@@ -267,3 +267,17 @@ test("Generate does not start while an upscale model is running, and starts agai
await app.runner.units(["a"], 1);
assert.equal(queued, 1);
});
test("the Training preview waits for an upscale model too, and the server's word counts after a reload", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.has = new Set();
app.api.upscaleBusy = async () => ({ busy: true });
const r = await app.runner.preview({ lora: "x.safetensors" });
assert.match(r.error, /upscale model is running/);
await app.runner.units(["a"], 1);
assert.equal(queued, 0);
app.api.upscaleBusy = async () => ({ busy: false });
await app.runner.units(["a"], 1);
assert.equal(queued, 1);
});
+5 -1
View File
@@ -54,9 +54,12 @@ export default {
// `times` > 1: the same shots again, only the seed differing (the prompt's random picks are drawn once), so the takes can be compared.
// `once`: for a single run only - {seed} uses that seed on every seed input; onQueued runs once ComfyUI has taken (or refused) the run.
// This page's own upscale, or one the server still runs for a reloaded page or another tab.
const upscaling = async () => Boolean(app.has && app.has.has("upscale.model")) || Boolean(await Promise.resolve(app.api.upscaleBusy ? app.api.upscaleBusy() : null).then((r) => r && r.busy).catch(() => false));
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; }
if (await upscaling()) { tell("An upscale model is running. It shares ComfyUI's model memory, so Generate when it has finished."); return; }
if (busy) { tell("A run is already going: wait for it, or Stop it."); return; } // a second click that came in while the server was asked
busy = true; stopped = false; draw();
const pid = p.project.id;
let made = 0;
@@ -172,6 +175,7 @@ export default {
app.runner.preview = async ({ lora, strength = 1 }) => {
if (busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING) return { error: "A run is already going: the preview waits for it." };
if (!p.project) return { error: "Open a project first: the preview uses its settings." };
if (await upscaling()) return { error: "An upscale model is running. It shares ComfyUI's model memory, so the preview waits for it." };
const frozen = structuredClone(app.pipeline.slots() || []);
const slot = frozen.find((s) => s.node === "FunUILoraLoader" && !s.bypassed) || frozen.find((s) => s.node === "FunUILoraLoader");
if (!slot) return { error: "This pipeline has no LoRA loader to try the LoRA in. Add one in Models & Pipeline." };
+1 -1
View File
@@ -52,7 +52,7 @@ export default {
p.setScene(id, "source_image", a.id);
return c.toast.good({ text: "Set as that clip's starting picture." });
}
const made = p.edit((pr) => { const sc = addScene(pr, "image"); sc.source_image = a.id; return sc; });
const made = p.edit((pr) => { const sc = addScene(pr); sc.source_image = a.id; return sc; });
if (made) p.select(made.id);
}),
onMediaDrop("lane:audio", async (item, lane, e) => {
+2 -2
View File
@@ -9,7 +9,7 @@ const STEPS = [
{ at: "zone:1", side: "left", title: "Preview", text: "The monitor plays the cut wherever the playhead is. Save frame puts the current picture in the media bin." },
{ at: "zone:2", side: "left", title: "Properties", text: "Project settings (size, length, postfix) and the picked scene's prompt, source, references and length." },
{ at: "zone:3", side: "top", title: "Timeline", text: "One clip per scene. Drag the edges to trim, press S to split, Space to play. Lanes below carry separated sound." },
{ at: "button:▶ Generate", side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes and video clips; an earlier render of a scene stays as a take. Beside it: Selected makes only the picked scenes, Takes ×N makes the picked scene N times with only the seed changing, and Rate scene… lets FunUI learn your taste (Upscale and Interpolate appear there too once you switch them on). “Generate this scene” in Properties makes just the picked one." },
{ at: "button:▶ Generate|Generating…", side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes and video clips; an earlier render of a scene stays as a take. Beside it: Selected makes only the picked scenes, Takes ×N makes the picked scene N times with only the seed changing, and Rate scene… lets FunUI learn your taste (Upscale and Interpolate appear there too once you switch them on). “Generate this scene” in Properties makes just the picked one." },
{ at: "button:⏱ Sampler", side: "top", title: "Sampler", text: "Steps, sampler, scheduler, seed and the common switches, one click from the timeline. While a run goes, the step count shows to its left." },
{ at: "button:◆ Composer", side: "top", title: "Composer", text: "Write the whole story in one box, cut at the cut word; keep templates, $variables and shortcuts; chat with the prompt enhancer." },
{ at: "button:+ Add", side: "top", title: "Add and edit", text: "+ Add opens one menu: a scene, a video clip or audio track from the bin, an effect or transition, a text or picture overlay. Next to it: Split, Remove, Export, Save to media bin and Separate audio." },
@@ -44,7 +44,7 @@ export default {
const body = c.region.stack({ gap: "sm", children: [c.label.section({ text: `${place} / ${seen.length} · ${s.title}` }), c.text.sm({ text: s.text }),
c.toolbar.default({ items: [c.button.sm({ label: "Back", tone: "ghost", disabled: i === 0, onClick: () => show(i - 1) }), c.button.sm({ label: last ? "Done" : "Next", tone: "primary", onClick: () => (last ? stop() : show(i + 1)) })],
trailing: [c.button.sm({ label: "Exit tour", tone: "ghost", onClick: stop })] })] });
pop = c.popover.anchored({ anchor: target, body, side: s.side, align: "center", gap: 10, closeOnOutside: false, onClose: () => { if (pop) { pop = null; at = -1; app.has.delete("tour.running"); } } });
pop = c.popover.anchored({ anchor: target, body, side: s.side, align: "center", gap: 10, closeOnOutside: false, onClose: () => { if (pop) { pop = null; at = -1; if (watcher) { watcher.disconnect(); watcher = null; } app.has.delete("tour.running"); } } });
}
app.has.add("tour");
const off = app.on((what) => {
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
let hub;
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
test.after(() => teardownDom());
// A project with one rendered scene and the Upscale button on: pressing it runs one job with the model file or with RTX.
function rig(model, upscaleJob) {
const media = { filename: "a.mp4", subfolder: "", type: "output" };
const doc = { id: "P", scenes: [{ id: "a", text: "x" }], scene_renders: { a: { media } }, editor_settings: { upscaleMode: "button", upscaleModel: model } };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: doc.scenes[0], edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc),
pref: (k, d) => (doc.editor_settings[k] ?? d), setPref() {}, onChange: () => () => {}, subscribe: () => () => {} };
const has = new Set();
const app = { dom: createDom(), project, selection: { focus: "a", ids: ["a"] }, has, api: { upscaleModels: async () => ({ models: [model], rtx: null }), upscale: async () => ({ job_id: "j" }), upscaleJob: upscaleJob, stopJob() {} },
editorSettings: [], on: () => () => {}, openSettings() {} };
const host = document.createElement("div");
hub.setup({ host, app });
return { app, host, has };
}
test("the upscale-model flag is up while a model job runs and down after it, also when it fails", async () => {
let release, seen = [];
const { host, has } = rig("4x.safetensors", () => new Promise((r) => { release = r; }));
const press = () => host.querySelector("button");
press().click();
await new Promise((r) => setTimeout(r, 1300)); // the job is polled after a second
seen.push(has.has("upscale.model"));
release({ state: "error", detail: "boom" });
await new Promise((r) => setTimeout(r, 100));
seen.push(has.has("upscale.model"));
assert.deepEqual(seen, [true, false]);
});
+8 -8
View File
@@ -1,5 +1,5 @@
/* The frame's proportions, as v4 had them: fixed side columns and a fixed-height timeline across the bottom. */
:root { --media-w: clamp(180px, 20vw, 280px); --properties-w: clamp(270px, 28vw, 360px); --timeline-h: clamp(340px, 42vh, 380px); --pad: 10px; --pgap: 10px; }
:root { --media-w: clamp(180px, 20vw, 280px); --properties-w: clamp(270px, 28vw, 360px); --timeline-h: clamp(340px, 42vh, 380px); --pad: 10px; --pgap: 10px; --split: 7px; --pane-min-h: 200px; --peek-fallback: 64px; --strip-h: 26px; }
.fp-main { flex-direction: column; padding: 0 var(--pad) var(--pad); gap: var(--pgap); }
/* Narrower windows wrap the timeline's header and toolbar onto more rows, so its fixed height gets more room for the lanes. */
@media (max-width: 1250px) { :root { --timeline-h: clamp(380px, 44vh, 440px); } }
@@ -8,7 +8,7 @@
.fp-row > :first-child { flex: 0 0 var(--media-w); }
.fp-row > :nth-child(2) { flex: 1 1 auto; }
.fp-row > :nth-child(3) { flex: 0 0 var(--properties-w); }
.fp-main > :nth-child(2) { flex: 0 0 var(--timeline-h); min-block-size: 200px; }
.fp-main > :nth-child(2) { flex: 0 0 var(--timeline-h); min-block-size: var(--pane-min-h); }
.cx-frame-head { padding-inline: calc(var(--sp-6) + var(--sp-1)); }
.cx-frame-head > .cx-toolbar { gap: calc(var(--sp-6) + var(--sp-3)); }
/* A zone body is a scroller of rows: they keep their height instead of squashing, and a tab strip spans the zone. */
@@ -24,10 +24,10 @@
.fp-main, .fp-row { position: relative; } /* side bars live in the row, so they stop above the timeline */
.fp-split { position: absolute; z-index: 20; background: transparent; touch-action: none; }
.fp-split:hover { background: var(--accent, var(--line)); opacity: .5; }
.fp-split-left, .fp-split-right { top: 0; bottom: 0; inline-size: 7px; cursor: col-resize; }
.fp-split-left { left: calc(var(--media-w) + var(--pgap) / 2 - 3.5px); }
.fp-split-right { right: calc(var(--properties-w) + var(--pgap) / 2 - 3.5px); }
.fp-split-bottom { left: 0; right: 0; block-size: 7px; bottom: calc(var(--pad) + var(--timeline-h) + var(--pgap) / 2 - 3.5px); cursor: row-resize; }
.fp-split-left, .fp-split-right { top: 0; bottom: 0; inline-size: var(--split); cursor: col-resize; }
.fp-split-left { left: calc(var(--media-w) + var(--pgap) / 2 - var(--split) / 2); }
.fp-split-right { right: calc(var(--properties-w) + var(--pgap) / 2 - var(--split) / 2); }
.fp-split-bottom { left: 0; right: 0; block-size: var(--split); bottom: calc(var(--pad) + var(--timeline-h) + var(--pgap) / 2 - var(--split) / 2); cursor: row-resize; }
/* Dock: a hidden zone leaves the row (and the splitter beside it). */
.fp-hide-assets .fp-row > :nth-child(1),
@@ -39,13 +39,13 @@
.fp-split { border: 0 !important; }
/* Auto-hide timeline: its title bar and a strip; full height once the pointer rests on the strip or the timeline, or during a drag. */
:root.fp-peek .fp-main { position: relative; padding-block-end: calc(var(--peek-h, 64px) + var(--pgap)); }
:root.fp-peek .fp-main { position: relative; padding-block-end: calc(var(--peek-h, var(--peek-fallback)) + var(--pgap)); }
:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: auto; min-block-size: 0; z-index: 20; overflow: hidden; }
:root.fp-peek .fp-main > :nth-child(2) .cx-panel-body { display: none; }
:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; }
:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) .cx-panel-body { display: block; }
.fp-peek-strip { display: none; }
:root.fp-peek .fp-peek-strip { display: flex; align-items: center; justify-content: center; block-size: 26px; flex: none; font-size: var(--fs-6); color: var(--faint); user-select: none; }
:root.fp-peek .fp-peek-strip { display: flex; align-items: center; justify-content: center; block-size: var(--strip-h); flex: none; font-size: var(--fs-6); color: var(--faint); user-select: none; }
:root.fp-peek .fp-peek-strip:hover { color: var(--text); }
:root.fp-peek.fp-peek-open .fp-peek-strip { display: none; }
:root.fp-peek .fp-split-bottom { display: none; }
+1
View File
@@ -142,6 +142,7 @@ export const api = {
upscaleModels: () => call("GET", "/api/m/render/upscale_models"),
/** The upscale is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */
upscale: (media, model, rtx) => call("POST", "/api/m/render/upscale", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, model, ...(rtx ? { rtx_options: rtx } : {}) }),
upscaleBusy: () => call("GET", "/api/m/render/upscale_busy"),
upscaleJob: (id) => call("GET", `/api/m/render/upscale/${encodeURIComponent(id)}`),
/** The Stop button for Upscale and Interpolate jobs (Upscale's route; Interpolate has its own below). -> {stopped} */
stopJob: (id) => call("POST", `/api/m/render/jobs/${encodeURIComponent(id)}/stop`),
+1 -1
View File
@@ -50,7 +50,7 @@ export function createDom(doc = document) {
if (name === "main") return doc.querySelector(".cx-frame-main");
const [kind, rest] = name.split(/:(.*)/s);
if (kind === "zone") return doc.querySelectorAll("section.cx-zone")[Number(rest)] || null;
if (kind === "button") return [...doc.querySelectorAll("button")].find((b) => b.textContent.trim().startsWith(rest)) || null;
if (kind === "button") return [...doc.querySelectorAll("button")].find((b) => rest.split("|").some((r) => b.textContent.trim().startsWith(r))) || null; // "a|b": either label (a button that is renamed while it works)
return null;
},
/** The page's body, as an anchor for things that float. */
+6
View File
@@ -636,3 +636,9 @@ def test_a_scene_source_type_that_no_longer_exists_opens_as_carry():
assert projects._clean_source({"type": old})["type"] == "carry"
for kept in ("carry", "video", "v2v"):
assert projects._clean_source({"type": kept})["type"] == kept
def test_a_saved_scene_with_an_old_source_type_loads_as_carry_through_the_project():
from core import projects
p = projects.Project.from_dict({"scenes": [{"text": "a", "source": {"type": "image"}}, {"text": "b", "source": {"type": "v2v", "media_ref": "m"}}]})
assert [s.source["type"] for s in p.scenes] == ["carry", "v2v"]
+2 -2
View File
@@ -170,8 +170,8 @@ def _sol_vae() -> str:
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"
except Exception: # noqa: BLE001 -- no ComfyUI folders to ask: the model's own VAE, never a file that may be missing
return "None"
def h3_fast_simple_r2v():
+9 -1
View File
@@ -104,9 +104,17 @@ def routes(table, base, web):
if model != rtx.NAME and upscale.comfy_busy():
return bad(upscale.BUSY)
job = jobs.new(stoppable=True)
asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError), stoppable=True, exclusive=True))
task = asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError), stoppable=True, exclusive=True))
if model != rtx.NAME:
upscale.MODEL_JOBS[0] += 1
task.add_done_callback(lambda _t: upscale.MODEL_JOBS.__setitem__(0, max(0, upscale.MODEL_JOBS[0] - 1)))
return web.json_response({"job_id": job})
@table.get(base + "/upscale_busy")
async def _upscale_busy(_req):
"""Whether an upscale-model job is queued or running: Generate waits for it (they share ComfyUI's model memory)."""
return web.json_response({"busy": upscale.MODEL_JOBS[0] > 0})
@table.post(base + "/jobs/{job}/stop")
async def _stop_job(req):
"""Stop an Upscale job (the page's Stop button); only jobs that check for it can be stopped."""
@@ -354,3 +354,24 @@ def test_rtx_upscale_job_runs_while_comfyui_is_busy(monkeypatch, tmp_path):
with pytest.raises(RuntimeError) as e:
upscale.run({"filename": "missing.mp4"}, rtx.NAME)
assert "ComfyUI is generating" not in str(e.value)
def test_upscale_busy_is_true_only_while_a_model_job_is_queued_or_running(server, monkeypatch):
from modules.output.render import upscale, rtx
monkeypatch.setattr(upscale, "comfy_busy", lambda: False)
assert call(server, "GET", f"{BASE}/upscale_busy")[1] == {"busy": False}
import threading
gate = threading.Event()
monkeypatch.setattr(upscale, "run", lambda *a, **k: (gate.wait(5), {"media": {}})[1])
media_ref = {"filename": "x.mp4", "subfolder": "", "type": "output"}
call(server, "POST", f"{BASE}/upscale", {"media": media_ref, "model": "4x.safetensors"})
assert call(server, "GET", f"{BASE}/upscale_busy")[1] == {"busy": True}
gate.set()
import time
for _ in range(50):
if call(server, "GET", f"{BASE}/upscale_busy")[1] == {"busy": False}:
break
time.sleep(0.1)
assert call(server, "GET", f"{BASE}/upscale_busy")[1] == {"busy": False}
call(server, "POST", f"{BASE}/upscale", {"media": media_ref, "model": rtx.NAME})
assert call(server, "GET", f"{BASE}/upscale_busy")[1] == {"busy": False} # RTX uses no ComfyUI model memory
+3
View File
@@ -30,6 +30,9 @@ BUSY = ("ComfyUI is generating right now. An upscale model shares ComfyUI's mode
"(RTX Super Resolution can run beside it).")
MODEL_JOBS = [0] # upscale-model jobs queued or running (the page asks, so a reloaded page or a second tab knows too)
def comfy_busy() -> bool:
"""Whether ComfyUI has a prompt running or waiting. An upscale model is loaded through ComfyUI's model manager, which is not safe to
use from two threads at once, so it is not started beside a generation. False when there is no server to ask (tests, a script)."""