From 2ccd5bac69082eb5e4340a991b73643fe4d896b0 Mon Sep 17 00:00:00 2001 From: TN019 Date: Sun, 26 Jul 2026 20:04:02 +1000 Subject: [PATCH] History management and a built-in player: delete records one-by-one or in batch, edit subtitle files in place, and play the source video with live subtitles. --- .github/workflows/ci.yml | 2 +- src/scripto/gui/viewmodel.py | 7 ++ src/scripto/gui_qt/history_page.py | 142 +++++++++++++++++++++- src/scripto/gui_qt/player.py | 187 +++++++++++++++++++++++++++++ src/scripto/i18n/en.py | 8 ++ src/scripto/i18n/zh.py | 8 ++ tests/test_gui_qt.py | 56 +++++++++ tests/test_player.py | 41 +++++++ 8 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 src/scripto/gui_qt/player.py create mode 100644 tests/test_player.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c0d848..a015a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Install Qt runtime libraries (headless) run: > sudo apt-get update && sudo apt-get install -y --no-install-recommends - libegl1 libgl1 libxkbcommon0 libdbus-1-3 libfontconfig1 + libegl1 libgl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libpulse0 - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/src/scripto/gui/viewmodel.py b/src/scripto/gui/viewmodel.py index 43f756d..07b7a8e 100644 --- a/src/scripto/gui/viewmodel.py +++ b/src/scripto/gui/viewmodel.py @@ -411,6 +411,13 @@ def history_clean_missing(self) -> int: stale = {e.id for e, exists in self.history_rows() if not exists} return self.history.remove(stale) if stale else 0 + def history_delete_sources(self, sources: set[str]) -> int: + """Remove every history entry for the given source files. + + Records only — output files on disk are never touched.""" + ids = {e.id for e in self.history.entries() if e.source in sources} + return self.history.remove(ids) if ids else 0 + @staticmethod def read_preview(path: str, limit: int = 20_000) -> str: text = Path(path).read_text(encoding="utf-8", errors="replace") diff --git a/src/scripto/gui_qt/history_page.py b/src/scripto/gui_qt/history_page.py index e0fd3b4..a6ac7fb 100644 --- a/src/scripto/gui_qt/history_page.py +++ b/src/scripto/gui_qt/history_page.py @@ -8,9 +8,11 @@ from pathlib import Path from PySide6.QtWidgets import ( + QCheckBox, QDialog, QHBoxLayout, QLabel, + QPlainTextEdit, QPushButton, QScrollArea, QTextBrowser, @@ -33,15 +35,21 @@ def __init__(self, window): def _build(self) -> None: t = self.t + self._selected: set[str] = set() refresh_btn = QPushButton(t("gui.history_refresh")) refresh_btn.clicked.connect(self.refresh) clean_btn = QPushButton(t("gui.history_clean")) clean_btn.clicked.connect(self._clean) + self.delete_selected_btn = QPushButton() + self.delete_selected_btn.setProperty("variant", "danger") + self.delete_selected_btn.clicked.connect(self._delete_selected) + self.delete_selected_btn.hide() top = QHBoxLayout() top.setSpacing(8) top.addWidget(refresh_btn) top.addWidget(clean_btn) + top.addWidget(self.delete_selected_btn) top.addStretch(1) self.list_box = QVBoxLayout() @@ -64,6 +72,8 @@ def _build(self) -> None: def refresh(self) -> None: t = self.t clear_layout(self.list_box, keep_tail=1) + self._selected.clear() + self._sync_delete_button() groups = self.vm.history_groups() if not groups: @@ -77,6 +87,13 @@ def refresh(self) -> None: row.setContentsMargins(12, 8, 12, 8) row.setSpacing(8) + select = QCheckBox() + select.setToolTip(t("gui.history_delete_selected", n="…")) + select.toggled.connect( + lambda on, s=group.source: self._toggle_selected(s, on) + ) + row.addWidget(select) + name = ElidedLabel(group.name) name.setToolTip(group.source) langs = " · ".join( @@ -111,6 +128,15 @@ def refresh(self) -> None: row.addWidget(view_btn) row.addWidget(reveal_btn) + remove_btn = QPushButton("✕") + remove_btn.setProperty("variant", "quiet") + remove_btn.setFixedWidth(28) + remove_btn.setToolTip(t("gui.history_delete")) + remove_btn.clicked.connect( + lambda _=False, s=group.source: self._delete_sources({s}) + ) + row.addWidget(remove_btn) + self.list_box.insertWidget(self.list_box.count() - 1, frame) def _clean(self) -> None: @@ -118,6 +144,31 @@ def _clean(self) -> None: self.window_ref.toast(self.t("gui.history_cleaned", n=removed)) self.refresh() + # Deleting removes history records only; files on disk stay untouched. + + def _toggle_selected(self, source: str, on: bool) -> None: + if on: + self._selected.add(source) + else: + self._selected.discard(source) + self._sync_delete_button() + + def _sync_delete_button(self) -> None: + count = len(self._selected) + self.delete_selected_btn.setVisible(count > 0) + if count: + self.delete_selected_btn.setText( + self.t("gui.history_delete_selected", n=count) + ) + + def _delete_selected(self) -> None: + self._delete_sources(set(self._selected)) + + def _delete_sources(self, sources: set[str]) -> None: + removed = self.vm.history_delete_sources(sources) + self.window_ref.toast(self.t("gui.history_deleted_n", n=removed)) + self.refresh() + # ------------------------------------------------------------------ # # Viewer dialog # ------------------------------------------------------------------ # @@ -151,11 +202,29 @@ def __init__(self, page: HistoryPage, group): self.body.setOpenExternalLinks(False) self.body.setProperty("role", "preview") self.body.setFrameShape(QTextBrowser.Shape.NoFrame) - + self.editor = QPlainTextEdit() + self.editor.setProperty("role", "preview") + self.editor.hide() + + self.play_btn = QPushButton(self.t("gui.play")) + self.play_btn.clicked.connect(self._play) + self.edit_btn = QPushButton(self.t("gui.edit")) + self.edit_btn.clicked.connect(self._start_edit) + self.save_btn = QPushButton(self.t("gui.save")) + self.save_btn.setProperty("variant", "primary") + self.save_btn.clicked.connect(self._save_edit) + self.save_btn.hide() + self.cancel_btn = QPushButton(self.t("gui.cancel")) + self.cancel_btn.clicked.connect(self._cancel_edit) + self.cancel_btn.hide() close_btn = QPushButton(self.t("gui.close")) close_btn.clicked.connect(self.accept) bottom = QHBoxLayout() + bottom.addWidget(self.play_btn) + bottom.addWidget(self.edit_btn) bottom.addStretch(1) + bottom.addWidget(self.cancel_btn) + bottom.addWidget(self.save_btn) bottom.addWidget(close_btn) root = QVBoxLayout(self) @@ -163,6 +232,7 @@ def __init__(self, page: HistoryPage, group): root.addLayout(self.lang_row) root.addWidget(self.status_label) root.addWidget(self.body, 1) + root.addWidget(self.editor, 1) root.addLayout(bottom) self._rebuild_buttons() @@ -195,6 +265,8 @@ def _rebuild_buttons(self) -> None: insert_at += 1 def _load(self, lang: str) -> None: + if self.editor.isVisible(): + self._cancel_edit() self.lang = lang path = self.group.existing[lang] try: @@ -203,6 +275,74 @@ def _load(self, lang: str) -> None: self.body.setPlainText(str(exc)) self._rebuild_buttons() + # ------------------------------------------------------------------ # + # Play (source video + this language's subtitles) + # ------------------------------------------------------------------ # + + def _play(self) -> None: + from .player import PlayerDialog + + if not Path(self.group.source).exists(): + self.page.window_ref.toast( + self.t("gui.player_missing_video"), ok=False + ) + return + srt = self.group.existing.get(self.lang or "", "") + PlayerDialog( + self, self.page.window_ref, self.group.source, + srt if srt.endswith(".srt") else None, + ).exec() + + # ------------------------------------------------------------------ # + # Edit the underlying file in place + # ------------------------------------------------------------------ # + + def _current_path(self) -> str | None: + return self.group.existing.get(self.lang or "") + + def _start_edit(self) -> None: + path = self._current_path() + if path is None: + return + try: + # Full file, not read_preview: previews truncate long files and + # saving a truncated buffer would destroy the tail. + content = Path(path).read_text(encoding="utf-8", errors="replace") + except Exception as exc: + self.status_label.setText(str(exc)) + return + self.editor.setPlainText(content) + self.busy = True # freezes language switching + translate buttons + self._rebuild_buttons() + self._set_editing(True) + + def _save_edit(self) -> None: + path = self._current_path() + if path is None: + return + try: + Path(path).write_text(self.editor.toPlainText(), encoding="utf-8") + except Exception as exc: + self.status_label.setText(str(exc)) + return + self.busy = False + self._set_editing(False) + self._load(self.lang) + self.page.window_ref.toast(self.t("gui.settings_saved")) + + def _cancel_edit(self) -> None: + self.busy = False + self._set_editing(False) + self._rebuild_buttons() + + def _set_editing(self, editing: bool) -> None: + self.editor.setVisible(editing) + self.body.setVisible(not editing) + self.save_btn.setVisible(editing) + self.cancel_btn.setVisible(editing) + self.edit_btn.setVisible(not editing) + self.play_btn.setVisible(not editing) + def _render(self, content: str, path: str) -> None: """Subtitles read like a transcript, not like the raw file format: one block per cue, dim second-precision time range, normal text.""" diff --git a/src/scripto/gui_qt/player.py b/src/scripto/gui_qt/player.py new file mode 100644 index 0000000..8fa5772 --- /dev/null +++ b/src/scripto/gui_qt/player.py @@ -0,0 +1,187 @@ +"""Built-in media player: the source video with live subtitles from the SRT. + +Subtitles are drawn by us (a label overlaid on the video), not by the media +backend: that keeps every produced language selectable and renders exactly +what the .srt on disk says — including edits the user just made. Qt 6 ships +the FFmpeg media backend, so mkv/mp4/m4a all play. +""" + +from __future__ import annotations + +import bisect +import re +from pathlib import Path + +from PySide6.QtCore import Qt, QUrl +from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer +from PySide6.QtMultimediaWidgets import QVideoWidget +from PySide6.QtWidgets import ( + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) + +from ..translate.srt import parse_srt +from .widgets import subtext + +_TIME_RE = re.compile(r"(\d+):(\d+):(\d+)[,.](\d+)") + +Cue = tuple[int, int, str] # start_ms, end_ms, text + + +def timestamp_ms(text: str) -> int: + """`00:01:02,345` (or `.345`) → milliseconds; 0 when unparsable.""" + match = _TIME_RE.search(text) + if not match: + return 0 + hours, minutes, seconds, ms = (int(g) for g in match.groups()) + return ((hours * 60 + minutes) * 60 + seconds) * 1000 + ms + + +def build_cues(content: str) -> list[Cue]: + cues: list[Cue] = [] + for block in parse_srt(content): + start_raw, _, end_raw = block.timestamp.partition("-->") + cues.append((timestamp_ms(start_raw), timestamp_ms(end_raw), block.text)) + cues.sort(key=lambda c: (c[0], c[1])) + return cues + + +def cue_at(cues: list[Cue], ms: int) -> str: + """The subtitle text active at ``ms``; empty string between cues.""" + index = bisect.bisect_right(cues, (ms, 1 << 62, "")) - 1 + if index >= 0: + start, end, text = cues[index] + if start <= ms < end: + return text + return "" + + +def format_ms(ms: int) -> str: + seconds = max(0, ms) // 1000 + hours, rest = divmod(seconds, 3600) + minutes, secs = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +class PlayerDialog(QDialog): + def __init__(self, parent, window, video_path: str, srt_path: str | None): + super().__init__(parent) + self.window_ref = window + self.setWindowTitle(Path(video_path).name) + self.resize(920, 600) + + self.cues: list[Cue] = [] + if srt_path: + try: + self.cues = build_cues( + Path(srt_path).read_text(encoding="utf-8", errors="replace") + ) + except Exception: + pass + self._current_text = "" + self._dragging = False + + self.player = QMediaPlayer(self) + self.audio = QAudioOutput(self) + self.player.setAudioOutput(self.audio) + video = QVideoWidget() + self.player.setVideoOutput(video) + + self.subtitle = QLabel("") + self.subtitle.setWordWrap(True) + self.subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.subtitle.setStyleSheet( + "background: rgba(0, 0, 0, 168); color: white; font-size: 16px;" + "padding: 6px 14px; border-radius: 8px; margin-bottom: 26px;" + ) + self.subtitle.hide() + + stage = QWidget() + stage.setStyleSheet("background: black;") + grid = QGridLayout(stage) + grid.setContentsMargins(0, 0, 0, 0) + grid.addWidget(video, 0, 0) + grid.addWidget( + self.subtitle, 0, 0, + Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, + ) + + self.play_btn = QPushButton("⏸") + self.play_btn.setFixedWidth(44) + self.play_btn.clicked.connect(self._toggle) + self.slider = QSlider(Qt.Orientation.Horizontal) + self.slider.setRange(0, 0) + self.slider.sliderPressed.connect(lambda: setattr(self, "_dragging", True)) + self.slider.sliderReleased.connect(self._seek_released) + self.slider.sliderMoved.connect(self._preview_position) + self.time_label = subtext("0:00 / 0:00") + + controls = QHBoxLayout() + controls.setContentsMargins(12, 8, 12, 10) + controls.setSpacing(10) + controls.addWidget(self.play_btn) + controls.addWidget(self.slider, 1) + controls.addWidget(self.time_label) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(stage, 1) + root.addLayout(controls) + + self.player.positionChanged.connect(self._on_position) + self.player.durationChanged.connect(lambda d: self.slider.setRange(0, int(d))) + self.player.playbackStateChanged.connect(self._on_state) + self.player.setSource(QUrl.fromLocalFile(video_path)) + self.player.play() + + # ------------------------------------------------------------------ # + + def _toggle(self) -> None: + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() + + def _on_state(self, state) -> None: + playing = state == QMediaPlayer.PlaybackState.PlayingState + self.play_btn.setText("⏸" if playing else "▶") + + def _seek_released(self) -> None: + self._dragging = False + self.player.setPosition(self.slider.value()) + + def _preview_position(self, ms: int) -> None: + self._update_subtitle(ms) + self._update_time(ms) + + def _on_position(self, ms: int) -> None: + if not self._dragging: + self.slider.setValue(int(ms)) + self._update_time(int(ms)) + self._update_subtitle(int(ms)) + + def _update_time(self, ms: int) -> None: + self.time_label.setText( + f"{format_ms(ms)} / {format_ms(int(self.player.duration()))}" + ) + + def _update_subtitle(self, ms: int) -> None: + text = cue_at(self.cues, ms) + if text == self._current_text: + return + self._current_text = text + self.subtitle.setText(text) + self.subtitle.setVisible(bool(text)) + + def closeEvent(self, event) -> None: # noqa: N802 + self.player.stop() + super().closeEvent(event) diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index d118e7b..6dbda22 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -160,6 +160,14 @@ "gui.doctor_title": "Environment check", "gui.doctor_startup_failed": "Some required components are missing — open Settings → Run check for details.", "gui.history_view": "View", + "gui.history_delete": "Delete record", + "gui.history_delete_selected": "Delete selected ({n})", + "gui.history_deleted_n": "Removed {n} record(s) — output files were not touched.", + "gui.edit": "Edit", + "gui.save": "Save", + "gui.cancel": "Cancel", + "gui.play": "Play", + "gui.player_missing_video": "The source video no longer exists on disk.", "gui.history_translate_to": "Translate to {lang}", "gui.history_translating": "Translating to {lang}…", "gui.history_translate_done": "Translation finished.", diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index bac254e..c459578 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -160,6 +160,14 @@ "gui.doctor_title": "环境检查", "gui.doctor_startup_failed": "部分必需组件缺失——打开 设置 → 运行检查 查看详情。", "gui.history_view": "查看", + "gui.history_delete": "删除记录", + "gui.history_delete_selected": "删除选中({n})", + "gui.history_deleted_n": "已删除 {n} 条记录——磁盘上的输出文件未受影响。", + "gui.edit": "编辑", + "gui.save": "保存", + "gui.cancel": "取消", + "gui.play": "播放", + "gui.player_missing_video": "源视频已不在磁盘上。", "gui.history_translate_to": "翻译成{lang}", "gui.history_translating": "正在翻译成{lang}…", "gui.history_translate_done": "翻译完成。", diff --git a/tests/test_gui_qt.py b/tests/test_gui_qt.py index 60f7e3d..86d9d62 100644 --- a/tests/test_gui_qt.py +++ b/tests/test_gui_qt.py @@ -153,3 +153,59 @@ def test_paths_are_added_and_removed_one_by_one(tmp_path, qapp): page._remove_path(str(media)) assert page.input_paths == [] assert page.hint_label.isVisibleTo(page.paths_card) + + +def _seed_history(tmp_path, vm, stem: str) -> str: + src = tmp_path / f"{stem}.mp4" + src.write_bytes(b"x") + srt = tmp_path / f"{stem}.en.srt" + srt.write_text( + "1\n00:00:01,000 --> 00:00:03,200\nline one\n", encoding="utf-8" + ) + from scripto.core.history import HistoryEntry + + vm.history.append(HistoryEntry( + source=str(src), outputs=[{"lang": "en", "format": "srt", "path": str(srt)}], + model="tiny", engine="mlx", status="done", + )) + return str(src) + + +def test_history_delete_single_and_batch(tmp_path, qapp): + window = make_window(tmp_path, qapp) + a = _seed_history(tmp_path, window.vm, "a") + b = _seed_history(tmp_path, window.vm, "b") + c = _seed_history(tmp_path, window.vm, "c") + page = window.history_page + page.refresh() + + page._delete_sources({a}) # single (the per-card ✕ path) + assert {g.source for g in window.vm.history_groups()} == {b, c} + + page._toggle_selected(b, True) + page._toggle_selected(c, True) + assert page.delete_selected_btn.isVisibleTo(page) + page._delete_selected() # batch + assert window.vm.history_groups() == [] + + +def test_history_viewer_edits_the_file_in_place(tmp_path, qapp): + from scripto.gui_qt.history_page import _ViewerDialog + + window = make_window(tmp_path, qapp) + _seed_history(tmp_path, window.vm, "talk") + group = window.vm.history_groups()[0] + dialog = _ViewerDialog(window.history_page, group) + + dialog._start_edit() + assert dialog.editor.isVisibleTo(dialog) + assert "line one" in dialog.editor.toPlainText() + dialog.editor.setPlainText( + "1\n00:00:01,000 --> 00:00:03,200\ncorrected line\n" + ) + dialog._save_edit() + + saved = (tmp_path / "talk.en.srt").read_text(encoding="utf-8") + assert "corrected line" in saved + assert "corrected line" in dialog.body.toPlainText() # re-rendered + assert not dialog.editor.isVisibleTo(dialog) diff --git a/tests/test_player.py b/tests/test_player.py new file mode 100644 index 0000000..69cf013 --- /dev/null +++ b/tests/test_player.py @@ -0,0 +1,41 @@ +"""Player cue logic: timestamp parsing, cue lookup, time formatting.""" + +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +pytest.importorskip("PySide6.QtMultimedia") + +from scripto.gui_qt.player import build_cues, cue_at, format_ms, timestamp_ms + +SRT = ( + "1\n00:00:01,000 --> 00:00:03,200\nfirst line\n\n" + "2\n00:00:03,600 --> 00:01:06,900\nsecond line\n" +) + + +def test_timestamp_ms_parses_comma_and_dot(): + assert timestamp_ms("00:00:01,000") == 1000 + assert timestamp_ms("00:01:02.345") == 62345 + assert timestamp_ms("01:00:00,001") == 3600001 + assert timestamp_ms("garbage") == 0 + + +def test_cue_lookup_inside_between_and_outside(): + cues = build_cues(SRT) + assert cue_at(cues, 0) == "" # before the first cue + assert cue_at(cues, 1000) == "first line" + assert cue_at(cues, 3199) == "first line" + assert cue_at(cues, 3400) == "" # the gap between cues + assert cue_at(cues, 60000) == "second line" + assert cue_at(cues, 70000) == "" # after the last cue + + +def test_format_ms_hours_and_minutes(): + assert format_ms(0) == "0:00" + assert format_ms(65_000) == "1:05" + assert format_ms(3_600_000) == "1:00:00"