From 94ca31822812928e2eceac50969627c4aaa1897a Mon Sep 17 00:00:00 2001 From: Justin Leader Date: Sat, 14 Mar 2026 06:55:53 -0700 Subject: [PATCH] Fix clip scan deduplication by display name --- backend/clip_state.py | 30 +++++++++++++++++++--------- tests/test_clip_state.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/test_clip_state.py diff --git a/backend/clip_state.py b/backend/clip_state.py index f0c435e1..dd150176 100644 --- a/backend/clip_state.py +++ b/backend/clip_state.py @@ -441,7 +441,22 @@ def scan_clips_dir( if is_v2_project(clips_dir): return scan_project_clips(clips_dir) - seen_names: set[str] = set() + seen_entries: set[tuple[str, str]] = set() + valid_folder_names: set[str] = set() + + def add_entry(clip: ClipEntry) -> None: + """Track entries by on-disk identity so display-name collisions don't hide clips.""" + if clip.input_asset and clip.root_path == clips_dir and clip.input_asset.asset_type == "video": + identity_path = clip.input_asset.path + else: + identity_path = clip.root_path + + key = ("clip", os.path.normcase(os.path.abspath(identity_path))) + if key in seen_entries: + return + + entries.append(clip) + seen_entries.add(key) for item in sorted(os.listdir(clips_dir)): item_path = os.path.join(clips_dir, item) @@ -457,16 +472,14 @@ def scan_clips_dir( if is_v2_project(item_path): # v2 project: scan its clips/ subdirectory for clip in scan_project_clips(item_path): - if clip.name not in seen_names: - entries.append(clip) - seen_names.add(clip.name) + add_entry(clip) else: # Flat clip dir or v1 project clip = ClipEntry(name=item, root_path=item_path) try: clip.find_assets() - entries.append(clip) - seen_names.add(clip.name) + add_entry(clip) + valid_folder_names.add(item) except ClipScanError as e: # Skip folders without valid input assets logger.debug(str(e)) @@ -474,13 +487,12 @@ def scan_clips_dir( elif allow_standalone_videos and os.path.isfile(item_path) and _is_video_file(item_path): # Standalone video file → treat as a clip needing extraction stem = os.path.splitext(item)[0] - if stem in seen_names: + if stem in valid_folder_names: continue # folder clip already exists with this name clip = ClipEntry(name=stem, root_path=clips_dir) clip.input_asset = ClipAsset(item_path, "video") clip.state = ClipState.EXTRACTING - entries.append(clip) - seen_names.add(stem) + add_entry(clip) logger.info(f"Scanned {clips_dir}: {len(entries)} clip(s) found") return entries diff --git a/tests/test_clip_state.py b/tests/test_clip_state.py new file mode 100644 index 00000000..bada811a --- /dev/null +++ b/tests/test_clip_state.py @@ -0,0 +1,43 @@ +"""Regression tests for backend.clip_state scanning behavior.""" + +from __future__ import annotations + +import os + +from backend.clip_state import scan_clips_dir +from backend.project import write_clip_json + + +def _write_frame_sequence(sequence_dir, count: int = 1) -> None: + sequence_dir.mkdir(parents=True, exist_ok=True) + for i in range(count): + (sequence_dir / f"frame_{i:04d}.png").touch() + + +def test_scan_clips_dir_keeps_v2_clips_with_same_display_name(tmp_path): + """Different clips must not collapse just because their display names match.""" + shared_name = "Hero Plate" + + for project_name, clip_name in (("project_a", "shot_a"), ("project_b", "shot_b")): + clip_root = tmp_path / project_name / "clips" / clip_name + _write_frame_sequence(clip_root / "Frames") + write_clip_json(str(clip_root), {"display_name": shared_name}) + + entries = scan_clips_dir(str(tmp_path), allow_standalone_videos=False) + + assert len(entries) == 2 + assert [entry.name for entry in entries] == [shared_name, shared_name] + assert {os.path.basename(entry.root_path) for entry in entries} == {"shot_a", "shot_b"} + + +def test_scan_clips_dir_still_prefers_folder_clip_over_loose_video(tmp_path): + """A valid clip folder should still win over a loose video with the same stem.""" + clip_root = tmp_path / "hero" + _write_frame_sequence(clip_root / "Input") + (tmp_path / "hero.mp4").touch() + + entries = scan_clips_dir(str(tmp_path)) + + assert len(entries) == 1 + assert entries[0].root_path == str(clip_root) + assert entries[0].name == "hero"