feat: configurable clip duration, playback speed, Windows WId embedding
Add clip duration spinner (2–30s, default 8s) replacing all hardcoded 8.0 references. Store clip_duration in DB for accurate re-export span calculations. Add x2/x4 playback speed toggle buttons. On Windows, mpv renders directly into the widget's native window handle (WId embedding) instead of slow FBO readback; crop overlays use a transparent child widget. Fix _poll_render crash when player is None after closeEvent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+24
-17
@@ -46,6 +46,7 @@ class ProcessedDB:
|
||||
" crop_center REAL NOT NULL DEFAULT 0.5,"
|
||||
" format TEXT NOT NULL DEFAULT 'MP4',"
|
||||
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
||||
" clip_duration REAL NOT NULL DEFAULT 8.0,"
|
||||
" spread REAL NOT NULL DEFAULT 3.0,"
|
||||
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||
" source_path TEXT NOT NULL DEFAULT '',"
|
||||
@@ -63,6 +64,7 @@ class ProcessedDB:
|
||||
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
||||
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
||||
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
||||
"clip_duration": "REAL NOT NULL DEFAULT 8.0",
|
||||
"spread": "REAL NOT NULL DEFAULT 3.0",
|
||||
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
||||
"source_path": "TEXT NOT NULL DEFAULT ''",
|
||||
@@ -232,7 +234,8 @@ class ProcessedDB:
|
||||
label: str = "", category: str = "",
|
||||
short_side: int | None = None, portrait_ratio: str = "",
|
||||
crop_center: float = 0.5, fmt: str = "MP4",
|
||||
clip_count: int = 3, spread: float = 3.0,
|
||||
clip_count: int = 3, clip_duration: float = 8.0,
|
||||
spread: float = 3.0,
|
||||
profile: str = "default", source_path: str = "",
|
||||
scan_export: bool = False) -> None:
|
||||
if not self._enabled:
|
||||
@@ -242,11 +245,12 @@ class ProcessedDB:
|
||||
"INSERT INTO processed"
|
||||
" (filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread, profile, source_path, scan_export, processed_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
" clip_count, clip_duration, spread, profile, source_path,"
|
||||
" scan_export, processed_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(filename, start_time, output_path, label, category,
|
||||
short_side, portrait_ratio, crop_center, fmt,
|
||||
clip_count, spread, profile, source_path,
|
||||
clip_count, clip_duration, spread, profile, source_path,
|
||||
1 if scan_export else 0,
|
||||
datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
@@ -278,7 +282,7 @@ class ProcessedDB:
|
||||
cur.row_factory = sqlite3.Row
|
||||
row = cur.execute(
|
||||
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread"
|
||||
" clip_count, clip_duration, spread"
|
||||
" FROM processed WHERE output_path = ?",
|
||||
(output_path,),
|
||||
).fetchone()
|
||||
@@ -383,13 +387,14 @@ class ProcessedDB:
|
||||
"""Return manual (non-scan) export groups for *filename*.
|
||||
|
||||
Each group dict has:
|
||||
start_time, paths (list[str] sorted), clip_count, spread,
|
||||
short_side, portrait_ratio, crop_center, format, label, category
|
||||
start_time, paths (list[str] sorted), clip_count, clip_duration,
|
||||
spread, short_side, portrait_ratio, crop_center, format, label,
|
||||
category
|
||||
"""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_count, spread,"
|
||||
"SELECT start_time, output_path, clip_count, clip_duration, spread,"
|
||||
" short_side, portrait_ratio, crop_center, format, label, category"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
@@ -403,10 +408,11 @@ class ProcessedDB:
|
||||
groups[t] = {
|
||||
"start_time": t,
|
||||
"paths": [],
|
||||
"clip_count": r[2], "spread": r[3],
|
||||
"short_side": r[4], "portrait_ratio": r[5],
|
||||
"crop_center": r[6], "format": r[7],
|
||||
"label": r[8], "category": r[9],
|
||||
"clip_count": r[2], "clip_duration": r[3],
|
||||
"spread": r[4],
|
||||
"short_side": r[5], "portrait_ratio": r[6],
|
||||
"crop_center": r[7], "format": r[8],
|
||||
"label": r[9], "category": r[10],
|
||||
}
|
||||
groups[t]["paths"].append(r[1])
|
||||
return list(groups.values())
|
||||
@@ -447,7 +453,8 @@ class ProcessedDB:
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread, source_path, scan_export, processed_at"
|
||||
" clip_count, clip_duration, spread, source_path, scan_export,"
|
||||
" processed_at"
|
||||
" FROM processed WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
@@ -455,10 +462,10 @@ class ProcessedDB:
|
||||
"INSERT INTO processed"
|
||||
" (filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread, profile, source_path, scan_export,"
|
||||
" processed_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(*r[:11], dst, *r[11:]),
|
||||
" clip_count, clip_duration, spread, profile,"
|
||||
" source_path, scan_export, processed_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(*r[:12], dst, *r[12:]),
|
||||
)
|
||||
total += len(rows)
|
||||
# scan_results
|
||||
|
||||
+5
-3
@@ -78,6 +78,7 @@ def build_ffmpeg_command(
|
||||
crop_center: float = 0.5,
|
||||
image_sequence: bool = False,
|
||||
encoder: str = "libx264",
|
||||
duration: float = 8.0,
|
||||
) -> 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.
|
||||
@@ -96,7 +97,7 @@ def build_ffmpeg_command(
|
||||
"-threads", "0",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", "8",
|
||||
"-t", str(duration),
|
||||
]
|
||||
|
||||
filters: list[str] = []
|
||||
@@ -141,14 +142,15 @@ def build_ffmpeg_command(
|
||||
return cmd
|
||||
|
||||
|
||||
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
||||
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str,
|
||||
duration: float = 8.0) -> list[str]:
|
||||
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
|
||||
audio_path = sequence_dir + ".wav"
|
||||
return [
|
||||
_bin("ffmpeg"), "-y",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", "8",
|
||||
"-t", str(duration),
|
||||
"-vn",
|
||||
"-c:a", "pcm_s16le",
|
||||
audio_path,
|
||||
|
||||
@@ -71,7 +71,8 @@ class ExportWorker(QThread):
|
||||
short_side: int | None = None,
|
||||
image_sequence: bool = False,
|
||||
max_workers: int | None = None,
|
||||
encoder: str = "libx264"):
|
||||
encoder: str = "libx264",
|
||||
duration: float = 8.0):
|
||||
super().__init__()
|
||||
self._input = input_path
|
||||
self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...]
|
||||
@@ -79,6 +80,7 @@ class ExportWorker(QThread):
|
||||
self._image_sequence = image_sequence
|
||||
self._max_workers = max_workers
|
||||
self._encoder = encoder
|
||||
self._duration = duration
|
||||
self._cancel = False
|
||||
self._procs: list[subprocess.Popen] = []
|
||||
self._procs_lock = __import__('threading').Lock()
|
||||
@@ -106,6 +108,7 @@ class ExportWorker(QThread):
|
||||
crop_center=crop_center,
|
||||
image_sequence=self._image_sequence,
|
||||
encoder=self._encoder,
|
||||
duration=self._duration,
|
||||
)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
with self._procs_lock:
|
||||
@@ -124,7 +127,8 @@ class ExportWorker(QThread):
|
||||
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||||
raise RuntimeError(msg)
|
||||
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)
|
||||
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||
return output
|
||||
|
||||
@@ -2287,18 +2291,64 @@ class TimelineWidget(QWidget):
|
||||
import ctypes
|
||||
|
||||
|
||||
class MpvWidget(QWidget):
|
||||
"""Embeds mpv using an off-screen OpenGL FBO with QPainter readback.
|
||||
class _CropOverlayWidget(QWidget):
|
||||
"""Transparent child widget for drawing crop overlays on top of native mpv window (WId mode)."""
|
||||
|
||||
mpv renders each frame into a QOpenGLFramebufferObject on an off-screen
|
||||
surface. The FBO is read back to a QImage and displayed via QPainter,
|
||||
bypassing Wayland sub-surface compositing issues that affect both
|
||||
QOpenGLWidget and QOpenGLWindow+createWindowContainer.
|
||||
def __init__(self, mpv_widget: "MpvWidget"):
|
||||
super().__init__(mpv_widget)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self._mpv = mpv_widget
|
||||
|
||||
def paintEvent(self, event):
|
||||
mw = self._mpv
|
||||
if not mw._overlays or not mw._player or not mw._player.pause:
|
||||
return
|
||||
vw, vh = mw._video_w, mw._video_h
|
||||
vr = mw._video_rect()
|
||||
p = QPainter(self)
|
||||
for ov in mw._overlays:
|
||||
if ov["_fracs"] is None and vw > 0 and vh > 0:
|
||||
num, den = ov["ratio"]
|
||||
crop_w_frac = min((vh * num / den) / vw, 1.0)
|
||||
half = crop_w_frac / 2.0
|
||||
center = ov["center"]
|
||||
ov["_fracs"] = (
|
||||
max(0.0, center - half),
|
||||
min(1.0, center + half),
|
||||
)
|
||||
if ov["_fracs"] is None:
|
||||
continue
|
||||
left_frac, right_frac = ov["_fracs"]
|
||||
left_px = vr.x() + int(left_frac * vr.width())
|
||||
right_px = vr.x() + int(right_frac * vr.width())
|
||||
color = ov["color"]
|
||||
if ov["lines_only"]:
|
||||
line_pen = QPen(color)
|
||||
line_pen.setWidth(2)
|
||||
p.setPen(line_pen)
|
||||
p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height())
|
||||
p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height())
|
||||
else:
|
||||
cut_color = QColor(color.red(), color.green(), color.blue(), 140)
|
||||
if left_px > vr.x():
|
||||
p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color)
|
||||
if right_px < vr.x() + vr.width():
|
||||
p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color)
|
||||
p.end()
|
||||
|
||||
|
||||
class MpvWidget(QWidget):
|
||||
"""Embeds mpv for video playback.
|
||||
|
||||
On Windows, mpv renders directly into the widget's native window handle
|
||||
(WId embedding) for best performance. On Linux, an off-screen OpenGL FBO
|
||||
is used with QPainter readback to avoid Wayland compositing issues.
|
||||
"""
|
||||
file_loaded = pyqtSignal()
|
||||
crop_clicked = pyqtSignal(float)
|
||||
time_pos_changed = pyqtSignal(float) # emits current playback position in seconds
|
||||
_do_file_loaded = pyqtSignal() # mpv thread → Qt main thread for file-loaded event
|
||||
time_pos_changed = pyqtSignal(float)
|
||||
_do_file_loaded = pyqtSignal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -2310,8 +2360,24 @@ class MpvWidget(QWidget):
|
||||
self._video_w: int = 0
|
||||
self._video_h: int = 0
|
||||
self._fbo = None
|
||||
self._needs_render = False # set True by mpv update_cb (any thread)
|
||||
self._needs_render = False
|
||||
self._overlays: list[dict] = []
|
||||
self._overlay_widget: "_CropOverlayWidget | None" = None
|
||||
|
||||
self._wid_mode = sys.platform == "win32"
|
||||
|
||||
if self._wid_mode:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||||
self._player = mpv.MPV(
|
||||
keep_open=True, pause=True,
|
||||
wid=str(int(self.winId())),
|
||||
hwdec="auto",
|
||||
)
|
||||
_log("mpv created with WId embedding (Windows)")
|
||||
self._overlay_widget = _CropOverlayWidget(self)
|
||||
self._overlay_widget.setGeometry(self.rect())
|
||||
self._overlay_widget.show()
|
||||
else:
|
||||
from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
|
||||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||||
|
||||
@@ -2335,7 +2401,7 @@ class MpvWidget(QWidget):
|
||||
self._get_proc_addr_fn = _get_proc_addr
|
||||
|
||||
self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv", hwdec="auto")
|
||||
_log("mpv created (hwdec=auto)")
|
||||
_log("mpv created (FBO readback, hwdec=auto)")
|
||||
try:
|
||||
self._render_ctx = mpv.MpvRenderContext(
|
||||
self._player, "opengl",
|
||||
@@ -2348,17 +2414,12 @@ class MpvWidget(QWidget):
|
||||
|
||||
self._gl_ctx.doneCurrent()
|
||||
|
||||
# Timer polls for new frames at ~60 fps; avoids flooding the event loop
|
||||
# from mpv's C thread which calls update_cb at playback rate.
|
||||
self._render_timer = QTimer(self)
|
||||
self._render_timer.setInterval(16)
|
||||
self._render_timer.timeout.connect(self._poll_render)
|
||||
self._render_timer.start()
|
||||
|
||||
self._do_file_loaded.connect(self._on_file_loaded_qt)
|
||||
# Each overlay: {"ratio": (num,den), "center": float, "lines_only": bool,
|
||||
# "color": QColor, "_fracs": (left,right)|None}
|
||||
self._overlays: list[dict] = []
|
||||
|
||||
@self._player.event_callback("file-loaded")
|
||||
def _on_file_loaded(event):
|
||||
@@ -2385,6 +2446,8 @@ class MpvWidget(QWidget):
|
||||
"color": color or QColor(220, 60, 60, 200),
|
||||
"_fracs": None,
|
||||
})
|
||||
if self._overlay_widget:
|
||||
self._overlay_widget.update()
|
||||
self.update()
|
||||
|
||||
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
||||
@@ -2394,6 +2457,9 @@ class MpvWidget(QWidget):
|
||||
self._overlays = []
|
||||
else:
|
||||
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
||||
return
|
||||
if self._overlay_widget:
|
||||
self._overlay_widget.update()
|
||||
self.update()
|
||||
|
||||
def _on_mpv_update(self):
|
||||
@@ -2401,6 +2467,9 @@ class MpvWidget(QWidget):
|
||||
self._needs_render = True
|
||||
|
||||
def _poll_render(self):
|
||||
if not self._player:
|
||||
return
|
||||
if not self._wid_mode:
|
||||
if self._needs_render and self._render_ctx and self._render_ctx.update():
|
||||
self._needs_render = False
|
||||
self._render_frame()
|
||||
@@ -2432,9 +2501,10 @@ class MpvWidget(QWidget):
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
# Re-render the current frame at the new widget size so it isn't
|
||||
# stretched from the old FBO dimensions.
|
||||
if self._render_ctx:
|
||||
if self._wid_mode:
|
||||
if self._overlay_widget:
|
||||
self._overlay_widget.setGeometry(self.rect())
|
||||
elif self._render_ctx:
|
||||
self._render_frame()
|
||||
|
||||
def _video_rect(self) -> QRect:
|
||||
@@ -2457,6 +2527,8 @@ class MpvWidget(QWidget):
|
||||
return QRect(0, (wh - draw_h) // 2, draw_w, draw_h)
|
||||
|
||||
def paintEvent(self, event):
|
||||
if self._wid_mode:
|
||||
return
|
||||
p = QPainter(self)
|
||||
p.fillRect(self.rect(), QColor(0, 0, 0))
|
||||
if self._frame and not self._frame.isNull():
|
||||
@@ -2527,6 +2599,8 @@ class MpvWidget(QWidget):
|
||||
self._player["ab-loop-a"] = "no"
|
||||
self._player["ab-loop-b"] = "no"
|
||||
self._player.pause = True
|
||||
if self._overlay_widget:
|
||||
self._overlay_widget.update()
|
||||
|
||||
def get_duration(self) -> float:
|
||||
d = self._player.duration
|
||||
@@ -3121,7 +3195,8 @@ class MainWindow(QMainWindow):
|
||||
self._timeline.setFixedHeight(160)
|
||||
_init_clips = int(self._settings.value("clip_count", "3"))
|
||||
_init_spread = float(self._settings.value("spread", "3.0"))
|
||||
self._timeline.set_clip_span(8.0 + (_init_clips - 1) * _init_spread)
|
||||
_init_dur = float(self._settings.value("clip_duration", "8.0"))
|
||||
self._timeline.set_clip_span(_init_dur + (_init_clips - 1) * _init_spread)
|
||||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
||||
@@ -3150,6 +3225,18 @@ class MainWindow(QMainWindow):
|
||||
self._btn_pause.setToolTip("Pause playback (Space / K)")
|
||||
self._btn_pause.clicked.connect(self._on_pause)
|
||||
|
||||
self._btn_speed2 = QPushButton("x2")
|
||||
self._btn_speed2.setCheckable(True)
|
||||
self._btn_speed2.setFixedWidth(32)
|
||||
self._btn_speed2.setToolTip("Playback at 2× speed")
|
||||
self._btn_speed2.clicked.connect(lambda: self._set_playback_speed(2.0))
|
||||
|
||||
self._btn_speed4 = QPushButton("x4")
|
||||
self._btn_speed4.setCheckable(True)
|
||||
self._btn_speed4.setFixedWidth(32)
|
||||
self._btn_speed4.setToolTip("Playback at 4× speed")
|
||||
self._btn_speed4.clicked.connect(lambda: self._set_playback_speed(4.0))
|
||||
|
||||
self._btn_lock = QPushButton("🔒 Lock")
|
||||
self._btn_lock.setCheckable(True)
|
||||
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
|
||||
@@ -3221,9 +3308,26 @@ class MainWindow(QMainWindow):
|
||||
lambda v: self._settings.setValue("hw_encode", "true" if v else "false")
|
||||
)
|
||||
|
||||
self._spn_clip_dur = QDoubleSpinBox()
|
||||
self._spn_clip_dur.setRange(2.0, 30.0)
|
||||
self._spn_clip_dur.setSingleStep(0.5)
|
||||
self._spn_clip_dur.setSuffix("s")
|
||||
self._spn_clip_dur.setToolTip("Duration of each exported clip")
|
||||
saved_clip_dur = float(self._settings.value("clip_duration", "8.0"))
|
||||
self._spn_clip_dur.setValue(saved_clip_dur)
|
||||
self._spn_clip_dur.valueChanged.connect(
|
||||
lambda v: self._settings.setValue("clip_duration", str(v))
|
||||
)
|
||||
self._spn_clip_dur.valueChanged.connect(
|
||||
lambda: self._timeline.set_clip_span(self._clip_span)
|
||||
)
|
||||
self._spn_clip_dur.valueChanged.connect(lambda: self._update_next_label())
|
||||
self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start())
|
||||
self._spn_clip_dur.valueChanged.connect(self._update_play_loop)
|
||||
|
||||
self._spn_clips = QSpinBox()
|
||||
self._spn_clips.setRange(1, 99)
|
||||
self._spn_clips.setToolTip("Number of overlapping 8s clips per export")
|
||||
self._spn_clips.setToolTip("Number of overlapping clips per export")
|
||||
saved_clips = int(self._settings.value("clip_count", "3"))
|
||||
self._spn_clips.setValue(saved_clips)
|
||||
self._spn_clips.valueChanged.connect(
|
||||
@@ -3240,7 +3344,7 @@ class MainWindow(QMainWindow):
|
||||
self._spn_spread.setRange(2.0, 8.0)
|
||||
self._spn_spread.setSingleStep(0.5)
|
||||
self._spn_spread.setSuffix("s")
|
||||
self._spn_spread.setToolTip("Offset between overlapping 8s clips")
|
||||
self._spn_spread.setToolTip("Offset between overlapping clips")
|
||||
saved_spread = float(self._settings.value("spread", "3.0"))
|
||||
self._spn_spread.setValue(saved_spread)
|
||||
self._spn_spread.valueChanged.connect(
|
||||
@@ -3304,7 +3408,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_scan.clicked.connect(self._start_scan)
|
||||
|
||||
self._btn_auto_export = QPushButton("Auto")
|
||||
self._btn_auto_export.setToolTip("Scan + auto-export best 8s clips")
|
||||
self._btn_auto_export.setToolTip("Scan + auto-export best clips")
|
||||
self._btn_auto_export.clicked.connect(self._auto_export)
|
||||
|
||||
self._btn_train = QPushButton("Train")
|
||||
@@ -3438,6 +3542,8 @@ class MainWindow(QMainWindow):
|
||||
transport_row = QHBoxLayout()
|
||||
transport_row.addWidget(self._btn_play)
|
||||
transport_row.addWidget(self._btn_pause)
|
||||
transport_row.addWidget(self._btn_speed2)
|
||||
transport_row.addWidget(self._btn_speed4)
|
||||
transport_row.addWidget(self._btn_lock)
|
||||
transport_row.addWidget(self._lbl_time)
|
||||
transport_row.addStretch()
|
||||
@@ -3478,6 +3584,8 @@ class MainWindow(QMainWindow):
|
||||
settings_row.addWidget(QLabel("Format:"))
|
||||
settings_row.addWidget(self._cmb_format)
|
||||
settings_row.addWidget(self._chk_hw)
|
||||
settings_row.addWidget(QLabel("Dur:"))
|
||||
settings_row.addWidget(self._spn_clip_dur)
|
||||
settings_row.addWidget(QLabel("Clips:"))
|
||||
settings_row.addWidget(self._spn_clips)
|
||||
settings_row.addWidget(QLabel("Spread:"))
|
||||
@@ -4063,8 +4171,9 @@ class MainWindow(QMainWindow):
|
||||
if self._btn_lock.isChecked():
|
||||
meta = self._db.get_by_output_path(output_path)
|
||||
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
|
||||
clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur
|
||||
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
|
||||
next_pos = start_time + 8.0 + (clip_count - 1) * spread
|
||||
next_pos = start_time + clip_dur + (clip_count - 1) * spread
|
||||
self._cursor = next_pos
|
||||
self._timeline.set_cursor(next_pos)
|
||||
self._mpv.seek(next_pos)
|
||||
@@ -4110,6 +4219,8 @@ class MainWindow(QMainWindow):
|
||||
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:
|
||||
self._spn_clip_dur.setValue(meta["clip_duration"])
|
||||
if meta["spread"] is not None:
|
||||
self._spn_spread.setValue(meta["spread"])
|
||||
if meta["crop_center"] is not None:
|
||||
@@ -4390,10 +4501,14 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._on_play(resume=True)
|
||||
|
||||
@property
|
||||
def _clip_dur(self) -> float:
|
||||
return self._spn_clip_dur.value()
|
||||
|
||||
@property
|
||||
def _clip_span(self) -> float:
|
||||
"""Total time covered by the overlapping clips."""
|
||||
return 8.0 + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||||
return self._clip_dur + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||||
|
||||
def _on_play(self, resume: bool = False):
|
||||
if not self._file_path:
|
||||
@@ -4407,6 +4522,15 @@ class MainWindow(QMainWindow):
|
||||
def _on_pause(self):
|
||||
self._mpv.stop_loop()
|
||||
|
||||
def _set_playback_speed(self, speed: float) -> None:
|
||||
btn = self._btn_speed2 if speed == 2.0 else self._btn_speed4
|
||||
other = self._btn_speed4 if speed == 2.0 else self._btn_speed2
|
||||
if btn.isChecked():
|
||||
self._mpv._player.speed = speed
|
||||
other.setChecked(False)
|
||||
else:
|
||||
self._mpv._player.speed = 1.0
|
||||
|
||||
def _autoclip(self):
|
||||
"""Set clip count to fit the current pause position."""
|
||||
if not self._file_path:
|
||||
@@ -4416,8 +4540,7 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
elapsed = play_t - self._cursor
|
||||
spread = self._spn_spread.value()
|
||||
# n clips span 8 + (n-1)*spread seconds
|
||||
n = int((elapsed - 8.0) / spread) + 1
|
||||
n = int((elapsed - self._clip_dur) / spread) + 1
|
||||
n = max(1, n)
|
||||
self._spn_clips.setValue(n)
|
||||
|
||||
@@ -4641,6 +4764,7 @@ class MainWindow(QMainWindow):
|
||||
groups = self._build_export_spans(
|
||||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||||
spread=self._spn_spread.value(),
|
||||
min_dur=self._clip_dur,
|
||||
)
|
||||
n = sum(len(g) for g in groups)
|
||||
self._scan_panel.set_export_count(n, partial=partial)
|
||||
@@ -5019,7 +5143,7 @@ class MainWindow(QMainWindow):
|
||||
def _build_export_spans(regions: list[tuple[float, float, float]],
|
||||
fuse_gap: float = 30.0,
|
||||
spread: float = 3.0,
|
||||
min_dur: float = 8.0,
|
||||
min_dur: float = 8.0, # caller passes self._clip_dur
|
||||
) -> list[list[float]]:
|
||||
"""Build export position groups from fused scan regions.
|
||||
|
||||
@@ -5090,12 +5214,13 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
spread = self._spn_spread.value()
|
||||
clip_dur = self._clip_dur
|
||||
groups = self._build_export_spans(
|
||||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||||
spread=spread,
|
||||
spread=spread, min_dur=clip_dur,
|
||||
)
|
||||
if not groups:
|
||||
self._show_status("Auto: no regions >= 8s")
|
||||
self._show_status(f"Auto: no regions >= {clip_dur}s")
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
return
|
||||
|
||||
@@ -5149,6 +5274,7 @@ class MainWindow(QMainWindow):
|
||||
"image_sequence": image_sequence,
|
||||
"max_workers": max_workers,
|
||||
"encoder": encoder,
|
||||
"clip_duration": self._clip_dur,
|
||||
"spread": spread,
|
||||
"folder": folder,
|
||||
"format": fmt,
|
||||
@@ -5174,6 +5300,7 @@ class MainWindow(QMainWindow):
|
||||
self._export_crop_center = 0.5
|
||||
self._export_format = batch["format"]
|
||||
self._export_clip_count = 1
|
||||
self._export_clip_duration = batch["clip_duration"]
|
||||
self._export_spread = batch["spread"]
|
||||
self._export_folder = batch["folder"]
|
||||
self._export_folder_suffix = ""
|
||||
@@ -5198,6 +5325,7 @@ class MainWindow(QMainWindow):
|
||||
image_sequence=batch["image_sequence"],
|
||||
max_workers=batch["max_workers"],
|
||||
encoder=batch["encoder"],
|
||||
duration=batch["clip_duration"],
|
||||
)
|
||||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||||
@@ -5230,6 +5358,7 @@ class MainWindow(QMainWindow):
|
||||
crop_center=0.5,
|
||||
fmt=self._export_format,
|
||||
clip_count=1,
|
||||
clip_duration=self._export_clip_duration,
|
||||
spread=self._export_spread,
|
||||
profile=self._export_profile,
|
||||
source_path=batch_file,
|
||||
@@ -5340,11 +5469,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Check for overlapping existing markers
|
||||
if not self._overwrite_path:
|
||||
clip_end = self._cursor + 8.0 + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||||
clip_end = self._cursor + self._clip_span
|
||||
for t, _num, _path in self._timeline._markers:
|
||||
if abs(t - self._cursor) < 0.1:
|
||||
continue # same position (overwrite case)
|
||||
marker_end = t + 8.0
|
||||
marker_end = t + self._clip_dur
|
||||
if self._cursor < marker_end and clip_end > t:
|
||||
self._show_status("Warning: overlaps with existing export", 3000)
|
||||
break
|
||||
@@ -5468,6 +5597,7 @@ class MainWindow(QMainWindow):
|
||||
self._export_crop_center = self._crop_center
|
||||
self._export_format = fmt
|
||||
self._export_clip_count = self._spn_clips.value()
|
||||
self._export_clip_duration = self._clip_dur
|
||||
self._export_spread = self._spn_spread.value()
|
||||
self._export_folder = folder
|
||||
self._export_folder_suffix = folder_suffix
|
||||
@@ -5497,6 +5627,7 @@ class MainWindow(QMainWindow):
|
||||
image_sequence=image_sequence,
|
||||
max_workers=max_workers,
|
||||
encoder=encoder,
|
||||
duration=self._clip_dur,
|
||||
)
|
||||
self._export_worker.finished.connect(self._on_clip_done)
|
||||
self._export_worker.all_done.connect(self._on_batch_done)
|
||||
@@ -5521,6 +5652,7 @@ class MainWindow(QMainWindow):
|
||||
crop_center=self._export_crop_center,
|
||||
fmt=self._export_format,
|
||||
clip_count=self._export_clip_count,
|
||||
clip_duration=self._export_clip_duration,
|
||||
spread=self._export_spread,
|
||||
profile=self._export_profile,
|
||||
source_path=self._file_path,
|
||||
@@ -5611,12 +5743,14 @@ class MainWindow(QMainWindow):
|
||||
folder = self._txt_folder.text()
|
||||
spread = self._spn_spread.value()
|
||||
|
||||
clip_dur = self._clip_dur
|
||||
# Compute clip counts for both modes.
|
||||
keep_length_total = 0
|
||||
keep_count_total = 0
|
||||
for g in groups:
|
||||
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
|
||||
keep_length_n = max(1, int((orig_span - 8.0) / spread) + 1)
|
||||
orig_dur = g.get("clip_duration", 8.0)
|
||||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||||
keep_length_n = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||||
keep_length_total += keep_length_n
|
||||
keep_count_total += g["clip_count"]
|
||||
|
||||
@@ -5693,8 +5827,9 @@ class MainWindow(QMainWindow):
|
||||
ratio = g["portrait_ratio"] or None
|
||||
center = g["crop_center"]
|
||||
if keep_length:
|
||||
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
|
||||
n_clips = max(1, int((orig_span - 8.0) / spread) + 1)
|
||||
orig_dur = g.get("clip_duration", 8.0)
|
||||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||||
n_clips = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||||
else:
|
||||
n_clips = g["clip_count"]
|
||||
tag = f"m{manual_n}"
|
||||
@@ -5719,7 +5854,9 @@ class MainWindow(QMainWindow):
|
||||
hw_on = 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
|
||||
self._export_spread = spread
|
||||
self._export_clip_duration = clip_dur
|
||||
self._export_folder = folder
|
||||
self._export_profile = self._profile
|
||||
|
||||
@@ -5734,6 +5871,7 @@ class MainWindow(QMainWindow):
|
||||
image_sequence=image_sequence,
|
||||
max_workers=max_workers,
|
||||
encoder=encoder,
|
||||
duration=clip_dur,
|
||||
)
|
||||
self._export_worker.finished.connect(self._on_reexport_clip_done)
|
||||
self._export_worker.all_done.connect(self._on_reexport_batch_done)
|
||||
@@ -5755,6 +5893,7 @@ class MainWindow(QMainWindow):
|
||||
crop_center=meta.get("crop_center", 0.5),
|
||||
fmt=self._cmb_format.currentText(),
|
||||
clip_count=meta.get("clip_count", 1),
|
||||
clip_duration=self._export_clip_duration,
|
||||
spread=self._spn_spread.value(),
|
||||
profile=self._export_profile,
|
||||
source_path=self._file_path,
|
||||
|
||||
Reference in New Issue
Block a user