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 - **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 - **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 - **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 - **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) - **Resize** — scale short side to a fixed pixel size (e.g. 512)
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox - **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) - **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
### Audio extraction & editing ### Audio extraction & editing
+29 -3
View File
@@ -82,11 +82,12 @@ def build_ffmpeg_command(
target_fps: float | None = None, target_fps: float | None = None,
snap32: bool = False, snap32: bool = False,
frames: int | None = None, frames: int | None = None,
stream_copy: bool = False,
) -> list[str]: ) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode, # Re-encoded output is not constrained to source keyframes. Stream-copy
# so there is no keyframe-alignment issue from pre-input seek. # output remains keyframe-limited even though it uses the same fast seek.
# Image sequences always use libwebp, so skip HW encoder setup. # 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") and sys.platform == "linux")
cmd = [_bin("ffmpeg"), "-y"] cmd = [_bin("ffmpeg"), "-y"]
@@ -96,6 +97,31 @@ def build_ffmpeg_command(
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
"-vaapi_device", vaapi_dev] "-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 += [ cmd += [
"-threads", "0", "-threads", "0",
"-ss", str(start), "-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, 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).""" """Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
name = f"{basename}_{counter:03d}" name = f"{basename}_{counter:03d}"
if tag is not None: if tag is not None:
name = f"{name}_{tag}" name = f"{name}_{tag}"
if sub is not None: if sub is not None:
name = f"{name}_{sub}" 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, 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, duration: float = 8.0,
target_fps: float | None = None, target_fps: float | None = None,
snap32: bool = False, snap32: bool = False,
frames: int | None = None): frames: int | None = None,
stream_copy: bool = False):
super().__init__() super().__init__()
self._input = input_path self._input = input_path
self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...] 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._target_fps = target_fps # LTX-2: force output fps (None = source)
self._snap32 = snap32 # LTX-2: crop W/H down to ÷32 self._snap32 = snap32 # LTX-2: crop W/H down to ÷32
self._frames = frames # LTX-2: exact video frame count self._frames = frames # LTX-2: exact video frame count
self._stream_copy = stream_copy
self._cancel = False self._cancel = False
self._procs: list[subprocess.Popen] = [] self._procs: list[subprocess.Popen] = []
self._procs_lock = __import__('threading').Lock() self._procs_lock = __import__('threading').Lock()
@@ -146,7 +148,7 @@ class ExportWorker(QThread):
def _run_one(self, start: float, output: str, def _run_one(self, start: float, output: str,
portrait_ratio: str | None, crop_center: float) -> 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: if self._cancel:
raise RuntimeError("cancelled") raise RuntimeError("cancelled")
if self._image_sequence: if self._image_sequence:
@@ -154,7 +156,7 @@ class ExportWorker(QThread):
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
self._input, start, output, self._input, start, output,
short_side=self._short_side, short_side=self._short_side,
portrait_ratio=portrait_ratio, portrait_ratio=None if self._stream_copy else portrait_ratio,
crop_center=crop_center, crop_center=crop_center,
image_sequence=self._image_sequence, image_sequence=self._image_sequence,
encoder=self._encoder, encoder=self._encoder,
@@ -162,15 +164,16 @@ class ExportWorker(QThread):
target_fps=self._target_fps, target_fps=self._target_fps,
snap32=self._snap32, snap32=self._snap32,
frames=self._frames, frames=self._frames,
stream_copy=self._stream_copy,
) )
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
with self._procs_lock: with self._procs_lock:
self._procs.append(proc) self._procs.append(proc)
try: try:
_, stderr = proc.communicate(timeout=120) # Long-duration exports are valid; cancellation explicitly kills
except subprocess.TimeoutExpired: # tracked ffmpeg processes, so an arbitrary wall-clock cap is not
proc.kill() # needed here.
raise RuntimeError("ffmpeg timed out") _, stderr = proc.communicate()
finally: finally:
with self._procs_lock: with self._procs_lock:
self._procs.remove(proc) self._procs.remove(proc)
@@ -182,7 +185,7 @@ class ExportWorker(QThread):
if self._image_sequence: if self._image_sequence:
audio_cmd = build_audio_extract_command(self._input, start, output, audio_cmd = build_audio_extract_command(self._input, start, output,
duration=self._duration) 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 return output
def run(self): def run(self):
@@ -5170,8 +5173,22 @@ class MainWindow(QMainWindow):
lambda v: self._settings.setValue("hw_encode", "true" if v else "false") 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 = 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.setSingleStep(0.5)
self._spn_clip_dur.setSuffix("s") self._spn_clip_dur.setSuffix("s")
self._spn_clip_dur.setToolTip("Duration of each exported clip") self._spn_clip_dur.setToolTip("Duration of each exported clip")
@@ -5839,9 +5856,11 @@ class MainWindow(QMainWindow):
# Row 2: separator — annotation+folder │ encode # Row 2: separator — annotation+folder │ encode
g.addWidget(self._group_sep(), 2, 0, 1, 7) g.addWidget(self._group_sep(), 2, 0, 1, 7)
# Row 3: encode / clip params # Row 3: encode / clip params
g.addWidget(QLabel("Format:"), 3, 0); g.addWidget(self._cmb_format, 3, 1) self._lbl_export_format = QLabel("Format:")
g.addWidget(self._chk_hw, 3, 2) g.addWidget(self._lbl_export_format, 3, 0); g.addWidget(self._cmb_format, 3, 1)
g.addWidget(QLabel("Resize:"), 3, 3); g.addWidget(self._spn_resize, 3, 4) 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 # Row 4: separator — encode │ batch
g.addWidget(self._group_sep(), 4, 0, 1, 7) g.addWidget(self._group_sep(), 4, 0, 1, 7)
# Row 5/6: batch params + actions # Row 5/6: batch params + actions
@@ -6398,6 +6417,64 @@ class MainWindow(QMainWindow):
self._lbl_duration.setVisible(not is_ltx2) self._lbl_duration.setVisible(not is_ltx2)
if is_ltx2 and self._spn_resize.value() == 0: if is_ltx2 and self._spn_resize.value() == 0:
self._spn_resize.setValue(512) # LTX-2 default short side 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: def _ltx2_export_params(self) -> dict | None:
"""Return LTX-2 ffmpeg kwargs for the active tab, or None for Foley.""" """Return LTX-2 ffmpeg kwargs for the active tab, or None for Foley."""
@@ -7306,9 +7383,11 @@ class MainWindow(QMainWindow):
if idx >= 0: if idx >= 0:
self._cmb_portrait.setCurrentIndex(idx) self._cmb_portrait.setCurrentIndex(idx)
fmt = meta["format"] or "MP4" fmt = meta["format"] or "MP4"
idx = self._cmb_format.findText(fmt) self._chk_stream_copy.setChecked(fmt == "Source copy")
if idx >= 0: if fmt != "Source copy":
self._cmb_format.setCurrentIndex(idx) idx = self._cmb_format.findText(fmt)
if idx >= 0:
self._cmb_format.setCurrentIndex(idx)
if meta["clip_count"] is not None: if meta["clip_count"] is not None:
self._spn_clips.setValue(meta["clip_count"]) self._spn_clips.setValue(meta["clip_count"])
if meta.get("clip_duration") is not None: if meta.get("clip_duration") is not None:
@@ -7403,6 +7482,8 @@ class MainWindow(QMainWindow):
self._transport_row.removeWidget(btn) self._transport_row.removeWidget(btn)
btn.setParent(None) btn.setParent(None)
self._format_btns.clear() self._format_btns.clear()
if self._stream_copy_active():
return
formats = [] formats = []
ratio_text = self._cmb_portrait.currentText() ratio_text = self._cmb_portrait.currentText()
if ratio_text != "Off": if ratio_text != "Off":
@@ -9163,8 +9244,13 @@ class MainWindow(QMainWindow):
folder = self._tab_export_folder() folder = self._tab_export_folder()
name = self._txt_name.text() or "clip" name = self._txt_name.text() or "clip"
fmt = self._cmb_format.currentText() fmt = self._cmb_format.currentText()
image_sequence = fmt == "WebP sequence" stream_copy = self._stream_copy_active()
ext = "" if image_sequence else ".mp4" 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_name = self._get_vid_folder(folder)
vid_folder = os.path.join(folder, vid_name) vid_folder = os.path.join(folder, vid_name)
os.makedirs(vid_folder, exist_ok=True) os.makedirs(vid_folder, exist_ok=True)
@@ -9196,8 +9282,8 @@ class MainWindow(QMainWindow):
jobs.append((start_t, out, None, 0.5)) jobs.append((start_t, out, None, 0.5))
positions.append((start_t, out)) positions.append((start_t, out))
short_side = self._spn_resize.value() or None short_side = None if stream_copy else (self._spn_resize.value() or None)
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" 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() 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) is_scan = getattr(self, '_auto_export_no_markers', False)
@@ -9223,13 +9309,14 @@ class MainWindow(QMainWindow):
"clip_duration": clip_duration, "clip_duration": clip_duration,
"spread": spread, "spread": spread,
"folder": folder, "folder": folder,
"format": fmt, "format": "Source copy" if stream_copy else fmt,
"profile": self._profile, "profile": self._profile,
"is_scan": is_scan, "is_scan": is_scan,
"replace_scan_exports": replace_scan_exports, "replace_scan_exports": replace_scan_exports,
"target_fps": ltx2["target_fps"] if ltx2 else None, "target_fps": ltx2["target_fps"] if ltx2 else None,
"snap32": ltx2["snap32"] if ltx2 else False, "snap32": ltx2["snap32"] if ltx2 else False,
"frames": ltx2["frames"] if ltx2 else None, "frames": ltx2["frames"] if ltx2 else None,
"stream_copy": stream_copy,
} }
if self._export_worker and self._export_worker.isRunning(): if self._export_worker and self._export_worker.isRunning():
@@ -9278,6 +9365,7 @@ class MainWindow(QMainWindow):
target_fps=batch.get("target_fps"), target_fps=batch.get("target_fps"),
snap32=batch.get("snap32", False), snap32=batch.get("snap32", False),
frames=batch.get("frames"), frames=batch.get("frames"),
stream_copy=batch.get("stream_copy", False),
) )
self._export_worker.finished.connect(self._on_auto_clip_done) self._export_worker.finished.connect(self._on_auto_clip_done)
self._export_worker.all_done.connect(self._on_auto_batch_done) self._export_worker.all_done.connect(self._on_auto_batch_done)
@@ -9394,6 +9482,10 @@ class MainWindow(QMainWindow):
def _update_next_label(self): def _update_next_label(self):
folder = self._tab_export_folder() folder = self._tab_export_folder()
name = self._txt_name.text() or "clip" 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 # 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 # 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 # it. The cheap m-counter probe is recomputed each call so it stays
@@ -9408,8 +9500,11 @@ class MainWindow(QMainWindow):
self._export_counter = 1 self._export_counter = 1
while True: while True:
tag = f"m{self._export_counter}" tag = f"m{self._export_counter}"
if not os.path.exists( if not self._export_stem_exists(
build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)): build_export_path(
vid_folder, name, vid_num, sub=0, tag=tag,
extension=extension,
)):
break break
self._export_counter += 1 self._export_counter += 1
n = self._spn_clips.value() n = self._spn_clips.value()
@@ -9438,7 +9533,9 @@ class MainWindow(QMainWindow):
break break
fmt = self._cmb_format.currentText() 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() folder = self._tab_export_folder()
if folder_suffix: if folder_suffix:
folder = folder.rstrip(os.sep) + "_" + 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. # Group overwrite mode — re-export all sub-clips at this marker.
# Delete old DB rows first to avoid duplicates on re-insert. # 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] 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: for path in group_paths:
self._db.delete_by_output_path(path) self._db.delete_by_output_path(path)
jobs = [] jobs = []
@@ -9509,8 +9615,11 @@ class MainWindow(QMainWindow):
if image_sequence: if image_sequence:
p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag) p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag)
else: else:
p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) p = build_export_path(
if not os.path.exists(p): vid_folder, name, vid_num, sub=0, tag=tag,
extension=extension,
)
if not self._export_stem_exists(p):
break break
manual_n += 1 manual_n += 1
else: else:
@@ -9522,7 +9631,10 @@ class MainWindow(QMainWindow):
if image_sequence: if image_sequence:
out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag) out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag)
else: 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)) jobs.append((start, out, base_ratio, base_center))
# Apply crop keyframes (or fall back to base state). # Apply crop keyframes (or fall back to base state).
@@ -9558,7 +9670,7 @@ class MainWindow(QMainWindow):
jobs.append((s, o, ratio, center)) jobs.append((s, o, ratio, center))
# Subject tracking: re-detect crop center per sub-clip. # 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] starts = [j[0] for j in jobs]
self._show_status(f"Tracking subject across {len(jobs)} clip(s)…") self._show_status(f"Tracking subject across {len(jobs)} clip(s)…")
QApplication.processEvents() QApplication.processEvents()
@@ -9570,7 +9682,7 @@ class MainWindow(QMainWindow):
for i, (s, o, r, c) in enumerate(jobs) 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 duration = self._clip_dur
# LTX-2 mode (active tab) overrides length/resize and feeds the # 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. # Cursor is frozen here — user may move it during async export.
self._export_cursor = self._cursor self._export_cursor = self._cursor
self._export_short_side = short_side 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_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_count = self._spn_clips.value()
self._export_clip_duration = duration self._export_clip_duration = duration
self._export_spread = self._spn_spread.value() self._export_spread = self._spn_spread.value()
@@ -9606,12 +9720,13 @@ class MainWindow(QMainWindow):
pending.append((self._cursor, counter, first_out, self._clip_span)) pending.append((self._cursor, counter, first_out, self._clip_span))
self._timeline.set_markers(pending) 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" encoder = self._hw_encoders[0] if hw_on else "libx264"
# GPU encoders have a limited number of concurrent sessions # GPU encoders have a limited number of concurrent sessions
# (typically 35 on consumer NVIDIA cards), so cap workers. # (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() 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"resize={short_side}, format={fmt}"
+ (f", ltx2 frames={ltx2['frames']}@{ltx2['target_fps']:g}fps" if ltx2 else "")) + (f", ltx2 frames={ltx2['frames']}@{ltx2['target_fps']:g}fps" if ltx2 else ""))
self._export_worker = ExportWorker( self._export_worker = ExportWorker(
@@ -9624,6 +9739,7 @@ class MainWindow(QMainWindow):
target_fps=ltx2["target_fps"] if ltx2 else None, target_fps=ltx2["target_fps"] if ltx2 else None,
snap32=ltx2["snap32"] if ltx2 else False, snap32=ltx2["snap32"] if ltx2 else False,
frames=ltx2["frames"] if ltx2 else None, frames=ltx2["frames"] if ltx2 else None,
stream_copy=stream_copy,
) )
self._export_worker.finished.connect(self._on_clip_done) self._export_worker.finished.connect(self._on_clip_done)
self._export_worker.all_done.connect(self._on_batch_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" name = self._txt_name.text() or "clip"
fmt = self._cmb_format.currentText() 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. # Resolve vid folder BEFORE deleting DB rows, so we reuse the same one.
vid_name = self._get_vid_folder(folder) vid_name = self._get_vid_folder(folder)
@@ -9811,8 +9929,11 @@ class MainWindow(QMainWindow):
manual_n = 1 manual_n = 1
while True: while True:
tag = f"m{manual_n}" tag = f"m{manual_n}"
test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) test = build_export_path(
if not os.path.exists(test): vid_folder, name, vid_num, sub=0, tag=tag,
extension=extension,
)
if not self._export_stem_exists(test):
break break
manual_n += 1 manual_n += 1
@@ -9835,7 +9956,10 @@ class MainWindow(QMainWindow):
if image_sequence: if image_sequence:
out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag) out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag)
else: 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)) jobs.append((start, out, ratio, center))
self._reexport_meta[os.path.normpath(out)] = { self._reexport_meta[os.path.normpath(out)] = {
"cursor": cursor_t, "cursor": cursor_t,
@@ -9846,8 +9970,8 @@ class MainWindow(QMainWindow):
"crop_center": center, "crop_center": center,
} }
short_side = self._spn_resize.value() or None short_side = None if stream_copy else (self._spn_resize.value() or None)
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" 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() max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
clip_dur = self._clip_dur clip_dur = self._clip_dur
@@ -9864,6 +9988,10 @@ class MainWindow(QMainWindow):
self._export_clip_duration = clip_dur self._export_clip_duration = clip_dur
self._export_folder = folder self._export_folder = folder
self._export_profile = self._profile 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_export.setEnabled(False)
self._btn_reexport.setEnabled(False) self._btn_reexport.setEnabled(False)
@@ -9880,6 +10008,7 @@ class MainWindow(QMainWindow):
target_fps=ltx2["target_fps"] if ltx2 else None, target_fps=ltx2["target_fps"] if ltx2 else None,
snap32=ltx2["snap32"] if ltx2 else False, snap32=ltx2["snap32"] if ltx2 else False,
frames=ltx2["frames"] if ltx2 else None, frames=ltx2["frames"] if ltx2 else None,
stream_copy=stream_copy,
) )
self._export_worker.finished.connect(self._on_reexport_clip_done) self._export_worker.finished.connect(self._on_reexport_clip_done)
self._export_worker.all_done.connect(self._on_reexport_batch_done) self._export_worker.all_done.connect(self._on_reexport_batch_done)
@@ -9896,10 +10025,13 @@ class MainWindow(QMainWindow):
path, path,
label=meta.get("label", ""), label=meta.get("label", ""),
category=meta.get("category", ""), category=meta.get("category", ""),
short_side=self._spn_resize.value() or None, short_side=self._export_short_side,
portrait_ratio=meta.get("portrait_ratio", ""), portrait_ratio=(
"" if self._export_stream_copy
else meta.get("portrait_ratio", "")
),
crop_center=meta.get("crop_center", 0.5), 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_count=meta.get("clip_count", 1),
clip_duration=self._export_clip_duration, clip_duration=self._export_clip_duration,
spread=self._spn_spread.value(), 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) 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): def test_scan_button_in_audio_tab(win):
from PyQt6.QtWidgets import QPushButton from PyQt6.QtWidgets import QPushButton
assert win._btn_scan in win._tab_audio.findChildren(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=0) == "/out/clip_001_0.mp4"
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.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(): 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=0) == "/out/clip_001_0"
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1" 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 "scale" in vf_value
assert cmd[-1] == "/out/clip_001.mp4" 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(): def test_audio_clip_command_exact_length():
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav") cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")