@@ -140,3 +140,125 @@ def test_cli_min_scene_len_smoke(test_video_file, tmp_path):
140140 cwd = os .path .abspath (os .path .dirname (test_video_file ) + "/../.." ),
141141 )
142142 assert result .returncode == 0 , f"stderr:\n { result .stderr } \n stdout:\n { result .stdout } "
143+
144+
145+ @pytest .mark .release
146+ def test_cli_save_fcp_smoke (test_video_file , tmp_path ):
147+ """save-fcp writes a well-formed Final Cut Pro XML."""
148+ import xml .etree .ElementTree as ET
149+
150+ result = _run (
151+ [
152+ "-i" ,
153+ os .path .abspath (test_video_file ),
154+ "-o" ,
155+ str (tmp_path ),
156+ "detect-content" ,
157+ "save-fcp" ,
158+ ],
159+ cwd = os .path .abspath (os .path .dirname (test_video_file ) + "/../.." ),
160+ )
161+ assert result .returncode == 0 , f"stderr:\n { result .stderr } \n stdout:\n { result .stdout } "
162+ xml_files = [p for p in tmp_path .iterdir () if p .suffix == ".xml" ]
163+ assert xml_files , "save-fcp produced no .xml file"
164+ # Parse must succeed; root <fcpxml> or <xmeml> depending on the FCP variant.
165+ root = ET .parse (xml_files [0 ]).getroot ()
166+ assert root .tag in ("fcpxml" , "xmeml" ), f"Unexpected root element: { root .tag } "
167+
168+
169+ @pytest .mark .release
170+ def test_cli_save_qp_smoke (test_video_file , tmp_path ):
171+ """save-qp writes a QP file with `<frame> I <shift>` lines for scene boundaries."""
172+ result = _run (
173+ [
174+ "-i" ,
175+ os .path .abspath (test_video_file ),
176+ "-o" ,
177+ str (tmp_path ),
178+ "detect-content" ,
179+ "save-qp" ,
180+ ],
181+ cwd = os .path .abspath (os .path .dirname (test_video_file ) + "/../.." ),
182+ )
183+ assert result .returncode == 0 , f"stderr:\n { result .stderr } \n stdout:\n { result .stdout } "
184+ qp_files = [p for p in tmp_path .iterdir () if p .suffix == ".qp" ]
185+ assert qp_files , "save-qp produced no .qp file"
186+ contents = qp_files [0 ].read_text ().strip ()
187+ assert contents , "save-qp produced an empty file"
188+ # Each line must be `<frame_number> I <shift>` where shift is an integer.
189+ for line in contents .splitlines ():
190+ parts = line .split ()
191+ assert len (parts ) == 3 and parts [0 ].isdigit () and parts [1 ] == "I" , (
192+ f"Malformed QP line: { line !r} "
193+ )
194+ int (parts [2 ]) # shift must parse as int (may be negative)
195+
196+
197+ @pytest .mark .release
198+ def test_cli_save_html_smoke (test_video_file , tmp_path ):
199+ """save-html replaces the deprecated export-html and produces an HTML report.
200+
201+ Note: save-html lacks its own --output option and ignores the global -o, so the
202+ file is routed via --filename with an absolute path.
203+ """
204+ out_html = tmp_path / "scenes.html"
205+ result = _run (
206+ [
207+ "-i" ,
208+ os .path .abspath (test_video_file ),
209+ "detect-content" ,
210+ "save-html" ,
211+ "--filename" ,
212+ str (out_html ),
213+ "--no-images" ,
214+ ],
215+ cwd = os .path .abspath (os .path .dirname (test_video_file ) + "/../.." ),
216+ )
217+ assert result .returncode == 0 , f"stderr:\n { result .stderr } \n stdout:\n { result .stdout } "
218+ assert out_html .exists (), f"save-html produced no file at { out_html } "
219+ contents = out_html .read_text (encoding = "utf-8" )
220+ # The output is an HTML fragment (a <table> of scenes), not a full document.
221+ lowered = contents .lower ()
222+ assert "<table" in lowered and "</table>" in lowered , (
223+ f"save-html output is missing the scenes <table>:\n { contents [:500 ]} "
224+ )
225+
226+
227+ @pytest .mark .release
228+ def test_cli_save_edl_start_timecode_smoke (test_video_file , tmp_path ):
229+ """save-edl --start-timecode produces an EDL where event timestamps are offset by the
230+ requested start. Both SMPTE (HH:MM:SS:FF) and 8-digit (HHMMSSFF) inputs must be accepted."""
231+ repo_cwd = os .path .abspath (os .path .dirname (test_video_file ) + "/../.." )
232+
233+ def _edl (form : str , out_dir ):
234+ result = _run (
235+ [
236+ "-i" ,
237+ os .path .abspath (test_video_file ),
238+ "-o" ,
239+ str (out_dir ),
240+ "detect-content" ,
241+ "save-edl" ,
242+ "--start-timecode" ,
243+ form ,
244+ ],
245+ cwd = repo_cwd ,
246+ )
247+ assert result .returncode == 0 , (
248+ f"start-timecode { form !r} failed:\n stderr:\n { result .stderr } \n stdout:\n { result .stdout } "
249+ )
250+ edls = [p for p in out_dir .iterdir () if p .suffix == ".edl" ]
251+ assert edls , f"save-edl --start-timecode { form !r} produced no .edl file"
252+ return edls [0 ].read_text ()
253+
254+ # SMPTE form.
255+ smpte_dir = tmp_path / "smpte"
256+ smpte_dir .mkdir ()
257+ smpte_text = _edl ("01:00:00:00" , smpte_dir )
258+ # 8-digit form (semantically equivalent to 01:00:00:00).
259+ digit_dir = tmp_path / "digit"
260+ digit_dir .mkdir ()
261+ digit_text = _edl ("01000000" , digit_dir )
262+ # Both EDLs must contain at least one event line with the 01:00:... offset visible.
263+ assert "01:00:" in smpte_text , f"SMPTE start TC not propagated to EDL:\n { smpte_text } "
264+ assert "01:00:" in digit_text , f"8-digit start TC not propagated to EDL:\n { digit_text } "
0 commit comments