diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 6cb8cc4b..01b2b41f 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -537,6 +537,11 @@ def _open_video_stream( frame_rate=frame_rate, backend=backend, ) + if not self.video_stream.name: + logger.warning( + "Could not determine a video name from the input path, so $VIDEO_NAME will be" + " empty in output filenames." + ) duration = self.video_stream.duration duration_str = f"{duration} ({duration.frame_num} frames)" if duration else "unknown" rate = self.video_stream.frame_rate diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 65e54436..b47e53b9 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -24,6 +24,7 @@ import string import subprocess import sys +import urllib.parse import cv2 @@ -129,11 +130,50 @@ def _get_cv2_param(param_name: str) -> int | None: ## +def is_url(path: StrPath) -> bool: + """True if `path` looks like a URL or network stream (e.g. https://..., rtsp://...).""" + return "://" in os.fspath(path) + + def get_file_name(file_path: StrPath, include_extension: bool = True) -> str: """Return the file name that `file_path` refers to, optionally removing the extension. - E.g. /tmp/foo.bar -> foo""" - file_name = os.path.basename(os.fspath(file_path)) + E.g. /tmp/foo.bar -> foo + + URL and URI-like inputs: + Inputs containing "://" are treated as URLs (see :func:`is_url`) and handled by their + URL components rather than as paths, since a path separator inside a query string or + fragment is not part of the name. The name is the last non-empty segment of the path + component, percent-decoded, e.g. https://example.com/videos/clip.mp4?token=1.2 -> clip. + + When the path component is empty there is no segment to use, so the host becomes the + name, e.g. rtsp://192.168.0.1:554 -> 192.168.0.1_554. Userinfo (credentials) is never + part of the result, and the port is joined with "_" because ":" is not valid in a file + name on Windows. Hosts are returned in full even when `include_extension` is False, as + the last "." in a host is a domain separator and not an extension. + + Arguments: + file_path: Path, or URL, to get the name of. + include_extension: If False, remove the extension (the last "." and everything after + it) from the name. Has no effect on names derived from a URL host. + + Returns: + The name, or an empty string if none can be derived (e.g. a path that is only + separators, or a URL with neither a path nor a host). + """ + file_path = os.fspath(file_path) + if is_url(file_path): + parsed = urllib.parse.urlsplit(file_path) + segments = [urllib.parse.unquote(seg) for seg in parsed.path.split("/") if seg] + if not segments: + # No path to take a name from, so fall back to the host (never the credentials). A + # host has no extension to remove: stripping after the last "." would turn + # example.com into example. + host = parsed.hostname or "" + return f"{host}_{parsed.port}" if host and parsed.port else host + file_name = segments[-1] + else: + file_name = os.path.basename(file_path) if not include_extension: last_dot_pos = file_name.rfind(".") if last_dot_pos >= 0: diff --git a/tests/test_platform.py b/tests/test_platform.py index e4b3fe18..7157851b 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -19,7 +19,7 @@ import pytest -from scenedetect.platform import CommandTooLong, invoke_command +from scenedetect.platform import CommandTooLong, get_file_name, invoke_command, is_url def test_invoke_command(): @@ -38,3 +38,50 @@ def test_long_command(): if platform.system() == "Windows": with pytest.raises(CommandTooLong): invoke_command(["x" * 2**15]) + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/tmp/foo.bar", "foo"), + ("foo.bar", "foo"), + ("foo", "foo"), + ("https://example.com/videos/clip.mp4", "clip"), + # Query strings and fragments are not part of the name, even if they contain a "." or "/". + ("https://example.com/videos/clip.mp4?token=1.2&dir=a/b", "clip"), + ("https://example.com/videos/clip.mp4#t=10", "clip"), + ("https://example.com/videos/clip.mp4/", "clip"), + ("https://example.com/videos/my%20clip.mp4", "my clip"), + ("rtsp://user:pass@192.168.0.1:554/live/stream1", "stream1"), + # No path component: fall back to the host, which must stay a valid file name. + ("https://example.com", "example.com"), + ("https://example.com/", "example.com"), + ("rtsp://192.168.0.1:554", "192.168.0.1_554"), + ("rtsp://user:pass@192.168.0.1:554", "192.168.0.1_554"), + # Neither a path nor a host to derive a name from. + ("rtsp://", ""), + ("rtsp://:554", ""), + ], +) +def test_get_file_name_without_extension(path: str, expected: str): + assert get_file_name(path, include_extension=False) == expected + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/tmp/foo.bar", "foo.bar"), + ("https://example.com/videos/clip.mp4?token=1", "clip.mp4"), + ("https://example.com/", "example.com"), + ], +) +def test_get_file_name_with_extension(path: str, expected: str): + assert get_file_name(path, include_extension=True) == expected + + +def test_is_url(): + assert is_url("https://example.com/clip.mp4") + assert is_url("rtsp://192.168.0.1:554/live") + assert not is_url("/tmp/clip.mp4") + assert not is_url(r"C:\videos\clip.mp4") + assert not is_url("img_%04d.jpg") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a1c6fbbe..c5727f10 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -5,6 +5,7 @@ ### Unreleased + - [bugfix] `$VIDEO_NAME` is now derived from the path component of URL inputs, ignoring the query string and fragment (e.g. `https://host/clip.mp4?token=1.2` -> `clip`). URLs with no path use the host. Previously the query string leaked into output filenames, which fails on Windows. [#197](https://github.com/Breakthrough/PySceneDetect/issues/197) - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580) ### PySceneDetect 0.7.2 (October 11, 2026)