Files

133 lines
8.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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];