Skip to content

Commit a68e947

Browse files
authored
Merge branch 'main' into issue-496-deflake-moviepy-tests
2 parents b28460a + 129ec32 commit a68e947

16 files changed

Lines changed: 228 additions & 139 deletions

File tree

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,68 @@
11
name: 'Setup FFmpeg'
2+
description: 'Ensure ffmpeg is available on the runner, using OS package managers as a fallback.'
23
inputs:
34
github-token:
4-
required: true
5+
description: 'Unused; kept for backward compatibility with existing callers.'
6+
required: false
7+
default: ''
58

69
runs:
710
using: 'composite'
811
steps:
9-
- name: Setup FFmpeg (latest)
10-
id: latest
11-
continue-on-error: true
12-
uses: FedericoCarboni/setup-ffmpeg@v3
13-
with:
14-
github-token: ${{ inputs.github-token }}
12+
- name: Check for preinstalled ffmpeg
13+
id: check
14+
shell: bash
15+
run: |
16+
if command -v ffmpeg >/dev/null 2>&1; then
17+
echo "ffmpeg already available at: $(command -v ffmpeg)"
18+
ffmpeg -version | head -n 1
19+
echo "installed=true" >> "$GITHUB_OUTPUT"
20+
else
21+
echo "ffmpeg not found on PATH; will install via package manager."
22+
echo "installed=false" >> "$GITHUB_OUTPUT"
23+
fi
1524
16-
- name: Setup FFmpeg (7.0.0)
17-
if: ${{ steps.latest.outcome == 'failure' }}
18-
id: v7-0-0
19-
continue-on-error: true
20-
uses: FedericoCarboni/setup-ffmpeg@v3
21-
with:
22-
github-token: ${{ inputs.github-token }}
23-
ffmpeg-version: "7.0.0"
25+
- name: Install ffmpeg (Linux)
26+
if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }}
27+
shell: bash
28+
run: |
29+
for attempt in 1 2 3; do
30+
echo "apt-get attempt $attempt"
31+
if sudo apt-get update && sudo apt-get install -y ffmpeg; then
32+
exit 0
33+
fi
34+
sleep 10
35+
done
36+
echo "Failed to install ffmpeg via apt-get after 3 attempts" >&2
37+
exit 1
2438
25-
- name: Setup FFmpeg (6.1.1)
26-
if: ${{ steps.v7-0-0.outcome == 'failure' }}
27-
id: v6-1-1
28-
continue-on-error: true
29-
uses: FedericoCarboni/setup-ffmpeg@v3
30-
with:
31-
github-token: ${{ inputs.github-token }}
32-
ffmpeg-version: "6.1.1"
39+
- name: Install ffmpeg (macOS)
40+
if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }}
41+
shell: bash
42+
run: |
43+
for attempt in 1 2 3; do
44+
echo "brew attempt $attempt"
45+
if brew install ffmpeg; then
46+
exit 0
47+
fi
48+
sleep 10
49+
done
50+
echo "Failed to install ffmpeg via brew after 3 attempts" >&2
51+
exit 1
3352
34-
# The oldest version we allow falling back to must not have `continue-on-error: true`
35-
- name: Setup FFmpeg (6.1.0)
36-
if: ${{ steps.v6-1-1.outcome == 'failure' }}
37-
id: v6-1-0
38-
uses: FedericoCarboni/setup-ffmpeg@v3
39-
with:
40-
github-token: ${{ inputs.github-token }}
41-
ffmpeg-version: "6.1.0"
53+
- name: Install ffmpeg (Windows)
54+
if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }}
55+
shell: pwsh
56+
run: |
57+
for ($attempt = 1; $attempt -le 3; $attempt++) {
58+
Write-Host "choco attempt $attempt"
59+
choco install ffmpeg -y --no-progress
60+
if ($LASTEXITCODE -eq 0) { exit 0 }
61+
Start-Sleep -Seconds 10
62+
}
63+
Write-Error "Failed to install ffmpeg via choco after 3 attempts"
64+
exit 1
65+
66+
- name: Verify ffmpeg
67+
shell: bash
68+
run: ffmpeg -version | head -n 1

.github/workflows/build.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ jobs:
3636
- uses: actions/checkout@v4
3737

3838
- name: Setup FFmpeg
39-
# TODO: This action currently does not work for non-x64 builders (e.g. macos-14):
40-
# https://github.com/federicocarboni/setup-ffmpeg/issues/21
41-
if: ${{ runner.arch == 'X64' }}
4239
uses: ./.github/actions/setup-ffmpeg
4340
with:
4441
github-token: ${{ secrets.GITHUB_TOKEN }}

