From 4665bf632aaade40e89f78c9f5dee974e1680ea3 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Tue, 27 Oct 2026 22:43:03 +0700 Subject: [PATCH] feat: translate subtitles with an OpenAI-compatible LLM --- CMakeLists.txt | 19 +- autotests/CMakeLists.txt | 4 + autotests/subtitleparsertest.cpp | 79 +++++ src/CMakeLists.txt | 44 ++- src/mpv/mpvitem.cpp | 24 ++ src/mpv/mpvitem.h | 8 +- src/qml/Main.qml | 10 + src/qml/PlayerPage.qml | 30 ++ src/qml/SettingsPage.qml | 132 ++++++++ src/qml/SubtitlePopup.qml | 69 ++++ src/settings/qtpotatosettings.kcfg | 26 ++ src/subtitles/subtitleextractor.cpp | 141 ++++++++ src/subtitles/subtitleextractor.h | 17 + src/subtitles/subtitleparser.cpp | 242 ++++++++++++++ src/subtitles/subtitleparser.h | 33 ++ src/translate/apikeystore.cpp | 121 +++++++ src/translate/apikeystore.h | 48 +++ src/translate/llmclient.cpp | 213 ++++++++++++ src/translate/llmclient.h | 54 +++ src/translate/translationservice.cpp | 477 +++++++++++++++++++++++++++ src/translate/translationservice.h | 91 +++++ 21 files changed, 1877 insertions(+), 5 deletions(-) create mode 100644 autotests/CMakeLists.txt create mode 100644 autotests/subtitleparsertest.cpp create mode 100644 src/qml/SettingsPage.qml create mode 100644 src/settings/qtpotatosettings.kcfg create mode 100644 src/subtitles/subtitleextractor.cpp create mode 100644 src/subtitles/subtitleextractor.h create mode 100644 src/subtitles/subtitleparser.cpp create mode 100644 src/subtitles/subtitleparser.h create mode 100644 src/translate/apikeystore.cpp create mode 100644 src/translate/apikeystore.h create mode 100644 src/translate/llmclient.cpp create mode 100644 src/translate/llmclient.h create mode 100644 src/translate/translationservice.cpp create mode 100644 src/translate/translationservice.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4508657..e31941c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,20 +12,33 @@ include(KDEInstallDirs) include(KDECMakeSettings) include(KDECompilerSettings NO_POLICY_SCOPE) include(ECMQmlModule) +include(ECMAddTests) include(FeatureSummary) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Widgets) -find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS Kirigami I18n CoreAddons Crash) +find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Widgets Network Concurrent) +find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS Kirigami I18n CoreAddons Crash Config) find_package(MpvQt 1.0 REQUIRED) +find_package(QCoro6 0.10 REQUIRED COMPONENTS Core Network) +find_package(Qt6Keychain 0.14 REQUIRED) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil) + +qcoro_enable_coroutines() find_package(KF6KirigamiAddons 1.0) set_package_properties(KF6KirigamiAddons PROPERTIES TYPE RUNTIME - PURPOSE "FormCard About page") + PURPOSE "FormCard settings and About pages") add_subdirectory(src) +if(BUILD_TESTING) + find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Test) + add_subdirectory(autotests) +endif() + feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt new file mode 100644 index 0000000..c050a3e --- /dev/null +++ b/autotests/CMakeLists.txt @@ -0,0 +1,4 @@ +ecm_add_test(subtitleparsertest.cpp + TEST_NAME subtitleparsertest + LINK_LIBRARIES qtpotato_subtitles Qt6::Test +) diff --git a/autotests/subtitleparsertest.cpp b/autotests/subtitleparsertest.cpp new file mode 100644 index 0000000..8b9fc42 --- /dev/null +++ b/autotests/subtitleparsertest.cpp @@ -0,0 +1,79 @@ +#include "subtitleparser.h" + +#include + +using namespace Qt::Literals::StringLiterals; + +class SubtitleParserTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void parsesSrt() + { + const QString srt = u"1\r\n00:00:01,000 --> 00:00:02,500\r\nHello\r\nthere\r\n\r\n" + "2\r\n00:01:00,000 --> 00:01:01,000 X1:10\r\n{\\an8}Top line\r\n"_s; + const auto cues = Subtitles::parseSrt(srt); + QCOMPARE(cues.size(), 2); + QCOMPARE(cues.at(0), (SubtitleCue{1000, 2500, u"Hello\nthere"_s})); + QCOMPARE(cues.at(1), (SubtitleCue{60000, 61000, u"Top line"_s})); + } + + void parsesWebVtt() + { + const QString vtt = u"WEBVTT\n\nintro\n00:05.250 --> 00:07.000 align:start\nHi!\n\n" + "01:00:00.000 --> 01:00:01.000\nLater\n"_s; + const auto cues = Subtitles::parseSrt(vtt); + QCOMPARE(cues.size(), 2); + QCOMPARE(cues.at(0), (SubtitleCue{5250, 7000, u"Hi!"_s})); + QCOMPARE(cues.at(1), (SubtitleCue{3600000, 3601000, u"Later"_s})); + } + + void parsesAss() + { + const QString ass = u"[Script Info]\nTitle: test\n\n[Events]\n" + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + "Dialogue: 0,0:00:01.50,0:00:03.00,Default,,0,0,0,,{\\i1}Wait,{\\i0} what?\\NNo way.\n" + "Comment: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,ignored\n" + "Dialogue: 0,0:00:04.00,0:00:05.00,Sign,,0,0,0,,{\\p1}m 0 0 l 10 0 10 10{\\p0}\n"_s; + const auto cues = Subtitles::parseAss(ass); + QCOMPARE(cues.size(), 2); + QCOMPARE(cues.at(0), (SubtitleCue{1500, 3000, u"Wait, what?\nNo way."_s})); + // Vector drawings carry no words. + QVERIFY(cues.at(1).text.isEmpty()); + } + + void mergesRepeatedFrames() + { + QList cues{ + {0, 1000, u"Hello"_s}, + {1000, 1500, u""_s}, + {5000, 6000, u"Sign"_s}, // too far from the earlier pieces: a new cue + {7000, 7040, u"Xkcd Qwer"_s}, // a lone 40 ms animation frame + }; + // One sign drawn frame by frame, 40 ms each. + for (qint64 t = 2000; t < 2400; t += 40) { + cues.append({t, t + 40, u"Sign"_s}); + } + const auto merged = Subtitles::mergeRepeated(cues); + QCOMPARE(merged.size(), 3); + QCOMPARE(merged.at(0), (SubtitleCue{0, 1000, u"Hello"_s})); + QCOMPARE(merged.at(1), (SubtitleCue{2000, 2400, u"Sign"_s})); + QCOMPARE(merged.at(2), (SubtitleCue{5000, 6000, u"Sign"_s})); + } + + void writesSrtThatParsesBack() + { + const QList cues{ + {1000, 2500, u"Xin chào\nbạn"_s}, + {3723004, 3724000, u"Một giờ sau"_s}, + }; + const QString srt = Subtitles::toSrt(cues); + QVERIFY(srt.startsWith(u"1\n00:00:01,000 --> 00:00:02,500\nXin chào\nbạn\n\n"_s)); + QCOMPARE(Subtitles::parseSrt(srt), cues); + } +}; + +QTEST_GUILESS_MAIN(SubtitleParserTest) + +#include "subtitleparsertest.moc" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fe7b1b4..3c1fd87 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,7 +1,40 @@ +# Subtitle parsing and extraction, kept apart from the app so autotests can link it. +add_library(qtpotato_subtitles STATIC + subtitles/subtitleparser.cpp + subtitles/subtitleparser.h + subtitles/subtitleextractor.cpp + subtitles/subtitleextractor.h +) +target_include_directories(qtpotato_subtitles PUBLIC subtitles) +target_link_libraries(qtpotato_subtitles PUBLIC + Qt6::Core + KF6::I18n + PRIVATE + PkgConfig::FFMPEG +) + add_executable(qtpotato main.cpp mpv/mpvitem.cpp mpv/mpvitem.h + translate/apikeystore.cpp + translate/apikeystore.h + translate/llmclient.cpp + translate/llmclient.h + translate/translationservice.cpp + translate/translationservice.h +) + +kconfig_target_kcfg_file(qtpotato + FILE ${CMAKE_CURRENT_SOURCE_DIR}/settings/qtpotatosettings.kcfg + CLASS_NAME QtPotatoSettings + MUTATORS + NOTIFIERS + DEFAULT_VALUE_GETTERS + GENERATE_PROPERTIES + GENERATE_MOC + SINGLETON + QML_REGISTRATION ) ecm_add_qml_module(qtpotato @@ -12,22 +45,31 @@ ecm_target_qml_sources(qtpotato SOURCES qml/Main.qml qml/PlayerPage.qml qml/SubtitlePopup.qml + qml/SettingsPage.qml ) -target_include_directories(qtpotato PRIVATE mpv) +target_include_directories(qtpotato PRIVATE mpv translate) target_compile_definitions(qtpotato PRIVATE QTPOTATO_VERSION_STRING="${PROJECT_VERSION}") target_link_libraries(qtpotato PRIVATE + qtpotato_subtitles Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Widgets + Qt6::Network + Qt6::Concurrent KF6::I18n KF6::I18nQml KF6::CoreAddons KF6::Crash + KF6::ConfigCore + KF6::ConfigGui MpvQt::MpvQt + QCoro6::Core + QCoro6::Network + qt6keychain ) install(TARGETS qtpotato ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index c047190..f0857d9 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -185,6 +185,24 @@ void MpvItem::addSubtitleFile(const QUrl &url) commandAsync(QStringList{QStringLiteral("sub-add"), localPathOrUrl(url), QStringLiteral("select")}); } +void MpvItem::showSecondarySubtitleFile(const QUrl &url, const QString &title) +{ + if (!url.isValid()) { + return; + } + const QString path = localPathOrUrl(url); + for (const QVariant &entry : std::as_const(m_subtitleTracks)) { + const QVariantMap track = entry.toMap(); + if (track.value(QStringLiteral("externalFilename")).toString() == path) { + setSecondarySubtitleId(track.value(QStringLiteral("id")).toInt()); + return; + } + } + // "auto" adds the track without selecting it; updateTracks() selects it as secondary. + m_pendingSecondaryPath = path; + commandAsync(QStringList{QStringLiteral("sub-add"), path, QStringLiteral("auto"), title}); +} + void MpvItem::showText(const QString &text) { commandAsync(QStringList{QStringLiteral("show-text"), text}); @@ -241,7 +259,13 @@ void MpvItem::updateTracks(const QVariantList &trackList) {QStringLiteral("codec"), track.value(QStringLiteral("codec")).toString()}, {QStringLiteral("external"), track.value(QStringLiteral("external")).toBool()}, {QStringLiteral("externalFilename"), track.value(QStringLiteral("external-filename")).toString()}, + {QStringLiteral("ffIndex"), track.value(QStringLiteral("ff-index"), -1).toInt()}, }); + + if (!m_pendingSecondaryPath.isEmpty() && track.value(QStringLiteral("external-filename")).toString() == m_pendingSecondaryPath) { + m_pendingSecondaryPath.clear(); + setSecondarySubtitleId(id); + } } m_subtitleTracks = subtitles; diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index ba3286a..7129285 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -21,7 +21,8 @@ class MpvItem : public MpvAbstractItem Q_PROPERTY(bool fileLoaded READ isFileLoaded NOTIFY fileLoadedChanged) // Subtitle tracks of the current file, each a map with id, title, lang, codec, - // external and externalFilename. Track id 0 means "no subtitle". + // external, externalFilename and ffIndex (FFmpeg stream index, -1 for external + // files). Track id 0 means "no subtitle". Q_PROPERTY(QVariantList subtitleTracks READ subtitleTracks NOTIFY subtitleTracksChanged) Q_PROPERTY(int subtitleId READ subtitleId WRITE setSubtitleId NOTIFY subtitleTracksChanged) Q_PROPERTY(int secondarySubtitleId READ secondarySubtitleId WRITE setSecondarySubtitleId NOTIFY subtitleTracksChanged) @@ -53,6 +54,9 @@ public: Q_INVOKABLE void seek(double seconds); // Adds an external subtitle file and makes it the primary subtitle. Q_INVOKABLE void addSubtitleFile(const QUrl &url); + // Shows \a url as the secondary subtitle, adding it first (named \a title) unless it + // is already loaded. + Q_INVOKABLE void showSecondarySubtitleFile(const QUrl &url, const QString &title); // Shows a short message on mpv's on-screen display. Q_INVOKABLE void showText(const QString &text); @@ -83,4 +87,6 @@ private: int m_subtitleId = 0; int m_secondarySubtitleId = 0; double m_subtitleDelay = 0.0; + // File added by showSecondarySubtitleFile() that should become secondary once mpv lists it. + QString m_pendingSecondaryPath; }; diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 5b5e2e1..40c3dfb 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -19,12 +19,22 @@ Kirigami.ApplicationWindow { id: player initialUrl: root.initialUrl onAboutRequested: root.pageStack.layers.push(aboutPage) + onSettingsRequested: root.pageStack.layers.push(settingsPage) onFullScreenToggled: root.visibility = root.visibility === Window.FullScreen ? Window.Windowed : Window.FullScreen } pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn + onClosing: QtPotatoSettings.save() + + Component { + id: settingsPage + SettingsPage { + translator: player.translator + } + } + Component { id: aboutPage FormCard.AboutPage {} diff --git a/src/qml/PlayerPage.qml b/src/qml/PlayerPage.qml index f3be795..d2c2bce 100644 --- a/src/qml/PlayerPage.qml +++ b/src/qml/PlayerPage.qml @@ -10,8 +10,10 @@ Kirigami.Page { property url initialUrl readonly property alias mediaTitle: mpv.mediaTitle + readonly property alias translator: translator signal aboutRequested() + signal settingsRequested() signal fullScreenToggled() title: KI18n.i18nc("@title", "Player") @@ -31,6 +33,13 @@ Kirigami.Page { shortcut: "F" onTriggered: page.fullScreenToggled() }, + Kirigami.Action { + icon.name: "configure" + text: KI18n.i18nc("@action", "Settings") + displayHint: Kirigami.DisplayHint.AlwaysHide + shortcut: StandardKey.Preferences + onTriggered: page.settingsRequested() + }, Kirigami.Action { icon.name: "help-about" text: KI18n.i18nc("@action", "About QtPotato") @@ -83,6 +92,25 @@ Kirigami.Page { mpv.showText(KI18n.i18nc("@info:osd", "Subtitle delay: %1 ms", Math.round(delay * 1000))) } + TranslationService { + id: translator + onTranslated: (subtitleUrl, fromCache) => { + mpv.showSecondarySubtitleFile(subtitleUrl, + KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)) + mpv.showText(fromCache + ? KI18n.i18nc("@info:osd", "Loaded the saved translation") + : KI18n.i18nc("@info:osd", "Translation ready")) + } + } + + Connections { + target: mpv + // A translation belongs to the file it was started for. + function onCurrentUrlChanged() { + translator.cancel() + } + } + function isSubtitleFile(url: url): bool { return /\.(srt|ass|ssa|vtt|sub|sup)$/i.test(url.toString()) } @@ -212,6 +240,8 @@ Kirigami.Page { id: subtitlePopup objectName: "subtitlePopup" mpv: mpv + translator: translator + onSettingsRequested: page.settingsRequested() x: subtitleButton.width - width y: -height - Kirigami.Units.smallSpacing } diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml new file mode 100644 index 0000000..81d58e8 --- /dev/null +++ b/src/qml/SettingsPage.qml @@ -0,0 +1,132 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard +import org.kde.ki18n + +FormCard.FormCardPage { + id: page + + required property TranslationService translator + + title: KI18n.i18nc("@title", "Settings") + + // Fields write to the settings object as they change; this writes it to disk. + Component.onDestruction: QtPotatoSettings.save() + + Kirigami.InlineMessage { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + type: Kirigami.MessageType.Error + text: page.translator.errorText || ApiKeyStore.error + visible: text.length > 0 + showCloseButton: true + onVisibleChanged: if (!visible) page.translator.clearError() + } + + FormCard.FormHeader { + title: KI18n.i18nc("@title:group", "Subtitle Translation") + } + + FormCard.FormCard { + FormCard.FormTextFieldDelegate { + label: KI18n.i18nc("@label:textbox", "API base URL") + placeholderText: "https://example.com/v1" + text: QtPotatoSettings.apiBaseUrl + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase + onTextEdited: QtPotatoSettings.apiBaseUrl = text.trim() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormPasswordFieldDelegate { + id: apiKeyField + label: KI18n.i18nc("@label:textbox", "API key") + placeholderText: ApiKeyStore.hasKey + ? KI18n.i18nc("@info:placeholder", "Saved in the wallet. Type a new key to replace it.") + : KI18n.i18nc("@info:placeholder", "Not set") + onEditingFinished: { + if (text.trim().length > 0) { + ApiKeyStore.setKey(text) + text = "" + } + } + } + + FormCard.FormButtonDelegate { + visible: ApiKeyStore.hasKey + icon.name: "edit-delete-remove" + text: KI18n.i18nc("@action:button", "Remove Saved API Key") + onClicked: ApiKeyStore.setKey("") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormComboBoxDelegate { + id: modelCombo + + // Swapping the list makes the combo box jump to its first entry; that must + // not overwrite the model the user chose. + property bool updatingList: false + + function showModels() { + const chosen = QtPotatoSettings.model + updatingList = true + model = page.translator.availableModels + currentIndex = page.translator.availableModels.indexOf(chosen) + editText = chosen + updatingList = false + } + + text: KI18n.i18nc("@label:listbox", "Model") + editable: true + onEditTextChanged: if (!updatingList) QtPotatoSettings.model = editText.trim() + Component.onCompleted: showModels() + + Connections { + target: page.translator + function onAvailableModelsChanged() { + modelCombo.showModels() + } + } + } + + FormCard.FormButtonDelegate { + icon.name: "view-refresh" + text: page.translator.fetchingModels + ? KI18n.i18nc("@action:button", "Loading Models…") + : KI18n.i18nc("@action:button", "Load Models from the API") + description: page.translator.availableModels.length > 0 + ? KI18n.i18ncp("@info", "%1 model available", "%1 models available", page.translator.availableModels.length) + : "" + enabled: !page.translator.fetchingModels + onClicked: page.translator.fetchModels() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + label: KI18n.i18nc("@label:textbox", "Translate into") + text: QtPotatoSettings.targetLanguage + onTextEdited: QtPotatoSettings.targetLanguage = text.trim() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormSpinBoxDelegate { + label: KI18n.i18nc("@label:spinbox", "Lines per request") + from: 5 + to: 200 + stepSize: 5 + value: QtPotatoSettings.batchSize + onValueModified: QtPotatoSettings.batchSize = value + } + } + + FormCard.FormSectionText { + text: KI18n.i18nc("@info", "Works with any OpenAI-compatible API. The whole subtitle track is translated in batches before it is shown, and the result is kept so the same track is never translated twice.") + } +} diff --git a/src/qml/SubtitlePopup.qml b/src/qml/SubtitlePopup.qml index 8d3b713..a423d9e 100644 --- a/src/qml/SubtitlePopup.qml +++ b/src/qml/SubtitlePopup.qml @@ -9,6 +9,11 @@ QQC2.Popup { id: popup required property MpvItem mpv + required property TranslationService translator + + signal settingsRequested() + + readonly property var currentTrack: mpv.subtitleTracks.find(track => track.id === mpv.subtitleId) ?? null // [{ id, text }] with "Off" first, shared by both track pickers. readonly property var trackModel: [{ id: 0, text: KI18n.i18nc("@item:inlistbox no subtitle", "Off") }] @@ -98,6 +103,70 @@ QQC2.Popup { text: KI18n.i18nc("@action:button", "Load Subtitle File…") onClicked: subtitleDialog.open() } + + Kirigami.Separator { + Kirigami.FormData.isSection: true + Kirigami.FormData.label: KI18n.i18nc("@title:group", "Translation") + } + + QQC2.Button { + visible: !popup.translator.busy + enabled: popup.currentTrack !== null + icon.name: "translate" + text: KI18n.i18nc("@action:button", "Translate Subtitle into %1", QtPotatoSettings.targetLanguage) + onClicked: popup.translator.translate(popup.mpv.currentUrl, popup.currentTrack, popup.mpv.mediaTitle) + + QQC2.ToolTip.text: KI18n.i18nc("@info:tooltip", "Translates the subtitle chosen above and shows the result as the secondary subtitle") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + RowLayout { + visible: popup.translator.busy + Layout.preferredWidth: Kirigami.Units.gridUnit * 16 + spacing: Kirigami.Units.smallSpacing + + QQC2.ProgressBar { + Layout.fillWidth: true + from: 0 + to: 1 + value: popup.translator.progress + indeterminate: popup.translator.progress === 0 + } + + QQC2.ToolButton { + icon.name: "dialog-cancel" + text: KI18n.i18nc("@action:button", "Cancel translation") + display: QQC2.AbstractButton.IconOnly + onClicked: popup.translator.cancel() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + QQC2.Label { + visible: popup.translator.busy + text: popup.translator.statusText + } + + Kirigami.InlineMessage { + Layout.preferredWidth: Kirigami.Units.gridUnit * 22 + type: Kirigami.MessageType.Error + text: popup.translator.errorText + visible: text.length > 0 + showCloseButton: true + onVisibleChanged: if (!visible) popup.translator.clearError() + actions: Kirigami.Action { + icon.name: "configure" + text: KI18n.i18nc("@action:button", "Settings") + onTriggered: { + popup.close() + popup.settingsRequested() + } + } + } } FileDialog { diff --git a/src/settings/qtpotatosettings.kcfg b/src/settings/qtpotatosettings.kcfg new file mode 100644 index 0000000..b53ef97 --- /dev/null +++ b/src/settings/qtpotatosettings.kcfg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + Vietnamese + + + + 40 + 5 + 200 + + + diff --git a/src/subtitles/subtitleextractor.cpp b/src/subtitles/subtitleextractor.cpp new file mode 100644 index 0000000..ff0ee8d --- /dev/null +++ b/src/subtitles/subtitleextractor.cpp @@ -0,0 +1,141 @@ +#include "subtitleextractor.h" + +#include + +#include + +extern "C" { +#include +#include +#include +} + +namespace +{ +struct FormatContextDeleter { + void operator()(AVFormatContext *context) const + { + avformat_close_input(&context); + } +}; +struct CodecContextDeleter { + void operator()(AVCodecContext *context) const + { + avcodec_free_context(&context); + } +}; +struct PacketDeleter { + void operator()(AVPacket *packet) const + { + av_packet_free(&packet); + } +}; + +QString ffmpegError(int code) +{ + char buffer[AV_ERROR_MAX_STRING_SIZE] = {}; + av_strerror(code, buffer, sizeof(buffer)); + return QString::fromUtf8(buffer); +} + +qint64 toMs(int64_t value, AVRational timeBase) +{ + return av_rescale_q(value, timeBase, AVRational{1, 1000}); +} + +// ASS events from the decoder look like "ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text". +QString assRectText(const char *ass) +{ + const QString event = QString::fromUtf8(ass); + qsizetype from = 0; + for (int field = 0; field < 8; ++field) { + from = event.indexOf(u',', from); + if (from < 0) { + return {}; + } + ++from; + } + return Subtitles::assEventText(QStringView(event).mid(from)); +} +} + +namespace Subtitles +{ +ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled) +{ + AVFormatContext *rawFormat = nullptr; + if (const int ret = avformat_open_input(&rawFormat, path.toUtf8().constData(), nullptr, nullptr); ret < 0) { + return {{}, i18n("Could not open %1: %2", path, ffmpegError(ret))}; + } + std::unique_ptr format(rawFormat); + + if (streamIndex < 0 || streamIndex >= static_cast(format->nb_streams)) { + return {{}, i18n("Subtitle stream %1 does not exist in this file.", streamIndex)}; + } + AVStream *stream = format->streams[streamIndex]; + + const AVCodecDescriptor *descriptor = avcodec_descriptor_get(stream->codecpar->codec_id); + if (stream->codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE || !descriptor) { + return {{}, i18n("Stream %1 is not a subtitle stream.", streamIndex)}; + } + if (descriptor->props & AV_CODEC_PROP_BITMAP_SUB) { + return {{}, i18n("This subtitle track is made of images (%1) and can't be translated.", QString::fromUtf8(descriptor->name))}; + } + + const AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id); + if (!codec) { + return {{}, i18n("No decoder for subtitle format %1.", QString::fromUtf8(descriptor->name))}; + } + std::unique_ptr decoder(avcodec_alloc_context3(codec)); + avcodec_parameters_to_context(decoder.get(), stream->codecpar); + decoder->pkt_timebase = stream->time_base; + if (const int ret = avcodec_open2(decoder.get(), codec, nullptr); ret < 0) { + return {{}, i18n("Could not open the subtitle decoder: %1", ffmpegError(ret))}; + } + + // Only the subtitle stream matters; let the demuxer skip the rest. + for (unsigned i = 0; i < format->nb_streams; ++i) { + if (static_cast(i) != streamIndex) { + format->streams[i]->discard = AVDISCARD_ALL; + } + } + + ExtractResult result; + std::unique_ptr packet(av_packet_alloc()); + while (av_read_frame(format.get(), packet.get()) >= 0) { + if (cancelled) { + return {}; + } + if (packet->stream_index != streamIndex || packet->pts == AV_NOPTS_VALUE) { + av_packet_unref(packet.get()); + continue; + } + + AVSubtitle subtitle{}; + int gotSubtitle = 0; + if (avcodec_decode_subtitle2(decoder.get(), &subtitle, &gotSubtitle, packet.get()) >= 0 && gotSubtitle) { + const qint64 start = toMs(packet->pts, stream->time_base); + qint64 end = start + toMs(packet->duration, stream->time_base); + if (packet->duration <= 0) { + end = subtitle.end_display_time > 0 && subtitle.end_display_time != UINT32_MAX ? start + subtitle.end_display_time : start + 3000; + } + + for (unsigned i = 0; i < subtitle.num_rects; ++i) { + const AVSubtitleRect *rect = subtitle.rects[i]; + QString text; + if (rect->type == SUBTITLE_ASS && rect->ass) { + text = assRectText(rect->ass); + } else if (rect->type == SUBTITLE_TEXT && rect->text) { + text = QString::fromUtf8(rect->text).trimmed(); + } + if (!text.isEmpty()) { + result.cues.append({start, end, text}); + } + } + avsubtitle_free(&subtitle); + } + av_packet_unref(packet.get()); + } + return result; +} +} diff --git a/src/subtitles/subtitleextractor.h b/src/subtitles/subtitleextractor.h new file mode 100644 index 0000000..ae38584 --- /dev/null +++ b/src/subtitles/subtitleextractor.h @@ -0,0 +1,17 @@ +#pragma once + +#include "subtitleparser.h" + +#include + +namespace Subtitles +{ +struct ExtractResult { + QList cues; + QString error; +}; + +// Decodes a text subtitle stream of a media file with FFmpeg. \a streamIndex is the +// FFmpeg stream index (mpv reports it as "ff-index"). Blocking; run it off the GUI thread. +ExtractResult extractEmbedded(const QString &path, int streamIndex, const std::atomic_bool &cancelled); +} diff --git a/src/subtitles/subtitleparser.cpp b/src/subtitles/subtitleparser.cpp new file mode 100644 index 0000000..5079ffc --- /dev/null +++ b/src/subtitles/subtitleparser.cpp @@ -0,0 +1,242 @@ +#include "subtitleparser.h" + +#include + +#include +#include +#include + +#include +#include + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +// Gap allowed between two pieces of the same line when merging them. +constexpr qint64 MergeGapMs = 50; +// Nobody can read a line shown for less than this; such cues are animation frames. +constexpr qint64 MinReadableMs = 250; + +// "01:02:03,456", "01:02:03.456", "02:03.456" (WebVTT may omit hours). +std::optional parseSrtTime(QStringView text) +{ + static const QRegularExpression re(u"^(?:(\\d+):)?(\\d{1,2}):(\\d{1,2})[,.](\\d{1,3})$"_s); + const auto match = re.matchView(text.trimmed()); + if (!match.hasMatch()) { + return std::nullopt; + } + QString fraction = match.captured(4); + while (fraction.size() < 3) { + fraction.append(u'0'); + } + return ((match.captured(1).toLongLong() * 60 + match.captured(2).toLongLong()) * 60 + match.captured(3).toLongLong()) * 1000 + fraction.toLongLong(); +} + +// "1:02:03.45" (centiseconds). +std::optional parseAssTime(QStringView text) +{ + static const QRegularExpression re(u"^(\\d+):(\\d{1,2}):(\\d{1,2})\\.(\\d{1,3})$"_s); + const auto match = re.matchView(text.trimmed()); + if (!match.hasMatch()) { + return std::nullopt; + } + QString fraction = match.captured(4); + while (fraction.size() < 3) { + fraction.append(u'0'); + } + return ((match.captured(1).toLongLong() * 60 + match.captured(2).toLongLong()) * 60 + match.captured(3).toLongLong()) * 1000 + fraction.toLongLong(); +} + +QString formatSrtTime(qint64 ms) +{ + ms = std::max(ms, 0); + return u"%1:%2:%3,%4"_s.arg(ms / 3600000, 2, 10, u'0') + .arg(ms / 60000 % 60, 2, 10, u'0') + .arg(ms / 1000 % 60, 2, 10, u'0') + .arg(ms % 1000, 3, 10, u'0'); +} + +// Trims every line and drops empty ones. +QString normalizeLines(const QString &text) +{ + QStringList lines; + for (const QString &line : text.split(u'\n')) { + const QString trimmed = line.simplified(); + if (!trimmed.isEmpty()) { + lines.append(trimmed); + } + } + return lines.join(u'\n'); +} + +QString stripSrtFormatting(const QString &text) +{ + static const QRegularExpression htmlTag(u"<[^>]*>"_s); + static const QRegularExpression assOverride(u"\\{\\\\[^}]*\\}"_s); + QString result = text; + result.remove(htmlTag); + result.remove(assOverride); + return normalizeLines(result); +} +} + +namespace Subtitles +{ +QList parseSrt(const QString &content) +{ + QList cues; + const QStringList lines = QString(content).remove(u'\r').split(u'\n'); + + for (qsizetype i = 0; i < lines.size(); ++i) { + const QString &line = lines.at(i); + const qsizetype arrow = line.indexOf(u"-->"_s); + if (arrow < 0) { + continue; + } + + // WebVTT puts cue settings after the end time: "00:01.000 --> 00:02.000 align:start". + const QString endPart = line.mid(arrow + 3).trimmed().section(u' ', 0, 0); + const auto start = parseSrtTime(QStringView(line).left(arrow)); + const auto end = parseSrtTime(endPart); + if (!start || !end) { + continue; + } + + QStringList textLines; + while (i + 1 < lines.size() && !lines.at(i + 1).trimmed().isEmpty()) { + textLines.append(lines.at(++i)); + } + cues.append({*start, *end, stripSrtFormatting(textLines.join(u'\n'))}); + } + return cues; +} + +QList parseAss(const QString &content) +{ + QList cues; + bool inEvents = false; + QStringList format; + + for (const QString &rawLine : QString(content).remove(u'\r').split(u'\n')) { + const QString line = rawLine.trimmed(); + if (line.startsWith(u'[')) { + inEvents = line.compare(u"[Events]"_s, Qt::CaseInsensitive) == 0; + continue; + } + if (!inEvents) { + continue; + } + + if (line.startsWith(u"Format:"_s)) { + format.clear(); + for (const QString &field : line.mid(7).split(u',')) { + format.append(field.trimmed()); + } + continue; + } + if (!line.startsWith(u"Dialogue:"_s) || format.isEmpty()) { + continue; + } + + // Text is the last field and may itself contain commas. + const QString values = line.mid(9).trimmed(); + QStringList fields; + qsizetype from = 0; + for (qsizetype n = 0; n < format.size() - 1; ++n) { + const qsizetype comma = values.indexOf(u',', from); + if (comma < 0) { + break; + } + fields.append(values.mid(from, comma - from).trimmed()); + from = comma + 1; + } + if (fields.size() != format.size() - 1) { + continue; + } + fields.append(values.mid(from)); + + const auto start = parseAssTime(fields.value(format.indexOf(u"Start"_s))); + const auto end = parseAssTime(fields.value(format.indexOf(u"End"_s))); + if (!start || !end) { + continue; + } + cues.append({*start, *end, assEventText(fields.last())}); + } + return cues; +} + +QList parseFile(const QString &path, QString *error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error) { + *error = i18n("Could not read %1: %2", path, file.errorString()); + } + return {}; + } + + QString content = QString::fromUtf8(file.readAll()); + if (content.startsWith(QChar(0xFEFF))) { + content.remove(0, 1); + } + if (content.contains(u"[Events]"_s, Qt::CaseInsensitive)) { + return parseAss(content); + } + return parseSrt(content); +} + +QString assEventText(QStringView text) +{ + static const QRegularExpression overrideBlock(u"\\{[^}]*\\}"_s); + // \p1, \p2… switch to drawing mode: the "text" is vector shapes, not words. + static const QRegularExpression drawing(u"\\{[^}]*\\\\p[1-9][^}]*\\}"_s); + + QString result = text.toString(); + if (drawing.match(result).hasMatch()) { + return {}; + } + result.remove(overrideBlock); + result.replace(u"\\N"_s, u"\n"_s).replace(u"\\n"_s, u"\n"_s).replace(u"\\h"_s, u" "_s); + return normalizeLines(result); +} + +QList mergeRepeated(QList cues) +{ + cues.removeIf([](const SubtitleCue &cue) { + return cue.text.isEmpty() || cue.endMs <= cue.startMs; + }); + std::stable_sort(cues.begin(), cues.end(), [](const SubtitleCue &a, const SubtitleCue &b) { + return a.startMs < b.startMs; + }); + + QList merged; + QHash lastIndexByText; + for (const SubtitleCue &cue : std::as_const(cues)) { + const auto it = lastIndexByText.constFind(cue.text); + if (it != lastIndexByText.cend()) { + SubtitleCue &previous = merged[*it]; + if (cue.startMs <= previous.endMs + MergeGapMs) { + previous.endMs = std::max(previous.endMs, cue.endMs); + continue; + } + } + lastIndexByText.insert(cue.text, merged.size()); + merged.append(cue); + } + merged.removeIf([](const SubtitleCue &cue) { + return cue.endMs - cue.startMs < MinReadableMs; + }); + return merged; +} + +QString toSrt(const QList &cues) +{ + QString out; + for (qsizetype i = 0; i < cues.size(); ++i) { + const SubtitleCue &cue = cues.at(i); + out += QString::number(i + 1) + u'\n' + formatSrtTime(cue.startMs) + u" --> "_s + formatSrtTime(cue.endMs) + u'\n' + cue.text + u"\n\n"_s; + } + return out; +} +} diff --git a/src/subtitles/subtitleparser.h b/src/subtitles/subtitleparser.h new file mode 100644 index 0000000..b111b65 --- /dev/null +++ b/src/subtitles/subtitleparser.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +struct SubtitleCue { + qint64 startMs = 0; + qint64 endMs = 0; + // Plain text without formatting; lines separated by '\n'. + QString text; + + bool operator==(const SubtitleCue &) const = default; +}; + +namespace Subtitles +{ +// SubRip and WebVTT. +QList parseSrt(const QString &content); +// Dialogue events of an ASS/SSA script. +QList parseAss(const QString &content); +// Picks the parser from the content. Sets \a error when the file can't be read. +QList parseFile(const QString &path, QString *error); + +// Text of an ASS event without override tags. Empty for vector drawings. +QString assEventText(QStringView text); + +// Drops empty cues, sorts by start time, and merges cues that repeat the same text +// back to back (typesetting often splits one line into many short frames). Cues that +// stay too short to read after merging are dropped as animation frames. +QList mergeRepeated(QList cues); + +QString toSrt(const QList &cues); +} diff --git a/src/translate/apikeystore.cpp b/src/translate/apikeystore.cpp new file mode 100644 index 0000000..e08bf53 --- /dev/null +++ b/src/translate/apikeystore.cpp @@ -0,0 +1,121 @@ +#include "apikeystore.h" + +#include + +#include + +#include + +#include + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +const QString Service = u"dev.trivk.qtpotato"_s; +const QString Key = u"translation-api-key"_s; + +struct JobDeleter { + void operator()(QKeychain::Job *job) const + { + job->deleteLater(); + } +}; + +template +std::unique_ptr makeJob() +{ + auto job = std::unique_ptr(new T(Service)); + job->setAutoDelete(false); + job->setKey(Key); + return job; +} +} + +ApiKeyStore::ApiKeyStore(QObject *parent) + : QObject(parent) +{ + refresh(); +} + +bool ApiKeyStore::hasKey() const +{ + return m_hasKey; +} + +QString ApiKeyStore::error() const +{ + return m_error; +} + +void ApiKeyStore::setKey(const QString &key) +{ + store(key.trimmed()); +} + +QCoro::Task ApiKeyStore::readKey() +{ + auto job = makeJob(); + job->start(); + co_await qCoro(job.get(), &QKeychain::Job::finished); + + if (job->error() == QKeychain::EntryNotFound) { + co_return ReadResult{}; + } + if (job->error() != QKeychain::NoError) { + co_return ReadResult{{}, job->errorString()}; + } + co_return ReadResult{job->textData(), {}}; +} + +QCoro::Task<> ApiKeyStore::refresh() +{ + QPointer self(this); + const ReadResult result = co_await readKey(); + if (!self) { + co_return; + } + setHasKey(!result.key.isEmpty()); + setError(result.error); +} + +QCoro::Task<> ApiKeyStore::store(QString key) +{ + QPointer self(this); + std::unique_ptr job; + if (key.isEmpty()) { + job = makeJob(); + } else { + auto write = makeJob(); + write->setTextData(key); + job.reset(write.release()); + } + job->start(); + co_await qCoro(job.get(), &QKeychain::Job::finished); + if (!self) { + co_return; + } + + if (job->error() != QKeychain::NoError && job->error() != QKeychain::EntryNotFound) { + setError(job->errorString()); + co_return; + } + setError({}); + setHasKey(!key.isEmpty()); +} + +void ApiKeyStore::setHasKey(bool hasKey) +{ + if (hasKey != m_hasKey) { + m_hasKey = hasKey; + Q_EMIT hasKeyChanged(); + } +} + +void ApiKeyStore::setError(const QString &error) +{ + if (error != m_error) { + m_error = error; + Q_EMIT errorChanged(); + } +} diff --git a/src/translate/apikeystore.h b/src/translate/apikeystore.h new file mode 100644 index 0000000..56040b3 --- /dev/null +++ b/src/translate/apikeystore.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include +#include + +// Keeps the translation API key in the system keychain (KWallet / Secret Service) +// instead of the plain-text config file. +class ApiKeyStore : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool hasKey READ hasKey NOTIFY hasKeyChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + +public: + struct ReadResult { + QString key; + QString error; + }; + + explicit ApiKeyStore(QObject *parent = nullptr); + + bool hasKey() const; + QString error() const; + + // Stores \a key, or removes the stored key when it is empty. + Q_INVOKABLE void setKey(const QString &key); + + // An empty key without error means none is stored. + static QCoro::Task readKey(); + +Q_SIGNALS: + void hasKeyChanged(); + void errorChanged(); + +private: + QCoro::Task<> refresh(); + QCoro::Task<> store(QString key); + void setHasKey(bool hasKey); + void setError(const QString &error); + + bool m_hasKey = false; + QString m_error; +}; diff --git a/src/translate/llmclient.cpp b/src/translate/llmclient.cpp new file mode 100644 index 0000000..c1557bb --- /dev/null +++ b/src/translate/llmclient.cpp @@ -0,0 +1,213 @@ +#include "llmclient.h" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +constexpr int RequestTimeoutMs = 180 * 1000; + +struct ReplyDeleter { + void operator()(QNetworkReply *reply) const + { + reply->deleteLater(); + } +}; +using ReplyPtr = std::unique_ptr; + +QString systemPrompt(const QString &language) +{ + return uR"(You are a professional subtitle translator. Translate subtitles into %1. + +The user sends a JSON object: +- "title": the video title, for context. +- "context": the subtitle lines right before this batch, for context only. Do not translate them. +- "lines": an object that maps ids to subtitle lines. + +Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. Keep names and the tone. Translate each entry on its own; never merge, split, skip or reorder entries. + +Reply with only a JSON object that maps every id to its translation, for example {"1": "...", "2": "..."}. No explanations, no Markdown.)"_s + .arg(language); +} + +// Pulls the error message out of an OpenAI-style error body, falling back to the raw text. +QString describeHttpError(QNetworkReply *reply, const QByteArray &body) +{ + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QJsonObject json = QJsonDocument::fromJson(body).object(); + QString message = json.value("error"_L1).toObject().value("message"_L1).toString(); + if (message.isEmpty()) { + message = json.value("error"_L1).toString(); + } + if (message.isEmpty()) { + message = QString::fromUtf8(body.left(300)).simplified(); + } + if (message.isEmpty()) { + message = reply->errorString(); + } + return status > 0 ? i18n("HTTP %1: %2", status, message) : message; +} + +bool isRetryable(QNetworkReply *reply) +{ + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status == 429 || status >= 500) { + return true; + } + switch (reply->error()) { + case QNetworkReply::TimeoutError: + case QNetworkReply::OperationCanceledError: // transfer timeout reports this + case QNetworkReply::RemoteHostClosedError: + case QNetworkReply::TemporaryNetworkFailureError: + case QNetworkReply::NetworkSessionFailedError: + return true; + default: + return false; + } +} + +// Models sometimes wrap the JSON in Markdown fences or prepend their reasoning. +QJsonObject extractJsonObject(QString content) +{ + static const QRegularExpression thinkBlock(u".*?"_s, QRegularExpression::DotMatchesEverythingOption); + content.remove(thinkBlock); + const qsizetype start = content.indexOf(u'{'); + const qsizetype end = content.lastIndexOf(u'}'); + if (start < 0 || end <= start) { + return {}; + } + return QJsonDocument::fromJson(QStringView(content).mid(start, end - start + 1).toUtf8()).object(); +} +} + +LlmClient::LlmClient(QNetworkAccessManager *network, Config config) + : m_network(network) + , m_config(std::move(config)) +{ +} + +QUrl LlmClient::normalizedBaseUrl(const QString &url) +{ + QString base = url.trimmed(); + while (base.endsWith(u'/')) { + base.chop(1); + } + if (base.endsWith(u"/chat/completions"_s)) { + base.chop(u"/chat/completions"_s.size()); + } + return QUrl(base); +} + +void LlmClient::abort() +{ + // Copy: aborting finishes the reply, which resumes its coroutine and edits the list. + const auto replies = m_replies; + for (const QPointer &reply : replies) { + if (reply) { + reply->abort(); + } + } +} + +QCoro::Task LlmClient::translate(QStringList lines, QStringList context, QString mediaTitle) +{ + QJsonObject linesObject; + for (qsizetype i = 0; i < lines.size(); ++i) { + linesObject.insert(QString::number(i + 1), lines.at(i)); + } + const QJsonObject userContent{ + {"title"_L1, mediaTitle}, + {"context"_L1, QJsonArray::fromStringList(context)}, + {"lines"_L1, linesObject}, + }; + const QJsonObject body{ + {"model"_L1, m_config.model}, + {"messages"_L1, + QJsonArray{ + QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage)}}, + QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}}, + }}, + }; + + QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/chat/completions"_s)); + request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s); + if (!m_config.apiKey.isEmpty()) { + request.setRawHeader("Authorization", "Bearer " + m_config.apiKey.toUtf8()); + } + request.setTransferTimeout(RequestTimeoutMs); + + ReplyPtr reply(m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact))); + m_replies.append(reply.get()); + co_await reply.get(); + m_replies.removeAll(reply.get()); + + const QByteArray responseBody = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + co_return BatchResult{{}, describeHttpError(reply.get(), responseBody), isRetryable(reply.get())}; + } + + const QJsonObject response = QJsonDocument::fromJson(responseBody).object(); + const QString content = response.value("choices"_L1).toArray().at(0).toObject().value("message"_L1).toObject().value("content"_L1).toString(); + if (content.isEmpty()) { + co_return BatchResult{{}, i18n("The API returned no message: %1", QString::fromUtf8(responseBody.left(300)).simplified()), false}; + } + + const QJsonObject translated = extractJsonObject(content); + if (translated.isEmpty()) { + // Malformed output is often a one-off; asking again usually works. + co_return BatchResult{{}, i18n("The model did not reply with JSON: %1", content.left(300).simplified()), true}; + } + + BatchResult result; + result.translations.reserve(lines.size()); + for (qsizetype i = 0; i < lines.size(); ++i) { + const QJsonValue value = translated.value(QString::number(i + 1)); + result.translations.append(value.isString() && !value.toString().trimmed().isEmpty() ? value.toString().trimmed() : QString()); + } + co_return result; +} + +QCoro::Task LlmClient::listModels() +{ + QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s)); + if (!m_config.apiKey.isEmpty()) { + request.setRawHeader("Authorization", "Bearer " + m_config.apiKey.toUtf8()); + } + request.setTransferTimeout(30 * 1000); + + ReplyPtr reply(m_network->get(request)); + m_replies.append(reply.get()); + co_await reply.get(); + m_replies.removeAll(reply.get()); + + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + co_return ModelsResult{{}, describeHttpError(reply.get(), body)}; + } + + ModelsResult result; + for (const QJsonValue &model : QJsonDocument::fromJson(body).object().value("data"_L1).toArray()) { + const QString id = model.toObject().value("id"_L1).toString(); + if (!id.isEmpty()) { + result.models.append(id); + } + } + result.models.sort(Qt::CaseInsensitive); + if (result.models.isEmpty()) { + result.error = i18n("The API returned no models: %1", QString::fromUtf8(body.left(300)).simplified()); + } + co_return result; +} diff --git a/src/translate/llmclient.h b/src/translate/llmclient.h new file mode 100644 index 0000000..d860578 --- /dev/null +++ b/src/translate/llmclient.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +// Talks to an OpenAI-compatible API (POST {base}/chat/completions, GET {base}/models). +class LlmClient +{ +public: + struct Config { + QUrl baseUrl; + QString apiKey; + QString model; + QString targetLanguage; + }; + + struct BatchResult { + // Same size as the input; a null QString marks a line the model left out. + QStringList translations; + QString error; + // Worth trying the same request again (rate limit, server or network error). + bool retryable = false; + }; + + struct ModelsResult { + QStringList models; + QString error; + }; + + LlmClient(QNetworkAccessManager *network, Config config); + + // Translates \a lines. \a context holds the source lines right before them, so the + // model can follow the conversation; they are not translated. + QCoro::Task translate(QStringList lines, QStringList context, QString mediaTitle); + QCoro::Task listModels(); + + // Aborts every request in flight. + void abort(); + + // Accepts ".../v1", ".../v1/" and ".../v1/chat/completions". + static QUrl normalizedBaseUrl(const QString &url); + +private: + QNetworkAccessManager *m_network; + Config m_config; + QList> m_replies; +}; diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp new file mode 100644 index 0000000..1a976e7 --- /dev/null +++ b/src/translate/translationservice.cpp @@ -0,0 +1,477 @@ +#include "translationservice.h" + +#include "apikeystore.h" +#include "qtpotatosettings.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace Qt::Literals::StringLiterals; +using namespace std::chrono_literals; + +namespace +{ +// Source lines before a batch that the model sees for context. +constexpr qsizetype ContextLines = 6; +constexpr int MaxAttempts = 3; +// Requests in flight at once. Batches only use source lines as context, so they don't +// depend on each other. +constexpr int ParallelRequests = 3; +// Bump when the prompt changes so old cached translations are not reused. +constexpr auto CacheVersion = "llm-v1"; + +// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect. +QString languageSuffix(const QString &language) +{ + for (int i = QLocale::C + 1; i <= QLocale::LastLanguage; ++i) { + const auto candidate = static_cast(i); + if (QLocale::languageToString(candidate).compare(language, Qt::CaseInsensitive) == 0) { + const QString code = QLocale::languageToCode(candidate, QLocale::ISO639Part1); + return code.isEmpty() ? QLocale::languageToCode(candidate) : code; + } + } + QString suffix = language.toLower(); + suffix.removeIf([](QChar c) { + return !c.isLetterOrNumber() && c != u'-'; + }); + return suffix.isEmpty() ? u"translated"_s : suffix; +} + +QString cachePath(const QUrl &videoUrl, const QString &key, const QString &language) +{ + const QString dir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u"/translations"_s; + const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s; + return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language)); +} +} + +struct TranslationService::Job { + std::atomic_bool cancelled = false; + std::unique_ptr client; + QString mediaTitle; +}; + +struct TranslationService::Batches { + QStringList lines; + QStringList translations; + qsizetype batchSize = 0; + qsizetype nextStart = 0; + qsizetype doneLines = 0; + QString error; +}; + +TranslationService::TranslationService(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{ +} + +TranslationService::~TranslationService() +{ + cancel(); +} + +bool TranslationService::isBusy() const +{ + return m_busy; +} + +double TranslationService::progress() const +{ + return m_progress; +} + +QString TranslationService::statusText() const +{ + return m_statusText; +} + +QString TranslationService::errorText() const +{ + return m_errorText; +} + +QStringList TranslationService::availableModels() const +{ + return m_availableModels; +} + +bool TranslationService::isFetchingModels() const +{ + return m_fetchingModels; +} + +void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle) +{ + cancel(); + m_job = std::make_shared(); + m_job->mediaTitle = mediaTitle; + run(videoUrl, track); +} + +void TranslationService::cancel() +{ + if (!m_job) { + return; + } + // The running coroutine keeps its own reference and stops at its next check. + m_job->cancelled = true; + if (m_job->client) { + m_job->client->abort(); + } + m_job.reset(); + setBusy(false); + setStatusText({}); +} + +void TranslationService::fetchModels() +{ + if (!m_fetchingModels) { + runFetchModels(); + } +} + +void TranslationService::clearError() +{ + setErrorText({}); +} + +QCoro::Task> TranslationService::loadConfig() +{ + const auto settings = QtPotatoSettings::self(); + LlmClient::Config config{ + LlmClient::normalizedBaseUrl(settings->apiBaseUrl()), + {}, + settings->model().trimmed(), + settings->targetLanguage().trimmed(), + }; + + if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { + co_return std::pair{config, i18n("Set the API base URL in Settings first.")}; + } + + const ApiKeyStore::ReadResult key = co_await ApiKeyStore::readKey(); + if (!key.error.isEmpty()) { + co_return std::pair{config, i18n("Could not read the API key from the wallet: %1", key.error)}; + } + config.apiKey = key.key; + co_return std::pair{config, QString()}; +} + +QCoro::Task TranslationService::loadCues(std::shared_ptr job, QUrl videoUrl, QVariantMap track) +{ + if (track.value(u"external"_s).toBool()) { + const QString path = track.value(u"externalFilename"_s).toString(); + co_return co_await QtConcurrent::run([path] { + QString error; + QList cues = Subtitles::parseFile(path, &error); + return Subtitles::ExtractResult{cues, error}; + }); + } + + if (!videoUrl.isLocalFile()) { + co_return Subtitles::ExtractResult{{}, i18n("Only subtitles of local files can be translated.")}; + } + const int streamIndex = track.value(u"ffIndex"_s, -1).toInt(); + if (streamIndex < 0) { + co_return Subtitles::ExtractResult{{}, i18n("mpv did not report where this subtitle track is stored.")}; + } + + const QString path = videoUrl.toLocalFile(); + co_return co_await QtConcurrent::run([path, streamIndex, job] { + return Subtitles::extractEmbedded(path, streamIndex, job->cancelled); + }); +} + +QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) +{ + const std::shared_ptr job = m_job; + + setErrorText({}); + setProgress(0); + setBusy(true); + setStatusText(i18n("Reading subtitles…")); + + const auto [config, configError] = co_await loadConfig(); + if (job->cancelled) { + co_return; + } + if (!configError.isEmpty()) { + fail(job, configError); + co_return; + } + if (config.model.isEmpty()) { + fail(job, i18n("Choose a model in Settings first.")); + co_return; + } + + Subtitles::ExtractResult source = co_await loadCues(job, videoUrl, track); + if (job->cancelled) { + co_return; + } + if (!source.error.isEmpty()) { + fail(job, source.error); + co_return; + } + const QList cues = Subtitles::mergeRepeated(source.cues); + if (cues.isEmpty()) { + fail(job, i18n("This subtitle track has no text to translate.")); + co_return; + } + + // Same subtitles, model and language give the same file, so it is translated once. + QCryptographicHash hash(QCryptographicHash::Sha1); + hash.addData(CacheVersion); + hash.addData(config.model.toUtf8()); + hash.addData(config.targetLanguage.toUtf8()); + hash.addData(Subtitles::toSrt(cues).toUtf8()); + const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); + + if (QFileInfo::exists(outputPath)) { + m_job.reset(); + setBusy(false); + setStatusText({}); + Q_EMIT translated(QUrl::fromLocalFile(outputPath), true); + co_return; + } + + // Repeated lines (songs, catchphrases) are sent once. + QStringList uniqueLines; + QHash uniqueIndex; + QList cueToUnique; + for (const SubtitleCue &cue : cues) { + auto it = uniqueIndex.constFind(cue.text); + if (it == uniqueIndex.cend()) { + it = uniqueIndex.insert(cue.text, uniqueLines.size()); + uniqueLines.append(cue.text); + } + cueToUnique.append(*it); + } + + job->client = std::make_unique(m_network, config); + auto batches = std::make_shared(); + batches->lines = uniqueLines; + batches->translations.resize(uniqueLines.size()); + batches->batchSize = QtPotatoSettings::self()->batchSize(); + setStatusText(i18n("Translating… %1 of %2 lines", 0, uniqueLines.size())); + + // Tasks start running as soon as they are created; awaiting them just waits for the end. + std::vector> workers; + for (int i = 0; i < ParallelRequests; ++i) { + workers.push_back(translateBatches(job, batches)); + } + for (QCoro::Task<> &worker : workers) { + co_await std::move(worker); + } + if (job->cancelled) { + co_return; + } + if (!batches->error.isEmpty()) { + fail(job, batches->error); + co_return; + } + + QList output = cues; + for (qsizetype i = 0; i < output.size(); ++i) { + output[i].text = batches->translations.at(cueToUnique.at(i)); + } + + QDir().mkpath(QFileInfo(outputPath).absolutePath()); + QSaveFile file(outputPath); + if (!file.open(QIODevice::WriteOnly) || file.write(Subtitles::toSrt(output).toUtf8()) < 0 || !file.commit()) { + fail(job, i18n("Could not save the translation to %1: %2", outputPath, file.errorString())); + co_return; + } + + m_job.reset(); + setBusy(false); + setStatusText({}); + Q_EMIT translated(QUrl::fromLocalFile(outputPath), false); +} + +QCoro::Task TranslationService::translateLines(std::shared_ptr job, QStringList lines, QStringList context) +{ + LlmClient::BatchResult result; + for (int attempt = 1; attempt <= MaxAttempts; ++attempt) { + result = co_await job->client->translate(lines, context, job->mediaTitle); + if (job->cancelled || result.error.isEmpty() || !result.retryable || attempt == MaxAttempts) { + break; + } + co_await QCoro::sleepFor(2s * attempt); + if (job->cancelled) { + co_return result; + } + } + if (job->cancelled || !result.error.isEmpty()) { + co_return result; + } + + QList missing; + for (qsizetype i = 0; i < result.translations.size(); ++i) { + if (result.translations.at(i).isNull()) { + missing.append(i); + } + } + if (missing.isEmpty()) { + co_return result; + } + if (lines.size() == 1) { + co_return LlmClient::BatchResult{{}, i18n("The model did not translate this line: %1", lines.first()), false}; + } + + if (missing.size() == lines.size()) { + // Nothing came back usable; smaller requests are easier for the model. + const qsizetype half = lines.size() / 2; + const LlmClient::BatchResult first = co_await translateLines(job, lines.first(half), context); + if (job->cancelled || !first.error.isEmpty()) { + co_return first; + } + QStringList secondContext = context + lines.first(half); + secondContext = secondContext.mid(std::max(0, secondContext.size() - ContextLines)); + const LlmClient::BatchResult second = co_await translateLines(job, lines.sliced(half), secondContext); + if (job->cancelled || !second.error.isEmpty()) { + co_return second; + } + co_return LlmClient::BatchResult{first.translations + second.translations, {}, false}; + } + + // Ask again only for the lines the model skipped. + QStringList retryLines; + for (const qsizetype i : missing) { + retryLines.append(lines.at(i)); + } + const LlmClient::BatchResult retry = co_await translateLines(job, retryLines, context); + if (job->cancelled || !retry.error.isEmpty()) { + co_return retry; + } + for (qsizetype i = 0; i < missing.size(); ++i) { + result.translations[missing.at(i)] = retry.translations.at(i); + } + co_return result; +} + +QCoro::Task<> TranslationService::translateBatches(std::shared_ptr job, std::shared_ptr batches) +{ + // After an error, requests already sent may finish, but no new ones start. + while (!job->cancelled && batches->error.isEmpty() && batches->nextStart < batches->lines.size()) { + const qsizetype start = batches->nextStart; + batches->nextStart += batches->batchSize; + + const qsizetype contextStart = std::max(0, start - ContextLines); + const LlmClient::BatchResult result = + co_await translateLines(job, batches->lines.mid(start, batches->batchSize), batches->lines.mid(contextStart, start - contextStart)); + if (job->cancelled) { + co_return; + } + if (!result.error.isEmpty()) { + if (batches->error.isEmpty()) { + batches->error = result.error; + } + co_return; + } + + for (qsizetype i = 0; i < result.translations.size(); ++i) { + batches->translations[start + i] = result.translations.at(i); + } + batches->doneLines += result.translations.size(); + setProgress(double(batches->doneLines) / batches->lines.size()); + setStatusText(i18n("Translating… %1 of %2 lines", batches->doneLines, batches->lines.size())); + } +} + +QCoro::Task<> TranslationService::runFetchModels() +{ + QPointer self(this); + setFetchingModels(true); + setErrorText({}); + + const auto [config, configError] = co_await loadConfig(); + if (!self) { + co_return; + } + if (!configError.isEmpty()) { + setErrorText(configError); + setFetchingModels(false); + co_return; + } + + LlmClient client(m_network, config); + const LlmClient::ModelsResult result = co_await client.listModels(); + if (!self) { + co_return; + } + if (!result.error.isEmpty()) { + setErrorText(i18n("Could not list models: %1", result.error)); + } else if (result.models != m_availableModels) { + m_availableModels = result.models; + Q_EMIT availableModelsChanged(); + } + setFetchingModels(false); +} + +void TranslationService::fail(const std::shared_ptr &job, const QString &error) +{ + if (m_job != job) { + return; + } + m_job.reset(); + setErrorText(error); + setBusy(false); + setStatusText({}); +} + +void TranslationService::setBusy(bool busy) +{ + if (busy != m_busy) { + m_busy = busy; + Q_EMIT busyChanged(); + } +} + +void TranslationService::setProgress(double progress) +{ + if (progress != m_progress) { + m_progress = progress; + Q_EMIT progressChanged(); + } +} + +void TranslationService::setStatusText(const QString &text) +{ + if (text != m_statusText) { + m_statusText = text; + Q_EMIT statusTextChanged(); + } +} + +void TranslationService::setErrorText(const QString &text) +{ + if (text != m_errorText) { + m_errorText = text; + Q_EMIT errorTextChanged(); + } +} + +void TranslationService::setFetchingModels(bool fetching) +{ + if (fetching != m_fetchingModels) { + m_fetchingModels = fetching; + Q_EMIT fetchingModelsChanged(); + } +} diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h new file mode 100644 index 0000000..c0933c6 --- /dev/null +++ b/src/translate/translationservice.h @@ -0,0 +1,91 @@ +#pragma once + +#include "llmclient.h" +#include "subtitleextractor.h" + +#include + +#include +#include +#include +#include +#include + +#include +#include + +class QNetworkAccessManager; + +// Translates a whole subtitle track with an LLM, batch by batch, and caches the result +// as an .srt file that mpv can load as the secondary subtitle. +class TranslationService : public QObject +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(bool busy READ isBusy NOTIFY busyChanged) + Q_PROPERTY(double progress READ progress NOTIFY progressChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) + Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) + +public: + explicit TranslationService(QObject *parent = nullptr); + ~TranslationService() override; + + bool isBusy() const; + double progress() const; + QString statusText() const; + QString errorText() const; + QStringList availableModels() const; + bool isFetchingModels() const; + + // \a track is an entry of MpvItem::subtitleTracks. + Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle); + Q_INVOKABLE void cancel(); + // Asks the API configured in the settings for its models. + Q_INVOKABLE void fetchModels(); + Q_INVOKABLE void clearError(); + +Q_SIGNALS: + void busyChanged(); + void progressChanged(); + void statusTextChanged(); + void errorTextChanged(); + void availableModelsChanged(); + void fetchingModelsChanged(); + void translated(const QUrl &subtitleUrl, bool fromCache); + +private: + struct Job; + struct Batches; + + QCoro::Task<> run(QUrl videoUrl, QVariantMap track); + QCoro::Task<> runFetchModels(); + // Translates \a lines, retrying failures and asking again for lines the model skipped. + QCoro::Task translateLines(std::shared_ptr job, QStringList lines, QStringList context); + // Takes batches from \a batches until none are left; several run side by side. + QCoro::Task<> translateBatches(std::shared_ptr job, std::shared_ptr batches); + QCoro::Task loadCues(std::shared_ptr job, QUrl videoUrl, QVariantMap track); + + // Reads the settings and the API key. The second value is an error message when + // something is missing. + QCoro::Task> loadConfig(); + void fail(const std::shared_ptr &job, const QString &error); + + void setBusy(bool busy); + void setProgress(double progress); + void setStatusText(const QString &text); + void setErrorText(const QString &text); + void setFetchingModels(bool fetching); + + QNetworkAccessManager *m_network; + std::shared_ptr m_job; + bool m_busy = false; + double m_progress = 0.0; + QString m_statusText; + QString m_errorText; + QStringList m_availableModels; + bool m_fetchingModels = false; +};