parlorsky commited on
Commit
ead274c
·
verified ·
1 Parent(s): c76f614

Update ComfyUI-ShotSplitter: OUTPUT_IS_LIST for clip_paths, upload video button in JS

Browse files
ComfyUI-ShotSplitter/.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
ComfyUI-ShotSplitter/.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
ComfyUI-ShotSplitter/.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tests/test_ensemble.py::test_union_within_window_merges_nearby": true,
3
+ "tests/test_ensemble.py::test_union_outside_window_keeps_both": true,
4
+ "tests/test_ensemble.py::test_union_empty_inputs_returns_empty": true,
5
+ "tests/test_ensemble.py::test_boundaries_to_intervals_adds_sentinels": true,
6
+ "tests/test_ensemble.py::test_boundaries_to_intervals_no_cuts_gives_single_shot": true,
7
+ "tests/test_ensemble.py::test_min_shot_filter_merges_short_with_right_neighbor": true,
8
+ "tests/test_ensemble.py::test_min_shot_filter_last_short_merges_with_left": true,
9
+ "tests/test_ensemble.py::test_min_shot_filter_keeps_all_when_above_threshold": true
10
+ }
ComfyUI-ShotSplitter/.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_ensemble.py::test_boundaries_to_intervals_adds_sentinels",
3
+ "tests/test_ensemble.py::test_boundaries_to_intervals_no_cuts_gives_single_shot",
4
+ "tests/test_ensemble.py::test_min_shot_filter_keeps_all_when_above_threshold",
5
+ "tests/test_ensemble.py::test_min_shot_filter_last_short_merges_with_left",
6
+ "tests/test_ensemble.py::test_min_shot_filter_merges_short_with_right_neighbor",
7
+ "tests/test_ensemble.py::test_union_empty_inputs_returns_empty",
8
+ "tests/test_ensemble.py::test_union_outside_window_keeps_both",
9
+ "tests/test_ensemble.py::test_union_within_window_merges_nearby"
10
+ ]
ComfyUI-ShotSplitter/js/shot_splitter_ui.js CHANGED
@@ -1,4 +1,6 @@
1
- // Oz_ShotSplitter preview: renders a stack of <video> tags from ui.videos payload.
 
 
2
  // Adapted from ComfyUI-VideoHelperSuite VHS.core.js (addVideoPreview pattern).
3
 
4
  import { app } from "../../scripts/app.js";
@@ -19,13 +21,10 @@ function buildPreviewURL(entry) {
19
  type: entry.type || "output",
20
  subfolder: entry.subfolder || "",
21
  });
22
- // Cache-bust so re-runs show the new file, not the stale one:
23
  return `/view?${params.toString()}&rand=${Math.random()}`;
24
  }
25
 
