feat: add stream-copy exports

This commit is contained in:
2026-08-16 09:29:47 +02:00
parent 8f354254b2
commit d5d74066b0
6 changed files with 264 additions and 47 deletions
+2 -1
View File
@@ -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
+29 -3
View File
@@ -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),
+5 -2
View File
@@ -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,
+173 -41
View File
@@ -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 35 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(),
+21
View File
@@ -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)
+34
View File
@@ -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")