feat: add stream-copy exports
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user