feat(ui): Simple ⇄ Editor mode switch

Easy Gen becomes a mode of the editor rather than a second app. FunPackMode
stamps data-ui-mode on <html>; Simple hides the timeline, assets, dock tabs,
Composer, scene-selection and Auto Montage, and the rating-gated Studio and
Chain Sampler settings. Nothing is disabled and no state is dropped — switching
back shows everything as it was.

The transport bar moves to the preview header in Simple mode instead of being
rebuilt, so a running generation's timer keeps writing to the same buttons
across a switch.

The mode is folded into every ViewBus fingerprint, in one place rather than per
zone: each zone builds different content per mode, and without it a switch left
the old copy on screen.

First stage of the merge. Routes, the hub, and the easy_gen frontend still
stand; the inspector still shows its full field set in Simple mode.
This commit is contained in:
digital-garbage
2026-08-13 14:23:13 +03:00
parent b28071bca2
commit 8ca4c1b0e3
8 changed files with 164 additions and 27 deletions
+20 -5
View File
@@ -1,9 +1,23 @@
// Main transport actions: Generate / Generate selection / Render (timeline header).
// Main transport actions: Generate / Generate selection / Render.
(function () {
const { el, clear } = window.dom;
const S = window.Store;
const mount = document.getElementById("transport-actions");
if (!mount) return;
const timelineHost = document.getElementById("transport-actions");
const simpleHost = document.getElementById("simple-actions");
if (!timelineHost) return;
// Home is the timeline header. Simple mode hides that whole zone, so the bar moves to
// the preview header rather than being rebuilt — one set of buttons, one set of handlers,
// and the running-generation tick keeps writing to the same nodes across a mode switch.
let mount = timelineHost;
function reparent() {
const want = (window.FunPackMode?.isSimple() && simpleHost) ? simpleHost : timelineHost;
if (want === mount) return;
clear(mount); // leave nothing behind in the header we came from
mount = want;
render(S.get()); // ViewBus only fires on a state change, and this isn't one
}
window.addEventListener("funpack-ui-mode", reparent);
function selCount() { return S.selectedSceneCount ? S.selectedSceneCount() : (S.get().selectedSceneId ? 1 : 0); }
function hasProject() { return !!S.get().project; }
@@ -62,7 +76,7 @@
const n = selCount();
const selLabel = n > 1 ? `Selected (${n})` : "Selected";
const genSel = el("button", "btn ghost compact", selLabel);
const genSel = el("button", "btn ghost compact mode-full-only", selLabel);
genSel.title = n > 1
? `Generate ${n} selected scenes (one chain run per segment)`
: "Generate the selected scene";
@@ -88,7 +102,7 @@
renderBtn.onclick = () => S.renderFinal();
mount.append(renderBtn);
const montageBtn = el("button", "btn ghost compact", "⚡ Auto Montage");
const montageBtn = el("button", "btn ghost compact mode-full-only", "⚡ Auto Montage");
montageBtn.title = "Build a trailer-style cut from already-rendered clips";
montageBtn.disabled = !hasProject() || busy(st);
montageBtn.onclick = () => window.MontageDialog?.open();
@@ -124,6 +138,7 @@
});
}
if (window.FunPackMode?.isSimple() && simpleHost) mount = simpleHost;
if (window.ViewBus) window.ViewBus.subscribeActionbar(render);
else S.subscribe(render);
render(S.get());
+19 -19
View File
@@ -13,12 +13,10 @@
// the migration re-enters itself and recurses until the stack blows.
const _ALG_MIGRATED = new Set();
// Easy Gen has no rating UI at all (by design — see easy_gen/frontend/), so every
// setting that is a no-op without a trained refinement key / rated history is hidden
// there, not just made harder to find. window.FunPackAppName is the same discriminator
// settings_window.js already uses to relabel the About section for a different frontend
// sharing this file; Editor leaves it unset.
const EASY = !!window.FunPackAppName;
// Simple mode has no rating UI, so every setting that is a no-op without a trained
// refinement key / rated history is hidden there rather than just made harder to find.
// Read per render, not once: the mode switch flips it live.
const EASY = () => !!window.FunPackMode?.isSimple();
const RATING_GATED_KNOBS = new Set([
"embed_guidance", "embed_guidance_source", "embed_guidance_strength",
"score_slider", "score_slider_strength", "taste_nearest_prompt",
@@ -117,11 +115,11 @@
const { rf } = parseStudioSettings(p);
let n = 0;
[...STUDIO_REFINER_ESSENTIALS, ...STUDIO_REFINER_ADVANCED].forEach((f) => {
if (EASY && RATING_GATED_STUDIO.has(f.name)) return;
if (EASY() && RATING_GATED_STUDIO.has(f.name)) return;
const cur = rf[f.name] != null ? rf[f.name] : f.default;
if (cur !== f.default) n++;
});
if (!EASY && (p.refinement_key || "default") !== "default") n++;
if (!EASY() && (p.refinement_key || "default") !== "default") n++;
return n;
}
@@ -385,7 +383,7 @@
}
function knobVisible(k, si) {
if (EASY && RATING_GATED_KNOBS.has(k.name)) return false;
if (EASY() && RATING_GATED_KNOBS.has(k.name)) return false;
// dependsOn/dependsValue: single condition (dependsValue absent = plain truthy
// gate, as every existing boolean dependsOn already relies on).
if (k.dependsOn && !_depSatisfied(k.dependsOn, k.dependsValue, si)) return false;
@@ -470,7 +468,7 @@
const si = p.sampler_inputs || {};
let n = 0;
(CHAIN_VIEW_KNOBS[id] || []).forEach((name) => {
if (EASY && RATING_GATED_KNOBS.has(name)) return;
if (EASY() && RATING_GATED_KNOBS.has(name)) return;
const k = SAMPLER_KNOB_MAP[name];
if (k && si[name] != null && si[name] !== k.default) n++;
});
@@ -562,7 +560,7 @@
const p = st.project;
const { rf } = parseStudioSettings(p);
if (!EASY) {
if (!EASY()) {
// Refinement key — project-level (feeds Studio / Chain Sampler / SaveRefinementLatent).
// "default" uses the keyless store; a custom name trains/loads its own key. Shortcuts
// bound to a non-default key layer per-scene training on top of this.
@@ -575,18 +573,18 @@
}
const gEss = group(pane, "Essentials");
STUDIO_REFINER_ESSENTIALS.filter((f) => !EASY || !RATING_GATED_STUDIO.has(f.name))
STUDIO_REFINER_ESSENTIALS.filter((f) => !EASY() || !RATING_GATED_STUDIO.has(f.name))
.forEach((f) => renderStudioRefinerBool(gEss, rf, f));
const gAdv = group(pane, EASY ? "Prompt shaping" : "Refinement");
STUDIO_REFINER_ADVANCED.filter((f) => !EASY || !RATING_GATED_STUDIO.has(f.name))
const gAdv = group(pane, EASY() ? "Prompt shaping" : "Refinement");
STUDIO_REFINER_ADVANCED.filter((f) => !EASY() || !RATING_GATED_STUDIO.has(f.name))
.forEach((f) => renderStudioRefinerField(gAdv, rf, f));
if (EASY) {
if (EASY()) {
pane.append(hintEl(
"Studio runs in Prompt-only mode here: it shapes and splits the prompt, nothing more. "
+ "Rating-dependent controls are hidden — Easy Gen has no rating UI to feed them. "
+ "Use the Cutting Room for the full learned refiner."));
+ "Rating-dependent controls are hidden — Simple mode has no rating UI to feed them. "
+ "Switch to Editor for the full learned refiner."));
} else {
pane.append(hintEl("Scene text and transitions come from the timeline. Advisor, LoRA, and batch training remain in the ComfyUI Studio popup on the graph."));
}
@@ -951,8 +949,8 @@
case "chain_continuity": return renderChainContinuity(pane, st);
case "chain_timing": return renderChainTiming(pane, st);
case "chain_guidance": return renderChainKnobsView(pane, st, "chain_guidance", "Guidance",
EASY ? "Rating-dependent guidance (embed guidance, score slider, output guidance, taste retrieval, "
+ "DynaShift) is hidden here — use the Cutting Room or ComfyUI graph for those." : null);
EASY() ? "Rating-dependent guidance (embed guidance, score slider, output guidance, taste retrieval, "
+ "DynaShift) is hidden in Simple mode — switch to Editor for those." : null);
case "chain_decode": return renderChainKnobsView(pane, st, "chain_decode", "Decode");
case "chain_experimental": return renderChainExperimental(pane, st);
default: return renderOverview(pane, st);
@@ -1030,8 +1028,10 @@
});
unsub = S.subscribe(() => render());
const offMode = window.FunPackMode?.onChange(() => render());
render();
return () => {
if (offMode) offMode();
if (unsub) { unsub(); unsub = null; }
_mounted = null;
_editing = false;
+6 -1
View File
@@ -7,6 +7,7 @@
<!-- Before the stylesheet on purpose: data-theme has to be on <html> for the
first paint, or a light-theme user gets a dark flash on every load. -->
<script src="theme.js"></script>
<script src="ui_mode.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@600;700;800&family=Roboto+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
@@ -48,7 +49,8 @@
<body>
<!-- ───────────────────────── MENU BAR ───────────────────────── -->
<nav class="menubar" id="menubar">
<a class="brand" href="/funpack/" title="FunPack Hub"><span class="brand-mark">◉</span> FunPack <span class="brand-sub">Cutting Room</span></a>
<span class="brand"><span class="brand-mark">◉</span> FunPack</span>
<div class="mode-switch" id="mode-switch" role="group" aria-label="Interface mode"></div>
<div class="menus" id="menus"><!-- populated by menubar.js --></div>
<div class="menubar-right">
<span class="save-chip" id="save-chip"></span>
@@ -69,6 +71,9 @@
<section class="zone preview dock-panel" id="preview-zone" data-dock="preview">
<div class="zone-head">
<span class="zone-title">Preview</span>
<!-- Simple mode parks the transport here: its home is the timeline header, and
that whole zone is hidden. -->
<div class="mode-actions" id="simple-actions"></div>
<span class="zone-head-right" id="preview-status"></span>
</div>
<div class="zone-body program" id="preview-body"></div>
+19
View File
@@ -212,7 +212,25 @@
function openMenu(name) { openName = name; veil.hidden = false; render(); }
// Simple ⇄ Editor, beside the wordmark. Both drive the same project and pipeline; the
// mode only decides how much of the app is on screen.
function renderModeSwitch() {
const M = window.FunPackMode;
const host = document.getElementById("mode-switch");
if (!M || !host) return;
clear(host);
[["simple", "Simple", "Prompt, Generate, result — nothing else"],
["editor", "Editor", "Timeline, inspector, ratings, every setting"]].forEach(([key, label, title]) => {
const b = el("button", "mode-btn" + (M.is(key) ? " on" : ""), label);
b.type = "button";
b.title = title;
b.onclick = () => M.set(key);
host.append(b);
});
}
function render() {
renderModeSwitch();
const spec = menuSpec();
clear(menusEl);
Object.keys(spec).forEach((name) => {
@@ -275,6 +293,7 @@
window.addEventListener("funpack-save-status", (e) => renderSaveChip(S.get(), e.detail));
window.addEventListener("funpack-history-state", () => render());
window.addEventListener("funpack-ui-mode", () => render());
window.addEventListener("keydown", (e) => {
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
const t = document.activeElement;
+3 -1
View File
@@ -1310,7 +1310,9 @@
const empty = el("div", "program-empty");
empty.append(el("div", "reel", "🎬"));
empty.append(el("div", null, p ? "No render yet" : "Open or create a project"));
empty.append(el("div", "pj-meta", "Use Generate in the timeline header"));
empty.append(el("div", "pj-meta", window.FunPackMode?.isSimple()
? "Write a prompt, then press Generate"
: "Use Generate in the timeline header"));
canvas.append(empty);
}
// generation progress overlay (visible even when there's already media). Built once per
+36
View File
@@ -322,6 +322,42 @@ a.brand { text-decoration: none; color: inherit; }
a.brand:hover .brand-sub { color: var(--accent-hi); }
.menus { display: flex; gap: 2px; }
/* ── Simple ⇄ Editor ──────────────────────────────────────────────────────
One app, two faces. The switch sits beside the wordmark because it changes
what the whole window is, not what one panel shows. */
.mode-switch {
display: inline-flex; flex: 0 0 auto; padding: 2px; gap: 2px;
border: 1px solid var(--line); border-radius: 7px; background: var(--ink-1);
}
.mode-btn {
border: 0; border-radius: 5px; background: transparent; color: var(--muted);
height: 22px; padding: 0 var(--sp-2); cursor: pointer;
font-family: inherit; font-size: var(--fs-subhead); line-height: var(--lh-subhead);
transition: background .12s, color .12s;
}
.mode-btn:hover { color: var(--text); }
.mode-btn.on { background: var(--accent-soft); color: var(--accent-hi); }
/* Where the transport bar lands when the timeline is hidden. */
.mode-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.mode-actions:empty { display: none; }
/* Simple mode: everything that needs explaining is gone. Nothing is disabled and no
state is dropped — the timeline, ratings and advanced panes are still there, and
switching back shows them exactly as they were. */
:root[data-ui-mode="simple"] .zone.timeline,
:root[data-ui-mode="simple"] #split-bottom,
:root[data-ui-mode="simple"] #media-zone,
:root[data-ui-mode="simple"] #split-left,
:root[data-ui-mode="simple"] .dock-tabs,
:root[data-ui-mode="simple"] .composer-btn,
:root[data-ui-mode="simple"] .insp-tabs,
:root[data-ui-mode="simple"] .insp-engine-strip,
:root[data-ui-mode="simple"] .rating-bar,
:root[data-ui-mode="simple"] .insp-fold,
:root[data-ui-mode="simple"] .mode-full-only { display: none !important; }
.menu { position: relative; }
.menu-btn {
background: transparent; border: 0; height: var(--ctl-h); padding: 0 var(--sp-2);
+57
View File
@@ -0,0 +1,57 @@
// Two faces of one app. "simple" is what Easy Gen used to be — prompt, Generate, result,
// and nothing that needs explaining. "editor" is the full cutting room. Same store, same
// pipeline, same project file: only what is on screen differs.
//
// Stamped on <html> as data-ui-mode so most of the hiding is CSS, and read at render time
// by the panels that have to build different content rather than hide some of it.
(function () {
const KEY = "funpack_ui_mode";
const MODES = ["simple", "editor"];
const listeners = new Set();
function stored() {
try {
const v = localStorage.getItem(KEY);
return MODES.includes(v) ? v : null;
} catch (_) { return null; }
}
// No stored choice: the URL decides once, so an Easy Gen bookmark still lands in the
// mode it promised. Everything after that is the toggle.
function initial() {
const s = stored();
if (s) return s;
try {
const q = new URLSearchParams(window.location.search).get("mode");
if (MODES.includes(q)) return q;
if (/\/easy\/?$/.test(window.location.pathname)) return "simple";
} catch (_) {}
return "editor";
}
let mode = initial();
function stamp() { document.documentElement.setAttribute("data-ui-mode", mode); }
function get() { return mode; }
function is(m) { return mode === m; }
function isSimple() { return mode === "simple"; }
function set(next) {
if (!MODES.includes(next) || next === mode) return mode;
mode = next;
stamp();
try { localStorage.setItem(KEY, mode); } catch (_) {}
listeners.forEach((fn) => { try { fn(mode); } catch (e) { console.error(e); } });
try { window.dispatchEvent(new CustomEvent("funpack-ui-mode", { detail: mode })); } catch (_) {}
// Panels that BUILD different content (rather than hide some of it) redraw off the
// store, so one notify covers the inspector, timeline and action bar together.
try { window.Store?.notify?.(); } catch (_) {}
return mode;
}
function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
stamp();
window.FunPackMode = { get, set, is, isSimple, onChange, MODES };
})();
+4 -1
View File
@@ -175,7 +175,10 @@
if (!S) return () => {};
let last = null;
return S.subscribe((st) => {
const fp = fpFn(st);
// Every zone builds different content in Simple mode, so the mode belongs in every
// fingerprint. Folded in here rather than in each fpFn — one place, no way to forget
// it in the next one added.
const fp = (window.FunPackMode ? window.FunPackMode.get() + "|" : "") + fpFn(st);
if (fp === last) return;
const ok = fn(st);
if (ok === false) return; // handler declined to apply this update — retry next notify