Введение
Видео с субтитрами — частая потребность, особенно для образовательного и развлекательного контента. Qt Multimedia не предоставляет встроенного парсера форматов субтитров (.srt, .vtt) — эта задача решается на уровне приложения: разбор файла субтитров, и синхронизация показа конкретной реплики с текущей позицией воспроизведения видео. В статье разберём базовую реализацию такой синхронизации.
Концепция
Файл субтитров в формате SRT представляет собой последовательность блоков, каждый с номером, временным диапазоном (начало-конец показа реплики в формате часы:минуты:секунды,миллисекунды) и самим текстом. Синхронизация реализуется через периодическую проверку текущей позиции QMediaPlayer (через сигнал positionChanged) и поиск реплики субтитров, временной диапазон которой содержит текущую позицию, с последующим отображением найденного текста в соответствующем QML/Widget-элементе.
Пример кода: Синхронизация медиа с субтитрами и текстом
// subtitleparser.h — простой парсер формата SRT
#pragma once
#include <QString>
#include <QVector>
#include <QFile>
#include <QTextStream>
#include <QRegularExpression>
struct SubtitleEntry
{
qint64 startMs;
qint64 endMs;
QString text;
};
class SubtitleParser
{
public:
static QVector<SubtitleEntry> parseSrtFile(const QString &filePath)
{
QVector<SubtitleEntry> entries;
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return entries;
}
QTextStream stream(&file);
const QString content = stream.readAll();
// Блоки субтитров SRT разделены пустой строкой
const QStringList blocks = content.split("\n\n", Qt::SkipEmptyParts);
static const QRegularExpression timeRangePattern(
R"((\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3}))");
for (const QString &block : blocks) {
const QStringList lines = block.split('\n');
if (lines.size() < 3) continue;
const auto match = timeRangePattern.match(lines[1]);
if (!match.hasMatch()) continue;
SubtitleEntry entry;
entry.startMs = timeToMs(match, 1);
entry.endMs = timeToMs(match, 5);
entry.text = lines.mid(2).join('\n');
entries.append(entry);
}
return entries;
}
private:
static qint64 timeToMs(const QRegularExpressionMatch &match, int groupOffset)
{
const int hours = match.captured(groupOffset).toInt();
const int minutes = match.captured(groupOffset + 1).toInt();
const int seconds = match.captured(groupOffset + 2).toInt();
const int milliseconds = match.captured(groupOffset + 3).toInt();
return ((hours * 3600 + minutes * 60 + seconds) * 1000) + milliseconds;
}
};
// subtitledisplaycontroller.h — синхронизация показа субтитров с позицией плеера
#pragma once
#include <QMediaPlayer>
#include "subtitleparser.h"
class SubtitleDisplayController : public QObject
{
Q_OBJECT
public:
SubtitleDisplayController(QMediaPlayer *player, QObject *parent = nullptr)
: QObject(parent), m_player(player)
{
connect(m_player, &QMediaPlayer::positionChanged, this, &SubtitleDisplayController::updateSubtitle);
}
void loadSubtitles(const QString &srtFilePath)
{
m_subtitles = SubtitleParser::parseSrtFile(srtFilePath);
m_lastFoundIndex = 0; // оптимизация поиска — начинаем со старого индекса при следующем вызове
}
private slots:
void updateSubtitle(qint64 position)
{
// Поиск начинается от последнего найденного индекса — субтитры идут
// в хронологическом порядке, и позиция обычно монотонно увеличивается
for (int i = m_lastFoundIndex; i < m_subtitles.size(); ++i) {
const SubtitleEntry &entry = m_subtitles[i];
if (position >= entry.startMs && position <= entry.endMs) {
emit currentSubtitleChanged(entry.text);
m_lastFoundIndex = i;
return;
}
if (position < entry.startMs) {
break; // ещё не дошли до этой реплики — дальше искать не нужно
}
}
emit currentSubtitleChanged(QString()); // между репликами — субтитры не показываются
}
signals:
void currentSubtitleChanged(const QString &text);
private:
QMediaPlayer *m_player;
QVector<SubtitleEntry> m_subtitles;
int m_lastFoundIndex = 0;
};
Пояснения к коду
SubtitleParser::parseSrtFile() разбирает типичную структуру SRT-файла — блоки, разделённые пустой строкой, где вторая строка каждого блока содержит временной диапазон в распознаваемом регулярным выражением формате чч:мм:сс,ммм --> чч:мм:сс,ммм, а оставшиеся строки — собственно текст реплики (которая может занимать несколько строк, что обрабатывается через lines.mid(2).join('\n')).
SubtitleDisplayController::updateSubtitle() вызывается при каждом изменении позиции воспроизведения и ищет реплику, чей временной диапазон включает текущую позицию — оптимизация через m_lastFoundIndex избегает повторного поиска с начала списка при каждом вызове, что важно, поскольку positionChanged срабатывает часто (несколько раз в секунду) во время обычного воспроизведения, и линейный поиск с начала списка для длинного фильма с тысячами реплик субтитров создавал бы заметную, неоправданную нагрузку.
Подводные камни
- Оптимизация через
m_lastFoundIndexпредполагает монотонно возрастающую позицию воспроизведения, что не выполняется при перемотке назад — после перемотки к более раннему моменту видео индекс поиска должен быть сброшен (или поиск должен переключиться на полный линейный/бинарный поиск), иначе субтитры для более раннего момента видео не будут найдены, поскольку поиск продолжит начинаться с индекса, соответствующего уже пройденному, более позднему моменту. - Парсер SRT в примере не обрабатывает форматирование внутри текста субтитров (теги вроде
<i>курсив</i>, часто встречающиеся в реальных файлах субтитров) — для полной поддержки таких файлов нужна дополнительная обработка простого HTML-подобного форматирования внутри текста реплики, не реализованная в упрощённом примере. - Отсутствие обработки кодировки файла субтитров, который может быть сохранён не в UTF-8 (особенно старые файлы субтитров на русском языке часто встречаются в кодировке Windows-1251) — без явного определения и обработки кодировки текст субтитров может отображаться с искажёнными символами (так называемыми «кракозябрами»), и для надёжной поддержки разнородных файлов субтитров стоит реализовать автоматическое определение кодировки или явный выбор кодировки пользователем.
- Линейный поиск, даже оптимизированный через сохранение последнего индекса, всё равно выполняется в основном потоке событий Qt при каждом срабатывании
positionChanged— для файлов субтитров с очень большим количеством реплик (что нетипично, но теоретически возможно) это может создавать минимальную, но измеримую нагрузку; для большинства практических случаев (фильмы с несколькими сотнями реплик) эта нагрузка незначительна, но для нестандартных, очень длинных файлов стоит рассмотреть более эффективную структуру данных (бинарный поиск по отсортированному списку) вместо линейного перебора.