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 = ``;
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}上传视频`;
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 = `选段 ${fmtTime(startT)} → ${fmtTime(endT)} 时长 ${(endT - startT).toFixed(1)}s`;
}
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);
};
},
});