Files
SilverAndJade-comfyui-silve…/js/silver_h3_segment_editor.js
SilverAndJade a73ed38f14 Added H3 Video editor
Testing shows that the video editor's video embed takes presidence over the text prompt
Resulting in the edit not working too well
2026-09-12 23:49:09 +02:00

633 lines
23 KiB
JavaScript

// Silver H3 video-picker + preview panel for SilverH3SegmentEditor and
// SilverH3VideoEditor.
//
// Both nodes share one panel (video preview/playback, upload from the node,
// and range picking). What the panel shows is driven by a per-node config:
//
// SilverH3SegmentEditor - SECTION (crop) + SEGMENT (regenerated) ranges,
// section unlimited.
// SilverH3VideoEditor - one EDIT WINDOW range (the whole selected
// section becomes <Video 1>), capped at 15 s, the
// model's limit.
//
// All values are pushed back into the real ComfyUI widgets, so the workflow
// serialises them and the Python node reads the exact same values.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const CONFIGS = {
"SilverH3SegmentEditor": {
sectionLabel: "SECTION (main video used)",
segmentLabel: "SEGMENT (regenerated)",
segments: true,
minSpan: 0,
maxSpan: 0,
},
"SilverH3VideoEditor": {
sectionLabel: "EDIT WINDOW (\u2264 15s \u2013 the whole window is the <Video 1> reference)",
segmentLabel: null,
segments: false,
minSpan: 5.2,
maxSpan: 15,
},
};
function rangeWidgetsFor(cfg) {
return cfg.segments
? ["source_video", "source_start", "source_end", "timeline_start", "timeline_duration"]
: ["source_video", "source_start", "source_end"];
}
const round1 = (v) => Math.round((Number(v) || 0) * 10) / 10;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const fmt = (t) => {
if (!isFinite(t) || t < 0) t = 0;
const m = Math.floor(t / 60);
const s = Math.round(t % 60);
if (s === 60) return fmt(m * 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
// Build a /view URL for an input-folder name like "video.mp4" or
// "subdir/video.mp4" (get_filename_list uses "/" for subfolders).
function inputFileUrl(file) {
if (!file || typeof file !== "string") return null;
file = file.trim();
if (!file) return null;
const idx = file.lastIndexOf("/");
let filename = file;
let subfolder = "";
if (idx > 0) {
filename = file.slice(idx + 1);
subfolder = file.slice(0, idx);
}
let url = `/view?filename=${encodeURIComponent(filename)}&type=input`;
if (subfolder) url += `&subfolder=${encodeURIComponent(subfolder)}`;
return api.apiURL(url);
}
// ---- dual-thumb range slider (drag the two thumbs, no track jump) --------
function makeDualRange(options = {}) {
const wrap = document.createElement("div");
wrap.style.cssText =
"position:relative;height:22px;width:100%;user-select:none;";
const fill = document.createElement("div");
fill.style.cssText =
"position:absolute;top:9px;left:0;width:0;height:4px;" +
"border-radius:2px;background:rgba(179,255,0,.55);" +
"pointer-events:none;mix-blend-mode:screen;";
const common =
"position:absolute;inset:0;width:100%;height:100%;margin:0;" +
"background:transparent;-webkit-appearance:none;appearance:none;" +
"pointer-events:none;outline:none;";
const thumb =
"-webkit-appearance:none;appearance:none;height:14px;width:14px;" +
"border-radius:50%;background:#b3ff00;border:1px solid #333;" +
"cursor:ew-resize;pointer-events:auto;box-shadow:0 0 2px rgba(0,0,0,.6);";
const styles = document.createElement("style");
styles.textContent = `
.du-wrap input[type=range]::-webkit-slider-runnable-track{background:transparent;border:none;}
.du-wrap input[type=range]::-moz-range-track{background:transparent;border:none;}
.du-wrap input[type=range]::-webkit-slider-thumb{${thumb}}
.du-wrap input[type=range]::-moz-range-thumb{${thumb}}
.du-wrap input[type=range]::-moz-range-progress{background:transparent;}
`;
const lo = document.createElement("input");
lo.type = "range";
lo.style.cssText = common + "z-index:3;";
const hi = document.createElement("input");
hi.type = "range";
hi.style.cssText = common + "z-index:4;";
wrap.className = "du-wrap";
wrap.appendChild(styles);
wrap.appendChild(lo);
wrap.appendChild(hi);
wrap.appendChild(fill);
const state = { min: 0, max: 0, step: 0.1, oninput: null };
const handlers = [lo, hi];
for (const el of handlers) {
el.addEventListener("input", () => {
if (state.oninput) state.oninput(render());
});
}
function render() {
const lv = parseFloat(lo.value);
const hv = parseFloat(hi.value);
const span = Math.max(1e-6, state.max - state.min);
const lp = ((lv - state.min) / span) * 100;
const hp = ((hv - state.min) / span) * 100;
fill.style.left = `${lp}%`;
fill.style.width = `${Math.max(0, hp - lp)}%`;
return [lv, hv];
}
return {
el: wrap,
setBounds(min, max, step = 0.1) {
state.min = min;
state.max = max;
state.step = step;
lo.min = hi.min = min;
lo.max = hi.max = max;
lo.step = hi.step = step;
hi.value = max;
lo.value = min;
render();
},
set(l, h) {
lo.value = clamp(l, state.min, state.max);
hi.value = clamp(h, state.min, state.max);
render();
},
get() {
const [l, h] = render();
return [clamp(l, state.min, state.max), clamp(h, state.min, state.max)];
},
refresh: render,
onInput(cb) {
state.oninput = cb;
},
};
}
// ---------------------------------------------------------------------------
app.registerExtension({
name: "SilverH3.VideoEditorPreview",
beforeRegisterNodeDef(nodeType, nodeData) {
const cfg = CONFIGS[nodeData.name];
if (!cfg) return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onCreated?.call(this);
try {
initPreview(this, cfg);
} catch (err) {
console.error("[SilverH3] preview init failed for " +
nodeData.name + ":", err);
}
};
},
});
function initPreview(node, cfg) {
const RANGE_WIDGETS = rangeWidgetsFor(cfg);
const W = {};
for (const w of node.widgets || []) {
if (RANGE_WIDGETS.includes(w.name)) W[w.name] = w;
}
if (!W.source_video) return;
const root = document.createElement("div");
root.style.cssText =
"box-sizing:border-box;width:100%;padding:6px 8px 8px;" +
"font-size:12px;color:#ddd;background:#1a1a1a;border-radius:4px;";
// ---- video ------------------------------------------------------------
const video = document.createElement("video");
video.playsInline = true;
video.preload = "metadata";
video.style.cssText =
"width:100%;max-height:190px;background:#000;display:block;" +
"border-radius:3px;";
root.appendChild(video);
const status = document.createElement("div");
status.style.cssText =
"padding:2px 0;color:#999;font-size:11px;white-space:nowrap;" +
"overflow:hidden;text-overflow:ellipsis;";
root.appendChild(status);
// ---- transport --------------------------------------------------------
const transport = document.createElement("div");
transport.style.cssText =
"display:flex;align-items:center;gap:6px;margin:4px 0 10px;";
const playBtn = document.createElement("button");
playBtn.textContent = "\u25B6";
playBtn.title = "Play / pause the source video";
transport.appendChild(playBtn);
const seek = document.createElement("input");
seek.type = "range";
seek.min = 0;
seek.max = 0;
seek.step = 0.1;
seek.style.cssText = "flex:1;";
transport.appendChild(seek);
const timeLbl = document.createElement("span");
timeLbl.textContent = "0:00 / 0:00";
timeLbl.style.cssText = "min-width:70px;text-align:right;";
transport.appendChild(timeLbl);
root.appendChild(transport);
// ---- range rows -------------------------------------------------------
function rangeRow(label, color) {
const row = document.createElement("div");
row.style.cssText = "margin:2px 0 6px;";
const head = document.createElement("div");
head.style.cssText =
"display:flex;justify-content:space-between;align-items:center;" +
"margin-bottom:1px;";
const lab = document.createElement("span");
lab.textContent = label;
lab.style.cssText = `color:${color};font-weight:600;`;
const nums = document.createElement("span");
nums.style.cssText = "font-variant-numeric:tabular-nums;color:#bbb;";
head.appendChild(lab);
head.appendChild(nums);
row.appendChild(head);
const dual = makeDualRange();
row.appendChild(dual.el);
const loInput = document.createElement("input");
loInput.type = "number";
loInput.step = 0.1;
loInput.style.cssText = "width:58px;margin-right:4px;";
const hiInput = document.createElement("input");
hiInput.type = "number";
hiInput.step = 0.1;
hiInput.style.cssText = "width:58px;";
const numBar = document.createElement("div");
numBar.style.cssText =
"display:flex;align-items:center;margin-top:2px;color:#888;";
numBar.append(loInput, document.createTextNode(" \u2013 "), hiInput);
row.appendChild(numBar);
return { row, dual, loInput, hiInput, nums };
}
const section = rangeRow(cfg.sectionLabel, "#8fdfff");
const segment = cfg.segments ? rangeRow(cfg.segmentLabel, "#b3ff00") : null;
root.appendChild(section.row);
if (segment) root.appendChild(segment.row);
// ---- open / upload ----------------------------------------------------
const openBtn = document.createElement("button");
openBtn.textContent = "\uD83D\uDCC2 Open video \u2026";
openBtn.title = "Upload a source video into the input folder";
openBtn.style.cssText = "margin-top:6px;";
root.appendChild(openBtn);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "video/*";
fileInput.style.display = "none";
root.appendChild(fileInput);
const domWidget = node.addDOMWidget("segment_preview", "preview", root, {
serialize: false,
getMinHeight: () => (cfg.segments ? 430 : 390),
getMaxHeight: () => (cfg.segments ? 620 : 520),
hideOnZoom: false,
});
// Re-home the source_video picker so it is the LAST input, sitting
// directly above the preview panel.
const dwIdx = node.widgets.indexOf(domWidget);
const wIdx = node.widgets.indexOf(W.source_video);
if (dwIdx >= 0 && wIdx >= 0 && wIdx !== dwIdx - 1) {
node.widgets.splice(wIdx, 1);
node.widgets.splice(node.widgets.indexOf(domWidget), 0, W.source_video);
node.graph?.setDirtyCanvas(true, true);
}
// ---- state + sync -----------------------------------------------------
const S = {
duration: 0,
loaded: false,
syncing: false,
};
const getDuration = () =>
S.loaded && isFinite(video.duration) && video.duration > 0
? video.duration
: 0;
function sectionBounds() {
const dur = getDuration();
if (dur <= 0) return { lo: 0, hi: 0, dur: 0 };
let lo = clamp(round1(W.source_start.value) || 0, 0, dur);
let hi = round1(W.source_end.value);
if (hi <= 0) hi = dur; // 0 = "to the end"
hi = clamp(hi, lo, dur);
if (cfg.maxSpan > 0 && hi - lo > cfg.maxSpan) hi = lo + cfg.maxSpan;
return { lo, hi, dur };
}
function clampSegInSection(sec) {
const dur = getDuration();
if (dur <= 0) return [0, 0];
let start = clamp(round1(W.timeline_start.value) || 0, sec.lo, sec.hi);
let len = clamp(round1(W.timeline_duration.value) || 0, 0.1, sec.hi - start);
return [round1(start), round1(len)];
}
function widgetsToUI() {
if (S.syncing) return;
const sec = sectionBounds();
const dur = sec.dur;
section.dual.setBounds(0, dur || 1, 0.1);
section.dual.set(sec.lo, sec.hi);
section.loInput.value = round1(sec.lo);
section.hiInput.value = round1(sec.hi === dur && W.source_end.value <= 0 ? 0 : sec.hi);
section.nums.textContent =
dur > 0
? `[${round1(sec.lo)} \u2013 ${sec.hi >= dur ? "end" : round1(sec.hi)}]s`
: "";
if (segment) {
const [s0, d0] = clampSegInSection(sec);
segment.dual.setBounds(sec.lo, Math.max(sec.lo, sec.hi), 0.1);
segment.dual.set(s0, s0 + d0);
segment.loInput.value = s0;
segment.hiInput.value = round1(d0);
segment.nums.textContent =
dur > 0 ? `${round1(s0)} \u2192 ${round1(s0 + d0)}s (+${round1(d0)}s)` : "";
}
}
function pushWidget(name, value) {
const w = W[name];
if (!w) return;
w.value = value;
if (typeof w.callback === "function") w.callback(w.value);
if (node.graph?.setDirtyCanvas) node.graph.setDirtyCanvas(true, true);
}
function sliderState() {
const dur = getDuration();
const [slo, shi] = section.dual.get();
if (!segment) return { dur, slo, shi };
const [glo, ghi] = segment.dual.get();
return { dur, slo, shi, glo, ghi };
}
// Write the panel's number fields + labels straight from the slider
// positions. Independent of the node widgets so a drag always shows.
function writeFields(s) {
section.loInput.value = round1(s.slo);
// For the video editor the window end is always a concrete time; for
// the segment editor it is shown as 0 = "to the end" at video end.
section.hiInput.value =
s.dur > 0 && cfg.segments && s.shi >= s.dur - 1e-6 ? 0 : round1(s.shi);
section.nums.textContent =
s.dur > 0
? `[${round1(s.slo)} \u2013 ${s.shi >= s.dur ? "end" : round1(s.shi)}]s`
: "";
if (segment) {
const segStart = round1(clamp(s.glo, s.slo, s.shi));
const segEnd = round1(clamp(s.ghi, segStart, s.shi));
segment.loInput.value = segStart;
segment.hiInput.value = round1(segEnd - segStart);
segment.nums.textContent =
s.dur > 0 ? `${round1(segStart)} \u2192 ${round1(segEnd)}s (+${round1(segEnd - segStart)}s)` : "";
}
}
function uiToWidgets() {
if (S.syncing) return;
const s = sliderState();
const dur = s.dur;
if (dur <= 0) return;
S.syncing = true;
try {
pushWidget("source_start", round1(s.slo));
// 0 = "to the end" only for the segment editor (kept for
// back-compat); the video editor always writes the concrete end.
pushWidget("source_end",
cfg.segments && s.shi >= dur - 1e-6 ? 0 : round1(s.shi));
if (segment) {
const segStart = clamp(s.glo, s.slo, s.shi);
const segEnd = clamp(s.ghi, segStart, s.shi);
pushWidget("timeline_start", round1(segStart));
pushWidget("timeline_duration", round1(Math.max(0.1, segEnd - segStart)));
}
} finally {
S.syncing = false;
}
}
section.dual.onInput(() => {
if (getDuration() <= 0) return;
if (segment) {
const [slo, shi] = section.dual.get();
const [glo, ghi] = segment.dual.get();
const len = Math.max(0.1, Math.min(shi - slo, ghi - glo));
const s = clamp(glo, slo, shi - len);
segment.dual.setBounds(slo, shi, 0.1);
segment.dual.set(s, s + len);
writeFields(sliderState());
uiToWidgets();
widgetsToUI();
return;
}
// Video editor: keep the window within [minSpan, maxSpan] while it
// slides, held against the video's duration.
let [lo, hi] = section.dual.get();
const dur = getDuration();
if (cfg.maxSpan > 0 && hi - lo > cfg.maxSpan) {
hi = lo + cfg.maxSpan;
if (hi > dur) {
hi = dur;
lo = Math.max(0, hi - cfg.maxSpan);
}
}
if (cfg.minSpan > 0 && hi - lo < cfg.minSpan) {
hi = lo + cfg.minSpan;
if (hi > dur) {
hi = dur;
lo = Math.max(0, hi - cfg.minSpan);
}
}
section.dual.set(lo, hi);
writeFields(sliderState());
uiToWidgets();
widgetsToUI();
});
if (segment) {
segment.dual.onInput(() => {
if (getDuration() <= 0) return;
writeFields(sliderState());
uiToWidgets();
widgetsToUI();
});
}
const pickers = [["source_start", section.dual, section.loInput]];
pickers.push(["source_end", section.dual, section.hiInput]);
if (segment) {
pickers.push(
["timeline_start", segment.dual, segment.loInput],
["timeline_duration", segment.dual, segment.hiInput]);
}
for (const [name, dual, picker] of pickers) {
picker.addEventListener("change", () => {
if (S.syncing) return;
let v = round1(picker.value);
const dur = getDuration();
if (name === "source_start") {
v = clamp(v, 0, dur || 0);
if (!segment && cfg.maxSpan > 0) {
let end = v + cfg.maxSpan;
if (cfg.minSpan > 0) end = v + cfg.minSpan;
pushWidget("source_end", round1(clamp(end, 0, dur || 0)));
}
} else if (name === "source_end") {
if (dur > 0 && (v <= 0 || v >= dur)) v = dur;
v = clamp(v, 0, dur || 0);
if (!segment) {
const s0 = clamp(round1(W.source_start.value) || 0, 0, dur || 0);
if (cfg.maxSpan > 0) v = Math.min(v, s0 + cfg.maxSpan);
if (cfg.minSpan > 0 && v - s0 < cfg.minSpan)
v = Math.min(dur || 0, s0 + cfg.minSpan);
}
} else if (name === "timeline_start") {
const sec = sectionBounds();
v = clamp(v, sec.lo, sec.hi);
const dlen = round1(W.timeline_duration.value) || 0;
pushWidget(
"timeline_duration",
round1(clamp(dlen, 0.1, sec.hi - v))
);
} else if (name === "timeline_duration") {
const sec = sectionBounds();
const s0 = clamp(round1(W.timeline_start.value) || 0, sec.lo, sec.hi);
v = Math.max(0.1, clamp(v, 0.1, sec.hi - s0));
}
pushWidget(name, v);
widgetsToUI();
});
}
// ---- video load/sync --------------------------------------------------
function loadFromWidget() {
const file = typeof W.source_video.value === "string" ? W.source_video.value : "";
S.loaded = false;
S.duration = 0;
if (!file) {
video.removeAttribute("src");
status.textContent = "No source video selected.";
widgetsToUI();
return;
}
const url = inputFileUrl(file);
if (!url) {
status.textContent = `Cannot preview ${file} (must live in the input folder).`;
widgetsToUI();
return;
}
video.src = url;
video.pause();
playBtn.textContent = "\u25B6";
seek.value = 0;
timeLbl.textContent = "0:00 / 0:00";
status.textContent = `Preview: ${file}`;
video.onloadedmetadata = () => {
S.loaded = true;
S.duration = video.duration;
seek.max = video.duration;
seek.value = 0;
sectionsSeekToStart();
widgetsToUI();
};
video.onerror = () => {
S.loaded = false;
status.textContent = `Could not load preview for ${file}.`;
widgetsToUI();
};
}
function sectionsSeekToStart() {
const sec = sectionBounds();
if (sec.lo > 0) video.currentTime = sec.lo;
}
// transport
playBtn.addEventListener("click", () => {
if (!S.loaded) return;
if (video.paused) {
video.play().catch(() => {});
playBtn.textContent = "\u23F8";
} else {
video.pause();
playBtn.textContent = "\u25B6";
}
});
video.addEventListener("ended", () => {
playBtn.textContent = "\u25B6";
});
video.addEventListener("timeupdate", () => {
if (!S.loaded) return;
seek.value = video.currentTime;
timeLbl.textContent = `${fmt(video.currentTime)} / ${fmt(video.duration || 0)}`;
});
seek.addEventListener("input", () => {
if (!S.loaded) return;
video.currentTime = parseFloat(seek.value) || 0;
timeLbl.textContent = `${fmt(video.currentTime)} / ${fmt(video.duration || 0)}`;
});
// upload
openBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file) return;
try {
const body = new FormData();
body.append("image", file, file.name);
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (!resp.ok) {
status.textContent = `Upload failed (HTTP ${resp.status}).`;
return;
}
const data = await resp.json();
const value = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
const comboOpts = Array.isArray(W.source_video.options?.values)
? W.source_video.options.values
: null;
if (comboOpts && !comboOpts.includes(value)) comboOpts.push(value);
W.source_video.value = value;
if (typeof W.source_video.callback === "function")
W.source_video.callback(value);
loadFromWidget();
} catch (err) {
console.error("[SilverH3] upload failed:", err);
status.textContent = "Upload failed - see browser console.";
}
});
const srcCb = W.source_video.callback;
W.source_video.callback = (v) => {
srcCb?.call(W.source_video, v);
loadFromWidget();
};
const wired = ["source_start", "source_end"];
if (segment) wired.push("timeline_start", "timeline_duration");
for (const name of wired) {
const widget = W[name];
const old = widget.callback;
widget.callback = (v) => {
old?.call(widget, v);
if (!S.syncing) widgetsToUI();
};
}
loadFromWidget();
}