Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/plugin/python_plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ add_python_plugin(on_screen_version_name)
add_python_plugin(viewport_field_chart)
add_python_plugin(annotations_exporter)
add_python_plugin(basic_viewport_mask)
add_python_plugin(utility)
add_python_plugin(utility)
add_python_plugin(scene_cut_detector)
50 changes: 50 additions & 0 deletions src/plugin/python_plugins/scene_cut_detector/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Scene Cut Detector

This experimental Python plugin detects hard scene cuts in a selected video and creates a new xSTUDIO Timeline with one sub-clip per detected scene.

## Requirements

- A Windows xSTUDIO build with the Python plugin support enabled.
- `ffmpeg.exe` available on `PATH`.
- `numpy` available to xSTUDIO's bundled Python environment.

The plugin intentionally uses an `ffmpeg` subprocess instead of `opencv-python` or PyAV. This keeps the runtime dependency small and avoids relying on compiled Python video-decoding wheels.

## Usage

1. Start the development build with `build/run_xstudio.bat`.
2. Add a video to a Playlist and select it.
3. Right-click the media item and choose **Detect Scene Cuts...**.
4. Choose a detector mode, threshold, and minimum scene length.
5. Click **Detect Scene Cuts**.

The result is a new Timeline in the inspected Playlist. The source media is registered on that Timeline and split at the detected frame numbers.

## Parameters

- **Content (fast cuts)** compares each frame with the previous frame using a fixed mean-pixel-difference threshold.
- **Adaptive (camera motion)** compares a frame against a rolling average of recent differences, which can help when the source contains camera movement.
- **Cut Threshold** controls how large a frame change must be. Lower values detect more changes; higher values reduce false positives.
- **Minimum Scene Length (frames)** prevents cuts from being placed too close together.

Detection currently runs synchronously, so the xSTUDIO UI may be unresponsive while a long source is scanned. This is a known limitation of the first version.

## Development

The pure detection and URI parsing helpers are in `detector.py`. They can be tested without a running xSTUDIO session:

```powershell
python -m pytest src/plugin/python_plugins/scene_cut_detector/test/test_scene_cut_detector.py
```

For a development build, redeploy the Python plugin after editing:

```powershell
cmake --build build --target COPY_PY_PLUGIN_scene_cut_detector
```

Then restart `build/run_xstudio.bat -n`.

## Review status

This is an initial contribution under review. Known follow-up work includes background processing with a thread-safe xSTUDIO API boundary, richer detector options, timeline/audio handling, and packaging/runtime discovery of `ffmpeg`.
9 changes: 9 additions & 0 deletions src/plugin/python_plugins/scene_cut_detector/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
try:
from .scene_cut_detector_plugin import create_plugin_instance
except ImportError:
# The 'xstudio' package is only available inside xSTUDIO's embedded
# Python environment, not when running the unit tests in test/
# standalone. Importing this package must not fail in that case.
create_plugin_instance = None

58 changes: 58 additions & 0 deletions src/plugin/python_plugins/scene_cut_detector/detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: Apache-2.0
"""Pure helpers for Scene Cut Detector."""

from urllib.parse import unquote

import numpy as np

DETECTOR_OPTIONS = ["Content (fast cuts)", "Adaptive (camera motion)"]


def uri_to_local_path(uri_str):
"""Convert an xSTUDIO file URI to a local filesystem path."""
path_part = uri_str
if path_part.startswith("file://"):
path_part = path_part[len("file://"):]
if path_part.startswith("localhost"):
path_part = path_part[len("localhost"):]
elif path_part.startswith("file:"):
path_part = path_part[len("file:"):]

path_part = unquote(path_part).lstrip("/")
if len(path_part) > 1 and path_part[1] == ":":
return path_part
return "/" + path_part


def compute_cut_frames(frames, detector_name, threshold, min_scene_len):
"""Return frame numbers where consecutive frames indicate a scene cut."""
cut_frames = []
recent_diffs = []
previous_frame = None
frame_index = 0
last_cut_frame = -min_scene_len

