diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cae302f..b8fc41d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ ### Added - **Sparse attention method "veda"** (H3 loader, SLA on, method veda; Experimental, UNTESTED on a GPU). A small learned predictor (Veda, arXiv 2605.30325) picks which 128-token tiles of the attention map matter and an INT8 Triton kernel computes only those, at the predictor's own trained budget (90% for text-to-video). It needs CUDA and Triton and a predictor file in ComfyUI/models/veda, chosen in "veda_predictor" (auto takes the text-to-video file, else the first). Works with LoRA (it sees q/k/v after the LoRA), MXFP8 (weights only: q/k/v stay bf16), Two stages (a size the predictor was not trained for uses the nearest tile plan, and the log says so), dense steps and the attention backend, which keeps the text refiner, masked calls and short sequences. sla_sparsity, the rising share and the references choice are not read in this first version. When Veda cannot run, the status says why and the chosen backend is installed on its own. One log line per run in the SLA line's wording (calls, tile pairs kept, asked vs real sparsity, share of full attention computed, steps at full attention). The Triton kernel is checked against an exact reference on the GPU before first use; the torch half (tiles, predictor, selection, the override on a real tiny H3) is tested on CPU. -- **Veda predictor download** (Settings > Models, shown only for method veda): both official files with their state, installed or a button per missing one, so you can have both (text-to-video, reference-to-video) fetches it straight from Hugging Face on your click, with the MiniMax H3 Community License named and linked beside it. FunUI does not host or bundle the file. The download streams to a `.part` file and is kept only when its size and checksum match; taggers and this share one downloader (`core/fetch.py`). +- **Veda predictor download** (Settings > Models, shown only for method veda): a button per official file that is not in models/veda yet, directly under the predictor selector; nothing once both are there (text-to-video, reference-to-video) fetches it straight from Hugging Face on your click, with the MiniMax H3 Community License named and linked beside it. FunUI does not host or bundle the file. The download streams to a `.part` file and is kept only when its size and checksum match; taggers and this share one downloader (`core/fetch.py`). - **Run meter** (Settings > Engine > Sampling, off by default). Logs how grainy the picture is, across it and over time, and how much it still changes at each step, then compares with the previous run of the same seed. It adds no model call, so flipping one setting and rendering again shows what that setting did. - **Post settings for the finished video** (Settings > Engine > Post, all off or neutral by default): Smooth jagged edges (Light or Strong; FXAA-style, only strongly contrasting edge pixels are softened), Auto contrast, Auto brightness and Colour strength. Contrast and brightness are measured once on the whole clip and applied as one fixed curve, so nothing flickers from frame to frame. They run on the frames before encoding, so NVENC, its CPU fallback and every codec get the same picture. Save Video reads them from the pipeline's settings object (older saved pipelines are linked to it automatically). Tried on the CPU only. - **LoRA Loader: "Apply to stage"** (First, Second or Both, per slot). Shown only while the Two stages module is present and not switched off for the project; the feature itself may stay off in the sampler settings. A LoRA set to First works only in the smaller first stage, one set to Second only in the full-size steps; Two stages hands each stage the model with its own LoRAs, and ComfyUI re-patches the weights between the stages. Only the model changes for First or Second (the text encoder is encoded before sampling, so its strength is not used). When Two stages is off, or a run cannot be cut in two, those LoRAs are not applied and the Log says so. Both is the old behaviour. Tried on a small model on the CPU with every feature switched on; not on a GPU yet. diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index 4fb7f6b2..308fb78e 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -123,29 +123,38 @@ export const models = (app) => function mount() { return row; }; - // The Veda predictors (method veda): both official files, each with its state. The files are not ours to host (MiniMax H3 Community + // The Veda predictors (method veda): the official files that are not in models/veda yet, none at all once both are. The files are not ours to host (MiniMax H3 Community // License), so the one way offered is a fetch from the official Hugging Face address on this click, with the licence beside it. + let vedaSeen = null; // the last answer: a redrawn page paints it at once, so the row does not empty and fill again const vedaRow = () => { const row = c.region.stack({ gap: "sm" }); - let timer = null, fetching = false; + let timer = null, fetching = false, painted = null; + const mb = (n) => `${Math.round(n / 1048576)} MB`; + const paint = (r) => { + const key = JSON.stringify(r); + if (key === painted) return; // nothing changed: leave the rows as they are + painted = key; + const busy = r.models.some((m) => m.state === "downloading"); + if (!r.models.some((m) => m.state !== "ready")) return row.set([]); // both are there: nothing to offer + row.set([...((r.files || []).length ? [] : [c.banner.info({ text: "Veda needs its predictor file in ComfyUI/models/veda, and none is there. You can fetch it straight from Hugging Face; nothing is downloaded until you click." })]), + ...r.models.filter((m) => m.state !== "ready").map((m) => c.settingsRow.default({ label: `${m.title} · ${mb(m.size)}`, hint: m.state === "error" ? `Failed: ${m.error}` : m.state === "downloading" ? `${Math.floor(100 * m.done / m.size)}%` : m.hint, + control: c.button.sm({ label: m.state === "downloading" ? "Downloading…" : m.state === "error" ? "Try again" : "Download", tone: "primary", disabled: busy, + onClick: async () => { try { await api.vedaDownload(m.id); fetching = true; } catch (err) { c.toast.warn({ text: err.message }); } show(); } }) })), + c.settingsRow.default({ label: `By downloading you accept the ${r.license.name}.`, hint: "FunUI does not host or bundle the file. It has its own terms of use, including who may use it and where.", + control: c.button.sm({ label: "Read the licence", tone: "ghost", onClick: () => app.dom.openTab(r.license.url) }) })]); + }; const show = async () => { let r; - try { r = await api.vedaState(); } catch (err) { return row.set([c.hint.default({ text: `Could not ask about the Veda predictor: ${err.message}` })]); } + try { r = await api.vedaState(); } catch (err) { return painted ? undefined : row.set([c.hint.default({ text: `Could not ask about the Veda predictor: ${err.message}` })]); } if (gone) return; + vedaSeen = r; const busy = r.models.some((m) => m.state === "downloading"); if (fetching && !busy) { fetching = false; specs = {}; describe().then(draw); } // a download ended: the predictor list is read again - const mb = (n) => `${Math.round(n / 1048576)} MB`; - const offered = r.models.some((m) => m.state !== "ready"); - row.set([...((r.files || []).length ? [] : [c.banner.info({ text: "Veda needs its predictor file in ComfyUI/models/veda, and none is there. You can fetch it straight from Hugging Face; nothing is downloaded until you click." })]), - ...r.models.map((m) => c.settingsRow.default({ label: `${m.title} · ${mb(m.size)}`, hint: m.state === "ready" ? "Installed" : m.state === "error" ? `Failed: ${m.error}` : m.state === "downloading" ? `${Math.floor(100 * m.done / m.size)}%` : m.hint, - control: m.state === "ready" ? c.button.sm({ label: "Installed", tone: "ghost", disabled: true }) - : c.button.sm({ label: m.state === "downloading" ? "Downloading…" : m.state === "error" ? "Try again" : "Download", tone: "primary", disabled: busy, - onClick: async () => { try { await api.vedaDownload(m.id); fetching = true; } catch (err) { c.toast.warn({ text: err.message }); } show(); } }) })), - ...(offered ? [c.settingsRow.default({ label: `By downloading you accept the ${r.license.name}.`, hint: "FunUI does not host or bundle the file. It has its own terms of use, including who may use it and where.", - control: c.button.sm({ label: "Read the licence", tone: "ghost", onClick: () => app.dom.openTab(r.license.url) }) })] : [])]); + paint(r); clearTimeout(timer); timer = busy ? setTimeout(() => { if (!gone && row.node && row.node.isConnected) show(); }, 1500) : null; // a redrawn page leaves its old row behind: that one stops asking }; + if (vedaSeen) paint(vedaSeen); show(); return row; }; diff --git a/app/ui/features/settings/models.test.js b/app/ui/features/settings/models.test.js index 046afa15..120d5b6c 100644 --- a/app/ui/features/settings/models.test.js +++ b/app/ui/features/settings/models.test.js @@ -185,13 +185,36 @@ test("the Veda predictor row appears only for method veda with no file, names th page = await open({ sla: true, sla_method: "veda" }); const buttons = () => [...page.node.querySelectorAll("button")].map((b) => b.textContent); assert.doesNotMatch(page.node.textContent, /none is there/); - assert.deepEqual(buttons().filter((t) => /Installed|Download/.test(t)), ["Installed", "Download"], "one installed, the other still offered"); + assert.deepEqual(buttons().filter((t) => /Installed|Download/.test(t)), ["Download"], "one installed: only the missing one is offered"); assert.match(page.node.textContent, /By downloading you accept/, "the licence stays while a button is shown"); page.node.remove(); state.files = ["a.safetensors", "b.safetensors"]; state.models[1].state = "ready"; page = await open({ sla: true, sla_method: "veda" }); - assert.deepEqual(buttons().filter((t) => /Installed|Download/.test(t)), ["Installed", "Installed"], "both installed, no download button"); - assert.match(page.node.textContent, /T2VA \/ FL2VA/); - assert.match(page.node.textContent, /R2VA/); + assert.doesNotMatch(page.node.textContent, /Installed|Download|By downloading|none is there/, "both installed: nothing to offer, so nothing is shown"); + page.node.remove(); +}); + +test("a redrawn Veda row paints the last answer at once, so it does not empty and fill again", async () => { + const widgets = [{ name: "sla", type: "BOOLEAN" }, { name: "sla_method", type: "COMBO", choices: ["sla", "veda"], shows: [{ input: "sla", when: [true] }] }, + { name: "veda_predictor", type: "COMBO", choices: ["auto"], shows: [{ input: "sla", when: [true] }, { input: "sla_method", when: ["veda"] }] }]; + const state = { files: [], license: { name: "MiniMax H3 Community License", url: "https://x/LICENSE" }, + models: [{ id: "t2va", title: "T2VA / FL2VA", hint: "", file: "a", size: 275415648, state: "absent", done: 0, error: "" }, + { id: "r2va", title: "R2VA", hint: "", file: "b", size: 275415752, state: "absent", done: 0, error: "" }] }; + let hang = false; + const slots = [{ id: "model", node: "FunUIDiffusionModelLoader", inputs: { sla: true, sla_method: "veda" } }]; + const ps = { slots: () => slots, snapshot: () => ({ slots }), ensureLoaded: async () => {}, subscribe: () => () => {}, restore: async () => ({ refused: [] }), + loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [], queueable: () => true, removedIds: () => [], offered: () => [] }; + const api = { describeNodes: async () => ({ nodes: { FunUIDiffusionModelLoader: { widgets } } }), packProviders: async () => ({}), + vedaState: () => (hang ? new Promise(() => {}) : Promise.resolve(state)) }; + const page = models({ dom: createDom(), pipeline: ps, api })(); + document.body.append(page.node); + await new Promise((r) => setTimeout(r, 0)); + page.node.querySelector(".cx-tile-node").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.match(page.node.textContent, /By downloading you accept/); + hang = true; // the server is slow now: a redraw must not wait for it + [...page.node.querySelectorAll("button")].find((b) => b.textContent.startsWith("‹")).click(); + page.node.querySelector(".cx-tile-node").click(); + assert.match(page.node.textContent, /By downloading you accept/, "painted from the last answer, in the same tick"); page.node.remove(); });