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

Picker wheel

-### Auto Montage - -Builds a new cut at the end of the timeline from your rendered clips. The originals stay as they are. - -

Auto Montage

- ### 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" }