diff --git a/app/services/director_pipeline.py b/app/services/director_pipeline.py index bfb6e209..3df1682e 100644 --- a/app/services/director_pipeline.py +++ b/app/services/director_pipeline.py @@ -4356,6 +4356,19 @@ def _rejoin_clips_impl(out_dir: str, pid: str) -> dict: state = _ensure_h3_segment_state(state) clips = state.get("clips", []) video_files = [] + # Image reruns keep video_stale on the clip even when a Studio selection or + # H3 segment list still points at playable files. Gate Rejoin before the + # H3 branch, which otherwise treats those files as current. + stale_clip_numbers = [ + str(index + 1) + for index, clip in enumerate(clips) + if clip.get("video_stale") + ] + if stale_clip_numbers: + raise ValueError( + "Regenerate stale video clip(s) " + f"{', '.join(stale_clip_numbers)} before rejoining." + ) legacy_h3_segments = ( _is_sequential_h3_model(state.get("video_model")) and any(clip.get("h3_segments") for clip in clips) @@ -4390,17 +4403,6 @@ def _rejoin_clips_impl(out_dir: str, pid: str) -> dict: if stale: raise ValueError("Regenerate stale H3 continuations before rejoining the final video") else: - stale_clip_numbers = [ - str(index + 1) - for index, clip in enumerate(clips) - if clip.get("video_stale") - ] - if stale_clip_numbers: - raise ValueError( - "Regenerate stale video clip(s) " - f"{', '.join(stale_clip_numbers)} before rejoining." - ) - if shot_images_required(_saved_pipeline_shot_image_policy(state)): invalid_start_numbers = _invalid_saved_media_numbers( [clip.get("start_image_filename") for clip in clips], diff --git a/tests/test_director_h3_workflow_edits.py b/tests/test_director_h3_workflow_edits.py index 525e5834..831d68e9 100644 --- a/tests/test_director_h3_workflow_edits.py +++ b/tests/test_director_h3_workflow_edits.py @@ -2,6 +2,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from app.services import director_pipeline @@ -346,3 +348,51 @@ def concatenate_multi_clip_videos(paths, destination, _audio, **_kwargs): director_pipeline.rejoin_clips(str(tmp_path), "h3-selection") assert joined == ["shot0_studio.mp4", "shot1.mp4"] + + +def test_h3_rejoin_rejects_stale_clip_even_when_segments_are_playable(tmp_path: Path): + filenames = ("shot0_a.mp4", "shot0_b.mp4", "shot1.mp4") + for filename in filenames: + (tmp_path / filename).write_bytes(b"video") + _write_pipeline(tmp_path, { + "pipeline_id": "h3-stale-rejoin", + "created_at": 10.0, + "status": "completed", + "pipeline_type": "short_film_story", + "video_model": "minimax_h3", + "clips": [ + { + "index": 0, + "video_filename": "shot0_b.mp4", + "video_stale": True, + "video_prompt": "Whole shot zero", + "h3_segments": [ + {"index": 0, "filename": "shot0_a.mp4", "stale": False}, + {"index": 1, "filename": "shot0_b.mp4", "stale": False}, + ], + }, + { + "index": 1, + "video_filename": "shot1.mp4", + "video_prompt": "Whole shot one", + "h3_segments": [ + {"index": 0, "filename": "shot1.mp4", "stale": False}, + ], + }, + ], + "output_files": list(filenames), + "workspace": "default", + }) + joined = [] + + class FakeWgp: + @staticmethod + def concatenate_multi_clip_videos(paths, destination, _audio, **_kwargs): + joined.extend(Path(path).name for path in paths) + Path(destination).write_bytes(b"joined") + return True + + with patch.object(director_pipeline, "_wgp", FakeWgp()): + with pytest.raises(ValueError, match="stale video clip.*1.*before rejoining"): + director_pipeline.rejoin_clips(str(tmp_path), "h3-stale-rejoin") + assert joined == []