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,"
|
" crop_center REAL NOT NULL DEFAULT 0.5,"
|
||||||
" format TEXT NOT NULL DEFAULT 'MP4',"
|
" format TEXT NOT NULL DEFAULT 'MP4',"
|
||||||
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
||||||
|
" clip_duration REAL NOT NULL DEFAULT 8.0,"
|
||||||
" spread REAL NOT NULL DEFAULT 3.0,"
|
" spread REAL NOT NULL DEFAULT 3.0,"
|
||||||
" profile TEXT NOT NULL DEFAULT 'default',"
|
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||||
" source_path TEXT NOT NULL DEFAULT '',"
|
" source_path TEXT NOT NULL DEFAULT '',"
|
||||||
@@ -63,6 +64,7 @@ class ProcessedDB:
|
|||||||
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
||||||
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
||||||
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
||||||
|
"clip_duration": "REAL NOT NULL DEFAULT 8.0",
|
||||||
"spread": "REAL NOT NULL DEFAULT 3.0",
|
"spread": "REAL NOT NULL DEFAULT 3.0",
|
||||||
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
||||||
"source_path": "TEXT NOT NULL DEFAULT ''",
|
"source_path": "TEXT NOT NULL DEFAULT ''",
|
||||||
@@ -232,7 +234,8 @@ class ProcessedDB:
|
|||||||
label: str = "", category: str = "",
|
label: str = "", category: str = "",
|
||||||
short_side: int | None = None, portrait_ratio: str = "",
|
short_side: int | None = None, portrait_ratio: str = "",
|
||||||
crop_center: float = 0.5, fmt: str = "MP4",
|
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 = "",
|
profile: str = "default", source_path: str = "",
|
||||||
scan_export: bool = False) -> None:
|
scan_export: bool = False) -> None:
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
@@ -242,11 +245,12 @@ class ProcessedDB:
|
|||||||
"INSERT INTO processed"
|
"INSERT INTO processed"
|
||||||
" (filename, start_time, output_path, label, category,"
|
" (filename, start_time, output_path, label, category,"
|
||||||
" short_side, portrait_ratio, crop_center, format,"
|
" short_side, portrait_ratio, crop_center, format,"
|
||||||
" clip_count, spread, profile, source_path, scan_export, processed_at)"
|
" clip_count, clip_duration, spread, profile, source_path,"
|
||||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
" scan_export, processed_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(filename, start_time, output_path, label, category,
|
(filename, start_time, output_path, label, category,
|
||||||
short_side, portrait_ratio, crop_center, fmt,
|
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,
|
1 if scan_export else 0,
|
||||||
datetime.now(timezone.utc).isoformat()),
|
datetime.now(timezone.utc).isoformat()),
|
||||||
)
|
)
|
||||||
@@ -278,7 +282,7 @@ class ProcessedDB:
|
|||||||
cur.row_factory = sqlite3.Row
|
cur.row_factory = sqlite3.Row
|
||||||
row = cur.execute(
|
row = cur.execute(
|
||||||
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
||||||
" clip_count, spread"
|
" clip_count, clip_duration, spread"
|
||||||
" FROM processed WHERE output_path = ?",
|
" FROM processed WHERE output_path = ?",
|
||||||
(output_path,),
|
(output_path,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -383,13 +387,14 @@ class ProcessedDB:
|
|||||||
"""Return manual (non-scan) export groups for *filename*.
|
"""Return manual (non-scan) export groups for *filename*.
|
||||||
|
|
||||||
Each group dict has:
|
Each group dict has:
|
||||||
start_time, paths (list[str] sorted), clip_count, spread,
|
start_time, paths (list[str] sorted), clip_count, clip_duration,
|
||||||
short_side, portrait_ratio, crop_center, format, label, category
|
spread, short_side, portrait_ratio, crop_center, format, label,
|
||||||
|
category
|
||||||
"""
|
"""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return []
|
return []
|
||||||
rows = self._con.execute(
|
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"
|
" short_side, portrait_ratio, crop_center, format, label, category"
|
||||||
" FROM processed"
|
" FROM processed"
|
||||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||||
@@ -403,10 +408,11 @@ class ProcessedDB:
|
|||||||
groups[t] = {
|
groups[t] = {
|
||||||
"start_time": t,
|
"start_time": t,
|
||||||
"paths": [],
|
"paths": [],
|
||||||
"clip_count": r[2], "spread": r[3],
|
"clip_count": r[2], "clip_duration": r[3],
|
||||||
"short_side": r[4], "portrait_ratio": r[5],
|
"spread": r[4],
|
||||||
"crop_center": r[6], "format": r[7],
|
"short_side": r[5], "portrait_ratio": r[6],
|
||||||
"label": r[8], "category": r[9],
|
"crop_center": r[7], "format": r[8],
|
||||||
|
"label": r[9], "category": r[10],
|
||||||
}
|
}
|
||||||
groups[t]["paths"].append(r[1])
|
groups[t]["paths"].append(r[1])
|
||||||
return list(groups.values())
|
return list(groups.values())
|
||||||
@@ -447,7 +453,8 @@ class ProcessedDB:
|
|||||||
rows = self._con.execute(
|
rows = self._con.execute(
|
||||||
"SELECT filename, start_time, output_path, label, category,"
|
"SELECT filename, start_time, output_path, label, category,"
|
||||||
" short_side, portrait_ratio, crop_center, format,"
|
" 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,),
|
" FROM processed WHERE profile = ?", (src,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -455,10 +462,10 @@ class ProcessedDB:
|
|||||||
"INSERT INTO processed"
|
"INSERT INTO processed"
|
||||||
" (filename, start_time, output_path, label, category,"
|
" (filename, start_time, output_path, label, category,"
|
||||||
" short_side, portrait_ratio, crop_center, format,"
|
" short_side, portrait_ratio, crop_center, format,"
|
||||||
" clip_count, spread, profile, source_path, scan_export,"
|
" clip_count, clip_duration, spread, profile,"
|
||||||
" processed_at)"
|
" source_path, scan_export, processed_at)"
|
||||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(*r[:11], dst, *r[11:]),
|
(*r[:12], dst, *r[12:]),
|
||||||
)
|
)
|
||||||
total += len(rows)
|
total += len(rows)
|
||||||
# scan_results
|
# scan_results
|
||||||
|
|||||||
+5
-3
@@ -78,6 +78,7 @@ def build_ffmpeg_command(
|
|||||||
crop_center: float = 0.5,
|
crop_center: float = 0.5,
|
||||||
image_sequence: bool = False,
|
image_sequence: bool = False,
|
||||||
encoder: str = "libx264",
|
encoder: str = "libx264",
|
||||||
|
duration: float = 8.0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
||||||
# so there is no keyframe-alignment issue from pre-input seek.
|
# so there is no keyframe-alignment issue from pre-input seek.
|
||||||
@@ -96,7 +97,7 @@ def build_ffmpeg_command(
|
|||||||
"-threads", "0",
|
"-threads", "0",
|
||||||
"-ss", str(start),
|
"-ss", str(start),
|
||||||
"-i", input_path,
|
"-i", input_path,
|
||||||
"-t", "8",
|
"-t", str(duration),
|
||||||
]
|
]
|
||||||
|
|
||||||
filters: list[str] = []
|
filters: list[str] = []
|
||||||
@@ -141,14 +142,15 @@ def build_ffmpeg_command(
|
|||||||
return cmd
|
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."""
|
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
|
||||||
audio_path = sequence_dir + ".wav"
|
audio_path = sequence_dir + ".wav"
|
||||||
return [
|
return [
|
||||||
_bin("ffmpeg"), "-y",
|
_bin("ffmpeg"), "-y",
|
||||||
"-ss", str(start),
|
"-ss", str(start),
|
||||||
"-i", input_path,
|
"-i", input_path,
|
||||||
"-t", "8",
|
"-t", str(duration),
|
||||||
"-vn",
|
"-vn",
|
||||||
"-c:a", "pcm_s16le",
|
"-c:a", "pcm_s16le",
|
||||||
audio_path,
|
audio_path,
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class ExportWorker(QThread):
|
|||||||
short_side: int | None = None,
|
short_side: int | None = None,
|
||||||
image_sequence: bool = False,
|
image_sequence: bool = False,
|
||||||
max_workers: int | None = None,
|
max_workers: int | None = None,
|
||||||
encoder: str = "libx264"):
|
encoder: str = "libx264",
|
||||||
|
duration: float = 8.0):
|
||||||
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), ...]
|
||||||
@@ -79,6 +80,7 @@ class ExportWorker(QThread):
|
|||||||
self._image_sequence = image_sequence
|
self._image_sequence = image_sequence
|
||||||
self._max_workers = max_workers
|
self._max_workers = max_workers
|
||||||
self._encoder = encoder
|
self._encoder = encoder
|
||||||
|
self._duration = duration
|
||||||
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()
|
||||||
@@ -106,6 +108,7 @@ class ExportWorker(QThread):
|
|||||||
crop_center=crop_center,
|
crop_center=crop_center,
|
||||||
image_sequence=self._image_sequence,
|
image_sequence=self._image_sequence,
|
||||||
encoder=self._encoder,
|
encoder=self._encoder,
|
||||||
|
duration=self._duration,
|
||||||
)
|
)
|
||||||
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:
|
||||||
@@ -124,7 +127,8 @@ class ExportWorker(QThread):
|
|||||||
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
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)
|
||||||
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@@ -2287,18 +2291,64 @@ class TimelineWidget(QWidget):
|
|||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
|
|
||||||
class MpvWidget(QWidget):
|
class _CropOverlayWidget(QWidget):
|
||||||
"""Embeds mpv using an off-screen OpenGL FBO with QPainter readback.
|
"""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
|
def __init__(self, mpv_widget: "MpvWidget"):
|
||||||
surface. The FBO is read back to a QImage and displayed via QPainter,
|
super().__init__(mpv_widget)
|
||||||
bypassing Wayland sub-surface compositing issues that affect both
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
QOpenGLWidget and QOpenGLWindow+createWindowContainer.
|
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()
|
file_loaded = pyqtSignal()
|
||||||
crop_clicked = pyqtSignal(float)
|
crop_clicked = pyqtSignal(float)
|
||||||
time_pos_changed = pyqtSignal(float) # emits current playback position in seconds
|
time_pos_changed = pyqtSignal(float)
|
||||||
_do_file_loaded = pyqtSignal() # mpv thread → Qt main thread for file-loaded event
|
_do_file_loaded = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -2310,8 +2360,24 @@ class MpvWidget(QWidget):
|
|||||||
self._video_w: int = 0
|
self._video_w: int = 0
|
||||||
self._video_h: int = 0
|
self._video_h: int = 0
|
||||||
self._fbo = None
|
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.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
|
||||||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||||||
|
|
||||||
@@ -2335,7 +2401,7 @@ class MpvWidget(QWidget):
|
|||||||
self._get_proc_addr_fn = _get_proc_addr
|
self._get_proc_addr_fn = _get_proc_addr
|
||||||
|
|
||||||
self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv", hwdec="auto")
|
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:
|
try:
|
||||||
self._render_ctx = mpv.MpvRenderContext(
|
self._render_ctx = mpv.MpvRenderContext(
|
||||||
self._player, "opengl",
|
self._player, "opengl",
|
||||||
@@ -2348,17 +2414,12 @@ class MpvWidget(QWidget):
|
|||||||
|
|
||||||
self._gl_ctx.doneCurrent()
|
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 = QTimer(self)
|
||||||
self._render_timer.setInterval(16)
|
self._render_timer.setInterval(16)
|
||||||
self._render_timer.timeout.connect(self._poll_render)
|
self._render_timer.timeout.connect(self._poll_render)
|
||||||
self._render_timer.start()
|
self._render_timer.start()
|
||||||
|
|
||||||
self._do_file_loaded.connect(self._on_file_loaded_qt)
|
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")
|
@self._player.event_callback("file-loaded")
|
||||||
def _on_file_loaded(event):
|
def _on_file_loaded(event):
|
||||||
@@ -2385,6 +2446,8 @@ class MpvWidget(QWidget):
|
|||||||
"color": color or QColor(220, 60, 60, 200),
|
"color": color or QColor(220, 60, 60, 200),
|
||||||
"_fracs": None,
|
"_fracs": None,
|
||||||
})
|
})
|
||||||
|
if self._overlay_widget:
|
||||||
|
self._overlay_widget.update()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
||||||
@@ -2394,6 +2457,9 @@ class MpvWidget(QWidget):
|
|||||||
self._overlays = []
|
self._overlays = []
|
||||||
else:
|
else:
|
||||||
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
||||||
|
return
|
||||||
|
if self._overlay_widget:
|
||||||
|
self._overlay_widget.update()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _on_mpv_update(self):
|
def _on_mpv_update(self):
|
||||||
@@ -2401,6 +2467,9 @@ class MpvWidget(QWidget):
|
|||||||
self._needs_render = True
|
self._needs_render = True
|
||||||
|
|
||||||
def _poll_render(self):
|
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():
|
if self._needs_render and self._render_ctx and self._render_ctx.update():
|
||||||
self._needs_render = False
|
self._needs_render = False
|
||||||
self._render_frame()
|
self._render_frame()
|
||||||
@@ -2432,9 +2501,10 @@ class MpvWidget(QWidget):
|
|||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
# Re-render the current frame at the new widget size so it isn't
|
if self._wid_mode:
|
||||||
# stretched from the old FBO dimensions.
|
if self._overlay_widget:
|
||||||
if self._render_ctx:
|
self._overlay_widget.setGeometry(self.rect())
|
||||||
|
elif self._render_ctx:
|
||||||
self._render_frame()
|
self._render_frame()
|
||||||
|
|
||||||
def _video_rect(self) -> QRect:
|
def _video_rect(self) -> QRect:
|
||||||
@@ -2457,6 +2527,8 @@ class MpvWidget(QWidget):
|
|||||||
return QRect(0, (wh - draw_h) // 2, draw_w, draw_h)
|
return QRect(0, (wh - draw_h) // 2, draw_w, draw_h)
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
|
if self._wid_mode:
|
||||||
|
return
|
||||||
p = QPainter(self)
|
p = QPainter(self)
|
||||||
p.fillRect(self.rect(), QColor(0, 0, 0))
|
p.fillRect(self.rect(), QColor(0, 0, 0))
|
||||||
if self._frame and not self._frame.isNull():
|
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-a"] = "no"
|
||||||
self._player["ab-loop-b"] = "no"
|
self._player["ab-loop-b"] = "no"
|
||||||
self._player.pause = True
|
self._player.pause = True
|
||||||
|
if self._overlay_widget:
|
||||||
|
self._overlay_widget.update()
|
||||||
|
|
||||||
def get_duration(self) -> float:
|
def get_duration(self) -> float:
|
||||||
d = self._player.duration
|
d = self._player.duration
|
||||||
@@ -3121,7 +3195,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._timeline.setFixedHeight(160)
|
self._timeline.setFixedHeight(160)
|
||||||
_init_clips = int(self._settings.value("clip_count", "3"))
|
_init_clips = int(self._settings.value("clip_count", "3"))
|
||||||
_init_spread = float(self._settings.value("spread", "3.0"))
|
_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.cursor_changed.connect(self._on_cursor_changed)
|
||||||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||||||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
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.setToolTip("Pause playback (Space / K)")
|
||||||
self._btn_pause.clicked.connect(self._on_pause)
|
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 = QPushButton("🔒 Lock")
|
||||||
self._btn_lock.setCheckable(True)
|
self._btn_lock.setCheckable(True)
|
||||||
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
|
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")
|
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 = QSpinBox()
|
||||||
self._spn_clips.setRange(1, 99)
|
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"))
|
saved_clips = int(self._settings.value("clip_count", "3"))
|
||||||
self._spn_clips.setValue(saved_clips)
|
self._spn_clips.setValue(saved_clips)
|
||||||
self._spn_clips.valueChanged.connect(
|
self._spn_clips.valueChanged.connect(
|
||||||
@@ -3240,7 +3344,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._spn_spread.setRange(2.0, 8.0)
|
self._spn_spread.setRange(2.0, 8.0)
|
||||||
self._spn_spread.setSingleStep(0.5)
|
self._spn_spread.setSingleStep(0.5)
|
||||||
self._spn_spread.setSuffix("s")
|
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"))
|
saved_spread = float(self._settings.value("spread", "3.0"))
|
||||||
self._spn_spread.setValue(saved_spread)
|
self._spn_spread.setValue(saved_spread)
|
||||||
self._spn_spread.valueChanged.connect(
|
self._spn_spread.valueChanged.connect(
|
||||||
@@ -3304,7 +3408,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_scan.clicked.connect(self._start_scan)
|
self._btn_scan.clicked.connect(self._start_scan)
|
||||||
|
|
||||||
self._btn_auto_export = QPushButton("Auto")
|
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_auto_export.clicked.connect(self._auto_export)
|
||||||
|
|
||||||
self._btn_train = QPushButton("Train")
|
self._btn_train = QPushButton("Train")
|
||||||
@@ -3438,6 +3542,8 @@ class MainWindow(QMainWindow):
|
|||||||
transport_row = QHBoxLayout()
|
transport_row = QHBoxLayout()
|
||||||
transport_row.addWidget(self._btn_play)
|
transport_row.addWidget(self._btn_play)
|
||||||
transport_row.addWidget(self._btn_pause)
|
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._btn_lock)
|
||||||
transport_row.addWidget(self._lbl_time)
|
transport_row.addWidget(self._lbl_time)
|
||||||
transport_row.addStretch()
|
transport_row.addStretch()
|
||||||
@@ -3478,6 +3584,8 @@ class MainWindow(QMainWindow):
|
|||||||
settings_row.addWidget(QLabel("Format:"))
|
settings_row.addWidget(QLabel("Format:"))
|
||||||
settings_row.addWidget(self._cmb_format)
|
settings_row.addWidget(self._cmb_format)
|
||||||
settings_row.addWidget(self._chk_hw)
|
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(QLabel("Clips:"))
|
||||||
settings_row.addWidget(self._spn_clips)
|
settings_row.addWidget(self._spn_clips)
|
||||||
settings_row.addWidget(QLabel("Spread:"))
|
settings_row.addWidget(QLabel("Spread:"))
|
||||||
@@ -4063,8 +4171,9 @@ class MainWindow(QMainWindow):
|
|||||||
if self._btn_lock.isChecked():
|
if self._btn_lock.isChecked():
|
||||||
meta = self._db.get_by_output_path(output_path)
|
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_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()
|
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._cursor = next_pos
|
||||||
self._timeline.set_cursor(next_pos)
|
self._timeline.set_cursor(next_pos)
|
||||||
self._mpv.seek(next_pos)
|
self._mpv.seek(next_pos)
|
||||||
@@ -4110,6 +4219,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._cmb_format.setCurrentIndex(idx)
|
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:
|
||||||
|
self._spn_clip_dur.setValue(meta["clip_duration"])
|
||||||
if meta["spread"] is not None:
|
if meta["spread"] is not None:
|
||||||
self._spn_spread.setValue(meta["spread"])
|
self._spn_spread.setValue(meta["spread"])
|
||||||
if meta["crop_center"] is not None:
|
if meta["crop_center"] is not None:
|
||||||
@@ -4390,10 +4501,14 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._on_play(resume=True)
|
self._on_play(resume=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _clip_dur(self) -> float:
|
||||||
|
return self._spn_clip_dur.value()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _clip_span(self) -> float:
|
def _clip_span(self) -> float:
|
||||||
"""Total time covered by the overlapping clips."""
|
"""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):
|
def _on_play(self, resume: bool = False):
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
@@ -4407,6 +4522,15 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_pause(self):
|
def _on_pause(self):
|
||||||
self._mpv.stop_loop()
|
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):
|
def _autoclip(self):
|
||||||
"""Set clip count to fit the current pause position."""
|
"""Set clip count to fit the current pause position."""
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
@@ -4416,8 +4540,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
elapsed = play_t - self._cursor
|
elapsed = play_t - self._cursor
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
# n clips span 8 + (n-1)*spread seconds
|
n = int((elapsed - self._clip_dur) / spread) + 1
|
||||||
n = int((elapsed - 8.0) / spread) + 1
|
|
||||||
n = max(1, n)
|
n = max(1, n)
|
||||||
self._spn_clips.setValue(n)
|
self._spn_clips.setValue(n)
|
||||||
|
|
||||||
@@ -4641,6 +4764,7 @@ class MainWindow(QMainWindow):
|
|||||||
groups = self._build_export_spans(
|
groups = self._build_export_spans(
|
||||||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||||||
spread=self._spn_spread.value(),
|
spread=self._spn_spread.value(),
|
||||||
|
min_dur=self._clip_dur,
|
||||||
)
|
)
|
||||||
n = sum(len(g) for g in groups)
|
n = sum(len(g) for g in groups)
|
||||||
self._scan_panel.set_export_count(n, partial=partial)
|
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]],
|
def _build_export_spans(regions: list[tuple[float, float, float]],
|
||||||
fuse_gap: float = 30.0,
|
fuse_gap: float = 30.0,
|
||||||
spread: float = 3.0,
|
spread: float = 3.0,
|
||||||
min_dur: float = 8.0,
|
min_dur: float = 8.0, # caller passes self._clip_dur
|
||||||
) -> list[list[float]]:
|
) -> list[list[float]]:
|
||||||
"""Build export position groups from fused scan regions.
|
"""Build export position groups from fused scan regions.
|
||||||
|
|
||||||
@@ -5090,12 +5214,13 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
|
clip_dur = self._clip_dur
|
||||||
groups = self._build_export_spans(
|
groups = self._build_export_spans(
|
||||||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||||||
spread=spread,
|
spread=spread, min_dur=clip_dur,
|
||||||
)
|
)
|
||||||
if not groups:
|
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)
|
self._btn_auto_export.setEnabled(True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -5149,6 +5274,7 @@ class MainWindow(QMainWindow):
|
|||||||
"image_sequence": image_sequence,
|
"image_sequence": image_sequence,
|
||||||
"max_workers": max_workers,
|
"max_workers": max_workers,
|
||||||
"encoder": encoder,
|
"encoder": encoder,
|
||||||
|
"clip_duration": self._clip_dur,
|
||||||
"spread": spread,
|
"spread": spread,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
"format": fmt,
|
"format": fmt,
|
||||||
@@ -5174,6 +5300,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_crop_center = 0.5
|
self._export_crop_center = 0.5
|
||||||
self._export_format = batch["format"]
|
self._export_format = batch["format"]
|
||||||
self._export_clip_count = 1
|
self._export_clip_count = 1
|
||||||
|
self._export_clip_duration = batch["clip_duration"]
|
||||||
self._export_spread = batch["spread"]
|
self._export_spread = batch["spread"]
|
||||||
self._export_folder = batch["folder"]
|
self._export_folder = batch["folder"]
|
||||||
self._export_folder_suffix = ""
|
self._export_folder_suffix = ""
|
||||||
@@ -5198,6 +5325,7 @@ class MainWindow(QMainWindow):
|
|||||||
image_sequence=batch["image_sequence"],
|
image_sequence=batch["image_sequence"],
|
||||||
max_workers=batch["max_workers"],
|
max_workers=batch["max_workers"],
|
||||||
encoder=batch["encoder"],
|
encoder=batch["encoder"],
|
||||||
|
duration=batch["clip_duration"],
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -5230,6 +5358,7 @@ class MainWindow(QMainWindow):
|
|||||||
crop_center=0.5,
|
crop_center=0.5,
|
||||||
fmt=self._export_format,
|
fmt=self._export_format,
|
||||||
clip_count=1,
|
clip_count=1,
|
||||||
|
clip_duration=self._export_clip_duration,
|
||||||
spread=self._export_spread,
|
spread=self._export_spread,
|
||||||
profile=self._export_profile,
|
profile=self._export_profile,
|
||||||
source_path=batch_file,
|
source_path=batch_file,
|
||||||
@@ -5340,11 +5469,11 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Check for overlapping existing markers
|
# Check for overlapping existing markers
|
||||||
if not self._overwrite_path:
|
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:
|
for t, _num, _path in self._timeline._markers:
|
||||||
if abs(t - self._cursor) < 0.1:
|
if abs(t - self._cursor) < 0.1:
|
||||||
continue # same position (overwrite case)
|
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:
|
if self._cursor < marker_end and clip_end > t:
|
||||||
self._show_status("Warning: overlaps with existing export", 3000)
|
self._show_status("Warning: overlaps with existing export", 3000)
|
||||||
break
|
break
|
||||||
@@ -5468,6 +5597,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_crop_center = self._crop_center
|
self._export_crop_center = self._crop_center
|
||||||
self._export_format = fmt
|
self._export_format = fmt
|
||||||
self._export_clip_count = self._spn_clips.value()
|
self._export_clip_count = self._spn_clips.value()
|
||||||
|
self._export_clip_duration = self._clip_dur
|
||||||
self._export_spread = self._spn_spread.value()
|
self._export_spread = self._spn_spread.value()
|
||||||
self._export_folder = folder
|
self._export_folder = folder
|
||||||
self._export_folder_suffix = folder_suffix
|
self._export_folder_suffix = folder_suffix
|
||||||
@@ -5497,6 +5627,7 @@ class MainWindow(QMainWindow):
|
|||||||
image_sequence=image_sequence,
|
image_sequence=image_sequence,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
encoder=encoder,
|
encoder=encoder,
|
||||||
|
duration=self._clip_dur,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -5521,6 +5652,7 @@ class MainWindow(QMainWindow):
|
|||||||
crop_center=self._export_crop_center,
|
crop_center=self._export_crop_center,
|
||||||
fmt=self._export_format,
|
fmt=self._export_format,
|
||||||
clip_count=self._export_clip_count,
|
clip_count=self._export_clip_count,
|
||||||
|
clip_duration=self._export_clip_duration,
|
||||||
spread=self._export_spread,
|
spread=self._export_spread,
|
||||||
profile=self._export_profile,
|
profile=self._export_profile,
|
||||||
source_path=self._file_path,
|
source_path=self._file_path,
|
||||||
@@ -5611,12 +5743,14 @@ class MainWindow(QMainWindow):
|
|||||||
folder = self._txt_folder.text()
|
folder = self._txt_folder.text()
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
|
|
||||||
|
clip_dur = self._clip_dur
|
||||||
# Compute clip counts for both modes.
|
# Compute clip counts for both modes.
|
||||||
keep_length_total = 0
|
keep_length_total = 0
|
||||||
keep_count_total = 0
|
keep_count_total = 0
|
||||||
for g in groups:
|
for g in groups:
|
||||||
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
|
orig_dur = g.get("clip_duration", 8.0)
|
||||||
keep_length_n = max(1, int((orig_span - 8.0) / spread) + 1)
|
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_length_total += keep_length_n
|
||||||
keep_count_total += g["clip_count"]
|
keep_count_total += g["clip_count"]
|
||||||
|
|
||||||
@@ -5693,8 +5827,9 @@ class MainWindow(QMainWindow):
|
|||||||
ratio = g["portrait_ratio"] or None
|
ratio = g["portrait_ratio"] or None
|
||||||
center = g["crop_center"]
|
center = g["crop_center"]
|
||||||
if keep_length:
|
if keep_length:
|
||||||
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
|
orig_dur = g.get("clip_duration", 8.0)
|
||||||
n_clips = max(1, int((orig_span - 8.0) / spread) + 1)
|
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||||||
|
n_clips = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||||||
else:
|
else:
|
||||||
n_clips = g["clip_count"]
|
n_clips = g["clip_count"]
|
||||||
tag = f"m{manual_n}"
|
tag = f"m{manual_n}"
|
||||||
@@ -5719,7 +5854,9 @@ class MainWindow(QMainWindow):
|
|||||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
hw_on = 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
|
||||||
self._export_spread = spread
|
self._export_spread = spread
|
||||||
|
self._export_clip_duration = clip_dur
|
||||||
self._export_folder = folder
|
self._export_folder = folder
|
||||||
self._export_profile = self._profile
|
self._export_profile = self._profile
|
||||||
|
|
||||||
@@ -5734,6 +5871,7 @@ class MainWindow(QMainWindow):
|
|||||||
image_sequence=image_sequence,
|
image_sequence=image_sequence,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
encoder=encoder,
|
encoder=encoder,
|
||||||
|
duration=clip_dur,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -5755,6 +5893,7 @@ class MainWindow(QMainWindow):
|
|||||||
crop_center=meta.get("crop_center", 0.5),
|
crop_center=meta.get("crop_center", 0.5),
|
||||||
fmt=self._cmb_format.currentText(),
|
fmt=self._cmb_format.currentText(),
|
||||||
clip_count=meta.get("clip_count", 1),
|
clip_count=meta.get("clip_count", 1),
|
||||||
|
clip_duration=self._export_clip_duration,
|
||||||
spread=self._spn_spread.value(),
|
spread=self._spn_spread.value(),
|
||||||
profile=self._export_profile,
|
profile=self._export_profile,
|
||||||
source_path=self._file_path,
|
source_path=self._file_path,
|
||||||
|
|||||||
Reference in New Issue
Block a user