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 547 additions and 15 deletions

No files matched your search

+6
View File
@@ -8,6 +8,12 @@ ecm_add_test(mpvuserconfigtest.cpp
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
TEST_NAME playlistmodeltest
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
translate/apikeystore.cpp
translate/apikeystore.h
translate/glossarystore.cpp
translate/glossarystore.h
translate/llmclient.cpp
translate/llmclient.h
translate/translationservice.cpp
translate/translationservice.h
translate/translationpresets.cpp
translate/translationpresets.h
)
kconfig_target_kcfg_file(qtpotato
+8
View File
@@ -35,6 +35,7 @@ Item {
signal headerToggleRequested()
signal contextMenuRequested()
signal translateRequested()
signal editGlossaryRequested()
function seekBy(seconds: real) {
mpv.seekBy(seconds)
@@ -364,6 +365,13 @@ Item {
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 {
enabled: root.translator.busy
text: KI18n.i18nc("@action", "Cancel Translation")
+41
View File
@@ -5,6 +5,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import org.kde.ki18n
import org.kde.kirigami as Kirigami
import org.kde.config as KConfig
@@ -108,6 +109,10 @@ ApplicationWindow {
onPlaylistToggleRequested: playlistPanel.toggle()
onContextMenuRequested: contextMenu.popup()
onTranslateRequested: appWindow.translateCurrentSubtitle()
onEditGlossaryRequested: {
translationService.loadGlossary(video.currentUrl)
glossaryDialog.open()
}
onMenuBarToggleRequested: {
QtPotatoSettings.showMenuBar = !QtPotatoSettings.showMenuBar
QtPotatoSettings.save()
@@ -312,4 +317,40 @@ ApplicationWindow {
]
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 {}
MenuItem { action: root.actions.translateSubtitle }
MenuItem { action: root.actions.editGlossary }
MenuItem {
action: root.actions.cancelTranslation
visible: root.actions.cancelTranslation.enabled
+52
View File
@@ -145,6 +145,58 @@ SettingsBasePage {
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 {
text: KI18n.i18nc("@label:spinbox", "Lines per request")
Layout.alignment: Qt.AlignRight
+12
View File
@@ -122,6 +122,18 @@
<label>Language to translate subtitles into, written for the model</label>
<default>Vietnamese</default>
</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">
<label>Subtitle lines sent per request</label>
<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 "translationpresets.h"
#include <KLocalizedString>
#include <QCoroNetworkReply>
@@ -28,19 +30,33 @@ struct 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:
- "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.
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
.arg(language);
Style guidance:
%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.
@@ -137,7 +153,7 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
{"model"_L1, m_config.model},
{"messages"_L1,
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))}},
}},
};
@@ -180,6 +196,39 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
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()
{
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s));
+4
View File
@@ -19,6 +19,9 @@ public:
QString apiKey;
QString model;
QString targetLanguage;
QString translationPreset;
QString customInstructions;
QString glossaryText;
};
struct BatchResult {
@@ -39,6 +42,7 @@ public:
// 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<BatchResult> extractGlossary(QStringList lines, QString mediaTitle);
QCoro::Task<ModelsResult> listModels();
// 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);
}
+131 -9
View File
@@ -2,6 +2,7 @@
#include "apikeystore.h"
#include "qtpotatosettings.h"
#include "translationpresets.h"
#include <KLocalizedString>
@@ -34,7 +35,7 @@ constexpr int MaxAttempts = 3;
// 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";
constexpr auto CacheVersion = "llm-v2";
// "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language)
@@ -112,11 +113,62 @@ QStringList TranslationService::availableModels() const
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
{
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)
{
cancel();
@@ -160,6 +212,9 @@ QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfi
{},
settings->model().trimmed(),
settings->targetLanguage().trimmed(),
settings->translationPreset().trimmed(),
settings->customInstructions(),
{},
};
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);
setStatusText(i18n("Reading subtitles…"));
const auto [config, configError] = co_await loadConfig();
auto [config, configError] = co_await loadConfig();
if (job->cancelled) {
co_return;
}
@@ -235,13 +290,25 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
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);
// 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);
hash.addData(CacheVersion);
hash.addData(config.model.toUtf8());
hash.addData(config.targetLanguage.toUtf8());
hash.addData(config.translationPreset.toUtf8());
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)) {
m_job.reset();
@@ -251,6 +318,55 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
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.
QStringList uniqueLines;
QHash<QString, qsizetype> uniqueIndex;
@@ -264,6 +380,12 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
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);
auto batches = std::make_shared<Batches>();
batches->lines = uniqueLines;
+13
View File
@@ -2,12 +2,14 @@
#include "llmclient.h"
#include "subtitleextractor.h"
#include "glossarystore.h"
#include <QCoroTask>
#include <QObject>
#include <QStringList>
#include <QUrl>
#include <QVariantList>
#include <QVariantMap>
#include <QtQml/qqmlregistration.h>
@@ -28,7 +30,10 @@ class TranslationService : public QObject
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(QVariantList translationPresets READ translationPresets CONSTANT)
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:
explicit TranslationService(QObject *parent = nullptr);
@@ -39,7 +44,10 @@ public:
QString statusText() const;
QString errorText() const;
QStringList availableModels() const;
QVariantList translationPresets() const;
bool isFetchingModels() const;
QVariantList glossaryEntries() const;
QString glossaryDirectory() const;
// \a track is an entry of MpvItem::subtitleTracks.
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.
Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError();
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
Q_INVOKABLE void saveGlossary(const QVariantList &entries);
Q_INVOKABLE void clearGlossary();
Q_SIGNALS:
void busyChanged();
@@ -55,6 +66,7 @@ Q_SIGNALS:
void errorTextChanged();
void availableModelsChanged();
void fetchingModelsChanged();
void glossaryChanged();
void translated(const QUrl &subtitleUrl, bool fromCache);
private:
@@ -88,4 +100,5 @@ private:
QString m_errorText;
QStringList m_availableModels;
bool m_fetchingModels = false;
GlossaryStore::Glossary m_glossary;
};