From c873f4b318c1603fbc579dd30d9c35a09fbf6f63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 21:52:16 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu --- CHANGELOG.md | 2 +- app/e2e/viewports.mjs | 6 ++-- app/ui/composer/elements/gallery.css | 2 +- app/ui/composer/elements/overlay.css | 2 +- app/ui/composer/elements/slider.css | 2 +- app/ui/composer/elements/stage.css | 2 +- .../tests/rule_kit_uses_the_scale.test.js | 17 +++++++-- app/ui/composer/tokens/tokens.css | 2 ++ app/ui/features/generate/hub.test.js | 14 ++++++++ app/ui/features/generate/index.js | 6 +++- app/ui/features/imports/index.js | 2 +- app/ui/features/tour/index.js | 4 +-- app/ui/features/upscale/index.test.js | 35 +++++++++++++++++++ app/ui/frame.css | 16 ++++----- app/ui/shell/api.js | 1 + app/ui/shell/dom.js | 2 +- core/tests/test_projects.py | 6 ++++ modules/models/minimax_h3/pipeline.py | 4 +-- modules/output/render/__init__.py | 10 +++++- .../output/render/tests/test_jobs_routes.py | 21 +++++++++++ modules/output/render/upscale.py | 3 ++ 21 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 app/ui/features/upscale/index.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 240d1a45..42b04a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/e2e/viewports.mjs b/app/e2e/viewports.mjs index 1169225f..06200cd7 100644 --- a/app/e2e/viewports.mjs +++ b/app/e2e/viewports.mjs @@ -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) }); diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css index 7a2e1a0f..05580667 100644 --- a/app/ui/composer/elements/gallery.css +++ b/app/ui/composer/elements/gallery.css @@ -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; } diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css index 4833a1fe..b29fb038 100644 --- a/app/ui/composer/elements/overlay.css +++ b/app/ui/composer/elements/overlay.css @@ -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 diff --git a/app/ui/composer/elements/slider.css b/app/ui/composer/elements/slider.css index 7c3520a2..e448e77a 100644 --- a/app/ui/composer/elements/slider.css +++ b/app/ui/composer/elements/slider.css @@ -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); diff --git a/app/ui/composer/elements/stage.css b/app/ui/composer/elements/stage.css index d1979f0a..7192c5d8 100644 --- a/app/ui/composer/elements/stage.css +++ b/app/ui/composer/elements/stage.css @@ -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; } diff --git a/app/ui/composer/tests/rule_kit_uses_the_scale.test.js b/app/ui/composer/tests/rule_kit_uses_the_scale.test.js index 07a37f6c..bfb021b1 100644 --- a/app/ui/composer/tests/rule_kit_uses_the_scale.test.js +++ b/app/ui/composer/tests/rule_kit_uses_the_scale.test.js @@ -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(/(? { + 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"); +}); diff --git a/app/ui/composer/tokens/tokens.css b/app/ui/composer/tokens/tokens.css index f7b6557f..4374e4c3 100644 --- a/app/ui/composer/tokens/tokens.css +++ b/app/ui/composer/tokens/tokens.css @@ -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); diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 4b2d0b2e..62c50d66 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -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); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index efb30594..aa0f8149 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -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." }; diff --git a/app/ui/features/imports/index.js b/app/ui/features/imports/index.js index d3641d6b..144b1f6d 100644 --- a/app/ui/features/imports/index.js +++ b/app/ui/features/imports/index.js @@ -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) => { diff --git a/app/ui/features/tour/index.js b/app/ui/features/tour/index.js index d6314e5d..71e49fdb 100644 --- a/app/ui/features/tour/index.js +++ b/app/ui/features/tour/index.js @@ -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) => { diff --git a/app/ui/features/upscale/index.test.js b/app/ui/features/upscale/index.test.js new file mode 100644 index 00000000..898df035 --- /dev/null +++ b/app/ui/features/upscale/index.test.js @@ -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]); +}); diff --git a/app/ui/frame.css b/app/ui/frame.css index 4d01193c..7a34d0ec 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -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; } diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 87e03be8..cb20028e 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -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`), diff --git a/app/ui/shell/dom.js b/app/ui/shell/dom.js index 9524fa92..87084bcb 100644 --- a/app/ui/shell/dom.js +++ b/app/ui/shell/dom.js @@ -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. */ diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py index e8265a4b..df7e3486 100644 --- a/core/tests/test_projects.py +++ b/core/tests/test_projects.py @@ -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"] diff --git a/modules/models/minimax_h3/pipeline.py b/modules/models/minimax_h3/pipeline.py index 99fc85a9..24a9e259 100644 --- a/modules/models/minimax_h3/pipeline.py +++ b/modules/models/minimax_h3/pipeline.py @@ -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(): diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index 0e016d25..4a80c317 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -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.""" diff --git a/modules/output/render/tests/test_jobs_routes.py b/modules/output/render/tests/test_jobs_routes.py index 54aa44ad..213d429d 100644 --- a/modules/output/render/tests/test_jobs_routes.py +++ b/modules/output/render/tests/test_jobs_routes.py @@ -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 diff --git a/modules/output/render/upscale.py b/modules/output/render/upscale.py index 6712879d..d84e7a9d 100644 --- a/modules/output/render/upscale.py +++ b/modules/output/render/upscale.py @@ -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)."""