Files

1791 lines
66 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.
/*
* LoadVideoCrop - visual aspect-ratio-locked crop + visual trim timeline
* for the core Load Video player.
*
* How the core renders the player (verified against the installed frontend):
* - The `file` combo carries `video_upload:true`, so the frontend builds a
* DOM <video> element (playsInline, controls, loop) and, in classic mode,
* exposes the `div.comfy-img-preview` container as a LiteGraph DOM widget
* (type "video", canvasOnly) held on `node.videoContainer`. The container
* is a flex box (overflow hidden) and the CSS makes the <video> fill it
* with object-fit:contain. The core also binds canvas interactions
* (wheel/pointer) to the element, so wheel/drag over the video drive the
* canvas.
* - In 2.0 / Vue mode the same player is rendered by a Vue component: the
* <video> lives inside the node's [data-node-id] element
* (div.video-preview) and may be re-created on re-render.
* - In BOTH designs the element is a real DOM <video> with object-fit:contain
* and its src is the INPUT file (uncropped): what you frame is what runs.
*
* Because the player is DOM in both designs there is a SINGLE overlay path
* (no canvas variant, unlike LoadImageCrop): positioned <div>s managed by a
* light 250ms poller. The crop rectangle is static across all frames, so one
* normalized rect is valid for the whole clip.
*
* TRIM TIMELINE (v1.0.1): an editor-style strip BELOW the video (the video
* box keeps its natural size; the container gets +TL_TOTAL_H px of height
* in which the timeline lives, so the native <video> controls are never
* covered). The node width is NOT pinned (the core's video widget pins it:
* it reports the node's current width as minWidth, which would make the node
* unshrinkable): the computeLayoutSize wrapper returns minWidth:0 instead,
* so the node can be resized to any width (the video letterboxes):
*
* - filmstrip of REAL thumbnails (a second hidden <video> seeked to N
* points: N = clamp(ceil(duration), 4, 48), ~1 per second, progressive);
* - audio waveform (WebAudio envelope, ~600 columns; skipped for files
* longer than 5 min to keep the decodeAudioData RAM bounded);
* - the trim window between the I (in) and O (out) markers: drag the two
* markers to change in/out (the middle is not draggable), click the track
* to seek, press I / O on the keyboard to mark in/out at the playhead; a
* white playhead follows the playback.
* Values live in the (hidden) start_time / duration / strict_duration widgets
* (0 = no trim / unlimited — core Trim Video semantics), so they serialize
* into the workflow and can be set via API. The window resets to the full
* clip when a different file is selected.
*
* Interaction (crop):
* - drag: a document capture-phase listener hit-tests the crop box and
* moves the rectangle; events outside the box pass through so the native
* <video> controls keep working.
* - wheel: a document capture-phase listener zooms the box while the cursor
* is over it (pre-empting the core canvas pan/zoom), and passes through
* everywhere else.
*
* The crop is stored as normalized floats in the (hidden) crop_x/y/w/h
* widgets so it serializes into the workflow and is usable via API.
* "Original" aspect ratio (default): no crop area, the video passes through
* uncropped.
*
* No static ES imports: /scripts/app.js is a shim read at evaluation time, so
* it is imported dynamically (absolute path) with a retry loop.
*/
const LVRC = {
NODE_NAME: "LoadVideoCrop",
CROP_KEYS: ["crop_x", "crop_y", "crop_w", "crop_h"],
TRIM_KEYS: ["start_time", "duration", "strict_duration"],
POLL_MS: 250,
TL_FILM_H: 48,
TL_WAVE_H: 20,
TL_TOTAL_H: 68, // filmstrip + waveform
TL_MIN_DUR: 0.05, // minimum trim window (seconds)
TL_MIN_THUMBS: 4,
TL_MAX_THUMBS: 48,
TL_WAVE_MAX_SECONDS: 5 * 60, // decodeAudioData RAM: skip the waveform past 5 min
TAG: "[LoadVideoCrop]",
};
let lvrcApp = null;
/* ------------------------------ helpers ------------------------------ */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function widgetOf(node, name) {
return node.widgets ? node.widgets.find((w) => w.name === name) : null;
}
function getState(node) {
if (!node.__lvrc) {
node.__lvrc = {
inited: false,
crop: null, // {x,y,w,h} normalized 0..1 in VIDEO CONTENT space
cropFromWorkflow: false,
trim: { start: 0, dur: 0 }, // seconds; dur 0 = unlimited (to the end)
trimFromWorkflow: false,
lastKey: null,
lastSrc: null,
dom: null, // {parent, overlay, box, label, tl, ...}
};
}
return node.__lvrc;
}
/* ------------------ localStorage persistence (crop + trim) -------------
* ComfyUI multi-tab (both the classic and the 2.0 layouts) re-instantiates
* the nodes and resets the FLOAT
* widgets to their defaults (losing crop + trim) while the aspect_ratio
* combo keeps its value. The last user settings are therefore cached here,
* per file (signature = name + native size + duration) and re-applied on
* re-init. The workflow always wins: if the crop/trim widgets carry
* non-default values (saved workflow / API) the cache is ignored. */
const LS_PREFIX = "lvrc1:";
function fileSigOf(v) {
let name = "";
try {
name = decodeURIComponent(
new URL(v.currentSrc || v.src, location.href).pathname.split("/").pop()
);
} catch (_) { /* fall back to the raw src below */ }
const dur = videoDuration(v);
const nat = videoNatural(v);
return `${name || (v.currentSrc || v.src || "")}|${nat ? nat.w + "x" + nat.h : "?"}|${dur.toFixed(3)}`;
}
function lsKeyOfVideo(v) {
return v ? LS_PREFIX + fileSigOf(v) : null;
}
function lsSave(node) {
try {
const st = node.__lvrc;
const v = st && st.dom ? st.dom.video : null;
const key = lsKeyOfVideo(v);
if (!key) return;
const crop = st.crop ? { x: st.crop.x, y: st.crop.y, w: st.crop.w, h: st.crop.h } : null;
const arw = widgetOf(node, "aspect_ratio");
localStorage.setItem(key, JSON.stringify({
crop,
ar: arw ? String(arw.value) : null, // the combo resets to default on reload
trim: { start: st.trim.start, dur: st.trim.dur },
ctrls: v ? v.controls : true,
ts: Date.now(),
}));
// housekeeping: drop entries older than 7 days so the cache never grows
const now = Date.now();
const stale = [];
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (!k || !k.startsWith(LS_PREFIX) || k === key) continue;
let old = null;
try { old = JSON.parse(localStorage.getItem(k) || "null"); } catch (_) { /* ignore */ }
if (!old || !old.ts || now - old.ts > 7 * 24 * 3600 * 1000) stale.push(k);
}
for (const k of stale) localStorage.removeItem(k);
} catch (_) { /* storage unavailable / quota: persistence is best-effort */ }
}
/** Restore reads the video element directly: at re-init time `st.dom` is
* not built yet, so the node-based lookup would silently miss. */
function lsLoadForVideo(v) {
try {
const key = lsKeyOfVideo(v);
if (!key) return null;
const raw = localStorage.getItem(key);
if (!raw) return null;
const s = JSON.parse(raw);
if (!s || typeof s.trim !== "object" || s.trim === null) return null;
return s;
} catch (_) {
return null;
}
}
/** Aspect-ratio mode: "off" (Original = no crop), "free" (user-drawn box),
* or a positive number for the preset "W:H" entries. */
function arMode(node) {
const w = widgetOf(node, "aspect_ratio");
const v = w ? String(w.value) : "Original";
if (/^\s*original\b/i.test(v)) return "off"; // "Original" -> no crop at all
if (/^\s*free\b/i.test(v)) return "free"; // user-drawn box, any shape
const m = v.match(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/);
if (m) {
const r = parseFloat(m[1]) / parseFloat(m[2]);
if (isFinite(r) && r > 0) return r;
}
return 1;
}
function videoIsVisible(el) {
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
/**
* Locate the visible <video> element for a node, in both designs:
* - classic: inside node.videoContainer (div.comfy-img-preview)
* - 2.0/Vue: inside the node's [data-node-id] element (div.video-preview)
* In 2.0 the LiteGraph container may be hidden while the Vue player is the
* visible one, so gather every candidate and prefer the visible element.
*/
function videoElOf(node) {
const cands = [];
if (node.videoContainer && node.videoContainer.querySelector) {
const v = node.videoContainer.querySelector("video");
if (v) cands.push(v);
}
try {
const root = document.querySelector(`[data-node-id="${node.id}"]`);
if (root) root.querySelectorAll("video").forEach((v) => cands.push(v));
} catch (_) { /* ignore */ }
for (const v of cands) if (videoIsVisible(v)) return v;
return cands[0] || null;
}
/** Video content (decoded) size, or null until the first frame is known. */
function videoNatural(v) {
if (!v) return null;
const w = v.videoWidth, h = v.videoHeight;
if (!w || !h) return null;
return { w, h };
}
/** Total playable duration in seconds, or 0 until metadata is loaded. */
function videoDuration(v) {
if (!v) return 0;
const d = v.duration;
return isFinite(d) && d > 0 ? d : 0;
}
/** Largest rect (normalized) with the given aspect ratio fitting cw x ch. */
function maxFitRatio(ratio, cw, ch) {
if (ratio <= cw / ch) return { w: (ratio * ch) / cw, h: 1 };
return { w: 1, h: cw / (ratio * ch) };
}
function resetCrop(node, cw, ch) {
const st = getState(node);
const m = arMode(node);
if (m === "off") {
st.crop = null;
} else if (m === "free") {
// Free keeps the current box (e.g. left over from a preset) or starts
// full-frame; the user then shapes it with the corner handles.
st.crop = st.crop || { x: 0, y: 0, w: 1, h: 1 };
fitCrop(node, st.crop, cw, ch);
} else {
const fr = maxFitRatio(m, cw, ch);
st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h };
}
st.cropFromWorkflow = false;
writeCrop(node);
}
/** Clamp crop to content bounds. Presets keep the ratio (height-driven);
* Free keeps the drawn shape (min size + bounds only). */
function fitCrop(node, c, cw, ch) {
const m = arMode(node);
if (m === "off") return c;
if (m === "free") {
c.w = Math.min(Math.max(c.w, 0.02), 1);
c.h = Math.min(Math.max(c.h, 0.02), 1);
c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w));
c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h));
return c;
}
const k = m * (ch / cw);
const fr = maxFitRatio(m, cw, ch);
c.h = Math.min(Math.max(c.h, 0.02), fr.h);
c.w = c.h * k;
c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w));
c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h));
return c;
}
function writeCrop(node) {
const st = getState(node);
const c = st.crop || { x: 0, y: 0, w: 1, h: 1 };
const map = { crop_x: "x", crop_y: "y", crop_w: "w", crop_h: "h" };
for (const [key, prop] of Object.entries(map)) {
const w = widgetOf(node, key);
if (w) w.value = c[prop];
}
}
function initCropFromWidgets(node, cw, ch) {
const st = getState(node);
if (arMode(node) === "off") {
st.crop = null;
writeCrop(node);
return;
}
if (st.crop) return;
const g = (k) => {
const w = widgetOf(node, k);
return w && Number.isFinite(w.value) ? w.value : undefined;
};
const x = g("crop_x"), y = g("crop_y"), w = g("crop_w"), h = g("crop_h");
if (x == null || y == null || w == null || h == null) {
resetCrop(node, cw, ch);
} else if (x === 0 && y === 0 && w === 1 && h === 1) {
resetCrop(node, cw, ch);
} else {
st.crop = { x, y, w, h };
st.cropFromWorkflow = true;
}
}
/* --------------------------- trim state ------------------------------ */
/** Push the trim window into the (hidden) widgets so it serializes. */
function writeTrim(node) {
const st = getState(node);
const t = st.trim;
const wStart = widgetOf(node, "start_time");
const wDur = widgetOf(node, "duration");
if (wStart) wStart.value = t.start;
if (wDur) wDur.value = t.dur;
}
function initTrimFromWidgets(node) {
const st = getState(node);
const g = (k) => {
const w = widgetOf(node, k);
return w && Number.isFinite(w.value) ? w.value : undefined;
};
const start = g("start_time");
const dur = g("duration");
if (start != null && dur != null && (start > 0 || dur > 0)) {
st.trim = { start: Math.max(0, start), dur: Math.max(0, dur) };
st.trimFromWorkflow = true;
return;
}
st.trim = { start: 0, dur: 0 };
st.trimFromWorkflow = false;
}
/** The window in ABSOLUTE seconds on the (uncropped) preview video.
* A negative start (API-set) means "from the end" and is resolved here. */
function trimWindowOf(node, durTotal) {
const t = getState(node).trim;
const start = t.start < 0 ? Math.max(0, durTotal + t.start) : Math.min(Math.max(0, t.start), durTotal);
const end = t.dur > 0 ? Math.min(start + t.dur, durTotal) : durTotal;
return { start, end };
}
function fmtTime(t) {
if (!isFinite(t) || t < 0) t = 0;
const m = Math.floor(t / 60);
const s = t - m * 60;
return `${m}:${s < 10 ? "0" : ""}${s.toFixed(2)}`;
}
/* ------------------------------ misc ------------------------------ */
function markDirty(node) {
try {
if (node.graph && typeof node.graph.setDirtyCanvas === "function") {
node.graph.setDirtyCanvas(true);
}
} catch (_) { /* ignore */ }
}
/* --------------------------- overlay (DOM) --------------------------- */
/**
* Detach our DOM (crop overlay + timeline + hidden thumb video). NEVER
* removes the <video> itself. Safe to call repeatedly.
*/
function removeOverlay(node) {
const st = node.__lvrc;
if (st && st.dom) {
// stop any in-flight thumbnail capture
if (st.dom.thumbs) st.dom.thumbs.cancelled = true;
// the hidden thumb <video> is a child of the timeline, so removing the
// timeline removes it too; clear the references either way
for (const el of [st.dom.overlay, st.dom.tl, st.dom.saveBtn]) {
if (el && el.parentElement) el.parentElement.removeChild(el);
}
}
if (st) st.dom = null;
}
/* Mirror of LoadVideoCrop._crop.even_box: the exact pixel box the backend
* writes to disk (even-snapped + clamped). pyRound reproduces Python's
* round-half-to-even so the JS math matches the backend bit-for-bit. */
function pyRound(x) {
const f = Math.floor(x);
const frac = x - f;
if (frac > 0.5) return f + 1;
if (frac < 0.5) return f;
return f % 2 === 0 ? f : f + 1; // exactly 0.5 -> even neighbor
}
function evenBox(fx0, fy0, fx1, fy1, W, H) {
const clampi = (v, lo, hi) => Math.max(lo, Math.min(hi, pyRound(v)));
let x0 = clampi(fx0 * W, 0, W - 2);
let y0 = clampi(fy0 * H, 0, H - 2);
let x1 = clampi(fx1 * W, x0 + 2, W);
let y1 = clampi(fy1 * H, y0 + 2, H);
x0 -= x0 % 2;
y0 -= y0 % 2;
let w = x1 - x0;
let h = y1 - y0;
w -= w % 2;
h -= h % 2;
if (w < 2) w = 2;
if (h < 2) h = 2;
if (x0 + w > W) { w = W - x0; w -= w % 2; }
if (y0 + h > H) { h = H - y0; h -= h % 2; }
return { x0, y0, w, h };
}
/**
* (Re)sync the DOM for a node. Reconnects (never destroys) when the video or
* its container changed under us (Vue re-render on aspect-ratio change, file
* change); re-inits crop/trim only when the content or ratio changed.
*/
function layoutOverlay(node) {
const st = getState(node);
const v = videoElOf(node);
const nat = videoNatural(v);
if (!v || !nat) {
removeOverlay(node);
return;
}
const cw = nat.w, ch = nat.h;
// the duration is part of the file signature (localStorage key); the
// block below re-runs once it becomes known
const dur = videoDuration(v);
// captured before st.lastSrc is consumed below
const fileChanged = st.lastSrc !== v.src;
// per-file AR memory: the aspect_ratio combo resets to its default
// ("Original") on a full page reload, losing the selection. When the
// widgets carry no crop (the workflow always wins) and the file was
// (re)loaded, restore the saved AR BEFORE the key is computed, so the
// restored mode is part of the key and this pass is the only one.
if (!st.cropFromWorkflow && fileChanged && dur > 0) {
const ld = lsLoadForVideo(v);
if (ld && ld.ar) {
const arw = widgetOf(node, "aspect_ratio");
if (arw && String(arw.value) !== ld.ar) arw.value = ld.ar;
}
}
const key = `${v.src}|${arMode(node)}|${cw}x${ch}|${dur.toFixed(3)}`;
if (st.lastKey !== key) {
st.lastKey = key;
// file changed -> the captured assets (thumbnails, waveform) belong to
// the previous file: drop them so they get re-captured. The very first
// sighting only records the src (a workflow-loaded trim must survive).
// (captured above, before the block below consumes st.lastSrc)
if (fileChanged) {
const first = st.lastSrc == null;
if (dur > 0) st.lastSrc = v.src; // only once the metadata is complete
if (!first && dur > 0 && st.dom) {
clearThumbs(node);
st.dom.waveData = null;
st.dom.waveKey = undefined;
if (st.dom.wave) {
try {
const wctx = st.dom.wave.getContext("2d");
wctx.clearRect(0, 0, st.dom.wave.width, st.dom.wave.height);
} catch (_) { /* ignore */ }
}
}
if (dur > 0) {
// Trim window for this file: the workflow wins (initTrimFromWidgets
// already loaded it); otherwise restore the per-file memory, or
// reset to the full clip (spec: a new file resets the window). This
// also covers the multi-tab re-instantiation (both layouts), where the node is
// brand new (first sighting) and the widgets carry defaults.
if (!first || !st.trimFromWorkflow) {
const ld = lsLoadForVideo(v);
const tm = ld && ld.trim;
if (tm && (tm.start > 0 || tm.dur > 0)) {
st.trim = { start: Math.max(0, tm.start), dur: Math.max(0, tm.dur) };
} else {
st.trim = { start: 0, dur: 0 };
}
writeTrim(node);
// restore native video controls preference
if (ld && typeof ld.ctrls === "boolean") {
v.controls = ld.ctrls;
if (st.dom.ctrlBtn) st.dom.ctrlBtn.style.opacity = v.controls ? "1" : "0.5";
}
}
}
}
let chosen = "?";
if (node.videoContainer && node.videoContainer.contains(v)) chosen = "videoContainer";
else if (document.querySelector(`[data-node-id="${node.id}"]`)) chosen = "data-node-id";
console.log(
LVRC.TAG, "node", node.id, "file", (v.currentSrc || v.src || "").split("/").pop(),
"content", cw + "x" + ch, "ratio", arMode(node), "chosen", chosen,
"parentClass", ((v.parentElement && v.parentElement.className) || "").toString().slice(0, 60)
);
if (!st.cropFromWorkflow) {
// the widgets carried no crop (fresh node, or the frontend reset the
// FLOAT widgets on a tab switch): prefer the localStorage memory for
// this exact file over the default max-fit crop. Only on (re)init or
// file change - an ASPECT RATIO change is not a file change and must
// reset the box to the new max fit (spec).
// the AR was restored above (same record); now the crop box
const ld = fileChanged && dur > 0 ? lsLoadForVideo(v) : null;
const lc = ld && ld.crop;
if (lc && lc.w > 0.01 && lc.h > 0.01 && arMode(node) !== "off") {
st.crop = { x: lc.x, y: lc.y, w: lc.w, h: lc.h };
fitCrop(node, st.crop, cw, ch);
writeCrop(node);
} else if (arMode(node) === "free" && fileChanged) {
// no saved box for this file: a Free crop starts full-frame
// (resetCrop would keep the previous file's box on a file change)
st.crop = { x: 0, y: 0, w: 1, h: 1 };
writeCrop(node);
} else {
resetCrop(node, cw, ch);
}
} else {
st.crop = fitCrop(node, st.crop, cw, ch);
writeCrop(node);
}
// Note: the trim window is deliberately NOT reset here. A key change
// also happens when the user only changes the ASPECT RATIO, and the
// IN/OUT marks must survive that. A changed FILE already resets the
// trim (see the lastSrc block above).
} else if (!st.crop && arMode(node) !== "off") {
initCropFromWidgets(node, cw, ch);
}
const parent = v.parentElement;
if (!parent) return;
const same = st.dom && st.dom.video === v && st.dom.parent === parent;
if (!same) buildOverlay(node, v, parent);
if (!st.dom) return;
sizeVideoBox(node);
const { overlay, box, label, handles } = st.dom;
const c = st.crop;
if (!c) {
overlay.style.display = "none";
} else {
// re-measure AFTER sizeVideoBox (the video box height is set above)
const vw = v.clientWidth || 1;
const vh = v.clientHeight || 1;
if (vw < 2 || vh < 2) { layoutTimeline(node); return; }
overlay.style.display = "";
// content area inside the VIDEO box: object-contain
const s = Math.min(vw / cw, vh / ch);
const dw = cw * s;
const dh = ch * s;
const vr = v.getBoundingClientRect();
const pr = parent.getBoundingClientRect();
// video box offset inside the parent (normally 0,0: the video is
// absolute-top in the parent; kept for robustness)
const vx = vr.left - pr.left;
const vy = vr.top - pr.top;
// box coords in VIDEO-CONTENT space, in px from the overlay origin
// (the overlay spans the video box exactly)
const bx = vx + (vw - dw) / 2 + c.x * dw;
const by = vy + (vh - dh) / 2 + c.y * dh;
box.style.left = (bx / vw) * 100 + "%";
box.style.top = (by / vh) * 100 + "%";
box.style.width = (c.w * dw / vw) * 100 + "%";
box.style.height = (c.h * dh / vh) * 100 + "%";
const out = evenBox(c.x, c.y, c.x + c.w, c.y + c.h, cw, ch);
label.textContent = `${out.w} × ${out.h}`;
label.style.display = box.offsetHeight > 18 ? "" : "none";
// corner handles are only useful in Free mode (presets keep their ratio)
const free = arMode(node) === "free";
if (handles) for (const h of handles) h.style.display = free ? "" : "none";
}
layoutTimeline(node);
// kick off the asynchronous bits (self-cancelling, re-run safely)
startThumbs(node);
startWave(node);
}
/** Keep the <video> filling the TOP of its container and the container
* (H_video + timeline) tall, so the timeline lives BELOW the video. */
function sizeVideoBox(node) {
const st = node.__lvrc;
if (!st || !st.dom) return;
const v = st.dom.video;
const parent = st.dom.parent;
const nat = videoNatural(v);
if (!nat) return;
const isClassic = node.videoContainer === parent;
if (isClassic) {
// classic: the LiteGraph DOM widget (type "video") drives the container
// size via computeLayoutSize -> add the timeline height to its minHeight.
const wdg = (node.widgets || []).find((w) => w && w.type === "video");
if (wdg && !wdg.__lvrcSized) {
wdg.__lvrcSized = true;
const orig = wdg.computeLayoutSize;
wdg.computeLayoutSize = () => {
const r = orig && typeof orig === "function" ? orig() : {};
// minWidth is DELIBERATELY 0: the core returns the node's current
// width as minWidth, which pins the node so it can never be resized
// narrower; returning 0 frees the node to any width (the video
// letterboxes via object-fit:contain)
return {
minHeight: (Number.isFinite(r.minHeight) ? r.minHeight : 0) + LVRC.TL_TOTAL_H,
minWidth: 0,
};
};
}
} else {
// 2.0/Vue: size the container ourselves (the poller re-applies after
// re-renders). Target = the video aspect height at the current width
// plus the timeline.
const baseH = (parent.clientWidth / nat.w) * nat.h;
const target = Math.round(baseH + LVRC.TL_TOTAL_H);
if (Math.abs(parent.clientHeight - target) > 1) {
parent.style.height = target + "px";
}
}
if (getComputedStyle(parent).position === "static") parent.style.position = "relative";
v.style.position = "absolute";
v.style.left = "0";
v.style.top = "0";
v.style.width = "100%";
v.style.height = `calc(100% - ${LVRC.TL_TOTAL_H}px)`;
v.style.objectFit = "contain";
}
function buildOverlay(node, v, parent) {
const st = getState(node);
const old = st.dom;
removeOverlay(node);
if (getComputedStyle(parent).position === "static") parent.style.position = "relative";
// The crop overlay is PURELY VISUAL (pointer-events:none): the native
// <video> controls always work; all crop interaction is driven from
// document capture-phase listeners with a manual hit-test against this box.
const overlay = document.createElement("div");
overlay.dataset.lvrcOverlay = "1";
// covers ONLY the video box (the bottom TL_TOTAL_H px is the timeline):
// the dimming box-shadow must not darken the timeline strip
overlay.style.cssText =
`position:absolute;left:0;top:0;width:100%;height:calc(100% - ${LVRC.TL_TOTAL_H}px);z-index:2;pointer-events:none;overflow:hidden;`;
const box = document.createElement("div");
box.style.cssText =
"position:absolute;box-sizing:border-box;border:2px solid #ff4040;box-shadow:0 0 0 4000px rgba(0,0,0,0.55);cursor:move;";
const label = document.createElement("div");
label.style.cssText =
"position:absolute;left:4px;top:2px;font:11px monospace;color:#fff;background:rgba(0,0,0,0.8);padding:0 4px;white-space:nowrap;";
box.appendChild(label);
// Free-mode corner handles (shown only while aspect_ratio is Free):
// dragging one resizes the box freely, the opposite corner stays fixed
const handles = [];
for (const corner of ["nw", "ne", "sw", "se"]) {
const h = document.createElement("div");
h.dataset.lvrcHandle = corner;
const pos = corner.startsWith("n") ? "top:-6px" : "bottom:-6px";
const hor = corner.endsWith("w") ? "left:-6px" : "right:-6px";
h.style.cssText =
`position:absolute;box-sizing:border-box;width:12px;height:12px;` +
`background:#fff;border:1px solid #ff4040;z-index:1;display:none;` +
`${pos};${hor};`; // cursor is set on the overlay via hit-test
box.appendChild(h);
handles.push(h);
}
overlay.appendChild(box);
parent.appendChild(overlay);
st.dom = { parent, overlay, box, label, video: v, handles };
buildTimeline(node, parent); // stores tl/film/wave/... on st.dom
// "Save frame" button: top-left of the video box (the right side is taken
// by the 2.0 layout controls). One click downloads the current frame as
// PNG: the full frame, plus the cropped frame at the exact output
// dimensions (identical in Original mode, so only the full one is saved).
// Created here so it is rebuilt with the overlay on every (re)render.
const saveBtn = document.createElement("button");
saveBtn.dataset.lvrcSaveFrame = "1";
saveBtn.type = "button";
saveBtn.textContent = "\uD83D\uDCBE Frame";
saveBtn.title = "Save the current frame as PNG (full, plus cropped at the exact output dimensions)";
saveBtn.style.cssText =
"position:absolute;top:8px;left:8px;z-index:3;pointer-events:auto;" +
"background:rgba(0,0,0,0.6);color:#fff;border:1px solid rgba(255,255,255,0.35);" +
"border-radius:4px;padding:2px 8px;font:12px sans-serif;cursor:pointer;";
// keep the core's container handlers (canvas pan/zoom) out of it
saveBtn.addEventListener("pointerdown", (e) => { e.stopPropagation(); });
saveBtn.addEventListener("click", (e) => {
e.stopPropagation();
saveFrames(node);
});
parent.appendChild(saveBtn);
st.dom.saveBtn = saveBtn;
// Video controls toggle: show/hide the native <video> controls.
// The native controls are redundant (we have our own timeline) and can
// interfere with crop-box interaction. Persisted per file in localStorage.
const ctrlBtn = document.createElement("button");
ctrlBtn.dataset.lvrcCtrls = "1";
ctrlBtn.type = "button";
ctrlBtn.textContent = "\u23EF"; // ⏯
ctrlBtn.title = "Toggle native video controls";
ctrlBtn.style.cssText =
"position:absolute;top:8px;left:82px;z-index:3;pointer-events:auto;" +
"background:rgba(0,0,0,0.6);color:#fff;border:1px solid rgba(255,255,255,0.35);" +
"border-radius:4px;padding:2px 8px;font:12px sans-serif;cursor:pointer;";
ctrlBtn.addEventListener("pointerdown", (e) => { e.stopPropagation(); });
ctrlBtn.addEventListener("click", (e) => {
e.stopPropagation();
const vv = st.dom.video;
if (vv) {
vv.controls = !vv.controls;
ctrlBtn.style.opacity = vv.controls ? "1" : "0.5";
// persist
try {
const key = lsKeyOfVideo(vv);
if (key) {
const raw = localStorage.getItem(key);
const s = raw ? JSON.parse(raw) : {};
if (s && typeof s.trim === "object" && s.trim !== null) {
s.ctrls = vv.controls;
localStorage.setItem(key, JSON.stringify(s));
}
}
} catch (_) { /* best-effort */ }
}
});
ctrlBtn.style.opacity = v.controls ? "1" : "0.5";
parent.appendChild(ctrlBtn);
st.dom.ctrlBtn = ctrlBtn;
// Reuse the already-captured assets (same file only): move the finished
// thumbnail <img>s into the new filmstrip and redraw the waveform, so a
// Vue re-render (e.g. on aspect-ratio change) does not re-seek the file.
const oldSrc = old && old.video ? (old.video.currentSrc || old.video.src || "") : "";
const newSrc = v.currentSrc || v.src || "";
if (old && oldSrc === newSrc) {
if (old.thumbs) old.thumbs.cancelled = true;
if (old.film && st.dom.film) {
for (const img of old.film.querySelectorAll("img")) st.dom.film.appendChild(img);
st.dom.thumbs.els = Array.from(st.dom.film.querySelectorAll("img"));
// carry the finished state: a completed capture is NOT re-seeked; an
// interrupted one is captured from scratch on the new DOM
st.dom.thumbs.key = old.thumbs ? old.thumbs.key : "";
st.dom.thumbs.done = old.thumbs ? old.thumbs.done : false;
}
if (old.waveData && st.dom.wave) {
st.dom.waveData = old.waveData;
st.dom.waveKey = old.waveKey;
drawWave(st.dom.wave, old.waveData);
}
}
layoutOverlay(node);
}
/* --------------------------- trim timeline --------------------------- */
function buildTimeline(node, parent) {
const st = getState(node);
const tl = document.createElement("div");
tl.dataset.lvrcTimeline = "1";
tl.style.cssText =
`position:absolute;left:0;right:0;bottom:0;height:${LVRC.TL_TOTAL_H}px;z-index:1;` +
"background:#0a0a0a;pointer-events:auto;";
const film = document.createElement("div");
film.dataset.lvrcFilm = "1";
film.style.cssText = `position:absolute;left:0;right:0;top:0;height:${LVRC.TL_FILM_H}px;background:#141414;overflow:hidden;`;
const wave = document.createElement("canvas");
wave.dataset.lvrcWave = "1";
wave.style.cssText = `position:absolute;left:0;right:0;top:${LVRC.TL_FILM_H}px;height:${LVRC.TL_WAVE_H}px;width:100%;background:#0d0d0d;`;
const dim = document.createElement("div");
dim.dataset.lvrcDim = "1";
dim.style.cssText = "position:absolute;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,0.55);";
const sel = document.createElement("div");
sel.dataset.lvrcSel = "1";
sel.style.cssText =
"position:absolute;left:0%;width:100%;top:0;bottom:0;" +
"background:rgba(64,192,255,0.10);cursor:default;";
// Thin 2px marker lines (frame-precise) with a small grip on top as the
// drag affordance. The pointer handler keeps a +/-7px grab tolerance, so
// the thin lines remain easy to grab.
const hL = document.createElement("div");
hL.dataset.lvrcHL = "1";
hL.style.cssText = "position:absolute;left:0;top:0;bottom:0;width:2px;cursor:ew-resize;background:#40c0ff;";
const gL = document.createElement("div");
gL.style.cssText =
"position:absolute;left:-4px;top:0;width:10px;height:12px;border-radius:2px;cursor:ew-resize;" +
"background:#40c0ff;box-shadow:0 0 3px rgba(0,0,0,0.8);";
hL.appendChild(gL);
const hR = document.createElement("div");
hR.dataset.lvrcHR = "1";
hR.style.cssText = "position:absolute;right:0;top:0;bottom:0;width:2px;cursor:ew-resize;background:#40c0ff;";
const gR = document.createElement("div");
gR.style.cssText =
"position:absolute;right:-4px;top:0;width:10px;height:12px;border-radius:2px;cursor:ew-resize;" +
"background:#40c0ff;box-shadow:0 0 3px rgba(0,0,0,0.8);";
hR.appendChild(gR);
const lbl = document.createElement("div");
lbl.dataset.lvrcLbl = "1";
lbl.style.cssText =
"position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);" +
"font:10px monospace;color:#fff;white-space:nowrap;pointer-events:none;text-shadow:0 0 3px #000;";
sel.appendChild(hL);
sel.appendChild(hR);
sel.appendChild(lbl);
const ph = document.createElement("div");
ph.dataset.lvrcPH = "1";
ph.style.cssText = "position:absolute;top:0;bottom:0;width:2px;left:0%;background:#fff;pointer-events:none;";
tl.appendChild(film);
tl.appendChild(wave);
tl.appendChild(dim);
tl.appendChild(sel);
tl.appendChild(ph);
parent.appendChild(tl);
installTimelineEvents(node, tl);
// attach into st.dom (created by buildOverlay)
const d = getState(node).dom;
d.tl = tl;
d.film = film;
d.wave = wave;
d.sel = sel;
d.lbl = lbl;
d.ph = ph;
d.thumbs = { key: "", els: [], src: "", cancelled: false, video: null, done: false };
d.waveData = null;
}
/** Position selection/handles/label for the current trim window. The
* playhead is driven by the rAF loop (startPlayheadLoop). */
function layoutTimeline(node) {
const st = node.__lvrc;
if (!st || !st.dom) return;
const { tl, sel, lbl, ph, video } = st.dom;
const durTotal = videoDuration(video);
if (!durTotal || !tl) {
if (tl) tl.style.display = "none";
return;
}
tl.style.display = "";
const { start, end } = trimWindowOf(node, durTotal);
sel.style.left = (start / durTotal) * 100 + "%";
sel.style.width = Math.max(0, ((end - start) / durTotal) * 100) + "%";
lbl.textContent = `${fmtTime(start)} – ${fmtTime(end)} (${(end - start).toFixed(2)}s)`;
if (ph) {
const p = Math.min(1, Math.max(0, (video.currentTime || 0) / durTotal));
ph.style.left = p * 100 + "%";
}
}
/* --------------------- filmstrip thumbnails -------------------------- */
function thumbCountOf(durTotal) {
return Math.min(LVRC.TL_MAX_THUMBS, Math.max(LVRC.TL_MIN_THUMBS, Math.ceil(durTotal)));
}
function clearThumbs(st) {
const d = st.dom;
if (!d) return;
d.thumbs.cancelled = true;
if (d.thumbs.els) {
for (const el of d.thumbs.els) {
if (el.parentElement) el.parentElement.removeChild(el);
}
}
d.thumbs.els = [];
if (d.thumbs.video && d.thumbs.video.parentElement) {
d.thumbs.video.parentElement.removeChild(d.thumbs.video);
}
d.thumbs.video = null;
}
function seekTo(v, t) {
return new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error("seek timeout")), 2000);
v.onseeked = () => { clearTimeout(to); res(); };
v.onerror = () => { clearTimeout(to); rej(new Error("seek error")); };
try {
v.currentTime = Math.min(Math.max(0, t), Math.max(0, videoDuration(v) - 0.05));
} catch (e) {
clearTimeout(to);
rej(e);
}
});
}
/** Fill the filmstrip with real frames: a second hidden <video> is seeked to
* N equidistant points (N = clamp(ceil(dur), 4, 48)) and each frame is drawn
* to a small JPEG. Progressive: thumbnails appear as they are captured. */
function startThumbs(node) {
const st = node.__lvrc;
const d = st.dom;
const v = d.video;
const durTotal = videoDuration(v);
if (!durTotal || !d.film) return;
const count = thumbCountOf(durTotal);
const src = v.currentSrc || v.src || "";
const key = `${src}|${durTotal.toFixed(3)}|${count}`;
// skip if this exact capture already finished, or is still in flight
// (the hidden <video> exists until the loop cleans up)
if (d.thumbs.key === key && (d.thumbs.done || d.thumbs.video)) return;
clearThumbs(st);
d.thumbs = { key, els: [], src, cancelled: false, video: null, done: false };
const cv = document.createElement("video");
cv.muted = true;
cv.playsInline = true;
cv.preload = "auto";
cv.src = src;
cv.style.cssText = "position:absolute;width:2px;height:2px;left:-9999px;opacity:0;pointer-events:none;";
d.thumbs.video = cv;
parentAppendKeepHidden(node, cv);
const nat = videoNatural(v);
const tw = nat ? Math.max(32, Math.round(LVRC.TL_FILM_H * (nat.w / nat.h))) : 50;
const c = document.createElement("canvas");
c.width = tw;
c.height = LVRC.TL_FILM_H;
const ctx = c.getContext("2d");
(async () => {
try {
await new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error("thumb video timeout")), 8000);
cv.onloadeddata = () => { clearTimeout(to); res(); };
cv.onerror = () => { clearTimeout(to); rej(new Error("thumb video error")); };
});
} catch (_) {
cleanup();
return;
}
for (let i = 0; i < count; i++) {
if (d.thumbs.cancelled) { cleanup(); return; }
const t = Math.min(Math.max(0, durTotal - 0.05), ((i + 0.5) / count) * durTotal);
try {
await seekTo(cv, t);
} catch (_) {
continue; // skip a bad seek point
}
if (d.thumbs.cancelled) { cleanup(); return; }
try {
ctx.drawImage(cv, 0, 0, tw, LVRC.TL_FILM_H);
const img = document.createElement("img");
img.src = c.toDataURL("image/jpeg", 0.6);
img.style.cssText =
`position:absolute;top:0;height:100%;` +
`width:${100 / count + 0.8}%;left:${(i * 100) / count}%;` +
"object-fit:cover;pointer-events:none;";
d.film.appendChild(img);
d.thumbs.els.push(img);
} catch (_) { /* skip frame */ }
await sleep(0); // yield to the UI
}
d.thumbs.done = true; // full set captured (lets a DOM rebuild skip the re-seek)
cleanup();
})();
function cleanup() {
if (d.thumbs.video && d.thumbs.video.parentElement) {
d.thumbs.video.parentElement.removeChild(d.thumbs.video);
}
d.thumbs.video = null;
}
}
/** The hidden thumb <video> lives in the timeline track (hidden off-canvas). */
function parentAppendKeepHidden(node, cv) {
const st = node.__lvrc;
if (!st || !st.dom || !st.dom.tl) return;
cv.style.position = "absolute";
cv.style.left = "-100px";
cv.style.width = "2px";
cv.style.height = "2px";
cv.style.opacity = "0";
st.dom.tl.appendChild(cv);
}
/* --------------------- audio waveform ------------------------------- */
let lrcAudioCtx = null;
function audioCtx() {
try {
if (!lrcAudioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
lrcAudioCtx = new AC();
}
if (lrcAudioCtx.state === "suspended") lrcAudioCtx.resume();
return lrcAudioCtx;
} catch (_) {
return null;
}
}
function drawWave(canvas, data) {
if (!data || !data.length) return;
const dpr = window.devicePixelRatio || 1;
const W = Math.max(1, Math.round((canvas.clientWidth || 300) * dpr));
const H = Math.round(LVRC.TL_WAVE_H * dpr);
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgba(64,192,255,0.85)";
const mid = H / 2;
for (let x = 0; x < W; x++) {
// sample the envelope with linear interpolation; data holds 600 bins x 2
// (min,max), so the bin count is data.length / 2
const bins = data.length / 2;
const f = (x / W) * bins;
const i0 = Math.min(bins - 1, Math.floor(f));
const i1 = Math.min(bins - 1, i0 + 1);
const fr = f - i0;
const mn = data[i0 * 2] * (1 - fr) + data[i1 * 2] * fr;
const mx = data[i0 * 2 + 1] * (1 - fr) + data[i1 * 2 + 1] * fr;
const y0 = mid + mn * mid; // mn is -1..0
const h = Math.max(1, (mx - mn) * mid);
ctx.fillRect(x, y0, 1, h);
}
}
/** Fetch + decode the audio once and store a min/max envelope. Skipped for
* very long files (large decode) - the filmstrip still works. */
function startWave(node) {
const st = node.__lvrc;
const d = st.dom;
const v = d.video;
const durTotal = videoDuration(v);
if (!durTotal || !d.wave) return;
const src = v.currentSrc || v.src || "";
if (d.waveData || d.waveKey === src) return;
d.waveKey = src;
if (durTotal > LVRC.TL_WAVE_MAX_SECONDS) {
d.waveData = null;
return;
}
(async () => {
let buf = null;
try {
const resp = await fetch(src);
buf = await resp.arrayBuffer();
} catch (_) {
return;
}
const actx = audioCtx();
if (!actx || !buf) return;
let audio = null;
try {
audio = await actx.decodeAudioData(buf);
} catch (_) {
return; // no decodable audio
}
// mono envelope over 600 bins (channel 0; enough for a visual cue)
const nBins = 600;
const data = new Float32Array(nBins * 2).fill(0);
const ch = audio.getChannelData(0);
const n = ch.length;
const step = n / nBins;
for (let b = 0; b < nBins; b++) {
const s0 = Math.floor(b * step);
const s1 = Math.min(n, Math.floor((b + 1) * step));
let mn = 0, mx = 0;
// sample at most every 8th point (fast, accurate enough)
const inc = Math.max(1, Math.floor((s1 - s0) / 512));
for (let i = s0; i < s1; i += inc) {
const x = ch[i];
if (x < mn) mn = x;
if (x > mx) mx = x;
}
data[b * 2] = mn;
data[b * 2 + 1] = mx;
}
if (d.waveKey !== src) return; // file changed meanwhile
d.waveData = data;
drawWave(d.wave, data);
})();
}
/** Redraw the waveform whenever the timeline width changed. */
function resyncWave(node) {
const st = node.__lvrc;
if (!st || !st.dom) return;
const d = st.dom;
if (!d.wave || !d.waveData) return;
drawWave(d.wave, d.waveData);
}
/* --------------------- frame saving ---------------------------------- */
/** Best-effort file stem from the video src (for the PNG file name). */
function frameBaseName(v) {
try {
const p = new URL(v.currentSrc || v.src, location.href).pathname.split("/").pop() || "";
let n = decodeURIComponent(p).replace(/\.[a-z0-9]{2,5}$/i, "");
n = n.replace(/[^\w\-]+/g, "_").replace(/^_+|_+$/g, "");
return n || "video";
} catch (_) {
return "video";
}
}
/** mm-ss.cc timecode for the file name. */
function frameTimecode(t) {
const m = Math.floor(t / 60);
const cs = Math.min(5999, Math.floor((t % 60) * 100));
return String(m).padStart(2, "0") + "-" + String(cs).padStart(4, "0");
}
function downloadBlob(blob, name) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
function saveFrameTo(v, sx, sy, sw, sh, name) {
return new Promise((resolve) => {
const cv = document.createElement("canvas");
cv.width = sw;
cv.height = sh;
const ctx = cv.getContext("2d");
ctx.drawImage(v, sx, sy, sw, sh, 0, 0, sw, sh);
cv.toBlob((blob) => {
if (!blob) { resolve(false); return; }
downloadBlob(blob, name);
resolve(true);
}, "image/png");
});
}
const SAVE_BTN_IDLE = "\uD83D\uDCBE Frame";
/** One click downloads the current frame as PNG via the browser: the full
* frame plus the cropped frame at the exact output dimensions (only the
* full one in Original mode); the outcome is reported on the button. */
async function saveFrames(node) {
const st = node.__lvrc;
const d = st && st.dom;
const v = d && d.video;
if (!v || !v.videoWidth) return;
const btn = d.saveBtn;
if (btn) { btn.disabled = true; btn.textContent = "\u2026"; }
let ok = true;
let state = "saved";
try {
const W = v.videoWidth;
const H = v.videoHeight;
const c = st.crop;
const base = frameBaseName(v) + "_t" + frameTimecode(v.currentTime || 0);
// Original aspect ratio has no crop, so a cropped PNG would be a
// duplicate of the full one: save only the full frame in that case.
const results = [saveFrameTo(v, 0, 0, W, H, base + "_full.png")];
if (c) {
const box = evenBox(c.x, c.y, c.x + c.w, c.y + c.h, W, H);
results.push(saveFrameTo(v, box.x0, box.y0, box.w, box.h, base + "_crop.png"));
}
ok = (await Promise.all(results)).every(Boolean);
} catch (err) {
console.warn(LVRC.TAG, "saveFrames", err);
state = "failed";
}
if (btn) {
btn.textContent = state === "failed" || !ok
? "\u26A0 Failed"
: "\u2713 Saved";
}
setTimeout(() => {
if (btn) { btn.textContent = SAVE_BTN_IDLE; btn.disabled = false; }
}, 1600);
}
/* --------------------- timeline interaction -------------------------- */
let tlActive = null; // { node, mode, grabT, start, dur, pid }
function tlFracOf(tl, e) {
const r = tl.getBoundingClientRect();
if (!r || r.width <= 0) return 0;
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
}
function installTimelineEvents(node, tl) {
tl.addEventListener("pointerdown", (e) => {
try {
if (e.button !== 0 || tlActive) return;
const st = node.__lvrc;
const v = st && st.dom ? st.dom.video : null;
const durTotal = videoDuration(v);
if (!st || !v || !durTotal) return;
const r = tl.getBoundingClientRect();
if (!r || r.width <= 0) return;
const px = e.clientX - r.left;
const t = (px / r.width) * durTotal;
const { start, end } = trimWindowOf(node, durTotal);
const selLeftPx = (start / durTotal) * r.width;
const selRightPx = (end / durTotal) * r.width;
let mode = "seek";
if (Math.abs(px - selLeftPx) <= 7) mode = "resize-l";
else if (Math.abs(px - selRightPx) <= 7) mode = "resize-r";
// the middle of the selection is NOT draggable: only IN/OUT move;
// pressing anywhere else (including the middle) just seeks
if (mode === "seek") {
try { v.currentTime = Math.min(Math.max(0, t), durTotal); } catch (_) { /* not seekable yet */ }
// keep the core's container handlers (canvas pan/zoom) out of it
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
tlActive = {
node, mode, grabT: t - start, start, dur: st.trim.dur, pid: e.pointerId,
downX: e.clientX, downY: e.clientY, moved: false,
};
try { tl.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
tl.style.cursor = "ew-resize";
// editor behavior: pause the preview so the marker drag below can scrub it
if (mode === "resize-l" || mode === "resize-r") {
try { v.pause(); } catch (_) { /* ignore */ }
}
} catch (err) {
console.warn(LVRC.TAG, "tl pointerdown", err);
}
});
tl.addEventListener("pointermove", (e) => {
if (!tlActive || tlActive.node !== node) return;
try {
const st = node.__lvrc;
const v = st && st.dom ? st.dom.video : null;
const durTotal = videoDuration(v);
if (!st || !v || !durTotal) return;
const t = tlFracOf(tl, e) * durTotal;
const a = tlActive;
// click-vs-drag: a pointer that barely moved is a click, not a drag
if (!a.moved) {
if (Math.abs(e.clientX - a.downX) <= 4 && Math.abs(e.clientY - a.downY) <= 4) return;
a.moved = true;
}
const MIN = LVRC.TL_MIN_DUR;
let start;
let dur;
if (a.mode === "resize-l") {
// only the IN moves: the OUT (end) stays fixed
const end = a.dur > 0 ? Math.min(a.start + a.dur, durTotal) : durTotal;
start = Math.min(Math.max(0, t), Math.max(0, end - MIN));
dur = a.dur > 0 ? end - start : 0;
} else {
start = a.start;
const end = Math.min(Math.max(t, a.start + MIN), durTotal);
dur = end - a.start;
}
st.trim = { start, dur };
writeTrim(node);
layoutTimeline(node);
// while dragging IN/OUT, the preview scrubs to the marker position
if (a.mode === "resize-l" || a.mode === "resize-r") {
const mt = (a.mode === "resize-l" ? start : start + dur);
try { v.currentTime = Math.min(Math.max(0, mt), durTotal); } catch (_) { /* ignore */ }
}
} catch (err) {
console.warn(LVRC.TAG, "tl pointermove", err);
}
});
const tlEnd = (e) => {
if (!tlActive || tlActive.node !== node) return;
const a = tlActive;
tlActive = null;
tl.style.cursor = "default";
if (a.moved) lsSave(node); // remember the marks (drag only; a click is a seek)
// a click (no drag) on the selection/handles = seek to that position,
// same as clicking the rest of the timeline
if (!a.moved && e && e.clientX !== undefined) {
try {
const st = node.__lvrc;
const v = st && st.dom ? st.dom.video : null;
const durTotal = videoDuration(v);
if (!v || !durTotal) return;
const r = tl.getBoundingClientRect();
if (!r || r.width <= 0) return;
const t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) * durTotal;
v.currentTime = Math.min(Math.max(0, t), durTotal);
} catch (_) { /* ignore */ }
}
};
tl.addEventListener("pointerup", tlEnd);
tl.addEventListener("pointercancel", tlEnd);
// the core binds wheel/pointer to the container for canvas pan/zoom:
// eat wheel over the timeline so the graph does not jump while adjusting
// the trim
tl.addEventListener(
"wheel",
(e) => {
e.preventDefault();
e.stopPropagation();
},
{ passive: false }
);
}
/* Keyboard I/O marking (editor style): I = mark in, O = mark out at the
* playhead. Only when not typing in an input. */
let keyHandlerInstalled = false;
function installKeyHandler() {
if (keyHandlerInstalled) return;
keyHandlerInstalled = true;
document.addEventListener("keydown", (e) => {
try {
const tag = (e.target && e.target.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target && e.target.isContentEditable)) return;
const k = (e.key || "").toLowerCase();
if (k !== "i" && k !== "o") return;
if (!lvrcApp || !lvrcApp.graph) return;
// the LAST LoadVideoCrop node with a visible player (or the first)
let target = null;
for (const n of lvrcApp.graph.nodes || []) {
if (n.type !== LVRC.NODE_NAME || !n.__lvrc || !n.__lvrc.dom) continue;
const v = n.__lvrc.dom.video;
if (v && videoIsVisible(v)) target = n;
}
if (!target) return;
const st = target.__lvrc;
const v = st.dom.video;
const durTotal = videoDuration(v);
if (!durTotal) return;
const ct = Math.min(Math.max(0, v.currentTime || 0), durTotal);
const { start, end } = trimWindowOf(target, durTotal);
const MIN = LVRC.TL_MIN_DUR;
let s = start;
let d = st.trim.dur;
if (k === "i") {
s = Math.min(ct, Math.max(0, end - MIN));
d = end - s; // only the IN moves; OUT (end) stays fixed
} else {
const ne = Math.max(ct, start + MIN);
d = ne - start;
}
st.trim = { start: s, dur: d };
writeTrim(target);
lsSave(target);
layoutTimeline(target);
} catch (err) {
console.warn(LVRC.TAG, "keydown", err);
}
}, true);
}
/* --------------------------- poller ---------------------------------- */
let pollerStarted = false;
function startPoller() {
if (pollerStarted) return;
pollerStarted = true;
setInterval(() => {
try {
if (!lvrcApp || !lvrcApp.graph) return;
for (const n of lvrcApp.graph.nodes || []) {
if (!n || n.type !== LVRC.NODE_NAME || !n.__lvrc || !n.__lvrc.inited) continue;
layoutOverlay(n);
// redraw the waveform when the node width changed
if (n.__lvrc.dom && n.__lvrc.dom.wave && n.__lvrc.dom.waveData) {
const w = n.__lvrc.dom.wave;
const dpr = window.devicePixelRatio || 1;
const wantW = Math.round((w.clientWidth || 300) * dpr);
if (Math.abs(w.width - wantW) > 1) resyncWave(n);
}
}
} catch (err) {
console.warn(LVRC.TAG, "poller", err);
}
}, LVRC.POLL_MS);
}
/* Playhead follows the playback position at display refresh rate. */
let playheadStarted = false;
function startPlayheadLoop() {
if (playheadStarted) return;
playheadStarted = true;
const tick = () => {
try {
if (lvrcApp && lvrcApp.graph) {
for (const n of lvrcApp.graph.nodes || []) {
if (!n || n.type !== LVRC.NODE_NAME || !n.__lvrc || !n.__lvrc.dom) continue;
const st = n.__lvrc;
const d = st.dom;
const v = d.video;
const dur = videoDuration(v);
if (!dur || !d.ph) continue;
// playback is confined to the trim window: starting (or seeking)
// before the IN mark jumps to IN, and when the OUT mark is reached
// the playhead loops back to IN (only while the window is strictly
// inside the clip, so OUT == end of video still stops naturally)
const t = st.trim;
if (t && t.dur > LVRC.TL_MIN_DUR && !v.paused) {
const ct = v.currentTime || 0;
if (
ct < t.start - 0.03 ||
(ct >= t.start + t.dur - 0.03 && t.start + t.dur < dur - 0.01)
) {
try { v.currentTime = t.start; } catch (_) { /* ignore */ }
}
}
d.ph.style.left = Math.min(1, Math.max(0, (v.currentTime || 0) / dur)) * 100 + "%";
// keep the hidden frame_time widget in sync with the player so the
// IMAGE outputs carry the frame the preview is showing (60Hz tick
// -> at most half a frame of lag)
const wt = widgetOf(n, "frame_time");
if (wt) wt.value = v.currentTime || 0;
}
}
} catch (_) { /* ignore */ }
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
/* --------------------- shared interaction helpers --------------------- */
/** Pointer position normalized to the CONTENT area of a <video> (letterbox aware). */
function contentPosOf(v, e) {
const r = v.getBoundingClientRect();
const pw = r.width || 1, ph = r.height || 1;
const nat = videoNatural(v);
if (!nat) return null;
const s = Math.min(pw / nat.w, ph / nat.h);
const dw = nat.w * s, dh = nat.h * s;
const ox = (pw - dw) / 2, oy = (ph - dh) / 2;
return { nx: (e.clientX - r.left - ox) / dw, ny: (e.clientY - r.top - oy) / dh };
}
function inRect(x, y, r) {
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function nodeOverBox(n, x, y) {
const st = n.__lvrc;
if (!st || !st.dom || !st.dom.box || !st.dom.video || !st.crop) return false;
let r = st.dom.box.getBoundingClientRect();
if (arMode(n) === "free") {
// pad so the Free-mode corner handles are grabbable from outside the box
r = { left: r.left - 8, top: r.top - 8, right: r.right + 8, bottom: r.bottom + 8 };
}
return inRect(x, y, r);
}
/** First node (in graph order) whose crop box contains the point. */
function nodeBoxAt(x, y) {
try {
if (!lvrcApp || !lvrcApp.graph) return null;
for (const n of lvrcApp.graph.nodes || []) {
if (n.type !== LVRC.NODE_NAME || !n.__lvrc) continue;
if (nodeOverBox(n, x, y)) return n;
}
} catch (_) { /* ignore */ }
return null;
}
/* --------------------------- wheel + drag ----------------------------- */
let domInterceptorsInstalled = false;
let activeDrag = null; // { node, nx, ny, cx, cy, pid }
let suppressNextClick = false; // next click belongs to our drag, not the player
function installDomInterceptors() {
if (domInterceptorsInstalled) return;
domInterceptorsInstalled = true;
// wheel over the box -> zoom (ratio locked)
document.addEventListener(
"wheel",
(e) => {
try {
if (!lvrcApp || !lvrcApp.graph) return;
for (const n of lvrcApp.graph.nodes || []) {
if (n.type !== LVRC.NODE_NAME || !n.__lvrc) continue;
if (!nodeOverBox(n, e.clientX, e.clientY)) continue;
const st = n.__lvrc;
const v = st.dom.video;
// Same guard as pointerdown: don't hijack wheel events that land on
// a floating UI element (e.g. a dropdown menu) over the box.
if (e.target !== v && !v.contains(e.target)) continue;
e.preventDefault();
e.stopPropagation();
const nat = videoNatural(v);
if (!nat) return;
const c = st.crop;
if (!c) return;
const m = arMode(n);
if (m === "off") return;
// Free: zoom keeps the box's own (drawn) ratio
const r = m === "free" ? (c.w * nat.w) / (c.h * nat.h) : m;
const fr = maxFitRatio(r, nat.w, nat.h);
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
let newH = c.h * factor;
if (newH > fr.h) newH = fr.h;
if (newH < 0.02) newH = 0.02;
if (Math.abs(newH - c.h) <= 1e-6) return;
const k = r * (nat.h / nat.w);
const newW = newH * k;
const p = contentPosOf(v, e);
if (!p) return;
c.x = c.x + (c.w - newW) * p.nx;
c.y = c.y + (c.h - newH) * p.ny;
c.w = newW;
c.h = newH;
fitCrop(n, c, nat.w, nat.h);
writeCrop(n);
layoutOverlay(n);
break;
}
} catch (err) {
console.warn(LVRC.TAG, "wheel", err);
}
},
{ passive: false, capture: true }
);
// pointerdown over the box -> start drag (dedicated capture listener: the
// box is draggable even where it overlaps the timeline)
document.addEventListener(
"pointerdown",
(e) => {
try {
suppressNextClick = false; // a fresh pointerdown invalidates any stale flag
if (activeDrag || tlActive) return;
const n = nodeBoxAt(e.clientX, e.clientY);
if (!n) return;
const st = n.__lvrc;
const v = st.dom.video;
// A floating UI layer (widget dropdown menu, save button, ...) can
// sit over the box; the overlay is pointer-events:none, so e.target
// is the topmost REAL element. Only hijack when the pointer is
// really on our <video>.
if (e.target !== v && !v.contains(e.target)) return;
const nat = videoNatural(v);
const p = contentPosOf(v, e);
if (!nat || !p) return;
e.preventDefault();
e.stopPropagation();
// Free mode: starting on a box corner resizes it (the opposite
// corner stays fixed); anywhere else the box moves as before
let corner = null;
if (arMode(n) === "free" && st.crop) {
const r = st.dom.box.getBoundingClientRect();
const pts = { nw: [r.left, r.top], ne: [r.right, r.top], sw: [r.left, r.bottom], se: [r.right, r.bottom] };
let best = 14; // px threshold around the corner
for (const cn of Object.keys(pts)) {
const d = Math.hypot(e.clientX - pts[cn][0], e.clientY - pts[cn][1]);
if (d < best) { best = d; corner = cn; }
}
}
activeDrag = { node: n, nx: p.nx, ny: p.ny, cx: st.crop.x, cy: st.crop.y, cw: st.crop.w, ch: st.crop.h, corner, pid: e.pointerId };
if (v) v.style.cursor = corner ? (corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize") : "move";
} catch (err) {
console.warn(LVRC.TAG, "pointerdown", err);
}
},
{ passive: false, capture: true }
);
// pointermove -> move the box while dragging, else update the hover cursor
document.addEventListener(
"pointermove",
(e) => {
try {
if (!lvrcApp || !lvrcApp.graph) return;
if (activeDrag) {
const n = activeDrag.node;
const st = n.__lvrc;
const v = st && st.dom ? st.dom.video : null;
const nat = videoNatural(v);
const p = contentPosOf(v, e);
if (!st || !st.crop || !nat || !p) return;
const c = st.crop;
if (activeDrag.corner) {
// resize: the opposite corner (from the box at drag start) stays
// fixed, the box grows/shrinks from it toward the pointer
const fx = activeDrag.corner === "nw" || activeDrag.corner === "sw" ? activeDrag.cx + activeDrag.cw : activeDrag.cx;
const fy = activeDrag.corner === "nw" || activeDrag.corner === "ne" ? activeDrag.cy + activeDrag.ch : activeDrag.cy;
c.x = Math.min(fx, p.nx);
c.y = Math.min(fy, p.ny);
c.w = Math.abs(p.nx - fx);
c.h = Math.abs(p.ny - fy);
} else {
c.x = activeDrag.cx + (p.nx - activeDrag.nx);
c.y = activeDrag.cy + (p.ny - activeDrag.ny);
}
fitCrop(n, c, nat.w, nat.h);
writeCrop(n);
layoutOverlay(n);
return;
}
for (const n of lvrcApp.graph.nodes || []) {
if (n.type !== LVRC.NODE_NAME || !n.__lvrc || !n.__lvrc.dom || !n.__lvrc.dom.overlay) continue;
let cur = "default";
if (nodeOverBox(n, e.clientX, e.clientY)) {
cur = "move";
if (arMode(n) === "free" && n.__lvrc.crop && n.__lvrc.dom.box) {
const r = n.__lvrc.dom.box.getBoundingClientRect();
const pts = { nw: [r.left, r.top], ne: [r.right, r.top], sw: [r.left, r.bottom], se: [r.right, r.bottom] };
let best = 14;
for (const cn of Object.keys(pts)) {
const d = Math.hypot(e.clientX - pts[cn][0], e.clientY - pts[cn][1]);
if (d < best) { best = d; cur = cn === "nw" || cn === "se" ? "nwse-resize" : "nesw-resize"; }
}
}
}
if (n.__lvrc.dom.video) n.__lvrc.dom.video.style.cursor = cur;
}
} catch (err) {
console.warn(LVRC.TAG, "pointermove", err);
}
},
{ passive: false, capture: true }
);
const endDrag = (e) => {
try {
if (!activeDrag) return;
// the drag is ours, not the player's: stop the pointerup from reaching
// the <video> (the player can toggle play/pause on pointerup), and
// swallow the click the browser still synthesizes for this down/up pair
try { e.preventDefault(); e.stopPropagation(); } catch (_) { /* ignore */ }
suppressNextClick = true;
const n = activeDrag.node;
const st = n.__lvrc;
const v = st && st.dom ? st.dom.video : null;
if (st && st.crop && v) {
const nat = videoNatural(v);
if (nat) fitCrop(n, st.crop, nat.w, nat.h);
writeCrop(n);
lsSave(n); // remember for tab switches / reloads
}
if (v) v.style.cursor = ""; // release: let CSS cascade decide
activeDrag = null;
} catch (err) {
console.warn(LVRC.TAG, "pointerup", err);
}
};
document.addEventListener("pointerup", endDrag, { capture: true });
document.addEventListener("pointercancel", endDrag, { capture: true });
// eat the events that follow one of our drag interactions (see above):
// the browser still synthesizes mouseup + click for the captured pair
const swallowTrailing = (e) => {
if (suppressNextClick) {
suppressNextClick = false;
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener("mouseup", swallowTrailing, { capture: true });
document.addEventListener("click", swallowTrailing, { capture: true });
}
/* --------------------------- node init/hooking ----------------------- */
function initNode(node) {
try {
if (!node || node.type !== LVRC.NODE_NAME) return;
const st = getState(node);
// drop the unlinked FLOAT/BOOLEAN slots (crop_*, start_time, duration,
// strict_duration): this node has no linkable inputs
const inputs = node.inputs;
if (Array.isArray(inputs)) {
for (let i = inputs.length - 1; i >= 0; i--) {
const slot = inputs[i];
if (slot && (slot.type === "FLOAT" || slot.type === "BOOLEAN") && slot.link == null) {
try {
if (typeof node.removeInput === "function") node.removeInput(slot);
} catch (_) { /* ignore */ }
const j = node.inputs.indexOf(slot);
if (j !== -1) node.inputs.splice(j, 1);
}
}
}
if (!st.inited) {
st.inited = true;
// hide the crop_*/trim/frame_time widgets (they still serialize)
for (const key of LVRC.CROP_KEYS.concat(LVRC.TRIM_KEYS, "frame_time")) {
const w = widgetOf(node, key);
if (!w) continue;
w.hidden = true;
if (!w.options || typeof w.options !== "object") w.options = {};
w.options.hidden = true;
}
// keep the aspect_ratio widget in sync with the crop
const ar = widgetOf(node, "aspect_ratio");
if (ar && !ar.__lvrcBound) {
ar.__lvrcBound = true;
const prev = ar.callback;
ar.callback = (val) => {
try {
const v = videoElOf(node);
const nat = videoNatural(v);
if (nat) resetCrop(node, nat.w, nat.h);
else {
const s = getState(node);
s.crop = null;
writeCrop(node);
}
if (typeof prev === "function") prev(val);
layoutOverlay(node);
markDirty(node);
lsSave(node); // keep the per-file memory current (the ar field)
} catch (err) {
console.warn(LVRC.TAG, "aspect_ratio callback", err);
}
};
}
initCropFromWidgets(node, 16, 9);
initTrimFromWidgets(node);
}
layoutOverlay(node);
} catch (e) {
console.warn(LVRC.TAG, "initNode", e);
}
}
/* --------------------------- registration ---------------------------- */
function register(a) {
lvrcApp = a;
a.registerExtension({
name: "LoadVideoCrop",
nodeCreated: (node) => initNode(node),
loadedGraphNode: (node) => initNode(node),
beforeRegisterNodeDef: (nodeType, nodeData) => {
if (nodeData && nodeData.name !== LVRC.NODE_NAME) return;
const proto = nodeType.prototype;
const prevConfigure = proto.onConfigure;
proto.onConfigure = function (data) {
const r = prevConfigure ? prevConfigure.call(this, data) : undefined;
initNode(this);
return r;
};
const prevAdded = proto.onAdded;
proto.onAdded = function (graph) {
const r = prevAdded ? prevAdded.call(this, graph) : undefined;
initNode(this);
return r;
};
},
});
installDomInterceptors();
installKeyHandler();
startPoller();
startPlayheadLoop();
console.log(LVRC.TAG, "extension registered");
}
(async function boot() {
let mod = null;
for (let i = 0; i < 100; i++) {
try {
mod = await import("/scripts/app.js");
if (mod && mod.app) break;
mod = null;
} catch (_) { /* retry */ }
await sleep(50);
}
if (!mod || !mod.app) {
console.error(LVRC.TAG, "could not load /scripts/app.js - extension not started");
return;
}
try {
register(mod.app);
} catch (e) {
console.error(LVRC.TAG, "register failed", e);
}
})();