Add subtitle translation presets, custom instructions, and per-series glossary #2

Merged
vokhactri merged 2 commits from feat/subtitle-translation-presets into main 2026-11-07 13:28:32 +07:00
16 changed files with 543 additions and 11 deletions

No files matched your search

+6
View File
@@ -8,6 +8,12 @@ ecm_add_test(mpvuserconfigtest.cpp
LINK_LIBRARIES qtpotato_core Qt6::Test LINK_LIBRARIES qtpotato_core Qt6::Test
) )
ecm_add_test(glossarystoretest.cpp ../src/translate/glossarystore.cpp
TEST_NAME glossarystoretest
LINK_LIBRARIES Qt6::Core Qt6::Test
)
target_include_directories(glossarystoretest PRIVATE ../src/translate)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest TEST_NAME playlistmodeltest
LINK_LIBRARIES Qt6::Qml Qt6::Test LINK_LIBRARIES Qt6::Qml Qt6::Test
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "glossarystore.h"
#include <QStandardPaths>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
class GlossaryStoreTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void saveLoadMerge()
{
QTemporaryDir temp;
QStandardPaths::setTestModeEnabled(true);
const QString directory = temp.path() + u"/series"_s;
GlossaryStore::Glossary original{directory, {{u"Lin"_s, u"Lâm"_s}}};
QVERIFY(GlossaryStore::save(original));
const auto loaded = GlossaryStore::load(directory);
QCOMPARE(loaded.entries.size(), 1);
const auto merged = GlossaryStore::merge(loaded, {{u"Lin"_s, u"Other"_s}, {u"Sect"_s, u"Tông"_s}});
QCOMPARE(merged.entries.size(), 2);
QCOMPARE(merged.entries.at(0).target, u"Lâm"_s);
QCOMPARE(merged.entries.at(1).source, u"Sect"_s);
}
};
QTEST_MAIN(GlossaryStoreTest)
#include "glossarystoretest.moc"
+4
View File
@@ -34,10 +34,14 @@ add_executable(qtpotato
playlist/playlistmodel.h playlist/playlistmodel.h
translate/apikeystore.cpp translate/apikeystore.cpp
translate/apikeystore.h translate/apikeystore.h
translate/glossarystore.cpp
translate/glossarystore.h
translate/llmclient.cpp translate/llmclient.cpp
translate/llmclient.h translate/llmclient.h
translate/translationservice.cpp translate/translationservice.cpp
translate/translationservice.h translate/translationservice.h
translate/translationpresets.cpp
translate/translationpresets.h
) )
kconfig_target_kcfg_file(qtpotato kconfig_target_kcfg_file(qtpotato
+8
View File
@@ -35,6 +35,7 @@ Item {
signal headerToggleRequested() signal headerToggleRequested()
signal contextMenuRequested() signal contextMenuRequested()
signal translateRequested() signal translateRequested()
signal editGlossaryRequested()
function seekBy(seconds: real) { function seekBy(seconds: real) {
mpv.seekBy(seconds) mpv.seekBy(seconds)
@@ -364,6 +365,13 @@ Item {
onTriggered: root.translateRequested() onTriggered: root.translateRequested()
} }
property Action editGlossary: Action {
enabled: root.shortcutsEnabled && root.hasFile && root.mpv.currentUrl.toString().startsWith("file:")
text: KI18n.i18nc("@action", "Edit Translation Glossary…")
icon.name: "document-edit"
onTriggered: root.editGlossaryRequested()
}
property Action cancelTranslation: Action { property Action cancelTranslation: Action {
enabled: root.translator.busy enabled: root.translator.busy
text: KI18n.i18nc("@action", "Cancel Translation") text: KI18n.i18nc("@action", "Cancel Translation")
+41
View File
@@ -5,6 +5,7 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs import QtQuick.Dialogs
import QtQuick.Layouts
import org.kde.ki18n import org.kde.ki18n
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.config as KConfig import org.kde.config as KConfig
@@ -108,6 +109,10 @@ ApplicationWindow {
onPlaylistToggleRequested: playlistPanel.toggle() onPlaylistToggleRequested: playlistPanel.toggle()
onContextMenuRequested: contextMenu.popup() onContextMenuRequested: contextMenu.popup()
onTranslateRequested: appWindow.translateCurrentSubtitle() onTranslateRequested: appWindow.translateCurrentSubtitle()
onEditGlossaryRequested: {
translationService.loadGlossary(video.currentUrl)
glossaryDialog.open()
}
onMenuBarToggleRequested: { onMenuBarToggleRequested: {
QtPotatoSettings.showMenuBar = !QtPotatoSettings.showMenuBar QtPotatoSettings.showMenuBar = !QtPotatoSettings.showMenuBar
QtPotatoSettings.save() QtPotatoSettings.save()
@@ -312,4 +317,40 @@ ApplicationWindow {
] ]
onAccepted: video.addSubtitleFile(selectedFile) onAccepted: video.addSubtitleFile(selectedFile)
} }
Kirigami.Dialog {
id: glossaryDialog
title: KI18n.i18nc("@title:window", "Translation Glossary")
standardButtons: Kirigami.Dialog.Save | Kirigami.Dialog.Cancel
width: Math.min(appWindow.width * 0.9, 650)
contentItem: ColumnLayout {
ListView {
id: glossaryList
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 15
clip: true
model: ListModel { id: glossaryModel }
delegate: RowLayout {
width: glossaryList.width
TextField { text: source; Layout.fillWidth: true; onTextChanged: model.source = text }
TextField { text: target; Layout.fillWidth: true; onTextChanged: model.target = text }
ToolButton { icon.name: "edit-delete-remove"; onClicked: glossaryModel.remove(index) }
}
}
RowLayout {
Button { text: KI18n.i18nc("@action:button", "Add"); onClicked: glossaryModel.append({source: "", target: ""}) }
Button { text: KI18n.i18nc("@action:button", "Clear"); onClicked: glossaryModel.clear() }
}
}
onOpened: {
glossaryModel.clear()
for (const entry of translationService.glossaryEntries) glossaryModel.append(entry)
}
onAccepted: {
const entries = []
for (let i = 0; i < glossaryModel.count; ++i) entries.push(glossaryModel.get(i))
translationService.saveGlossary(entries)
}
}
} }
+1
View File
@@ -26,6 +26,7 @@ Menu {
} }
MenuSeparator {} MenuSeparator {}
MenuItem { action: root.actions.translateSubtitle } MenuItem { action: root.actions.translateSubtitle }
MenuItem { action: root.actions.editGlossary }
MenuItem { MenuItem {
action: root.actions.cancelTranslation action: root.actions.cancelTranslation
visible: root.actions.cancelTranslation.enabled visible: root.actions.cancelTranslation.enabled
+52
View File
@@ -145,6 +145,58 @@ SettingsBasePage {
onEditingFinished: QtPotatoSettings.save() onEditingFinished: QtPotatoSettings.save()
} }
Label {
text: KI18n.i18nc("@label:listbox", "Preset")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
}
ColumnLayout {
Layout.fillWidth: true
ComboBox {
id: presetCombo
Layout.fillWidth: true
model: root.translator.translationPresets
textRole: "name"
valueRole: "id"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(QtPotatoSettings.translationPreset))
onActivated: {
QtPotatoSettings.translationPreset = currentValue
QtPotatoSettings.save()
}
}
Label {
text: presetCombo.currentIndex >= 0 ? presetCombo.model[presetCombo.currentIndex].guidance : ""
wrapMode: Text.WordWrap
opacity: 0.7
Layout.fillWidth: true
}
}
Label {
text: KI18n.i18nc("@label:textbox", "Additional instructions")
Layout.alignment: Qt.AlignRight | Qt.AlignTop
}
TextArea {
text: QtPotatoSettings.customInstructions
placeholderText: KI18n.i18nc("@info:placeholder", "e.g. Translate 'Young Master' as 'Thiếu gia'. Use 'ta/ngươi' between enemies.")
wrapMode: TextArea.Wrap
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 4
// TextArea has no textEdited before Qt 6.9.
onTextChanged: QtPotatoSettings.customInstructions = text
onEditingFinished: QtPotatoSettings.save()
}
CheckBox {
text: KI18n.i18nc("@option:check", "Build a glossary automatically before translating")
checked: QtPotatoSettings.autoBuildGlossary
Layout.columnSpan: 2
onToggled: { QtPotatoSettings.autoBuildGlossary = checked; QtPotatoSettings.save() }
}
Label { Label {
text: KI18n.i18nc("@label:spinbox", "Lines per request") text: KI18n.i18nc("@label:spinbox", "Lines per request")
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
+12
View File
@@ -122,6 +122,18 @@
<label>Language to translate subtitles into, written for the model</label> <label>Language to translate subtitles into, written for the model</label>
<default>Vietnamese</default> <default>Vietnamese</default>
</entry> </entry>
<entry name="TranslationPreset" type="String">
<label>Style preset used by the subtitle translation prompt</label>
<default>general</default>
</entry>
<entry name="CustomInstructions" type="String">
<label>Additional instructions for subtitle translation</label>
<default></default>
</entry>
<entry name="AutoBuildGlossary" type="Bool">
<label>Automatically build a glossary before translating</label>
<default>true</default>
</entry>
<entry name="BatchSize" type="Int"> <entry name="BatchSize" type="Int">
<label>Subtitle lines sent per request</label> <label>Subtitle lines sent per request</label>
<default>40</default> <default>40</default>
+92
View File
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "glossarystore.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QSet>
#include <QStandardPaths>
using namespace Qt::Literals::StringLiterals;
QString GlossaryStore::pathForDirectory(const QString &directory)
{
const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/glossaries"_s;
const QByteArray digest = QCryptographicHash::hash(QFile::encodeName(QDir::cleanPath(directory)), QCryptographicHash::Sha1).toHex();
return root + u"/"_s + QString::fromLatin1(digest) + u".json"_s;
}
static QJsonObject glossaryJson(const GlossaryStore::Glossary &glossary)
{
QJsonArray entries;
for (const auto &entry : glossary.entries) {
if (!entry.source.trimmed().isEmpty() && !entry.target.trimmed().isEmpty()) {
entries.append(QJsonObject{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
}
}
return QJsonObject{{u"directory"_s, glossary.directory}, {u"entries"_s, entries}};
}
GlossaryStore::Glossary GlossaryStore::load(const QString &directory)
{
Glossary result{QDir::cleanPath(directory), {}};
QFile file(pathForDirectory(result.directory));
if (!file.open(QIODevice::ReadOnly)) {
return result;
}
const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object();
for (const QJsonValue &value : object.value(u"entries"_s).toArray()) {
const QJsonObject entry = value.toObject();
const QString source = entry.value(u"source"_s).toString().trimmed();
const QString target = entry.value(u"target"_s).toString().trimmed();
if (!source.isEmpty() && !target.isEmpty()) {
result.entries.append({source, target});
}
}
return result;
}
bool GlossaryStore::save(const Glossary &glossary, QString *error)
{
const QString directory = QDir::cleanPath(glossary.directory);
const QString path = pathForDirectory(directory);
QDir().mkpath(QFileInfo(path).absolutePath());
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) {
if (error) {
*error = file.errorString();
}
return false;
}
return true;
}
GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QList<Entry> &additions)
{
Glossary result{QDir::cleanPath(existing.directory), existing.entries};
QSet<QString> sources;
for (const Entry &entry : result.entries) {
sources.insert(entry.source.trimmed());
}
for (const Entry &entry : additions) {
const QString source = entry.source.trimmed();
const QString target = entry.target.trimmed();
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(source)) {
result.entries.append({source, target});
sources.insert(source);
}
}
return result;
}
QString GlossaryStore::serialize(const Glossary &glossary)
{
return QString::fromUtf8(QJsonDocument(glossaryJson(glossary)).toJson(QJsonDocument::Compact));
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QList>
#include <QString>
class GlossaryStore
{
public:
struct Entry { QString source; QString target; };
struct Glossary { QString directory; QList<Entry> entries; };
static Glossary load(const QString &directory);
static bool save(const Glossary &glossary, QString *error = nullptr);
static Glossary merge(const Glossary &existing, const QList<Entry> &additions);
static QString serialize(const Glossary &glossary);
static QString pathForDirectory(const QString &directory);
};
+55 -6
View File
@@ -1,5 +1,7 @@
#include "llmclient.h" #include "llmclient.h"
#include "translationpresets.h"
#include <KLocalizedString> #include <KLocalizedString>
#include <QCoroNetworkReply> #include <QCoroNetworkReply>
@@ -28,19 +30,33 @@ struct ReplyDeleter {
}; };
using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>; using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>;
QString systemPrompt(const QString &language) QString systemPrompt(const QString &language, const QString &presetId, const QString &glossaryText, const QString &customInstructions)
{ {
return uR"(You are a professional subtitle translator. Translate subtitles into %1. const auto &preset = TranslationPresets::find(presetId);
QString prompt = uR"(You are a professional subtitle translator. Translate subtitles into %1.
The user sends a JSON object: The user sends a JSON object:
- "title": the video title, for context. - "title": the video title, for context.
- "context": the subtitle lines right before this batch, for context only. Do not translate them. - "context": the subtitle lines right before this batch, for context only. Do not translate them.
- "lines": an object that maps ids to subtitle lines. - "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. Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. 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 Style guidance:
.arg(language); %2
)"_s
.arg(language, preset.guidance);
if (!glossaryText.trimmed().isEmpty()) {
prompt += u"Glossary — always use these renderings exactly:\n"_s;
prompt += glossaryText.trimmed() + u"\n\n"_s;
}
if (!customInstructions.trimmed().isEmpty()) {
prompt += u"Additional instructions from the user (follow them unless they conflict with the reply format):\n"_s;
prompt += customInstructions.trimmed();
}
prompt += u"\n\nReply with only a JSON object that maps every id to its translation, for example {\"1\": \"...\", \"2\": \"...\"}. No explanations, no Markdown."_s;
return prompt;
} }
// Pulls the error message out of an OpenAI-style error body, falling back to the raw text. // Pulls the error message out of an OpenAI-style error body, falling back to the raw text.
@@ -137,7 +153,7 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
{"model"_L1, m_config.model}, {"model"_L1, m_config.model},
{"messages"_L1, {"messages"_L1,
QJsonArray{ QJsonArray{
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage)}}, QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.glossaryText, m_config.customInstructions)}},
QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}}, QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}},
}}, }},
}; };
@@ -180,6 +196,39 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
co_return result; co_return result;
} }
QCoro::Task<LlmClient::BatchResult> LlmClient::extractGlossary(QStringList lines, QString mediaTitle)
{
const auto &preset = TranslationPresets::find(m_config.translationPreset);
const QString prompt = u"Identify recurring proper nouns and special terms in these subtitles. Return only JSON of the form {\"entries\":[{\"source\":\"...\",\"target\":\"...\"}]}. Include people, places, sects/clans, techniques, ranks and recurring special terms; omit ordinary words. Follow the target language and style preset.\nTarget language: "_s + m_config.targetLanguage + u"\nStyle guidance:\n"_s + preset.guidance;
const QJsonObject userContent{{"title"_L1, mediaTitle}, {"lines"_L1, QJsonArray::fromStringList(lines)}};
const QJsonObject body{{"model"_L1, m_config.model}, {"messages"_L1, QJsonArray{
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, prompt}},
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();
const QJsonObject object = extractJsonObject(content);
if (!object.contains("entries"_L1)) co_return BatchResult{{}, i18n("The model did not return a glossary: %1", content.left(300).simplified()), true};
QStringList encoded;
for (const QJsonValue &value : object.value("entries"_L1).toArray()) {
const QJsonObject entry = value.toObject();
const QString source = entry.value("source"_L1).toString().trimmed();
const QString target = entry.value("target"_L1).toString().trimmed();
if (!source.isEmpty() && !target.isEmpty()) encoded.append(source + u"\t"_s + target);
}
co_return BatchResult{encoded, {}, false};
}
QCoro::Task<LlmClient::ModelsResult> LlmClient::listModels() QCoro::Task<LlmClient::ModelsResult> LlmClient::listModels()
{ {
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s)); QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s));
+4
View File
@@ -19,6 +19,9 @@ public:
QString apiKey; QString apiKey;
QString model; QString model;
QString targetLanguage; QString targetLanguage;
QString translationPreset;
QString customInstructions;
QString glossaryText;
}; };
struct BatchResult { struct BatchResult {
@@ -39,6 +42,7 @@ public:
// Translates \a lines. \a context holds the source lines right before them, so the // Translates \a lines. \a context holds the source lines right before them, so the
// model can follow the conversation; they are not translated. // model can follow the conversation; they are not translated.
QCoro::Task<BatchResult> translate(QStringList lines, QStringList context, QString mediaTitle); QCoro::Task<BatchResult> translate(QStringList lines, QStringList context, QString mediaTitle);
QCoro::Task<BatchResult> extractGlossary(QStringList lines, QString mediaTitle);
QCoro::Task<ModelsResult> listModels(); QCoro::Task<ModelsResult> listModels();
// Aborts every request in flight. // Aborts every request in flight.
+59
View File
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "translationpresets.h"
#include <KLocalizedString>
using namespace Qt::Literals::StringLiterals;
namespace TranslationPresets
{
const QList<Preset> &all()
{
static const QList<Preset> presets{
{
u"general"_s,
i18nc("@item:inlistbox translation preset", "General"),
u"Keep names as they are conventionally rendered and use a natural, conversational tone appropriate to the scene."_s,
},
{
u"chinese-donghua"_s,
i18nc("@item:inlistbox translation preset", "Chinese animation / wuxia / xianxia"),
u"For Chinese animation, wuxia, xianxia, and historical settings, the source is often an English subtitle with pinyin names. When translating into Vietnamese, convert Chinese personal names, place names, sect and clan names, techniques, and cultivation terms into their Sino-Vietnamese (Hán-Việt) forms (for example, Xiao Yan → Tiêu Viêm, Dou Qi → Đấu Khí, Sect Master → Tông chủ). For other target languages, use that language's conventional rendering. Be consistent and prefer well-known established renderings from famous works. When translating into Vietnamese, use period-appropriate forms of address such as ta/ngươi, huynh/đệ, sư phụ, and tiền bối."_s,
},
{
u"japanese-anime"_s,
i18nc("@item:inlistbox translation preset", "Japanese anime"),
u"Keep Japanese names in romaji. Keep honorifics such as -san, -kun, and -senpai as-is. Use a natural, casual tone."_s,
},
{
u"korean-drama"_s,
i18nc("@item:inlistbox translation preset", "Korean drama"),
u"Keep Korean names. Use modern forms of address that fit the relationships; when translating into Vietnamese, use forms such as anh/em, and oppa may be kept when appropriate."_s,
},
{
u"western"_s,
i18nc("@item:inlistbox translation preset", "Western"),
u"Keep names. Use a neutral modern tone and adapt idioms naturally for the target language."_s,
},
{
u"documentary"_s,
i18nc("@item:inlistbox translation preset", "Documentary"),
u"Keep names. Use a formal, precise tone and translate terminology accurately."_s,
},
};
return presets;
}
const Preset &find(const QString &id)
{
const auto &presets = all();
for (const Preset &preset : presets) {
if (preset.id == id) {
return preset;
}
}
return presets.constFirst();
}
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QList>
#include <QString>
namespace TranslationPresets
{
struct Preset {
QString id;
QString name;
QString guidance;
};
const QList<Preset> &all();
const Preset &find(const QString &id);
}
+127 -5
View File
@@ -2,6 +2,7 @@
#include "apikeystore.h" #include "apikeystore.h"
#include "qtpotatosettings.h" #include "qtpotatosettings.h"
#include "translationpresets.h"
#include <KLocalizedString> #include <KLocalizedString>
@@ -34,7 +35,7 @@ constexpr int MaxAttempts = 3;
// depend on each other. // depend on each other.
constexpr int ParallelRequests = 3; constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused. // Bump when the prompt changes so old cached translations are not reused.
constexpr auto CacheVersion = "llm-v1"; constexpr auto CacheVersion = "llm-v2";
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect. // "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language) QString languageSuffix(const QString &language)
@@ -112,11 +113,62 @@ QStringList TranslationService::availableModels() const
return m_availableModels; return m_availableModels;
} }
QVariantList TranslationService::translationPresets() const
{
QVariantList result;
for (const auto &preset : TranslationPresets::all()) {
result.append(QVariantMap{{u"id"_s, preset.id}, {u"name"_s, preset.name}, {u"guidance"_s, preset.guidance}});
}
return result;
}
bool TranslationService::isFetchingModels() const bool TranslationService::isFetchingModels() const
{ {
return m_fetchingModels; return m_fetchingModels;
} }
QVariantList TranslationService::glossaryEntries() const
{
QVariantList result;
for (const auto &entry : m_glossary.entries) result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
return result;
}
QString TranslationService::glossaryDirectory() const
{
return m_glossary.directory;
}
void TranslationService::loadGlossary(const QUrl &videoUrl)
{
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory);
Q_EMIT glossaryChanged();
}
void TranslationService::saveGlossary(const QVariantList &entries)
{
QList<GlossaryStore::Entry> result;
for (const QVariant &value : entries) {
const QVariantMap map = value.toMap();
result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed()});
}
m_glossary.entries = result;
if (!m_glossary.directory.isEmpty()) {
GlossaryStore::save(m_glossary);
}
Q_EMIT glossaryChanged();
}
void TranslationService::clearGlossary()
{
m_glossary.entries.clear();
if (!m_glossary.directory.isEmpty()) {
GlossaryStore::save(m_glossary);
}
Q_EMIT glossaryChanged();
}
void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle) void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle)
{ {
cancel(); cancel();
@@ -160,6 +212,9 @@ QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfi
{}, {},
settings->model().trimmed(), settings->model().trimmed(),
settings->targetLanguage().trimmed(), settings->targetLanguage().trimmed(),
settings->translationPreset().trimmed(),
settings->customInstructions(),
{},
}; };
if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) {
@@ -208,7 +263,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
setBusy(true); setBusy(true);
setStatusText(i18n("Reading subtitles…")); setStatusText(i18n("Reading subtitles…"));
const auto [config, configError] = co_await loadConfig(); auto [config, configError] = co_await loadConfig();
if (job->cancelled) { if (job->cancelled) {
co_return; co_return;
} }
@@ -235,13 +290,25 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_return; co_return;
} }
// Same subtitles, model and language give the same file, so it is translated once. // Episodes in one folder share a glossary. Streams have no folder, so they get none.
const QString seriesDirectory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
const bool useGlossary = !seriesDirectory.isEmpty();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
QCryptographicHash hash(QCryptographicHash::Sha1); QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion); hash.addData(CacheVersion);
hash.addData(config.model.toUtf8()); hash.addData(config.model.toUtf8());
hash.addData(config.targetLanguage.toUtf8()); hash.addData(config.targetLanguage.toUtf8());
hash.addData(Subtitles::toSrt(cues).toUtf8()); hash.addData(config.translationPreset.toUtf8());
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); hash.addData(config.customInstructions.toUtf8());
hash.addData(GlossaryStore::serialize(g).toUtf8());
hash.addData(sourceSrt.toUtf8());
return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
};
QString outputPath = outputPathFor(glossary);
if (QFileInfo::exists(outputPath)) { if (QFileInfo::exists(outputPath)) {
m_job.reset(); m_job.reset();
@@ -251,6 +318,55 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
co_return; co_return;
} }
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
setStatusText(i18n("Building glossary…"));
QStringList extractionLines;
QHash<QString, bool> seen;
for (const SubtitleCue &cue : cues) {
if (!seen.contains(cue.text)) { seen.insert(cue.text, true); extractionLines.append(cue.text); }
}
constexpr qsizetype MaxGlossaryChars = 30000;
// Long subtitles are sampled evenly so names from the whole episode are seen.
qsizetype allChars = 0;
for (const QString &line : std::as_const(extractionLines)) {
allChars += line.size() + 1;
}
const qsizetype step = std::max<qsizetype>(1, (allChars + MaxGlossaryChars - 1) / MaxGlossaryChars);
QStringList sampled;
qsizetype total = 0;
for (qsizetype i = 0; i < extractionLines.size(); i += step) {
if (total + extractionLines.at(i).size() + 1 > MaxGlossaryChars) {
break;
}
sampled.append(extractionLines.at(i));
total += extractionLines.at(i).size() + 1;
}
job->client = std::make_unique<LlmClient>(m_network, config);
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
if (job->cancelled) {
co_return;
}
if (extracted.error.isEmpty()) {
QList<GlossaryStore::Entry> additions;
for (const QString &encoded : extracted.translations) {
const qsizetype tab = encoded.indexOf(u'\t');
if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)});
}
glossary = GlossaryStore::merge(glossary, additions);
GlossaryStore::save(glossary);
} else {
setStatusText(i18n("Glossary unavailable; translating without it."));
}
outputPath = outputPathFor(glossary);
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. // Repeated lines (songs, catchphrases) are sent once.
QStringList uniqueLines; QStringList uniqueLines;
QHash<QString, qsizetype> uniqueIndex; QHash<QString, qsizetype> uniqueIndex;
@@ -264,6 +380,12 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
cueToUnique.append(*it); cueToUnique.append(*it);
} }
QStringList glossaryLines;
for (const auto &entry : std::as_const(glossary.entries)) {
glossaryLines.append(entry.source + u" → "_s + entry.target);
}
config.glossaryText = glossaryLines.join(u'\n');
job->client = std::make_unique<LlmClient>(m_network, config); job->client = std::make_unique<LlmClient>(m_network, config);
auto batches = std::make_shared<Batches>(); auto batches = std::make_shared<Batches>();
batches->lines = uniqueLines; batches->lines = uniqueLines;
+13
View File
@@ -2,12 +2,14 @@
#include "llmclient.h" #include "llmclient.h"
#include "subtitleextractor.h" #include "subtitleextractor.h"
#include "glossarystore.h"
#include <QCoroTask> #include <QCoroTask>
#include <QObject> #include <QObject>
#include <QStringList> #include <QStringList>
#include <QUrl> #include <QUrl>
#include <QVariantList>
#include <QVariantMap> #include <QVariantMap>
#include <QtQml/qqmlregistration.h> #include <QtQml/qqmlregistration.h>
@@ -28,7 +30,10 @@ class TranslationService : public QObject
Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(QVariantList translationPresets READ translationPresets CONSTANT)
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged)
Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory NOTIFY glossaryChanged)
public: public:
explicit TranslationService(QObject *parent = nullptr); explicit TranslationService(QObject *parent = nullptr);
@@ -39,7 +44,10 @@ public:
QString statusText() const; QString statusText() const;
QString errorText() const; QString errorText() const;
QStringList availableModels() const; QStringList availableModels() const;
QVariantList translationPresets() const;
bool isFetchingModels() const; bool isFetchingModels() const;
QVariantList glossaryEntries() const;
QString glossaryDirectory() const;
// \a track is an entry of MpvItem::subtitleTracks. // \a track is an entry of MpvItem::subtitleTracks.
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle); Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
@@ -47,6 +55,9 @@ public:
// Asks the API configured in the settings for its models. // Asks the API configured in the settings for its models.
Q_INVOKABLE void fetchModels(); Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError(); Q_INVOKABLE void clearError();
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
Q_INVOKABLE void saveGlossary(const QVariantList &entries);
Q_INVOKABLE void clearGlossary();
Q_SIGNALS: Q_SIGNALS:
void busyChanged(); void busyChanged();
@@ -55,6 +66,7 @@ Q_SIGNALS:
void errorTextChanged(); void errorTextChanged();
void availableModelsChanged(); void availableModelsChanged();
void fetchingModelsChanged(); void fetchingModelsChanged();
void glossaryChanged();
void translated(const QUrl &subtitleUrl, bool fromCache); void translated(const QUrl &subtitleUrl, bool fromCache);
private: private:
@@ -88,4 +100,5 @@ private:
QString m_errorText; QString m_errorText;
QStringList m_availableModels; QStringList m_availableModels;
bool m_fetchingModels = false; bool m_fetchingModels = false;
GlossaryStore::Glossary m_glossary;
}; };