for frame in frames:
frame = np.asarray(frame, dtype=np.float32)

if previous_frame is not None:
diff = float(np.abs(frame - previous_frame).mean())

if detector_name == DETECTOR_OPTIONS[1] and recent_diffs:
rolling_mean = sum(recent_diffs) / len(recent_diffs)
is_cut = diff > threshold and diff > 3.0 * max(rolling_mean, 1.0)
else:
is_cut = diff > threshold

if is_cut and (frame_index - last_cut_frame) >= min_scene_len:
cut_frames.append(frame_index)
last_cut_frame = frame_index

recent_diffs.append(diff)
if len(recent_diffs) > 15:
recent_diffs.pop(0)

previous_frame = frame
frame_index += 1

return cut_frames
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: Apache-2.0
import QtQuick
import QtQuick.Layouts

import xstudio.qml.models 1.0
import xStudio 1.0

XsWindow {

id: dialog
width: 420
title: "Detect Scene Cuts"
minimumWidth: 400
minimumHeight: 260

flags: Qt.platform.os === "windows" ? Qt.Window : Qt.Dialog

property var widgetHeight: 24

XsModuleData {
id: plugin_attrs
modelDataName: "scene_cut_detector_attrs"
}
XsAttributeValue {
id: threshold
attributeTitle: "Cut Threshold"
model: plugin_attrs
}
XsAttributeValue {
id: min_scene_len
attributeTitle: "Minimum Scene Length (frames)"
model: plugin_attrs
}

ColumnLayout {

width: parent.width

GridLayout {

Layout.fillWidth: true
Layout.margins: 20
columnSpacing: 12
rowSpacing: 14
columns: 2

XsText {
text: "Detector"
Layout.alignment: Qt.AlignRight
}

XsAttrComboBox {
id: detectorChoice
Layout.alignment: Qt.AlignLeft
Layout.minimumWidth: 220
Layout.preferredHeight: widgetHeight
attr_title: "Detector"
attr_model_name: "scene_cut_detector_attrs"
}

XsText {
text: "Cut Threshold"
Layout.alignment: Qt.AlignRight
}

XsTextField {
id: thresholdField
Layout.preferredWidth: 220
Layout.preferredHeight: widgetHeight
text: threshold.value != undefined ? threshold.value.toString() : ""
onEditingFinished: threshold.value = parseFloat(text)
onAccepted: threshold.value = parseFloat(text)
}

XsText {
text: "Min Scene Length (frames)"
Layout.alignment: Qt.AlignRight
}

XsTextField {
id: minSceneLenField
Layout.preferredWidth: 220
Layout.preferredHeight: widgetHeight
text: min_scene_len.value != undefined ? min_scene_len.value.toString() : ""
onEditingFinished: min_scene_len.value = parseInt(text)
onAccepted: min_scene_len.value = parseInt(text)
}
}
}

RowLayout {

spacing: 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 10

Item {
Layout.fillWidth: true
}

XsSimpleButton {
Layout.alignment: Qt.AlignRight
text: qsTr("Cancel")
width: XsStyleSheet.primaryButtonStdWidth*2
onClicked: {
dialog.hide()
dialog.destroy()
}
}

XsSimpleButton {
Layout.alignment: Qt.AlignRight
Layout.leftMargin: 5
text: qsTr("Detect Scene Cuts")
width: XsStyleSheet.primaryButtonStdWidth*3
onClicked: {

var return_val = python_callback("run_from_dialog")

if (Array.isArray(return_val)) {
// run_from_dialog returns [True, message]
dialogHelpers.messageDialogFunc("Detect Scene Cuts", return_val[1], "Ok")
if (return_val[0] == true) {
dialog.visible = false
}
} else {
// report (likely) error of some sort
dialogHelpers.errorDialogFunc("Detect Scene Cuts", return_val)
}
}
}
}
}
Loading
Loading