26
  function clearContainer(container) {
27
- // Explicitly drop <video> src before removing, to help the browser
28
- // release the underlying media resource and avoid leaks on re-execute.
29
  const videos = container.querySelectorAll("video");
30
  videos.forEach((v) => {
31
  try {
@@ -39,6 +38,118 @@ function clearContainer(container) {
39
  container.innerHTML = "";
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  function addShotSplitterPreview(nodeType) {
43
  chainCallback(nodeType.prototype, "onNodeCreated", function () {
44
  const container = document.createElement("div");
@@ -58,11 +169,9 @@ function addShotSplitterPreview(nodeType) {
58
  getValue() { return null; },
59
  setValue() {},
60
  });
61
- // Compute widget height based on number of children
62
  widget.computeSize = function (width) {
63
- if (!container.children.length) return [width, -4]; // hidden when empty
64
  const rows = container.children.length;
65
- // Each preview row ~140px (label + video + padding)
66
  return [width, Math.min(rows * 140 + 20, 600)];
67
  };
68
 
@@ -113,7 +222,6 @@ function addShotSplitterPreview(nodeType) {
113
  container.appendChild(wrap);
114
  });
115
  }
116
- // Force relayout and redraw
117
  if (this.graph) {
118
  this.setSize(this.computeSize());
119
  this.graph.setDirtyCanvas(true, true);
@@ -125,6 +233,7 @@ app.registerExtension({
125
  name: "oz.shot_splitter",
126
  async beforeRegisterNodeDef(nodeType, nodeData) {
127
  if (nodeData && nodeData.name === "Oz_ShotSplitter") {
 
128
  addShotSplitterPreview(nodeType);
129
  }
130
  },
 
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";
 
21
  type: entry.type || "output",
22
  subfolder: entry.subfolder || "",
23
  });
 
24
  return `/view?${params.toString()}&rand=${Math.random()}`;
25
  }
26
 
27
  function clearContainer(container) {
 
 
28
  const videos = container.querySelectorAll("video");
29
  videos.forEach((v) => {
30
  try {
 
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;
46
+
47
+ const wrapper = document.createElement("div");
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;";
62
+
63
+ const fileInput = document.createElement("input");
64
+ fileInput.type = "file";
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) {
118
+ node.setSize(node.computeSize());
119
+ node.graph.setDirtyCanvas(true, true);
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
+
138
+ const widget = this.addDOMWidget("upload_video", "upload", wrapper, {
139
+ serialize: false,
140
+ hideOnZoom: false,
141
+ getValue() {
142
+ return null;
143
+ },
144
+ setValue() {},
145
+ });
146
+ widget.computeSize = function (width) {
147
+ return [width, 56];
148
+ };
149
+ });
150
+ }
151
+
152
+ // ---------- Existing: Preview Stack ----------
153
  function addShotSplitterPreview(nodeType) {
154
  chainCallback(nodeType.prototype, "onNodeCreated", function () {
155
  const container = document.createElement("div");
 
169
  getValue() { return null; },
170
  setValue() {},
171
  });
 
172
  widget.computeSize = function (width) {
173
+ if (!container.children.length) return [width, -4];
174
  const rows = container.children.length;
 
175
  return [width, Math.min(rows * 140 + 20, 600)];
176
  };
177
 
 
222
  container.appendChild(wrap);
223
  });
224
  }
 
225
  if (this.graph) {
226
  this.setSize(this.computeSize());
227
  this.graph.setDirtyCanvas(true, true);
 
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
  },
ComfyUI-ShotSplitter/shot_splitter_node.py CHANGED
@@ -27,6 +27,7 @@ class Oz_ShotSplitter:
27
  FUNCTION = "run"
28
  RETURN_TYPES = ("STRING", "IMAGE", "INT", "STRING")
29
  RETURN_NAMES = ("clip_paths", "first_frames", "shot_count", "manifest_json")
 
30
 
31
  @classmethod
32
  def INPUT_TYPES(cls):
 
27
  FUNCTION = "run"
28
  RETURN_TYPES = ("STRING", "IMAGE", "INT", "STRING")
29
  RETURN_NAMES = ("clip_paths", "first_frames", "shot_count", "manifest_json")
30
+ OUTPUT_IS_LIST = (True, False, False, False)
31
 
32
  @classmethod
33
  def INPUT_TYPES(cls):
ComfyUI-ShotSplitter/tests/.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
ComfyUI-ShotSplitter/tests/.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
ComfyUI-ShotSplitter/tests/.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
ComfyUI-ShotSplitter/tests/.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "test_cutter.py::test_cut_keep_audio_false_produces_video_only_clips",
3
+ "test_cutter.py::test_cut_preserves_audio_stream",
4
+ "test_cutter.py::test_cut_real_reel_two_shots_by_detected_boundary",
5
+ "test_cutter.py::test_cut_synthetic_3shots_all_succeed_and_duration_sum_matches",
6
+ "test_ensemble.py::test_boundaries_to_intervals_adds_sentinels",
7
+ "test_ensemble.py::test_boundaries_to_intervals_no_cuts_gives_single_shot",
8
+ "test_ensemble.py::test_min_shot_filter_keeps_all_when_above_threshold",
9
+ "test_ensemble.py::test_min_shot_filter_last_short_merges_with_left",
10
+ "test_ensemble.py::test_min_shot_filter_merges_short_with_right_neighbor",
11
+ "test_ensemble.py::test_union_empty_inputs_returns_empty",
12
+ "test_ensemble.py::test_union_outside_window_keeps_both",
13
+ "test_ensemble.py::test_union_within_window_merges_nearby",
14
+ "test_first_frame_reader.py::test_read_empty_list_returns_empty_tensor",
15
+ "test_first_frame_reader.py::test_read_missing_file_returns_black_fallback",
16
+ "test_first_frame_reader.py::test_read_multiple_clips_uses_first_as_reference_size",
17
+ "test_first_frame_reader.py::test_read_single_clip_returns_1x_tensor",
18
+ "test_node_e2e.py::test_e2e_3shot_synthetic_produces_three_clips",
19
+ "test_node_e2e.py::test_e2e_real_reel_produces_two_or_more_clips",
20
+ "test_pyscenedetect.py::test_pyscenedetect_prints_count",
21
+ "test_pyscenedetect.py::test_pyscenedetect_returns_list_of_ints",
22
+ "test_pyscenedetect.py::test_pyscenedetect_sorted_and_unique",
23
+ "test_transnet.py::test_transnet_detects_3shot_synthetic",
24
+ "test_transnet.py::test_transnet_detects_at_least_one_cut_in_real_reel",
25
+ "test_transnet.py::test_transnet_detects_synthetic_3shot_ground_truth",
26
+ "test_transnet.py::test_transnet_single_shot_returns_empty",
27
+ "test_transnet.py::test_transnet_single_shot_returns_empty_or_one",
28
+ "test_video_probe.py::test_probe_detects_audio_stream_correctly",
29
+ "test_video_probe.py::test_probe_returns_sensible_values"
30
+ ]
ComfyUI-ShotSplitter/tests/conftest.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ # Add project root to path so 'detectors' package is importable
5
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ComfyUI-ShotSplitter/tests/test_cutter.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests: cut test_3shots.mp4 and reel into pieces; verify with ffprobe."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+
8
+ import pytest
9
+
10
+ from cutter import cut_shots
11
+ from video_probe import probe
12
+
13
+ THREESHOT = "/workspace/ComfyUI/input/test_3shots.mp4"
14
+ REEL = "/workspace/ComfyUI/input/reel_multishot.mp4"
15
+
16
+
17
+ def _probe_duration(path: str) -> float:
18
+ out = subprocess.check_output(
19
+ ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", path],
20
+ text=True,
21
+ )
22
+ return float(json.loads(out)["format"]["duration"])
23
+
24
+
25
+ @pytest.mark.skipif(not os.path.exists(THREESHOT), reason="sample missing")
26
+ def test_cut_synthetic_3shots_all_succeed_and_duration_sum_matches():
27
+ info = probe(THREESHOT)
28
+ # Ground truth intervals from TransNet: [0-60), [60-120), [120-180)
29
+ intervals = [(0, 60), (60, 120), (120, info.total_frames)]
30
+ with tempfile.TemporaryDirectory() as td:
31
+ results = cut_shots(
32
+ THREESHOT,
33
+ intervals,
34
+ fps=info.fps,
35
+ output_dir=td,
36
+ filename_prefix="syn",
37
+ crf=23,
38
+ preset="veryfast",
39
+ keep_audio=True,
40
+ has_audio=info.has_audio,
41
+ vfr=info.vfr,
42
+ )
43
+ assert len(results) == 3
44
+ assert all(r.success for r in results), [r.error for r in results if not r.success]
45
+ for r in results:
46
+ assert os.path.isfile(r.path), f"missing: {r.path}"
47
+ total_cut = sum(_probe_duration(r.path) for r in results)
48
+ assert abs(total_cut - info.duration_s) < 0.25, (total_cut, info.duration_s)
49
+
50
+
51
+ @pytest.mark.skipif(not os.path.exists(THREESHOT), reason="sample missing")
52
+ def test_cut_preserves_audio_stream():
53
+ info = probe(THREESHOT)
54
+ if not info.has_audio:
55
+ pytest.skip("sample has no audio")
56
+ intervals = [(0, info.total_frames // 2), (info.total_frames // 2, info.total_frames)]
57
+ with tempfile.TemporaryDirectory() as td:
58
+ results = cut_shots(
59
+ THREESHOT,
60
+ intervals,
61
+ fps=info.fps,
62
+ output_dir=td,
63
+ filename_prefix="aud",
64
+ crf=23,
65
+ preset="veryfast",
66
+ keep_audio=True,
67
+ has_audio=True,
68
+ vfr=info.vfr,
69
+ )
70
+ assert all(r.success for r in results)
71
+ for r in results:
72
+ out_info = probe(r.path)
73
+ assert out_info.has_audio, f"audio stream missing in {r.path}"
74
+ assert out_info.audio_codec == "aac"
75
+
76
+
77
+ @pytest.mark.skipif(not os.path.exists(REEL), reason="reel missing")
78
+ def test_cut_real_reel_two_shots_by_detected_boundary():
79
+ info = probe(REEL)
80
+ # TransNet detected cut at frame 140 in this reel
81
+ intervals = [(0, 140), (140, info.total_frames)]
82
+ with tempfile.TemporaryDirectory() as td:
83
+ results = cut_shots(
84
+ REEL,
85
+ intervals,
86
+ fps=info.fps,
87
+ output_dir=td,
88
+ filename_prefix="reel",
89
+ crf=23,
90
+ preset="veryfast",
91
+ keep_audio=True,
92
+ has_audio=info.has_audio,
93
+ vfr=info.vfr,
94
+ )
95
+ assert all(r.success for r in results), [r.error for r in results if not r.success]
96
+ # Both clips should have video+audio, sum to within 0.3s of source
97
+ total_cut = sum(_probe_duration(r.path) for r in results)
98
+ assert abs(total_cut - info.duration_s) < 0.3, (total_cut, info.duration_s)
99
+
100
+
101
+ @pytest.mark.skipif(not os.path.exists(THREESHOT), reason="sample missing")
102
+ def test_cut_keep_audio_false_produces_video_only_clips():
103
+ info = probe(THREESHOT)
104
+ intervals = [(0, info.total_frames)]
105
+ with tempfile.TemporaryDirectory() as td:
106
+ results = cut_shots(
107
+ THREESHOT,
108
+ intervals,
109
+ fps=info.fps,
110
+ output_dir=td,
111
+ filename_prefix="noaud",
112
+ crf=23,
113
+ preset="veryfast",
114
+ keep_audio=False,
115
+ has_audio=info.has_audio,
116
+ vfr=info.vfr,
117
+ )
118
+ assert results[0].success
119
+ out_info = probe(results[0].path)
120
+ assert out_info.has_audio is False, "clip should have no audio stream"
ComfyUI-ShotSplitter/tests/test_ensemble.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ensemble merge logic — pure functions, no video I/O."""
2
+
3
+ import pytest
4
+
5
+ from detectors.ensemble import (
6
+ union_boundaries,
7
+ apply_min_shot_filter,
8
+ boundaries_to_intervals,
9
+ )
10
+
11
+
12
+ def test_union_within_window_merges_nearby():
13
+ a = [10, 50, 100]
14
+ b = [11, 48, 200]
15
+ # window=3: 10/11 merge -> 10; 50/48 merge -> 48; 100 alone; 200 alone
16
+ result = union_boundaries([a, b], window=3)
17
+ assert result == [10, 48, 100, 200]
18
+
19
+
20
+ def test_union_outside_window_keeps_both():
21
+ a = [10, 50]
22
+ b = [20, 60]
23
+ # window=3: nothing merges (distances are 10)
24
+ result = union_boundaries([a, b], window=3)
25
+ assert result == [10, 20, 50, 60]
26
+
27
+
28
+ def test_union_empty_inputs_returns_empty():
29
+ assert union_boundaries([[], []], window=3) == []
30
+ assert union_boundaries([], window=3) == []
31
+
32
+
33
+ def test_boundaries_to_intervals_adds_sentinels():
34
+ result = boundaries_to_intervals([100, 200], total_frames=300)
35
+ assert result == [(0, 100), (100, 200), (200, 300)]
36
+
37
+
38
+ def test_boundaries_to_intervals_no_cuts_gives_single_shot():
39
+ result = boundaries_to_intervals([], total_frames=500)
40
+ assert result == [(0, 500)]
41
+
42
+
43
+ def test_min_shot_filter_merges_short_with_right_neighbor():
44
+ # fps=30, min=0.5s -> 15 frames
45
+ intervals = [(0, 100), (100, 105), (105, 300)]
46
+ # (100,105) is 5 frames, < 15; merge with right -> (100, 300)
47
+ result = apply_min_shot_filter(intervals, fps=30.0, min_shot_seconds=0.5)
48
+ assert result == [(0, 100), (100, 300)]
49
+
50
+
51
+ def test_min_shot_filter_last_short_merges_with_left():
52
+ intervals = [(0, 100), (100, 300), (300, 305)]
53
+ # (300,305) is at the end; no right neighbor -> merge with left
54
+ result = apply_min_shot_filter(intervals, fps=30.0, min_shot_seconds=0.5)
55
+ assert result == [(0, 100), (100, 305)]
56
+
57
+
58
+ def test_min_shot_filter_keeps_all_when_above_threshold():
59
+ intervals = [(0, 100), (100, 200), (200, 300)]
60
+ result = apply_min_shot_filter(intervals, fps=30.0, min_shot_seconds=0.5)
61
+ assert result == intervals
ComfyUI-ShotSplitter/tests/test_first_frame_reader.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test: read first frames of existing sample videos."""
2
+
3
+ import os
4
+ import pytest
5
+
6
+ from first_frame_reader import read_first_frames
7
+
8
+ THREESHOT = "/workspace/ComfyUI/input/test_3shots.mp4"
9
+ IMBA = "/workspace/ComfyUI/input/imba.mp4"
10
+
11
+
12
+ @pytest.mark.skipif(not os.path.exists(THREESHOT), reason="sample missing")
13
+ def test_read_single_clip_returns_1x_tensor():
14
+ t = read_first_frames([THREESHOT])
15
+ assert t.shape[0] == 1
16
+ assert t.ndim == 4
17
+ assert t.shape[3] == 3
18
+ assert t.dtype.is_floating_point
19
+ assert 0.0 <= t.min().item() and t.max().item() <= 1.0
20
+
21
+
22
+ @pytest.mark.skipif(not (os.path.exists(THREESHOT) and os.path.exists(IMBA)), reason="samples missing")
23
+ def test_read_multiple_clips_uses_first_as_reference_size():
24
+ t = read_first_frames([THREESHOT, IMBA])
25
+ assert t.shape[0] == 2
26
+ # Second clip should be resized to match first
27
+ assert t[0].shape == t[1].shape
28
+
29
+
30
+ def test_read_empty_list_returns_empty_tensor():
31
+ t = read_first_frames([])
32
+ assert t.shape[0] == 0
33
+
34
+
35
+ def test_read_missing_file_returns_black_fallback():
36
+ t = read_first_frames(["/nonexistent/fake_clip.mp4"])
37
+ assert t.shape[0] == 1
38
+ # Black frame: all zeros
39
+ assert t.max().item() == 0.0
ComfyUI-ShotSplitter/tests/test_node_e2e.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end: run the full Oz_ShotSplitter pipeline on real samples."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ PACK_DIR = "/workspace/ComfyUI/custom_nodes/ComfyUI-ShotSplitter"
10
+
11
+
12
+ def _load_pack():
13
+ """Load the hyphenated-name pack as a proper Python package."""
14
+ if "/workspace/ComfyUI" not in sys.path:
15
+ sys.path.insert(0, "/workspace/ComfyUI")
16
+
17
+ if "ComfyUI_ShotSplitter" in sys.modules:
18
+ return sys.modules["ComfyUI_ShotSplitter"]
19
+
20
+ spec = importlib.util.spec_from_file_location(
21
+ "ComfyUI_ShotSplitter",
22
+ os.path.join(PACK_DIR, "__init__.py"),
23
+ submodule_search_locations=[PACK_DIR],
24
+ )
25
+ mod = importlib.util.module_from_spec(spec)
26
+ sys.modules["ComfyUI_ShotSplitter"] = mod
27
+ spec.loader.exec_module(mod)
28
+ return mod
29
+
30
+
31
+ THREESHOT = "test_3shots.mp4"
32
+ REEL = "reel_multishot.mp4"
33
+
34
+
35
+ @pytest.mark.skipif(
36
+ not os.path.exists(f"/workspace/ComfyUI/input/{THREESHOT}"),
37
+ reason="synthetic sample missing",
38
+ )
39
+ def test_e2e_3shot_synthetic_produces_three_clips():
40
+ pack = _load_pack()
41
+ node = pack.Oz_ShotSplitter()
42
+ out = node.run(
43
+ video=THREESHOT,
44
+ detector="ensemble",
45
+ min_shot_seconds=0.3,
46
+ merge_window_frames=3,
47
+ adaptive_threshold=3.0,
48
+ transnet_threshold=0.5,
49
+ output_subfolder="shots/e2e_syn_test",
50
+ filename_prefix="shot",
51
+ crf=23,
52
+ preset="veryfast",
53
+ keep_audio=True,
54
+ )
55
+ assert "result" in out
56
+ clip_paths, first_frames, shot_count, manifest_json = out["result"]
57
+ print(f"\n[E2E synthetic] shot_count={shot_count}")
58
+ print(f"[E2E synthetic] clip_paths={clip_paths}")
59
+ assert shot_count == 3, f"expected 3 shots for synthetic red/green/blue, got {shot_count}"
60
+ assert len(clip_paths) == 3
61
+ assert first_frames.shape[0] == 3
62
+ assert all(os.path.isfile(p) for p in clip_paths)
63
+ import json as _json
64
+ manifest = _json.loads(manifest_json)
65
+ assert len(manifest) == 3
66
+
67
+ # Verify the ui.videos payload is populated
68
+ assert "ui" in out
69
+ assert "videos" in out["ui"]
70
+ assert len(out["ui"]["videos"]) == 3
71
+ for v in out["ui"]["videos"]:
72
+ assert v["filename"].endswith(".mp4")
73
+ assert v["subfolder"] == "shots/e2e_syn_test"
74
+ assert v["type"] == "output"
75
+
76
+
77
+ @pytest.mark.skipif(
78
+ not os.path.exists(f"/workspace/ComfyUI/input/{REEL}"),
79
+ reason="reel sample missing",
80
+ )
81
+ def test_e2e_real_reel_produces_two_or_more_clips():
82
+ pack = _load_pack()
83
+ node = pack.Oz_ShotSplitter()
84
+ out = node.run(
85
+ video=REEL,
86
+ detector="ensemble",
87
+ min_shot_seconds=0.5,
88
+ merge_window_frames=3,
89
+ adaptive_threshold=3.0,
90
+ transnet_threshold=0.5,
91
+ output_subfolder="shots/e2e_reel_test",
92
+ filename_prefix="shot",
93
+ crf=23,
94
+ preset="veryfast",
95
+ keep_audio=True,
96
+ )
97
+ clip_paths, first_frames, shot_count, manifest_json = out["result"]
98
+ print(f"\n[E2E reel] shot_count={shot_count}")
99
+ print(f"[E2E reel] clip_paths={clip_paths}")
100
+ assert shot_count >= 2, f"expected >=2 shots in reel, got {shot_count}"
101
+ assert all(os.path.isfile(p) for p in clip_paths)
102
+ assert first_frames.shape[0] == shot_count
ComfyUI-ShotSplitter/tests/test_pyscenedetect.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test for PySceneDetect adapter against imba.mp4 on remote."""
2
+
3
+ import os
4
+ import pytest
5
+
6
+ from detectors.pyscenedetect_adapter import detect_pyscenedetect
7
+
8
+ SAMPLE = "/workspace/ComfyUI/input/imba.mp4"
9
+
10
+
11
+ @pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample video not present")
12
+ def test_pyscenedetect_returns_list_of_ints():
13
+ boundaries = detect_pyscenedetect(SAMPLE, adaptive_threshold=3.0)
14
+ assert isinstance(boundaries, list)
15
+ assert all(isinstance(b, int) and b > 0 for b in boundaries)
16
+
17
+
18
+ @pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample video not present")
19
+ def test_pyscenedetect_sorted_and_unique():
20
+ boundaries = detect_pyscenedetect(SAMPLE, adaptive_threshold=3.0)
21
+ assert boundaries == sorted(set(boundaries))
22
+
23
+
24
+ @pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample video not present")
25
+ def test_pyscenedetect_prints_count(capsys):
26
+ """Just surface how many boundaries were detected for manual inspection."""
27
+ boundaries = detect_pyscenedetect(SAMPLE, adaptive_threshold=3.0)
28
+ print(f"\n[info] PySceneDetect boundaries for imba.mp4: {boundaries} (count={len(boundaries)})")
29
+ assert boundaries is not None # sanity
ComfyUI-ShotSplitter/tests/test_transnet.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for TransNetV2 wrapper."""
2
+
3
+ import os
4
+ import pytest
5
+
6
+ from detectors.transnet import detect_transnet
7
+
8
+ THREESHOT = "/workspace/ComfyUI/input/test_3shots.mp4"
9
+ REEL = "/workspace/ComfyUI/input/reel_multishot.mp4"
10
+ IMBA = "/workspace/ComfyUI/input/imba.mp4"
11
+
12
+
13
+ @pytest.mark.skipif(not os.path.exists(THREESHOT), reason="synthetic 3-shot missing")
14
+ def test_transnet_detects_synthetic_3shot_ground_truth():
15
+ boundaries = detect_transnet(THREESHOT, threshold=0.5)
16
+ print(f"\n[info] TransNet on test_3shots.mp4: {boundaries}")
17
+ assert len(boundaries) == 2, f"expected 2 cuts, got {len(boundaries)}: {boundaries}"
18
+ # Ground truth: frame 60 and 120. Tolerance +/- 3 frames.
19
+ assert 57 <= boundaries[0] <= 63, f"first cut expected ~60, got {boundaries[0]}"
20
+ assert 117 <= boundaries[1] <= 123, f"second cut expected ~120, got {boundaries[1]}"
21
+
22
+
23
+ @pytest.mark.skipif(not os.path.exists(REEL), reason="reel sample missing")
24
+ def test_transnet_detects_at_least_one_cut_in_real_reel():
25
+ boundaries = detect_transnet(REEL, threshold=0.5)
26
+ print(f"\n[info] TransNet on reel_multishot.mp4: {boundaries}")
27
+ assert len(boundaries) >= 1, f"expected at least 1 cut in multi-shot reel, got {boundaries}"
28
+ # PSD found 1 cut near frame 140. TransNet should agree on at least one cut there or nearby.
29
+
30
+
31
+ @pytest.mark.skipif(not os.path.exists(IMBA), reason="imba sample missing")
32
+ def test_transnet_single_shot_returns_empty_or_one():
33
+ boundaries = detect_transnet(IMBA, threshold=0.5)
34
+ print(f"\n[info] TransNet on imba.mp4: {boundaries}")
35
+ # imba.mp4 is a single 8.7s phone video — expect 0, tolerate up to 1 (TransNet is sensitive)
36
+ assert len(boundaries) <= 1, f"expected <=1 cut in single-shot video, got {boundaries}"
ComfyUI-ShotSplitter/tests/test_video_probe.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for video_probe — require a real file on the remote."""
2
+
3
+ import os
4
+ import pytest
5
+
6
+ from video_probe import probe
7
+
8
+ SAMPLE = "/workspace/ComfyUI/input/imba.mp4"
9
+
10
+
11
+ @pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample video not present")
12
+ def test_probe_returns_sensible_values():
13
+ info = probe(SAMPLE)
14
+ assert info.width > 0
15
+ assert info.height > 0
16
+ assert info.fps > 0
17
+ assert info.duration_s > 0
18
+ assert info.total_frames > 0
19
+
20
+
21
+ @pytest.mark.skipif(not os.path.exists(SAMPLE), reason="sample video not present")
22
+ def test_probe_detects_audio_stream_correctly():
23
+ info = probe(SAMPLE)
24
+ # Just check has_audio is a bool (whether True or False depends on the sample)
25
+ assert isinstance(info.has_audio, bool)
26
+ if info.has_audio:
27
+ assert info.audio_codec is not None
28
+ assert info.audio_sample_rate is not None and info.audio_sample_rate > 0
29
+ assert info.audio_channels is not None and info.audio_channels > 0