Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e62a712de2 | |||
| 16e0bc231c | |||
| 4c44d78c37 | |||
| b57131a3d9 | |||
| 9652b249ba | |||
| fe3e642dfa | |||
| 855797a1b3 | |||
| f9649f7584 | |||
| 13c8a8aeab | |||
| c163b298c4 |
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QComboBox, QDialog, QPlainTextEdit, QCheckBox,
|
QComboBox, QDialog, QPlainTextEdit, QCheckBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QSettings
|
||||||
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont
|
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeyEvent
|
||||||
import mpv
|
import mpv
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +77,20 @@ def build_ffmpeg_command(
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
||||||
|
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
|
||||||
|
audio_path = sequence_dir + ".wav"
|
||||||
|
return [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-i", input_path,
|
||||||
|
"-t", "8",
|
||||||
|
"-vn",
|
||||||
|
"-c:a", "pcm_s16le",
|
||||||
|
audio_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_mask_output_dir(video_path: str) -> str:
|
def build_mask_output_dir(video_path: str) -> str:
|
||||||
"""Return path of mask output directory: <stem>_masks/ next to the video."""
|
"""Return path of mask output directory: <stem>_masks/ next to the video."""
|
||||||
p = Path(video_path)
|
p = Path(video_path)
|
||||||
@@ -106,18 +120,22 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
|
|||||||
return f"crop={cw}:ih:{x}:0"
|
return f"crop={cw}:ih:{x}:0"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_filename(filename: str) -> str:
|
_QUALITY_RE = re.compile(
|
||||||
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
|
|
||||||
name = os.path.splitext(filename)[0].lower()
|
|
||||||
# Use lookaround assertions instead of \b: \b treats '_' as a word char,
|
|
||||||
# so 'clip_2160p' would not form a word boundary before '2160p'.
|
|
||||||
name = re.sub(
|
|
||||||
r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
|
r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
|
||||||
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
|
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
|
||||||
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
|
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
|
||||||
'', name, flags=re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
|
_SEP_RE = re.compile(r'[\s_\-\.]+')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_filename(filename: str) -> str:
|
||||||
|
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
|
||||||
|
# Use lookaround assertions instead of \b: \b treats '_' as a word char,
|
||||||
|
# so 'clip_2160p' would not form a word boundary before '2160p'.
|
||||||
|
name = os.path.splitext(filename)[0].lower()
|
||||||
|
name = _QUALITY_RE.sub('', name)
|
||||||
|
name = _SEP_RE.sub('_', name).strip('_')
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
@@ -127,8 +145,9 @@ class ProcessedDB:
|
|||||||
def __init__(self, db_path: str | None = None):
|
def __init__(self, db_path: str | None = None):
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = str(Path.home() / ".8cut.db")
|
db_path = str(Path.home() / ".8cut.db")
|
||||||
|
self._path = db_path
|
||||||
try:
|
try:
|
||||||
self._con = sqlite3.connect(db_path)
|
self._con = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
self._migrate()
|
self._migrate()
|
||||||
self._enabled = True
|
self._enabled = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -185,6 +204,14 @@ class ProcessedDB:
|
|||||||
best_ratio, best_match = ratio, stored
|
best_ratio, best_match = ratio, stored
|
||||||
return best_match
|
return best_match
|
||||||
|
|
||||||
|
def _get_markers_for(self, match: str) -> list[tuple[float, int, str]]:
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT start_time, output_path FROM processed"
|
||||||
|
" WHERE filename = ? ORDER BY start_time",
|
||||||
|
(match,),
|
||||||
|
).fetchall()
|
||||||
|
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
|
||||||
|
|
||||||
def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
|
def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
|
||||||
"""Return [(start_time, marker_number, output_path), ...] for the best
|
"""Return [(start_time, marker_number, output_path), ...] for the best
|
||||||
fuzzy match of filename, sorted by start_time. Empty list if no match."""
|
fuzzy match of filename, sorted by start_time. Empty list if no match."""
|
||||||
@@ -193,12 +220,25 @@ class ProcessedDB:
|
|||||||
match = self.find_similar(filename)
|
match = self.find_similar(filename)
|
||||||
if match is None:
|
if match is None:
|
||||||
return []
|
return []
|
||||||
rows = self._con.execute(
|
return self._get_markers_for(match)
|
||||||
"SELECT start_time, output_path FROM processed"
|
|
||||||
" WHERE filename = ? ORDER BY start_time",
|
|
||||||
(match,),
|
class _DBWorker(QThread):
|
||||||
).fetchall()
|
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
||||||
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
|
result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
|
||||||
|
|
||||||
|
def __init__(self, db: "ProcessedDB", filename: str):
|
||||||
|
super().__init__()
|
||||||
|
self._db = db
|
||||||
|
self._filename = filename
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
match = self._db.find_similar(self._filename)
|
||||||
|
markers = self._db._get_markers_for(match) if match else []
|
||||||
|
except Exception:
|
||||||
|
match, markers = None, []
|
||||||
|
self.result.emit(self._filename, match, markers)
|
||||||
|
|
||||||
|
|
||||||
class ExportWorker(QThread):
|
class ExportWorker(QThread):
|
||||||
@@ -232,6 +272,13 @@ class ExportWorker(QThread):
|
|||||||
)
|
)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
if self._image_sequence:
|
||||||
|
audio_cmd = build_audio_extract_command(
|
||||||
|
self._input, self._start, self._output
|
||||||
|
)
|
||||||
|
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
# Audio extraction failure (e.g. no audio stream) is ignored —
|
||||||
|
# the frame sequence is the primary output.
|
||||||
self.finished.emit(self._output)
|
self.finished.emit(self._output)
|
||||||
else:
|
else:
|
||||||
self.error.emit(result.stderr[-500:])
|
self.error.emit(result.stderr[-500:])
|
||||||
@@ -251,21 +298,52 @@ class TimelineWidget(QWidget):
|
|||||||
self._duration = 0.0
|
self._duration = 0.0
|
||||||
self._cursor = 0.0
|
self._cursor = 0.0
|
||||||
self._markers: list[tuple[float, int, str]] = []
|
self._markers: list[tuple[float, int, str]] = []
|
||||||
|
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
|
||||||
|
|
||||||
|
# Cached paint resources — created once, reused every frame
|
||||||
|
self._cursor_pen = QPen(QColor(255, 200, 0))
|
||||||
|
self._cursor_pen.setWidth(2)
|
||||||
|
self._marker_pen = QPen(QColor(220, 60, 60))
|
||||||
|
self._marker_pen.setWidth(2)
|
||||||
|
self._marker_font = QFont()
|
||||||
|
self._marker_font.setPixelSize(9)
|
||||||
|
|
||||||
|
# Debounce timer: update visual cursor immediately but only emit
|
||||||
|
# cursor_changed (which triggers mpv.seek) at most once per interval.
|
||||||
|
self._seek_timer = QTimer()
|
||||||
|
self._seek_timer.setSingleShot(True)
|
||||||
|
self._seek_timer.setInterval(16) # ~60 fps
|
||||||
|
self._seek_timer.timeout.connect(lambda: self.cursor_changed.emit(self._cursor))
|
||||||
|
|
||||||
def set_duration(self, duration: float):
|
def set_duration(self, duration: float):
|
||||||
self._duration = duration
|
self._duration = duration
|
||||||
self._cursor = 0.0
|
self._cursor = 0.0
|
||||||
|
self._rebuild_hover_cache()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_cursor(self, seconds: float):
|
def set_cursor(self, seconds: float):
|
||||||
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
|
clamped = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
|
||||||
|
if clamped == self._cursor:
|
||||||
|
return
|
||||||
|
self._cursor = clamped
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
|
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
|
||||||
"""markers: list of (start_time, number, output_path)"""
|
"""markers: list of (start_time, number, output_path)"""
|
||||||
self._markers = markers
|
self._markers = markers
|
||||||
|
self._rebuild_hover_cache()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def _rebuild_hover_cache(self) -> None:
|
||||||
|
"""Pre-compute (pixel_x_fraction, output_path) for hover detection."""
|
||||||
|
if self._duration > 0:
|
||||||
|
self._hover_cache = [
|
||||||
|
(t / self._duration, path)
|
||||||
|
for (t, _num, path) in self._markers
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self._hover_cache: list[tuple[float, str]] = []
|
||||||
|
|
||||||
def _pos_to_time(self, x: int) -> float:
|
def _pos_to_time(self, x: int) -> float:
|
||||||
if self._duration <= 0 or self.width() <= 0:
|
if self._duration <= 0 or self.width() <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -287,22 +365,16 @@ class TimelineWidget(QWidget):
|
|||||||
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
|
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
|
||||||
|
|
||||||
# Cursor line
|
# Cursor line
|
||||||
pen = QPen(QColor(255, 200, 0))
|
p.setPen(self._cursor_pen)
|
||||||
pen.setWidth(2)
|
|
||||||
p.setPen(pen)
|
|
||||||
p.drawLine(x_start, 0, x_start, h)
|
p.drawLine(x_start, 0, x_start, h)
|
||||||
|
|
||||||
# Markers
|
# Markers
|
||||||
font = QFont()
|
p.setFont(self._marker_font)
|
||||||
font.setPixelSize(9)
|
|
||||||
p.setFont(font)
|
|
||||||
marker_pen = QPen(QColor(220, 60, 60))
|
|
||||||
marker_pen.setWidth(2)
|
|
||||||
for (t, num, _path) in self._markers:
|
for (t, num, _path) in self._markers:
|
||||||
if self._duration <= 0:
|
if self._duration <= 0:
|
||||||
break
|
break
|
||||||
mx = int(t / self._duration * w)
|
mx = int(t / self._duration * w)
|
||||||
p.setPen(marker_pen)
|
p.setPen(self._marker_pen)
|
||||||
p.drawLine(mx, 0, mx, h)
|
p.drawLine(mx, 0, mx, h)
|
||||||
p.setPen(QColor(255, 255, 255))
|
p.setPen(QColor(255, 255, 255))
|
||||||
p.drawText(mx + 2, 10, str(num))
|
p.drawText(mx + 2, 10, str(num))
|
||||||
@@ -314,12 +386,11 @@ class TimelineWidget(QWidget):
|
|||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
x = event.position().x()
|
x = event.position().x()
|
||||||
# Check marker hover (±4px)
|
# Check marker hover (±4px) using pre-computed fractions.
|
||||||
if self._duration > 0 and self._markers:
|
if self._hover_cache:
|
||||||
w = self.width()
|
w = self.width()
|
||||||
for (t, _num, output_path) in self._markers:
|
for (frac, output_path) in self._hover_cache:
|
||||||
mx = t / self._duration * w
|
if abs(x - frac * w) <= 4:
|
||||||
if abs(x - mx) <= 4:
|
|
||||||
QToolTip.showText(QCursor.pos(), output_path, self)
|
QToolTip.showText(QCursor.pos(), output_path, self)
|
||||||
if event.buttons():
|
if event.buttons():
|
||||||
self._seek(x)
|
self._seek(x)
|
||||||
@@ -328,10 +399,15 @@ class TimelineWidget(QWidget):
|
|||||||
if event.buttons():
|
if event.buttons():
|
||||||
self._seek(x)
|
self._seek(x)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
# On release, flush any pending debounced seek immediately.
|
||||||
|
self._seek_timer.stop()
|
||||||
|
self.cursor_changed.emit(self._cursor)
|
||||||
|
|
||||||
def _seek(self, x: float):
|
def _seek(self, x: float):
|
||||||
t = self._pos_to_time(int(x))
|
t = self._pos_to_time(int(x))
|
||||||
self.set_cursor(t)
|
self.set_cursor(t) # update visuals immediately
|
||||||
self.cursor_changed.emit(self._cursor)
|
self._seek_timer.start() # debounce the mpv seek
|
||||||
|
|
||||||
|
|
||||||
class MpvWidget(QFrame):
|
class MpvWidget(QFrame):
|
||||||
@@ -395,6 +471,14 @@ class MpvWidget(QFrame):
|
|||||||
return (self._player.width or 0, self._player.height or 0)
|
return (self._player.width or 0, self._player.height or 0)
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
|
|
||||||
|
def get_fps(self) -> float:
|
||||||
|
if self._player:
|
||||||
|
return self._player.container_fps or 25.0
|
||||||
|
return 25.0
|
||||||
|
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return bool(self._player and not self._player.pause)
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
w = self.width()
|
w = self.width()
|
||||||
if w > 0:
|
if w > 0:
|
||||||
@@ -422,6 +506,8 @@ class CropBarWidget(QWidget):
|
|||||||
self._source_ratio: float = 16 / 9 # w/h of source video
|
self._source_ratio: float = 16 / 9 # w/h of source video
|
||||||
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
|
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
|
||||||
self._crop_center: float = 0.5
|
self._crop_center: float = 0.5
|
||||||
|
self._crop_pen = QPen(QColor(100, 160, 240))
|
||||||
|
self._crop_pen.setWidth(1)
|
||||||
|
|
||||||
def set_source_ratio(self, w: int, h: int) -> None:
|
def set_source_ratio(self, w: int, h: int) -> None:
|
||||||
self._source_ratio = w / h if h > 0 else 16 / 9
|
self._source_ratio = w / h if h > 0 else 16 / 9
|
||||||
@@ -458,9 +544,7 @@ class CropBarWidget(QWidget):
|
|||||||
x = int(max_x * self._crop_center)
|
x = int(max_x * self._crop_center)
|
||||||
|
|
||||||
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
|
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
|
||||||
pen = QPen(QColor(100, 160, 240))
|
p.setPen(self._crop_pen)
|
||||||
pen.setWidth(1)
|
|
||||||
p.setPen(pen)
|
|
||||||
p.drawRect(x, 1, win_px - 1, h - 2)
|
p.drawRect(x, 1, win_px - 1, h - 2)
|
||||||
finally:
|
finally:
|
||||||
p.end()
|
p.end()
|
||||||
@@ -498,14 +582,16 @@ class PlaylistWidget(QListWidget):
|
|||||||
self.setMinimumWidth(200)
|
self.setMinimumWidth(200)
|
||||||
self.setWordWrap(True)
|
self.setWordWrap(True)
|
||||||
self._paths: list[str] = []
|
self._paths: list[str] = []
|
||||||
|
self._path_set: set[str] = set() # O(1) duplicate check
|
||||||
self.itemClicked.connect(self._on_item_clicked)
|
self.itemClicked.connect(self._on_item_clicked)
|
||||||
|
|
||||||
def add_files(self, paths: list[str]) -> None:
|
def add_files(self, paths: list[str]) -> None:
|
||||||
"""Append paths not already in queue; auto-select first if queue was empty."""
|
"""Append paths not already in queue; auto-select first if queue was empty."""
|
||||||
was_empty = len(self._paths) == 0
|
was_empty = len(self._paths) == 0
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if path not in self._paths and os.path.isfile(path):
|
if path not in self._path_set and os.path.isfile(path):
|
||||||
self._paths.append(path)
|
self._paths.append(path)
|
||||||
|
self._path_set.add(path)
|
||||||
self.addItem(os.path.basename(path))
|
self.addItem(os.path.basename(path))
|
||||||
if was_empty and self._paths:
|
if was_empty and self._paths:
|
||||||
self._select(0)
|
self._select(0)
|
||||||
@@ -521,16 +607,15 @@ class PlaylistWidget(QListWidget):
|
|||||||
return self._paths[row] if 0 <= row < len(self._paths) else None
|
return self._paths[row] if 0 <= row < len(self._paths) else None
|
||||||
|
|
||||||
def _select(self, row: int) -> None:
|
def _select(self, row: int) -> None:
|
||||||
|
prev = self.currentRow()
|
||||||
self.setCurrentRow(row)
|
self.setCurrentRow(row)
|
||||||
self._refresh_labels()
|
# Only update the two items that actually changed label.
|
||||||
|
if prev >= 0 and prev != row and self.item(prev):
|
||||||
|
self.item(prev).setText(os.path.basename(self._paths[prev]))
|
||||||
|
if self.item(row):
|
||||||
|
self.item(row).setText(f"▶ {os.path.basename(self._paths[row])}")
|
||||||
self.file_selected.emit(self._paths[row])
|
self.file_selected.emit(self._paths[row])
|
||||||
|
|
||||||
def _refresh_labels(self) -> None:
|
|
||||||
current = self.currentRow()
|
|
||||||
for i in range(self.count()):
|
|
||||||
name = os.path.basename(self._paths[i])
|
|
||||||
self.item(i).setText(f"▶ {name}" if i == current else name)
|
|
||||||
|
|
||||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||||
self._select(self.row(item))
|
self._select(self.row(item))
|
||||||
|
|
||||||
@@ -666,6 +751,11 @@ class SettingsDialog(QDialog):
|
|||||||
self.masks_visibility_changed.emit(checked)
|
self.masks_visibility_changed.emit(checked)
|
||||||
|
|
||||||
def _on_install(self):
|
def _on_install(self):
|
||||||
|
if self._worker and self._worker.isRunning():
|
||||||
|
return
|
||||||
|
if self._worker:
|
||||||
|
self._worker.quit()
|
||||||
|
self._worker.wait()
|
||||||
self._btn_install.setEnabled(False)
|
self._btn_install.setEnabled(False)
|
||||||
self._log.clear()
|
self._log.clear()
|
||||||
self._worker = SetupWorker()
|
self._worker = SetupWorker()
|
||||||
@@ -724,6 +814,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_worker: ExportWorker | None = None
|
self._export_worker: ExportWorker | None = None
|
||||||
self._last_export_path: str = ""
|
self._last_export_path: str = ""
|
||||||
self._mask_worker: MaskWorker | None = None
|
self._mask_worker: MaskWorker | None = None
|
||||||
|
self._db_worker: _DBWorker | None = None
|
||||||
|
self._fps: float = 25.0 # cached on file load via get_fps()
|
||||||
|
|
||||||
# Widgets
|
# Widgets
|
||||||
self._playlist = PlaylistWidget()
|
self._playlist = PlaylistWidget()
|
||||||
@@ -779,6 +871,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
||||||
|
|
||||||
|
self._cmb_format = QComboBox()
|
||||||
|
self._cmb_format.addItems(["MP4", "WebP sequence"])
|
||||||
|
saved_fmt = self._settings.value("export_format", "MP4")
|
||||||
|
idx = self._cmb_format.findText(saved_fmt)
|
||||||
|
self._cmb_format.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
|
self._cmb_format.currentTextChanged.connect(
|
||||||
|
lambda v: self._settings.setValue("export_format", v)
|
||||||
|
)
|
||||||
|
self._cmb_format.currentTextChanged.connect(self._update_next_label)
|
||||||
|
|
||||||
self._crop_bar = CropBarWidget()
|
self._crop_bar = CropBarWidget()
|
||||||
self._crop_bar.set_crop_center(self._crop_center)
|
self._crop_bar.set_crop_center(self._crop_center)
|
||||||
self._crop_bar.set_portrait_ratio(
|
self._crop_bar.set_portrait_ratio(
|
||||||
@@ -831,6 +933,8 @@ class MainWindow(QMainWindow):
|
|||||||
export_row.addWidget(self._txt_resize)
|
export_row.addWidget(self._txt_resize)
|
||||||
export_row.addWidget(QLabel("Portrait:"))
|
export_row.addWidget(QLabel("Portrait:"))
|
||||||
export_row.addWidget(self._cmb_portrait)
|
export_row.addWidget(self._cmb_portrait)
|
||||||
|
export_row.addWidget(QLabel("Format:"))
|
||||||
|
export_row.addWidget(self._cmb_format)
|
||||||
export_row.addWidget(self._lbl_next)
|
export_row.addWidget(self._lbl_next)
|
||||||
export_row.addWidget(self._btn_export)
|
export_row.addWidget(self._btn_export)
|
||||||
|
|
||||||
@@ -849,7 +953,7 @@ class MainWindow(QMainWindow):
|
|||||||
mask_row.addWidget(self._cmb_mask)
|
mask_row.addWidget(self._cmb_mask)
|
||||||
mask_row.addWidget(self._btn_masks)
|
mask_row.addWidget(self._btn_masks)
|
||||||
mask_row.addStretch()
|
mask_row.addStretch()
|
||||||
show_masks = QSettings("8cut", "8cut").value("show_masks_row", "true") == "true"
|
show_masks = self._settings.value("show_masks_row", "true") == "true"
|
||||||
self._mask_row_widget.setVisible(show_masks)
|
self._mask_row_widget.setVisible(show_masks)
|
||||||
|
|
||||||
right_layout.addLayout(controls)
|
right_layout.addLayout(controls)
|
||||||
@@ -892,18 +996,36 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_play.setEnabled(True)
|
self._btn_play.setEnabled(True)
|
||||||
self._btn_pause.setEnabled(True)
|
self._btn_pause.setEnabled(True)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._fps = self._mpv.get_fps()
|
||||||
|
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
|
||||||
|
|
||||||
match = self._db.find_similar(os.path.basename(self._file_path))
|
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||||||
|
filename = os.path.basename(self._file_path)
|
||||||
|
self._db_worker = _DBWorker(self._db, filename)
|
||||||
|
self._db_worker.result.connect(self._on_db_result)
|
||||||
|
self._db_worker.start()
|
||||||
|
|
||||||
|
def _on_db_result(self, queried: str, match: object, markers: list) -> None:
|
||||||
|
# Discard stale results if the user loaded a different file already.
|
||||||
|
if os.path.basename(self._file_path) != queried:
|
||||||
|
return
|
||||||
if match:
|
if match:
|
||||||
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
|
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
|
||||||
else:
|
else:
|
||||||
self.statusBar().clearMessage()
|
self.statusBar().clearMessage()
|
||||||
|
self._timeline.set_markers(markers)
|
||||||
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
|
|
||||||
self._refresh_markers()
|
|
||||||
|
|
||||||
def _refresh_markers(self) -> None:
|
def _refresh_markers(self) -> None:
|
||||||
markers = self._db.get_markers(os.path.basename(self._file_path))
|
filename = os.path.basename(self._file_path)
|
||||||
|
# After an export we already know the exact stored filename, so skip
|
||||||
|
# the expensive fuzzy match and query directly.
|
||||||
|
if self._db._enabled:
|
||||||
|
markers = self._db._get_markers_for(filename)
|
||||||
|
if not markers:
|
||||||
|
# First export for this file — fall back to fuzzy match once.
|
||||||
|
markers = self._db.get_markers(filename)
|
||||||
|
else:
|
||||||
|
markers = []
|
||||||
self._timeline.set_markers(markers)
|
self._timeline.set_markers(markers)
|
||||||
|
|
||||||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||||||
@@ -936,6 +1058,57 @@ class MainWindow(QMainWindow):
|
|||||||
self._mpv.stop_loop()
|
self._mpv.stop_loop()
|
||||||
self._mpv.seek(self._cursor)
|
self._mpv.seek(self._cursor)
|
||||||
|
|
||||||
|
def _step_cursor(self, delta: float) -> None:
|
||||||
|
if not self._file_path:
|
||||||
|
return
|
||||||
|
dur = self._mpv.get_duration()
|
||||||
|
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - 8.0)))
|
||||||
|
# Update label and internal state immediately; route the seek through
|
||||||
|
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
||||||
|
self._cursor = new_t
|
||||||
|
self._lbl_cursor.setText(f"cursor: {format_time(new_t)}")
|
||||||
|
self._timeline.set_cursor(new_t)
|
||||||
|
self._timeline._seek_timer.start()
|
||||||
|
|
||||||
|
def _jump_to_next_marker(self) -> None:
|
||||||
|
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
||||||
|
if not markers:
|
||||||
|
return
|
||||||
|
for (t, _num, _path) in markers:
|
||||||
|
if t > self._cursor + 0.1:
|
||||||
|
self._step_cursor(t - self._cursor)
|
||||||
|
return
|
||||||
|
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||||
|
focused = QApplication.focusWidget()
|
||||||
|
if isinstance(focused, (QLineEdit, QPlainTextEdit)):
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = event.key()
|
||||||
|
shift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
|
||||||
|
frame = 1.0 / self._fps
|
||||||
|
step = 1.0 if shift else frame
|
||||||
|
|
||||||
|
if key in (Qt.Key.Key_Left, Qt.Key.Key_J):
|
||||||
|
self._step_cursor(-step)
|
||||||
|
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
|
||||||
|
self._step_cursor(step)
|
||||||
|
elif key in (Qt.Key.Key_Space, Qt.Key.Key_P):
|
||||||
|
if self._mpv.is_playing():
|
||||||
|
self._on_pause()
|
||||||
|
else:
|
||||||
|
self._on_play()
|
||||||
|
elif key == Qt.Key.Key_K:
|
||||||
|
self._on_pause()
|
||||||
|
elif key == Qt.Key.Key_E:
|
||||||
|
self._on_export()
|
||||||
|
elif key == Qt.Key.Key_M:
|
||||||
|
self._jump_to_next_marker()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
# --- Export ---
|
# --- Export ---
|
||||||
|
|
||||||
def _pick_folder(self):
|
def _pick_folder(self):
|
||||||
@@ -950,11 +1123,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
|
||||||
def _update_next_label(self):
|
def _update_next_label(self):
|
||||||
path = build_export_path(
|
folder = self._txt_folder.text()
|
||||||
self._txt_folder.text(),
|
name = self._txt_name.text() or "clip"
|
||||||
self._txt_name.text() or "clip",
|
if self._cmb_format.currentText() == "WebP sequence":
|
||||||
self._export_counter,
|
path = build_sequence_dir(folder, name, self._export_counter)
|
||||||
)
|
else:
|
||||||
|
path = build_export_path(folder, name, self._export_counter)
|
||||||
self._lbl_next.setText(f"→ {os.path.basename(path)}")
|
self._lbl_next.setText(f"→ {os.path.basename(path)}")
|
||||||
|
|
||||||
def _on_export(self):
|
def _on_export(self):
|
||||||
@@ -964,11 +1138,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage("Export already running…")
|
self.statusBar().showMessage("Export already running…")
|
||||||
return
|
return
|
||||||
|
|
||||||
output = build_export_path(
|
fmt = self._cmb_format.currentText()
|
||||||
self._txt_folder.text(),
|
image_sequence = fmt == "WebP sequence"
|
||||||
self._txt_name.text() or "clip",
|
folder = self._txt_folder.text()
|
||||||
self._export_counter,
|
name = self._txt_name.text() or "clip"
|
||||||
)
|
if image_sequence:
|
||||||
|
output = build_sequence_dir(folder, name, self._export_counter)
|
||||||
|
else:
|
||||||
|
output = build_export_path(folder, name, self._export_counter)
|
||||||
|
|
||||||
raw = self._txt_resize.text().strip()
|
raw = self._txt_resize.text().strip()
|
||||||
try:
|
try:
|
||||||
@@ -989,6 +1166,7 @@ class MainWindow(QMainWindow):
|
|||||||
short_side=short_side,
|
short_side=short_side,
|
||||||
portrait_ratio=portrait_ratio,
|
portrait_ratio=portrait_ratio,
|
||||||
crop_center=self._crop_center,
|
crop_center=self._crop_center,
|
||||||
|
image_sequence=image_sequence,
|
||||||
)
|
)
|
||||||
self._export_worker.finished.connect(self._on_export_done)
|
self._export_worker.finished.connect(self._on_export_done)
|
||||||
self._export_worker.error.connect(self._on_export_error)
|
self._export_worker.error.connect(self._on_export_error)
|
||||||
@@ -996,6 +1174,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _on_export_done(self, path: str):
|
def _on_export_done(self, path: str):
|
||||||
self._db.add(os.path.basename(self._file_path), self._cursor, path)
|
self._db.add(os.path.basename(self._file_path), self._cursor, path)
|
||||||
|
# For MP4 exports path is a file; for WebP sequence it is a directory.
|
||||||
|
# build_mask_output_dir handles both correctly via Path.stem.
|
||||||
self._last_export_path = path
|
self._last_export_path = path
|
||||||
self._export_counter += 1
|
self._export_counter += 1
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
@@ -1020,6 +1200,9 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._last_export_path:
|
if not self._last_export_path:
|
||||||
self.statusBar().showMessage("No clip exported yet — export first.")
|
self.statusBar().showMessage("No clip exported yet — export first.")
|
||||||
return
|
return
|
||||||
|
if os.path.isdir(self._last_export_path):
|
||||||
|
self.statusBar().showMessage("Mask generation requires an MP4 export — switch format to MP4 and export first.")
|
||||||
|
return
|
||||||
if self._mask_worker and self._mask_worker.isRunning():
|
if self._mask_worker and self._mask_worker.isRunning():
|
||||||
self.statusBar().showMessage("Mask generation already running…")
|
self.statusBar().showMessage("Mask generation already running…")
|
||||||
return
|
return
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,5 @@
|
|||||||
import tempfile, os
|
import tempfile, os
|
||||||
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir
|
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command
|
||||||
from main import _normalize_filename, ProcessedDB
|
from main import _normalize_filename, ProcessedDB
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +184,29 @@ def test_mask_output_dir_nested():
|
|||||||
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
|
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_audio_extract_command ---
|
||||||
|
|
||||||
|
def test_audio_extract_output_path():
|
||||||
|
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
|
||||||
|
assert cmd[-1] == "/out/clip_001.wav"
|
||||||
|
|
||||||
|
def test_audio_extract_no_video():
|
||||||
|
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
|
||||||
|
assert "-vn" in cmd
|
||||||
|
|
||||||
|
def test_audio_extract_lossless_codec():
|
||||||
|
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001")
|
||||||
|
assert "-c:a" in cmd
|
||||||
|
assert cmd[cmd.index("-c:a") + 1] == "pcm_s16le"
|
||||||
|
|
||||||
|
def test_audio_extract_timing():
|
||||||
|
cmd = build_audio_extract_command("/in/v.mp4", 12.5, "/out/clip_001")
|
||||||
|
assert "-ss" in cmd
|
||||||
|
assert cmd[cmd.index("-ss") + 1] == "12.5"
|
||||||
|
assert "-t" in cmd
|
||||||
|
assert cmd[cmd.index("-t") + 1] == "8"
|
||||||
|
|
||||||
|
|
||||||
def test_build_sequence_dir_basic():
|
def test_build_sequence_dir_basic():
|
||||||
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
|
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user