Files
olivv-cs-ComfyUI-FunPack/app/ui/composer/elements/nle.js
T

178 lines
9.7 KiB
JavaScript

// The cutting-room timeline: a ruler in seconds over lanes of clips, a sticky gutter naming each lane, and a
// playhead through all of it. Draws and reports -- what a clip IS, and what a drag MEANS, belong to the feature.
//
// lanes: [{ id, label, kind: "video" | "audio" | "overlay", reorder?, clips: [clip] }]
// clip: { id, start, dur, title?, head?: [text], badge?, body?, thumbs?: [url],
// actions?: [{ label, title, onClick }], selected?, focus?, excluded?, ghost?, rating?, trim? }
// callbacks: onSeek(sec) onSelect(id, { additive, range }) onTrim(id, "in"|"out", deltaSec) onReorder(id, index) onMove(id, deltaSec)
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { drag } from "../internals/drag.js";
import { snapDelta } from "../internals/snap.js";
const NICE = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
const MIN_TICK_PX = 64;
const MIN_CLIP_PX = 12;
const TAIL_SECONDS = 2; // room past the last clip to drop or seek into
const MOVE_THRESHOLD_PX = 12; // a press that travels less than this is a click, not a reorder
const tickEvery = (px) => NICE.find((s) => s * px >= MIN_TICK_PX) || NICE[NICE.length - 1];
const clock = (s) => {
const whole = Math.max(0, Math.floor(s));
return `${String(Math.floor(whole / 60)).padStart(2, "0")}:${String(whole % 60).padStart(2, "0")}`;
};
define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: initial = [], onSeek, onSelect, onTrim, onReorder, onMove } = {}) => {
let px = pxPerSecond;
let lanes = initial;
let playhead = 0;
let disposers = [];
const gutter = el("div", { cls: "cx-nle-gutter" });
const ruler = el("div", { cls: "cx-nle-ruler" });
const laneBox = el("div", { cls: "cx-nle-lanes" });
const head = el("div", { cls: "cx-nle-playhead", attrs: { "aria-hidden": "true" } });
const content = el("div", { cls: "cx-nle-content", children: [ruler, laneBox, head] });
const stage = el("div", { cls: "cx-nle-stage", children: [gutter, content] });
const node = el("div", { cls: "cx-nle", attrs: { role: "application", "aria-label": label }, children: [stage] });
const spanOf = () => Math.max(1, ...lanes.flatMap((l) => l.clips.map((c) => c.start + c.dur))) + TAIL_SECONDS;
const timeAt = (clientX) => Math.max(0, (clientX - content.getBoundingClientRect().left) / px);
function drawRuler(span) {
ruler.replaceChildren();
const every = tickEvery(px);
for (let s = 0; s <= span; s += every) {
const tick = el("span", { cls: "cx-nle-tick", text: clock(s) });
tick.style.insetInlineStart = `${s * px}px`;
ruler.append(tick);
}
}
// Every other clip's edges, the playhead and zero: what a dragged edge sticks to (Shift holds it free).
const SNAP_PX = 10;
const anchorsFor = (clip) => [0, playhead, ...lanes.flatMap((l) => l.clips.filter((c) => c.id !== clip.id).flatMap((c) => [c.start, c.start + c.dur]))];
const snapped = (clip, edges, dxSec, event) => (event && event.shiftKey ? dxSec : snapDelta(dxSec, edges, anchorsFor(clip), SNAP_PX / px));
function clipNode(lane, clip) {
const parts = [];
if (clip.head || clip.actions) {
parts.push(el("div", { cls: "cx-nle-clip-head", children: [
...(clip.head || []).map((t) => el("span", { cls: "cx-nle-clip-tag", text: t })),
el("span", { cls: "cx-nle-clip-actions", children: (clip.actions || []).map((a) => el("button", {
cls: ["cx-nle-clip-action", "cx-focusable"], text: a.label,
attrs: { type: "button", title: a.title, "data-action": "" },
on: { click: (e) => { e.stopPropagation(); a.onClick(); } },
})) }),
] }));
}
if (clip.thumbs && clip.thumbs.length) {
parts.push(el("div", { cls: "cx-nle-strip", children: clip.thumbs.map((url) => el("img", { cls: "cx-nle-thumb", attrs: { src: url, alt: "", loading: "lazy" } })) }));
}
if (clip.title) parts.push(el("div", { cls: "cx-nle-clip-title", text: clip.title }));
if (clip.body) parts.push(el("div", { cls: "cx-nle-clip-body", text: clip.body }));
if (clip.trim) {
for (const edge of ["in", "out"]) parts.push(el("span", { cls: ["cx-nle-trim", `cx-nle-trim-${edge}`], attrs: { "data-trim": edge } }));
}
const cell = el("div", {
cls: ["cx-nle-clip", `cx-nle-clip-${lane.kind}`, clip.selected ? "cx-on" : null, clip.focus ? "cx-focus" : null,
clip.excluded ? "cx-excluded" : null, clip.ghost ? "cx-ghost" : null, clip.rating ? "cx-rated" : null],
attrs: { role: "option", tabindex: "0", "aria-selected": String(Boolean(clip.selected)), "aria-label": clip.title || clip.id,
"data-id": clip.id, "data-rating": clip.rating || undefined },
children: parts,
});
cell.style.insetInlineStart = `${clip.start * px}px`;
cell.style.width = `${Math.max(MIN_CLIP_PX, clip.dur * px)}px`;
cell.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect && onSelect(clip.id, {}); } });
let slipping = false;
// Trim handles first, then the body: a press on a handle must not also start a reorder.
cell.querySelectorAll("[data-trim]").forEach((h) => disposers.push(drag(h, {
onStart: ({ event }) => { event.stopPropagation(); slipping = Boolean(lane.slip && event.altKey && h.dataset.trim === "in"); }, // Alt on the left edge slides what plays, not the length
onMove: ({ dx, event }) => {
if (slipping) { cell.title = `slip ${dx >= 0 ? "+" : ""}${(dx / px).toFixed(2)}s`; return; }
const edge = h.dataset.trim;
dx = snapped(clip, [edge === "out" ? clip.start + clip.dur : clip.start], dx / px, event) * px;
const dur = Math.max(0.1, clip.dur + (edge === "out" ? dx : -dx) / px);
cell.style.width = `${Math.max(MIN_CLIP_PX, dur * px)}px`;
if (edge === "in") cell.style.insetInlineStart = `${(clip.start + (clip.dur - dur)) * px}px`;
},
onEnd: ({ dx, cancelled, event }) => {
if (slipping) { cell.title = clip.title || ""; if (!cancelled && onTrim && dx) onTrim(clip.id, "slip", dx / px); slipping = false; draw(); return; }
dx = snapped(clip, [h.dataset.trim === "out" ? clip.start + clip.dur : clip.start], dx / px, event) * px;
if (!cancelled && onTrim && dx) onTrim(clip.id, h.dataset.trim, dx / px);
draw();
},
})));
let moved = false;
disposers.push(drag(cell, {
onStart: ({ event }) => { if (event.target.closest("[data-action],[data-trim]")) { moved = null; return; } moved = false; },
onMove: ({ dx, event }) => {
if (moved === null) return;
if (Math.abs(dx) >= MOVE_THRESHOLD_PX && (lane.reorder || lane.move)) moved = true;
if (moved && lane.move) dx = snapped(clip, [clip.start, clip.start + clip.dur], dx / px, event) * px;
if (moved) cell.style.transform = `translateX(${dx}px)`;
},
onEnd: ({ dx, event, cancelled }) => {
cell.style.transform = "";
if (moved === null || cancelled) return;
if (!moved) {
if (onSelect) onSelect(clip.id, { additive: event.metaKey || event.ctrlKey, range: event.shiftKey });
return;
}
if (lane.move) { if (onMove) onMove(clip.id, snapped(clip, [clip.start, clip.start + clip.dur], dx / px, event)); draw(); return; } // a free lane: the clip goes where it was dropped, in seconds
// Dropped where its middle now sits among the lane's other clips.
const mid = clip.start + clip.dur / 2 + dx / px;
const others = lane.clips.filter((c) => c.id !== clip.id);
const index = others.filter((c) => c.start + c.dur / 2 < mid).length;
if (onReorder) onReorder(clip.id, index);
draw();
},
}));
return cell;
}
function draw() {
disposers.forEach((d) => d());
disposers = [];
const span = Math.max(spanOf(), ((node.clientWidth || 0) - 96) / px); // the ruler runs to the window's edge, as an editor's does
content.style.width = `${span * px}px`;
drawRuler(span);
gutter.replaceChildren(el("div", { cls: "cx-nle-gutter-ruler" }));
laneBox.replaceChildren();
for (const lane of lanes) {
gutter.append(el("div", { cls: ["cx-nle-gutter-lane", `cx-nle-lane-${lane.kind}`], text: lane.label }));
const row = el("div", { cls: ["cx-nle-lane", `cx-nle-lane-${lane.kind}`], attrs: { role: "listbox", "aria-label": lane.label }, children: lane.clips.map((c) => clipNode(lane, c)) });
laneBox.append(row);
}
head.style.insetInlineStart = `${Math.max(0, playhead) * px}px`;
}
// The ruler and every lane's empty space seek; a clip's own press selects instead.
content.addEventListener("click", (e) => {
if (e.target.closest(".cx-nle-clip") || !onSeek) return;
onSeek(Math.min(spanOf(), timeAt(e.clientX)));
});
draw();
// The ruler follows the width it is given.
let observer = null;
if (typeof ResizeObserver !== "undefined") { observer = new ResizeObserver(() => draw()); observer.observe(node); }
return {
node,
get span() { return spanOf(); },
get pxPerSecond() { return px; },
timeAt,
setLanes(next = []) { lanes = next; draw(); },
setZoom(next) { px = Math.max(4, next); draw(); },
/** `follow`: scroll along when the playhead leaves the visible part (while playing). */
setPlayhead(seconds, follow = false) {
playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`;
if (follow) { const x = Math.max(0, seconds) * px + gutter.offsetWidth, w = node.clientWidth; if (x > node.scrollLeft + w - 24 || x < node.scrollLeft + gutter.offsetWidth) node.scrollLeft = Math.max(0, x - w * 0.2); }
},
destroy() { disposers.forEach((d) => d()); if (observer) observer.disconnect(); node.remove(); },
};
});