133 lines
8.1 KiB
JavaScript
133 lines
8.1 KiB
JavaScript
// 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 { segments, totalSeconds, clock, LTX_GRID } from "../../shell/scenes.js";
|
||
import { gridOf } from "../../shell/frame_grid.js";
|
||
|
||
const gridFor = (app) => gridOf(app.pipeline && app.pipeline.slots()) || LTX_GRID; // trims and cuts land on frame counts this model can make
|
||
import { inPlace } from "../../shell/place.js";
|
||
import * as edits from "../../shell/edits.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);
|
||
|
||
const stage = {
|
||
id: "timeline",
|
||
mount: "timeline",
|
||
needs: ["project", "selection", "playhead"],
|
||
setup({ host, app }) {
|
||
const p = app.project, sel = app.selection, head = app.playhead;
|
||
let zoom = 2;
|
||
const edit = (fn) => p.edit(fn);
|
||
const extra = app.timelineLanes || []; // lanes other features add: { owns(id), lanes(project), select/move/trim(id, ...) }
|
||
const owner = (id) => extra.find((e) => e.owns(id));
|
||
const view = c.timeline.stage({
|
||
pxPerSecond: ZOOM[zoom],
|
||
onSeek: (sec) => head.set(sec),
|
||
onSelect: (id, how) => { const own = owner(id); if (own) own.select(id); else sel.pick(id, how, sceneIds(p.project)); },
|
||
onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); },
|
||
onTrim: (id, edge, d) => {
|
||
const own = owner(id);
|
||
if (own) return own.trim(id, edge, d);
|
||
if (edge === "slip") return edit((pr) => Boolean(((pr.scene_renders || {})[id] || {}).media) && edits.slip(pr, id, d)); // slides what plays; only a rendered clip has more to show
|
||
const dur = segments(p.project).find((s) => s.id === id).dur;
|
||
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d, gridFor(app)) : d > 0 && edits.trimLeft(pr, id, d, gridFor(app))); // the left edge only cuts in
|
||
},
|
||
onNudge: (id, sec) => edit((pr) => edits.nudge(pr, id, sec)),
|
||
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);
|
||
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; }) }];
|
||
|
||
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's
|
||
const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); };
|
||
host.append(view.node);
|
||
if (app.timelineView) app.timelineView.timeAt = view.timeAt; // so a drop can say where on the timeline it landed
|
||
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, true))];
|
||
draw();
|
||
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
|
||
if (!p.project) return;
|
||
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, gridFor(app)))) c.toast.warn({ text: "Too close to the clip's edge to cut there." });
|
||
};
|
||
// One "+ Add" for everything that can go on the timeline: a scene, a clip or sound from the bin, an effect, an overlay.
|
||
const addButton = c.button.menu({ label: "+ Add", tone: "ghost", onClick: () => {
|
||
const groups = [...(app.addMenu || [])].sort((a, b) => a.order - b.order).map((g) => { try { return g.items(); } catch { return []; } }).filter((g) => g.length);
|
||
const items = [{ id: "scene", label: "Scene", disabled: !p.project, run: () => { const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); } },
|
||
...groups.flatMap((g) => [{ separator: true }, ...g])];
|
||
c.menu.dropdown({ anchor: addButton, items, onPick: (id) => { const hit = items.find((i) => i.id === id); if (hit && hit.run) hit.run(); } });
|
||
} });
|
||
const draw = () => {
|
||
const open = p.project, ids = open ? sel.ids : [];
|
||
if (addButton.setDisabled) addButton.setDisabled(!open);
|
||
put(c.hint.default({ text: `${clock(head.at)} / ${clock(open ? totalSeconds(open) : 0)}` }).node,
|
||
addButton.node,
|
||
btn("Split", split, !ids.length, "Cut the selected clip at the playhead (in the middle when the playhead is elsewhere)"),
|
||
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((what) => (what === "split" ? split() : 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;
|
||
const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom", low: true }).node;
|
||
host.append(keys,
|
||
b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window"));
|
||
},
|
||
};
|
||
|
||
// "4 clips · 3 active" in the row with the hint and zoom. The selection and the total time are already in the tools row.
|
||
const count = {
|
||
id: "timeline-count",
|
||
mount: "timeline.toolbar.end",
|
||
needs: ["project"],
|
||
setup({ host, app }) {
|
||
const p = app.project;
|
||
const put = inPlace(host);
|
||
const draw = () => {
|
||
const open = p.project, scenes = open ? open.scenes : [];
|
||
put(c.hint.default({ text: `${scenes.length} ${scenes.length === 1 ? "clip" : "clips"} · ${scenes.filter((s) => !s.excluded).length} active` }).node);
|
||
};
|
||
draw();
|
||
return app.on(draw);
|
||
},
|
||
};
|
||
|
||
export default [stage, tools, zoom, count];
|