Мультимедиа

Синхронизация медиа с субтитрами и текстом

Введение

Видео с субтитрами — частая потребность, особенно для образовательного и развлекательного контента. 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 — для файлов субтитров с очень большим количеством реплик (что нетипично, но теоретически возможно) это может создавать минимальную, но измеримую нагрузку; для большинства практических случаев (фильмы с несколькими сотнями реплик) эта нагрузка незначительна, но для нестандартных, очень длинных файлов стоит рассмотреть более эффективную структуру данных (бинарный поиск по отсортированному списку) вместо линейного перебора.