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