Update ComfyUI-ShotSplitter: OUTPUT_IS_LIST for clip_paths, upload video button in JS
Browse files- ComfyUI-ShotSplitter/.pytest_cache/CACHEDIR.TAG +4 -0
- ComfyUI-ShotSplitter/.pytest_cache/README.md +8 -0
- ComfyUI-ShotSplitter/.pytest_cache/v/cache/lastfailed +10 -0
- ComfyUI-ShotSplitter/.pytest_cache/v/cache/nodeids +10 -0
- ComfyUI-ShotSplitter/js/shot_splitter_ui.js +117 -8
- ComfyUI-ShotSplitter/shot_splitter_node.py +1 -0
- ComfyUI-ShotSplitter/tests/.pytest_cache/CACHEDIR.TAG +4 -0
- ComfyUI-ShotSplitter/tests/.pytest_cache/README.md +8 -0
- ComfyUI-ShotSplitter/tests/.pytest_cache/v/cache/lastfailed +1 -0
- ComfyUI-ShotSplitter/tests/.pytest_cache/v/cache/nodeids +30 -0
- ComfyUI-ShotSplitter/tests/conftest.py +5 -0
- ComfyUI-ShotSplitter/tests/test_cutter.py +120 -0
- ComfyUI-ShotSplitter/tests/test_ensemble.py +61 -0
- ComfyUI-ShotSplitter/tests/test_first_frame_reader.py +39 -0
- ComfyUI-ShotSplitter/tests/test_node_e2e.py +102 -0
- ComfyUI-ShotSplitter/tests/test_pyscenedetect.py +29 -0
- ComfyUI-ShotSplitter/tests/test_transnet.py +36 -0
- ComfyUI-ShotSplitter/tests/test_video_probe.py +29 -0
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
|
|
|
|
|
|
|
| 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];
|
| 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
|