scenedetect.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@
227227
# Compression amount for png images (0 to 9). Only affects size, not quality.
228228
#compression = 3
229229

230-
# Number of frames to ignore around each scene cut when selecting frames.
230+
# Padding around each scene cut when selecting frames. Accepts a number of frames (1),
231+
# seconds with `s` suffix (0.1s), or timecode (00:00:00.100).
231232
#frame-margin = 1
232233

233234
# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double).

scenedetect/_cli/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,11 +1397,11 @@ def split_video_command(
13971397
@click.option(
13981398
"-m",
13991399
"--frame-margin",
1400-
metavar="N",
1400+
metavar="DURATION",
14011401
default=None,
1402-
type=click.INT,
1403-
help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s"
1404-
% (USER_CONFIG.get_help_string("save-images", "num-images")),
1402+
type=click.STRING,
1403+
help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).%s"
1404+
% (USER_CONFIG.get_help_string("save-images", "frame-margin")),
14051405
)
14061406
@click.option(
14071407
"--scale",
@@ -1441,7 +1441,7 @@ def save_images_command(
14411441
quality: ty.Optional[int] = None,
14421442
png: bool = False,
14431443
compression: ty.Optional[int] = None,
1444-
frame_margin: ty.Optional[int] = None,
1444+
frame_margin: ty.Optional[str] = None,
14451445
scale: ty.Optional[float] = None,
14461446
height: ty.Optional[int] = None,
14471447
width: ty.Optional[int] = None,

scenedetect/_cli/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ class XmlFormat(Enum):
412412
"compression": RangeValue(3, min_val=0, max_val=9),
413413
"filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER",
414414
"format": "jpeg",
415-
"frame-margin": 1,
415+
"frame-margin": TimecodeValue(1),
416416
"height": 0,
417417
"num-images": 3,
418418
"output": None,

scenedetect/detector.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
event (in, out, cut, etc...).
2525
"""
2626

27+
import math
2728
import typing as ty
2829
from abc import ABC, abstractmethod
2930
from enum import Enum
@@ -114,26 +115,48 @@ class Mode(Enum):
114115
SUPPRESS = 1
115116
"""Suppress consecutive cuts until the filter length has passed."""
116117

117-
def __init__(self, mode: Mode, length: int):
118+
def __init__(self, mode: Mode, length: ty.Union[int, float, str]):
118119
"""
119120
Arguments:
120121
mode: The mode to use when enforcing `length`.
121-
length: Number of frames to use when filtering cuts.
122+
length: Minimum scene length. Accepts an `int` (number of frames), `float` (seconds),
123+
or `str` (timecode, e.g. ``"0.6s"`` or ``"00:00:00.600"``).
122124
"""
123125
self._mode = mode
124-
self._filter_length = length # Number of frames to use for activating the filter.
125-
self._filter_secs: ty.Optional[float] = None # Threshold in seconds, computed on first use.
126+
# Frame count (int) and seconds (float) representations of `length`. Exactly one is
127+
# populated up front; the other is computed on the first frame once the framerate is
128+
# known. Temporal inputs (float/non-digit str) populate `_filter_secs`; integer inputs
129+
# (int/digit str) populate `_filter_length`.
130+
self._filter_length: int = 0
131+
self._filter_secs: ty.Optional[float] = None
132+
if isinstance(length, float):
133+
self._filter_secs = length
134+
elif isinstance(length, str) and not length.strip().isdigit():
135+
self._filter_secs = FrameTimecode(timecode=length, fps=100.0).seconds
136+
else:
137+
self._filter_length = int(length)
126138
self._last_above = None # Last frame above threshold.
127139
self._merge_enabled = False # Used to disable merging until at least one cut was found.
128140
self._merge_triggered = False # True when the merge filter is active.
129141
self._merge_start = None # Frame number where we started the merge filter.
130142

131143
@property
132144
def max_behind(self) -> int:
133-
return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length
145+
if self._mode == FlashFilter.Mode.SUPPRESS:
146+
return 0
147+
if self._filter_secs is not None:
148+
# Estimate using 240fps so the event buffer is large enough for any reasonable input.
149+
return math.ceil(self._filter_secs * 240.0)
150+
return self._filter_length
151+
152+
@property
153+
def _is_disabled(self) -> bool:
154+
if self._filter_secs is not None:
155+
return self._filter_secs <= 0.0
156+
return self._filter_length <= 0
134157

135158
def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]:
136-
if not self._filter_length > 0:
159+
if self._is_disabled:
137160
return [timecode] if above_threshold else []
138161
if self._last_above is None:
139162
self._last_above = timecode

scenedetect/detectors/adaptive_detector.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class AdaptiveDetector(ContentDetector):
3838
def __init__(
3939
self,
4040
adaptive_threshold: float = 3.0,
41-
min_scene_len: int = 15,
41+
min_scene_len: ty.Union[int, float, str] = 15,
4242
window_width: int = 2,
4343
min_content_val: float = 15.0,
4444
weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS,
@@ -49,8 +49,9 @@ def __init__(
4949
Arguments:
5050
adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a
5151
new scene (see frame metric adaptive_ratio in stats file).
52-
min_scene_len: Once a cut is detected, this many frames must pass before a new one can
53-
be added to the scene list. Can be an int or FrameTimecode type.
52+
min_scene_len: Once a cut is detected, this much time must pass before a new one can
53+
be added to the scene list. Accepts an int (frames), float (seconds), or
54+
str (e.g. ``"0.6s"``, ``"00:00:00.600"``).
5455
window_width: Size of window (number of frames) before and after each frame to
5556
average together in order to detect deviations from the mean. Must be at least 1.
5657
min_content_val: Minimum threshold (float) that the content_val must exceed in order to

scenedetect/detectors/content_detector.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class _FrameData:
104104
def __init__(
105105
self,
106106
threshold: float = 27.0,
107-
min_scene_len: int = 15,
107+
min_scene_len: ty.Union[int, float, str] = 15,
108108
weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS,
109109
luma_only: bool = False,
110110
kernel_size: ty.Optional[int] = None,
@@ -113,8 +113,9 @@ def __init__(
113113
"""
114114
Arguments:
115115
threshold: Threshold the average change in pixel intensity must exceed to trigger a cut.
116-
min_scene_len: Once a cut is detected, this many frames must pass before a new one can
117-
be added to the scene list. Can be an int or FrameTimecode type.
116+
min_scene_len: Once a cut is detected, this much time must pass before a new one can
117+
be added to the scene list. Accepts an int (frames), float (seconds), or
118+
str (e.g. ``"0.6s"``, ``"00:00:00.600"``).
118119
weights: Weight to place on each component when calculating frame score
119120
(`content_val` in a statsfile, the value `threshold` is compared against).
120121
luma_only: If True, only considers changes in the luminance channel of the video.

scenedetect/detectors/hash_detector.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,17 @@ class HashDetector(SceneDetector):
4141
size: Size of square of low frequency data to use for the DCT
4242
lowpass: How much high frequency information to filter from the DCT. A value of 2 means
4343
keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc...
44-
min_scene_len: Once a cut is detected, this many frames must pass before a new one can
45-
be added to the scene list. Can be an int or FrameTimecode type.
44+
min_scene_len: Once a cut is detected, this much time must pass before a new one can
45+
be added to the scene list. Accepts an int (frames), float (seconds), or
46+
str (e.g. ``"0.6s"``, ``"00:00:00.600"``).
4647
"""
4748

4849
def __init__(
4950
self,
5051
threshold: float = 0.395,
5152
size: int = 16,
5253
lowpass: int = 2,
53-
min_scene_len: int = 15,
54+
min_scene_len: ty.Union[int, float, str] = 15,
5455
):
5556
super(HashDetector, self).__init__()
5657
self._threshold = threshold

scenedetect/detectors/histogram_detector.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,22 @@ class HistogramDetector(SceneDetector):
3030

3131
METRIC_KEYS = ["hist_diff"]
3232

33-
def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15):
33+
def __init__(
34+
self,
35+
threshold: float = 0.05,
36+
bins: int = 256,
37+
min_scene_len: ty.Union[int, float, str] = 15,
38+
):
3439
"""
3540
Arguments:
3641
threshold: maximum relative difference between 0.0 and 1.0 that the histograms can
3742
differ. Histograms are calculated on the Y channel after converting the frame to
3843
YUV, and normalized based on the number of bins. Higher dicfferences imply greater
3944
change in content, so larger threshold values are less sensitive to cuts.
4045
bins: Number of bins to use for the histogram.
41-
min_scene_len: Once a cut is detected, this many frames must pass before a new one can
42-
be added to the scene list. Can be an int or FrameTimecode type.
46+
min_scene_len: Once a cut is detected, this much time must pass before a new one can
47+
be added to the scene list. Accepts an int (frames), float (seconds), or
48+
str (e.g. ``"0.6s"``, ``"00:00:00.600"``).
4349
"""
4450
super().__init__()
4551
# Internally, threshold represents the correlation between two histograms and has values

0 commit comments

Comments
 (0)