diff --git a/easy_gen/frontend/app.js b/easy_gen/frontend/app.js
index 06b52da9..23fd7743 100644
--- a/easy_gen/frontend/app.js
+++ b/easy_gen/frontend/app.js
@@ -274,7 +274,9 @@
if (lastId) {
try { await S.loadProject(lastId); loaded = true; } catch (_) { /* fall through to picker */ }
}
- if (!loaded) window.ProjectMenu.open({ dismissable: false });
+ // First run (or a wizard interrupted by an install restart) gets the setup flow;
+ // everyone else gets the project picker they already know.
+ if (!loaded && !window.Onboarding?.maybeOpen()) window.ProjectMenu.open({ dismissable: false });
// After the project is in place, so a run targeting it re-attaches fully (result and
// all) instead of falling back to the queue-watch path. Not awaited — it runs for as
// long as the generation does.
diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html
index 5f718d1e..a7d8ad73 100644
--- a/easy_gen/frontend/index.html
+++ b/easy_gen/frontend/index.html
@@ -105,6 +105,8 @@
+
+
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 78812a8a..84562601 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -138,6 +138,7 @@
+
diff --git a/movie_editor/frontend/onboarding.js b/movie_editor/frontend/onboarding.js
new file mode 100644
index 00000000..b06b6f64
--- /dev/null
+++ b/movie_editor/frontend/onboarding.js
@@ -0,0 +1,588 @@
+// First-run wizard, shared by the Cutting Room and Easy Gen.
+//
+// Shape is lifted from the macOS Setup Assistant: full screen, no window chrome, one
+// vertically centred column per step, a glyph over a large headline over a short
+// paragraph, controls in the middle, and a fixed action bar at the bottom whose primary
+// button is always the way forward. Back is a chevron in the top-left; there are no step
+// dots, because a linear flow that tells you how far you have to go invites you to bail.
+//
+// The steps a host runs are declared in HOSTS below, not branched on inside each step —
+// Easy Gen has no timeline, so prompt splits are not a thing it can offer, and that
+// belongs in one table rather than scattered through the screens.
+(function () {
+ const { el, clear } = window.dom;
+ const S = () => window.Store;
+ const API = () => window.MovieEditorAPI;
+ const LS_DONE = "funpack_onboarded";
+
+ let root = null; // the full-screen overlay
+ let stage = null; // the animated content column
+ let idx = 0;
+ let steps = [];
+ let ctx = null; // per-run state: project, family, deps, generation type
+
+ const isEasyGen = () => window.FunPackAppName === "Easy Gen";
+
+ const HOSTS = {
+ editor: {
+ product: "Cutting Room",
+ lead: "Multi-scene video on a real timeline.",
+ steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "done"],
+ extras: ["links", "shortcuts", "splits"],
+ newProject: (name) => S().newProject(name),
+ },
+ easy: {
+ product: "Easy Gen",
+ lead: "One prompt, one button, one video.",
+ steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "done"],
+ extras: ["links", "shortcuts"],
+ newProject: (name) => S().createProject(name),
+ },
+ };
+ const host = () => (isEasyGen() ? HOSTS.easy : HOSTS.editor);
+
+ // ── chrome ────────────────────────────────────────────────────────────────
+
+ function glyph(paths, cls) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 48 48");
+ svg.setAttribute("class", "oo-glyph" + (cls ? " " + cls : ""));
+ svg.setAttribute("aria-hidden", "true");
+ svg.innerHTML = paths;
+ return svg;
+ }
+
+ const GLYPHS = {
+ theme: ''
+ + '',
+ project: ''
+ + '',
+ prereqs: ''
+ + ''
+ + '',
+ gentype: ''
+ + '',
+ models: ''
+ + '',
+ extras: ''
+ + '',
+ done: ''
+ + '',
+ };
+
+ function head(icon, title, sub) {
+ const h = el("div", "oo-head");
+ if (icon) h.append(glyph(GLYPHS[icon], "oo-glyph-" + icon));
+ h.append(el("h1", "oo-title", title));
+ if (sub) h.append(el("p", "oo-sub", sub));
+ return h;
+ }
+
+ // The action bar is fixed to the bottom of the screen on every step, so the primary
+ // button never moves as the content above it changes height.
+ function actions({ primary, onPrimary, secondary, onSecondary, disabled }) {
+ const bar = el("div", "oo-actions");
+ const btn = el("button", "oo-btn oo-btn-primary" + (disabled ? " disabled" : ""), primary || "Continue");
+ btn.type = "button";
+ if (!disabled) btn.onclick = onPrimary || next;
+ bar.append(btn);
+ if (secondary) {
+ const s = el("button", "oo-btn oo-btn-quiet", secondary);
+ s.type = "button";
+ s.onclick = onSecondary || next;
+ bar.append(s);
+ }
+ return bar;
+ }
+
+ function busy(text) {
+ const b = el("div", "oo-busy");
+ b.append(el("span", "oo-spinner"));
+ b.append(el("span", null, text));
+ return b;
+ }
+
+ // ── step registry ─────────────────────────────────────────────────────────
+ // Each returns a DOM node; `enter` may be async and runs before the render.
+
+ const STEPS = {
+ // ── colour scheme ───────────────────────────────────────────────────────
+ theme: {
+ render() {
+ const box = el("div", "oo-step");
+ box.append(head("theme", "Choose your look",
+ "You can change this any time in Settings ▸ Appearance."));
+ const { grid } = window.AppearanceSettings.buildPicker();
+ grid.classList.add("oo-theme-cards");
+ box.append(grid);
+ box.append(actions({}));
+ return box;
+ },
+ },
+
+ // ── project name + model family ─────────────────────────────────────────
+ project: {
+ async enter() {
+ // Families come from the backend so the wizard can't drift from what the
+ // pipeline actually supports, including the "not released yet" flags.
+ try { ctx.deps = await API().pipelineDeps(null); } catch (_) { ctx.deps = null; }
+ },
+ render() {
+ const box = el("div", "oo-step oo-step-wide");
+ box.append(head("project", "Name it, and pick a model",
+ "The model decides which nodes and which files this project needs, so it is asked first."));
+
+ const nameRow = el("div", "oo-field");
+ nameRow.append(el("label", "oo-label", "Project name"));
+ const input = el("input", "oo-input");
+ input.type = "text";
+ input.value = ctx.name || (isEasyGen() ? "Untitled" : "Untitled montage");
+ input.placeholder = "Project name";
+ input.oninput = () => { ctx.name = input.value; };
+ nameRow.append(input);
+ box.append(nameRow);
+
+ const families = (ctx.deps?.families || []).slice();
+ const cards = el("div", "oo-cards");
+ const paint = () => {
+ [...cards.children].forEach((c) => c.classList.toggle("active", c.dataset.key === ctx.family));
+ bar.replaceWith(bar = mkBar());
+ };
+ const card = (key, title, note, sub) => {
+ const c = el("button", "oo-card");
+ c.type = "button";
+ c.dataset.key = key;
+ const t = el("div", "oo-card-title", title);
+ if (note) t.append(el("span", "oo-card-note", note));
+ c.append(t);
+ c.append(el("div", "oo-card-sub", sub || ""));
+ c.onclick = () => { ctx.family = key; paint(); };
+ return c;
+ };
+ families.forEach((f) => cards.append(
+ card(f.key, f.label, f.released ? "" : "not released yet", f.summary)));
+ cards.append(card("__own__", "My own pipeline", "",
+ "Disable the built-in graph. Generation runs only the nodes you wire yourself in Models."));
+ box.append(cards);
+
+ let bar = mkBar();
+ box.append(bar);
+ function mkBar() {
+ return actions({
+ primary: "Continue",
+ disabled: !ctx.family,
+ onPrimary: createProject,
+ });
+ }
+ if (ctx.family) paint();
+ setTimeout(() => input.focus(), 60);
+ return box;
+ },
+ },
+
+ // ── missing node packs ──────────────────────────────────────────────────
+ prereqs: {
+ async enter() {
+ ctx.deps = null;
+ try { ctx.deps = await API().pipelineDeps(ctx.project?.id || null); } catch (_) {}
+ },
+ // Nothing to install and nothing to warn about — don't show a screen that only
+ // says "all good" and asks you to press Continue.
+ skip() {
+ if (ctx.family === "__own__") return true;
+ const d = ctx.deps;
+ if (!d) return true;
+ const r = d.readiness || {};
+ return !d.needs_setup && !(d.missing_packs || []).length && !r.note && r.released !== false;
+ },
+ render() {
+ const d = ctx.deps || {};
+ const r = d.readiness || {};
+ const packs = d.missing_packs || [];
+ const box = el("div", "oo-step oo-step-wide");
+ box.append(head("prereqs", packs.length ? "A few nodes are missing" : "About this model",
+ packs.length
+ ? "This model's pipeline needs custom node packs that aren't in your ComfyUI yet."
+ : "Worth knowing before you download anything."));
+
+ const panel = el("div", "oo-panel");
+ if (r.note) panel.append(el("p", "oo-note", r.note));
+ if (r.source_url) {
+ const a = el("a", "oo-link", r.source_title || r.source_url);
+ a.href = r.source_url; a.target = "_blank"; a.rel = "noopener";
+ panel.append(a);
+ }
+ if (packs.length) {
+ panel.append(el("div", "oo-list-label", "Node packs to install"));
+ const ul = el("ul", "oo-list");
+ packs.forEach((p) => {
+ const li = el("li", null, p.title);
+ if (p.missing_classes?.length) li.append(el("span", "oo-list-sub", p.missing_classes.join(", ")));
+ ul.append(li);
+ });
+ panel.append(ul);
+ }
+ if ((r.models || []).length) {
+ panel.append(el("div", "oo-list-label",
+ r.released ? "Model files this pipeline uses" : "Model files to download once published"));
+ const ul = el("ul", "oo-list");
+ r.models.forEach((m) => {
+ const li = el("li", null, m.label);
+ li.append(el("span", "oo-list-sub", "models/" + m.folder + (m.hint ? " — " + m.hint : "")));
+ ul.append(li);
+ });
+ panel.append(ul);
+ }
+ if (panel.children.length) box.append(panel);
+
+ // Installing restarts ComfyUI, which reloads this page — the wizard resumes from
+ // its saved position rather than starting over. See restore().
+ let primary = "Continue", onPrimary = next, secondary = null, onSecondary = null;
+ if (packs.length && d.needs_manager_install) {
+ primary = "Install ComfyUI-Manager";
+ onPrimary = () => { save(); window.PipelineSetup.installManager(); };
+ secondary = "Skip for now";
+ } else if (packs.length && d.needs_manager_restart) {
+ primary = "Restart ComfyUI";
+ onPrimary = () => { save(); window.PipelineSetup.restartComfy(); };
+ secondary = "Skip for now";
+ } else if (packs.length && d.manager_available) {
+ primary = "Install missing nodes";
+ onPrimary = () => { save(); window.PipelineSetup.installPacks(d); };
+ secondary = "Skip for now";
+ }
+ box.append(actions({ primary, onPrimary, secondary, onSecondary }));
+ return box;
+ },
+ },
+
+ // ── text-to-video vs image-to-video ─────────────────────────────────────
+ gentype: {
+ render() {
+ const box = el("div", "oo-step oo-step-wide");
+ box.append(head("gentype", "How do you want to start a shot?",
+ "This only sets what the editor asks you for. You can use both in the same project."));
+
+ const cards = el("div", "oo-cards oo-cards-2");
+ let bar;
+ const paint = () => {
+ [...cards.children].forEach((c) => c.classList.toggle("active", c.dataset.key === ctx.genType));
+ const nb = mkBar(); bar.replaceWith(nb); bar = nb;
+ };
+ const card = (key, title, sub) => {
+ const c = el("button", "oo-card");
+ c.type = "button"; c.dataset.key = key;
+ c.append(el("div", "oo-card-title", title));
+ c.append(el("div", "oo-card-sub", sub));
+ c.onclick = () => { ctx.genType = key; paint(); };
+ return c;
+ };
+ cards.append(
+ card("t2v", "From a prompt",
+ "Text to video. No image is needed anywhere — anchors and guides stay out of your way."),
+ card("i2v", "From an image",
+ "Image to video. Each shot starts from a still, and scenes can carry the previous frame forward."),
+ );
+ box.append(cards);
+ bar = mkBar();
+ box.append(bar);
+ function mkBar() {
+ return actions({ disabled: !ctx.genType, onPrimary: saveGenType });
+ }
+ if (ctx.genType) paint();
+ return box;
+ },
+ },
+
+ // ── model files ─────────────────────────────────────────────────────────
+ models: {
+ async enter() {
+ try { await S().loadModels?.(); } catch (_) {}
+ },
+ render() {
+ const box = el("div", "oo-step");
+ const own = ctx.family === "__own__";
+ box.append(head("models", own ? "Wire your pipeline" : "Point it at your models",
+ own
+ ? "Nothing is generated until a final IMAGE reaches the global video output."
+ : "Pick the checkpoint, text encoder and VAE files this model uses. Models opens over this screen."));
+
+ const p = el("p", "oo-body");
+ p.textContent = ctx.genType === "t2v"
+ ? "Set for text-to-video, so no image loader is required."
+ : "Set for image-to-video, so a still is expected at the start of a shot.";
+ box.append(p);
+
+ box.append(actions({
+ primary: "Open Models…",
+ onPrimary: () => window.ModelsModal.open(),
+ secondary: "Continue",
+ onSecondary: next,
+ }));
+ return box;
+ },
+ },
+
+ // ── optional extras ─────────────────────────────────────────────────────
+ extras: {
+ render() {
+ const wanted = host().extras;
+ const box = el("div", "oo-step oo-step-wide");
+ box.append(head("extras", "Optional, and easy to add later",
+ "None of this is needed to generate. Skip it and come back when you want it."));
+
+ const ROWS = {
+ links: {
+ title: "Pipeline links",
+ sub: "Drive several node inputs from one control, or tie an input to a project value.",
+ open: () => window.SettingsWindow.open("models"),
+ },
+ shortcuts: {
+ title: "Prompt shortcuts",
+ sub: "Short triggers that expand into full phrases while you type.",
+ open: () => window.SettingsWindow.open("shortcuts"),
+ },
+ splits: {
+ title: "Prompt splits",
+ sub: "Markers that cut one written prompt into separate scenes.",
+ open: () => window.Composer?.open?.(),
+ },
+ };
+ const list = el("div", "oo-rows");
+ wanted.forEach((k) => {
+ const r = ROWS[k];
+ if (!r) return;
+ const row = el("div", "oo-row");
+ const main = el("div", "oo-row-main");
+ main.append(el("div", "oo-row-title", r.title));
+ main.append(el("div", "oo-row-sub", r.sub));
+ row.append(main);
+ const b = el("button", "oo-btn oo-btn-small", "Set up");
+ b.type = "button";
+ b.onclick = () => { save(); close(); r.open(); };
+ row.append(b);
+ list.append(row);
+ });
+ box.append(list);
+ box.append(actions({ primary: "Skip for now", onPrimary: next }));
+ return box;
+ },
+ },
+
+ // ── done ────────────────────────────────────────────────────────────────
+ done: {
+ render() {
+ const box = el("div", "oo-step");
+ box.append(head("done", "You're ready to generate",
+ isEasyGen()
+ ? "Write a prompt at the bottom and press Generate."
+ : "Write in the Composer, then press Generate in the timeline header."));
+ box.append(actions({ primary: "Let's go", onPrimary: finish }));
+ return box;
+ },
+ },
+ };
+
+ // ── the splash, which is not a step: it has no Back and no progress ───────
+
+ function splash() {
+ const box = el("div", "oo-splash");
+ const mark = el("div", "oo-splash-mark", "◉");
+ box.append(mark);
+ box.append(el("h1", "oo-splash-title", "Welcome to FunPack"));
+ box.append(el("p", "oo-splash-lead", host().lead));
+
+ const begin = el("button", "oo-btn oo-btn-primary oo-btn-hero", "Begin");
+ begin.type = "button";
+ begin.onclick = () => { idx = 0; renderStep(); };
+ box.append(begin);
+
+ const load = el("button", "oo-btn oo-btn-quiet", "Load an existing project");
+ load.type = "button";
+ load.onclick = openExisting;
+ box.append(load);
+
+ box.append(window.FunPackGit.maintenanceRow("oo-maint"));
+
+ // Return advances, the way the Setup Assistant's splash does.
+ const onKey = (e) => {
+ if (e.key === "Enter" && root && root.querySelector(".oo-splash")) {
+ e.preventDefault();
+ begin.click();
+ }
+ };
+ document.addEventListener("keydown", onKey);
+ box._cleanup = () => document.removeEventListener("keydown", onKey);
+ setTimeout(() => begin.focus(), 80);
+ return box;
+ }
+
+ // ── actions ───────────────────────────────────────────────────────────────
+
+ async function createProject() {
+ const name = (ctx.name || "").trim() || (isEasyGen() ? "Untitled" : "Untitled montage");
+ const bar = root.querySelector(".oo-actions");
+ const again = !!ctx.project; // stepped Back to this screen and pressed Continue again
+ if (bar) clear(bar).append(busy(again ? "Saving…" : "Creating project…"));
+ try {
+ if (again) {
+ // Don't leave a trail of abandoned projects behind a user who changes their mind.
+ if (S().get().project?.name !== name) S().patchProject({ name });
+ } else {
+ await host().newProject(name);
+ }
+ ctx.project = S().get().project;
+ if (ctx.family === "__own__") await window.PipelineSetup.useOwnPipeline();
+ else await window.PipelineSetup.applyFamily(ctx.family);
+ } catch (e) {
+ if (bar) { clear(bar); bar.append(el("div", "oo-error", "Could not create the project: " + (e.message || e))); }
+ return;
+ }
+ next();
+ }
+
+ function saveGenType() {
+ try { S().setEditorSetting?.("generationType", ctx.genType); } catch (_) {}
+ next();
+ }
+
+ function openExisting() {
+ close();
+ if (isEasyGen()) window.ProjectMenu.open({ dismissable: true });
+ else window.WelcomePage.open();
+ }
+
+ // ── navigation ────────────────────────────────────────────────────────────
+
+ async function go(delta) {
+ let i = idx + delta;
+ while (i >= 0 && i < steps.length) {
+ const st = STEPS[steps[i]];
+ if (st.enter) await st.enter();
+ if (!st.skip || !st.skip()) break;
+ i += delta; // stepping over a skipped screen keeps Back symmetrical
+ }
+ if (i >= steps.length) { finish(); return; }
+ idx = Math.max(0, i);
+ renderStep();
+ }
+
+ const next = () => go(1);
+ const back = () => go(-1);
+
+ function renderStep() {
+ const name = steps[idx];
+ const st = STEPS[name];
+ paint(st.render(), name);
+ save();
+ }
+
+ function paint(node, name) {
+ if (stage?._child?._cleanup) stage._child._cleanup();
+ clear(stage);
+ root.querySelector(".oo-actions")?.remove();
+ stage.dataset.step = name || "splash";
+ stage._child = node;
+ stage.append(node);
+ // The action bar is position:fixed, and the step's entry animation leaves a resolved
+ // (identity) transform behind, which would make the step a containing block and trap
+ // the bar inside it. Steps still declare their own bar; it just gets hoisted out.
+ const bar = node.querySelector(":scope > .oo-actions");
+ if (bar) root.append(bar);
+ root.classList.toggle("oo-has-actions", !!bar);
+ // Re-trigger the entry animation on every step, not just the first.
+ stage.classList.remove("oo-in");
+ void stage.offsetWidth;
+ stage.classList.add("oo-in");
+ const backBtn = root.querySelector(".oo-back");
+ backBtn.hidden = !name || idx <= 0;
+ }
+
+ // ── persistence: an install restarts ComfyUI and reloads the page ─────────
+
+ function save() {
+ try {
+ localStorage.setItem("funpack_wizard", JSON.stringify({
+ idx, ctx: { name: ctx.name, family: ctx.family, genType: ctx.genType, projectId: ctx.project?.id || null },
+ }));
+ } catch (_) {}
+ }
+
+ function restore() {
+ try {
+ const raw = localStorage.getItem("funpack_wizard");
+ if (!raw) return null;
+ return JSON.parse(raw);
+ } catch (_) { return null; }
+ }
+
+ function clearSaved() {
+ try { localStorage.removeItem("funpack_wizard"); } catch (_) {}
+ }
+
+ function finish() {
+ try { localStorage.setItem(LS_DONE, "1"); } catch (_) {}
+ clearSaved();
+ // The wizard IS the pipeline setup — without this its modal reopens over the editor
+ // and asks for the family and the node packs all over again.
+ window.PipelineSetup?.markHandled?.();
+ close();
+ }
+
+ // ── lifecycle ─────────────────────────────────────────────────────────────
+
+ function close() {
+ if (stage?._child?._cleanup) stage._child._cleanup();
+ root?.remove();
+ root = null;
+ stage = null;
+ }
+
+ function open({ resume } = {}) {
+ close();
+ steps = host().steps;
+ // project stays null until the wizard creates one: seeding it from whatever happens
+ // to be open would make the project step rename that project instead of making a new one.
+ ctx = { name: "", family: null, genType: null, project: null, deps: null };
+
+ root = el("div", "oo-root");
+ const backBtn = el("button", "oo-back", "‹");
+ backBtn.type = "button";
+ backBtn.title = "Back";
+ backBtn.hidden = true;
+ backBtn.onclick = back;
+ root.append(backBtn);
+
+ stage = el("div", "oo-stage");
+ root.append(stage);
+ document.body.append(root);
+
+ const saved = resume ? restore() : null;
+ if (saved) {
+ Object.assign(ctx, saved.ctx || {});
+ idx = Math.min(Math.max(saved.idx || 0, 0), steps.length - 1);
+ // The project survived the restart; the in-memory handle did not.
+ const pid = saved.ctx?.projectId;
+ const load = pid ? S().loadProject(pid).catch(() => {}) : Promise.resolve();
+ load.then(() => { ctx.project = S().get().project; renderStep(); });
+ return;
+ }
+ idx = 0;
+ paint(splash(), null);
+ }
+
+ function done() {
+ try { return localStorage.getItem(LS_DONE) === "1"; } catch (_) { return false; }
+ }
+
+ // Called by each host's boot instead of its old welcome screen.
+ function maybeOpen() {
+ if (window.__FUNPACK_TOUR__) return false;
+ if (restore()) { open({ resume: true }); return true; }
+ if (done()) return false;
+ open();
+ return true;
+ }
+
+ window.Onboarding = { open, close, maybeOpen, done, isOpen: () => !!root };
+})();
diff --git a/movie_editor/frontend/pipeline_setup.js b/movie_editor/frontend/pipeline_setup.js
index c46ee305..aa0bf890 100644
--- a/movie_editor/frontend/pipeline_setup.js
+++ b/movie_editor/frontend/pipeline_setup.js
@@ -31,12 +31,23 @@
return !m.disable_core;
}
+ // The Editor keeps the live model config at state.models and reloads it from the
+ // server; Easy Gen has neither, and reads it off state.project.models (see
+ // PipelineCaps.models). Write back to whichever this host actually has.
+ async function refreshModels(saved) {
+ if (typeof S.loadModels === "function") { await S.loadModels(); return; }
+ const p = S.get().project;
+ if (p) p.models = JSON.parse(JSON.stringify(saved));
+ if (typeof S.notify === "function") S.notify();
+ else S.set?.({}); // Easy Gen's store notifies through set()
+ }
+
async function useOwnPipeline() {
if (!S.get().project) return;
- const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] }));
+ const models = JSON.parse(JSON.stringify(S.get().models || S.get().project?.models || { slots: [] }));
models.disable_core = true;
await API.saveModels(S.get().project.id, models);
- await S.loadModels();
+ await refreshModels(models);
setDismissed();
closeModal();
}
@@ -183,12 +194,16 @@
// The family decides which nodes and which model files the project needs, so it is the
// FIRST question — asking it after an install would have installed the wrong thing.
- async function chooseFamily(key) {
- if (!S.get().project) return;
- const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] }));
+ // Persist the family and bring the project onto its frame geometry. Split out from
+ // chooseFamily so the onboarding wizard can pick a family without the modal reopening
+ // itself on top of the wizard.
+ async function applyFamily(key) {
+ if (!S.get().project) return "";
+ geometryNote = ""; // otherwise a previous pick's note is returned again
+ const models = JSON.parse(JSON.stringify(S.get().models || S.get().project?.models || { slots: [] }));
models.model_family = key;
await API.saveModels(S.get().project.id, models);
- await S.loadModels();
+ await refreshModels(models);
// Frame geometry belongs to the model, not to taste: the new family may generate on a
// different frame grid (LTX 8k+1 / H3 17k+5) and at a fixed rate (H3 is always 24 fps).
// Bring the project onto them now, while the user is looking at the choice that caused
@@ -211,6 +226,12 @@
+ " (grid " + grid.label + ").";
}
}
+ return geometryNote;
+ }
+
+ async function chooseFamily(key) {
+ if (!S.get().project) return;
+ await applyFamily(key);
let deps;
try { deps = await API.pipelineDeps(S.get().project?.id); } catch (_) { closeModal(); return; }
if (!deps?.needs_setup) { closeModal(); return; }
@@ -365,8 +386,16 @@
document.body.append(overlay);
}
+ // The onboarding wizard asks the same questions in its own chrome, so this modal must
+ // not open behind it — or, worse, on top of the editor the moment the wizard finishes.
+ function markHandled() {
+ setDismissed();
+ closeModal();
+ }
+
async function maybePrompt() {
if (window.__FUNPACK_TOUR__) return;
+ if (window.Onboarding?.isOpen?.()) return;
if (dismissed()) return;
const st = S.get();
if (!st.project || !builtInPipelineActive(st)) return;
@@ -385,5 +414,14 @@
openModal(deps);
}
- window.PipelineSetup = { maybePrompt, open, close: closeModal };
+ window.PipelineSetup = {
+ maybePrompt, open, close: closeModal,
+ // Exposed for the onboarding wizard, which renders the prerequisite step in its
+ // own full-screen chrome but must not re-implement the install/poll/restart
+ // machinery below.
+ applyFamily, useOwnPipeline, markHandled,
+ installPacks: startInstall,
+ installManager: startManagerInstall,
+ restartComfy: restartComfyOnly,
+ };
})();
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 8c140870..eddf12f2 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -4303,7 +4303,11 @@
}
} catch (_) { /* queue unreachable — boot normally */ }
notify();
- if (!resumed && window.WelcomePage) window.WelcomePage.open();
+ // First run (or a wizard interrupted by an install restart) gets the setup flow;
+ // everyone else gets the project picker they already know.
+ if (resumed) return;
+ if (window.Onboarding?.maybeOpen()) return;
+ if (window.WelcomePage) window.WelcomePage.open();
}
window.Store = {
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index 10d423e2..051e5299 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -2132,3 +2132,205 @@ body.tour-mode { overflow: hidden; }
.maint-btn-label { font-weight: 600; font-size: 13px; }
.maint-btn-hint { font-size: 11px; color: var(--faint); line-height: 1.35; }
@media (max-width: 620px) { .maint-row { grid-template-columns: 1fr; } }
+
+/* ════════════════════════════════════════════════════════════════════════
+ ONBOARDING WIZARD — macOS Setup Assistant shape
+ Full screen, no chrome. One centred column per step: glyph, headline,
+ short paragraph, controls, and an action bar pinned to the bottom so the
+ primary button never moves between steps.
+
+ Type follows Apple's macOS text styles (HIG "Specifications"): Body 13/16,
+ Callout 12/15, Title 3 15/20, Title 1 22/26, Large Title 26/32 — with a
+ display scale above them, because Setup Assistant is a full-screen context
+ and not an inspector. --font-system resolves to SF Pro on a Mac and falls
+ back to the platform UI face everywhere else.
+ ════════════════════════════════════════════════════════════════════════ */
+:root {
+ --font-system: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
+ system-ui, "Helvetica Neue", sans-serif;
+ --oo-col: 560px; /* the centred content column */
+ --oo-col-wide: 760px; /* steps that hold cards or lists */
+}
+
+.oo-root {
+ position: fixed; inset: 0; z-index: 800;
+ display: flex; align-items: center; justify-content: center;
+ padding: 72px 32px;
+ background:
+ radial-gradient(1100px 700px at 50% -15%, color-mix(in srgb, var(--accent) 9%, transparent) 0%, transparent 62%),
+ var(--ink-0);
+ font-family: var(--font-system);
+ -webkit-font-smoothing: antialiased;
+ overflow-y: auto;
+}
+
+/* Back is a chevron in the top-left corner, the way the Setup Assistant does it —
+ never a button in the action bar, where it would compete with Continue. */
+.oo-back {
+ position: absolute; top: 22px; left: 26px; z-index: 2;
+ width: 32px; height: 32px; padding: 0 0 3px;
+ border: 0; border-radius: 50%; background: transparent;
+ color: var(--muted); font-size: 26px; line-height: 1; cursor: pointer;
+ transition: background .12s, color .12s;
+}
+.oo-back:hover { background: var(--ink-2); color: var(--text); }
+.oo-back[hidden] { display: none; }
+
+/* Only steps that carry an action bar reserve room for it; the splash has none. */
+.oo-root.oo-has-actions { padding-bottom: 140px; }
+.oo-stage { width: 100%; display: flex; justify-content: center; }
+.oo-stage.oo-in .oo-step, .oo-stage.oo-in .oo-splash { animation: oo-rise .34s cubic-bezier(.22,.61,.36,1) both; }
+@keyframes oo-rise { from { opacity: 0; transform: translateY(10px); } }
+
+.oo-step {
+ width: 100%; max-width: var(--oo-col);
+ display: flex; flex-direction: column; align-items: center; text-align: center;
+}
+.oo-step-wide { max-width: var(--oo-col-wide); }
+
+/* ── head: glyph, headline, one short paragraph ─────────────────────────── */
+.oo-head { display: flex; flex-direction: column; align-items: center; margin-bottom: 34px; }
+.oo-glyph { width: 76px; height: 76px; color: var(--accent); margin-bottom: 22px; }
+.oo-title {
+ margin: 0; font-size: 34px; line-height: 40px; font-weight: 700;
+ letter-spacing: -.4px; color: var(--text);
+}
+.oo-sub {
+ margin: 12px 0 0; max-width: 46ch;
+ font-size: 15px; line-height: 21px; color: var(--muted);
+}
+.oo-body { margin: 0 0 18px; font-size: 13px; line-height: 16px; color: var(--muted); }
+
+/* ── action bar, pinned ─────────────────────────────────────────────────── */
+.oo-actions {
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 3;
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ padding: 22px 32px 30px;
+ background: linear-gradient(to top, var(--ink-0) 62%, transparent);
+ pointer-events: none;
+}
+.oo-actions > * { pointer-events: auto; }
+
+.oo-btn {
+ font-family: var(--font-system); font-size: 13px; line-height: 16px; font-weight: 500;
+ border-radius: 8px; border: 1px solid transparent; cursor: pointer;
+ padding: 8px 18px; background: transparent; color: var(--text);
+ transition: background .12s, border-color .12s, opacity .12s, transform .06s;
+}
+.oo-btn:active:not(.disabled) { transform: scale(.985); }
+.oo-btn-primary {
+ min-width: 220px; padding: 11px 26px;
+ font-size: 14px; font-weight: 600;
+ background: var(--accent); border-color: var(--accent); color: var(--on-accent);
+ box-shadow: 0 1px 3px color-mix(in srgb, var(--shadow-ink) 30%, transparent);
+}
+.oo-btn-primary:hover:not(.disabled) { background: var(--accent-hi); border-color: var(--accent-hi); }
+.oo-btn-primary.disabled { opacity: .38; cursor: default; }
+.oo-btn-hero { min-width: 260px; padding: 13px 30px; font-size: 15px; margin-top: 8px; }
+.oo-btn-quiet { color: var(--accent); background: transparent; }
+.oo-btn-quiet:hover { background: var(--accent-soft); }
+.oo-btn-small { padding: 5px 13px; border-color: var(--line); background: var(--ink-1); }
+.oo-btn-small:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); }
+
+.oo-busy { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); }
+.oo-spinner {
+ width: 15px; height: 15px; border-radius: 50%; flex: none;
+ border: 2px solid var(--line); border-top-color: var(--accent);
+ animation: oo-spin .8s linear infinite;
+}
+@keyframes oo-spin { to { transform: rotate(360deg); } }
+.oo-error { font-size: 13px; line-height: 17px; color: var(--danger); max-width: 46ch; text-align: center; }
+
+/* ── splash ─────────────────────────────────────────────────────────────── */
+.oo-splash {
+ width: 100%; max-width: 620px;
+ display: flex; flex-direction: column; align-items: center; text-align: center;
+}
+.oo-splash-mark {
+ font-size: 34px; color: var(--accent); margin-bottom: 26px;
+ filter: drop-shadow(0 0 22px var(--accent-soft));
+}
+.oo-splash-title {
+ margin: 0; font-size: 56px; line-height: 62px; font-weight: 700;
+ letter-spacing: -1.4px; color: var(--text);
+}
+.oo-splash-lead { margin: 14px 0 40px; font-size: 17px; line-height: 22px; color: var(--muted); }
+.oo-splash .oo-btn-quiet { margin-top: 4px; }
+.oo-maint { margin-top: 48px; width: 100%; max-width: var(--oo-col-wide); }
+
+/* ── fields ─────────────────────────────────────────────────────────────── */
+.oo-field { width: 100%; max-width: 420px; text-align: left; margin-bottom: 30px; }
+.oo-label {
+ display: block; margin-bottom: 6px;
+ font-size: 12px; line-height: 15px; font-weight: 500; color: var(--muted);
+}
+.oo-input {
+ width: 100%; padding: 9px 12px;
+ font-family: var(--font-system); font-size: 14px; line-height: 18px; color: var(--text);
+ background: var(--ink-1); border: 1px solid var(--line); border-radius: 8px;
+}
+.oo-input:focus {
+ outline: none; border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
+/* ── choice cards ───────────────────────────────────────────────────────── */
+.oo-cards {
+ width: 100%; display: grid; gap: 12px; margin-bottom: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+.oo-cards-2 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
+.oo-card {
+ display: flex; flex-direction: column; gap: 6px; text-align: left;
+ padding: 16px 17px; border-radius: 12px;
+ border: 1px solid var(--line); background: var(--ink-1);
+ font-family: var(--font-system); cursor: pointer;
+ transition: border-color .12s, box-shadow .12s, transform .08s, background .12s;
+}
+.oo-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); transform: translateY(-1px); }
+.oo-card.active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent), 0 10px 26px -18px var(--shadow-ink);
+ background: color-mix(in srgb, var(--accent) 5%, var(--ink-1));
+}
+.oo-card-title { font-size: 15px; line-height: 20px; font-weight: 600; color: var(--text); }
+.oo-card-note {
+ margin-left: 8px; padding: 1px 7px; border-radius: 20px; vertical-align: 2px;
+ font-size: 10px; line-height: 15px; font-weight: 500;
+ color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent);
+}
+.oo-card-sub { font-size: 12px; line-height: 16px; color: var(--muted); }
+
+.oo-theme-cards { width: 100%; margin-bottom: 8px; }
+
+/* ── panels and lists ───────────────────────────────────────────────────── */
+.oo-panel {
+ width: 100%; text-align: left; padding: 18px 20px; margin-bottom: 8px;
+ border: 1px solid var(--line); border-radius: 12px; background: var(--ink-1);
+}
+.oo-note { margin: 0 0 12px; font-size: 13px; line-height: 18px; color: var(--text); }
+.oo-link { font-size: 12px; line-height: 16px; color: var(--accent); }
+.oo-list-label {
+ margin: 16px 0 7px; font-size: 11px; line-height: 14px; font-weight: 600;
+ letter-spacing: .4px; text-transform: uppercase; color: var(--faint);
+}
+.oo-panel > .oo-list-label:first-child { margin-top: 0; }
+.oo-list { margin: 0; padding-left: 18px; }
+.oo-list li { font-size: 13px; line-height: 18px; color: var(--text); margin-bottom: 5px; }
+.oo-list-sub { display: block; font-size: 11px; line-height: 15px; color: var(--faint); font-family: var(--font-mono); }
+
+.oo-rows { width: 100%; display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
+.oo-row {
+ display: flex; align-items: center; gap: 16px; text-align: left;
+ padding: 13px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--ink-1);
+}
+.oo-row-main { flex: 1; min-width: 0; }
+.oo-row-title { font-size: 13.5px; line-height: 18px; font-weight: 600; color: var(--text); }
+.oo-row-sub { font-size: 12px; line-height: 16px; color: var(--muted); margin-top: 2px; }
+
+@media (max-width: 700px) {
+ .oo-root { padding: 60px 20px 128px; }
+ .oo-splash-title { font-size: 40px; line-height: 46px; letter-spacing: -.8px; }
+ .oo-title { font-size: 27px; line-height: 33px; }
+ .oo-cards { grid-template-columns: 1fr; }
+}