From acf3c516e67730574f860fff3c4d3fff8aafc446 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 18:29:09 +0000 Subject: [PATCH] Fix review/rejoin treating a selected stale take as current Backfill used a Studio selection as playback authority and also cleared video_stale. After an image rerun the selected take no longer matches the new start frame, but load, prompt edits, and review persist then let Rejoin assemble that outdated video. Keep the selection for playback and leave video_stale for callers that gate export. Co-authored-by: ignaciodelcano+dcl --- app/services/director_pipeline.py | 4 ++- tests/test_director_cancellation.py | 40 +++++++++++++++++++++++++++++ tests/test_director_review.py | 22 ++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/services/director_pipeline.py b/app/services/director_pipeline.py index bfb6e2091..c47b19286 100644 --- a/app/services/director_pipeline.py +++ b/app/services/director_pipeline.py @@ -2192,8 +2192,10 @@ def _backfill_clip_video_attempts(state: dict, state_dir: str) -> dict: selected = "" clip["selected_video_filename"] = selected or None if selected: + # A Studio selection is the playback authority, but it does not + # refresh inputs. Image reruns keep video_stale so Rejoin/export + # cannot assemble a take that no longer matches the start frame. clip["video_filename"] = selected - clip["video_stale"] = False clip["video_attempts"] = sorted( attempts_by_clip[index].values(), key=lambda item: (float(item.get("created_at") or 0), item["filename"]), diff --git a/tests/test_director_cancellation.py b/tests/test_director_cancellation.py index 65fef171c..088cda306 100644 --- a/tests/test_director_cancellation.py +++ b/tests/test_director_cancellation.py @@ -1153,6 +1153,46 @@ def test_rejoin_rejects_stale_video_instead_of_omitting_clip(self): concatenate.assert_not_called() + def test_rejoin_rejects_stale_video_even_when_a_take_is_selected(self): + pid = "pipe-stale-selected-rejoin" + record = self._add_pipeline(pid, "completed") + record["clip_plans"] = [ + {"image_prompt": "one", "video_prompt": "one"}, + {"image_prompt": "two", "video_prompt": "two"}, + ] + record["_clip_video_files"] = ["one.mp4", "two.mp4"] + for filename in record["_clip_video_files"]: + self._write_media(filename, b"video") + self.assertTrue(pipeline._save_pipeline_state(pid)) + + def mark_selected_stale(state): + clip = state["clips"][0] + clip["selected_video_filename"] = clip["video_filename"] + clip["video_stale"] = True + + pipeline._update_saved_pipeline(self.temp_dir.name, pid, mark_selected_stale) + loaded = pipeline.load_pipeline_state(self.temp_dir.name, pid) + self.assertTrue(loaded["clips"][0]["video_stale"]) + self.assertEqual(loaded["clips"][0]["selected_video_filename"], "one.mp4") + + def keep_notes(state): + state["clips"][0]["review_notes"] = "keep stale" + + pipeline._update_saved_pipeline(self.temp_dir.name, pid, keep_notes) + raw_path = pipeline._find_pipeline_file(self.temp_dir.name, pid) + with open(raw_path, encoding="utf-8") as handle: + saved = json.load(handle) + self.assertTrue(saved["clips"][0]["video_stale"]) + self.assertEqual(saved["clips"][0]["review_notes"], "keep stale") + + concatenate = Mock(return_value=True) + pipeline._wgp.concatenate_multi_clip_videos = concatenate + with self.assertRaisesRegex( + ValueError, "stale video clip.*1.*before rejoining", + ): + pipeline.rejoin_clips(self.temp_dir.name, pid) + concatenate.assert_not_called() + def test_rejoin_rejects_clip_whose_start_image_is_missing(self): pid = "pipe-missing-rejoin-start" record = self._add_pipeline(pid, "completed") diff --git a/tests/test_director_review.py b/tests/test_director_review.py index 3b08f2c1c..b94fdffb5 100644 --- a/tests/test_director_review.py +++ b/tests/test_director_review.py @@ -75,3 +75,25 @@ def test_switching_an_approved_take_updates_h3_selection_without_approving_it(tm assert saved['clips'][0]['h3_segments'][0]['filename'] == 'old.mp4' assert saved['clips'][0]['h3_segments'][0]['stale'] is False assert 'old.mp4' in saved['output_files'] + + +def test_review_notes_keep_a_stale_selected_take_stale(tmp_path): + path, state = fixture(tmp_path) + state['clips'][0].update( + selected_video_filename='old.mp4', + video_filename='old.mp4', + video_stale=True, + tag='good', + ) + path.write_text(json.dumps(state)) + loaded = pipeline.load_pipeline_state(str(tmp_path), 'review-test') + assert loaded['clips'][0]['video_stale'] is True + assert loaded['clips'][0]['selected_video_filename'] == 'old.mp4' + save_review(str(tmp_path), 'review-test', [ + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'keep stale'}, + ]) + saved = json.loads(path.read_text()) + assert saved['clips'][0]['video_stale'] is True + assert saved['clips'][0]['selected_video_filename'] == 'old.mp4' + assert saved['clips'][0]['review_notes'] == 'keep stale' + assert saved['clips'][0]['tag'] == 'good'