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); }; }, });