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; } +}