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
30 changes: 21 additions & 9 deletions backend/clip_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -457,30 +472,27 @@ 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))

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
43 changes: 43 additions & 0 deletions tests/test_clip_state.py
Original file line number Diff line number Diff line change
@@ -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"