diff --git a/README.md b/README.md index f4a38f4..51712b4 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets - **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps - **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset -- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`) +- **Three export modes** — H.264 MP4 with lossless PCM audio, WebP image sequence (frames + `.wav`), or lossless/fast ffmpeg stream copy that preserves the source container - **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition - **Random portrait/square** — optionally apply a random crop to a subset of each batch - **Resize** — scale short side to a fixed pixel size (e.g. 512) - **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox +- **Long clips** — clip duration is no longer capped at 30 seconds - **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional) ### Audio extraction & editing diff --git a/core/ffmpeg.py b/core/ffmpeg.py index faebe28..bfc7d96 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -82,11 +82,12 @@ def build_ffmpeg_command( target_fps: float | None = None, snap32: bool = False, frames: int | None = None, + stream_copy: bool = False, ) -> list[str]: - # -ss before -i: fast input-seeking. Safe here because we always re-encode, - # so there is no keyframe-alignment issue from pre-input seek. + # Re-encoded output is not constrained to source keyframes. Stream-copy + # output remains keyframe-limited even though it uses the same fast seek. # Image sequences always use libwebp, so skip HW encoder setup. - use_hw_vaapi = (encoder == "h264_vaapi" and not image_sequence + use_hw_vaapi = (not stream_copy and encoder == "h264_vaapi" and not image_sequence and sys.platform == "linux") cmd = [_bin("ffmpeg"), "-y"] @@ -96,6 +97,31 @@ def build_ffmpeg_command( cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", vaapi_dev] + if stream_copy: + incompatible = ( + image_sequence or short_side is not None or portrait_ratio is not None + or target_fps is not None or snap32 or frames is not None + ) + if incompatible: + raise ValueError("Stream copy cannot be combined with image or video transforms") + # Matroska/WebM input seeking can retain a whole cluster of keyframe + # pre-roll and then count -t from its shifted timestamps. Keeping source + # timestamps avoids that extension. MOV/MP4 needs normal timestamp + # rebasing instead, so this workaround is deliberately container-only. + ext = os.path.splitext(output_path)[1].lower() + timestamp_args = ( + ["-copyts", "-start_at_zero"] if ext in (".mkv", ".webm") else [] + ) + return cmd + [ + "-threads", "0", + "-ss", str(start), + "-i", input_path, + "-t", str(duration), + *timestamp_args, + "-c", "copy", + output_path, + ] + cmd += [ "-threads", "0", "-ss", str(start), diff --git a/core/paths.py b/core/paths.py index ed5a0b3..44621dd 100644 --- a/core/paths.py +++ b/core/paths.py @@ -25,14 +25,17 @@ def _log(*args) -> None: def build_export_path(folder: str, basename: str, counter: int, - sub: int | None = None, tag: str | None = None) -> str: + sub: int | None = None, tag: str | None = None, + extension: str = ".mp4") -> str: """Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001).""" name = f"{basename}_{counter:03d}" if tag is not None: name = f"{name}_{tag}" if sub is not None: name = f"{name}_{sub}" - return os.path.join(folder, name + ".mp4") + if extension and not extension.startswith("."): + extension = "." + extension + return os.path.join(folder, name + extension) def build_sequence_dir(folder: str, basename: str, counter: int, diff --git a/main.py b/main.py index ca9031f..e560cc8 100755 --- a/main.py +++ b/main.py @@ -119,7 +119,8 @@ class ExportWorker(QThread): duration: float = 8.0, target_fps: float | None = None, snap32: bool = False, - frames: int | None = None): + frames: int | None = None, + stream_copy: bool = False): super().__init__() self._input = input_path self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...] @@ -131,6 +132,7 @@ class ExportWorker(QThread): self._target_fps = target_fps # LTX-2: force output fps (None = source) self._snap32 = snap32 # LTX-2: crop W/H down to ÷32 self._frames = frames # LTX-2: exact video frame count + self._stream_copy = stream_copy self._cancel = False self._procs: list[subprocess.Popen] = [] self._procs_lock = __import__('threading').Lock() @@ -146,7 +148,7 @@ class ExportWorker(QThread): def _run_one(self, start: float, output: str, portrait_ratio: str | None, crop_center: float) -> str: - """Encode a single clip. Returns output path on success, raises on error.""" + """Export a single clip. Returns output path on success, raises on error.""" if self._cancel: raise RuntimeError("cancelled") if self._image_sequence: @@ -154,7 +156,7 @@ class ExportWorker(QThread): cmd = build_ffmpeg_command( self._input, start, output, short_side=self._short_side, - portrait_ratio=portrait_ratio, + portrait_ratio=None if self._stream_copy else portrait_ratio, crop_center=crop_center, image_sequence=self._image_sequence, encoder=self._encoder, @@ -162,15 +164,16 @@ class ExportWorker(QThread): target_fps=self._target_fps, snap32=self._snap32, frames=self._frames, + stream_copy=self._stream_copy, ) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) with self._procs_lock: self._procs.append(proc) try: - _, stderr = proc.communicate(timeout=120) - except subprocess.TimeoutExpired: - proc.kill() - raise RuntimeError("ffmpeg timed out") + # Long-duration exports are valid; cancellation explicitly kills + # tracked ffmpeg processes, so an arbitrary wall-clock cap is not + # needed here. + _, stderr = proc.communicate() finally: with self._procs_lock: self._procs.remove(proc) @@ -182,7 +185,7 @@ class ExportWorker(QThread): if self._image_sequence: audio_cmd = build_audio_extract_command(self._input, start, output, duration=self._duration) - subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) + subprocess.run(audio_cmd, capture_output=True, text=True) return output def run(self): @@ -5170,8 +5173,22 @@ class MainWindow(QMainWindow): lambda v: self._settings.setValue("hw_encode", "true" if v else "false") ) + self._chk_stream_copy = QCheckBox("Stream copy") + self._chk_stream_copy.setToolTip( + "Copy compressed video/audio without re-encoding (ffmpeg -c copy).\n" + "Very fast and lossless; cuts begin on source keyframes.\n" + "Preserves the source container and disables crop, resize, WebP, " + "LTX-2, and hardware encoding." + ) + self._chk_stream_copy.setChecked( + self._settings.value("stream_copy", "false") == "true" + ) + self._chk_stream_copy.toggled.connect(self._on_stream_copy_toggled) + self._spn_clip_dur = QDoubleSpinBox() - self._spn_clip_dur.setRange(2.0, 30.0) + # QDoubleSpinBox requires a finite maximum; this is effectively + # unrestricted for media while removing the old 30-second ceiling. + self._spn_clip_dur.setRange(2.0, 999999999.0) self._spn_clip_dur.setSingleStep(0.5) self._spn_clip_dur.setSuffix("s") self._spn_clip_dur.setToolTip("Duration of each exported clip") @@ -5839,9 +5856,11 @@ class MainWindow(QMainWindow): # Row 2: separator — annotation+folder │ encode g.addWidget(self._group_sep(), 2, 0, 1, 7) # Row 3: encode / clip params - g.addWidget(QLabel("Format:"), 3, 0); g.addWidget(self._cmb_format, 3, 1) - g.addWidget(self._chk_hw, 3, 2) - g.addWidget(QLabel("Resize:"), 3, 3); g.addWidget(self._spn_resize, 3, 4) + self._lbl_export_format = QLabel("Format:") + g.addWidget(self._lbl_export_format, 3, 0); g.addWidget(self._cmb_format, 3, 1) + g.addWidget(self._chk_stream_copy, 3, 2) + g.addWidget(self._chk_hw, 3, 3) + g.addWidget(QLabel("Resize:"), 3, 4); g.addWidget(self._spn_resize, 3, 5) # Row 4: separator — encode │ batch g.addWidget(self._group_sep(), 4, 0, 1, 7) # Row 5/6: batch params + actions @@ -6398,6 +6417,64 @@ class MainWindow(QMainWindow): self._lbl_duration.setVisible(not is_ltx2) if is_ltx2 and self._spn_resize.value() == 0: self._spn_resize.setValue(512) # LTX-2 default short side + self._update_stream_copy_controls() + + def _stream_copy_active(self) -> bool: + """Whether the current tab can and should export with ``-c copy``.""" + if not hasattr(self, "_chk_stream_copy"): + return False + pw = self._playlist + is_ltx2 = pw is not None and getattr(pw, "_mode", "foley") == "ltx2" + return self._chk_stream_copy.isChecked() and not is_ltx2 + + def _source_container_extension(self, path: str | None = None) -> str: + """Return the source suffix used for stream-copy output paths.""" + suffix = os.path.splitext(path or self._file_path)[1] + return suffix or ".mkv" + + @staticmethod + def _export_stem_exists(path: str) -> bool: + """Return True when this output stem exists in any export format.""" + if os.path.exists(path): + return True + root, _extension = os.path.splitext(path) + if os.path.exists(root): # WebP sequence directory + return True + folder = os.path.dirname(path) + stem = os.path.basename(root) + try: + return any(os.path.splitext(name)[0] == stem for name in os.listdir(folder)) + except OSError: + return False + + def _on_stream_copy_toggled(self, checked: bool) -> None: + self._settings.setValue("stream_copy", "true" if checked else "false") + self._update_stream_copy_controls() + self._update_next_label() + + def _update_stream_copy_controls(self) -> None: + """Keep frame-transform controls mutually exclusive with stream copy.""" + if not hasattr(self, "_chk_stream_copy"): + return + pw = self._playlist + is_ltx2 = pw is not None and getattr(pw, "_mode", "foley") == "ltx2" + self._chk_stream_copy.setEnabled(not is_ltx2) + active = self._chk_stream_copy.isChecked() and not is_ltx2 + + # WebP is a transcode. Keep the prior crop/resize values intact so + # turning stream copy off restores the user's encoding setup. + if active and self._cmb_format.currentText() != "MP4": + self._cmb_format.setCurrentText("MP4") + self._lbl_export_format.setVisible(not active) + self._cmb_format.setVisible(not active) + self._chk_hw.setEnabled(not active and bool(self._hw_encoders)) + self._spn_resize.setEnabled(not active) + self._cmb_portrait.setEnabled(not active) + self._chk_rand_portrait.setEnabled(not active) + self._chk_rand_square.setEnabled(not active) + self._chk_track.setEnabled(not active) + self._crop_bar.setEnabled(not active) + self._rebuild_format_buttons() def _ltx2_export_params(self) -> dict | None: """Return LTX-2 ffmpeg kwargs for the active tab, or None for Foley.""" @@ -7306,9 +7383,11 @@ class MainWindow(QMainWindow): if idx >= 0: self._cmb_portrait.setCurrentIndex(idx) fmt = meta["format"] or "MP4" - idx = self._cmb_format.findText(fmt) - if idx >= 0: - self._cmb_format.setCurrentIndex(idx) + self._chk_stream_copy.setChecked(fmt == "Source copy") + if fmt != "Source copy": + idx = self._cmb_format.findText(fmt) + if idx >= 0: + self._cmb_format.setCurrentIndex(idx) if meta["clip_count"] is not None: self._spn_clips.setValue(meta["clip_count"]) if meta.get("clip_duration") is not None: @@ -7403,6 +7482,8 @@ class MainWindow(QMainWindow): self._transport_row.removeWidget(btn) btn.setParent(None) self._format_btns.clear() + if self._stream_copy_active(): + return formats = [] ratio_text = self._cmb_portrait.currentText() if ratio_text != "Off": @@ -9163,8 +9244,13 @@ class MainWindow(QMainWindow): folder = self._tab_export_folder() name = self._txt_name.text() or "clip" fmt = self._cmb_format.currentText() - image_sequence = fmt == "WebP sequence" - ext = "" if image_sequence else ".mp4" + stream_copy = self._stream_copy_active() + image_sequence = fmt == "WebP sequence" and not stream_copy + ext = ( + "" if image_sequence else + self._source_container_extension(self._file_path) if stream_copy else + ".mp4" + ) vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) os.makedirs(vid_folder, exist_ok=True) @@ -9196,8 +9282,8 @@ class MainWindow(QMainWindow): jobs.append((start_t, out, None, 0.5)) positions.append((start_t, out)) - short_side = self._spn_resize.value() or None - hw_on = self._chk_hw.isChecked() and self._hw_encoders + short_side = None if stream_copy else (self._spn_resize.value() or None) + hw_on = not stream_copy and self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() is_scan = getattr(self, '_auto_export_no_markers', False) @@ -9223,13 +9309,14 @@ class MainWindow(QMainWindow): "clip_duration": clip_duration, "spread": spread, "folder": folder, - "format": fmt, + "format": "Source copy" if stream_copy else fmt, "profile": self._profile, "is_scan": is_scan, "replace_scan_exports": replace_scan_exports, "target_fps": ltx2["target_fps"] if ltx2 else None, "snap32": ltx2["snap32"] if ltx2 else False, "frames": ltx2["frames"] if ltx2 else None, + "stream_copy": stream_copy, } if self._export_worker and self._export_worker.isRunning(): @@ -9278,6 +9365,7 @@ class MainWindow(QMainWindow): target_fps=batch.get("target_fps"), snap32=batch.get("snap32", False), frames=batch.get("frames"), + stream_copy=batch.get("stream_copy", False), ) self._export_worker.finished.connect(self._on_auto_clip_done) self._export_worker.all_done.connect(self._on_auto_batch_done) @@ -9394,6 +9482,10 @@ class MainWindow(QMainWindow): def _update_next_label(self): folder = self._tab_export_folder() name = self._txt_name.text() or "clip" + extension = ( + self._source_container_extension() if self._stream_copy_active() + else ".mp4" + ) # The vid-folder lookup hits the DB and stats the disk and is stable for # a given (file, folder), so cache it — spinner ticks shouldn't repeat # it. The cheap m-counter probe is recomputed each call so it stays @@ -9408,8 +9500,11 @@ class MainWindow(QMainWindow): self._export_counter = 1 while True: tag = f"m{self._export_counter}" - if not os.path.exists( - build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)): + if not self._export_stem_exists( + build_export_path( + vid_folder, name, vid_num, sub=0, tag=tag, + extension=extension, + )): break self._export_counter += 1 n = self._spn_clips.value() @@ -9438,7 +9533,9 @@ class MainWindow(QMainWindow): break fmt = self._cmb_format.currentText() - image_sequence = fmt == "WebP sequence" + stream_copy = self._stream_copy_active() + image_sequence = fmt == "WebP sequence" and not stream_copy + extension = self._source_container_extension() if stream_copy else ".mp4" folder = self._tab_export_folder() if folder_suffix: folder = folder.rstrip(os.sep) + "_" + folder_suffix @@ -9475,6 +9572,15 @@ class MainWindow(QMainWindow): # Group overwrite mode — re-export all sub-clips at this marker. # Delete old DB rows first to avoid duplicates on re-insert. group_paths = sorted(self._overwrite_group) if self._overwrite_group else [self._overwrite_path] + if stream_copy and any( + os.path.splitext(path)[1].lower() != extension.lower() + for path in group_paths): + self._show_status( + "Stream-copy overwrite requires outputs with the source container; " + "deselect the marker to create a new copy", + 6000, + ) + return for path in group_paths: self._db.delete_by_output_path(path) jobs = [] @@ -9509,8 +9615,11 @@ class MainWindow(QMainWindow): if image_sequence: p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag) else: - p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) - if not os.path.exists(p): + p = build_export_path( + vid_folder, name, vid_num, sub=0, tag=tag, + extension=extension, + ) + if not self._export_stem_exists(p): break manual_n += 1 else: @@ -9522,7 +9631,10 @@ class MainWindow(QMainWindow): if image_sequence: out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag) else: - out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag) + out = build_export_path( + vid_folder, name, vid_num, sub=sub, tag=tag, + extension=extension, + ) jobs.append((start, out, base_ratio, base_center)) # Apply crop keyframes (or fall back to base state). @@ -9558,7 +9670,7 @@ class MainWindow(QMainWindow): jobs.append((s, o, ratio, center)) # Subject tracking: re-detect crop center per sub-clip. - if self._chk_track.isChecked() and any(j[2] for j in jobs): + if not stream_copy and self._chk_track.isChecked() and any(j[2] for j in jobs): starts = [j[0] for j in jobs] self._show_status(f"Tracking subject across {len(jobs)} clip(s)…") QApplication.processEvents() @@ -9570,7 +9682,7 @@ class MainWindow(QMainWindow): for i, (s, o, r, c) in enumerate(jobs) ] - short_side = self._spn_resize.value() or None + short_side = None if stream_copy else (self._spn_resize.value() or None) duration = self._clip_dur # LTX-2 mode (active tab) overrides length/resize and feeds the @@ -9585,9 +9697,11 @@ class MainWindow(QMainWindow): # Cursor is frozen here — user may move it during async export. self._export_cursor = self._cursor self._export_short_side = short_side - self._export_portrait = force_ratio or self._cmb_portrait.currentText() + self._export_portrait = ( + "Off" if stream_copy else (force_ratio or self._cmb_portrait.currentText()) + ) self._export_crop_center = self._crop_center - self._export_format = fmt + self._export_format = "Source copy" if stream_copy else fmt self._export_clip_count = self._spn_clips.value() self._export_clip_duration = duration self._export_spread = self._spn_spread.value() @@ -9606,12 +9720,13 @@ class MainWindow(QMainWindow): pending.append((self._cursor, counter, first_out, self._clip_span)) self._timeline.set_markers(pending) - hw_on = self._chk_hw.isChecked() and self._hw_encoders + hw_on = not stream_copy and self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" # GPU encoders have a limited number of concurrent sessions # (typically 3–5 on consumer NVIDIA cards), so cap workers. max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() - _log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, " + mode = "stream-copy" if stream_copy else f"encoder={encoder}" + _log(f"Export: {len(jobs)} clip(s), {mode}, workers={max_workers}, " f"resize={short_side}, format={fmt}" + (f", ltx2 frames={ltx2['frames']}@{ltx2['target_fps']:g}fps" if ltx2 else "")) self._export_worker = ExportWorker( @@ -9624,6 +9739,7 @@ class MainWindow(QMainWindow): target_fps=ltx2["target_fps"] if ltx2 else None, snap32=ltx2["snap32"] if ltx2 else False, frames=ltx2["frames"] if ltx2 else None, + stream_copy=stream_copy, ) self._export_worker.finished.connect(self._on_clip_done) self._export_worker.all_done.connect(self._on_batch_done) @@ -9780,7 +9896,9 @@ class MainWindow(QMainWindow): name = self._txt_name.text() or "clip" fmt = self._cmb_format.currentText() - image_sequence = fmt == "WebP sequence" + stream_copy = self._stream_copy_active() + image_sequence = fmt == "WebP sequence" and not stream_copy + extension = self._source_container_extension() if stream_copy else ".mp4" # Resolve vid folder BEFORE deleting DB rows, so we reuse the same one. vid_name = self._get_vid_folder(folder) @@ -9811,8 +9929,11 @@ class MainWindow(QMainWindow): manual_n = 1 while True: tag = f"m{manual_n}" - test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) - if not os.path.exists(test): + test = build_export_path( + vid_folder, name, vid_num, sub=0, tag=tag, + extension=extension, + ) + if not self._export_stem_exists(test): break manual_n += 1 @@ -9835,7 +9956,10 @@ class MainWindow(QMainWindow): if image_sequence: out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag) else: - out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag) + out = build_export_path( + vid_folder, name, vid_num, sub=i, tag=tag, + extension=extension, + ) jobs.append((start, out, ratio, center)) self._reexport_meta[os.path.normpath(out)] = { "cursor": cursor_t, @@ -9846,8 +9970,8 @@ class MainWindow(QMainWindow): "crop_center": center, } - short_side = self._spn_resize.value() or None - hw_on = self._chk_hw.isChecked() and self._hw_encoders + short_side = None if stream_copy else (self._spn_resize.value() or None) + hw_on = not stream_copy and self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() clip_dur = self._clip_dur @@ -9864,6 +9988,10 @@ class MainWindow(QMainWindow): self._export_clip_duration = clip_dur self._export_folder = folder self._export_profile = self._profile + self._export_short_side = short_side + self._export_portrait = "Off" if stream_copy else self._cmb_portrait.currentText() + self._export_format = "Source copy" if stream_copy else fmt + self._export_stream_copy = stream_copy self._btn_export.setEnabled(False) self._btn_reexport.setEnabled(False) @@ -9880,6 +10008,7 @@ class MainWindow(QMainWindow): target_fps=ltx2["target_fps"] if ltx2 else None, snap32=ltx2["snap32"] if ltx2 else False, frames=ltx2["frames"] if ltx2 else None, + stream_copy=stream_copy, ) self._export_worker.finished.connect(self._on_reexport_clip_done) self._export_worker.all_done.connect(self._on_reexport_batch_done) @@ -9896,10 +10025,13 @@ class MainWindow(QMainWindow): path, label=meta.get("label", ""), category=meta.get("category", ""), - short_side=self._spn_resize.value() or None, - portrait_ratio=meta.get("portrait_ratio", ""), + short_side=self._export_short_side, + portrait_ratio=( + "" if self._export_stream_copy + else meta.get("portrait_ratio", "") + ), crop_center=meta.get("crop_center", 0.5), - fmt=self._cmb_format.currentText(), + fmt=self._export_format, clip_count=meta.get("clip_count", 1), clip_duration=self._export_clip_duration, spread=self._spn_spread.value(), diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index a2f2018..accbc97 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -54,6 +54,27 @@ def test_workers_spinbox_in_export_tab(win): assert win._spn_workers in win._tab_export.findChildren(QSpinBox) +def test_clip_duration_allows_long_exports(win): + assert win._spn_clip_dur.maximum() >= 86400.0 + + +def test_stream_copy_mode_disables_transforms(win): + win._file_path = "/x/source.mkv" + win._cmb_format.setCurrentText("WebP sequence") + win._chk_stream_copy.setChecked(True) + try: + assert win._stream_copy_active() + assert win._source_container_extension() == ".mkv" + assert win._cmb_format.currentText() == "MP4" + assert win._cmb_format.isHidden() + assert not win._chk_hw.isEnabled() + assert not win._spn_resize.isEnabled() + assert not win._cmb_portrait.isEnabled() + assert not win._chk_track.isEnabled() + finally: + win._chk_stream_copy.setChecked(False) + + def test_scan_button_in_audio_tab(win): from PyQt6.QtWidgets import QPushButton assert win._btn_scan in win._tab_audio.findChildren(QPushButton) diff --git a/tests/test_utils.py b/tests/test_utils.py index 026ca52..0ed5c95 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -18,6 +18,10 @@ def test_build_export_path_sub(): assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4" assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4" +def test_build_export_path_custom_extension(): + assert build_export_path("/out", "clip", 1, sub=0, extension=".mkv") == "/out/clip_001_0.mkv" + assert build_export_path("/out", "clip", 1, extension="mov") == "/out/clip_001.mov" + def test_build_sequence_dir_sub(): assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0" assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1" @@ -54,6 +58,36 @@ def test_ffmpeg_command_with_resize(): assert "scale" in vf_value assert cmd[-1] == "/out/clip_001.mp4" +def test_ffmpeg_command_stream_copy(): + cmd = build_ffmpeg_command( + "/in/video.mkv", 12.5, "/out/clip_001.mkv", + duration=3600.0, stream_copy=True, + ) + assert cmd[cmd.index("-ss") + 1] == "12.5" + assert cmd[cmd.index("-t") + 1] == "3600.0" + assert cmd.index("-ss") < cmd.index("-i") + assert "-copyts" in cmd + assert "-start_at_zero" in cmd + assert cmd[cmd.index("-c") + 1] == "copy" + assert "-c:v" not in cmd + assert "-c:a" not in cmd + assert cmd[-1] == "/out/clip_001.mkv" + +def test_ffmpeg_command_stream_copy_rejects_transforms(): + import pytest + with pytest.raises(ValueError, match="Stream copy"): + build_ffmpeg_command( + "/in/video.mkv", 0.0, "/out/clip.mkv", + short_side=256, stream_copy=True, + ) + +def test_ffmpeg_command_stream_copy_mp4_uses_normal_timestamp_rebasing(): + cmd = build_ffmpeg_command( + "/in/video.mp4", 12.5, "/out/clip.mp4", stream_copy=True, + ) + assert "-copyts" not in cmd + assert "-start_at_zero" not in cmd + def test_audio_clip_command_exact_length(): cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")