646 lines
34 KiB
JavaScript
646 lines
34 KiB
JavaScript
// The video timeline the transform nodes share, on the node face and in the
|
|
// editor: one rail with a playhead, and on the clip node the IN/OUT handles
|
|
// around it. Frame arithmetic lives in timeline_math.mjs (tested); this
|
|
// file is the DOM.
|
|
//
|
|
// Interaction, in the words of a video editor: press or drag anywhere on
|
|
// the rail to scrub the playhead - the stage shows that frame, whatever the
|
|
// Length says. Drag an IN or OUT handle to trim; the playhead rides along on
|
|
// the handle, so the frame on the stage is the first (IN) or last (OUT)
|
|
// frame the run keeps, and stays there on release. The small grip on the
|
|
// kept part slides the whole clip (so does IN, while Length is on). IN, OUT
|
|
// and the playhead are frames, snapped to the source's own frame grid; the
|
|
// seconds the widgets store are derived from them and round-trip exactly.
|
|
//
|
|
// The clip's length is set one way at a time (clipLengthPlan): by OUT; by a
|
|
// Length that keeps OUT a set number of frames after IN; or by a connected
|
|
// fixed_frames / frame_load_cap input, which leaves only IN to set. OUT
|
|
// always sits on the last frame the run outputs - every nth, the snap rule
|
|
// and the Length included - so the handle and the bright bar agree.
|
|
import { makeScrubInput } from "./scrub_input.mjs";
|
|
import { formatTimecode } from "./timecode.mjs";
|
|
import {
|
|
boundaryAtFraction,
|
|
clampFrame,
|
|
lengthFillsClip,
|
|
clipInfo,
|
|
clipLengthPlan,
|
|
formatFps,
|
|
fractionOfFrame,
|
|
frameAtFraction,
|
|
frameTime,
|
|
framesThrough,
|
|
keyboardStep,
|
|
lastFrameFor,
|
|
latestFirstFor,
|
|
railZone,
|
|
setTrimFrame,
|
|
slideRange,
|
|
snapStep,
|
|
snapToValid,
|
|
windowSeconds,
|
|
} from "./timeline_math.mjs";
|
|
|
|
const CSS_ID = "ausboss-transform-trim-css-v4";
|
|
const HANDLE_HIT_PX = 9;
|
|
// Connected inputs that set the length themselves, in the backend's order of
|
|
// precedence: fixed_frames replaces the window, a cap replaces the Length.
|
|
const LENGTH_INPUTS = ["fixed_frames", "frame_load_cap", "max_frames"];
|
|
// Connected inputs whose value only the run knows make the count unknowable.
|
|
const COUNT_INPUTS = ["start_frame", "end_frame", "start_seconds", "end_seconds", "every_nth", "frame_snap", "force_rate"];
|
|
|
|
function installCss() {
|
|
if (document.getElementById(CSS_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = CSS_ID;
|
|
style.textContent = `
|
|
.ausboss-transform-trim{flex:none;display:flex;flex-direction:column;gap:8px;min-width:0;padding:9px 10px 8px;border:1px solid #164b49;border-radius:8px;background:#0e1718;color:#cadddb;font:11px system-ui;box-sizing:border-box;overflow:hidden}
|
|
.ausboss-transform-trim-rail{position:relative;height:28px;margin:0 6px;touch-action:none;cursor:pointer;user-select:none}
|
|
.ausboss-transform-trim-rail.is-disabled{opacity:.45;cursor:default}
|
|
.ausboss-transform-trim-rail:before{content:"";position:absolute;left:0;right:0;top:11px;height:6px;border-radius:4px;background:#293637}
|
|
.ausboss-transform-trim-span{position:absolute;top:11px;height:6px;border-radius:4px;background:rgba(0,180,170,.3);pointer-events:none}
|
|
.ausboss-transform-trim-kept{position:absolute;top:11px;height:6px;border-radius:4px;background:#00b4aa;pointer-events:none}
|
|
.ausboss-transform-trim-handle{position:absolute!important;top:2px;width:12px!important;height:24px;min-width:0;padding:0!important;margin:0!important;transform:translateX(-50%);border:2px solid #00b4aa!important;border-radius:4px!important;background:#e5fffc!important;cursor:ew-resize;touch-action:none;z-index:2}
|
|
.ausboss-transform-trim-handle[hidden]{display:none!important}
|
|
.ausboss-transform-trim-handle:focus-visible{outline:2px solid white;outline-offset:2px}
|
|
.ausboss-transform-trim-grip{position:absolute;top:8px;width:26px;height:12px;margin-left:-13px;box-sizing:border-box;border:1px solid #00b4aa;border-radius:6px;background:#0e1718;cursor:grab;z-index:2;display:flex;align-items:center;justify-content:center;gap:2px;touch-action:none}
|
|
.ausboss-transform-trim-grip[hidden]{display:none}
|
|
.ausboss-transform-trim-grip:hover{background:#163234}
|
|
.ausboss-transform-trim-grip>i{display:block;width:1px;height:6px;background:#9fe3dc;pointer-events:none}
|
|
.ausboss-transform-trim-rail.is-sliding,.ausboss-transform-trim-rail.is-sliding .ausboss-transform-trim-grip{cursor:grabbing}
|
|
.ausboss-transform-trim-playhead{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:#f4fffd;box-shadow:0 0 0 1px rgba(0,0,0,.55);pointer-events:none;z-index:3}
|
|
.ausboss-transform-trim-playhead:before{content:"";position:absolute;top:-1px;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top:6px solid #f4fffd;filter:drop-shadow(0 0 1px rgba(0,0,0,.7))}
|
|
.ausboss-transform-trim-line{display:flex;align-items:center;gap:7px;min-width:0}
|
|
.ausboss-transform-trim-line.wrap{flex-wrap:wrap;row-gap:6px}
|
|
.ausboss-transform-trim-line label{display:flex;align-items:center;gap:5px;white-space:nowrap}
|
|
.ausboss-transform-trim-line .spacer{flex:1 1 0;min-width:0}
|
|
.ausboss-transform-trim-line .ausboss-scrub{width:64px}
|
|
.ausboss-transform-trim-line select{background:#1b2627;color:#e5fffc;border:1px solid #2c4d4b;border-radius:5px;padding:3px 4px;font:11px system-ui;cursor:pointer;flex:none}
|
|
.ausboss-transform-trim-line select:focus-visible{outline:2px solid #00b4aa;outline-offset:1px}
|
|
.ausboss-transform-trim-line .ausboss-scrub-step button{font-size:8px}
|
|
.ausboss-transform-trim-caption{color:#00b4aa;font-weight:650;font-size:10px;letter-spacing:.06em}
|
|
.ausboss-transform-trim-readout{flex:1 1 0;min-width:0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#8ca8a5;font-variant-numeric:tabular-nums}
|
|
.ausboss-transform-trim-readout b{color:#e5fffc;font-weight:600}
|
|
.ausboss-transform-trim-pill{display:inline-flex;flex:none;height:22px;border:1px solid #2c4d4b;border-radius:5px;overflow:hidden;background:#1b2627}
|
|
.ausboss-transform-trim-pill button{border:0;margin:0;background:transparent;color:#8ca8a5;font:600 10px system-ui;letter-spacing:.04em;padding:0 9px;cursor:pointer}
|
|
.ausboss-transform-trim-pill button:hover{color:#e5fffc}
|
|
.ausboss-transform-trim-pill button.on{background:#00b4aa;color:#04201d}
|
|
.ausboss-transform-trim-pill.is-disabled{opacity:.45}
|
|
.ausboss-transform-trim-pill.is-disabled button{cursor:not-allowed}
|
|
.ausboss-transform-trim-hint{flex:1 1 0;min-width:0;color:#6f8886;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.ausboss-transform-trim-hint.note{flex:0 1 auto;max-width:100%;color:#ffc46b}
|
|
.ausboss-transform-trim-summary{flex:1 1 0;min-width:0;color:#8ca8a5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
|
|
.ausboss-transform-trim-reset{flex:none;height:18px;box-sizing:border-box;border:1px solid #2c4d4b;border-radius:4px;background:#1b2627;color:#cadddb;font:600 10px/16px system-ui;cursor:pointer;padding:0 8px;white-space:nowrap}
|
|
.ausboss-transform-trim-reset:hover{border-color:#8fa3a1;color:#fff}
|
|
.ausboss-transform-trim-reset:disabled{opacity:.45;cursor:not-allowed}
|
|
`;
|
|
document.head.append(style);
|
|
}
|
|
|
|
function el(tag, className = "", text = "") {
|
|
const item = document.createElement(tag);
|
|
if (className) item.className = className;
|
|
if (text) item.textContent = text;
|
|
return item;
|
|
}
|
|
|
|
const pct = (fraction) => `${(fraction * 100).toFixed(3)}%`;
|
|
|
|
// options:
|
|
// get(name, fallback) / set(name, value) widget access
|
|
// has(name) whether a widget exists
|
|
// driven(name) whether an input is linked
|
|
// number(name, fallback) a linked literal's value, null
|
|
// when only the run knows it
|
|
// outputRate() output fps, null when unknown
|
|
// metadata() the source's {fps, frame_count, duration}
|
|
// trim IN/OUT handles and the length rows
|
|
// (the clip node) or playhead only
|
|
// onSeek(frame, settled) the playhead moved; settled on release
|
|
// onCommit() a stored value settled (undo point)
|
|
export function mountTransformTrim({ get, set, has = () => true, driven = () => false, number = (name, fallback) => get(name, fallback), outputRate, metadata, trim = true, onSeek, onCommit }) {
|
|
installCss();
|
|
const root = el("div", "ausboss-transform-trim");
|
|
const rail = el("div", "ausboss-transform-trim-rail");
|
|
const span = el("div", "ausboss-transform-trim-span");
|
|
const kept = el("div", "ausboss-transform-trim-kept");
|
|
const playhead = el("div", "ausboss-transform-trim-playhead");
|
|
const grip = el("div", "ausboss-transform-trim-grip");
|
|
grip.append(el("i"), el("i"), el("i"));
|
|
grip.hidden = true;
|
|
const handles = {};
|
|
if (trim) rail.append(span, kept, grip);
|
|
rail.append(playhead);
|
|
|
|
const info = () => clipInfo(metadata());
|
|
const everyNth = () => Math.max(1, Math.trunc(Number(number("every_nth", get("every_nth", 1))) || 1));
|
|
const snapRule = () => String(get("frame_snap", "free"));
|
|
const wiredBy = () => LENGTH_INPUTS.find((name) => driven(name)) ?? null;
|
|
// Seconds the run starts and ends at, a connected frame bound included.
|
|
const boundSeconds = (edge) => {
|
|
const clip = info();
|
|
if (driven(`${edge}_frame`)) {
|
|
const frame = number(`${edge}_frame`, null);
|
|
if (frame != null && clip.fps > 0) return Math.max(0, frame) / clip.fps;
|
|
}
|
|
const name = `${edge}_seconds`;
|
|
return number(name, get(name, 0)) ?? get(name, 0);
|
|
};
|
|
const countAtRunTime = () => COUNT_INPUTS.some((name) => driven(name) && number(name, null) == null);
|
|
const plan = () => {
|
|
if (!trim) return null;
|
|
const clip = info();
|
|
const wired = wiredBy();
|
|
return clipLengthPlan(clip, {
|
|
start: boundSeconds("start"),
|
|
end: boundSeconds("end"),
|
|
everyNth: everyNth(),
|
|
frameSnap: snapRule(),
|
|
maxFrames: get("max_frames", 0),
|
|
fixedFrames: driven("fixed_frames") ? 0 : get("fixed_frames", 0),
|
|
wired,
|
|
wiredFrames: wired ? number(wired, null) : null,
|
|
outputFps: outputRate ? outputRate() : clip.fps,
|
|
resampled: driven("force_rate"),
|
|
});
|
|
};
|
|
const currentWindow = () => {
|
|
const clip = info();
|
|
if (!trim) return { first: 0, last: Math.max(0, clip.count - 1) };
|
|
const current = plan();
|
|
return { first: current.first, last: current.last ?? current.windowLast };
|
|
};
|
|
const playheadFrame = () => clampFrame(get("frame_index", 0), info());
|
|
const controls = {};
|
|
let syncLength = () => {};
|
|
// IN is locked by a connected bound; OUT by a connected bound in free
|
|
// trim, and always when a connected input sets the length.
|
|
const locked = (edge) => {
|
|
if (edge === "start") return driven("start_frame") || driven("start_seconds");
|
|
const mode = plan()?.mode;
|
|
if (mode === "wired") return true;
|
|
if (mode === "free") return driven("end_frame") || driven("end_seconds");
|
|
return false;
|
|
};
|
|
const lockTip = (edge) => edge === "start"
|
|
? "The starting frame is supplied by a connected input. Disconnect it to edit IN here."
|
|
: wiredBy()
|
|
? `The connected ${wiredBy()} input sets the length, so the clip ends where it says. Disconnect it to use OUT or Length.`
|
|
: "The ending frame is supplied by a connected input. Disconnect it to edit OUT here.";
|
|
const disableControl = (control, disabled, tip) => {
|
|
if (!control) return;
|
|
control.root.style.opacity = disabled ? ".45" : "";
|
|
control.root.inert = disabled;
|
|
control.root.parentElement.title = disabled ? tip : "";
|
|
};
|
|
|
|
const seek = (frame, settled) => {
|
|
onSeek?.(clampFrame(frame, info()), settled);
|
|
sync();
|
|
};
|
|
const writeStart = (first) => {
|
|
if (!locked("start")) set("start_seconds", windowSeconds(info(), first, first).start_seconds);
|
|
};
|
|
const writeEnd = (first, last) => set("end_seconds", windowSeconds(info(), first, last).end_seconds);
|
|
// An older face's Fixed frames reads as a Length; the first edit makes it
|
|
// one, keeping the window where the fixed count had placed it.
|
|
const adoptLength = (current) => {
|
|
if (current.mode !== "fixed") return current;
|
|
set("fixed_frames", 0);
|
|
set("max_frames", Math.max(1, current.requested ?? current.frames ?? 1));
|
|
set("end_seconds", 0);
|
|
writeStart(current.first);
|
|
return plan();
|
|
};
|
|
// A trim edge moved: store it and park the playhead on that edge's frame,
|
|
// so the stage shows the first or last frame the run keeps.
|
|
const moveEdge = (edge, frame, settled) => {
|
|
if (!trim || locked(edge)) return;
|
|
const clip = info();
|
|
const current = adoptLength(plan());
|
|
const nth = everyNth();
|
|
let parked;
|
|
if (edge === "start") {
|
|
if (current.mode === "length") {
|
|
// A Length drags the whole window, stopping where OUT meets the end.
|
|
parked = Math.max(0, Math.min(clampFrame(frame, clip), latestFirstFor(clip, current.requested ?? get("max_frames", 1), nth)));
|
|
set("end_seconds", 0);
|
|
} else {
|
|
parked = setTrimFrame({ first: current.first, last: current.windowLast }, "start", frame, clip).first;
|
|
}
|
|
writeStart(parked);
|
|
} else {
|
|
const target = Math.max(current.first, clampFrame(frame, clip));
|
|
const frames = framesThrough(current.first, target, nth, snapRule());
|
|
if (current.mode === "length") {
|
|
set("max_frames", frames);
|
|
set("end_seconds", 0);
|
|
parked = Math.min(clip.count - 1, lastFrameFor(current.first, frames, nth));
|
|
} else {
|
|
parked = driven("force_rate") ? target : lastFrameFor(current.first, frames, nth);
|
|
writeEnd(current.first, parked);
|
|
}
|
|
}
|
|
seek(parked, settled);
|
|
if (settled) onCommit?.();
|
|
};
|
|
// The grip slides the kept part: a Length moves with IN (OUT follows), a
|
|
// free window keeps its span and both edges move.
|
|
const canSlide = () => {
|
|
if (!trim || locked("start")) return false;
|
|
const current = plan();
|
|
if (!current || !["length", "fixed", "free"].includes(current.mode)) return false;
|
|
if (current.mode === "free" && locked("end")) return false;
|
|
const range = slideRange(info(), current, everyNth());
|
|
return range.max > range.min;
|
|
};
|
|
const slideTo = (target, settled) => {
|
|
const clip = info();
|
|
const current = plan();
|
|
const range = slideRange(clip, current, everyNth());
|
|
const first = Math.max(range.min, Math.min(range.max, Math.round(target)));
|
|
if (current.mode === "free") {
|
|
const spanFrames = current.windowLast - current.first;
|
|
writeStart(first);
|
|
writeEnd(first, Math.min(clip.count - 1, first + spanFrames));
|
|
seek(first, settled);
|
|
if (settled) onCommit?.();
|
|
return;
|
|
}
|
|
moveEdge("start", first, settled);
|
|
};
|
|
// Length on keeps what the rail shows now as the count; off turns the
|
|
// Length into an OUT at the same frame. Either way nothing moves.
|
|
const setLength = (on) => {
|
|
const current = plan();
|
|
if (!current || current.mode === "wired" || (on === (current.mode !== "free"))) return;
|
|
if (on) {
|
|
const frames = current.frames ?? framesThrough(current.first, current.windowLast, everyNth(), snapRule());
|
|
set("max_frames", Math.max(1, frames));
|
|
set("end_seconds", 0);
|
|
} else {
|
|
const last = current.last ?? current.windowLast;
|
|
writeStart(current.first);
|
|
writeEnd(current.first, last);
|
|
set("max_frames", 0);
|
|
if (!driven("fixed_frames")) set("fixed_frames", 0);
|
|
}
|
|
sync();
|
|
onCommit?.();
|
|
};
|
|
|
|
const readout = el("span", "ausboss-transform-trim-readout");
|
|
const summary = el("div", "ausboss-transform-trim-summary");
|
|
|
|
const sync = () => {
|
|
const clip = info();
|
|
const head = playheadFrame();
|
|
rail.classList.toggle("is-disabled", !clip.count);
|
|
playhead.style.left = pct(fractionOfFrame(head, clip, "center"));
|
|
playhead.style.visibility = clip.count ? "" : "hidden";
|
|
readout.replaceChildren();
|
|
if (clip.count) {
|
|
readout.append(el("b", "", `fr ${head}`), document.createTextNode(` · ${formatTimecode(frameTime(head, clip))}`));
|
|
readout.title = `Playhead: frame ${head} of ${clip.count} at ${frameTime(head, clip).toFixed(3)} s. Press or drag the rail to scrub; ${trim ? "the trim handles carry it with them." : "this is the frame the node outputs."}`;
|
|
}
|
|
controls.frame?.set(head);
|
|
if (!trim) return;
|
|
const current = plan();
|
|
const wired = wiredBy();
|
|
const unknown = countAtRunTime();
|
|
const hasOut = current.last != null && current.mode !== "wired";
|
|
const outFrame = current.last ?? current.windowLast;
|
|
const startFraction = fractionOfFrame(current.first, clip, "start");
|
|
const endFraction = fractionOfFrame(outFrame, clip, "end");
|
|
// Dim: frames the free window holds but the run drops (every nth, snap).
|
|
const tailFraction = current.mode === "free" ? fractionOfFrame(current.windowLast, clip, "end") : endFraction;
|
|
span.style.left = pct(startFraction);
|
|
span.style.width = pct(current.last == null && current.mode !== "free" ? 0 : Math.max(0, tailFraction - startFraction));
|
|
kept.style.left = pct(startFraction);
|
|
kept.style.width = current.last == null || unknown ? "0%" : pct(Math.max(0, endFraction - startFraction));
|
|
const fills = lengthFillsClip(clip, current, everyNth());
|
|
const railWidth = rail.getBoundingClientRect().width || 0;
|
|
const keptWidth = Math.max(0, endFraction - startFraction) * railWidth;
|
|
grip.hidden = !canSlide() || unknown || (railWidth > 0 && keptWidth < 34);
|
|
grip.style.left = pct((startFraction + endFraction) / 2);
|
|
grip.title = current.mode === "free"
|
|
? "Drag to slide the kept part: IN and OUT move together."
|
|
: "Drag to slide the clip: OUT follows at the Length.";
|
|
for (const edge of ["start", "end"]) {
|
|
const handle = handles[edge];
|
|
const frame = edge === "start" ? current.first : outFrame;
|
|
const isLocked = locked(edge);
|
|
handle.hidden = edge === "end" && !hasOut;
|
|
handle.style.left = pct(edge === "start" ? startFraction : endFraction);
|
|
handle.disabled = !clip.count;
|
|
handle.setAttribute("aria-disabled", String(isLocked || !clip.count));
|
|
handle.style.opacity = isLocked ? ".35" : "";
|
|
handle.style.cursor = isLocked ? "not-allowed" : "";
|
|
handle.title = isLocked ? lockTip(edge)
|
|
: edge === "start"
|
|
? current.mode === "free" ? "Drag IN to trim; Shift + arrow moves one frame."
|
|
: fills ? "The Length is the whole clip, so IN has nowhere to go. Shorten the Length to move IN."
|
|
: "Drag IN to move the clip; OUT follows at the Length. Shift + arrow moves one frame."
|
|
: current.mode === "free" ? "Drag OUT to trim; it lands on the last frame the run keeps." : "Drag OUT to change the Length; IN stays.";
|
|
handle.setAttribute("aria-valuemin", "0");
|
|
handle.setAttribute("aria-valuemax", String(Math.max(0, clip.count - 1)));
|
|
handle.setAttribute("aria-valuenow", String(frame));
|
|
handle.setAttribute("aria-valuetext", `frame ${frame}, ${formatTimecode(frameTime(frame, clip))}`);
|
|
}
|
|
controls.start_seconds?.set(current.first);
|
|
controls.end_seconds?.set(outFrame);
|
|
disableControl(controls.start_seconds, locked("start"), lockTip("start"));
|
|
disableControl(controls.end_seconds, locked("end") || !hasOut, lockTip("end"));
|
|
syncLength(current, wired);
|
|
if (controls.reset) {
|
|
const free = current.mode === "free";
|
|
controls.reset.textContent = free ? "Full clip" : "To start";
|
|
controls.reset.title = free ? "Reset IN/OUT to the full source. Keeps Every nth and Snap." : "Move IN to the start of the source; the length stays.";
|
|
controls.reset.disabled = !free && locked("start");
|
|
}
|
|
controls.every_nth?.set(get("every_nth", 1));
|
|
disableControl(controls.every_nth, driven("every_nth"), "Every nth is supplied by a connected input.");
|
|
if (controls.frame_snap) {
|
|
controls.frame_snap.value = snapRule();
|
|
const exact = current.mode === "fixed" || (current.mode === "wired" && wired === "fixed_frames");
|
|
controls.frame_snap.disabled = exact || driven("frame_snap");
|
|
controls.frame_snap.title = exact ? "An exact fixed_frames count is used as it is; this does not apply." : "Keep a frame count the video model takes: LTX wants 8n+1 (49, 97, 121), Wan 4n+1. OUT and Length step to those counts. Any keeps every frame.";
|
|
}
|
|
syncSummary(clip, current, wired, unknown);
|
|
};
|
|
|
|
const syncSummary = (clip, current, wired, unknown) => {
|
|
if (!clip.count) {
|
|
summary.textContent = "Choose a source to set the clip window";
|
|
summary.title = "";
|
|
return;
|
|
}
|
|
const rate = outputRate?.();
|
|
const fps = `Source ${formatFps(clip.fps)} fps → Output ${rate == null ? "rate at run time" : `${formatFps(rate)} fps`}`;
|
|
if (current.mode === "wired") {
|
|
summary.textContent = current.frames == null ? `${fps} · length from ${wired} at run time` : `${fps} · ${current.frames} frames from ${wired}`;
|
|
summary.title = `The connected ${wired} input sets the length, so only IN is set here. Disconnect it to use OUT or a Length.`;
|
|
return;
|
|
}
|
|
if (unknown || current.frames == null) {
|
|
summary.textContent = `${fps} · frame count at run time`;
|
|
summary.title = "IN, OUT and the playhead refer to source frames. A connected input decides the exact frame count when the workflow runs. Connect the fps output to the next video node's fps input.";
|
|
return;
|
|
}
|
|
const from = formatTimecode(frameTime(current.first, clip));
|
|
const to = formatTimecode((current.last + 1) / clip.fps);
|
|
if (current.mode === "fixed") {
|
|
summary.textContent = current.tooLong
|
|
? `Fixed length needs more than the source's ${clip.duration.toFixed(3)}s`
|
|
: `${fps} · exactly ${current.frames} frames · ${from} → ${to}`;
|
|
summary.title = "An older Fixed frames value: an exact count from IN. Any change here turns it into a Length.";
|
|
return;
|
|
}
|
|
const total = current.windowLast - current.first + 1;
|
|
const count = current.mode === "length"
|
|
? current.truncated ? `${current.frames} of ${current.requested} frames, source ends` : `${current.frames} frames`
|
|
: current.frames < total ? `${current.frames} of ${total} frames` : `${current.frames} frames`;
|
|
summary.textContent = `${fps} · ${count} · ${from} → ${to}`;
|
|
summary.title = current.mode === "length"
|
|
? current.truncated
|
|
? `The source ends ${current.frames} frames after IN, before the Length of ${current.requested}. Move IN earlier or shorten the Length.`
|
|
: "Length keeps OUT this many output frames after IN; drag IN to move the clip, OUT to change the Length."
|
|
: current.frames < total
|
|
? `The window holds ${total} frames; every nth and the snap rule keep ${current.frames}, ending at frame ${current.last}, where OUT sits. The dim part is dropped.`
|
|
: "Frames the run outputs, at the fps the node reports.";
|
|
};
|
|
|
|
// --- rail gestures --------------------------------------------------------
|
|
const hit = (event) => {
|
|
const box = rail.getBoundingClientRect();
|
|
const width = Math.max(1, box.width);
|
|
const x = Math.max(0, Math.min(width, event.clientX - box.left));
|
|
const fraction = x / width;
|
|
if (!trim) return { fraction, zone: "playhead" };
|
|
if (event.target === handles.start) return { fraction, zone: "start" };
|
|
if (event.target === handles.end && !handles.end.hidden) return { fraction, zone: "end" };
|
|
const clip = info();
|
|
const window = currentWindow();
|
|
// Screen pixels, so the zone keeps its size whatever the graph zoom.
|
|
const zone = railZone({
|
|
x,
|
|
inX: fractionOfFrame(window.first, clip, "start") * width,
|
|
outX: fractionOfFrame(window.last, clip, "end") * width,
|
|
hasOut: !handles.end.hidden,
|
|
hitPx: HANDLE_HIT_PX,
|
|
onGrip: !grip.hidden && grip.contains(event.target),
|
|
});
|
|
return { fraction, zone };
|
|
};
|
|
const applyPointer = (zone, fraction, settled, gesture = drag) => {
|
|
const clip = info();
|
|
if (zone === "playhead") { seek(frameAtFraction(fraction, clip), settled); return; }
|
|
if (zone === "grip") {
|
|
if (gesture) slideTo(gesture.first + Math.round((fraction - gesture.origin) * clip.count), settled);
|
|
return;
|
|
}
|
|
moveEdge(zone, boundaryAtFraction(fraction, clip) - (zone === "end" ? 1 : 0), settled);
|
|
};
|
|
let drag = null;
|
|
rail.addEventListener("pointerdown", (event) => {
|
|
if (event.button !== 0 || !info().count) return;
|
|
event.preventDefault(); event.stopPropagation();
|
|
const { fraction, zone } = hit(event);
|
|
if (zone !== "playhead" && zone !== "window" && locked(zone)) return;
|
|
drag = { zone, fraction, origin: fraction, first: currentWindow().first, pointerId: event.pointerId };
|
|
rail.classList.toggle("is-sliding", zone === "grip");
|
|
try { rail.setPointerCapture(event.pointerId); } catch { /* mouse fallback */ }
|
|
applyPointer(zone, fraction, false);
|
|
});
|
|
rail.addEventListener("pointermove", (event) => {
|
|
if (!drag) {
|
|
const { zone } = info().count ? hit(event) : { zone: "playhead" };
|
|
rail.style.cursor = zone === "playhead" ? "pointer" : zone === "grip" ? "grab" : locked(zone) ? "not-allowed" : "ew-resize";
|
|
return;
|
|
}
|
|
event.preventDefault(); event.stopPropagation();
|
|
drag.fraction = hit(event).fraction;
|
|
applyPointer(drag.zone, drag.fraction, false);
|
|
});
|
|
const finish = (event) => {
|
|
if (!drag) return;
|
|
const gesture = drag;
|
|
const { zone, fraction, pointerId } = gesture;
|
|
drag = null;
|
|
rail.classList.remove("is-sliding");
|
|
try { if (rail.hasPointerCapture(pointerId)) rail.releasePointerCapture(pointerId); } catch { /* released already */ }
|
|
if (event?.type === "pointerup") event.stopPropagation();
|
|
applyPointer(zone, fraction, true, gesture);
|
|
};
|
|
rail.addEventListener("pointerup", finish);
|
|
rail.addEventListener("pointercancel", finish);
|
|
rail.addEventListener("lostpointercapture", () => finish(null));
|
|
|
|
// --- trim handles ---------------------------------------------------------
|
|
if (trim) {
|
|
for (const edge of ["start", "end"]) {
|
|
const handle = el("button", "ausboss-transform-trim-handle");
|
|
handle.type = "button";
|
|
handle.setAttribute("role", "slider");
|
|
handle.setAttribute("aria-label", edge === "start" ? "Clip IN" : "Clip OUT");
|
|
handle.addEventListener("keydown", (event) => {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault(); event.stopPropagation();
|
|
const clip = info();
|
|
if (!clip.count || locked(edge)) return;
|
|
const direction = event.key === "ArrowLeft" || event.key === "Home" ? -1 : 1;
|
|
const window = currentWindow();
|
|
if (edge === "start") {
|
|
const target = event.key === "Home" ? 0 : event.key === "End" ? clip.count - 1 : window.first + keyboardStep(clip, event.shiftKey) * direction;
|
|
moveEdge("start", target, true);
|
|
return;
|
|
}
|
|
// OUT steps by output frames, to the next count the snap rule keeps.
|
|
const current = plan();
|
|
const nth = everyNth();
|
|
const frames = current.frames ?? framesThrough(window.first, window.last, nth, snapRule());
|
|
const step = Math.max(1, Math.round(keyboardStep(clip, event.shiftKey) / nth));
|
|
const next = event.key === "Home" ? 1 : event.key === "End" ? framesThrough(window.first, clip.count - 1, nth, snapRule()) : snapToValid(frames + step * direction, snapRule(), direction);
|
|
moveEdge("end", lastFrameFor(window.first, next, nth), true);
|
|
});
|
|
handles[edge] = handle;
|
|
rail.append(handle);
|
|
}
|
|
}
|
|
|
|
// --- rows -----------------------------------------------------------------
|
|
const timeRow = el("div", "ausboss-transform-trim-line");
|
|
if (trim) {
|
|
for (const [name, caption, edge] of [["start_seconds", "IN", "start"], ["end_seconds", "OUT", "end"]]) {
|
|
const label = el("label");
|
|
label.append(el("span", "ausboss-transform-trim-caption", caption));
|
|
controls[name] = makeScrubInput({
|
|
value: 0, min: 0, max: 10000000, step: 1, decimals: 0,
|
|
title: edge === "start" ? "IN frame: the first frame the run keeps (stored as start_seconds)." : "OUT frame: the last frame the run keeps. With Length on it follows IN; typing it changes the Length.",
|
|
onChange: (frame) => moveEdge(edge, frame, false),
|
|
onSettle: () => { const window = currentWindow(); seek(edge === "start" ? window.first : window.last, true); onCommit?.(); },
|
|
});
|
|
label.append(controls[name].root);
|
|
timeRow.append(label);
|
|
if (edge === "start") timeRow.append(readout);
|
|
}
|
|
} else {
|
|
const label = el("label");
|
|
label.append(el("span", "ausboss-transform-trim-caption", "FRAME"));
|
|
controls.frame = makeScrubInput({
|
|
value: 0, min: 0, max: 10000000, step: 1, decimals: 0,
|
|
title: "The frame this node outputs (frame_index).",
|
|
onChange: (frame) => seek(frame, false),
|
|
onSettle: () => seek(playheadFrame(), true),
|
|
});
|
|
label.append(controls.frame.root);
|
|
timeRow.append(label, readout);
|
|
}
|
|
root.append(rail, timeRow);
|
|
|
|
// Length: off, OUT ends the clip; on, a frame count from IN (max_frames,
|
|
// end left open), so OUT follows IN and the count survives a new source.
|
|
if (trim && has("max_frames")) {
|
|
const lengthRow = el("div", "ausboss-transform-trim-line wrap");
|
|
const label = el("label", "", "Length");
|
|
const pill = el("div", "ausboss-transform-trim-pill");
|
|
const off = el("button", "", "off");
|
|
const on = el("button", "", "on");
|
|
off.type = on.type = "button";
|
|
off.title = "OUT ends the clip: drag both handles freely.";
|
|
on.title = "Set the clip by a frame count from IN: OUT follows IN, and the count stays when you swap the video.";
|
|
off.addEventListener("click", () => setLength(false));
|
|
on.addEventListener("click", () => setLength(true));
|
|
pill.append(off, on);
|
|
const hint = el("span", "ausboss-transform-trim-hint");
|
|
const count = makeScrubInput({ value: 1, min: 1, max: 100000, step: 1, decimals: 0, unit: "fr", width: 72,
|
|
title: "Output frames from IN. Under a snap rule it steps to the counts the model keeps (97, 105, ... for 8n+1).",
|
|
onChange: (next) => {
|
|
const current = adoptLength(plan());
|
|
if (current.mode !== "length") { sync(); return; }
|
|
const was = current.requested ?? get("max_frames", 1);
|
|
set("max_frames", snapToValid(next, snapRule(), Math.sign(next - was)));
|
|
set("end_seconds", 0);
|
|
sync();
|
|
},
|
|
onSettle: () => { const window = currentWindow(); seek(window.last, true); onCommit?.(); },
|
|
});
|
|
label.append(pill, count.root);
|
|
lengthRow.append(label, hint);
|
|
root.append(lengthRow);
|
|
syncLength = (current, wired) => {
|
|
const active = current.mode !== "free";
|
|
const blocked = current.mode === "wired";
|
|
off.classList.toggle("on", !active);
|
|
on.classList.toggle("on", active);
|
|
off.setAttribute("aria-pressed", String(!active));
|
|
on.setAttribute("aria-pressed", String(active));
|
|
pill.classList.toggle("is-disabled", blocked);
|
|
off.disabled = on.disabled = blocked;
|
|
pill.title = blocked ? lockTip("end") : "";
|
|
count.root.style.display = active ? "" : "none";
|
|
const step = snapStep(snapRule());
|
|
count.setStep(step, step);
|
|
count.set(blocked ? (current.frames ?? current.requested ?? 0) : (current.requested ?? get("max_frames", 1)));
|
|
disableControl(count, blocked, lockTip("end"));
|
|
const fills = lengthFillsClip(info(), current, everyNth());
|
|
hint.textContent = blocked ? `from ${wired}` : current.mode === "fixed" ? "exact (older setting)" : fills ? "= the whole clip. Shorten it to move IN" : active ? "" : "OUT ends the clip";
|
|
hint.classList.toggle("note", fills && !blocked);
|
|
hint.title = fills && !blocked ? `The Length (${current.requested ?? current.frames} frames) covers the whole clip, so IN and the clip cannot move. The bar still scrubs.` : "";
|
|
};
|
|
}
|
|
|
|
if (trim) {
|
|
const sampling = el("div", "ausboss-transform-trim-line wrap");
|
|
const nthLabel = el("label", "", "Every nth");
|
|
controls.every_nth = makeScrubInput({ value: get("every_nth", 1), min: 1, max: 512, step: 1, decimals: 0,
|
|
title: "Keep one frame in this many. Output fps is adjusted to keep real-time playback; a Length counts the frames kept.",
|
|
onChange: (next) => { if (!driven("every_nth")) set("every_nth", next); sync(); }, onSettle: onCommit,
|
|
});
|
|
nthLabel.append(controls.every_nth.root);
|
|
sampling.append(nthLabel, el("span", "spacer"));
|
|
if (has("frame_snap")) {
|
|
// Video models keep 8n+1 (LTX) or 4n+1 (Wan) frames and drop the
|
|
// rest; snapping here keeps the clip, its audio, and its stitcher the
|
|
// same length as what comes back from the sampler.
|
|
const label = el("label", "", "Frames for");
|
|
label.style.marginLeft = "auto";
|
|
const select = el("select");
|
|
// The stored values stay free / 8n+1 / 4n+1; only the words change.
|
|
for (const [rule, words] of [["free", "any"], ["8n+1", "LTX (8n+1)"], ["4n+1", "Wan (4n+1)"]]) {
|
|
const option = el("option", "", words); option.value = rule; select.append(option);
|
|
}
|
|
select.addEventListener("change", () => {
|
|
set("frame_snap", select.value);
|
|
// A Length moves to the nearest count the new rule keeps.
|
|
if (plan()?.mode === "length") set("max_frames", snapToValid(get("max_frames", 1), select.value));
|
|
sync(); onCommit?.();
|
|
});
|
|
controls.frame_snap = select;
|
|
label.append(select);
|
|
sampling.append(label);
|
|
}
|
|
const footer = el("div", "ausboss-transform-trim-line");
|
|
const reset = el("button", "ausboss-transform-trim-reset", "Full clip");
|
|
controls.reset = reset;
|
|
reset.type = "button";
|
|
reset.addEventListener("click", () => {
|
|
const current = plan();
|
|
if (current.mode === "free") {
|
|
if (!locked("start")) set("start_seconds", 0);
|
|
if (!locked("end")) set("end_seconds", 0);
|
|
sync(); onCommit?.();
|
|
} else {
|
|
moveEdge("start", 0, true);
|
|
}
|
|
});
|
|
footer.append(summary, reset);
|
|
root.append(sampling, footer);
|
|
}
|
|
root.addEventListener("pointerdown", (event) => {
|
|
if (event.target.closest("button,input,select")) event.stopPropagation();
|
|
});
|
|
sync();
|
|
return { root, sync, info, window: currentWindow, moveEdge, plan };
|
|
}
|