Timeline header on one line; kit literals into tokens; Composer fits; running-state viewport test; remove Auto Montage
- Header: view toggles (Assets/Preview/Properties/Auto-hide) move to the top bar; progress, the prompt warning and scene actions (rate, upscale, interpolate) move to the row under the toolbar; the timeline head never wraps from 1000px up and never clips its controls. The top bar grows a line instead of spilling when it wraps. - Kit: every literal pixel size in element CSS is a token (the ratchet test is now zero); Composer's text box follows the viewport; the catalogue shows the component sizes. - test:viewport adds a fake ComfyUI queue and socket for the render-running state and asserts the header is one row. - Auto Montage removed (button, window, code, README/docs, tests). Saved projects holding pieces it made still load: the reused flag is kept. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d8a906a866
commit
aa31b95ea5
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
<p align="center"><img src="docs/images/wheel.jpg" alt="Picker wheel" width="900"></p>
|
||||
|
||||
### Auto Montage
|
||||
|
||||
Builds a new cut at the end of the timeline from your rendered clips. The originals stay as they are.
|
||||
|
||||
<p align="center"><img src="docs/images/montage.jpg" alt="Auto Montage" width="900"></p>
|
||||
|
||||
### 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.
|
||||
|
||||
+24
-3
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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(/(?<![\w.-])(?:[2-9]|\d{2,})(?:\.\d+)?px\b/g) || []).length; // 1px lines and 0 are borders and resets
|
||||
}
|
||||
}
|
||||
assert.ok(found <= LITERAL_PX, `${found} literal px values in element CSS (limit ${LITERAL_PX}): use a token from tokens.css`);
|
||||
if (found < LITERAL_PX) console.log(`lower LITERAL_PX to ${found}`);
|
||||
assert.equal(found, 0, `${found} literal px values in element CSS: use a --sp-* step or a component size from tokens.css`);
|
||||
});
|
||||
|
||||
@@ -86,6 +86,26 @@
|
||||
--win-margin: var(--sp-7); /* the room a window or popover keeps from the viewport edge */
|
||||
--lane-gutter-w: 56px; /* timeline: label column */
|
||||
--lane-h-video: 96px; --lane-h-audio: 44px; --lane-h-overlay: 40px;
|
||||
|
||||
/* ── component sizes ─────────────────────────────────────────────────
|
||||
What is not a step of the spacing scale but is still one number in one
|
||||
place. Element CSS writes no pixel value of its own (a 1px hairline is the
|
||||
only exemption, --bw): it names one of these or a --sp-* step. */
|
||||
--bw-2: 2px; /* emphasis line: a focus bar, a playhead, a spinner ring */
|
||||
--ring-w: 3px; /* a soft halo around a state dot */
|
||||
--blur-sm: 6px; --blur-md: 8px; --shadow-blur-lg: 48px;
|
||||
--mark-size: 5px; /* the accent dot before a panel title */
|
||||
--tick-w: 9px; --tick-h: 5px; /* the check mark */
|
||||
--ctl-icon-xs: 11px; --ctl-h-chip: 22px; --ctl-h-menu: 26px;
|
||||
--thumb-size: 14px; /* slider thumb */
|
||||
--toggle-w: 34px; /* switch track; its knob is --sp-7 */
|
||||
--slider-min-w: 80px; --list-control-w: 92px; --wheel-item-w: 70px; --skeleton-w: 90px;
|
||||
--tile-col: 190px; --tile-min-h: 84px; --cell-default: 96px;
|
||||
--cell-glyph-min: 18px; --cell-glyph-max: 34px;
|
||||
--field-min-w: 220px; --menu-min-w: 180px; --note-max-w: 260px; --split-pane-min: 260px;
|
||||
--dialog-w: 420px; --popover-max-w: 480px; --drawer-min-w: 268px; --drawer-max-w: 360px;
|
||||
--settings-rail-w: 232px; --modal-h: 660px; --settings-h: 680px;
|
||||
--window-field-max-h: 200px; --listbox-max-h: 200px; --textarea-max-h: 240px; --split-min-h: 120px;
|
||||
}
|
||||
|
||||
/* ── dark: the cutting-room palette (default) ─────────────────────────── */
|
||||
|
||||
@@ -5,7 +5,7 @@ const ZONES = [["assets", "Assets"], ["preview", "Preview"], ["properties", "Pro
|
||||
|
||||
export default {
|
||||
id: "dock",
|
||||
mount: "timeline.status",
|
||||
mount: "menubar.right",
|
||||
needs: ["dock"],
|
||||
setup({ host, app }) {
|
||||
const tabs = ZONES.map(([zone, label]) => {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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." },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
+19
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
+1
-1
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user