Add subtitle translation presets, custom instructions, and per-series glossary #2
No files matched your search
@@ -38,6 +38,8 @@ add_executable(qtpotato
|
|||||||
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
|
||||||
|
|||||||
@@ -145,6 +145,51 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -122,6 +122,14 @@
|
|||||||
<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="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>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "llmclient.h"
|
#include "llmclient.h"
|
||||||
|
|
||||||
|
#include "translationpresets.h"
|
||||||
|
|
||||||
#include <KLocalizedString>
|
#include <KLocalizedString>
|
||||||
|
|
||||||
#include <QCoroNetworkReply>
|
#include <QCoroNetworkReply>
|
||||||
@@ -28,19 +30,29 @@ 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 &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 (!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 +149,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.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))}},
|
||||||
}},
|
}},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public:
|
|||||||
QString apiKey;
|
QString apiKey;
|
||||||
QString model;
|
QString model;
|
||||||
QString targetLanguage;
|
QString targetLanguage;
|
||||||
|
QString translationPreset;
|
||||||
|
QString customInstructions;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct BatchResult {
|
struct BatchResult {
|
||||||
|
|||||||
@@ -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 "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,6 +113,15 @@ 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;
|
||||||
@@ -160,6 +170,8 @@ 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()) {
|
||||||
@@ -235,11 +247,13 @@ 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.
|
// Same subtitles, model, language, and prompt settings give the same file, so it is translated once.
|
||||||
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(config.translationPreset.toUtf8());
|
||||||
|
hash.addData(config.customInstructions.toUtf8());
|
||||||
hash.addData(Subtitles::toSrt(cues).toUtf8());
|
hash.addData(Subtitles::toSrt(cues).toUtf8());
|
||||||
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#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,6 +29,7 @@ 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)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -39,6 +41,7 @@ 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;
|
||||||
|
|
||||||
// \a track is an entry of MpvItem::subtitleTracks.
|
// \a track is an entry of MpvItem::subtitleTracks.
|
||||||
|
|||||||
Reference in new issue
Block a user