|
| 1 | +"""#128: a clip made with captions off got them back at its first edit. Off |
| 2 | +lived only in the job's settings, and an editor re-render uses the app's, so |
| 3 | +the clip now keeps the job's choice (`job_captions`), and the editor's |
| 4 | +Captions tab has a switch of its own (`captions`), which wins.""" |
| 5 | + |
| 6 | +import json |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +import pytest |
| 10 | + |
| 11 | +ROOT = Path(__file__).resolve().parent.parent |
| 12 | +EDITOR = ROOT / "ui" / "src" / "renderer" / "src" / "components" / "TimelineEditor.tsx" |
| 13 | +API_TS = ROOT / "ui" / "src" / "renderer" / "src" / "lib" / "api.ts" |
| 14 | + |
| 15 | + |
| 16 | +def _cfg(captions=True): |
| 17 | + return {"clips": {"captions": captions}} |
| 18 | + |
| 19 | + |
| 20 | +@pytest.fixture |
| 21 | +def pipeline(): |
| 22 | + pytest.importorskip("numpy", reason="core.pipeline needs numpy, which CI does not install") |
| 23 | + from core import pipeline |
| 24 | + |
| 25 | + return pipeline |
| 26 | + |
| 27 | + |
| 28 | +@pytest.mark.parametrize("captions, opts, expected", [ |
| 29 | + (True, {}, True), |
| 30 | + (False, {}, False), |
| 31 | + (True, {"job_captions": False}, False), # an editor re-render of an off clip |
| 32 | + (True, {"job_captions": False, "captions": True}, True), # the switch turned back on |
| 33 | + (True, {"captions": False}, False), # the switch turned off |
| 34 | +]) |
| 35 | +def test_whether_a_clip_burns_captions(pipeline, captions, opts, expected): |
| 36 | + assert pipeline.captions_on(_cfg(captions), opts) is expected |
| 37 | + |
| 38 | + |
| 39 | +def test_the_jobs_choice_is_not_an_edit_by_hand(pipeline): |
| 40 | + # "Make it again with my edits" must not take every clip of a captions-off |
| 41 | + # job for one a person edited, nor carry the old job's choice to a new run. |
| 42 | + assert not pipeline._edited({"job_captions": False}) |
| 43 | + assert "job_captions" in pipeline._JOB_DECIDES |
| 44 | + |
| 45 | + |
| 46 | +@pytest.fixture |
| 47 | +def render(pipeline, monkeypatch, tmp_path): |
| 48 | + pytest.importorskip("cv2") |
| 49 | + from core import modes |
| 50 | + from core.models import ClipCandidate |
| 51 | + |
| 52 | + burned = [] |
| 53 | + |
| 54 | + def fake_cut(source, candidate, output, ass_path=None, vf_extra="", normalize=True): |
| 55 | + burned.append(ass_path) |
| 56 | + Path(output).write_bytes(b"clip") |
| 57 | + |
| 58 | + monkeypatch.setattr(pipeline, "cut_clip", fake_cut) |
| 59 | + monkeypatch.setattr(pipeline, "build_captions", lambda *a, **k: tmp_path / "clip.ass") |
| 60 | + monkeypatch.setattr(modes, "probe_size", lambda _path: (1080, 1920)) |
| 61 | + |
| 62 | + def run(captions, opts=None): |
| 63 | + config = { |
| 64 | + "clips": {"captions": captions, "outro": False, "vertical": True, "vertical_live": True}, |
| 65 | + "paths": {"data_dir": str(tmp_path)}, |
| 66 | + "tracking": {"detector": "yolov8n-pose.pt", "sample_fps": 8}, |
| 67 | + } |
| 68 | + burned.clear() |
| 69 | + _final, opts_json = pipeline._render_files( |
| 70 | + tmp_path / "source.mp4", ClipCandidate(start=10.0, end=40.0, score=80), [], |
| 71 | + tmp_path / "clips", config, opts, |
| 72 | + ) |
| 73 | + return json.loads(opts_json or "{}"), burned[0] |
| 74 | + |
| 75 | + return run |
| 76 | + |
| 77 | + |
| 78 | +def test_a_captions_off_clip_stays_off_when_edited(render): |
| 79 | + saved, ass = render(captions=False) |
| 80 | + assert saved["job_captions"] is False and ass is None |
| 81 | + # The editor re-renders with the app's settings, where captions are on. |
| 82 | + resaved, ass = render(captions=True, opts=saved) |
| 83 | + assert ass is None and resaved["job_captions"] is False |
| 84 | + |
| 85 | + |
| 86 | +def test_the_editors_switch_turns_them_back_on(render): |
| 87 | + saved, _ = render(captions=False) |
| 88 | + _resaved, ass = render(captions=True, opts={**saved, "captions": True}) |
| 89 | + assert ass is not None |
| 90 | + |
| 91 | + |
| 92 | +def test_a_captions_on_job_saves_nothing_new(render): |
| 93 | + saved, ass = render(captions=True) |
| 94 | + assert "job_captions" not in saved and ass is not None |
| 95 | + |
| 96 | + |
| 97 | +def test_the_editor_sends_its_switch_on_apply_and_preview(): |
| 98 | + editor = EDITOR.read_text(encoding="utf-8") |
| 99 | + assert "if (captionsDirty) renderOpts.captions = captionsOn" in editor |
| 100 | + assert "captionsDirty ? captionsOn : undefined" in editor |
| 101 | + assert "clip.render_opts?.captions ?? clip.render_opts?.job_captions ?? true" in editor |
| 102 | + assert "captions: captions ?? null" in API_TS.read_text(encoding="utf-8") |
| 103 | + |
| 104 | + |
| 105 | +def test_a_clips_own_switch_from_an_earlier_run_is_not_saved_over_a_captions_off_run(render): |
| 106 | + # "Make it again with my edits" with captions off carries a clip's |
| 107 | + # captions:true; the file burns none, so neither may what is saved. |
| 108 | + saved, ass = render(captions=False, opts={"captions": True}) |
| 109 | + assert ass is None and "captions" not in saved and saved["job_captions"] is False |
| 110 | + _resaved, ass = render(captions=True, opts=saved) |
| 111 | + assert ass is None |
| 112 | + |
| 113 | + |
| 114 | +def test_reset_and_the_translation_preview_read_the_same_switch(): |
| 115 | + editor = EDITOR.read_text(encoding="utf-8") |
| 116 | + assert "setCaptionsOn(storedCaptionsOn)" in editor # Reset puts it back |
| 117 | + modal = (EDITOR.parent / "EditorModal.tsx").read_text(encoding="utf-8") |
| 118 | + assert "clip.render_opts?.captions ?? clip.render_opts?.job_captions ?? true" in modal |
0 commit comments