Add subtitle translation presets, custom instructions, and per-series glossary #2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
// 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(Subtitles::toSrt(cues).toUtf8());
|
||||
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user