parlorsky commited on
Commit
a4f9f3b
·
verified ·
1 Parent(s): c5179c4

ShotSplitter: add Download from URL button + yt-dlp route

Browse files
ComfyUI-ShotSplitter/js/shot_splitter_ui.js CHANGED
@@ -1,6 +1,7 @@
1
  // Oz_ShotSplitter UI:
2
  // 1. Upload Video button (uses ComfyUI /upload/image endpoint, type=input)
3
- // 2. Preview stack of <video> tags from ui.videos payload
 
4
  // Adapted from ComfyUI-VideoHelperSuite VHS.core.js (addVideoPreview pattern).
5
 
6
  import { app } from "../../scripts/app.js";
@@ -38,8 +39,24 @@ function clearContainer(container) {
38
  container.innerHTML = "";
39
  }
40
 
41
- // ---------- FEATURE 1: Upload Video Button ----------
42
- function addUploadButton(nodeType) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  chainCallback(nodeType.prototype, "onNodeCreated", function () {
44
  if (this._ozUploadAdded) return;
45
  this._ozUploadAdded = true;
@@ -48,14 +65,25 @@ function addUploadButton(nodeType) {
48
  wrapper.style.cssText =
49
  "display:flex;flex-direction:column;gap:4px;padding:4px;width:100%;box-sizing:border-box;";
50
 
51
- const btn = document.createElement("button");
52
- btn.textContent = "📤 Upload Video";
53
- btn.type = "button";
54
- btn.style.cssText =
55
- "width:100%;padding:6px 8px;cursor:pointer;background:#3a3a3a;color:#fff;" +
56
  "border:1px solid #555;border-radius:4px;font-size:12px;";
57
- btn.addEventListener("mouseenter", () => (btn.style.background = "#4a4a4a"));
58
- btn.addEventListener("mouseleave", () => (btn.style.background = "#3a3a3a"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  const status = document.createElement("div");
61
  status.style.cssText = "font-size:10px;color:#888;text-align:center;min-height:12px;";
@@ -65,53 +93,36 @@ function addUploadButton(nodeType) {
65
  fileInput.accept = "video/*";
66
  fileInput.style.display = "none";
67
 
68
- btn.addEventListener("click", () => fileInput.click());
69
 
70
  const node = this;
 
71
  fileInput.addEventListener("change", async (e) => {
72
  const file = e.target.files && e.target.files[0];
73
  if (!file) return;
74
 
75
- btn.disabled = true;
76
- btn.textContent = `⏳ Uploading...`;
 
77
  status.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`;
78
 
79
  try {
80
  const fd = new FormData();
81
- // ComfyUI's /upload/image accepts arbitrary file types
82
  fd.append("image", file, file.name);
83
  fd.append("type", "input");
84
  fd.append("subfolder", "");
85
  fd.append("overwrite", "true");
86
 
87
- const res = await fetch("/upload/image", {
88
- method: "POST",
89
- body: fd,
90
- });
91
- if (!res.ok) {
92
- throw new Error(`HTTP ${res.status} ${res.statusText}`);
93
- }
94
  const data = await res.json();
95
  const uploadedName = data.name || file.name;
96
 
97
- // Update the video widget options + value
98
- const videoWidget = node.widgets && node.widgets.find((w) => w.name === "video");
99
- if (videoWidget) {
100
- if (!videoWidget.options) videoWidget.options = {};
101
- if (!videoWidget.options.values) videoWidget.options.values = [];
102
- if (!videoWidget.options.values.includes(uploadedName)) {
103
- // Drop the placeholder if present
104
- videoWidget.options.values = videoWidget.options.values.filter(
105
- (v) => v !== "<no videos in input/>"
106
- );
107
- videoWidget.options.values.push(uploadedName);
108
- videoWidget.options.values.sort();
109
- }
110
- videoWidget.value = uploadedName;
111
- btn.textContent = `✅ ${uploadedName}`;
112
  status.textContent = "Video selected in dropdown.";
113
  } else {
114
- btn.textContent = `⚠️ video widget not found`;
115
  }
116
 
117
  if (node.graph) {
@@ -120,18 +131,68 @@ function addUploadButton(nodeType) {
120
  }
121
  } catch (err) {
122
  console.error("[Oz ShotSplitter] Upload failed:", err);
123
- btn.textContent = `❌ Upload failed`;
124
  status.textContent = String(err.message || err);
125
  } finally {
126
- btn.disabled = false;
 
127
  fileInput.value = "";
128
  setTimeout(() => {
129
- btn.textContent = "📤 Upload Video";
130
  }, 6000);
131
  }
132
  });
133
 
134
- wrapper.appendChild(btn);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  wrapper.appendChild(status);
136
  wrapper.appendChild(fileInput);
137
 
@@ -144,7 +205,7 @@ function addUploadButton(nodeType) {
144
  setValue() {},
145
  });
146
  widget.computeSize = function (width) {
147
- return [width, 56];
148
  };
149
  });
150
  }
@@ -233,7 +294,7 @@ app.registerExtension({
233
  name: "oz.shot_splitter",
234
  async beforeRegisterNodeDef(nodeType, nodeData) {
235
  if (nodeData && nodeData.name === "Oz_ShotSplitter") {
236
- addUploadButton(nodeType);
237
  addShotSplitterPreview(nodeType);
238
  }
239
  },
 
1
  // Oz_ShotSplitter UI:
2
  // 1. Upload Video button (uses ComfyUI /upload/image endpoint, type=input)
3
+ // 2. Download from URL button (POST /oz_shotsplitter/download_url, uses yt-dlp)
4
+ // 3. Preview stack of <video> tags from ui.videos payload
5
  // Adapted from ComfyUI-VideoHelperSuite VHS.core.js (addVideoPreview pattern).
6
 
7
  import { app } from "../../scripts/app.js";
 
39
  container.innerHTML = "";
40
  }
41
 
42
+ function setVideoWidget(node, filename) {
43
+ const videoWidget = node.widgets && node.widgets.find((w) => w.name === "video");
44
+ if (!videoWidget) return false;
45
+ if (!videoWidget.options) videoWidget.options = {};
46
+ if (!videoWidget.options.values) videoWidget.options.values = [];
47
+ if (!videoWidget.options.values.includes(filename)) {
48
+ videoWidget.options.values = videoWidget.options.values.filter(
49
+ (v) => v !== "<no videos in input/>"
50
+ );
51
+ videoWidget.options.values.push(filename);
52
+ videoWidget.options.values.sort();
53
+ }
54
+ videoWidget.value = filename;
55
+ return true;
56
+ }
57
+
58
+ // ---------- FEATURE 1+2: Upload Video / Download from URL ----------
59
+ function addInputButtons(nodeType) {
60
  chainCallback(nodeType.prototype, "onNodeCreated", function () {
61
  if (this._ozUploadAdded) return;
62
  this._ozUploadAdded = true;
 
65
  wrapper.style.cssText =
66
  "display:flex;flex-direction:column;gap:4px;padding:4px;width:100%;box-sizing:border-box;";
67
 
68
+ const btnStyle =
69
+ "width:100%;padding:6px 8px;cursor:pointer;color:#fff;" +
 
 
 
70
  "border:1px solid #555;border-radius:4px;font-size:12px;";
71
+
72
+ // -- Upload button --
73
+ const uploadBtn = document.createElement("button");
74
+ uploadBtn.textContent = "📤 Upload Video";
75
+ uploadBtn.type = "button";
76
+ uploadBtn.style.cssText = btnStyle + "background:#3a3a3a;";
77
+ uploadBtn.addEventListener("mouseenter", () => (uploadBtn.style.background = "#4a4a4a"));
78
+ uploadBtn.addEventListener("mouseleave", () => (uploadBtn.style.background = "#3a3a3a"));
79
+
80
+ // -- URL download button --
81
+ const urlBtn = document.createElement("button");
82
+ urlBtn.textContent = "🔗 Download from URL";
83
+ urlBtn.type = "button";
84
+ urlBtn.style.cssText = btnStyle + "background:#2c4a6e;";
85
+ urlBtn.addEventListener("mouseenter", () => (urlBtn.style.background = "#345a86"));
86
+ urlBtn.addEventListener("mouseleave", () => (urlBtn.style.background = "#2c4a6e"));
87
 
88
  const status = document.createElement("div");
89
  status.style.cssText = "font-size:10px;color:#888;text-align:center;min-height:12px;";
 
93
  fileInput.accept = "video/*";
94
  fileInput.style.display = "none";
95
 
96
+ uploadBtn.addEventListener("click", () => fileInput.click());
97
 
98
  const node = this;
99
+
100
  fileInput.addEventListener("change", async (e) => {
101
  const file = e.target.files && e.target.files[0];
102
  if (!file) return;
103
 
104
+ uploadBtn.disabled = true;
105
+ urlBtn.disabled = true;
106
+ uploadBtn.textContent = `⏳ Uploading...`;
107
  status.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`;
108
 
109
  try {
110
  const fd = new FormData();
 
111
  fd.append("image", file, file.name);
112
  fd.append("type", "input");
113
  fd.append("subfolder", "");
114
  fd.append("overwrite", "true");
115
 
116
+ const res = await fetch("/upload/image", { method: "POST", body: fd });
117
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
 
 
 
 
 
118
  const data = await res.json();
119
  const uploadedName = data.name || file.name;
120
 
121
+ if (setVideoWidget(node, uploadedName)) {
122
+ uploadBtn.textContent = `✅ ${uploadedName}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  status.textContent = "Video selected in dropdown.";
124
  } else {
125
+ uploadBtn.textContent = `⚠️ video widget not found`;
126
  }
127
 
128
  if (node.graph) {
 
131
  }
132
  } catch (err) {
133
  console.error("[Oz ShotSplitter] Upload failed:", err);
134
+ uploadBtn.textContent = `❌ Upload failed`;
135
  status.textContent = String(err.message || err);
136
  } finally {
137
+ uploadBtn.disabled = false;
138
+ urlBtn.disabled = false;
139
  fileInput.value = "";
140
  setTimeout(() => {
141
+ uploadBtn.textContent = "📤 Upload Video";
142
  }, 6000);
143
  }
144
  });
145
 
146
+ urlBtn.addEventListener("click", async () => {
147
+ const url = (prompt("Paste video URL (Instagram / TikTok / YouTube / …):") || "").trim();
148
+ if (!url) return;
149
+ if (!/^https?:\/\//i.test(url)) {
150
+ status.textContent = "Must start with http(s)://";
151
+ return;
152
+ }
153
+
154
+ uploadBtn.disabled = true;
155
+ urlBtn.disabled = true;
156
+ urlBtn.textContent = "⏳ Downloading...";
157
+ status.textContent = "yt-dlp running (may take a minute)...";
158
+
159
+ try {
160
+ const res = await fetch("/oz_shotsplitter/download_url", {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ url }),
164
+ });
165
+ const data = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
166
+ if (!res.ok || !data.ok) {
167
+ throw new Error(data.error || `HTTP ${res.status}`);
168
+ }
169
+ const filename = data.filename;
170
+ if (setVideoWidget(node, filename)) {
171
+ urlBtn.textContent = `✅ ${filename}${data.cached ? " (cached)" : ""}`;
172
+ status.textContent = "Video selected in dropdown.";
173
+ } else {
174
+ urlBtn.textContent = `⚠️ video widget not found`;
175
+ }
176
+
177
+ if (node.graph) {
178
+ node.setSize(node.computeSize());
179
+ node.graph.setDirtyCanvas(true, true);
180
+ }
181
+ } catch (err) {
182
+ console.error("[Oz ShotSplitter] URL download failed:", err);
183
+ urlBtn.textContent = "❌ Download failed";
184
+ status.textContent = String(err.message || err);
185
+ } finally {
186
+ uploadBtn.disabled = false;
187
+ urlBtn.disabled = false;
188
+ setTimeout(() => {
189
+ urlBtn.textContent = "🔗 Download from URL";
190
+ }, 8000);
191
+ }
192
+ });
193
+
194
+ wrapper.appendChild(uploadBtn);
195
+ wrapper.appendChild(urlBtn);
196
  wrapper.appendChild(status);
197
  wrapper.appendChild(fileInput);
198
 
 
205
  setValue() {},
206
  });
207
  widget.computeSize = function (width) {
208
+ return [width, 90];
209
  };
210
  });
211
  }
 
294
  name: "oz.shot_splitter",
295
  async beforeRegisterNodeDef(nodeType, nodeData) {
296
  if (nodeData && nodeData.name === "Oz_ShotSplitter") {
297
+ addInputButtons(nodeType);
298
  addShotSplitterPreview(nodeType);
299
  }
300
  },
ComfyUI-ShotSplitter/server_hooks.py CHANGED
@@ -1,14 +1,17 @@
1
  """ComfyUI server hooks for Oz ShotSplitter.
2
 
3
- Exposes a lightweight debug endpoint that lists active WebSocket client IDs
4
- so a driver script can route prompt submissions to the user's browser session
5
- (required because ComfyUI gates `executed` events on a non-None client_id in
6
- execution.py — events submitted without client_id are silently dropped).
7
  """
8
 
9
  from __future__ import annotations
10
 
 
 
11
  import logging
 
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
@@ -18,6 +21,33 @@ except Exception as e: # pragma: no cover
18
  PromptServer = None # type: ignore
19
  logger.warning("Oz ShotSplitter: PromptServer unavailable (%s)", e)
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  def register_routes() -> None:
23
  if PromptServer is None:
@@ -35,7 +65,98 @@ def register_routes() -> None:
35
  sids = list(getattr(server, "sockets", {}).keys())
36
  return web.json_response({"client_ids": sids, "count": len(sids)})
37
 
38
- logger.info("Oz ShotSplitter: registered /oz_shotsplitter/active_clients")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  register_routes()
 
1
  """ComfyUI server hooks for Oz ShotSplitter.
2
 
3
+ Routes:
4
+ GET /oz_shotsplitter/active_clients — debug list of active websocket clients
5
+ POST /oz_shotsplitter/download_url — yt-dlp a public URL into ComfyUI input/
 
6
  """
7
 
8
  from __future__ import annotations
9
 
10
+ import asyncio
11
+ import hashlib
12
  import logging
13
+ import os
14
+ import re
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
21
  PromptServer = None # type: ignore
22
  logger.warning("Oz ShotSplitter: PromptServer unavailable (%s)", e)
23
 
24
+ DOWNLOAD_TIMEOUT_SEC = 600
25
+ DOWNLOAD_MAX_FILESIZE = "500M"
26
+ VIDEO_EXTS = ("mp4", "mkv", "webm", "mov", "m4v")
27
+ _INPUT_DIR_CACHE: str | None = None
28
+
29
+
30
+ def _resolve_input_dir() -> str:
31
+ global _INPUT_DIR_CACHE
32
+ if _INPUT_DIR_CACHE:
33
+ return _INPUT_DIR_CACHE
34
+ try:
35
+ import folder_paths # type: ignore
36
+ d = folder_paths.get_input_directory()
37
+ except Exception:
38
+ d = "/workspace/ComfyUI/input"
39
+ os.makedirs(d, exist_ok=True)
40
+ _INPUT_DIR_CACHE = d
41
+ return d
42
+
43
+
44
+ def _find_cached(input_dir: str, stem: str) -> str | None:
45
+ for ext in VIDEO_EXTS:
46
+ p = os.path.join(input_dir, f"{stem}.{ext}")
47
+ if os.path.exists(p) and os.path.getsize(p) > 0:
48
+ return p
49
+ return None
50
+
51
 
52
  def register_routes() -> None:
53
  if PromptServer is None:
 
65
  sids = list(getattr(server, "sockets", {}).keys())
66
  return web.json_response({"client_ids": sids, "count": len(sids)})
67
 
68
+ @server.routes.post("/oz_shotsplitter/download_url")
69
+ async def _download_url(request):
70
+ try:
71
+ data = await request.json()
72
+ except Exception:
73
+ return web.json_response({"ok": False, "error": "invalid JSON body"}, status=400)
74
+
75
+ url = (data.get("url") or "").strip()
76
+ if not url or not re.match(r"^https?://", url):
77
+ return web.json_response({"ok": False, "error": "invalid URL"}, status=400)
78
+
79
+ input_dir = _resolve_input_dir()
80
+ url_hash = hashlib.md5(url.encode("utf-8")).hexdigest()[:12]
81
+ stem = f"yt_{url_hash}"
82
+
83
+ cached = _find_cached(input_dir, stem)
84
+ if cached:
85
+ return web.json_response({
86
+ "ok": True,
87
+ "filename": os.path.basename(cached),
88
+ "cached": True,
89
+ })
90
+
91
+ out_template = os.path.join(input_dir, f"{stem}.%(ext)s")
92
+ cmd = [
93
+ "yt-dlp",
94
+ url,
95
+ "-o", out_template,
96
+ "--no-playlist",
97
+ "--max-filesize", DOWNLOAD_MAX_FILESIZE,
98
+ "-f", "mp4/best[ext=mp4]/bestvideo*+bestaudio/best",
99
+ "--merge-output-format", "mp4",
100
+ "--no-warnings",
101
+ "--no-progress",
102
+ "--quiet",
103
+ ]
104
+ logger.info("Oz ShotSplitter: yt-dlp %s -> %s", url, stem)
105
+
106
+ try:
107
+ proc = await asyncio.create_subprocess_exec(
108
+ *cmd,
109
+ stdout=asyncio.subprocess.PIPE,
110
+ stderr=asyncio.subprocess.PIPE,
111
+ )
112
+ except FileNotFoundError:
113
+ return web.json_response({
114
+ "ok": False,
115
+ "error": "yt-dlp not installed. Run: /venv/main/bin/pip install yt-dlp",
116
+ }, status=500)
117
+ except Exception as e:
118
+ return web.json_response({"ok": False, "error": f"spawn failed: {e}"}, status=500)
119
+
120
+ try:
121
+ stdout, stderr = await asyncio.wait_for(
122
+ proc.communicate(), timeout=DOWNLOAD_TIMEOUT_SEC
123
+ )
124
+ except asyncio.TimeoutError:
125
+ try:
126
+ proc.kill()
127
+ await proc.wait()
128
+ except Exception:
129
+ pass
130
+ return web.json_response({
131
+ "ok": False,
132
+ "error": f"yt-dlp timed out after {DOWNLOAD_TIMEOUT_SEC}s",
133
+ }, status=504)
134
+
135
+ if proc.returncode != 0:
136
+ err_tail = (stderr or b"").decode("utf-8", errors="replace").strip()
137
+ if len(err_tail) > 600:
138
+ err_tail = err_tail[-600:]
139
+ return web.json_response({
140
+ "ok": False,
141
+ "error": f"yt-dlp exit {proc.returncode}: {err_tail or 'no stderr'}",
142
+ }, status=500)
143
+
144
+ saved = _find_cached(input_dir, stem)
145
+ if not saved:
146
+ return web.json_response({
147
+ "ok": False,
148
+ "error": "yt-dlp succeeded but output file missing",
149
+ }, status=500)
150
+
151
+ return web.json_response({
152
+ "ok": True,
153
+ "filename": os.path.basename(saved),
154
+ "cached": False,
155
+ })
156
+
157
+ logger.info(
158
+ "Oz ShotSplitter: registered /oz_shotsplitter/{active_clients,download_url}"
159
+ )
160
 
161
 
162
  register_routes()