feat(ui): Composer window, Simple mode, Welcome, draggable splitters + Reset Layout

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 21:59:46 +03:00
co-authored by Claude Sonnet 5.5
parent 326f3a145a
commit b59d90ebae
12 changed files with 221 additions and 9 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ import { linkPipeline } from "./shell/pipeline_link.js";
import { api } from "./shell/api.js";
import { createSelection } from "./shell/selection.js";
import { createPlayhead } from "./shell/playhead.js";
import { createMode } from "./shell/mode.js";
import { hostFor } from "./shell/mounts.js";
import paths from "./modules.js";
@@ -24,7 +25,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
const app = { project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme };
const app = { project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, resetLayout: frame.resetLayout };
const settings = createSettings({ menubar: hostFor("menubar.menus") });
const result = await loadFeatures(paths, { app });
settings.addButton();
+5 -2
View File
@@ -124,7 +124,7 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
function draw() {
disposers.forEach((d) => d());
disposers = [];
const span = spanOf();
const span = Math.max(spanOf(), ((node.clientWidth || 0) - 96) / px); // the ruler runs to the window's edge, as an editor's does
content.style.width = `${span * px}px`;
drawRuler(span);
gutter.replaceChildren(el("div", { cls: "cx-nle-gutter-ruler" }));
@@ -144,6 +144,9 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
});
draw();
// The ruler follows the width it is given.
let observer = null;
if (typeof ResizeObserver !== "undefined") { observer = new ResizeObserver(() => draw()); observer.observe(node); }
return {
node,
get span() { return spanOf(); },
@@ -152,6 +155,6 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
setLanes(next = []) { lanes = next; draw(); },
setZoom(next) { px = Math.max(4, next); draw(); },
setPlayhead(seconds) { playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`; },
destroy() { disposers.forEach((d) => d()); node.remove(); },
destroy() { disposers.forEach((d) => d()); if (observer) observer.disconnect(); node.remove(); },
};
});
+35
View File
@@ -0,0 +1,35 @@
// ◆ Composer: the floating "prompt craft" window. Opens from the timeline header; its tabs are the five v4 had.
import { composer as c } from "../../composer/composer.js";
const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }];
const inert = (label, tone = "ghost") => c.button.sm({ label, tone, disabled: true });
const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built yet", hint: what });
const story = (p) => c.region.stack({ gap: "sm", children: [
c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: true, options: [{ value: "", label: "Templates…" }], value: "" })], trailing: [inert("Save")] }),
c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [inert("+ Add shortcut"), inert("💡")] }),
c.textarea.md({ label: "Story", rows: 14, value: p.scenes.map((s) => s.text || "").join("\n\n") }),
c.hint.default({ text: "Edits apply to the scenes as you type, and scene edits show here. The anchor is its own field. Shortcuts expand at generation time." }),
c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }),
] });
const sheets = { story, shortcuts: () => later("Your trigger → replacement library."), cuts: () => later("Where a story splits into shots."),
enhance: () => later("Rewrite a prompt with a language model."), chat: () => later("Talk a scene through with the enhancer.") };
export default {
id: "composer",
mount: "timeline.status",
needs: ["project"],
setup({ host, app }) {
let win = null;
const open = () => {
if (win) return;
const body = c.region.stack({ gap: "sm", fill: true });
const show = (tab) => body.set([c.tabs.underline({ label: "Composer", tabs: TABS, value: tab, onChange: show }), sheets[tab](app.project)]);
show("story");
win = c.floating.window({ id: "composer", title: "Composer", subtitle: "prompt craft", body, width: 420, height: 520, x: 320, y: 90,
onClose: () => { win = null; } });
};
host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node);
},
};
+4 -2
View File
@@ -30,6 +30,8 @@ export default {
add: () => { const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); },
del: () => sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))),
left: () => move(-1), right: () => move(1),
reset: () => app.resetLayout(),
welcome: () => app.say("welcome.open"),
exclude: () => { const sc = p.selected; if (sc) p.setScene(sc.id, "excluded", !sc.excluded); },
};
const menus = {
@@ -44,8 +46,8 @@ export default {
{ id: "add", label: "Add Scene", hint: "+", disabled: !p.project }, { id: "del", label: "Delete Scene", disabled: !sel.ids.length }, { separator: true },
{ id: "left", label: "Move Clip Left", hint: "timeline cut", disabled: !sel.focus }, { id: "right", label: "Move Clip Right", hint: "timeline cut", disabled: !sel.focus }, { separator: true },
{ id: "exclude", label: "Toggle Exclude", disabled: !sel.focus }],
View: () => [soon("Refresh Preview", ""), soon("Reset Layout", "")],
Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, soon("Welcome tour…", "?")],
View: () => [soon("Refresh Preview", ""), { id: "reset", label: "Reset Layout" }],
Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, { id: "welcome", label: "Welcome tour…", hint: "?" }],
};
const pick = (id) => (id.startsWith("open:") ? p.open(id.slice(5)) : actions[id] && actions[id]());
for (const [label, items] of Object.entries(menus)) {
+52
View File
@@ -0,0 +1,52 @@
// The Simple | Editor switch in the menubar, and Simple's own surface: the one-prompt bar under the monitor.
import { composer as c } from "../../composer/composer.js";
const switcher = {
id: "mode-switch",
mount: "menubar.mode",
needs: ["mode"],
setup({ host, app }) {
const seg = c.segmented.sm({ label: "Mode", value: app.mode.now, onChange: (v) => app.mode.set(v),
options: [{ value: "simple", label: "Simple" }, { value: "editor", label: "Editor" }] });
host.append(seg.node);
return app.mode.on((v) => seg.setValue && seg.setValue(v));
},
};
// "Describe the shot you want…" over two buttons that slide Assets and Properties in. Only shown in Simple mode.
const bar = {
id: "simple-bar",
mount: "preview.simple",
needs: ["mode", "project", "panel"],
setup({ host, app }) {
const p = app.project;
const area = c.textarea.md({ label: "Prompt", rows: 3, placeholder: "Describe the shot you want…", value: "",
onInput: (v) => { if (p.selected) p.setText(p.selected.id, v); } });
const row = c.toolbar.default({ items: [], trailing: [c.button.sm({ label: "Media", tone: "ghost", onClick: () => app.panel("assets") }),
c.button.sm({ label: "Advanced settings", tone: "ghost", onClick: () => app.panel("props") })] });
host.append(area.node, row.node);
const draw = () => {
const simple = app.mode.now === "simple";
area.node.hidden = row.node.hidden = !simple;
if (!simple) app.closePanels();
else if (!area.node.matches(":focus")) area.setValue(p.selected ? p.selected.text || "" : "");
};
draw();
const off = [app.mode.on(draw), app.on(draw)];
return () => off.forEach((f) => f());
},
};
// A slid-over zone covers the button that opened it, so each carries its own close (Simple mode only).
const closer = (mount) => ({
id: `panel-close:${mount}`, mount, needs: ["mode", "closePanels"],
setup({ host, app }) {
const b = c.button.sm({ label: "✕", tone: "ghost", title: "Close", onClick: () => app.closePanels() }).node;
host.append(b);
const draw = () => { b.hidden = app.mode.now !== "simple"; };
draw();
return app.mode.on(draw);
},
});
export default [switcher, bar, closer("assets.actions"), closer("inspector.actions")];
+1 -2
View File
@@ -7,7 +7,6 @@ const menu = (label) => c.button.menu({ label, tone: "ghost" }); // ine
const dockTab = (label) => c.button.sm({ label, tone: "neutral", pressed: true, disabled: true });
const items = {
"menubar.mode": () => [c.segmented.sm({ options: [{ value: "simple", label: "Simple" }, { value: "editor", label: "Editor" }], value: "editor" })],
"menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })],
preview: () => [
@@ -18,7 +17,7 @@ const items = {
],
"timeline.actions": () => [btn("⚡ Auto Montage")],
"timeline.status": () => [dockTab("Assets"), dockTab("Preview"), dockTab("Properties"), btn("◆ Composer", "neutral")],
"timeline.status": () => [dockTab("Assets"), dockTab("Preview"), dockTab("Properties")],
"timeline.toolbar": () => [btn("⤓ Export"), btn("Save to media bin"), btn("⊟ Separate audio"), btn("Remove audio")],
};
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
const viewer = c.viewer.media({ kind: "video", empty: "" });
const empty = c.emptyState.default({ icon: "🎬", title: "No render yet", hint: "Use Generate in the timeline header" });
host.append(viewer.node, empty.node);
let shown = "";
let shown = null;
const draw = () => {
const sc = p.selected, media = sc && p.project && (p.project.scene_renders || {})[sc.id];
const src = media && media.media ? `${viewUrl(media.media)}#t=${start(sc, media, p.project)}${end(sc, media, p.project)}` : ""; // a split clip starts where its half does
+35
View File
@@ -0,0 +1,35 @@
// The welcome screen: shown the first time this browser opens the app, and from Help ▸ Welcome tour.
import { composer as c } from "../../composer/composer.js";
const SEEN = "funpack_welcomed";
const card = (title, hint) => c.field.default({ label: title, hint, control: c.button.sm({ label: title, tone: "ghost", disabled: true }) });
export default {
id: "welcome",
mount: "menubar.right",
needs: ["project"],
setup({ app }) {
let win = null;
const seen = () => { try { return localStorage.getItem(SEEN) === "1"; } catch { return true; } };
const markSeen = () => { try { localStorage.setItem(SEEN, "1"); } catch { /* private window */ } };
const open = () => {
if (win) return;
const recent = app.project.project;
const done = () => { markSeen(); if (win) win.close("done"); };
win = c.modal.generic({
title: "Welcome to FunPack", size: "lg",
body: c.region.stack({ gap: "md", children: [
c.hint.default({ text: "Multi-scene video on a real timeline." }),
c.button.lg({ label: "Begin", tone: "primary", onClick: done }),
...(recent ? [c.button.sm({ label: `Continue with “${recent.name}”`, tone: "ghost", onClick: done })] : []),
c.button.sm({ label: "Load an existing project", tone: "ghost", disabled: true }),
c.toolbar.default({ items: [card("Update", "Check this install for a newer version"), card("Switch branch", "Pick another branch"),
card("Restart ComfyUI", "Reload the server without updating"), card("Rollback update", "Undo the last update")] }),
] }),
onClose: () => { markSeen(); win = null; },
});
};
if (!seen()) setTimeout(open, 600); // after the first paint, so the screen it sits over is already there
return app.on((what) => { if (what === "welcome.open") open(); });
},
};
+25
View File
@@ -17,3 +17,28 @@
[hidden] { display: none !important; }
/* An empty zone says so in the middle of it, with the controls kept at its foot. */
.cx-zone .cx-stack-fill > .cx-empty { flex: 1 1 auto; justify-content: center; }
/* Simple mode: one prompt and one Generate. The timeline, splitters and dock go; Assets and Properties slide over the preview. */
.fp-row { position: relative; }
:root[data-ui-mode="simple"] .fp-main > :last-child,
:root[data-ui-mode="simple"] .fp-simple-hide { display: none !important; }
:root[data-ui-mode="simple"] .fp-row > :first-child,
:root[data-ui-mode="simple"] .fp-row > :last-child {
position: absolute; top: 0; bottom: 0; z-index: 60; inline-size: min(var(--properties-w), 82vw);
background: var(--bg, var(--elev-1)); box-shadow: 0 0 40px -8px var(--shadow-ink, #000);
transition: transform .18s cubic-bezier(.22, .61, .36, 1);
}
:root[data-ui-mode="simple"] .fp-row > :first-child { left: 0; transform: translateX(-101%); }
:root[data-ui-mode="simple"] .fp-row > :last-child { right: 0; transform: translateX(101%); }
:root[data-ui-mode="simple"] .fp-row.fp-show-assets > :first-child,
:root[data-ui-mode="simple"] .fp-row.fp-show-props > :last-child { transform: none; }
/* Splitters: thin hit areas straddling the zone borders. */
.fp-main { position: relative; }
.fp-split { position: absolute; z-index: 20; background: transparent; touch-action: none; }
.fp-split:hover { background: var(--accent, var(--line)); opacity: .5; }
.fp-split-left, .fp-split-right { top: 0; bottom: 0; inline-size: 7px; cursor: col-resize; }
.fp-split-left { left: calc(var(--media-w) - 3px); }
.fp-split-right { right: calc(var(--properties-w) - 3px); }
.fp-split-bottom { left: 0; right: 0; block-size: 7px; bottom: calc(var(--timeline-h) - 3px); cursor: row-resize; }
:root[data-ui-mode="simple"] .fp-split { display: none; }
+3
View File
@@ -1,5 +1,6 @@
// The one list of UI features. Adding a feature is a folder under ./features and a line here.
export default [
"./features/modes/index.js",
"./features/log/index.js",
"./features/projects/index.js",
"./features/media/index.js",
@@ -12,6 +13,8 @@ export default [
"./features/render/index.js",
"./features/placeholders/index.js",
"./features/generate/warn.js",
"./features/composer/index.js",
"./features/settings/index.js",
"./features/welcome/index.js",
"./features/temp/index.js",
].map((path) => new URL(path, import.meta.url).href);
+36 -1
View File
@@ -1,11 +1,13 @@
// The app's regions, laid out as v4 had them: a menubar, then Assets | Preview | Properties side by
// side with the Timeline under all three. Every region offers mount points; no feature is named here.
// menubar.mode | menubar.menus | menubar.right the bar
// preview.simple (under the monitor, Simple mode only)
// timeline.toolbar | timeline.toolbar.end (the row over the stage; the stage itself is `timeline`)
// assets.projects | assets.media (stacked in the Assets zone)
// <zone> (body) | <zone>.actions (after the title) | <zone>.status (far end) for assets, preview, inspector, timeline
import { composer } from "../composer/composer.js";
import { offer } from "./mounts.js";
import { drag } from "../composer/internals/drag.js";
export function build(root) {
const group = (mount) => {
@@ -31,6 +33,14 @@ export function build(root) {
});
return composer.region.stack({ gap: "md", fill: true, children: sheets });
};
// Preview: the monitor's stack, then (Simple mode only) the one-prompt bar under it.
const preview = () => {
const stack = composer.region.stack({ gap: "sm", fill: true });
offer("preview", stack.node);
const bar = composer.region.stack({ gap: "sm" });
offer("preview.simple", bar.node);
return composer.region.stack({ gap: "sm", fill: true, children: [stack, bar] });
};
// The timeline zone: a toolbar (what acts on the cut | zoom and hints), then the stage.
const timeline = () => {
const bar = composer.toolbar.default({ items: [group("timeline.toolbar")], trailing: [group("timeline.toolbar.end")] });
@@ -38,7 +48,7 @@ export function build(root) {
offer("timeline", stage.node);
return composer.region.stack({ gap: "sm", fill: true, children: [bar, stage] });
};
const row = composer.region.stack({ gap: "none", children: [zone("Assets", "assets", assets), zone("Preview", "preview"), zone("Properties", "inspector")] });
const row = composer.region.stack({ gap: "none", children: [zone("Assets", "assets", assets), zone("Preview", "preview", preview), zone("Properties", "inspector")] });
const main = composer.region.stack({ gap: "none", fill: true, children: [row, zone("Timeline", "timeline", timeline)] });
row.node.classList.add("fp-row");
main.node.classList.add("fp-main");
@@ -48,7 +58,32 @@ export function build(root) {
trailing: [group("menubar.right")],
});
const frame = composer.frame.app({ header, main });
// In Simple mode Assets and Properties slide over the preview on request.
frame.togglePanel = (which) => { const open = row.node.classList.contains(`fp-show-${which}`); row.node.classList.remove("fp-show-assets", "fp-show-props"); if (!open) row.node.classList.add(`fp-show-${which}`); };
frame.closePanels = () => row.node.classList.remove("fp-show-assets", "fp-show-props");
root.append(frame.node);
// v4's draggable splitters: three 7px bars that set the zone sizes; sizes are remembered.
const KEY = "funpack_layout", css = document.documentElement.style;
const size = { "--media-w": [160, 600], "--properties-w": [200, 700], "--timeline-h": [200, 700] };
const saved = (() => { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } })();
const apply = () => { for (const k in size) saved[k] ? css.setProperty(k, saved[k] + "px") : css.removeProperty(k); };
const keep = () => { try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch { /* not remembered */ } };
const splitter = (host, cls, prop, sign, axis) => {
const bar = document.createElement("div");
bar.className = `fp-split ${cls}`; bar.setAttribute("role", "separator");
let from = 0;
drag(bar, {
onStart: () => { from = parseFloat(getComputedStyle(document.documentElement).getPropertyValue(prop)); },
onMove: (m) => { saved[prop] = Math.round(Math.min(size[prop][1], Math.max(size[prop][0], from + sign * m[axis]))); apply(); },
onEnd: keep,
});
host.append(bar);
};
splitter(row.node, "fp-split-left", "--media-w", 1, "dx");
splitter(row.node, "fp-split-right", "--properties-w", -1, "dx");
splitter(main.node, "fp-split-bottom", "--timeline-h", -1, "dy");
apply();
frame.resetLayout = () => { for (const k in size) delete saved[k]; apply(); keep(); };
frame.setZoneTitle = (mount, text) => titles[mount] && titles[mount](text);
return frame;
}
+22
View File
@@ -0,0 +1,22 @@
// Simple or Editor: how much of the screen is showing. One word, kept in this browser, written onto the page
// (`data-ui-mode`) where the stylesheet reads it.
const KEY = "funpack_ui_mode";
export function createMode(root = document.documentElement) {
const heard = new Set();
const stored = () => { try { return localStorage.getItem(KEY) === "simple" ? "simple" : "editor"; } catch { return "editor"; } };
let now = stored();
root.dataset.uiMode = now;
return {
get now() { return now; },
set(next) {
if (next !== "simple" && next !== "editor") return;
if (next === now) return;
now = next;
root.dataset.uiMode = now;
try { localStorage.setItem(KEY, now); } catch { /* private window: lasts until reload */ }
heard.forEach((fn) => fn(now));
},
on(fn) { heard.add(fn); return () => heard.delete(fn); },
};
}