Review round 10: tour follows Render relabel and resize, upscale re-check before queueing, Always retry cap tested, px lint flags every px but 0 and 1

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-09 10:41:29 +00:00
parent 50b92efbc1
commit 9d4d7ca107
9 changed files with 72 additions and 13 deletions
+1
View File
@@ -6,6 +6,7 @@
### Changed
- **Old scenes whose Source was "empty" or "image" now carry the previous clip on (Continuity)**, and a picture dropped on an empty timeline makes a carry scene.
- **Save Video quality is a setting now, and "high" is the default** (constant quality 16 where it was 23, a slower encoder preset): files are larger and encode a little slower, and clip trims and the final render re-encode at 16 instead of 23. Saved pipelines without the input get "high". The finished picture is also rounded to 8 bits instead of cut down, so every value is up to one level brighter than before.
- **Auto Montage is gone** (the button, its window and its code). A saved project that already holds pieces it made loads normally: they stay on the timeline as ordinary copies and are still never regenerated.
- **"Video detail" (Engine > Post) is gone.** The other Post settings (edge smoothing, auto contrast, auto brightness, colour strength) stay. A project or setting that still holds Video detail loads normally: its values are skipped and the Log says so once.
+1 -1
View File
@@ -43,7 +43,7 @@
}
.cx-brand-name {
font-family: var(--font-display); font-weight: 800;
font-size: var(--fs-3); letter-spacing: .2px; color: var(--text);
font-size: var(--fs-3); letter-spacing: var(--ls-tight); color: var(--text);
}
.cx-center { text-align: center; }
@@ -18,7 +18,7 @@ test("element CSS has no literal pixel sizes", () => {
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.matchAll(/(?<![\w])-?(\d*\.?\d+)px\b/g)].filter((m) => ![0, 1].includes(Math.abs(Number(m[1])))).length; // 0 and a 1px hairline are exempt; 1.5px and .5px... are not // 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`);
+1
View File
@@ -52,6 +52,7 @@
/* ── borders & states ───────────────────────────────────────────────── */
--bw: 1px;
--ls-tight: 0.2px; /* letter spacing of display text */
--disabled-opacity: 0.45;
/* ── motion ─────────────────────────────────────────────────────────── */
+8 -3
View File
@@ -104,12 +104,15 @@ export default {
const drawn = new Map();
const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand;
let told = false; // the wait is said once per Generate
for (const unit of Array.from({ length: times }, () => units).flat()) {
if (stopped) break;
while (!stopped && await upscaling()) { // an Always upscale of the shot before may be running: the next shot waits for it
const waitForUpscale = async () => { // an Always upscale of the shot before may be running: the next shot waits for it
while (!stopped && await upscaling()) {
if (!told) { told = true; tell("Waiting for the upscale model to finish before the next shot."); }
await new Promise((r) => setTimeout(r, 2000));
}
};
for (const unit of Array.from({ length: times }, () => units).flat()) {
if (stopped) break;
await waitForUpscale();
if (stopped) break;
if (!p.project || p.project.id !== pid) { tell("Stopped: another project was opened."); break; }
const root = unitRoot(snap, unit);
@@ -130,6 +133,7 @@ export default {
if (unwired) tell(unwiredText(unwired, left));
// The page's own guard does not see another tab: ComfyUI's queue does. Asked per shot: between this run's shots it is empty.
if (app.api.projectQueued && (await app.api.projectQueued(pid))) { tell("This project already has a run in ComfyUI's queue (another tab or window?). Wait for it, or Stop it there."); break; }
await waitForUpscale(); // asked again right before queueing: the shot's inputs took time to build
if (stopped) break;
const done = g.waitForTerminal(); // listening before the run starts, so a fast one is not missed
said = false;
@@ -205,6 +209,7 @@ export default {
await Promise.all((app.beforeRun || []).map((f) => f().catch(() => {})));
const { inputs, unwired, left } = await buildInputs({ project: snap, scene: root, slots: frozen, expand, nodeInfo: app.api.wireTargets, frames, hooks: app.inputHooks, prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root, snap); } catch { return []; } }) });
if (unwired) tell(unwiredText(unwired, left));
if (await upscaling()) return { error: "An upscale model is running. It shares ComfyUI's model memory, so the preview waits for it." };
const done = g.waitForTerminal();
const queued = await g.generate({ sceneId: root.id, projectId: snap.id, inputs, slots: frozen });
if (!queued) { done.cancel(); return { error: (g.run.state.error && g.run.state.error.message) || "The preview could not be queued. Is ComfyUI running?" }; }
+5 -5
View File
@@ -13,7 +13,7 @@ const STEPS = [
{ 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." },
{ at: "button:⧉ Render", side: "top", title: "Render", text: "Joins the whole timeline into one file, with the separated sound mixed in. Export downloads the picked clips." },
{ at: "button:⧉ Render|Rendering…", side: "top", title: "Render", text: "Joins the whole timeline into one file, with the separated sound mixed in. Export downloads the picked clips." },
{ at: "main", side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log window. Wrong look? Settings ▸ Appearance. Lost a layout? View ▸ Reset Layout. Lost a project? File ▸ Open recent. That is the tour: Help ▸ Start tour runs it again." },
];
@@ -25,9 +25,9 @@ export default {
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;
if (queued || !pop || !target || (target.isConnected && target.getBoundingClientRect().width)) return; // gone from the page, or hidden
queued = true;
requestAnimationFrame(() => { queued = false; if (pop && at >= 0 && target && !target.isConnected) show(at); });
requestAnimationFrame(() => { queued = false; if (pop && at >= 0 && target && !(target.isConnected && target.getBoundingClientRect().width)) 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; };
@@ -38,7 +38,7 @@ export default {
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 }); }
if (!watcher && typeof MutationObserver === "function") { watcher = new MutationObserver(follow); watcher.observe(app.dom.body, { childList: true, subtree: true }); watcher.observe(app.dom.body.ownerDocument.documentElement, { attributes: true, attributeFilter: ["class"] }); }
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 }),
@@ -52,6 +52,6 @@ export default {
else if (what === "tour.faq") show(STEPS.length - 1);
else if (what === "tour.stop") stop();
});
return () => { off(); app.has.delete("tour"); app.has.delete("tour.running"); };
return () => { off(); stop(); app.has.delete("tour"); app.has.delete("tour.running"); };
},
};
+3 -2
View File
@@ -12,6 +12,7 @@ export default {
modules: ["render"],
setup({ host, app }) {
const p = app.project, sel = app.selection;
const RETRIES = 480; // 15 s apart: two hours of a 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
@@ -61,7 +62,7 @@ export default {
else c.toast.good({ text: "Upscaled: the render now plays the larger video." });
} catch (err) {
if (err.stopped) c.toast.info({ text: "Upscale stopped. The render stays as it was." });
else if (!pressed && /^ComfyUI is generating/.test(err.message || "") && (waited.get(key) || 0) < 60) { // Always: not lost, tried again when the generation is over
else if (!pressed && /^ComfyUI is generating/.test(err.message || "") && (waited.get(key) || 0) < RETRIES) { // Always: not lost, tried again when the generation is over
if (!told) { told = true; c.toast.info({ text: "Upscale waits for the generation to finish." }); }
waited.set(key, (waited.get(key) || 0) + 1);
setTimeout(() => { // only for the same project, the same render and while Always is still on
@@ -69,7 +70,7 @@ export default {
else { waited.delete(key); if (!waited.size) told = false; }
}, 15000);
}
else { gaveUp(media, "upscale"); c.toast.warn({ text: `Upscale failed: ${err.message}` }); }
else { waited.delete(key); if (!waited.size) told = false; gaveUp(media, "upscale"); c.toast.warn({ text: `Upscale failed: ${err.message}` }); }
}
finally { busy.delete(key); heavy.delete(key); if (!heavy.size) app.has.delete("upscale.model"); draw(); }
}
+44 -1
View File
@@ -1,4 +1,4 @@
import test from "node:test";
import test, { mock } from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
@@ -33,3 +33,46 @@ test("the upscale-model flag is up while a model job runs and down after it, als
seen.push(has.has("upscale.model"));
assert.deepEqual(seen, [true, false]);
});
// "Always": a render that appears while ComfyUI is generating is tried again every 15 s, for the same project and render only.
function always(refused) {
const first = { filename: "a.mp4", subfolder: "", type: "output" };
const doc = { id: "P", scenes: [{ id: "a" }, { id: "b" }], scene_renders: { a: { media: first } }, editor_settings: { upscaleMode: "always", upscaleModel: "4x.safetensors" } };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null, edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc),
pref: (k, d) => (doc.editor_settings[k] ?? d), setPref() {}, };
let calls = 0, listener = () => {};
const api = { upscaleModels: async () => ({ models: ["4x.safetensors"], rtx: null }), upscale: async () => { calls += 1; throw new Error(refused); }, upscaleJob: async () => ({}), stopJob() {} };
const app = { dom: createDom(), project, selection: { focus: null, ids: [] }, has: new Set(), api, editorSettings: [], on: (fn) => { listener = fn; return () => {}; }, openSettings() {} };
hub.setup({ host: document.createElement("div"), app });
const render = (id, name) => { doc.scene_renders = { ...doc.scene_renders, [id]: { media: { filename: name, subfolder: "", type: "output" } } }; listener(); };
return { doc, render, calls: () => calls };
}
const flush = async () => { for (let i = 0; i < 6; i += 1) await Promise.resolve(); };
const BUSY = "ComfyUI is generating right now.";
test("Always tries a render again after a running generation, for as long as it is the same render", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { render, calls } = always(BUSY);
render("b", "b.mp4"); await flush();
assert.equal(calls(), 1);
mock.timers.tick(15000); await flush();
assert.equal(calls(), 2, "tried again");
for (let i = 0; i < 600; i += 1) { mock.timers.tick(15000); await flush(); }
assert.equal(calls(), 481, "one first try and 480 more, then it gives up");
} finally { mock.timers.reset(); }
});
test("Always does not try again when the render was replaced, the mode was switched off or another project is open", async () => {
for (const change of [(d) => { d.scene_renders = { a: d.scene_renders.a }; }, (d) => { d.editor_settings.upscaleMode = "never"; }]) {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { doc, render, calls } = always(BUSY);
render("b", "b.mp4"); await flush();
assert.equal(calls(), 1);
change(doc);
mock.timers.tick(15000); await flush();
assert.equal(calls(), 1);
} finally { mock.timers.reset(); }
}
});
+8
View File
@@ -17,3 +17,11 @@ test("each file is uploaded on its own, and a refused one is named without sinki
assert.deepEqual(out.media.map((m) => m.id), ["a.png", "b.png"]);
assert.match(out.problems[0], /big\.mov: HTTP 413/);
});
test("upscaleBusy asks the render module's busy route", async () => {
let seen;
globalThis.fetch = async (url, opts) => { seen = [url, (opts && opts.method) || "GET"]; return { ok: true, status: 200, json: async () => ({ busy: false }) }; };
await api.upscaleBusy();
assert.match(seen[0], /\/api\/m\/render\/upscale_busy$/);
assert.equal(seen[1], "GET");
});