diff --git a/CHANGELOG.md b/CHANGELOG.md
index 737d3a2f..dd3209eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Changed
+- **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.
- **LoRA Loader shows one slot at a time.** With no LoRA picked it shows only the first picker. Once a LoRA is chosen its strengths (and stage) appear, together with the picker for the next slot, and so on up to eight. A slot that already holds a LoRA never hides.
- **Simpler Project and Scene settings.** The rarely used groups (Video input, Source trim, Final render size, Split preview) are folded under "More" and stay open once opened.
diff --git a/README.md b/README.md
index 8c783ecc..2d9c5369 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,6 @@ A rewrite from scratch, not an update of 4.x. Every feature is a module that ann
- **Models & Pipeline**: the pipeline as cards, not a graph. Pick files, swap or add nodes (any installed custom node), wire outputs to inputs, import a ComfyUI workflow. The server refuses what would not build and says why.
- **Modules**: switch each feature on or off in Settings. A pipeline runs without an optional node whose module is missing, and tells you so.
- **Learning from ratings**: rate a clip and the guidance, steering and prompt modules start acting on your taste once they have enough ratings.
-- **Auto Montage**: builds a new cut from your rendered clips; the originals stay untouched.
The full list of features, and what is proven and what is experimental, is in [CHANGELOG.md](CHANGELOG.md).
@@ -72,12 +71,6 @@ Press the middle mouse button anywhere and your most used actions fan out under

