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:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user