feat: translate subtitles with an OpenAI-compatible LLM
This commit is contained in:
1 parent
48cac86d60
commit
4665bf632a
21 files changed
+1877
-5
No files matched your search
+16
-3
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
ecm_add_test(subtitleparsertest.cpp
|
||||
TEST_NAME subtitleparsertest
|
||||
LINK_LIBRARIES qtpotato_subtitles Qt6::Test
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "subtitleparser.h"
|
||||
|
||||
#include <QTest>
|
||||
|
||||
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\n<i>Hello</i>\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\n<v Anna>Hi!</v>\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<SubtitleCue> 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<SubtitleCue> 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"
|
||||
+43
-1
@@ -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})
|
||||
@@ -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;
|
||||
|
||||
+7
-1
@@ -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;
|
||||
};
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
|
||||
<kcfgfile name="qtpotatorc"/>
|
||||
<group name="Translation">
|
||||
<entry name="ApiBaseUrl" type="String">
|
||||
<label>Base URL of the OpenAI-compatible API, including the version path (for example …/v1)</label>
|
||||
<default></default>
|
||||
</entry>
|
||||
<entry name="Model" type="String">
|
||||
<label>Model name sent to the chat completions endpoint</label>
|
||||
<default></default>
|
||||
</entry>
|
||||
<entry name="TargetLanguage" type="String">
|
||||
<label>Language to translate subtitles into, written for the model</label>
|
||||
<default>Vietnamese</default>
|
||||
</entry>
|
||||
<entry name="BatchSize" type="Int">
|
||||
<label>Subtitle lines sent per request</label>
|
||||
<default>40</default>
|
||||
<min>5</min>
|
||||
<max>200</max>
|
||||
</entry>
|
||||
</group>
|
||||
</kcfg>
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "subtitleextractor.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <memory>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/error.h>
|
||||
}
|
||||
|
||||
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<AVFormatContext, FormatContextDeleter> format(rawFormat);
|
||||
|
||||
if (streamIndex < 0 || streamIndex >= static_cast<int>(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<AVCodecContext, CodecContextDeleter> 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<int>(i) != streamIndex) {
|
||||
format->streams[i]->discard = AVDISCARD_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
ExtractResult result;
|
||||
std::unique_ptr<AVPacket, PacketDeleter> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "subtitleparser.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace Subtitles
|
||||
{
|
||||
struct ExtractResult {
|
||||
QList<SubtitleCue> 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);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#include "subtitleparser.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
|
||||
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<qint64> 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<qint64> 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<qint64>(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<SubtitleCue> parseSrt(const QString &content)
|
||||
{
|
||||
QList<SubtitleCue> 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<SubtitleCue> parseAss(const QString &content)
|
||||
{
|
||||
QList<SubtitleCue> 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<SubtitleCue> 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<SubtitleCue> mergeRepeated(QList<SubtitleCue> 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<SubtitleCue> merged;
|
||||
QHash<QString, qsizetype> 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<SubtitleCue> &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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
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<SubtitleCue> parseSrt(const QString &content);
|
||||
// Dialogue events of an ASS/SSA script.
|
||||
QList<SubtitleCue> parseAss(const QString &content);
|
||||
// Picks the parser from the content. Sets \a error when the file can't be read.
|
||||
QList<SubtitleCue> 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<SubtitleCue> mergeRepeated(QList<SubtitleCue> cues);
|
||||
|
||||
QString toSrt(const QList<SubtitleCue> &cues);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "apikeystore.h"
|
||||
|
||||
#include <QCoroSignal>
|
||||
|
||||
#include <QPointer>
|
||||
|
||||
#include <qt6keychain/keychain.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<typename T>
|
||||
std::unique_ptr<T, JobDeleter> makeJob()
|
||||
{
|
||||
auto job = std::unique_ptr<T, JobDeleter>(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::ReadResult> ApiKeyStore::readKey()
|
||||
{
|
||||
auto job = makeJob<QKeychain::ReadPasswordJob>();
|
||||
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<ApiKeyStore> 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<ApiKeyStore> self(this);
|
||||
std::unique_ptr<QKeychain::Job, JobDeleter> job;
|
||||
if (key.isEmpty()) {
|
||||
job = makeJob<QKeychain::DeletePasswordJob>();
|
||||
} else {
|
||||
auto write = makeJob<QKeychain::WritePasswordJob>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCoroTask>
|
||||
|
||||
#include <QObject>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
// 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<ReadResult> 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;
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
#include "llmclient.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QCoroNetworkReply>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<QNetworkReply, ReplyDeleter>;
|
||||
|
||||
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"<think>.*?</think>"_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<QNetworkReply> &reply : replies) {
|
||||
if (reply) {
|
||||
reply->abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QCoro::Task<LlmClient::BatchResult> 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::ModelsResult> 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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCoroTask>
|
||||
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
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<BatchResult> translate(QStringList lines, QStringList context, QString mediaTitle);
|
||||
QCoro::Task<ModelsResult> 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<QPointer<QNetworkReply>> m_replies;
|
||||
};
|
||||
@@ -0,0 +1,477 @@
|
||||
#include "translationservice.h"
|
||||
|
||||
#include "apikeystore.h"
|
||||
#include "qtpotatosettings.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QCoroFuture>
|
||||
#include <QCoroTimer>
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QLocale>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QPointer>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QtConcurrentRun>
|
||||
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
|
||||
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<QLocale::Language>(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<LlmClient> 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<Job>();
|
||||
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<std::pair<LlmClient::Config, QString>> 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<Subtitles::ExtractResult> TranslationService::loadCues(std::shared_ptr<Job> 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<SubtitleCue> 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> 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<SubtitleCue> 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<QString, qsizetype> uniqueIndex;
|
||||
QList<qsizetype> 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<LlmClient>(m_network, config);
|
||||
auto batches = std::make_shared<Batches>();
|
||||
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<QCoro::Task<>> 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<SubtitleCue> 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<LlmClient::BatchResult> TranslationService::translateLines(std::shared_ptr<Job> 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<qsizetype> 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<qsizetype>(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> job, std::shared_ptr<Batches> 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<qsizetype>(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<TranslationService> 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> &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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include "llmclient.h"
|
||||
#include "subtitleextractor.h"
|
||||
|
||||
#include <QCoroTask>
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QVariantMap>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
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<LlmClient::BatchResult> translateLines(std::shared_ptr<Job> 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> job, std::shared_ptr<Batches> batches);
|
||||
QCoro::Task<Subtitles::ExtractResult> loadCues(std::shared_ptr<Job> 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<std::pair<LlmClient::Config, QString>> loadConfig();
|
||||
void fail(const std::shared_ptr<Job> &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<Job> m_job;
|
||||
bool m_busy = false;
|
||||
double m_progress = 0.0;
|
||||
QString m_statusText;
|
||||
QString m_errorText;
|
||||
QStringList m_availableModels;
|
||||
bool m_fetchingModels = false;
|
||||
};
|
||||
Reference in new issue
Block a user