-### Auto Montage
-
-Builds a new cut at the end of the timeline from your rendered clips. The originals stay as they are.
-
-
-
### Train your own LoRA (add-on)
Teach the model your style from your own media and results you rated. Pick the data, describe each item, press Start. It is an add-on you switch on in Settings > Modules, and it has been run on MiniMax H3 only.
diff --git a/app/e2e/viewports.mjs b/app/e2e/viewports.mjs
index 42151eb9..4f6846af 100644
--- a/app/e2e/viewports.mjs
+++ b/app/e2e/viewports.mjs
@@ -1,11 +1,12 @@
// The app at the screens people really have, in a real browser: no page overflow, every window inside the viewport,
-// both timeline lanes visible. Not part of `npm test` (it needs browsers): run it with `npm run test:viewport`.
+// both timeline lanes visible, the timeline header on one row from 1000px wide, idle and while a render is running (a fake ComfyUI queue and socket). Not part of `npm test` (it needs browsers): run it with `npm run test:viewport`.
// FUNUI_BROWSERS=chromium,firefox picks the engines (default: every one that is installed).
import playwright from "playwright";
import { start } from "./stub.mjs";
const SIZES = [[1280, 720], [1024, 1024], [1366, 768], [1920, 1080], [2560, 1440], [768, 1024], [720, 1280], [1080, 1920]];
const WINDOWS = [["Settings", /^Settings$/], ["Log", /^Log$/], ["Composer", /Composer/], ["Sampler", /Sampler/], ["Training", /Training/]];
+const CLIENT = "viewport-test";
const wanted = (process.env.FUNUI_BROWSERS || "chromium,firefox").split(",");
const inspect = () => {
@@ -13,28 +14,48 @@ const inspect = () => {
const seen = [...document.querySelectorAll(".cx-modal, .cx-popover, .cx-window")].filter((e) => box(e).width > 0);
const out = seen.map((e) => ({ name: e.className.toString().split(" ")[0], r: box(e) }));
const nle = document.querySelector(".cx-nle"), lanes = [...document.querySelectorAll(".cx-nle-gutter-lane")];
+ const head = [...document.querySelectorAll(".cx-panel-head")].find((h) => h.textContent.startsWith("Timeline")), act = head && head.querySelector(".cx-panel-actions");
+ const header = innerWidth < 1000 || !head ? [] : [box(head).height > 50 && `Timeline header is ${Math.round(box(head).height)}px tall (more than one row)`, act.scrollWidth > act.clientWidth + 1 && `Timeline header clips ${act.scrollWidth - act.clientWidth}px of its controls`].filter(Boolean);
return {
+ header,
over: [document.documentElement.scrollWidth - innerWidth, document.documentElement.scrollHeight - innerHeight],
windows: out.filter(({ r }) => r.left < -1 || r.top < -1 || r.right > innerWidth + 1 || r.bottom > innerHeight + 1).map(({ name, r }) => `${name} ${Math.round(r.left)},${Math.round(r.top)}..${Math.round(r.right)},${Math.round(r.bottom)}`),
lanes: lanes.length < 2 ? ["fewer than two lanes"] : lanes.filter((l) => box(l).bottom > box(nle).bottom + 1).map((l) => `lane ends ${Math.round(box(l).bottom)}, timeline ${Math.round(box(nle).bottom)}`),
};
};
+// A render in progress, as ComfyUI would show it: the page's own client id in the queue, and the socket reporting step 4 of 5.
+async function fakeRunning(page) {
+ await page.addInitScript((id) => sessionStorage.setItem("funui.client-id", id), CLIENT);
+ await page.route("**/queue", (route) => route.fulfill({ json: { queue_pending: [], queue_running: [[1, "pid1", {}, { client_id: CLIENT, funui_scene_id: "s1", funui_scene_ids: ["s1"], funui_project_id: "p1" }, []]] } }));
+ await page.routeWebSocket(/\/ws\?/, (ws) => {
+ const say = (type, data) => ws.send(JSON.stringify({ type, data }));
+ setTimeout(() => { say("execution_start", { prompt_id: "pid1" }); say("progress_state", { prompt_id: "pid1", nodes: { 3: { state: "running", value: 4, max: 5, node_id: "3", display_node_id: "3" } } }); }, 300);
+ });
+}
+
const { server, url } = await start(0);
const problems = [];
let ran = 0;
for (const name of wanted) {
let browser;
try { browser = await playwright[name].launch(); } catch { console.log(`skipping ${name}: not installed`); continue; }
- for (const [w, h] of SIZES) {
+ for (const [w, h, running] of SIZES.flatMap(([w, h]) => [[w, h, false], [w, h, true]])) {
const page = await browser.newPage({ viewport: { width: w, height: h } });
- const note = (what) => problems.push(`${name} ${w}x${h}: ${what}`);
+ const note = (what) => problems.push(`${name} ${w}x${h}${running ? " running" : ""}: ${what}`);
+ if (running) await fakeRunning(page);
await page.goto(url);
await page.getByText(/Continue with/).click();
await page.waitForTimeout(800);
+ if (running) {
+ await page.waitForTimeout(700);
+ if (!(await page.getByText(/Step 4\/5/).count())) note("the running render's progress is not shown");
+ if (process.env.FUNUI_SHOTS) await page.screenshot({ path: `${process.env.FUNUI_SHOTS}/running-${name}-${w}x${h}.png` });
+ }
const main = await page.evaluate(inspect);
if (main.over[0] > 0 || main.over[1] > 0) note(`page overflows by ${main.over}`);
main.lanes.forEach((m) => note(m));
+ main.header.forEach((m) => note(m));
for (const [label, text] of WINDOWS) {
await page.getByText(text).first().click({ timeout: 1500 }).catch(() => note(`${label} cannot be opened`));
await page.waitForTimeout(500);
diff --git a/app/ui/catalogue/catalogue.js b/app/ui/catalogue/catalogue.js
index 0c2599a2..0d161f50 100644
--- a/app/ui/catalogue/catalogue.js
+++ b/app/ui/catalogue/catalogue.js
@@ -19,7 +19,8 @@ const GROUPS = [
{ id: "motion", title: "Motion", match: (n) => /^--dur-|^--ease-/.test(n), render: renderMotion },
{ id: "state", title: "States", match: (n) => /^--(bw|disabled-opacity)$/.test(n), render: renderState },
{ id: "density", title: "Density", match: (n) => n.startsWith("--cell-"), render: renderBar },
- { id: "layout", title: "Layout", match: (n) => /^--menubar-h$/.test(n), render: renderBar },
+ { id: "layout", title: "Layout", match: (n) => /^--(menubar-h|gutter|control-col)$/.test(n), render: renderBar },
+ { id: "size", title: "Component sizes", match: (n) => /^--(bw-2|ring-w|blur-|shadow-blur|mark-size|tick-|thumb-size|toggle-w|slider-|list-control-w|wheel-|skeleton-w|tile-|field-min-w|menu-min-w|note-max-w|split-|dialog-w|popover-max-w|drawer-|settings-|modal-h|listbox|textarea|window-field|win-margin|lane-)/.test(n), render: renderBar },
// Colour is last and greedy: anything left that a browser accepts as a colour.
{ id: "colour", title: "Colour", match: isColourName, render: renderSwatch },
];
diff --git a/app/ui/composer/elements/button.css b/app/ui/composer/elements/button.css
index 233f9d98..98b3cb48 100644
--- a/app/ui/composer/elements/button.css
+++ b/app/ui/composer/elements/button.css
@@ -11,7 +11,7 @@
.cx-btn-xl { height: var(--ctl-h-xl); padding: 0 var(--ctl-pad-x-xl); font-size: var(--fs-4); }
.cx-btn-lg { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-lg); font-size: var(--fs-5); }
.cx-btn-md { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-5); }
-.cx-btn-menu { height: 26px; padding: 0 var(--sp-4); font-size: var(--fs-5); }
+.cx-btn-menu { height: var(--ctl-h-menu); padding: 0 var(--sp-4); font-size: var(--fs-5); }
.cx-btn-sm { height: var(--ctl-h-sm); padding: 0 var(--ctl-pad-x-sm); font-size: var(--fs-6); }
.cx-btn:hover:not(:disabled) { border-color: var(--line-hover); background: var(--hover); }
@@ -72,8 +72,8 @@
}
.cx-btn-spin {
- width: 11px; height: 11px; border-radius: var(--r-pill);
- border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
+ width: var(--ctl-icon-xs); height: var(--ctl-icon-xs); border-radius: var(--r-pill);
+ border: var(--bw-2) solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
animation: cx-spin 800ms linear infinite;
}
diff --git a/app/ui/composer/elements/choice.css b/app/ui/composer/elements/choice.css
index 1f790584..e1ea71ae 100644
--- a/app/ui/composer/elements/choice.css
+++ b/app/ui/composer/elements/choice.css
@@ -7,15 +7,15 @@
.cx-check { display: inline-flex; }
.cx-check-box {
- width: 16px; height: 16px; flex: none;
+ width: var(--sp-7); height: var(--sp-7); flex: none;
border: var(--bw) solid var(--line); border-radius: var(--r-2);
background: var(--elev-2);
display: inline-flex; align-items: center; justify-content: center;
transition: background var(--dur-1) var(--ease-standard), border-color var(--dur-1) var(--ease-standard);
}
.cx-check-box::after {
- content: ""; width: 9px; height: 5px;
- border-left: 2px solid var(--on-accent); border-bottom: 2px solid var(--on-accent);
+ content: ""; width: var(--tick-w); height: var(--tick-h);
+ border-left: var(--bw-2) solid var(--on-accent); border-bottom: var(--bw-2) solid var(--on-accent);
transform: rotate(-45deg) translateY(-1px) scale(.6);
opacity: 0; transition: opacity var(--dur-1) var(--ease-standard), transform var(--dur-1) var(--ease-standard);
}
@@ -23,7 +23,7 @@
.cx-check-input:checked + .cx-check-face .cx-check-box::after { opacity: 1; transform: rotate(-45deg) translateY(-1px) scale(1); }
.cx-check-input:indeterminate + .cx-check-face .cx-check-box { background: var(--accent); border-color: var(--accent); }
.cx-check-input:indeterminate + .cx-check-face .cx-check-box::after {
- opacity: 1; width: 8px; height: 0; border-bottom: 2px solid var(--on-accent);
+ opacity: 1; width: var(--sp-4); height: 0; border-bottom: var(--bw-2) solid var(--on-accent);
border-left: 0; transform: none;
}
.cx-check-input:focus-visible + .cx-check-face .cx-check-box { border-color: var(--accent); box-shadow: var(--focus-ring); }
@@ -47,7 +47,7 @@
.cx-check-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
.cx-checklist {
display: flex; flex-direction: column;
- max-height: 200px; overflow-y: auto;
+ max-height: var(--listbox-max-h); overflow-y: auto;
border: var(--bw) solid var(--line); border-radius: var(--r-3);
padding: var(--sp-3) var(--sp-4);
}
@@ -55,12 +55,12 @@
.cx-radio-group { display: flex; gap: var(--sp-4); }
.cx-radio-vertical { flex-direction: column; gap: var(--sp-2); }
.cx-radio-dot {
- width: 16px; height: 16px; flex: none; border-radius: var(--r-pill);
+ width: var(--sp-7); height: var(--sp-7); flex: none; border-radius: var(--r-pill);
border: var(--bw) solid var(--line); background: var(--elev-2);
display: inline-flex; align-items: center; justify-content: center;
}
.cx-radio-dot::after {
- content: ""; width: 8px; height: 8px; border-radius: var(--r-pill);
+ content: ""; width: var(--sp-4); height: var(--sp-4); border-radius: var(--r-pill);
background: var(--accent); transform: scale(0);
transition: transform var(--dur-1) var(--ease-standard);
}
@@ -92,17 +92,17 @@
.cx-toggle-track {
- width: 34px; height: 20px; flex: none; border-radius: var(--r-pill);
+ width: var(--toggle-w); height: var(--sp-8); flex: none; border-radius: var(--r-pill);
background: var(--line); position: relative;
transition: background var(--dur-1) var(--ease-standard);
}
.cx-toggle-knob {
- position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
+ position: absolute; top: var(--sp-1); left: var(--sp-1); width: var(--sp-7); height: var(--sp-7);
border-radius: var(--r-pill); background: var(--elev-1);
transition: transform var(--dur-1) var(--ease-standard);
}
.cx-toggle-input:checked + .cx-check-face .cx-toggle-track { background: var(--accent); }
-.cx-toggle-input:checked + .cx-check-face .cx-toggle-track .cx-toggle-knob { transform: translateX(14px); }
+.cx-toggle-input:checked + .cx-check-face .cx-toggle-track .cx-toggle-knob { transform: translateX(calc(var(--toggle-w) - var(--sp-7) - 2 * var(--sp-1))); }
.cx-toggle-input:focus-visible + .cx-check-face .cx-toggle-track { box-shadow: var(--focus-ring); }
/* Colour: the swatch sized to the control height so it lines up with every
@@ -124,7 +124,7 @@
cursor: pointer;
}
-.cx-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
+.cx-swatch::-webkit-color-swatch-wrapper { padding: var(--sp-1); }
.cx-swatch::-webkit-color-swatch { border: 0; border-radius: var(--r-2); }
.cx-swatch::-moz-color-swatch { border: 0; border-radius: var(--r-2); }
diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css
index a50fffd9..7a2e1a0f 100644
--- a/app/ui/composer/elements/gallery.css
+++ b/app/ui/composer/elements/gallery.css
@@ -2,7 +2,7 @@
* 280px panel however wide the window is. */
.cx-gallery-wrap { container-type: inline-size; width: 100%; }
.cx-gallery {
- display: grid; gap: clamp(6px, 2.5cqw, 12px);
+ display: grid; gap: clamp(var(--sp-3), 2.5cqw, var(--sp-6));
grid-template-columns: repeat(auto-fill, minmax(var(--cell, var(--cell-thumb)), 1fr));
}
.cx-gallery[data-cols="1"] { grid-template-columns: repeat(1, minmax(0, 1fr)); }
@@ -23,7 +23,7 @@
display: flex; align-items: center; justify-content: center;
}
.cx-cell-img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.cx-cell-glyph { font-size: clamp(18px, calc(var(--cell, 96px) * .3), 34px); color: var(--faint); }
+.cx-cell-glyph { font-size: clamp(var(--cell-glyph-min), calc(var(--cell, var(--cell-default)) * .3), var(--cell-glyph-max)); color: var(--faint); }
.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);
@@ -31,10 +31,10 @@
}
.cx-cell-badge { left: var(--sp-1); }
.cx-cell-duration { right: var(--sp-1); font-variant-numeric: tabular-nums; }
-.cx-cell-flags { position: absolute; top: 2px; right: 2px; display: flex; gap: var(--sp-1); pointer-events: none; }
-.cx-cell-flag { font-size: 11px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: var(--r-1); background: var(--accent); color: var(--void); }
+.cx-cell-flags { position: absolute; top: var(--sp-1); right: var(--sp-1); display: flex; gap: var(--sp-1); pointer-events: none; }
+.cx-cell-flag { font-size: var(--fs-7); font-weight: 700; line-height: 1; padding: var(--sp-1) var(--sp-2); border-radius: var(--r-1); background: var(--accent); color: var(--void); }
.cx-cell-name {
- font-size: clamp(11px, calc(var(--cell, 96px) * .115), 13px);
+ font-size: clamp(var(--fs-7), calc(var(--cell, var(--cell-default)) * .115), var(--fs-4));
line-height: var(--lh-snug); padding: 0 var(--sp-3) var(--sp-3);
text-align: left; display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
@@ -62,7 +62,7 @@
.cx-media-row:hover { border-color: var(--line-hover); background: var(--elev-2); }
.cx-media-row.cx-on { border-color: var(--accent); background: var(--accent-soft); }
.cx-media-row-thumb {
- position: relative; flex: none; inline-size: 40px; aspect-ratio: 16 / 10;
+ position: relative; flex: none; inline-size: var(--sp-11); aspect-ratio: 16 / 10;
display: flex; align-items: center; justify-content: center;
background: var(--void); border-radius: var(--r-1); overflow: hidden;
}
@@ -96,7 +96,7 @@
/* A floor and a ceiling. A clip grows with its length (flex-grow, set by the
element), and one clip in a project must not become the whole strip.
--strip-w is the zoom level, set on .cx-strip itself and inherited. */
- flex: none; width: var(--strip-w, 72px); min-inline-size: var(--strip-w, 72px);
+ flex: none; width: var(--strip-w, var(--cell-thumb)); min-inline-size: var(--strip-w, var(--cell-thumb));
max-inline-size: var(--cell-panel);
aspect-ratio: 16 / 10; padding: 0;
border: var(--bw) solid var(--line); border-radius: var(--r-2);
diff --git a/app/ui/composer/elements/input.css b/app/ui/composer/elements/input.css
index cdbdb9ca..2bc42881 100644
--- a/app/ui/composer/elements/input.css
+++ b/app/ui/composer/elements/input.css
@@ -30,14 +30,16 @@
font-size: var(--fs-4); line-height: var(--lh-snug);
resize: vertical; min-height: var(--ctl-h-xl);
}
+/* In a window the box shares the room with its tabs, hint and folds: it gives way as the screen gets shorter, so the window does not scroll inside itself. */
+.cx-window .cx-textarea { max-block-size: min(28dvh, var(--window-field-max-h)); }
.cx-autogrow { resize: none; overflow: hidden; }
.cx-fold { min-height: var(--ctl-h-md); height: var(--ctl-h-md); padding: var(--sp-2) var(--ctl-pad-x-sm); font-size: var(--fs-5); }
-.cx-fold:not(:focus) { padding-block: 0; line-height: calc(var(--ctl-h-md) - 2px); } /* folded: the first line centred, the rest fully out of sight */
+.cx-fold:not(:focus) { padding-block: 0; line-height: calc(var(--ctl-h-md) - var(--bw-2)); } /* folded: the first line centred, the rest fully out of sight */
.cx-search { appearance: none; }
.cx-search::-webkit-search-cancel-button { filter: grayscale(1) opacity(.6); }
-.cx-filter { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 220px; }
+.cx-filter { display: flex; flex-direction: column; gap: var(--sp-3); min-width: var(--field-min-w); }
/* filterList always builds its own search box, which is right everywhere it
is the ONLY way to narrow the list -- and wrong stacked under a search box
that already re-queries the server on every keystroke: two boxes doing the
@@ -48,7 +50,7 @@
.cx-node-picker-results .cx-filter > .cx-search { display: none; }
.cx-filter-list {
display: flex; flex-direction: column; gap: 1px;
- max-height: 240px; overflow-y: auto; overflow-x: hidden;
+ max-height: var(--textarea-max-h); overflow-y: auto; overflow-x: hidden;
border: var(--bw) solid var(--line); border-radius: var(--r-3);
background: var(--elev-1); padding: var(--sp-1);
}
@@ -78,7 +80,7 @@
kit; there is no path from item data to a raw colour. */
.cx-filter-icon {
display: inline-flex; align-items: center; justify-content: center;
- width: 22px; height: 22px; flex: 0 0 auto; border-radius: var(--r-2);
+ width: var(--ctl-h-chip); height: var(--ctl-h-chip); flex: 0 0 auto; border-radius: var(--r-2);
background: var(--elev-3); color: var(--text); font-size: var(--fs-6); line-height: 1;
/* Optical, against the label's cap-height, not the row's own top edge. */
margin-block-start: 1px;
diff --git a/app/ui/composer/elements/layout.css b/app/ui/composer/elements/layout.css
index ef8e645f..e6d89bbd 100644
--- a/app/ui/composer/elements/layout.css
+++ b/app/ui/composer/elements/layout.css
@@ -104,7 +104,7 @@
/* One accent mark per panel. Carried over from v4, where it is what makes a
head read as the top of something rather than as a strip of grey. */
.cx-panel-title::before {
- content: ""; flex: none; inline-size: 5px; block-size: 5px;
+ content: ""; flex: none; inline-size: var(--mark-size); block-size: var(--mark-size);
border-radius: var(--r-pill); background: var(--accent);
}
/* Wraps, like the head around it. The head was made to wrap when a zone got
@@ -167,7 +167,7 @@
.cx-list-row:last-child { border-bottom: 0; }
.cx-list-label { font-size: var(--fs-4); flex: 1; min-width: 0; }
.cx-list-hint { font-size: var(--fs-7); color: var(--muted); }
-.cx-list-control { flex: none; width: 92px; }
+.cx-list-control { flex: none; width: var(--list-control-w); }
/* Separated from the row's own control by a rule, so the arrows read as
* "reorder this row" rather than "adjust that number". */
.cx-list-actions {
@@ -187,7 +187,7 @@
}
.cx-tabs-underline { border-bottom: var(--bw) solid var(--line); gap: var(--sp-5); }
.cx-tabs-underline .cx-tab {
- padding: var(--sp-3) 0; border-bottom: 2px solid transparent; margin-bottom: -1px;
+ padding: var(--sp-3) 0; border-bottom: var(--bw-2) solid transparent; margin-bottom: -1px;
}
.cx-tabs-underline .cx-tab.cx-on { color: var(--text); border-bottom-color: var(--accent); }
.cx-tabs-dock .cx-tab {
@@ -198,7 +198,7 @@
.cx-tabs-dock .cx-tab.cx-on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.cx-tab:hover { color: var(--text); }
-.cx-split { display: flex; width: 100%; min-height: 120px; min-inline-size: 0; }
+.cx-split { display: flex; width: 100%; min-height: var(--split-min-h); min-inline-size: 0; }
.cx-split-v { flex-direction: column; }
/* A column, so what is inside a pane inherits the pane's height instead of
sizing to its own content. A panel in a pane was the right size with a body
@@ -217,7 +217,7 @@
a 360px viewport. Wrapping rather than a media query because the kit sizes
itself from its container, not from the window. */
.cx-split-h { flex-wrap: wrap; }
-.cx-split-h > .cx-split-pane { min-inline-size: min(100%, 260px); }
+.cx-split-h > .cx-split-pane { min-inline-size: min(100%, var(--split-pane-min)); }
.cx-split-h > .cx-split-pane:first-child { max-inline-size: 100%; }
.cx-split-bar { flex: none; background: transparent; position: relative; }
.cx-split-h > .cx-split-bar { width: var(--gutter); cursor: col-resize; }
@@ -267,7 +267,7 @@
}
.cx-rail-item:hover { background: var(--hover); color: var(--text); }
.cx-rail-item.cx-on { background: var(--accent-soft); color: var(--accent-text); }
-.cx-rail-icon { width: 18px; text-align: center; }
+.cx-rail-icon { width: var(--ctl-h-icon); text-align: center; }
/* Workspace: a centre that shrinks, panels docked either side.
The rails hold the toggles and never collapse, so the control for a panel is
@@ -292,7 +292,7 @@
heading broke mid-word. A panel is as wide as a settings row needs; when
the window cannot afford two of them it stops docking them (below). */
flex: 0 0 auto;
- inline-size: clamp(268px, 24vw, 360px);
+ inline-size: clamp(var(--drawer-min-w), 24vw, var(--drawer-max-w));
min-block-size: 0;
overflow: auto;
display: flex; flex-direction: column;
@@ -349,7 +349,8 @@
button floating above the first panel. */
.cx-frame-head {
display: flex; align-items: center;
- block-size: var(--menubar-h); padding-inline: var(--gutter);
+ /* A minimum, so a bar whose items wrap in a narrow window grows a line instead of spilling over the panels. */
+ min-block-size: var(--menubar-h); padding-inline: var(--gutter); padding-block: var(--sp-1);
border-block-end: var(--bw) solid var(--line); background: var(--grad-band);
}
.cx-frame-head > * { flex: 1 1 auto; min-inline-size: 0; }
@@ -375,7 +376,7 @@
position: absolute;
inset-block: 0;
z-index: 1;
- inline-size: min(86vw, 360px);
+ inline-size: min(86vw, var(--drawer-max-w));
background: var(--elev-1);
box-shadow: var(--shadow-3);
}
diff --git a/app/ui/composer/elements/nle.css b/app/ui/composer/elements/nle.css
index 62ab9db9..3624a175 100644
--- a/app/ui/composer/elements/nle.css
+++ b/app/ui/composer/elements/nle.css
@@ -55,5 +55,5 @@
.cx-nle-playhead {
position: absolute; inset-block: 0; inline-size: 0; z-index: 2; pointer-events: none;
- border-inline-start: 2px solid var(--accent-hi);
+ border-inline-start: var(--bw-2) solid var(--accent-hi);
}
diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css
index a6e36cd0..4833a1fe 100644
--- a/app/ui/composer/elements/overlay.css
+++ b/app/ui/composer/elements/overlay.css
@@ -27,7 +27,7 @@
* whole app painted twice, side by side, until a window resize forced a
* repaint. */
.cx-modal-stacked {
- box-shadow: 0 20px 48px color-mix(in srgb, var(--shadow-ink) 90%, transparent);
+ 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); } }
@@ -36,7 +36,7 @@
itself grows and shrinks on every click, which nothing in a real System
Settings window does. A real height, not a max, holds the frame still;
.cx-modal-body's own scroll takes the rest. */
-.cx-settings-modal { height: min(660px, 90vh); } /* v4's own window size, verbatim */
+.cx-settings-modal { height: min(var(--modal-h), 90vh); } /* v4's own window size, verbatim */
.cx-modal-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-5);
@@ -87,11 +87,11 @@
repositioning could bring it back: anchor.js can move a box, it cannot
shrink one. Measured at 1436px inside a 1280px viewport. */
max-height: 60vh; overflow-y: auto;
- max-inline-size: min(92vw, 480px);
+ max-inline-size: min(92vw, var(--popover-max-w));
overflow-wrap: anywhere;
animation: cx-pop var(--dur-1) var(--ease-standard);
}
-.cx-popover-menu { min-width: 180px; }
+.cx-popover-menu { min-width: var(--menu-min-w); }
/* a popover holding a panel (settings, fields), not a list of items, needs room to the sides */
.cx-popover[role="dialog"] { padding: var(--sp-5) var(--sp-6); }
@@ -107,7 +107,7 @@
.cx-menu-item.cx-danger { color: var(--danger); }
.cx-menu-label { flex: 1; }
.cx-menu-hint { font-size: var(--fs-7); color: var(--faint); }
-.cx-menu-icon { width: 16px; text-align: center; color: var(--muted); }
+.cx-menu-icon { width: var(--sp-7); text-align: center; color: var(--muted); }
.cx-menu-sep { height: 1px; background: var(--line); margin: var(--sp-2) 0; }
.cx-menu-heading { padding: var(--sp-2) var(--sp-4); font-size: var(--fs-7); letter-spacing: .06em; text-transform: uppercase; color: var(--faint); }
@@ -117,10 +117,10 @@
border: var(--bw) solid var(--line); border-radius: var(--r-2);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-6); box-shadow: var(--shadow-2);
- max-width: 260px;
+ max-width: var(--note-max-w);
}
-.cx-autocomplete { min-width: 220px; inline-size: min(92vw, 420px); padding: var(--sp-1); }
+.cx-autocomplete { min-width: var(--field-min-w); inline-size: min(92vw, var(--dialog-w)); padding: var(--sp-1); }
/* a suggestion is one line: the trigger, then as much of its prompt as fits */
.cx-autocomplete .cx-menu-label { flex: none; max-inline-size: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-autocomplete .cx-menu-hint { flex: 1 1 0; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -151,7 +151,7 @@
.cx-window-body { flex: 1; overflow: auto; padding: var(--sp-5); }
.cx-rolled .cx-window-body, .cx-rolled .cx-window-grip { display: none; }
.cx-window-grip {
- position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
+ position: absolute; right: 0; bottom: 0; width: var(--sp-7); height: var(--sp-7);
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, var(--line) 50%, var(--line) 60%, transparent 60%);
}
@@ -167,13 +167,13 @@
.cx-slideover-layer { align-items: stretch; }
.cx-slideover {
position: relative; margin-left: auto;
- width: min(420px, 92vw); display: flex; flex-direction: column;
+ width: min(var(--dialog-w), 92vw); display: flex; flex-direction: column;
background: var(--elev-1); border-left: var(--bw) solid var(--line);
box-shadow: var(--shadow-3);
animation: cx-slide-in var(--dur-2) var(--ease-standard);
}
.cx-slideover-left { margin-left: 0; margin-right: auto; border-left: 0; border-right: var(--bw) solid var(--line); }
-@keyframes cx-slide-in { from { transform: translateX(12px); opacity: 0; } }
+@keyframes cx-slide-in { from { transform: translateX(var(--sp-6)); opacity: 0; } }
.cx-slideover-head {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-5) var(--sp-6); border-bottom: var(--bw) solid var(--line);
diff --git a/app/ui/composer/elements/polish.css b/app/ui/composer/elements/polish.css
index 03f31f48..9d7c5740 100644
--- a/app/ui/composer/elements/polish.css
+++ b/app/ui/composer/elements/polish.css
@@ -45,7 +45,7 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; }
.cx-filter-list { border-color: var(--line); border-radius: var(--r-4); }
/* hairlines are quieter, rows roomier */
-.cx-segmented { border-color: transparent; background: var(--elev-2); border-radius: var(--r-4); padding: 2px; }
+.cx-segmented { border-color: transparent; background: var(--elev-2); border-radius: var(--r-4); padding: var(--sp-1); }
.cx-segmented .cx-segment { border-radius: var(--r-3); }
/* timeline: quiet clips, the selected one speaks */
diff --git a/app/ui/composer/elements/settings.css b/app/ui/composer/elements/settings.css
index 02344a20..ad48f9ad 100644
--- a/app/ui/composer/elements/settings.css
+++ b/app/ui/composer/elements/settings.css
@@ -1,10 +1,10 @@
/* The Settings window, after macOS System Settings / About: a sidebar that stays open, a large title,
* and rows grouped into rounded inset cards -- label on the left, value on the right, hairlines between. */
-.cx-settings-modal { height: min(680px, 92vh); }
+.cx-settings-modal { height: min(var(--settings-h), 92vh); }
.cx-settings-modal .cx-modal-body { padding: 0; }
-.cx-settings-body.cx-stack { display: grid; grid-template-columns: 232px minmax(0, 1fr); block-size: 100%; min-block-size: 0; }
+.cx-settings-body.cx-stack { display: grid; grid-template-columns: var(--settings-rail-w) minmax(0, 1fr); block-size: 100%; min-block-size: 0; }
/* sidebar */
.cx-settings-rail {
@@ -15,7 +15,7 @@
.cx-settings-rail .cx-search { inline-size: 100%; }
.cx-settings-rail .cx-filter-list { gap: var(--sp-1); }
.cx-settings-rail .cx-filter-row { gap: var(--sp-5); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-4); font-size: var(--fs-4); }
-.cx-settings-rail .cx-filter-icon { inline-size: 24px; block-size: 24px; border-radius: 6px; font-size: var(--fs-5); margin: 0; }
+.cx-settings-rail .cx-filter-icon { inline-size: var(--sp-9); block-size: var(--sp-9); border-radius: var(--sp-3); font-size: var(--fs-5); margin: 0; }
.cx-settings-rail .cx-filter-row.cx-on { background: var(--accent); color: var(--on-accent); }
.cx-settings-rail .cx-filter-row.cx-on .cx-filter-label { font-weight: var(--fw-medium); }
.cx-settings-rail .cx-filter-gap { block-size: var(--sp-5); }
@@ -34,7 +34,7 @@
/* a run of rows (and switches) is one card */
:is(.cx-settings-content, .cx-modal-body) .cx-stack > :is(.cx-settings-row, .cx-toggle-row) {
background-color: var(--elev-2); border: 0; border-radius: var(--r-5);
- padding: var(--sp-5) var(--sp-7); min-block-size: 40px; display: flex; align-items: center;
+ padding: var(--sp-5) var(--sp-7); min-block-size: var(--sp-11); display: flex; align-items: center;
}
:is(.cx-settings-content, .cx-modal-body) .cx-stack > .cx-toggle-row > label { inline-size: 100%; display: flex; }
:is(.cx-settings-content, .cx-modal-body) .cx-stack > :is(.cx-settings-row, .cx-toggle-row):has(+ :is(.cx-settings-row, .cx-toggle-row)) { border-end-start-radius: 0; border-end-end-radius: 0; }
diff --git a/app/ui/composer/elements/slider.css b/app/ui/composer/elements/slider.css
index 14575810..7c3520a2 100644
--- a/app/ui/composer/elements/slider.css
+++ b/app/ui/composer/elements/slider.css
@@ -2,29 +2,29 @@
.cx-range {
appearance: none; -webkit-appearance: none;
- flex: 1; min-width: 80px; height: 18px; background: transparent; cursor: pointer;
+ flex: 1; min-width: var(--slider-min-w); height: var(--ctl-h-icon); background: transparent; cursor: pointer;
/* --fill is written by the element as the value changes, so the filled part
* of the track follows the theme's accent without JS naming a colour. */
--fill: 0%;
}
.cx-range::-webkit-slider-runnable-track {
- height: 4px; border-radius: var(--r-pill);
+ height: var(--sp-2); border-radius: var(--r-pill);
background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
}
.cx-range::-moz-range-track {
- height: 4px; border-radius: var(--r-pill);
+ height: var(--sp-2); border-radius: var(--r-pill);
background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
}
.cx-range::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
- width: 14px; height: 14px; margin-top: -5px;
+ width: var(--thumb-size); height: var(--thumb-size); margin-top: -5px;
border-radius: var(--r-pill); background: var(--elev-1);
- border: 2px solid var(--accent);
+ border: var(--bw-2) solid var(--accent);
transition: transform var(--dur-1) var(--ease-standard);
}
.cx-range::-moz-range-thumb {
- width: 14px; height: 14px; border-radius: var(--r-pill);
- background: var(--elev-1); border: 2px solid var(--accent);
+ width: var(--thumb-size); height: var(--thumb-size); border-radius: var(--r-pill);
+ background: var(--elev-1); border: var(--bw-2) solid var(--accent);
}
.cx-range:hover::-webkit-slider-thumb { transform: scale(1.12); }
.cx-range:focus-visible { outline: none; }
@@ -32,7 +32,7 @@
.cx-range:focus-visible::-moz-range-thumb { box-shadow: var(--focus-ring); }
.cx-range:disabled { opacity: var(--disabled-opacity); cursor: default; }
-.cx-slider-sm .cx-range { height: 14px; }
+.cx-slider-sm .cx-range { height: var(--thumb-size); }
.cx-readout {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
diff --git a/app/ui/composer/elements/stage.css b/app/ui/composer/elements/stage.css
index 723f6a17..d1979f0a 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: 10px; block-size: 10px; background: var(--accent); border: var(--bw) solid #000; cursor: nwse-resize; border-radius: var(--r-1); }
+.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); }
:has(> .cx-stage) { position: relative; }
diff --git a/app/ui/composer/elements/status.css b/app/ui/composer/elements/status.css
index d704c7b1..3b294559 100644
--- a/app/ui/composer/elements/status.css
+++ b/app/ui/composer/elements/status.css
@@ -4,13 +4,13 @@
border: var(--bw) solid var(--line); border-radius: var(--r-pill);
font-size: var(--fs-6); color: var(--muted); white-space: nowrap;
}
-.cx-chip-dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: currentColor; }
+.cx-chip-dot { width: var(--sp-3); height: var(--sp-3); border-radius: var(--r-pill); background: currentColor; }
.cx-chip-good { color: var(--good); border-color: color-mix(in srgb, var(--good) 30%, var(--line)); }
.cx-chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 30%, var(--line)); }
.cx-chip-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
.cx-chip-info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
-.cx-dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--muted); display: inline-block; flex: none; }
+.cx-dot { width: var(--sp-4); height: var(--sp-4); border-radius: var(--r-pill); background: var(--muted); display: inline-block; flex: none; }
.cx-dot-good { background: var(--good); }
.cx-dot-warn { background: var(--warn); }
.cx-dot-danger { background: var(--danger); }
@@ -33,7 +33,7 @@
.cx-inline-error { font-size: var(--fs-6); color: var(--danger); }
.cx-progress {
- width: 100%; height: 4px; border-radius: var(--r-pill);
+ width: 100%; height: var(--sp-2); border-radius: var(--r-pill);
background: var(--line); overflow: hidden;
}
.cx-progress-fill {
@@ -47,12 +47,12 @@
.cx-spinner {
display: inline-block; border-radius: var(--r-pill);
- border: 2px solid color-mix(in srgb, var(--muted) 30%, transparent);
+ border: var(--bw-2) solid color-mix(in srgb, var(--muted) 30%, transparent);
border-top-color: var(--accent);
animation: cx-spin 800ms linear infinite;
}
-.cx-spinner-md { width: 16px; height: 16px; }
-.cx-spinner-sm { width: 12px; height: 12px; }
+.cx-spinner-md { width: var(--sp-7); height: var(--sp-7); }
+.cx-spinner-sm { width: var(--sp-6); height: var(--sp-6); }
.cx-empty {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
@@ -80,9 +80,9 @@
background-size: 200% 100%;
animation: cx-shimmer 1.4s linear infinite;
}
-.cx-skeleton-line .cx-skeleton-cell { height: 10px; }
+.cx-skeleton-line .cx-skeleton-cell { height: var(--sp-5); }
.cx-skeleton-line .cx-skeleton-cell:last-child { width: 60%; }
-.cx-skeleton-block .cx-skeleton-cell { height: 44px; }
+.cx-skeleton-block .cx-skeleton-cell { height: var(--lane-h-audio); }
.cx-skeleton-grid .cx-skeleton-cell { aspect-ratio: 16 / 10; }
@keyframes cx-shimmer { to { background-position: -200% 0; } }
@@ -101,7 +101,7 @@
.cx-toast-good { border-color: color-mix(in srgb, var(--good) 45%, var(--line)); }
.cx-toast-warn { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.cx-toast-danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); }
-@keyframes cx-rise { from { opacity: 0; transform: translateY(8px); } }
+@keyframes cx-rise { from { opacity: 0; transform: translateY(var(--sp-4)); } }
.cx-progress-inline { display: inline-flex; align-items: center; gap: var(--sp-4); min-inline-size: 0; }
-.cx-progress-inline .cx-progress { inline-size: 90px; flex: none; }
+.cx-progress-inline .cx-progress { inline-size: var(--skeleton-w); flex: none; }
diff --git a/app/ui/composer/elements/tile.css b/app/ui/composer/elements/tile.css
index 0ace8c41..73ad9fd0 100644
--- a/app/ui/composer/elements/tile.css
+++ b/app/ui/composer/elements/tile.css
@@ -1,19 +1,19 @@
.cx-grid { display: grid !important; gap: var(--sp-5); }
.cx-grid-pair { grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
-.cx-grid-cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
+.cx-grid-cards { grid-template-columns: repeat(auto-fill, minmax(var(--tile-col), 1fr)); }
.cx-tile { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.cx-tile:hover { background: var(--hover); }
.cx-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
.cx-tile-sub { font-size: var(--fs-6); color: var(--muted); }
-.cx-tile-node { gap: var(--sp-2); min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); }
+.cx-tile-node { gap: var(--sp-2); min-block-size: var(--tile-min-h); padding: var(--sp-6) var(--sp-6) var(--sp-5); }
.cx-tile-node .cx-tile-title { font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
.cx-tile-node .cx-tile-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
-.cx-tile-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
-.cx-tile-state.cx-tile-needs { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); }
+.cx-tile-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: var(--sp-4); block-size: var(--sp-4); border-radius: var(--r-pill); background: var(--good); }
+.cx-tile-state.cx-tile-needs { background: var(--warn); box-shadow: 0 0 0 var(--ring-w) color-mix(in srgb, var(--warn) 25%, transparent); }
-.cx-tile-add { align-items: center; justify-content: center; min-block-size: 84px; background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
+.cx-tile-add { align-items: center; justify-content: center; min-block-size: var(--tile-min-h); background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
.cx-tile-add .cx-tile-title { font-weight: var(--fw-medium); }
@media (max-width: 1150px) { .cx-hint-low { display: none; } }
diff --git a/app/ui/composer/elements/track.css b/app/ui/composer/elements/track.css
index e8f29e39..5a7969c9 100644
--- a/app/ui/composer/elements/track.css
+++ b/app/ui/composer/elements/track.css
@@ -23,7 +23,7 @@
insetInlineStart in JS: a clip under the pixel floor must not push every
clip after it out of step with the ruler and the playhead, which both
stay in pure seconds*pxPerSecond regardless of any floor. */
-.cx-track-clips { position: relative; min-inline-size: 100%; block-size: 45px; }
+.cx-track-clips { position: relative; min-inline-size: 100%; block-size: var(--lane-h-audio); }
.cx-track-clip {
position: absolute; inset-block: 0; padding: 0;
border: var(--bw) solid var(--line); border-radius: var(--r-2);
@@ -48,6 +48,6 @@
read as "this is where you are" the way a line through both does. */
.cx-track-playhead {
position: absolute; inset-block: 0; inline-size: 0;
- border-inline-start: 2px solid var(--accent-hi);
+ border-inline-start: var(--bw-2) solid var(--accent-hi);
pointer-events: none; z-index: 1;
}
diff --git a/app/ui/composer/elements/wheel.css b/app/ui/composer/elements/wheel.css
index 971bdc8f..2f0436e7 100644
--- a/app/ui/composer/elements/wheel.css
+++ b/app/ui/composer/elements/wheel.css
@@ -5,7 +5,7 @@
position: fixed; pointer-events: auto; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--elev-3) 92%, transparent);
box-shadow: var(--shadow-3), inset 0 0 0 var(--bw) var(--line);
- backdrop-filter: blur(6px);
+ backdrop-filter: blur(var(--blur-sm));
animation: cx-wheel-in var(--dur-1) var(--ease-standard);
}
@keyframes cx-wheel-in { from { opacity: 0; transform: scale(.9); } }
@@ -24,7 +24,7 @@
.cx-wheel-item {
position: absolute; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-1);
- width: 70px; padding: var(--sp-1) 0; line-height: 1.15; overflow-wrap: anywhere; /* long names wrap inside the ring instead of sticking out of it */
+ width: var(--wheel-item-w); padding: var(--sp-1) 0; line-height: 1.15; overflow-wrap: anywhere; /* long names wrap inside the ring instead of sticking out of it */
background: transparent; border: 0; color: var(--muted);
font-size: var(--fs-7); cursor: pointer; text-align: center;
transition: color var(--dur-1) var(--ease-standard),
@@ -59,4 +59,4 @@
/* Half wheels sit ON the edge: the off-screen half is simply clipped by the
* viewport, which is what gives the edge-panel silhouette. The flat side gets a
* straight edge so it reads as pinned rather than cropped by accident. */
-.cx-wheel-half { backdrop-filter: blur(8px); }
+.cx-wheel-half { backdrop-filter: blur(var(--blur-md)); }
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 8ccdaef4..07a37f6c 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
@@ -1,6 +1,7 @@
// Sizes and gaps come from the scale in tokens.css (--sp-*, --ctl-h-*, --lane-*, ...), not from literals in element
-// rules: a literal is a number nobody can change in one place. This is a ratchet: the count of literal pixel values
-// in element CSS may go down, never up. Lowering it is the point; when it does, lower LITERAL_PX here too.
+// rules: a literal is a number nobody can change in one place. There are none left. Exempt by design: a 1px hairline
+// (that is --bw's own value), 0, token definitions (a line starting `--`, where a number belongs), and the width in a
+// @media / @container condition (a custom property cannot be used there; the breakpoints are listed in tokens.css).
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
@@ -8,17 +9,15 @@ import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "elements");
-const LITERAL_PX = 118;
-test("element CSS does not grow new literal pixel sizes", () => {
+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 decl of css.split(/[;{}]/)) {
- if (/^\s*--/.test(decl)) continue; // a token definition is where a number belongs
+ if (/^\s*(--|@media|@container)/.test(decl)) continue; // a token definition is where a number belongs; a breakpoint cannot use one
found += (decl.match(/(? {
diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js
index f64108c2..c421c16d 100644
--- a/app/ui/features/generate/index.js
+++ b/app/ui/features/generate/index.js
@@ -141,7 +141,7 @@ export default {
if (kept === false) tell(`Scene ${snap.scenes.findIndex((s) => s.id === root.id) + 1} was removed while it was being made, so its result is not on the timeline. It is in ComfyUI's output folder as ${images[images.length - 1].filename}.`);
snap.scene_renders = structuredClone(p.project.scene_renders || {}); // ...and in what the next shot reads
}
- if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out, a video clip or an Auto Montage piece.");
+ if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out or a video clip.");
} finally { busy = false; draw(); }
}
app.runner.units = (units, times) => runUnits(units, times);
@@ -206,7 +206,7 @@ export default {
if (key === drawn) return; // progress ticks must not rebuild the buttons
drawn = key;
const working = busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start
- const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, title: "Generate every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces. An earlier render of a scene stays as a take.",
+ const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, title: "Generate every scene on the timeline, except left-out scenes and video clips. An earlier render of a scene stays as a take.",
onClick: () => runUnits(unitsOf(p.scenes)) });
const one = c.button.sm({ label: picked > 1 ? `Selected (${picked})` : "Selected", title: "Generate only the picked scenes", disabled: working || !picked, onClick: () => runUnits(unitsOf(pickedScenes())) });
const stop = c.button.sm({ label: "■ Stop", tone: "danger", title: "Stop the run that is going, and any Upscale or Interpolate in progress", onClick: () => {
diff --git a/app/ui/features/generate/progress.js b/app/ui/features/generate/progress.js
index 85a7e3ac..c7af96c6 100644
--- a/app/ui/features/generate/progress.js
+++ b/app/ui/features/generate/progress.js
@@ -6,7 +6,7 @@ export const elapsed = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); r
export default {
id: "generate-progress",
- mount: "timeline.actions",
+ mount: "timeline.toolbar.end",
needs: ["generate"],
setup({ host, app }) {
const g = app.generate;
diff --git a/app/ui/features/generate/warn.js b/app/ui/features/generate/warn.js
index bba438de..9373c42c 100644
--- a/app/ui/features/generate/warn.js
+++ b/app/ui/features/generate/warn.js
@@ -4,7 +4,7 @@ import { composer as c } from "../../composer/composer.js";
export default {
id: "generate-warn",
- mount: "timeline.actions",
+ mount: "timeline.toolbar.end",
needs: ["pipeline"],
setup({ host, app }) {
const chip = c.button.sm({ label: "⚠ Nothing encodes your prompt", tone: "ghost", disabled: true,
diff --git a/app/ui/features/interpolate/index.js b/app/ui/features/interpolate/index.js
index c5605f60..701c8df6 100644
--- a/app/ui/features/interpolate/index.js
+++ b/app/ui/features/interpolate/index.js
@@ -7,7 +7,7 @@ import { DEFAULTS, RTX, interpolate, modeLabel } from "./job.js";
export default {
id: "interpolate",
- mount: "timeline.actions",
+ mount: "timeline.toolbar.end",
needs: ["project", "api", "selection"],
modules: ["interpolate"],
setup({ host, app }) {
diff --git a/app/ui/features/montage/build.js b/app/ui/features/montage/build.js
deleted file mode 100644
index 84593f4d..00000000
--- a/app/ui/features/montage/build.js
+++ /dev/null
@@ -1,64 +0,0 @@
-// Auto Montage, trailer-style: the lead clip is cut into segments that shrink as it goes, and after each one a segment from a
-// pool of other rendered clips is cut in. Every piece reuses media that already exists and is its own unit (`reused`): Generate never
-// makes it and it never lengthens the clip it was copied from. Pure.
-import { trackFor } from "../../shell/audio.js";
-import { snapFrames, effFps, seconds, isVideoClip } from "../../shell/scenes.js";
-
-const newId = () => "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
-const clone = (x) => JSON.parse(JSON.stringify(x));
-
-/** What a clip can play from: {fps, from (seconds into its file), total (seconds), render?} or null when it has nothing yet. */
-export function playable(sc, p) {
- const fps = effFps(sc, p);
- if (seconds(sc, p) * fps < 9) return null; // shorter than the shortest piece the model can make
- if (isVideoClip(sc)) return sc.source && sc.source.media_ref ? { fps, from: sc.source_in || 0, total: seconds(sc, p) } : null;
- const r = (p.scene_renders || {})[sc.id];
- return r && r.media ? { fps, from: (r.inSec || 0) + (sc.source_in || 0), total: seconds(sc, p), render: r } : null;
-}
-
-/** Cut a clip's length into [{start, frames}] (frames), each `base * decay^i` long (decay 1 = all equal), rounded DOWN to the frame grid so no piece reaches past the clip or overlaps the next. */
-export function chop(totalFrames, base, decay = 1) {
- const out = [];
- for (let at = 0, i = 0; at < totalFrames - 8; i += 1) {
- const frames = snapFrames(Math.min(totalFrames - at, Math.max(9, Math.round(base * decay ** i))), "floor");
- out.push({ start: at, frames });
- at += frames;
- }
- return out;
-}
-
-/** Append the montage to `p` (the scenes, and their renders). -> how many clips were added (0 = nothing could be built). */
-export function build(p, { leadId, poolIds, segmentFrames = 100, decay = 1, random = Math.random }) {
- const find = (id) => p.scenes.find((s) => s.id === id);
- const lead = find(leadId), leadSrc = lead && playable(lead, p);
- const pool = (poolIds || []).map(find).filter((s) => s && playable(s, p));
- if (!leadSrc || !pool.length) return 0;
- const base = Math.max(9, Math.round(segmentFrames)), dk = Math.min(1, Math.max(0.3, decay));
- const leadSegs = chop(snapFrames(leadSrc.total * leadSrc.fps, "floor"), base, dk);
- // Each pool clip is its own queue read front to back: chance only picks WHICH clip to draw from next.
- const queues = pool.map((sc) => ({ sc, src: playable(sc, p), cursor: 0, chunks: chop(snapFrames(playable(sc, p).total * playable(sc, p).fps, "floor"), base, 1) })).filter((q) => q.chunks.length);
- if (!leadSegs.length || !queues.length) return 0;
- const next = () => {
- let open = queues.filter((q) => q.cursor < q.chunks.length);
- if (!open.length) { queues.forEach((q) => { q.cursor = 0; }); open = queues; } // every clip used up: each starts over from its own beginning
- const q = open[Math.floor(random() * open.length)];
- return { sc: q.sc, src: q.src, ...q.chunks[q.cursor++] };
- };
- const piece = (sc, src, start, frames) => {
- const out = clone(sc), at = start / src.fps;
- const id = newId();
- Object.assign(out, { id, gen_unit_id: id, reused: true, cut_offset_frames: 0, frames, rating: "", transition_to_next: "cut", transition_frames: null, frames_mode: "timeline", excluded: false, audio_separated: false, audio_volume: sc.audio_separated ? (trackFor(p, sc.id) || {}).volume ?? 1 : sc.audio_volume });
- if (isVideoClip(sc)) Object.assign(out, { source_in: src.from + at, source_dur: frames / src.fps });
- else {
- out.source_in = 0; // the render entry below already starts where this piece does
- p.scene_renders = p.scene_renders || {};
- p.scene_renders[out.id] = { media: src.render.media, inSec: src.from + at, renderPrompt: src.render.renderPrompt ? { ...src.render.renderPrompt } : null };
- }
- return out;
- };
- const built = [];
- leadSegs.forEach((seg) => { built.push(piece(lead, leadSrc, seg.start, seg.frames)); const c = next(); built.push(piece(c.sc, c.src, c.start, c.frames)); });
- built[built.length - 1].transition_to_next = "";
- p.scenes.push(...built);
- return built.length;
-}
diff --git a/app/ui/features/montage/build.test.js b/app/ui/features/montage/build.test.js
deleted file mode 100644
index 160317b4..00000000
--- a/app/ui/features/montage/build.test.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { build, chop, playable } from "./build.js";
-import { isGenerative, genUnitId } from "../../shell/scenes.js";
-import { removeScene } from "../../shell/edits.js";
-
-const media = (n) => ({ filename: `${n}.mp4`, subfolder: "", type: "output" });
-const project = () => ({ num_frames_per_scene: 97, frame_rate: 25,
- scenes: [{ id: "L", text: "lead" }, { id: "A", text: "a" }, { id: "B", text: "b" }, { id: "N", text: "no render" }],
- scene_renders: { L: { media: media("L") }, A: { media: media("A"), inSec: 1 }, B: { media: media("B") } } });
-
-test("chop: segments shrink by the decay, never below 9 frames, and stop within the length (the last few frames may be left over)", () => {
- const segs = chop(97, 40, 0.5);
- assert.ok(segs.every((s) => s.frames >= 9));
- assert.ok(segs.every((s, i) => i === 0 || s.start === segs[i - 1].start + segs[i - 1].frames), "pieces butt up exactly: no overlap, no gap");
- assert.ok(segs.every((s) => s.start + s.frames <= 97), "no piece reaches past the clip");
- assert.equal(chop(97, 100, 1).length, 1);
-});
-
-test("build: lead pieces alternate with pool pieces, each pool clip is read front to back, renders are reused", () => {
- const p = project();
- const n = build(p, { leadId: "L", poolIds: ["A"], segmentFrames: 33, decay: 1, random: () => 0 });
- const added = p.scenes.slice(4);
- assert.equal(n, added.length);
- assert.equal(added.length % 2, 0);
- assert.ok(added.every((s) => s.reused && s.gen_unit_id === s.id && !isGenerative(s)), "every piece is its own unit that Generate never makes");
- assert.ok(!p.scenes.slice(0, 4).some((s) => genUnitId(s) !== s.id), "the originals gain no unit mates, so Generate cannot lengthen them");
- const fromL = added.filter((_, i) => i % 2 === 0);
- assert.deepEqual(fromL.map((s) => p.scene_renders[s.id].inSec).slice(0, 2), [0, 33 / 25], "the lead plays in order");
- const fromA = added.filter((_, i) => i % 2 === 1);
- assert.deepEqual(fromA.map((s) => p.scene_renders[s.id].inSec).slice(0, 2), [1, 1 + 33 / 25], "A is read in order");
- assert.equal(p.scene_renders[fromA[1].id].inSec, 1 + 33 / 25, "a piece plays from where it starts in A's render");
- assert.equal(p.scene_renders[fromA[1].id].media.filename, "A.mp4");
- assert.equal(added[added.length - 1].transition_to_next, "");
- assert.ok(added.every((s) => s.frames_mode === "timeline" && s.source_in === 0));
-
- const before = added.map((s) => [s.id, p.scene_renders[s.id].inSec]);
- p.scenes.find((s) => s.id === "L").frames_mode = "timeline";
- removeScene(p, "L");
- assert.deepEqual(added.map((s) => [s.id, p.scene_renders[s.id].inSec]), before, "deleting the original leaves the montage as it was");
-});
-
-test("build: nothing is built without a rendered lead and a rendered pool; the project is left alone", () => {
- const p = project(), before = JSON.stringify(p);
- assert.equal(build(p, { leadId: "N", poolIds: ["A"] }), 0);
- assert.equal(build(p, { leadId: "L", poolIds: ["N"] }), 0);
- assert.equal(JSON.stringify(p), before);
-});
-
-test("build: deleting a piece leaves no ghost, and an off-grid clip is never read past its end", () => {
- const p = project();
- p.scene_renders.L = { media: media("L") };
- p.scenes[0].frames = 103; p.scenes[0].frames_mode = "timeline"; // 103 is off the 8k+1 grid; its nearest grid length, 105, is past the end
- build(p, { leadId: "L", poolIds: ["A"], segmentFrames: 33, decay: 1, random: () => 0 });
- const lastLead = p.scenes.filter((s) => s.reused).filter((_, i) => i % 2 === 0).pop();
- assert.ok(p.scene_renders[lastLead.id].inSec * 25 + lastLead.frames <= 103, "no piece plays past the clip");
- const piece = p.scenes.find((s) => s.reused);
- removeScene(p, piece.id);
- assert.equal((p.scene_ghosts || []).some((g) => g.id === piece.id), false);
-});
-
-test("playable: a clip shorter than the shortest piece offers nothing to cut", () => {
- const p = project();
- p.scenes[1].frames = 5; p.scenes[1].frames_mode = "timeline";
- assert.equal(playable(p.scenes[1], p), null);
-});
diff --git a/app/ui/features/montage/index.js b/app/ui/features/montage/index.js
deleted file mode 100644
index 48d77ad0..00000000
--- a/app/ui/features/montage/index.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// Auto Montage: the button in the timeline header and its window.
-import { offer } from "../../shell/actions.js";
-import { composer as c } from "../../composer/composer.js";
-import { snapFrames } from "../../shell/scenes.js";
-import { playable, build } from "./build.js";
-
-const snippet = (sc) => { const t = (sc.text || "").trim().replace(/\s+/g, " "); return t ? (t.length > 36 ? `${t.slice(0, 36)}…` : t) : "(no prompt)"; };
-
-export default {
- id: "montage",
- mount: "timeline.actions",
- needs: ["project", "selection"],
- setup({ host, app }) {
- const p = app.project, sel = app.selection;
- const button = c.button.sm({ label: "⚡ Auto Montage", tone: "ghost", disabled: true, onClick: ask });
- host.append(button.node);
- const clips = () => (p.project ? p.scenes.filter((s) => !s.excluded && playable(s, p.project)) : []);
-
- function ask() {
- const list = clips();
- if (list.length < 2) return c.toast.warn({ text: "Auto Montage needs at least two rendered clips on the timeline: one lead and at least one cutaway." });
- const picked = list.filter((s) => sel.ids.includes(s.id));
- let lead = (picked[0] || list[0]).id;
- const pool = new Set(picked.slice(1).map((s) => s.id));
- const leadFrames = Math.round(playable(picked[0] || list[0], p.project).total * playable(picked[0] || list[0], p.project).fps);
- let seg = Math.max(9, snapFrames(leadFrames / 2.5, "floor")), decay = 0.85; // a handful of lead pieces, not one lump and not a flicker
- const label = (s) => `${snippet(s)} — ${Math.round(playable(s, p.project).total * playable(s, p.project).fps)}f`;
- const body = c.region.stack({ gap: "md" });
- const draw = () => body.set([
- c.field.default({ label: "Lead clip (plays in order)", control: c.select.md({ label: "Lead clip", value: lead, options: list.map((s) => ({ value: s.id, label: label(s) })), onChange: (v) => { lead = v; pool.delete(v); draw(); } }) }),
- c.field.default({ label: "Cutaway pool (random draw, in order within each clip)", control: c.checklist.default({ label: "Cutaway pool", values: [...pool],
- items: list.filter((s) => s.id !== lead).map((s) => ({ value: s.id, label: label(s) })), onChange: (v) => { pool.clear(); v.forEach((x) => pool.add(x)); } }) }),
- c.field.default({ label: "Segment length (frames)", hint: "How long the lead clip's first piece and each cutaway piece are, in frames. Rounded down to 9, 17, 25 ... frames.", control: c.number.md({ label: "Segment length", value: seg, min: 9, step: 1, precision: 0, onChange: (v) => { seg = v; } }) }),
- c.field.default({ label: "Acceleration", hint: "1.0 = equal segments; lower = the lead clip's segments get shorter toward the end", control: c.slider.readout({ label: "Acceleration", value: decay, min: 0.5, max: 1, step: 0.05, onCommit: (v) => { decay = v; } }) }),
- ]);
- draw();
- const win = c.modal.generic({ title: "Auto Montage", subtitle: "Adds a new cut at the end of the timeline: the lead clip in pieces (shorter and shorter, set by Acceleration) with pieces of the cutaway clips between them. Your original clips stay as they are.", size: "md", body });
- win.setFooter({ actions: [c.button.sm({ label: "Build montage", tone: "primary", onClick: () => {
- if (!pool.size) return c.toast.warn({ text: "Pick at least one cutaway clip." });
- const added = p.edit((pr) => build(pr, { leadId: lead, poolIds: [...pool], segmentFrames: seg, decay }) || false);
- win.close("done");
- c.toast[added ? "good" : "warn"]({ text: added ? `Added ${added} clips to the end of the timeline.` : "Could not build the montage: the clips need more rendered length." });
- } })] });
- }
- const draw = () => button.setDisabled(!p.project);
- draw();
- const offered = offer(app, { id: "auto-montage", label: "Auto Montage", icon: "⚡", run: () => { if (!button.node.disabled) ask(); } });
- const off = app.on(draw);
- return () => { off(); offered(); };
- },
-};
diff --git a/app/ui/features/peek/index.js b/app/ui/features/peek/index.js
index 3891d7ad..df1b5bb2 100644
--- a/app/ui/features/peek/index.js
+++ b/app/ui/features/peek/index.js
@@ -6,7 +6,7 @@ const KEY = "funui_timeline_peek";
export default {
id: "peek",
- mount: "timeline.status",
+ mount: "menubar.right",
needs: ["peek"],
setup({ host, app }) {
let on = false;
diff --git a/app/ui/features/rating/index.js b/app/ui/features/rating/index.js
index 5b86abe3..989b98dd 100644
--- a/app/ui/features/rating/index.js
+++ b/app/ui/features/rating/index.js
@@ -16,7 +16,7 @@ export function notTaught(r, ps) {
export default {
id: "rating",
- mount: "timeline.actions",
+ mount: "timeline.toolbar.end",
needs: ["project", "api", "selection", "pipeline"],
setup({ host, app }) {
const p = app.project, sel = app.selection;
diff --git a/app/ui/features/tour/index.js b/app/ui/features/tour/index.js
index 0c038565..04a19538 100644
--- a/app/ui/features/tour/index.js
+++ b/app/ui/features/tour/index.js
@@ -10,7 +10,7 @@ const STEPS = [
{ 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:◆ 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:▶ Generate", side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces; an earlier render of a scene stays as a take. “Generate this scene” in Properties makes just the picked one; rate a result so FunUI learns your taste." },
+ { 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. “Generate this scene” in Properties makes just the picked one; rate a result so FunUI learns your taste." },
{ 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." },
];
diff --git a/app/ui/features/upscale/index.js b/app/ui/features/upscale/index.js
index 79536688..4e3b9c61 100644
--- a/app/ui/features/upscale/index.js
+++ b/app/ui/features/upscale/index.js
@@ -7,7 +7,7 @@ import { RTX, RTX_DEFAULTS, keepsSize, keyOf, qualityLabel, swap, upscale } from
export default {
id: "upscale",
- mount: "timeline.actions",
+ mount: "timeline.toolbar.end",
needs: ["project", "api", "selection"],
modules: ["render"],
setup({ host, app }) {
diff --git a/app/ui/frame.css b/app/ui/frame.css
index ba90a397..890792cd 100644
--- a/app/ui/frame.css
+++ b/app/ui/frame.css
@@ -2,7 +2,7 @@
:root { --media-w: clamp(180px, 20vw, 280px); --properties-w: clamp(270px, 28vw, 360px); --timeline-h: clamp(340px, 42vh, 380px); --pad: 10px; --pgap: 10px; }
.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(360px, 44vh, 400px); } }
+@media (max-width: 1250px) { :root { --timeline-h: clamp(380px, 44vh, 440px); } }
.fp-row { flex: 1 1 auto; flex-direction: row; min-block-size: 0; gap: var(--pgap); }
.fp-row > *, .fp-main > :nth-child(2) { min-inline-size: 0; border: var(--bw) solid var(--line); border-radius: var(--card-r); overflow: hidden; background: var(--elev-1); }
.fp-row > :first-child { flex: 0 0 var(--media-w); }
@@ -49,3 +49,21 @@
: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; }
+
+/* The timeline's header is ONE line wherever its controls can physically fit (about 1000px and up). It never spreads
+ its controls apart: the room goes to the text that can give way (hints, the step counter), which truncates, and
+ below the widest layouts a button that has an icon shows only the icon (its label stays for screen readers). */
+@media (min-width: 1000px) {
+ .fp-main > :nth-child(2) > .cx-panel-head { flex-wrap: nowrap; column-gap: var(--sp-3); }
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-panel-actions { flex: 1 1 0; flex-wrap: nowrap; min-inline-size: 0; overflow: hidden; }
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-panel-status { flex: none; flex-wrap: nowrap; gap: var(--sp-2); }
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-toolbar { flex-wrap: nowrap; min-inline-size: 0; }
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-panel-actions > .cx-toolbar { flex: 0 1 auto; }
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-btn, .fp-main > :nth-child(2) > .cx-panel-head .cx-chip { flex: none; }
+ .fp-main > :nth-child(2) > .cx-panel-head :is(.cx-hint, .cx-t) { flex: 0 1 auto; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+}
+@media (min-width: 1000px) and (max-width: 1500px) {
+ .fp-main > :nth-child(2) > .cx-panel-head .cx-btn:has(.cx-btn-icon) .cx-btn-label {
+ position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
+ }
+}
diff --git a/app/ui/modules.js b/app/ui/modules.js
index 7f602aec..ae9f4d9f 100644
--- a/app/ui/modules.js
+++ b/app/ui/modules.js
@@ -36,7 +36,6 @@ export default [
"./features/preview/lanes.js",
"./features/preview/peek.js",
"./features/preview/fx.js",
- "./features/montage/index.js",
"./features/generate/warn.js",
"./features/generate/progress.js",
"./features/continuity/index.js",
diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js
index 7631be1d..6b651cca 100644
--- a/app/ui/shell/edits.js
+++ b/app/ui/shell/edits.js
@@ -36,7 +36,7 @@ function ghostOrDrop(p, sc, afterId, pending) {
const renders = p.scene_renders || (p.scene_renders = {});
const r = renders[sc.id];
delete renders[sc.id];
- if (sc.reused) return; // a montage piece is a copy: nothing to regenerate, so nothing to leave behind
+ if (sc.reused) return; // a reused copy is a copy: nothing to regenerate, so nothing to leave behind
p.scene_ghosts = (p.scene_ghosts || []).filter((g) => g.id !== sc.id);
if (!(r && r.media) && !pending) return;
p.scene_ghosts.push({
diff --git a/app/ui/shell/scenes.js b/app/ui/shell/scenes.js
index 10e3ac25..92e3885a 100644
--- a/app/ui/shell/scenes.js
+++ b/app/ui/shell/scenes.js
@@ -7,7 +7,7 @@ export const DRIVEN = { frames: "num_frames_per_scene", fps: "frame_rate" };
export const genUnitId = (sc) => (sc && (sc.gen_unit_id || sc.id)) || "";
export const isVideoClip = (sc) => Boolean(sc && sc.source && sc.source.type === "video");
-export const isGenerative = (sc) => Boolean(sc) && !isVideoClip(sc) && !sc.reused; // a montage piece only replays media that already exists
+export const isGenerative = (sc) => Boolean(sc) && !isVideoClip(sc) && !sc.reused; // a reused copy (from older projects) only replays media that already exists
/** A timeline cut of one generated clip: only its root owns the prompt, rating and source. */
export const isSubclip = (sc) => Boolean(sc) && (sc.cut_offset_frames || 0) > 0;
diff --git a/core/projects.py b/core/projects.py
index 334fb928..09dd9079 100644
--- a/core/projects.py
+++ b/core/projects.py
@@ -229,7 +229,7 @@ class Scene:
#: (cut_offset_frames == 0) owns prompt, rating and source.
gen_unit_id: str | None = None
cut_offset_frames: int = 0
- #: A copy of already-rendered media (Auto Montage pieces): never generated, never part of another clip's unit.
+ #: A copy of already-rendered media (copies made by an older version's Auto Montage, still loaded from saved projects): never generated, never part of another clip's unit.
reused: bool = False
guides: list = field(default_factory=list)
#: Slip edit: where in the generated media this clip starts and how long it runs.
diff --git a/core/suggestions.py b/core/suggestions.py
index c3989a3f..184557db 100644
--- a/core/suggestions.py
+++ b/core/suggestions.py
@@ -120,7 +120,7 @@ def stats() -> dict:
if k < b:
votes.setdefault((k, b), []).append((when, v))
# Habits: what the cut uses now.
- if sc.excluded or sc.reused or not (sc.text or "").strip(): # a montage piece replays a shot, it is not a new one
+ if sc.excluded or sc.reused or not (sc.text or "").strip(): # a reused copy replays a shot, it is not a new one
continue
present = fired(sc.text)
scanned += 1
diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py
index 9e14b15e..aba743f2 100644
--- a/core/tests/test_projects.py
+++ b/core/tests/test_projects.py
@@ -576,8 +576,8 @@ def test_timeline_fields_are_checked_not_trusted():
assert s.guides == [{"k": 1}] and s.gen_unit_id is None and s.excluded is False
-def test_a_montage_piece_stays_reused_through_a_save():
- """Auto Montage marks its copies `reused`; losing the flag on save would let Generate remake them."""
+def test_a_reused_copy_stays_reused_through_a_save():
+ """Copies made by the removed Auto Montage carry `reused`; losing the flag on save would let Generate remake them."""
s = projects.Scene.from_dict({"id": "p1", "gen_unit_id": "p1", "reused": True})
assert s.reused is True and projects.Scene.from_dict(__import__("dataclasses").asdict(s)).reused is True
assert projects.Scene.from_dict({"id": "x", "reused": "yes"}).reused is False
diff --git a/core/tests/test_suggestions.py b/core/tests/test_suggestions.py
index 95526b49..4e606535 100644
--- a/core/tests/test_suggestions.py
+++ b/core/tests/test_suggestions.py
@@ -44,7 +44,7 @@ def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_orde
assert s["follows"] == [["neon", "rain", 1]]
-def test_auto_montage_pieces_are_not_shots_the_user_wrote():
+def test_reused_copies_are_not_shots_the_user_wrote():
for n in ("rain", "neon"):
shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]})
scenes = [projects.Scene(id="a", text="rain"), projects.Scene(id="b", text="neon"),
diff --git a/docs/how-to-start.md b/docs/how-to-start.md
index c078d09c..000f4a2d 100644
--- a/docs/how-to-start.md
+++ b/docs/how-to-start.md
@@ -43,7 +43,7 @@ Click **Composer** and write all scenes in one box, separated by the cut word (s
## 6. Generate
-- **▶ Generate** makes every scene on the timeline (not left-out scenes, video clips or Auto Montage pieces). An earlier render of a scene stays as a take.
+- **▶ Generate** makes every scene on the timeline (not left-out scenes or video clips). An earlier render of a scene stays as a take.
- **Selected** makes only the picked scene. **Takes ×N** makes the same scene several times, differing only by seed.
- No picture needed: on H3, a scene with no source image is generated from text alone.
@@ -51,7 +51,7 @@ Watch the Log window if nothing happens: any module that fails says why there, a
## 7. Cut, render, export
-Trim and split clips on the timeline. **⚡ Auto Montage** builds a new cut at the end from your rendered clips; your originals stay as they are. **⧉ Render** joins the timeline into one file; **⤓ Export** downloads the picked clip (picture only: a clip whose sound was separated is silent there, Render mixes it).
+Trim and split clips on the timeline. **⧉ Render** joins the timeline into one file; **⤓ Export** downloads the picked clip (picture only: a clip whose sound was separated is silent there, Render mixes it).
## 8. Teach it your taste (optional)
diff --git a/docs/images/montage.jpg b/docs/images/montage.jpg
deleted file mode 100644
index a3af9e52..00000000
Binary files a/docs/images/montage.jpg and /dev/null differ
diff --git a/pyproject.toml b/pyproject.toml
index 505e842f..178ab6f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation.
-description = "An AI movie studio inside ComfyUI: a browser-based studio with timeline, per-scene prompts and one-click montage generation, plus a self-improving engine that learns from your ratings. Every feature is a module, so a broken one is simply absent."
+description = "An AI movie studio inside ComfyUI: a browser-based studio with timeline, per-scene prompts and one-click generation, plus a self-improving engine that learns from your ratings. Every feature is a module, so a broken one is simply absent."
version = "5.1.0" # Custom Node version. Must be semantically versioned.
# Release codename ("Classy Chopper" for 5.x) lives in core/update.py:CODENAMES, keyed by major, shown in Settings > About.
license = { text = "GNU GPL v3" }