feat(ui): v4's screen — inspector tabs/cards/fields, timeline toolbar+zoom row, count, clip move/remove, audio lane, preview empty state and transport, full menus, media bin filters/sort/views
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1e9e3ea947
commit
63dfd9c499
+5
-2
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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" }) }) }),
|
||||
];
|
||||
}
|
||||
@@ -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) }),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) }];
|
||||
@@ -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]) => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) })),
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
+19
-10
@@ -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)
|
||||
// <zone> (body) | <zone>.actions (after the title) | <zone>.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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user