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:
digital-garbage
2026-11-06 14:28:45 +02:00
co-authored by Claude Sonnet 5.5
parent d8a906a866
commit aa31b95ea5
44 changed files with 174 additions and 300 deletions
+1
View File
@@ -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.
-7
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -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 },
];
+3 -3
View File
@@ -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;
}
+11 -11
View File
@@ -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); }
+7 -7
View File
@@ -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);
+6 -4
View File
@@ -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;
+10 -9
View File
@@ -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);
}
+1 -1
View File
@@ -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);
}
+10 -10
View File
@@ -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);
+1 -1
View File
@@ -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 */
+4 -4
View File
@@ -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; }
+8 -8
View File
@@ -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 -1
View File
@@ -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; }
+10 -10
View File
@@ -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; }
+5 -5
View File
@@ -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; } }
+2 -2
View File
@@ -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;
}
+3 -3
View File
@@ -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`);
});
+20
View File
@@ -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) ─────────────────────────── */
+1 -1
View File
@@ -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]) => {
+2 -2
View File
@@ -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: () => {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 }) {
-64
View File
@@ -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;
}
-66
View File
@@ -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);
});
-51
View File
@@ -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(); };
},
};
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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." },
];
+1 -1
View File
@@ -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
View File
@@ -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;
}
}
-1
View File
@@ -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",
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"),
+2 -2
View File
@@ -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
View File
@@ -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" }