diff --git a/app/ui/boot.js b/app/ui/boot.js index fd5dde67..050ec18e 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -7,6 +7,8 @@ import { createPipelineState } from "./shell/pipeline_state.js"; import { createGenerate } from "./shell/generate.js"; 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 { hostFor } from "./shell/mounts.js"; import paths from "./modules.js"; @@ -19,9 +21,10 @@ const project = createProject({ onChange: () => say("change"), onOpen: () => say const pipeline = createPipelineState(api); const generate = createGenerate({ pipeline }); linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "open") fn(); }) }); -const app = { project, pipeline, generate, api, on, say }; - 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() }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); const result = await loadFeatures(paths, { app }); settings.addButton(); diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css index 0833d281..1974cf7f 100644 --- a/app/ui/composer/elements/overlay.css +++ b/app/ui/composer/elements/overlay.css @@ -106,6 +106,7 @@ .cx-menu-hint { font-size: var(--fs-7); color: var(--faint); } .cx-menu-icon { width: 16px; text-align: center; color: var(--muted); } .cx-menu-sep { height: 1px; background: var(--line); margin: var(--sp-2) 0; } +.cx-menu-heading { padding: var(--sp-2) var(--sp-4); font-size: var(--fs-7); letter-spacing: .06em; text-transform: uppercase; color: var(--faint); } .cx-tooltip { position: fixed; pointer-events: none; diff --git a/app/ui/composer/elements/popover.js b/app/ui/composer/elements/popover.js index c84442a4..4386b951 100644 --- a/app/ui/composer/elements/popover.js +++ b/app/ui/composer/elements/popover.js @@ -64,6 +64,7 @@ define("popover", "anchored", ({ body, ...rest } = {}) => function menuList(items, onPick, close) { const list = el("div", { cls: "cx-menu", attrs: { role: "menu" } }); for (const item of items) { + if (item.heading) { list.append(el("div", { cls: "cx-menu-heading", text: item.heading })); continue; } if (item.separator) { list.append(el("div", { cls: "cx-menu-sep", attrs: { role: "separator" } })); continue; } list.append(el("button", { cls: ["cx-menu-item", "cx-focusable", item.danger ? "cx-danger" : null], diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index f2b0ec6b..5bdbf5db 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -2,6 +2,7 @@ import { composer as c } from "../../composer/composer.js"; import { isGenerative, isVideoClip, genUnitId, unitRoot, effFrames, effFps } from "../../shell/scenes.js"; import { QUEUED, RUNNING } from "../../shell/run.js"; +import { inPlace } from "../../shell/place.js"; import { buildInputs } from "./inputs.js"; const expand = (body) => fetch("/funpack/api/prompt/expand", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -75,7 +76,8 @@ export default { } const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))]; - let nodes = [], drawn = ""; + const put = inPlace(host); + let drawn = ""; function draw() { const open = p.project; const key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${Boolean(p.selected)}`; @@ -85,10 +87,9 @@ export default { const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, onClick: () => runUnits(unitsOf(p.scenes)) }); const one = c.button.sm({ label: "Selected", disabled: working || !p.selected, onClick: () => runUnits(unitsOf([p.selected])) }); - const stop = c.button.sm({ label: "■ Stop", tone: "danger", disabled: !working, onClick: () => { stopped = true; g.cancel(); } }); - const next = [go, one, stop].map((b) => b.node); - if (nodes.length) nodes.forEach((n, i) => n.replaceWith(next[i])); else host.append(...next); // in place: keeps its spot in the row - nodes = next; + const stop = c.button.sm({ label: "■ Stop", tone: "danger", onClick: () => { stopped = true; g.cancel(); } }); + stop.node.hidden = !working; + put(go.node, one.node, stop.node); } // A run found after a reload belongs to this page's last session: its result still goes on its clips. const claim = ({ sceneId, projectId }) => { @@ -123,7 +124,7 @@ export default { }); draw(); const offRun = g.subscribe(draw); - const offApp = app.on(draw); + const offApp = app.on((what) => { if (what === "generate.selected" && !busy && p.selected) runUnits(unitsOf([p.selected])); else draw(); }); return () => { offRun(); offApp(); }; }, }; diff --git a/app/ui/features/generate/warn.js b/app/ui/features/generate/warn.js new file mode 100644 index 00000000..b65653a1 --- /dev/null +++ b/app/ui/features/generate/warn.js @@ -0,0 +1,17 @@ +// The chip beside Generate when the pipeline has nowhere to put the scene text. +import { composer as c } from "../../composer/composer.js"; + +export default { + id: "generate-warn", + mount: "timeline.actions", + needs: ["pipeline"], + setup({ host, app }) { + const chip = c.button.sm({ label: "⚠ Nothing encodes your prompt", tone: "ghost", disabled: true, + title: "This pipeline has no prompt input: the scene text is not sent." }).node; + chip.hidden = true; + host.append(chip); + const draw = (slots) => { chip.hidden = !(slots && !slots.some((s) => (s.roles || []).some((r) => r.at === "generation.prompt"))); }; + draw(app.pipeline.slots()); + return app.pipeline.subscribe ? app.pipeline.subscribe(draw) : undefined; + }, +}; diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index bedf4f97..adf29cef 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -1,47 +1,8 @@ -// The Properties zone: what this scene is, and what the whole project makes. +// The Properties zone: Project and Scene tabs, titled for what they show. import { composer as c } from "../../composer/composer.js"; -import { effFrames, effFps, isSubclip, DRIVEN } from "../../shell/scenes.js"; - -const SOURCES = [{ value: "carry", label: "Continue from previous" }, { value: "image", label: "Image" }, { value: "video", label: "Video" }]; - -const sceneRows = (p) => { - const sc = p.selected, open = p.project; - if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })]; - return [ - c.label.section({ text: `Scene ${p.scenes.indexOf(sc) + 1}` }), - ...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut of a longer clip: its prompt and source belong to the first part." })] : [ - c.textarea.md({ label: "Scene prompt", value: sc.text || "", rows: 6, autoGrow: true, onInput: (v) => p.setText(sc.id, v) }), - c.settingsRow.default({ label: "Source", control: c.select.md({ label: "Source", options: SOURCES, value: (sc.source || {}).type || "carry", - onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) })]), - c.settingsRow.default({ label: "Frames", hint: `${effFrames(sc, open)} frames at ${effFps(sc, open)} fps. Trim on the timeline to change.` }), - c.toggle.default({ label: "Leave out of the cut", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) }), - ]; -}; - -// What the pipeline lets the project decide (width, length, rate...): one whole-number control per slot input -// that declares the role. A role that `drives` length or rate edits the number the timeline draws. -const videoRows = (p, slots) => (slots || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "project.video" && r.input).map((role) => { - const field = DRIVEN[role.drives]; - const now = field ? p.project[field] : p.video[role.input] ?? (slot.inputs || {})[role.input]; - if (!Number.isInteger(now)) return null; // the project file keeps whole numbers only - const label = role.label || role.input; - return { key: field || role.input, row: c.settingsRow.default({ label, control: c.number.md({ label, min: 1, max: role.drives === "fps" ? 1000 : 16384, step: 1, precision: 0, value: now, - onChange: (v) => (field ? p.setField(field, v) : p.setVideo(role.input, v)) }) }) }; -}).filter(Boolean)).filter((r, i, all) => all.findIndex((x) => x.key === r.key) === i).map((r) => r.row); // one row per thing edited - -const projectRows = (p, slots) => { - const open = p.project; - if (!open) return [c.emptyState.default({ icon: "▭", title: "No project", hint: "Open or create one." })]; - const rows = videoRows(p, slots); - const num = (label, key, fallback, min) => c.settingsRow.default({ label, control: c.number.md({ label, min, max: key === "frame_rate" ? 1000 : 16384, step: 1, precision: 0, value: open[key] || fallback, - onChange: (v) => p.setField(key, v) }) }); - return [ - c.label.section({ text: "Project" }), - ...(rows.length ? rows : [num("Frames per scene", "num_frames_per_scene", 97, 9), num("Frame rate", "frame_rate", 25, 1)]), - c.label.section({ text: "Negative prompt" }), - c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 3, autoGrow: true, onInput: (v) => p.setNegative(v) }), - ]; -}; +import { effFrames, effFps, genUnitId } from "../../shell/scenes.js"; +import { sceneRows } from "./scene.js"; +import { projectRows } from "./project.js"; export default { id: "inspector", @@ -54,12 +15,20 @@ export default { const tabs = c.segmented.sm({ label: "Properties", options: [{ value: "project", label: "Project" }, { value: "scene", label: "Scene" }], value: tab, onChange: (v) => { tab = v; draw(true); } }); host.append(tabs.node, body.node); + + const titled = () => { + const sc = p.selected; + if (tab === "project" || !sc) return app.title && app.title("inspector", tab === "project" ? "Project" : "Scene"); + const unit = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)); + app.title && app.title("inspector", `Scene · ${p.scenes.indexOf(sc) + 1}${unit.length > 1 ? ` · cut ${unit.indexOf(sc) + 1}/${unit.length}` : ""}`); + }; function draw(force) { // not on every keystroke: that would rebuild the box being typed in const sc = p.selected, open = p.project; - const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc)] : ""}`; // a trim or reorder changes what the panel says + const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length] : ""}`; + titled(); if (!force && next === key) return; key = next; - body.set((tab === "scene" ? sceneRows : projectRows)(p, app.pipeline && app.pipeline.slots())); + body.set((tab === "scene" ? sceneRows(p, app) : projectRows(p, app.pipeline && app.pipeline.slots()))); } draw(true); const offSlots = app.pipeline && app.pipeline.subscribe ? app.pipeline.subscribe(() => draw(true)) : null; // the pipeline arrives after the project diff --git a/app/ui/features/inspector/project.js b/app/ui/features/inspector/project.js new file mode 100644 index 00000000..c98c2fe5 --- /dev/null +++ b/app/ui/features/inspector/project.js @@ -0,0 +1,48 @@ +// The Project tab: what the whole project makes. +import { composer as c } from "../../composer/composer.js"; +import { DRIVEN } from "../../shell/scenes.js"; + +const SIZES = [{ value: "", label: "The first clip's own size" }, { value: "project", label: "The project's size" }]; +const STARTS = [{ value: "image", label: "From an image" }, { value: "prompt", label: "From a prompt" }]; + +// What the pipeline lets the project decide (width, length, rate...): one whole-number control per slot input +// that declares the role. A role that `drives` length or rate edits the number the timeline draws. +const videoControls = (p, slots) => (slots || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "project.video" && r.input).map((role) => { + const field = DRIVEN[role.drives]; + const now = field ? p.project[field] : p.video[role.input] ?? (slot.inputs || {})[role.input]; + if (!Number.isInteger(now)) return null; // the project file keeps whole numbers only + const label = role.label || role.input; + return { key: field || role.input, label, field: c.field.default({ label, control: c.number.md({ label, min: 1, max: role.drives === "fps" ? 1000 : 16384, step: 1, precision: 0, value: now, + onChange: (v) => (field ? p.setField(field, v) : p.setVideo(role.input, v)) }) }) }; +}).filter(Boolean)).filter((r, i, all) => all.findIndex((x) => x.key === r.key) === i); + +const num = (p, label, key, fallback) => ({ key, label, field: c.field.default({ label, control: c.number.md({ label, min: 1, max: key === "frame_rate" ? 1000 : 16384, step: 1, precision: 0, + value: p.project[key] || fallback, onChange: (v) => p.setField(key, v) }) }) }); + +export function projectRows(p, slots) { + const open = p.project; + if (!open) return [c.emptyState.default({ icon: "▭", title: "No project", hint: "Open or create one." })]; + const controls = videoControls(p, slots); + const pick = (...keys) => keys.map((k) => controls.find((x) => x.key === k)).filter(Boolean); + const frames = pick("num_frames_per_scene")[0] || num(p, "Frames / scene", "num_frames_per_scene", 97); + const fps = pick("frame_rate")[0] || num(p, "FPS", "frame_rate", 25); + const size = controls.filter((x) => x !== frames && x !== fps); + const per = (open.num_frames_per_scene || 97) / (open.frame_rate || 25); + return [ + c.label.section({ text: "Project" }), + c.field.default({ label: "Project name", control: c.input.md({ label: "Project name", value: open.name || "", onCommit: (v) => p.rename(v) }) }), + c.label.section({ text: "Video settings" }), + c.field.row({ fields: [frames.field, fps.field] }), + c.hint.default({ text: `≈ ${per.toFixed(2)} s per shot` }), + ...(size.length ? [c.field.row({ fields: size.map((x) => x.field) })] : []), + c.field.default({ label: "Final render size", control: c.select.md({ label: "Final render size", options: SIZES, value: open.export_size_from || "", + onChange: (v) => p.setField("export_size_from", v) }) }), + c.field.default({ label: "Start shots", control: c.select.md({ label: "Start shots", options: STARTS, value: "image", onChange() {}, disabled: true }) }), + c.label.section({ text: "Prompt" }), + c.field.default({ label: "Anchor", control: c.textarea.md({ label: "Anchor", value: p.anchor, rows: 2, onInput: (v) => p.setAnchor(v) }) }), + c.field.default({ label: "Negative prompt", control: c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 2, onInput: (v) => p.setNegative(v) }) }), + c.field.default({ label: "Postfix", control: c.textarea.md({ label: "Postfix", value: p.postfix, rows: 2, onInput: (v) => p.setPostfix(v) }) }), + c.collapsible.default({ label: "ADVANCED PROJECT SETTINGS", body: c.collapsible.default({ label: "Split preview (generation prompt)", + body: c.hint.default({ text: "ComfyUI offline — preview paused" }) }) }), + ]; +} diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js new file mode 100644 index 00000000..837dfa66 --- /dev/null +++ b/app/ui/features/inspector/scene.js @@ -0,0 +1,42 @@ +// The Scene tab: this clip's prompt, where it starts from, how long it runs. +import { composer as c } from "../../composer/composer.js"; +import { effFrames, effFps, genUnitId, isSubclip } from "../../shell/scenes.js"; + +const SOURCES = [{ value: "image", label: "Image · i2v anchor" }, { value: "carry", label: "From generated frame" }, { value: "video", label: "Video clip" }]; +const MODES = [{ value: "project", label: "Project default" }, { value: "timeline", label: "Timeline trim" }, { value: "custom", label: "Custom" }]; +const inert = (label, extra = {}) => c.button.sm({ label, tone: "ghost", disabled: true, ...extra }); + +/** A number-or-mode pair: "Project default / Timeline trim / Custom", and the number only when Custom. */ +const lengthField = (p, sc, label, modeKey, valueKey, shown) => { + const mode = sc[modeKey] || "project"; + return c.field.default({ label, control: c.region.stack({ gap: "xs", children: [ + c.select.md({ label, options: MODES, value: mode, onChange: (v) => p.setScene(sc.id, modeKey, v) }), + mode === "custom" ? c.number.md({ label: `${label} (custom)`, min: 1, max: 16384, precision: 0, value: sc[valueKey] ?? shown, + onChange: (v) => p.setScene(sc.id, valueKey, v) }) : null, + ] }) }); +}; + +export function sceneRows(p, app) { + const sc = p.selected, open = p.project; + if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })]; + const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1; + return [ + c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick() {} } }), + ...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []), + ...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [ + c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }), + c.field.default({ label: "Source", control: c.select.md({ label: "Source", options: SOURCES, value: (sc.source || {}).type || "carry", + onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) }), + ]), + c.label.section({ text: "Reference media (v5 pipeline)" }), + c.field.default({ label: "Resolution source", hint: "Sets this scene's aspect ratio for generation — the project's own Width/Height set the actual resolution; this image's pixels are not used.", + control: c.toolbar.default({ items: [c.text.sm({ text: "◇ — choose resolution source —" })], trailing: [inert("Browse")] }) }), + c.field.default({ label: "References", control: c.toolbar.default({ items: [c.text.sm({ text: "◇ + Add reference" })], trailing: [inert("Browse"), inert("✕")] }) }), + c.field.row({ fields: [ + lengthField(p, sc, "Frames", "frames_mode", "frames", effFrames(sc, open)), + lengthField(p, sc, "FPS", "fps_mode", "fps", effFps(sc, open)), + ] }), + c.button.sm({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.selected") }), + c.checkbox.default({ label: "Exclude from full generation", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) }), + ]; +} diff --git a/app/ui/features/media/hub.test.js b/app/ui/features/media/hub.test.js index 74aa118e..6f0fdc79 100644 --- a/app/ui/features/media/hub.test.js +++ b/app/ui/features/media/hub.test.js @@ -15,8 +15,9 @@ test("choosing an image in the bin makes it the selected scene's source", async await new Promise((r) => setTimeout(r, 10)); const cells = [...host.querySelectorAll(".cx-cell")]; assert.equal(cells.length, 2); - cells[0].click(); + const named = (n) => cells.find((x) => x.textContent.includes(n)); + named("cat.png").click(); assert.deepEqual(set, [["s1", "source_image", "m1"]]); - cells[1].click(); // audio: refused, nothing set + named("a.wav").click(); // audio: refused, nothing set assert.equal(set.length, 1); }); diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index 71dc7167..254e14b2 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -12,38 +12,53 @@ export default { let items = []; const tell = (text) => c.toast.warn({ text }); - const gallery = c.gallery.adaptive({ - id: "media", items: [], empty: "No media yet. Drop images or clips here.", - onActivate: (it) => { // an image becomes the selected scene's source - const sc = p.selected, item = items.find((m) => m.id === it.id); - if (!sc || !item) return tell("Select a scene first."); - if (item.kind !== "image") return tell("Only an image can be a scene's source here."); - p.setScene(sc.id, "source_image", item.id); - draw(); - }, - onContext: (it, e) => c.menu.context({ x: e.clientX, y: e.clientY, items: [{ id: "delete", label: "Delete from the bin", danger: true }], - onPick: async () => { - try { - await api.deleteMedia(it.id); - p.edit((pr) => pr.scenes.reduce((hit, s) => (s.source_image === it.id ? (s.source_image = "", true) : hit), false)); // nothing may keep pointing at it - await refresh(); - } catch (err) { tell(err.message); } - } }), - }); + const FILTERS = [{ value: "all", label: "All" }, { value: "video", label: "Video" }, { value: "audio", label: "Audio" }, { value: "image", label: "Images" }]; + const SORTS = [{ value: "name", label: "Name A-Z" }, { value: "name-", label: "Name Z-A" }, { value: "kind", label: "Type" }, { value: "added", label: "Date added" }]; + const VIEWS = [{ value: "adaptive", label: "Grid" }, { value: "list", label: "List" }, { value: "icons", label: "Icons" }]; + const DENSITY = [{ value: "0", label: "Auto" }, ...[1, 2, 3, 4].map((n) => ({ value: String(n), label: `${n}×` }))]; + const show = { filter: "all", sort: "name", view: "adaptive", cols: "0" }; + + const pick = (it) => { // an image becomes the selected scene's source + const sc = p.selected, item = items.find((m) => m.id === it.id); + if (!sc || !item) return tell("Select a scene first."); + if (item.kind !== "image") return tell("Only an image can be a scene's source here."); + p.setScene(sc.id, "source_image", item.id); + draw(); + }; + const context = (it, e) => c.menu.context({ x: e.clientX, y: e.clientY, items: [{ id: "delete", label: "Delete from the bin", danger: true }], + onPick: async () => { + try { + await api.deleteMedia(it.id); + p.edit((pr) => pr.scenes.reduce((hit, s) => (s.source_image === it.id ? (s.source_image = "", true) : hit), false)); // nothing may keep pointing at it + await refresh(); + } catch (err) { tell(err.message); } + } }); + const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: pick, onContext: context }; + const galleries = { adaptive: c.gallery.adaptive(props), list: c.gallery.list(props), icons: c.gallery.icons(props) }; + const shelf = c.region.stack({ gap: "none", children: [galleries.adaptive] }); const drop = c.dropzone.default({ label: "Drop or choose files", hint: "images, clips, audio", onFiles: async (files) => { try { const r = await api.uploadMedia(files); (r.problems || []).forEach(tell); await refresh(); } catch (err) { tell(err.message); } } }); - host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })] }).node, drop.node, gallery.node); + const seg = (label, options, key) => c.segmented.sm({ label, options, value: show[key], onChange: (v) => { show[key] = v; draw(true); } }); + const sort = c.select.sm({ label: "Sort", options: SORTS, value: show.sort, onChange: (v) => { show.sort = v; draw(true); } }); + host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })] }).node, drop.node, seg("Show", FILTERS, "filter").node, sort.node, + seg("View", VIEWS, "view").node, seg("Tile size", DENSITY, "cols").node, shelf.node); let drawn = ""; function draw(force) { const key = `${(p.selected || {}).source_image}|${items.map((m) => m.id)}`; if (!force && key === drawn) return; // typing elsewhere must not rebuild the thumbnails drawn = key; - gallery.setItems(items.map((m) => ({ id: m.id, label: m.name, thumb: m.kind === "audio" ? undefined : `${base(m.id)}/thumb`, badge: m.kind }))); + const rev = show.sort.endsWith("-") ? -1 : 1, by = show.sort.replace("-", ""); + const shown = items.filter((m) => show.filter === "all" || m.kind === show.filter) + .sort((x, y) => rev * (by === "added" ? (y.added || 0) - (x.added || 0) : String(x[by === "kind" ? "kind" : "name"]).localeCompare(String(y[by === "kind" ? "kind" : "name"])))); + const g = galleries[show.view]; + shelf.set([g]); + if (g.setCols) g.setCols(Number(show.cols)); + g.setItems(shown.map((m) => ({ id: m.id, label: m.name, thumb: m.kind === "audio" ? undefined : `${base(m.id)}/thumb`, badge: m.kind }))); const sc = p.selected; - gallery.setValue(sc && sc.source_image ? [sc.source_image] : []); + g.setValue(sc && sc.source_image ? [sc.source_image] : []); } async function refresh() { try { items = (await api.media()).media || []; } catch (err) { tell(err.message); } diff --git a/app/ui/features/menus/hub.test.js b/app/ui/features/menus/hub.test.js index 540b6d7f..b062df16 100644 --- a/app/ui/features/menus/hub.test.js +++ b/app/ui/features/menus/hub.test.js @@ -9,31 +9,30 @@ test.after(() => teardownDom()); const rig = (over = {}) => { const calls = []; const project = { project: { id: "P", name: "Demo" }, canUndo: true, canRedo: false, undo: () => calls.push("undo"), redo: () => calls.push("redo"), ...over }; + const selection = { ids: [], focus: null }; const host = document.createElement("div"); - hub.setup({ host, app: { project } }); + hub.setup({ host, app: { project, selection } }); return { host, calls }; }; -const pick = (host, menu, item) => { +const pick = async (host, menu, item) => { [...host.querySelectorAll("button")].find((b) => b.textContent === menu).click(); + await new Promise((r) => setTimeout(r, 10)); [...document.querySelectorAll(".cx-menu-item")].find((b) => b.textContent.includes(item)).click(); }; -test("Edit > Undo undoes; Redo is greyed when there is nothing to redo", () => { +test("Edit > Undo undoes; Redo is greyed when there is nothing to redo", async () => { const { host, calls } = rig(); - pick(host, "Edit", "Undo"); + await pick(host, "Edit", "Undo"); assert.deepEqual(calls, ["undo"]); [...host.querySelectorAll("button")].find((b) => b.textContent === "Edit").click(); + await new Promise((r) => setTimeout(r, 10)); assert.equal([...document.querySelectorAll(".cx-menu-item")].find((b) => b.textContent.includes("Redo")).disabled, true); }); -test("File > Rename renames the open project", async () => { - const renamed = []; - const { host } = rig({ rename: (n) => renamed.push(n) }); - pick(host, "File", "Rename"); +test("Edit > Delete Scene is greyed until a scene is picked; File lists recent projects", async () => { + const { host } = rig(); + [...host.querySelectorAll("button")].find((b) => b.textContent === "Edit").click(); await new Promise((r) => setTimeout(r, 10)); - const input = document.querySelector("input"); - input.value = "Better"; - [...document.querySelectorAll("button")].find((b) => b.textContent === "Rename").click(); - await new Promise((r) => setTimeout(r, 10)); - assert.deepEqual(renamed, ["Better"]); + assert.equal([...document.querySelectorAll(".cx-menu-item")].find((b) => b.textContent.includes("Delete Scene")).disabled, true); + assert.ok([...document.querySelectorAll(".cx-menu-item")].find((b) => b.textContent.includes("Add Scene"))); }); diff --git a/app/ui/features/menus/index.js b/app/ui/features/menus/index.js index 37ab60e0..adef2777 100644 --- a/app/ui/features/menus/index.js +++ b/app/ui/features/menus/index.js @@ -1,18 +1,23 @@ -// The File and Edit menus of the menubar. +// The menubar: File, Edit, View, Help. What is not built yet is listed and greyed ("soon"), so the bar reads as v4's. import { composer as c } from "../../composer/composer.js"; import { list, remove } from "../../shell/project.js"; +import { segments } from "../../shell/scenes.js"; +import * as edits from "../../shell/edits.js"; + +const soon = (label, hint = "soon") => ({ id: "-", label, hint, disabled: true }); +const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) => s.id); export default { id: "menus", mount: "menubar.menus", - needs: ["project"], + needs: ["project", "selection"], setup({ host, app }) { - const p = app.project; + const p = app.project, sel = app.selection; const ask = (title, value, confirmLabel) => c.modal.prompt({ title, label: "Name", value, confirmLabel }).result; + const move = (by) => p.edit((pr) => { const order = sceneIds(pr), at = order.indexOf(sel.focus); return at >= 0 && edits.reorder(pr, sel.focus, at + by, order); }); const actions = { new: async () => { const name = await ask("New project", "Untitled"); if (name) await p.newProject(name.trim() || "Untitled"); }, - rename: async () => { const name = await ask("Rename project", p.project.name, "Rename"); if (name) p.rename(name); }, delete: async () => { const open = p.project; if (!(await c.modal.dialogue({ title: "Delete project", message: `Delete "${open.name}" for good?`, tone: "danger", confirmLabel: "Delete" }).result)) return; @@ -21,16 +26,31 @@ export default { await remove(open.id); if (next) await p.open(next.id); else await p.newProject("Untitled"); }, - undo: () => p.undo(), - redo: () => p.redo(), + undo: () => p.undo(), redo: () => p.redo(), + 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), + exclude: () => { const sc = p.selected; if (sc) p.setScene(sc.id, "excluded", !sc.excluded); }, }; const menus = { - File: () => [{ id: "new", label: "New project…" }, { id: "rename", label: "Rename…", disabled: !p.project }, - { separator: true }, { id: "delete", label: "Delete project…", danger: true, disabled: !p.project }], - Edit: () => [{ id: "undo", label: "Undo", disabled: !p.canUndo }, { id: "redo", label: "Redo", disabled: !p.canRedo }], + File: async () => { + const recent = await list().catch(() => []); + return [{ id: "new", label: "New Project", hint: "⌘N" }, soon("Project Setup Wizard…", "theme · model · tour"), { separator: true }, + { heading: "Open recent" }, ...(recent.length ? recent.slice(0, 8).map((r) => ({ id: `open:${r.id}`, label: r.name })) : [{ id: "-", label: "No projects", disabled: true }]), + { separator: true }, soon("Save Project File…", "⬇"), soon("Load Project File…"), { separator: true }, soon("Import Media…"), + { id: "delete", label: "Delete Current Project", danger: true, disabled: !p.project }]; + }, + Edit: () => [{ id: "undo", label: "Undo", hint: "⌘Z", disabled: !p.canUndo }, { id: "redo", label: "Redo", hint: "⇧⌘Z", disabled: !p.canRedo }, { separator: true }, + { 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…", "?")], }; + const pick = (id) => (id.startsWith("open:") ? p.open(id.slice(5)) : actions[id] && actions[id]()); for (const [label, items] of Object.entries(menus)) { - const button = c.button.menu({ label, tone: "ghost", onClick: () => c.menu.dropdown({ anchor: button, items: items(), onPick: (id) => Promise.resolve(actions[id]()).catch((err) => c.toast.warn({ text: err.message })) }) }); + const button = c.button.menu({ label, tone: "ghost", onClick: async () => c.menu.dropdown({ anchor: button, items: await items(), + onPick: (id) => Promise.resolve(pick(id)).catch((err) => c.toast.warn({ text: err.message })) }) }); host.append(button.node); } }, diff --git a/app/ui/features/placeholders/early.js b/app/ui/features/placeholders/early.js new file mode 100644 index 00000000..3eed2146 --- /dev/null +++ b/app/ui/features/placeholders/early.js @@ -0,0 +1,5 @@ +// Stand-ins that sit BEFORE a real feature in the same row (see index.js). +import { composer as c } from "../../composer/composer.js"; + +export default [{ id: "placeholder:sampler", mount: "timeline.status", + setup: ({ host }) => host.append(c.button.sm({ label: "⏱ Sampler", tone: "neutral", disabled: true }).node) }]; diff --git a/app/ui/features/placeholders/index.js b/app/ui/features/placeholders/index.js index e1c90c04..feaa427f 100644 --- a/app/ui/features/placeholders/index.js +++ b/app/ui/features/placeholders/index.js @@ -8,21 +8,18 @@ const dockTab = (label) => c.button.sm({ label, tone: "neutral", pressed: true, const items = { "menubar.mode": () => [c.segmented.sm({ options: [{ value: "simple", label: "Simple" }, { value: "editor", label: "Editor" }], value: "editor" })], - "menubar.menus": () => ["View", "Help"].map(menu), "menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })], preview: () => [ - c.toolbar.default({ items: [btn("⏹"), btn("▶"), c.text.sm({ text: "00:00:00:00" }), btn("📌 Save frame")] }), + c.progress.bar({ value: 0, label: "Playback position" }), + c.toolbar.default({ items: [c.iconButton.sm({ icon: "⏹", label: "Stop", disabled: true }), c.iconButton.sm({ icon: "▶", label: "Play", disabled: true }), + c.text.sm({ text: "00:00:00:00" }), c.select.sm({ label: "Save frame to", disabled: true, options: [{ value: "", label: "— save to Media bin —" }], value: "" }), + btn("📌 Save frame")] }), ], - "timeline.actions": () => [btn("⚡ Auto Montage")], - "timeline.status": () => [btn("⏱ Sampler", "neutral"), c.text.sm({ text: "0 clips" }), - dockTab("Assets"), dockTab("Preview"), dockTab("Properties"), btn("◆ Composer", "neutral")], - timeline: () => [ - c.toolbar.default({ items: [btn("⤓ Export"), btn("Save to media bin"), btn("⊟ Separate audio"), btn("Remove audio")], - trailing: [c.text.sm({ text: "J/K/L · S split · I/O in/out · +/- zoom" })] }), - ], + "timeline.status": () => [dockTab("Assets"), dockTab("Preview"), dockTab("Properties"), btn("◆ Composer", "neutral")], + "timeline.toolbar": () => [btn("⤓ Export"), btn("Save to media bin"), btn("⊟ Separate audio"), btn("Remove audio")], }; export default Object.entries(items).map(([mount, build]) => ({ diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index 6a7e0d5b..0cf781e8 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -14,14 +14,16 @@ export default { needs: ["project"], setup({ host, app }) { const p = app.project; - const viewer = c.viewer.media({ kind: "video", empty: "No render yet. Use Generate in the timeline header." }); - host.append(viewer.node); + 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 = ""; 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 if (src === shown) return; // a keystroke elsewhere must not restart the video shown = src; + viewer.node.hidden = !src; empty.node.hidden = Boolean(src); viewer.setSource(src || null, "video", src ? media.media : null); }; draw(); diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 6961e3c8..18fa4cf8 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -1,76 +1,111 @@ -// The cutting-room timeline: scenes as clips on a stage, trimmed, reordered, split, added and removed by hand. +// The cutting-room timeline. One hub, several features: the stage (clips you trim, reorder, split), the buttons +// that act on the cut, the zoom controls, and the count in the zone's head. They share only the app's +// selection, playhead and message bus. import { composer as c } from "../../composer/composer.js"; -import { createSelection } from "../../shell/selection.js"; import { segments, totalSeconds, clock } from "../../shell/scenes.js"; +import { inPlace } from "../../shell/place.js"; import * as edits from "../../shell/edits.js"; -import { videoLane, framesAt } from "./lanes.js"; +import { videoLane, audioLane, framesAt } from "./lanes.js"; const ZOOM = [20, 40, 80, 160, 320]; +const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) => s.id); -export default { +const stage = { id: "timeline", mount: "timeline", - needs: ["project"], + needs: ["project", "selection", "playhead"], setup({ host, app }) { - const p = app.project; - const sel = createSelection({ project: p }); + const p = app.project, sel = app.selection, head = app.playhead; let zoom = 2; - let at = 0; - const edit = (fn) => p.edit(fn); - const stage = c.timeline.stage({ + const view = c.timeline.stage({ pxPerSecond: ZOOM[zoom], - onSeek: (sec) => { at = sec; stage.setPlayhead(sec); bar(); }, - onSelect: (id, how) => sel.pick(id, how, segments(p.project).filter((s) => s.kind === "scene").map((s) => s.id)), + onSeek: (sec) => head.set(sec), + onSelect: (id, how) => sel.pick(id, how, sceneIds(p.project)), onTrim: (id, edge, d) => { - const sc = p.project.scenes.find((s) => s.id === id); - if (!sc) return; const dur = segments(p.project).find((s) => s.id === id).dur; edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in }, onReorder: (id, index) => edit((pr) => { // the stage counts ghosts and pauses too; the cut counts scenes const all = segments(pr).filter((s) => s.id !== id); - const order = segments(pr).filter((s) => s.kind === "scene").map((s) => s.id); - return edits.reorder(pr, id, all.slice(0, index).filter((s) => s.kind === "scene").length, order); + return edits.reorder(pr, id, all.slice(0, index).filter((s) => s.kind === "scene").length, sceneIds(pr)); }), }); + const move = (id, by) => edit((pr) => { const order = sceneIds(pr), at = order.indexOf(id); return at >= 0 && edits.reorder(pr, id, at + by, order); }); + const clipActions = (sc) => [{ label: "◀", title: "Move left in the cut", onClick: () => move(sc.id, -1) }, { label: "▶", title: "Move right in the cut", onClick: () => move(sc.id, 1) }, + { label: "Remove", title: "Remove this clip", onClick: () => edit((pr) => edits.removeScene(pr, sc.id)) }]; + const ghostActions = (g) => [{ label: "✕", title: "Remove from the timeline", + onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }]; - let toolbar = c.toolbar.default({ items: [] }); - let zoomBar = c.toolbar.default({ items: [] }); - const swap = (old, items) => { const next = c.toolbar.default({ items }); old.node.replaceWith(next.node); return next; }; - - const btn = (label, onClick, disabled = false, title) => c.button.sm({ label, onClick, disabled, title }); - function bar() { - const open = p.project, ids = open ? sel.ids : []; - const splitAt = () => { // the selected clip, at the playhead if it is over it, else in the middle - const seg = segments(open).find((s) => s.id === sel.focus && s.kind === "scene"); - if (!seg) return; - const inside = at > seg.start && at < seg.start + seg.dur; - if (!edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, at - seg.start) : undefined))) c.toast.warn({ text: "Too close to the clip's edge to cut there." }); - }; - toolbar = swap(toolbar, [ - c.text.sm({ text: `${clock(at)} / ${clock(open ? totalSeconds(open) : 0)}` }), - btn("+ Add", () => { const sc = edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }, !open), - btn("Split", splitAt, !ids.length, "Split the selected clip at the playhead"), - btn("Remove", () => ids.forEach((id) => edit((pr) => edits.removeScene(pr, id))), !ids.length), - ids.length > 1 ? c.chip.neutral({ label: `${ids.length} selected` }) : c.text.sm({ text: "" }), - ]); - zoomBar = swap(zoomBar, [ - c.text.sm({ text: "zoom" }), - btn("−", () => setZoom(zoom - 1), zoom <= 0), btn("+", () => setZoom(zoom + 1), zoom >= ZOOM.length - 1), - ]); - } - function setZoom(z) { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); stage.setZoom(ZOOM[zoom]); bar(); } - - const draw = () => { - const open = p.project; - stage.setLanes(open ? [videoLane(open, sel.ids, sel.focus, undefined, (g) => [{ label: "✕", title: "Remove from the timeline", - onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }])] : []); - bar(); - }; - host.append(toolbar.node, zoomBar.node, stage.node); - const off = [app.on(draw), sel.on(draw)]; + const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions), audioLane(open, sel.ids)] : []); }; + const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); }; + host.append(view.node); + const off = [app.on((what) => { + if (what === "zoom.in") setZoom(zoom + 1); else if (what === "zoom.out") setZoom(zoom - 1); + else if (what === "zoom.fit") { const total = p.project ? totalSeconds(p.project) : 0; const fit = ZOOM.filter((z) => z * (total + 2) <= view.node.clientWidth); setZoom(fit.length ? ZOOM.indexOf(fit[fit.length - 1]) : 0); } + else draw(); + }), head.on((sec) => view.setPlayhead(sec))]; draw(); - return () => { off.forEach((f) => f()); stage.destroy(); }; + return () => { off.forEach((f) => f()); view.destroy(); }; }, }; + +// Add / Split / Remove, the timecode, and how many clips are picked. +const tools = { + id: "timeline-tools", + mount: "timeline.toolbar", + needs: ["project", "selection", "playhead"], + setup({ host, app }) { + const p = app.project, sel = app.selection, head = app.playhead; + const put = inPlace(host); + const btn = (label, onClick, disabled, title) => c.button.sm({ label, onClick, disabled, title, tone: "ghost" }).node; + const split = () => { // the selected clip, at the playhead if it is over it, else in the middle + const seg = segments(p.project).find((s) => s.id === sel.focus && s.kind === "scene"); + if (!seg) return; + const inside = head.at > seg.start && head.at < seg.start + seg.dur; + if (!p.edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, head.at - seg.start) : undefined))) c.toast.warn({ text: "Too close to the clip's edge to cut there." }); + }; + const draw = () => { + const open = p.project, ids = open ? sel.ids : []; + put(c.text.sm({ text: `${clock(head.at)} / ${clock(open ? totalSeconds(open) : 0)}` }).node, + btn("+ Add", () => { const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }, !open), + btn("Split", split, !ids.length, "Split the selected clip at the playhead"), + btn("Remove", () => ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))), !ids.length), + c.chip.neutral({ label: ids.length ? `${ids.length} clip${ids.length > 1 ? "s" : ""} selected` : "no clip selected" }).node); + }; + draw(); + const off = [app.on(draw), head.on(draw)]; + return () => off.forEach((f) => f()); + }, +}; + +// The hint and the zoom controls, at the end of the same row. +const zoom = { + id: "timeline-zoom", + mount: "timeline.toolbar.end", + needs: ["say"], + setup({ host, app }) { + const b = (label, what, title) => c.button.sm({ label, tone: "ghost", title, onClick: () => app.say(what) }).node; + host.append(c.text.sm({ text: "J/K/L · S split · I/O in/out · +/- zoom" }).node, c.text.sm({ text: "zoom" }).node, + b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window")); + }, +}; + +// "4 clips · 4 active · 1 selected · 00:09:19" at the far end of the zone's head. +const count = { + id: "timeline-count", + mount: "timeline.status", + needs: ["project", "selection"], + setup({ host, app }) { + const p = app.project, sel = app.selection; + const put = inPlace(host); + const draw = () => { + const open = p.project, scenes = open ? open.scenes : []; + put(c.text.sm({ text: `${scenes.length} clips · ${scenes.filter((s) => !s.excluded).length} active · ${open ? sel.ids.length : 0} selected · ${clock(open ? totalSeconds(open) : 0)}` }).node); + }; + draw(); + return app.on(draw); + }, +}; + +export default [stage, tools, zoom, count]; diff --git a/app/ui/features/timeline/lanes.js b/app/ui/features/timeline/lanes.js index f439718f..61a98c2f 100644 --- a/app/ui/features/timeline/lanes.js +++ b/app/ui/features/timeline/lanes.js @@ -25,3 +25,9 @@ export function videoLane(p, picked, focus, actions = () => [], ghostActions = ( /** Frames at `sec` into scene `sc`, for Split. */ export const framesAt = (sc, p, sec) => Math.round(sec * effFps(sc, p)); + +/** The sound that came with the picture, one block under each clip. */ +export const audioLane = (p, picked) => ({ + id: "audio", label: "Original", kind: "audio", + clips: segments(p).filter((s) => s.kind === "scene").map((s, i) => ({ id: `a:${s.id}`, start: s.start, dur: s.dur, title: `S${i + 1}`, selected: picked.includes(s.id), excluded: Boolean(s.scene.excluded) })), +}); diff --git a/app/ui/frame.css b/app/ui/frame.css index d842577e..9c57a181 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -9,3 +9,11 @@ .fp-main > :last-child { flex: 0 0 var(--timeline-h); min-block-size: 200px; border-block-start: var(--bw) solid var(--line); } .cx-frame-head { padding-inline: calc(var(--sp-6) + var(--sp-1)); } .cx-frame-head > .cx-toolbar { gap: calc(var(--sp-6) + var(--sp-3)); } +/* A zone body is a scroller of rows: they keep their height instead of squashing, and a tab strip spans the zone. */ +.cx-zone .cx-panel-body > :not(.cx-stack-fill):not(.cx-viewer) { flex-shrink: 0; } +.cx-zone .cx-stack-fill > :not(.cx-viewer):not(.cx-stack-fill) { flex-shrink: 0; } +.cx-zone .cx-stack-fill > .cx-segmented { display: flex; } +.cx-zone .cx-stack-fill > .cx-segmented .cx-segment { flex: 1 1 0; } +[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; } diff --git a/app/ui/modules.js b/app/ui/modules.js index 2ea22268..0a8461c2 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -3,6 +3,7 @@ export default [ "./features/log/index.js", "./features/projects/index.js", "./features/media/index.js", + "./features/placeholders/early.js", "./features/timeline/index.js", "./features/inspector/index.js", "./features/generate/index.js", @@ -10,5 +11,6 @@ export default [ "./features/menus/index.js", "./features/render/index.js", "./features/placeholders/index.js", + "./features/generate/warn.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 e00f65b0..2ceb7ba9 100644 --- a/app/ui/shell/frame.js +++ b/app/ui/shell/frame.js @@ -1,7 +1,8 @@ // 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 -// assets.projects | assets.media (tabs in the Assets zone) +// 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"; @@ -12,26 +13,33 @@ export function build(root) { offer(mount, bar.node); return bar; }; + const titles = {}; const zone = (title, mount, bodyOf) => { const body = bodyOf ? bodyOf() : composer.region.stack({ gap: "sm", fill: true }); if (!bodyOf) offer(mount, body.node); - return composer.panel.zone({ title, body, actions: [group(`${mount}.actions`)], status: [group(`${mount}.status`)] }); + const panel = composer.panel.zone({ title, body, actions: [group(`${mount}.actions`)], status: [group(`${mount}.status`)] }); + titles[mount] = panel.setTitle; + return panel; }; - // Assets holds two sheets under tabs: the project list and the media bin. + // Assets stacks two sections, as v4 did: the project list, then the media bin below it. const assets = () => { const sheets = ["projects", "media"].map((name) => { - const sheet = composer.region.stack({ gap: "sm", fill: true }); + const sheet = composer.region.stack({ gap: "sm" }); offer(`assets.${name}`, sheet.node); - sheet.node.hidden = name !== "projects"; - return { name, sheet }; + return sheet; }); - const tabs = composer.tabs.underline({ label: "Assets", value: "projects", tabs: [{ value: "projects", label: "Projects" }, { value: "media", label: "Media" }], - onChange: (v) => sheets.forEach(({ name, sheet }) => { sheet.node.hidden = name !== v; }) }); - return composer.region.stack({ gap: "sm", fill: true, children: [tabs, ...sheets.map((x) => x.sheet)] }); + return composer.region.stack({ gap: "md", fill: true, children: sheets }); + }; + // 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")] }); + const stage = composer.region.stack({ gap: "none", fill: true }); + 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 main = composer.region.stack({ gap: "none", fill: true, children: [row, zone("Timeline", "timeline")] }); + 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"); @@ -41,5 +49,6 @@ export function build(root) { }); const frame = composer.frame.app({ header, main }); root.append(frame.node); + frame.setZoneTitle = (mount, text) => titles[mount] && titles[mount](text); return frame; } diff --git a/app/ui/shell/place.js b/app/ui/shell/place.js new file mode 100644 index 00000000..d0a684af --- /dev/null +++ b/app/ui/shell/place.js @@ -0,0 +1,9 @@ +// A feature's own nodes inside a row it shares with others: drawn again in the same spot, never moved to the end. +export function inPlace(host) { + let mine = []; + return (...next) => { + if (mine.length === next.length && mine.length) mine.forEach((n, i) => n.replaceWith(next[i])); + else { mine.forEach((n) => n.remove()); host.append(...next); } + mine = next; + }; +} diff --git a/app/ui/shell/playhead.js b/app/ui/shell/playhead.js new file mode 100644 index 00000000..0849c725 --- /dev/null +++ b/app/ui/shell/playhead.js @@ -0,0 +1,10 @@ +// Where on the timeline "now" is, in seconds. One number, shared by whoever draws or acts at it. +export function createPlayhead() { + let at = 0; + const heard = new Set(); + return { + get at() { return at; }, + set(sec) { const next = Math.max(0, sec); if (next === at) return; at = next; heard.forEach((fn) => fn(at)); }, + on(fn) { heard.add(fn); return () => heard.delete(fn); }, + }; +}