Files
comfyui_o1key/web/js/videoTrim.js
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

525 lines
26 KiB
JavaScript
Raw 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.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// ── 上传本地视频到 ComfyUI input 目录,返回 {name, subfolder, type} ──────────────
async function uploadVideo(file) {
const formData = new FormData();
formData.append("image", file, file.name); // /upload/image 接受任意文件,按字节写入
const resp = await api.fetchApi("/upload/image", { method: "POST", body: formData });
if (!resp.ok) throw new Error(`上传失败: ${file.name}`);
return await resp.json(); // {name, subfolder, type:"input"}
}
let _inputDir = null;
async function getInputDir() {
if (_inputDir !== null) return _inputDir;
try {
const resp = await api.fetchApi("/o1key/input_dir");
_inputDir = resp.ok ? (await resp.json()).path : "";
} catch { _inputDir = ""; }
return _inputDir;
}
function fmtTime(s) {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
const cs = Math.floor((s - Math.floor(s)) * 10); // 0.1s 精度
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${cs}`;
}
function inputVideoDescriptor(rawPath, inputDir) {
const path = String(rawPath ?? "").trim().replace(/^(["'])(.*)\1$/, "$2").replace(/\\/g, "/");
if (!path) return null;
const root = String(inputDir ?? "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
const isAbsolute = /^[A-Za-z]:\//.test(path) || path.startsWith("/");
let relative = path.replace(/^\/+/, "");
if (isAbsolute) {
if (!root) return null;
const pathLower = path.toLowerCase();
const rootLower = root.toLowerCase();
if (!pathLower.startsWith(`${rootLower}/`)) return null;
relative = path.slice(root.length + 1);
}
const parts = relative.split("/").filter(Boolean);
if (!parts.length || parts.some((part) => part === "." || part === "..")) return null;
const filename = parts.pop();
return { filename, subfolder: parts.join("/"), type: "input" };
}
function videoViewUrl(descriptor) {
const params = new URLSearchParams({
filename: descriptor.filename,
type: descriptor.type || "input",
});
if (descriptor.subfolder) params.set("subfolder", descriptor.subfolder);
return api.apiURL(`/view?${params.toString()}`);
}
function hideInternalPathWidget(widget) {
if (!widget) return;
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
}
const CSS = `
.o1vt-wrap{width:100%;display:flex;flex-direction:column;gap:6px;padding:6px;box-sizing:border-box;font-size:12px;color:#ddd;}
.o1vt-btn{width:100%;padding:6px 8px;cursor:pointer;background:#3a5a3a;color:#eee;border:1px solid #5c805c;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:6px;}
.o1vt-btn:hover{background:#456b45;}
.o1vt-btn svg{width:16px;height:16px;flex:0 0 auto;}
.o1vt-stage{position:relative;width:100%;background:#000;border-radius:4px;overflow:hidden;display:flex;align-items:center;justify-content:center;min-height:120px;}
.o1vt-stage video{width:100%;display:block;object-fit:contain;background:#000;}
.o1vt-empty{color:#888;font-size:12px;padding:30px 8px;text-align:center;line-height:1.6;}
.o1vt-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.4);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;}
.o1vt-play:hover{background:rgba(0,0,0,.75);}
.o1vt-times{display:flex;justify-content:space-between;color:#9adf9a;font-variant-numeric:tabular-nums;}
.o1vt-track{position:relative;width:100%;height:54px;background:#1a1a1a;border-radius:4px;overflow:hidden;user-select:none;touch-action:none;}
.o1vt-film{position:absolute;inset:0;display:flex;background:#222;}
.o1vt-film img{height:100%;flex:1 1 0;min-width:0;object-fit:cover;pointer-events:none;display:block;}
.o1vt-dim{position:absolute;top:0;bottom:0;background:rgba(0,0,0,.62);pointer-events:none;}
.o1vt-sel{position:absolute;top:0;bottom:0;border:2px solid #4caf50;box-sizing:border-box;border-radius:3px;cursor:grab;background:rgba(76,175,80,.08);}
.o1vt-sel.grabbing{cursor:grabbing;}
.o1vt-handle{position:absolute;top:0;bottom:0;width:12px;background:#4caf50;cursor:ew-resize;display:flex;align-items:center;justify-content:center;z-index:3;}
.o1vt-handle::after{content:"";width:2px;height:18px;background:rgba(0,0,0,.55);border-radius:1px;}
.o1vt-handle.l{border-radius:3px 0 0 3px;}
.o1vt-handle.r{border-radius:0 3px 3px 0;}
.o1vt-playhead{position:absolute;top:0;bottom:0;width:2px;background:#fff;pointer-events:none;box-shadow:0 0 3px rgba(0,0,0,.8);z-index:4;}
.o1vt-info{text-align:center;color:#aaa;}
.o1vt-info b{color:#9adf9a;}
`;
// 视频图标(svg)
const VIDEO_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="14" height="16" rx="2"/><path d="M16 9l5-3v12l-5-3"/></svg>`;
function injectCSS() {
if (document.getElementById("o1vt-style")) return;
const s = document.createElement("style");
s.id = "o1vt-style";
s.textContent = CSS;
document.head.appendChild(s);
}
// 抽帧缩略图(同源视频,canvas 不会被污染)。等待真实解码帧后再绘制,避免黑帧。
async function buildFilmstrip(url, count, filmEl, token, isCurrent) {
const v = document.createElement("video");
v.muted = true;
v.preload = "auto";
v.crossOrigin = "anonymous";
v.src = url;
try {
await new Promise((res, rej) => {
v.addEventListener("loadeddata", res, { once: true });
v.addEventListener("error", () => rej(new Error("video load error")), { once: true });
});
} catch { return; }
const dur = v.duration;
if (!isFinite(dur) || dur <= 0) return;
const w = 96;
const ratio = (v.videoHeight && v.videoWidth) ? v.videoHeight / v.videoWidth : 0.56;
const h = Math.max(1, Math.round(w * ratio));
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
const grabAt = (t) => new Promise((resolve) => {
let settled = false;
const done = () => { if (!settled) { settled = true; resolve(); } };
const onSeeked = () => {
// 等一帧真正解码完成再画,否则可能拿到黑帧
if (v.requestVideoFrameCallback) {
v.requestVideoFrameCallback(() => done());
} else {
setTimeout(done, 70);
}
};
v.addEventListener("seeked", onSeeked, { once: true });
v.addEventListener("error", done, { once: true });
try { v.currentTime = Math.min(Math.max(t, 0), Math.max(dur - 0.04, 0)); }
catch { done(); }
});
const imgs = [];
for (let i = 0; i < count; i++) {
if (!isCurrent(token)) return; // 期间换了视频,放弃
await grabAt((dur * (i + 0.5)) / count);
try { ctx.drawImage(v, 0, 0, w, h); } catch { break; }
const img = document.createElement("img");
img.src = canvas.toDataURL("image/jpeg", 0.6);
imgs.push(img);
}
if (!isCurrent(token)) return;
filmEl.replaceChildren(...imgs);
v.removeAttribute("src");
v.load();
}
app.registerExtension({
name: "o1key.videoTrim",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "O1keyVideoTrim") return;
injectCSS();
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.call(this);
const node = this;
const pathW = node.widgets?.find(w => w.name === "视频路径");
const startW = node.widgets?.find(w => w.name === "开始时间");
const fixedW = node.widgets?.find(w => w.name === "固定时长");
const endW = node.widgets?.find(w => w.name === "结束时间");
// 路径只承载上传结果和工作流恢复,不作为用户输入控件显示。
// options.hidden 是 Nodes 2.0 的正式隐藏标记;不要再写入负高度。
hideInternalPathWidget(pathW);
// 内部状态
let duration = 0; // 视频总时长
let startT = 0, endT = 0; // 当前选区
let dragging = null; // "l" | "r" | "move" | "seek"
let dragOffset = 0; // move 模式:按下点相对窗口起点的时间偏移
let filmToken = 0; // 抽帧任务标识(换视频时作废旧任务)
const fixedDur = () => Math.max(0, +(fixedW?.value) || 0);
// ── DOM 结构 ──────────────────────────────────────────────
const wrap = document.createElement("div");
wrap.className = "o1vt-wrap";
const upBtn = document.createElement("button");
upBtn.className = "o1vt-btn";
upBtn.innerHTML = `${VIDEO_ICON}<span>上传视频</span>`;
const upLabel = upBtn.querySelector("span");
const stage = document.createElement("div");
stage.className = "o1vt-stage";
const videoEl = document.createElement("video");
videoEl.muted = true; videoEl.playsInline = true; videoEl.loop = false;
videoEl.crossOrigin = "anonymous";
const emptyEl = document.createElement("div");
emptyEl.className = "o1vt-empty";
emptyEl.textContent = "点上方「上传视频」,拖动绿色窗口裁剪选段";
const playBtn = document.createElement("button");
playBtn.className = "o1vt-play";
playBtn.textContent = "▶";
playBtn.style.display = "none";
stage.append(emptyEl, videoEl, playBtn);
videoEl.style.display = "none";
const timesEl = document.createElement("div");
timesEl.className = "o1vt-times";
const tCur = document.createElement("span");
const tDur = document.createElement("span");
tCur.textContent = "00:00.0"; tDur.textContent = "00:00.0";
timesEl.append(tCur, tDur);
const track = document.createElement("div");
track.className = "o1vt-track";
const film = document.createElement("div"); film.className = "o1vt-film";
const dimL = document.createElement("div"); dimL.className = "o1vt-dim";
const dimR = document.createElement("div"); dimR.className = "o1vt-dim";
const sel = document.createElement("div"); sel.className = "o1vt-sel";
const hL = document.createElement("div"); hL.className = "o1vt-handle l";
const hR = document.createElement("div"); hR.className = "o1vt-handle r";
const phead = document.createElement("div"); phead.className = "o1vt-playhead";
track.append(film, dimL, dimR, sel, hL, hR, phead);
const info = document.createElement("div");
info.className = "o1vt-info";
wrap.append(upBtn, stage, timesEl, track, info);
const editorWidget = node.addDOMWidget("o1vt_editor", "div", wrap, { serialize: false, hideOnZoom: false });
// 让 LiteGraph 布局时把编辑器高度算进节点:computeSize 返回 [宽, 编辑器内容高]。
// 否则节点初始高度不含 DOM widget,内容会溢出到节点框外。
editorWidget.computeSize = function (width) {
const w = width ?? node.size?.[0] ?? 300;
const aspect = node._o1vtAspect || 0.56;
const videoH = Math.round((w - 24) * aspect);
const chromeH = 38 + 18 + 54 + 22 + 30; // 上传按钮+时间+轨道+信息+间距
return [w, videoH + chromeH];
};
// ── 选区计算 / 渲染 / 回写 ─────────────────────────────────
function clampSel() {
if (duration <= 0) return;
const fx = fixedDur();
if (fx > 0) {
const win = Math.min(fx, duration);
startT = Math.max(0, Math.min(startT, duration - win));
endT = startT + win;
} else {
startT = Math.max(0, Math.min(startT, duration));
endT = Math.max(0, Math.min(endT, duration));
if (endT <= startT) endT = Math.min(duration, startT + 0.1);
}
}
function renderSel() {
const pctS = duration > 0 ? (startT / duration) * 100 : 0;
const pctE = duration > 0 ? (endT / duration) * 100 : 100;
dimL.style.left = "0"; dimL.style.width = pctS + "%";
dimR.style.left = pctE + "%"; dimR.style.width = (100 - pctE) + "%";
sel.style.left = pctS + "%"; sel.style.width = Math.max(0, pctE - pctS) + "%";
hL.style.left = `calc(${pctS}% - 6px)`;
hR.style.left = `calc(${pctE}% - 6px)`;
// 固定时长模式隐藏两端手柄(整体拖动窗口)
const fixed = fixedDur() > 0;
hL.style.display = fixed ? "none" : "flex";
hR.style.display = fixed ? "none" : "flex";
info.innerHTML = `选段 <b>${fmtTime(startT)}</b> → <b>${fmtTime(endT)}</b> 时长 <b>${(endT - startT).toFixed(1)}s</b>`;
}
function writeWidgets() {
if (startW) { startW.value = +startT.toFixed(2); }
if (endW && fixedDur() <= 0) { endW.value = +endT.toFixed(2); }
node.setDirtyCanvas(true, true);
}
function renderPlayhead() {
const cur = videoEl.currentTime || 0;
phead.style.left = (duration > 0 ? (cur / duration) * 100 : 0) + "%";
tCur.textContent = fmtTime(cur);
}
// 选区初始化:依据 widget 既有值或固定时长,并立即回写(修复“输出仍是原长”)
function initSelection() {
const fx = fixedDur();
if (fx > 0) {
startT = Math.max(0, Math.min(+(startW?.value) || 0, Math.max(duration - fx, 0)));
endT = startT + Math.min(fx, duration);
} else {
const ws = +(startW?.value) || 0;
const we = +(endW?.value) || 0;
startT = (ws > 0 && ws < duration) ? ws : 0;
endT = (we > 0 && we <= duration) ? we : duration;
}
clampSel(); renderSel(); renderPlayhead(); writeWidgets();
}
// ── 加载视频 ──────────────────────────────────────────────
function loadVideo(url) {
videoEl.src = url;
videoEl.style.display = "block";
emptyEl.style.display = "none";
playBtn.style.display = "flex";
film.replaceChildren();
filmToken++;
}
function clearVideo(message) {
videoEl.pause?.();
videoEl.removeAttribute("src");
videoEl.load?.();
videoEl.style.display = "none";
emptyEl.style.display = "block";
emptyEl.textContent = message;
playBtn.style.display = "none";
film.replaceChildren();
duration = 0; startT = 0; endT = 0;
tCur.textContent = "00:00.0"; tDur.textContent = "00:00.0";
filmToken++;
renderSel();
}
let pathPreviewToken = 0;
async function refreshPathPreview(value = pathW?.value) {
const token = ++pathPreviewToken;
const path = String(value ?? "").trim();
if (!path) {
upLabel.textContent = "上传视频";
clearVideo("点上方「上传视频」,拖动绿色窗口裁剪选段");
return;
}
const inputDir = await getInputDir();
if (token !== pathPreviewToken) return;
const descriptor = inputVideoDescriptor(path, inputDir);
if (!descriptor) {
upLabel.textContent = "重新上传";
clearVideo("已填写视频路径;input 目录外的视频将在执行时读取,画布内不预览");
return;
}
loadVideo(videoViewUrl(descriptor));
upLabel.textContent = "重新上传";
}
node._o1vtRefreshPath = refreshPathPreview;
videoEl.addEventListener("loadedmetadata", () => {
duration = videoEl.duration || 0;
tDur.textContent = fmtTime(duration);
node._o1vtAspect = videoEl.videoWidth ? videoEl.videoHeight / videoEl.videoWidth : 0.56;
node._o1vtResize?.();
initSelection();
const myToken = filmToken;
buildFilmstrip(videoEl.currentSrc || videoEl.src, 10, film, myToken,
(t) => t === filmToken && duration > 0).catch(() => {});
});
videoEl.addEventListener("timeupdate", () => {
renderPlayhead();
if (endT > 0 && videoEl.currentTime >= endT - 0.02) videoEl.pause();
});
videoEl.addEventListener("play", () => { playBtn.textContent = "⏸"; });
videoEl.addEventListener("pause", () => { playBtn.textContent = "▶"; });
playBtn.addEventListener("click", () => {
if (videoEl.paused) {
if (videoEl.currentTime < startT || videoEl.currentTime >= endT - 0.02) videoEl.currentTime = startT;
videoEl.play();
} else videoEl.pause();
});
// ── 时间轴拖拽 ────────────────────────────────────────────
function xToTime(clientX) {
const r = track.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
}
function startDrag(e, mode) {
if (duration <= 0) return;
dragging = mode;
if (mode === "move") { dragOffset = xToTime(e.clientX) - startT; sel.classList.add("grabbing"); }
track.setPointerCapture?.(e.pointerId);
e.preventDefault(); e.stopPropagation();
moveDrag(e);
}
function moveDrag(e) {
if (!dragging || duration <= 0) return;
const t = xToTime(e.clientX);
const fx = fixedDur();
if (dragging === "l") {
startT = Math.min(t, endT - 0.1);
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else if (dragging === "r") {
endT = Math.max(t, startT + 0.1);
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = Math.max(startT, endT - 0.05);
} else if (dragging === "move") {
const win = endT - startT;
startT = t - dragOffset;
endT = startT + win;
clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else if (dragging === "seek") {
if (fx > 0) { // 固定时长:在轨道点按 = 把窗口起点移到此处
startT = t; clampSel(); renderSel(); writeWidgets();
videoEl.currentTime = startT;
} else {
videoEl.currentTime = t; renderPlayhead();
}
}
}
function endDrag(e) {
if (!dragging) return;
dragging = null;
sel.classList.remove("grabbing");
try { track.releasePointerCapture?.(e.pointerId); } catch {}
}
hL.addEventListener("pointerdown", e => startDrag(e, "l"));
hR.addEventListener("pointerdown", e => startDrag(e, "r"));
sel.addEventListener("pointerdown", e => startDrag(e, "move"));
track.addEventListener("pointerdown", e => {
if (e.target === hL || e.target === hR || e.target === sel) return;
startDrag(e, "seek");
});
track.addEventListener("pointermove", moveDrag);
track.addEventListener("pointerup", endDrag);
track.addEventListener("pointercancel", endDrag);
// 数字 widget 手改 → 回写时间轴
function syncFromWidgets() {
if (duration <= 0) { renderSel(); return; }
const fx = fixedDur();
if (fx > 0) {
startT = +(startW?.value) || 0;
} else {
startT = +(startW?.value) || 0;
endT = (+(endW?.value) || 0) || duration;
}
clampSel(); renderSel();
}
for (const w of [startW, endW, fixedW]) {
if (!w) continue;
const orig = w.callback;
w.callback = function () {
const result = orig?.apply(this, arguments);
syncFromWidgets();
return result;
};
}
// ── 上传 ──────────────────────────────────────────────────
const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.accept = "video/*"; fileInput.style.display = "none";
document.body.appendChild(fileInput);
upBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) return;
upLabel.textContent = "上传中…"; upBtn.disabled = true;
try {
const info = await uploadVideo(file);
const dir = await getInputDir();
const relativePath = [info.subfolder, info.name].filter(Boolean).join("/");
const absPath = dir ? `${dir.replace(/\\/g, "/").replace(/\/+$/, "")}/${relativePath}` : relativePath;
if (pathW) pathW.value = absPath;
pathPreviewToken++;
duration = 0; startT = 0; endT = 0;
loadVideo(videoViewUrl({
filename: info.name,
subfolder: info.subfolder || "",
type: info.type || "input",
}));
upLabel.textContent = "重新上传";
} catch (err) {
console.error("[o1key videoTrim]", err);
upLabel.textContent = "上传失败,重试";
} finally {
upBtn.disabled = false; fileInput.value = "";
}
});
// 新建节点与工作流恢复都走同一条路径解析逻辑;绝对路径仅在 input 根目录内映射为 /view。
if (pathW?.value) void refreshPathPreview();
renderSel();
// 初始尺寸:让节点一创建就把编辑器高度收进框内,避免内容溢出。
requestAnimationFrame(() => node._o1vtResize?.());
node._o1vtCleanup = () => {
pathPreviewToken++;
filmToken++;
videoEl.pause?.();
videoEl.removeAttribute("src");
videoEl.load?.();
fileInput.remove?.();
};
};
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = origConfigure?.apply(this, arguments);
requestAnimationFrame(() => this._o1vtRefreshPath?.());
return result;
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._o1vtCleanup?.();
return origRemoved?.apply(this, arguments);
};
// ── 按视频比例自适应节点高度 ──────────────────────────────────
// editorWidget.computeSize 已把编辑器高度纳入,这里用 LiteGraph 的
// computeSize() 重新求节点尺寸,宽度保持当前值,高度按内容收紧。
nodeType.prototype._o1vtResize = function () {
const w = Math.max(this.size?.[0] || 0, 300);
const sz = this.computeSize([w, 0]);
this.setSize([w, sz[1]]);
this.setDirtyCanvas(true, true);
};
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
origResize?.apply(this, arguments);
};
},
});