]> git.sesse.net Git - pkanalytics/commitdiff
Drop QVideoWidget.
authorSteinar H. Gunderson <sgunderson@bigfoot.com>
Thu, 13 Jul 2023 09:12:04 +0000 (11:12 +0200)
committerSteinar H. Gunderson <sgunderson@bigfoot.com>
Thu, 13 Jul 2023 13:18:05 +0000 (15:18 +0200)
QVideoWidget is based on GStreamer and horribly buggy, so replace it with
our own homegrown code. This reuses and adapts a lot of code from Nageru
and Movit, but especially the seek code is pretty much written from scratch.

The biggest advantage is that we no longer get random hangs all the time
(which the F5 key would attempt to work around, by restarting the player).
The disadvantages are that we take on a bunch of new code, a direct dependency
on FFmpeg and OpenGL (4.6 for the time being, but can probably be reduced
to much lower if we want to target macOS), and lose sound support.
We get VA-API/VDPAU acceleration, although I'm not sure if the old code
did that for us or not.

The other motivating change is that this will allow us to implement zoom
more easily down the road.

The frame-based seeking is now actually frame-based, instead of trying to
emulate it by going +/- 20 ms.

ffmpeg_raii.cpp [new file with mode: 0644]
ffmpeg_raii.h [new file with mode: 0644]
main.cpp
mainwindow.h
mainwindow.ui
meson.build
quittable_sleeper.h [new file with mode: 0644]
video_widget.cpp [new file with mode: 0644]
video_widget.h [new file with mode: 0644]

diff --git a/ffmpeg_raii.cpp b/ffmpeg_raii.cpp
new file mode 100644 (file)
index 0000000..0e087c2
--- /dev/null
@@ -0,0 +1,102 @@
+#include "ffmpeg_raii.h"
+
+extern "C" {
+#include <libavcodec/avcodec.h>
+#include <libavformat/avformat.h>
+#include <libavutil/dict.h>
+#include <libavutil/frame.h>
+#include <libswscale/swscale.h>
+}
+
+using namespace std;
+
+// AVFormatContext
+
+void avformat_close_input_unique::operator() (AVFormatContext *format_ctx) const
+{
+       avformat_close_input(&format_ctx);
+}
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       const char *pathname, const AVInputFormat *fmt,
+       AVDictionary **options)
+{
+       return avformat_open_input_unique(pathname, fmt, options, AVIOInterruptCB{ nullptr, nullptr });
+}
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       const char *pathname, const AVInputFormat *fmt,
+       AVDictionary **options,
+       const AVIOInterruptCB &interrupt_cb)
+{
+       AVFormatContext *format_ctx = avformat_alloc_context();
+       format_ctx->interrupt_callback = interrupt_cb;
+#ifdef ff_const59
+       if (avformat_open_input(&format_ctx, pathname, const_cast<ff_const59 AVInputFormat *>(fmt), options) != 0) {
+#else
+       if (avformat_open_input(&format_ctx, pathname, fmt, options) != 0) {
+#endif
+               format_ctx = nullptr;
+       }
+       return AVFormatContextWithCloser(format_ctx);
+}
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       int (*read_packet)(void *opaque, uint8_t *buf, int buf_size),
+       void *opaque, const AVInputFormat *fmt, AVDictionary **options,
+       const AVIOInterruptCB &interrupt_cb)
+{
+       AVFormatContext *format_ctx = avformat_alloc_context();
+       format_ctx->interrupt_callback = interrupt_cb;
+       constexpr size_t buf_size = 4096;
+       unsigned char *buf = (unsigned char *)av_malloc(buf_size);
+       format_ctx->pb = avio_alloc_context(buf, buf_size, /*write_flag=*/false, opaque,
+               read_packet, /*write_packet=*/nullptr, /*seek=*/nullptr);
+#ifdef ff_const59
+       if (avformat_open_input(&format_ctx, "", const_cast<ff_const59 AVInputFormat *>(fmt), options) != 0) {
+#else
+       if (avformat_open_input(&format_ctx, "", fmt, options) != 0) {
+#endif
+               format_ctx = nullptr;
+       }
+       return AVFormatContextWithCloser(format_ctx);
+}
+
+// AVCodecContext
+
+void avcodec_free_context_unique::operator() (AVCodecContext *codec_ctx) const
+{
+       avcodec_free_context(&codec_ctx);
+}
+
+AVCodecContextWithDeleter avcodec_alloc_context3_unique(const AVCodec *codec)
+{
+       return AVCodecContextWithDeleter(avcodec_alloc_context3(codec));
+}
+
+
+// AVCodecParameters
+
+void avcodec_parameters_free_unique::operator() (AVCodecParameters *codec_par) const
+{
+       avcodec_parameters_free(&codec_par);
+}
+
+// AVFrame
+
+void av_frame_free_unique::operator() (AVFrame *frame) const
+{
+       av_frame_free(&frame);
+}
+
+AVFrameWithDeleter av_frame_alloc_unique()
+{
+       return AVFrameWithDeleter(av_frame_alloc());
+}
+
+// SwsContext
+
+void sws_free_context_unique::operator() (SwsContext *context) const
+{
+       sws_freeContext(context);
+}
diff --git a/ffmpeg_raii.h b/ffmpeg_raii.h
new file mode 100644 (file)
index 0000000..00f7fc1
--- /dev/null
@@ -0,0 +1,85 @@
+#ifndef _FFMPEG_RAII_H
+#define _FFMPEG_RAII_H 1
+
+// Some helpers to make RAII versions of FFmpeg objects.
+// The cleanup functions don't interact all that well with unique_ptr,
+// so things get a bit messy and verbose, but overall it's worth it to ensure
+// we never leak things by accident in error paths.
+//
+// This does not cover any of the types that can actually be declared as
+// a unique_ptr with no helper functions for deleter.
+
+#include <memory>
+
+struct AVCodec;
+struct AVCodecContext;
+struct AVCodecParameters;
+struct AVDictionary;
+struct AVFormatContext;
+struct AVFrame;
+struct AVInputFormat;
+struct SwsContext;
+typedef struct AVIOInterruptCB AVIOInterruptCB;
+
+// AVFormatContext
+struct avformat_close_input_unique {
+       void operator() (AVFormatContext *format_ctx) const;
+};
+
+typedef std::unique_ptr<AVFormatContext, avformat_close_input_unique>
+       AVFormatContextWithCloser;
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       const char *pathname, const AVInputFormat *fmt,
+       AVDictionary **options);
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       const char *pathname, const AVInputFormat *fmt,
+       AVDictionary **options,
+       const AVIOInterruptCB &interrupt_cb);
+
+AVFormatContextWithCloser avformat_open_input_unique(
+       int (*read_packet)(void *opaque, uint8_t *buf, int buf_size),
+       void *opaque, const AVInputFormat *fmt, AVDictionary **options,
+       const AVIOInterruptCB &interrupt_cb);
+
+
+// AVCodecContext
+struct avcodec_free_context_unique {
+       void operator() (AVCodecContext *ctx) const;
+};
+
+typedef std::unique_ptr<AVCodecContext, avcodec_free_context_unique>
+       AVCodecContextWithDeleter;
+
+AVCodecContextWithDeleter avcodec_alloc_context3_unique(const AVCodec *codec);
+
+
+// AVCodecParameters
+struct avcodec_parameters_free_unique {
+       void operator() (AVCodecParameters *codec_par) const;
+};
+
+typedef std::unique_ptr<AVCodecParameters, avcodec_parameters_free_unique>
+       AVCodecParametersWithDeleter;
+
+
+// AVFrame
+struct av_frame_free_unique {
+       void operator() (AVFrame *frame) const;
+};
+
+typedef std::unique_ptr<AVFrame, av_frame_free_unique>
+       AVFrameWithDeleter;
+
+AVFrameWithDeleter av_frame_alloc_unique();
+
+// SwsContext
+struct sws_free_context_unique {
+       void operator() (SwsContext *context) const;
+};
+
+typedef std::unique_ptr<SwsContext, sws_free_context_unique>
+       SwsContextWithDeleter;
+
+#endif  // !defined(_FFMPEG_RAII_H)
index 1c0f2e3017075034b570b28ee041a4e9c76e6231..560a9a658676b09723edab126b6230a5ac8c3190 100644 (file)
--- a/main.cpp
+++ b/main.cpp
@@ -2,7 +2,6 @@
 #include <QMainWindow>
 #include <QApplication>
 #include <QGridLayout>
-#include <QVideoWidget>
 #include <QShortcut>
 #include <QInputDialog>
 #include <QTimer>
@@ -18,6 +17,7 @@
 #include "players.h"
 #include "formations.h"
 #include "json.h"
+#include "video_widget.h"
 
 using namespace std;
 
@@ -39,22 +39,20 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                        FormationsModel *offensive_formations, FormationsModel *defensive_formations)
        : events(events), players(players), offensive_formations(offensive_formations), defensive_formations(defensive_formations)
 {
-       video = new QMediaPlayer;
-       //video->setSource(QUrl::fromLocalFile("/home/sesse/dev/stats/ultimate.mkv"));
-       video->setSource(QUrl::fromLocalFile("/home/sesse/dev/stats/ultimate-prores.mkv"));
-       video->play();
-
        ui = new Ui::MainWindow;
        ui->setupUi(this);
 
+       ui->video->open("/home/sesse/dev/stats/ultimate-prores.mkv");
+       ui->video->play();
+
        ui->event_view->setModel(events);
        ui->event_view->setColumnWidth(1, 150);
        ui->event_view->setColumnWidth(2, 150);
        connect(ui->event_view->selectionModel(), &QItemSelectionModel::currentRowChanged,
                [this, events](const QModelIndex &current, const QModelIndex &previous) {
-                       int64_t t = events->get_time(current.row());
-                       if (t != video->position()) {
-                               video->setPosition(events->get_time(current.row()));
+                       uint64_t t = events->get_time(current.row());
+                       if (t != ui->video->get_position()) {
+                               ui->video->seek_absolute(events->get_time(current.row()));
                        } else {
                                // Selection could have changed, so we still need to update.
                                // (Just calling setPosition() would not give us the signal
@@ -70,7 +68,7 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
 
        auto formation_changed = [this](const QModelIndex &current, const QModelIndex &previous) {
                QTimer::singleShot(1, [=]{  // The selection is wrong until the callback actually returns.
-                       update_action_buttons(video->position());
+                       update_action_buttons(ui->video->get_position());
                });
        };
        ui->offensive_formation_view->setModel(offensive_formations);
@@ -84,47 +82,33 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                formation_double_clicked(false, index.row());
        });
 
-       connect(video, &QMediaPlayer::positionChanged, [this](uint64_t pos) {
+       connect(ui->video, &VideoWidget::position_changed, [this](uint64_t pos) {
                position_changed(pos);
        });
 
-       video->setVideoOutput(ui->video);
 
        // It's not really clear whether PgUp should be forwards or backwards,
        // but mpv does at least up = forwards, so that's probably standard.
        QShortcut *pgdown = new QShortcut(QKeySequence(Qt::Key_PageDown), this);
-       connect(pgdown, &QShortcut::activated, [this]() { seek(-120000); });
+       connect(pgdown, &QShortcut::activated, [this]() { ui->video->seek(-120000); });
        QShortcut *pgup = new QShortcut(QKeySequence(Qt::Key_PageUp), this);
-       connect(pgup, &QShortcut::activated, [this]() { seek(120000); });
-
-       // Ugh. Used when Qt messes up and hangs the video.
-       QShortcut *f5 = new QShortcut(QKeySequence(Qt::Key_F5), this);
-       connect(f5, &QShortcut::activated, [this]() {
-               QVideoWidget *nvw = new QVideoWidget(ui->video->parentWidget());
-               nvw->setObjectName("video");
-               nvw->setMinimumSize(QSize(320, 240));
-               video->setVideoOutput(nvw);
-               ui->main_grid->replaceWidget(ui->video, nvw);
-               ui->video = nvw;
-       });
+       connect(pgup, &QShortcut::activated, [this]() { ui->video->seek(120000); });
 
-       connect(ui->minus10s, &QPushButton::clicked, [this]() { seek(-10000); });
-       connect(ui->plus10s, &QPushButton::clicked, [this]() { seek(10000); });
+       connect(ui->minus10s, &QPushButton::clicked, [this]() { ui->video->seek(-10000); });
+       connect(ui->plus10s, &QPushButton::clicked, [this]() { ui->video->seek(10000); });
 
-       connect(ui->minus2s, &QPushButton::clicked, [this]() { seek(-2000); });
-       connect(ui->plus2s, &QPushButton::clicked, [this]() { seek(2000); });
+       connect(ui->minus2s, &QPushButton::clicked, [this]() { ui->video->seek(-2000); });
+       connect(ui->plus2s, &QPushButton::clicked, [this]() { ui->video->seek(2000); });
 
-       // TODO: Would be nice to actually have a frame...
-       connect(ui->minus1f, &QPushButton::clicked, [this]() { seek(-20); });
-       connect(ui->plus1f, &QPushButton::clicked, [this]() { seek(20); });
+       connect(ui->minus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(-1); });
+       connect(ui->plus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(1); });
 
        connect(ui->play_pause, &QPushButton::clicked, [this]() {
                if (playing) {
-                       video->pause();
+                       ui->video->pause();
                        ui->play_pause->setText("Play (space)");
                } else {
-                       video->setPlaybackRate(1.0);
-                       video->play();
+                       ui->video->play();
                        ui->play_pause->setText("Pause (space)");
                }
                playing = !playing;
@@ -145,7 +129,7 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
        connect(ui->offense_label, &ClickableLabel::clicked, [this]() { insert_noplayer_event("set_offense"); });
        connect(ui->catch_, &QPushButton::clicked, [this]() { set_current_event_type("catch"); });
        connect(ui->throwaway, &QPushButton::clicked, [this, events]() {
-               EventsModel::Status s = events->get_status_at(video->position());
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.attack_state == EventsModel::Status::DEFENSE && s.pull_state == EventsModel::Status::PULL_IN_AIR) {
                        insert_noplayer_event("pull_oob");
                } else {
@@ -157,7 +141,7 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
        connect(ui->offensive_soft_plus, &QPushButton::clicked, [this]() { set_current_event_type("offensive_soft_plus"); });
        connect(ui->offensive_soft_minus, &QPushButton::clicked, [this]() { set_current_event_type("offensive_soft_minus"); });
        connect(ui->pull, &QPushButton::clicked, [this, events]() {
-               EventsModel::Status s = events->get_status_at(video->position());
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
                        set_current_event_type("pull");
                } else if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
@@ -171,7 +155,7 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
        connect(ui->their_throwaway, &QPushButton::clicked, [this]() { insert_noplayer_event("their_throwaway"); });
        connect(ui->their_goal, &QPushButton::clicked, [this]() { insert_noplayer_event("their_goal"); });
        connect(ui->their_pull, &QPushButton::clicked, [this, events]() {
-               EventsModel::Status s = events->get_status_at(video->position());
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
                        insert_noplayer_event("their_pull");
                }
@@ -186,7 +170,7 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
        // Misc. events
        connect(ui->substitution, &QPushButton::clicked, [this]() { make_substitution(); });
        connect(ui->stoppage, &QPushButton::clicked, [this, events]() {
-               EventsModel::Status s = events->get_status_at(video->position());
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.stoppage) {
                        insert_noplayer_event("restart");
                } else {
@@ -203,34 +187,19 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
 void MainWindow::position_changed(uint64_t pos)
 {
        ui->timestamp->setText(QString::fromUtf8(format_timestamp(pos)));
-       if (buffered_seek) {
-               video->setPosition(*buffered_seek);
-               buffered_seek.reset();
-       }
        if (!playing) {
-               video->pause();  // We only played to get a picture.
+               ui->video->pause();  // We only played to get a picture.
        }
        if (playing) {
-               QModelIndex row = events->get_last_event_qt(video->position());
+               QModelIndex row = events->get_last_event_qt(ui->video->get_position());
                ui->event_view->scrollTo(row, QAbstractItemView::PositionAtCenter);
        }
        update_ui_from_time(pos);
 }
 
-void MainWindow::seek(int64_t delta_ms)
-{
-       int64_t current_pos = buffered_seek ? *buffered_seek : video->position();
-       uint64_t pos = max<int64_t>(current_pos + delta_ms, 0);
-       buffered_seek = pos;
-       if (!playing) {
-               video->setPlaybackRate(0.01);
-               video->play();  // Or Qt won't show the seek.
-       }
-}
-
 void MainWindow::insert_player_event(int button_id)
 {
-       uint64_t t = video->position();
+       uint64_t t = ui->video->get_position();
        vector<int> team = events->sort_team(events->get_team_at(t));
        if (unsigned(button_id) >= team.size()) {
                return;
@@ -253,7 +222,7 @@ void MainWindow::insert_player_event(int button_id)
 
 void MainWindow::insert_noplayer_event(const string &type)
 {
-       uint64_t t = video->position();
+       uint64_t t = ui->video->get_position();
 
        ui->event_view->selectionModel()->blockSignals(true);
        ui->event_view->selectRow(events->insert_event(t, nullopt, nullopt, type));
@@ -270,7 +239,7 @@ void MainWindow::set_current_event_type(const string &type)
        }
        int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
        events->set_event_type(row, type);
-       update_ui_from_time(video->position());
+       update_ui_from_time(ui->video->get_position());
 }
 
 // Formation buttons either modify the existing formation (if we've selected
@@ -296,14 +265,14 @@ void MainWindow::insert_or_change_formation(bool offense)
                string expected_type = offense ? "formation_offense" : "formation_defense";
                if (events->get_event_type(row) == expected_type) {
                        events->set_event_formation(row, formation_id);
-                       update_ui_from_time(video->position());
+                       update_ui_from_time(ui->video->get_position());
                        return;
                }
        }
 
        // Insert a new formation event instead (same as double-click on the selected one).
-       events->set_formation_at(video->position(), offense, formation_id);
-       update_ui_from_time(video->position());
+       events->set_formation_at(ui->video->get_position(), offense, formation_id);
+       update_ui_from_time(ui->video->get_position());
 }
 
 void MainWindow::delete_current_event()
@@ -316,7 +285,7 @@ void MainWindow::delete_current_event()
        ui->event_view->selectionModel()->blockSignals(true);
        events->delete_event(row);
        ui->event_view->selectionModel()->blockSignals(false);
-       update_ui_from_time(video->position());
+       update_ui_from_time(ui->video->get_position());
 }
 
 void MainWindow::make_substitution()
@@ -326,7 +295,7 @@ void MainWindow::make_substitution()
        for (QModelIndex row : select->selectedRows()) {
                new_team.insert(players->get_player_id(row.row()));
        }
-       events->set_team_at(video->position(), new_team);
+       events->set_team_at(ui->video->get_position(), new_team);
 }
 
 void MainWindow::update_ui_from_time(uint64_t t)
@@ -543,9 +512,9 @@ void MainWindow::formation_double_clicked(bool offense, unsigned row)
                view->selectionModel()->select(formations->index(formations->get_row_from_id(id), 0), QItemSelectionModel::ClearAndSelect);
                events->inserted_new_formation(id, new_formation_str.toStdString());
        } else {
-               events->set_formation_at(video->position(), offense, id);
+               events->set_formation_at(ui->video->get_position(), offense, id);
        }
-       update_ui_from_time(video->position());
+       update_ui_from_time(ui->video->get_position());
 }
 
 sqlite3 *open_db(const char *filename)
index 9408436ae6e1e1e8a62f7f98fd62144a715d1202..f7c8830b8125ba6023cbe0f6a9f7eeb01f184ac2 100644 (file)
@@ -16,6 +16,11 @@ class MainWindow : public QMainWindow
 public:
        MainWindow(EventsModel *events, PlayersModel *players,
                   FormationsModel *offensive_formations, FormationsModel *defensive_formations);
+       ~MainWindow() {
+               if (ui && ui->video) {
+                       ui->video->stop();
+               }
+       }
 
 private:
        void position_changed(uint64_t pos);
@@ -41,5 +46,4 @@ private:
        bool seeking = false;
        bool playing = true;
        std::optional<uint64_t> buffered_seek;
-       QMediaPlayer *video;
 };
index 771fe116778d54f6e1f5219e9e6e2609ad054017..20bf733149c063ad0a29d9ac6bd248d20fb2a4cf 100644 (file)
        </layout>
       </item>
       <item row="0" column="0">
-       <widget class="QVideoWidget" name="video" native="true">
+       <widget class="VideoWidget" name="video" native="true">
         <property name="minimumSize">
          <size>
           <width>320</width>
  </widget>
  <customwidgets>
   <customwidget>
-   <class>QVideoWidget</class>
-   <extends>QWidget</extends>
-   <header location="global">QVideoWidget</header>
+   <class>VideoWidget</class>
+   <extends>QOpenGLWidget</extends>
+   <header>video_widget.h</header>
    <container>1</container>
   </customwidget>
   <customwidget>
index 02cb58639541407cb232888ceab96c59a7a7793f..d38e79761026877ad7d9276e252082a8edb6063b 100644 (file)
@@ -1,12 +1,20 @@
 project('stats', 'cpp', default_options: ['buildtype=debugoptimized'], version: '0.0.1')
 
 qt6 = import('qt6')
-qt6deps = dependency('qt6', modules: ['Core', 'Gui', 'Widgets', 'Multimedia', 'MultimediaWidgets'])
+qt6deps = dependency('qt6', modules: ['Core', 'Gui', 'Widgets', 'Multimedia', 'OpenGLWidgets', 'OpenGL'])
 sqlite3dep = dependency('sqlite3')
+libavcodecdep = dependency('libavcodec')
+libavformatdep = dependency('libavformat')
+libavutildep = dependency('libavutil')
+libswscaledep = dependency('libswscale')
+gldep = dependency('gl')
 
 qt_files = qt6.preprocess(
-        moc_headers: ['mainwindow.h', 'clickable_label.h'],
+        moc_headers: ['mainwindow.h', 'clickable_label.h', 'video_widget.h'],
        ui_files: ['mainwindow.ui'],
         dependencies: qt6deps)
 
-executable('stats', ['main.cpp', 'events.cpp', 'players.cpp', 'formations.cpp', 'json.cpp'], qt_files, dependencies: [qt6deps, sqlite3dep])
+executable('stats',
+           ['main.cpp', 'events.cpp', 'players.cpp', 'formations.cpp', 'json.cpp', 'video_widget.cpp', 'ffmpeg_raii.cpp'],
+           qt_files,
+           dependencies: [qt6deps, sqlite3dep, libavcodecdep, libavformatdep, libavutildep, libswscaledep, gldep])
diff --git a/quittable_sleeper.h b/quittable_sleeper.h
new file mode 100644 (file)
index 0000000..6c449a7
--- /dev/null
@@ -0,0 +1,74 @@
+#ifndef _QUITTABLE_SLEEPER
+#define _QUITTABLE_SLEEPER 1
+
+// A class that assists with fast shutdown of threads. You can set
+// a flag that says the thread should quit, which it can then check
+// in a loop -- and if the thread sleeps (using the sleep_* functions
+// on the class), that sleep will immediately be aborted.
+//
+// All member functions on this class are thread-safe.
+
+#include <chrono>
+#include <condition_variable>
+#include <mutex>
+
+class QuittableSleeper {
+public:
+       void quit()
+       {
+               std::lock_guard<std::mutex> l(mu);
+               should_quit_var = true;
+               quit_cond.notify_all();
+       }
+
+       void unquit()
+       {
+               std::lock_guard<std::mutex> l(mu);
+               should_quit_var = false;
+       }
+
+       void wakeup()
+       {
+               std::lock_guard<std::mutex> l(mu);
+               should_wakeup_var = true;
+               quit_cond.notify_all();
+       }
+
+       bool should_quit() const
+       {
+               std::lock_guard<std::mutex> l(mu);
+               return should_quit_var;
+       }
+
+       // Returns false if woken up early.
+       template<class Rep, class Period>
+       bool sleep_for(const std::chrono::duration<Rep, Period> &duration)
+       {
+               std::chrono::steady_clock::time_point t =
+                       std::chrono::steady_clock::now() +
+                       std::chrono::duration_cast<std::chrono::steady_clock::duration>(duration);
+               return sleep_until(t);
+       }
+
+       // Returns false if woken up early.
+       template<class Clock, class Duration>
+       bool sleep_until(const std::chrono::time_point<Clock, Duration> &t)
+       {
+               std::unique_lock<std::mutex> lock(mu);
+               quit_cond.wait_until(lock, t, [this]{
+                       return should_quit_var || should_wakeup_var;
+               });
+               if (should_wakeup_var) {
+                       should_wakeup_var = false;
+                       return false;
+               }
+               return !should_quit_var;
+       }
+
+private:
+       mutable std::mutex mu;
+       bool should_quit_var = false, should_wakeup_var = false;
+       std::condition_variable quit_cond;
+};
+
+#endif  // !defined(_QUITTABLE_SLEEPER) 
diff --git a/video_widget.cpp b/video_widget.cpp
new file mode 100644 (file)
index 0000000..ba8cf51
--- /dev/null
@@ -0,0 +1,893 @@
+#define GL_GLEXT_PROTOTYPES
+
+#include "video_widget.h"
+
+#include <assert.h>
+#include <pthread.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+extern "C" {
+#include <libavcodec/avcodec.h>
+#include <libavformat/avformat.h>
+#include <libavutil/avutil.h>
+#include <libavutil/error.h>
+#include <libavutil/frame.h>
+#include <libavutil/imgutils.h>
+#include <libavutil/mem.h>
+#include <libavutil/pixfmt.h>
+#include <libavutil/opt.h>
+#include <libswscale/swscale.h>
+}
+
+#include <chrono>
+#include <cstdint>
+#include <utility>
+#include <vector>
+#include <unordered_set>
+
+#include <QOpenGLFunctions>
+
+using namespace std;
+using namespace std::chrono;
+
+namespace {
+
+bool is_full_range(const AVPixFmtDescriptor *desc)
+{
+       // This is horrible, but there's no better way that I know of.
+       return (strchr(desc->name, 'j') != nullptr);
+}
+
+AVPixelFormat decide_dst_format(AVPixelFormat src_format)
+{
+       // If this is a non-Y'CbCr format, just convert to 4:4:4 Y'CbCr
+       // and be done with it. It's too strange to spend a lot of time on.
+       // (Let's hope there's no alpha.)
+       const AVPixFmtDescriptor *src_desc = av_pix_fmt_desc_get(src_format);
+       if (src_desc == nullptr ||
+           src_desc->nb_components != 3 ||
+           (src_desc->flags & AV_PIX_FMT_FLAG_RGB)) {
+               return AV_PIX_FMT_YUV444P;
+       }
+
+       // The best for us would be Cb and Cr together if possible,
+       // but FFmpeg doesn't support that except in the special case of
+       // NV12, so we need to go to planar even for the case of NV12.
+       // Thus, look for the closest (but no worse) 8-bit planar Y'CbCr format
+       // that matches in color range. (This will also include the case of
+       // the source format already being acceptable.)
+       bool src_full_range = is_full_range(src_desc);
+       const char *best_format = "yuv444p";
+       unsigned best_score = numeric_limits<unsigned>::max();
+       for (const AVPixFmtDescriptor *desc = av_pix_fmt_desc_next(nullptr);
+            desc;
+            desc = av_pix_fmt_desc_next(desc)) {
+               // Find planar Y'CbCr formats only.
+               if (desc->nb_components != 3) continue;
+               if (desc->flags & AV_PIX_FMT_FLAG_RGB) continue;
+               if (!(desc->flags & AV_PIX_FMT_FLAG_PLANAR)) continue;
+               if (desc->comp[0].plane != 0 ||
+                   desc->comp[1].plane != 1 ||
+                   desc->comp[2].plane != 2) continue;
+
+               // 8-bit formats only.
+               if (desc->flags & AV_PIX_FMT_FLAG_BE) continue;
+               if (desc->comp[0].depth != 8) continue;
+
+               // Same or better chroma resolution only.
+               int chroma_w_diff = src_desc->log2_chroma_w - desc->log2_chroma_w;
+               int chroma_h_diff = src_desc->log2_chroma_h - desc->log2_chroma_h;
+               if (chroma_w_diff < 0 || chroma_h_diff < 0)
+                       continue;
+
+               // Matching full/limited range only.
+               if (is_full_range(desc) != src_full_range)
+                       continue;
+
+               // Pick something with as little excess chroma resolution as possible.
+               unsigned score = (1 << (chroma_w_diff)) << chroma_h_diff;
+               if (score < best_score) {
+                       best_score = score;
+                       best_format = desc->name;
+               }
+       }
+       return av_get_pix_fmt(best_format);
+}
+
+}  // namespace
+
+bool VideoWidget::process_queued_commands(AVFormatContext *format_ctx, AVCodecContext *video_codec_ctx, int video_stream_index, bool *seeked)
+{
+       // Process any queued commands from other threads.
+       vector<QueuedCommand> commands;
+       {
+               lock_guard<mutex> lock(queue_mu);
+               swap(commands, command_queue);
+       }
+
+       for (const QueuedCommand &cmd : commands) {
+               switch (cmd.command) {
+               case QueuedCommand::PAUSE:
+                       paused = true;
+                       break;
+               case QueuedCommand::RESUME:
+                       paused = false;
+                       pts_origin = last_pts;
+                       start = next_frame_start = steady_clock::now();
+                       break;
+               case QueuedCommand::SEEK:
+               case QueuedCommand::SEEK_ABSOLUTE:
+                       // Dealt with below.
+                       break;
+               }
+       }
+
+       // Combine all seeks into one big one. (There are edge cases where this is probably
+       // subtly wrong, but we'll live with it.)
+       int64_t base_pts = last_pts;
+       int64_t relative_seek_ms = 0;
+       int64_t relative_seek_frames = 0;
+       for (const QueuedCommand &cmd : commands) {
+               if (cmd.command == QueuedCommand::SEEK) {
+                       relative_seek_ms += cmd.relative_seek_ms;
+                       relative_seek_frames += cmd.relative_seek_frames;
+               } else if (cmd.command == QueuedCommand::SEEK_ABSOLUTE) {
+                       base_pts = cmd.seek_ms;
+                       relative_seek_ms = 0;
+                       relative_seek_frames = 0;
+               }
+       }
+       int64_t relative_seek_pts = av_rescale_q(relative_seek_ms, AVRational{ 1, 1000 }, video_timebase);
+       if (relative_seek_ms != 0 && relative_seek_pts == 0) {
+               // Just to be sure rounding errors don't move us into nothingness.
+               relative_seek_pts = (relative_seek_ms > 0) ? 1 : -1;
+       }
+       int64_t goal_pts = base_pts + relative_seek_pts;
+       if (goal_pts != last_pts || relative_seek_frames < 0) {
+               avcodec_flush_buffers(video_codec_ctx);
+               queued_frames.clear();
+
+               // Seek to the last keyframe before this point.
+               int64_t seek_pts = goal_pts;
+               if (relative_seek_frames < 0) {
+                       // If we're frame-skipping backwards, add 100 ms of slop for each frame
+                       // so we're fairly certain we are able to see the ones we want.
+                       seek_pts -= av_rescale_q(-relative_seek_frames, AVRational{ 1, 10 }, video_timebase);
+               }
+               av_seek_frame(format_ctx, video_stream_index, seek_pts, AVSEEK_FLAG_BACKWARD);
+
+               // Decode frames until EOF, or until we see something past our seek point.
+               std::deque<AVFrameWithDeleter> queue;
+               for ( ;; ) {
+                       bool error = false;
+                       AVFrameWithDeleter frame = decode_frame(format_ctx, video_codec_ctx,
+                               pathname, video_stream_index, &error);
+                       if (frame == nullptr || error) {
+                               break;
+                       }
+
+                       int64_t frame_pts = frame->pts;
+                       if (relative_seek_frames < 0) {
+                               // Buffer this frame; don't display it unless we know it's the Nth-latest.
+                               queue.push_back(std::move(frame));
+                               if (queue.size() > uint64_t(-relative_seek_frames) + 1) {
+                                       queue.pop_front();
+                               }
+                       }
+                       if (frame_pts >= goal_pts) {
+                               if (relative_seek_frames > 0) {
+                                       --relative_seek_frames;
+                               } else {
+                                       if (relative_seek_frames < 0) {
+                                               // Hope we have the right amount.
+                                               // The rest will remain in the queue for when we play forward again.
+                                               frame = std::move(queue.front());
+                                               queue.pop_front();
+                                               queued_frames = std::move(queue);
+                                       }
+                                       current_frame.reset(new Frame(make_video_frame(frame.get())));
+                                       update();
+                                       store_pts(frame->pts);
+                                       break;
+                               }
+                       }
+               }
+
+               // NOTE: We keep pause status as-is.
+
+               pts_origin = last_pts;
+               start = next_frame_start = last_frame = steady_clock::now();
+               if (seeked) {
+                       *seeked = true;
+               }
+       } else if (relative_seek_frames > 0) {
+               // The base PTS is fine, we only need to skip a few frames forwards.
+               while (relative_seek_frames > 1) {
+                       // Eat a frame (ignore errors).
+                       bool error;
+                       decode_frame(format_ctx, video_codec_ctx, pathname, video_stream_index, &error);
+                       --relative_seek_frames;
+               }
+
+               // Display the last one.
+               bool error;
+               AVFrameWithDeleter frame = decode_frame(format_ctx, video_codec_ctx,
+                       pathname, video_stream_index, &error);
+               if (frame == nullptr || error) {
+                       return true;
+               }
+               current_frame.reset(new Frame(make_video_frame(frame.get())));
+               update();
+               store_pts(frame->pts);
+       }
+       return false;
+}
+
+VideoWidget::VideoWidget(QWidget *parent)
+       : QOpenGLWidget(parent) {}
+
+GLuint compile_shader(const string &shader_src, GLenum type)
+{
+       GLuint obj = glCreateShader(type);
+       const GLchar* source[] = { shader_src.data() };
+       const GLint length[] = { (GLint)shader_src.size() };
+       glShaderSource(obj, 1, source, length);
+       glCompileShader(obj);
+
+       GLchar info_log[4096];
+       GLsizei log_length = sizeof(info_log) - 1;
+       glGetShaderInfoLog(obj, log_length, &log_length, info_log);
+       info_log[log_length] = 0;
+       if (strlen(info_log) > 0) {
+               fprintf(stderr, "Shader compile log: %s\n", info_log);
+       }
+
+       GLint status;
+       glGetShaderiv(obj, GL_COMPILE_STATUS, &status);
+       if (status == GL_FALSE) {
+               // Add some line numbers to easier identify compile errors.
+               string src_with_lines = "/*   1 */ ";
+               size_t lineno = 1;
+               for (char ch : shader_src) {
+                       src_with_lines.push_back(ch);
+                       if (ch == '\n') {
+                               char buf[32];
+                               snprintf(buf, sizeof(buf), "/* %3zu */ ", ++lineno);
+                               src_with_lines += buf;
+                       }
+               }
+
+               fprintf(stderr, "Failed to compile shader:\n%s\n", src_with_lines.c_str());
+               exit(1);
+       }
+
+       return obj;
+}
+
+void VideoWidget::initializeGL()
+{
+       glDisable(GL_BLEND);
+       glDisable(GL_DEPTH_TEST);
+       glDepthMask(GL_FALSE);
+       glCreateTextures(GL_TEXTURE_2D, 3, tex);
+
+       ycbcr_vertex_shader = compile_shader(R"(
+#version 460 core
+
+layout(location = 0) in vec2 position;
+layout(location = 1) in vec2 texcoord;
+out vec2 tc;
+
+void main()
+{
+        // The result of glOrtho(0.0, 1.0, 0.0, 1.0, 0.0, 1.0) is:
+        //
+        //   2.000  0.000  0.000 -1.000
+        //   0.000  2.000  0.000 -1.000
+        //   0.000  0.000 -2.000 -1.000
+        //   0.000  0.000  0.000  1.000
+        gl_Position = vec4(2.0 * position.x - 1.0, 2.0 * position.y - 1.0, -1.0, 1.0);
+        tc = texcoord;
+       tc.y = 1.0f - tc.y;
+}
+)", GL_VERTEX_SHADER);
+       ycbcr_fragment_shader = compile_shader(R"(
+#version 460 core
+
+layout(location = 0) uniform sampler2D tex_y;
+layout(location = 1) uniform sampler2D tex_cb;
+layout(location = 2) uniform sampler2D tex_cr;
+layout(location = 3) uniform vec2 cbcr_offset;
+
+in vec2 tc;
+out vec4 FragColor;
+
+// Computed statically by Movit, for limited-range BT.709.
+// (We don't check whether the input could be BT.601 or BT.2020 currently, or full-range)
+const mat3 inv_ycbcr_matrix = mat3(
+       1.16438f, 1.16438f, 1.16438f,
+       0.0f, -0.21325f, 2.11240f,
+       1.79274f, -0.53291f, 0.0f
+);
+
+void main()
+{
+       if (tc.x < 0.0 || tc.x > 1.0 || tc.y < 0.0 || tc.y > 1.0) {
+               FragColor.rgba = vec4(0.0f, 0.0f, 0.0f, 1.0f);
+               return;
+       }
+
+       vec3 ycbcr;
+        ycbcr.r = texture(tex_y, tc).r;
+        ycbcr.g = texture(tex_cb, tc + cbcr_offset).r;
+        ycbcr.b = texture(tex_cr, tc + cbcr_offset).r;
+       ycbcr -= vec3(16.0f / 255.0f, 128.0f / 255.0f, 128.0f / 255.0f);
+       FragColor.rgb = inv_ycbcr_matrix * ycbcr;
+       FragColor.a = 1.0f;
+}
+)", GL_FRAGMENT_SHADER);
+       ycbcr_program = glCreateProgram();
+       glAttachShader(ycbcr_program, ycbcr_vertex_shader);
+       glAttachShader(ycbcr_program, ycbcr_fragment_shader);
+       glLinkProgram(ycbcr_program);
+
+       GLint success;
+       glGetProgramiv(ycbcr_program, GL_LINK_STATUS, &success);
+       if (success == GL_FALSE) {
+               GLchar error_log[1024] = {0};
+               glGetProgramInfoLog(ycbcr_program, 1024, nullptr, error_log);
+               fprintf(stderr, "Error linking program: %s\n", error_log);
+               exit(1);
+       }
+
+       glCreateSamplers(1, &bilinear_sampler);
+       glSamplerParameteri(bilinear_sampler, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST);
+       glSamplerParameteri(bilinear_sampler, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+       glSamplerParameteri(bilinear_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+       glSamplerParameteri(bilinear_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+}
+
+void VideoWidget::resizeGL(int w, int h)
+{
+       glViewport(0, 0, w, h);
+       display_aspect = double(w) / h;
+}
+
+int num_levels(GLuint width, GLuint height)
+{
+       int levels = 1;
+       while (width > 1 || height > 1) {
+               width = max(width / 2, 1u);
+               height = max(height / 2, 1u);
+               ++levels;
+       }
+       return levels;
+}
+
+void VideoWidget::paintGL()
+{
+       std::shared_ptr<Frame> frame = current_frame;
+       if (frame == nullptr) {
+               glClear(GL_COLOR_BUFFER_BIT);
+               return;
+       }
+
+       glUseProgram(ycbcr_program);
+       if (frame->width != last_width || frame->height != last_height) {
+               glTextureStorage2D(tex[0], num_levels(frame->width, frame->height), GL_R8, frame->width, frame->height);
+       }
+       if (frame->chroma_width != last_chroma_width || frame->chroma_height != last_chroma_height) {
+               for (GLuint num : { tex[1], tex[2] }) {
+                       glTextureStorage2D(num, num_levels(frame->chroma_width, frame->chroma_height), GL_R8, frame->chroma_width, frame->chroma_height);
+               }
+       }
+
+       glTextureSubImage2D(tex[0], 0, 0, 0, frame->width, frame->height, GL_RED, GL_UNSIGNED_BYTE, frame->data.get());
+       glGenerateTextureMipmap(tex[0]);
+
+       glTextureSubImage2D(tex[1], 0, 0, 0, frame->chroma_width, frame->chroma_height, GL_RED, GL_UNSIGNED_BYTE, frame->data.get() + frame->width * frame->height);
+       glGenerateTextureMipmap(tex[1]);
+
+       glTextureSubImage2D(tex[2], 0, 0, 0, frame->chroma_width, frame->chroma_height, GL_RED, GL_UNSIGNED_BYTE, frame->data.get() + frame->width * frame->height + frame->chroma_width * frame->chroma_height);
+       glGenerateTextureMipmap(tex[2]);
+
+       glBindTextureUnit(0, tex[0]);
+       glBindTextureUnit(1, tex[1]);
+       glBindTextureUnit(2, tex[2]);
+       glBindSampler(0, bilinear_sampler);
+       glBindSampler(1, bilinear_sampler);
+       glBindSampler(2, bilinear_sampler);
+       glProgramUniform1i(ycbcr_program, 0, 0);
+       glProgramUniform1i(ycbcr_program, 1, 1);
+       glProgramUniform1i(ycbcr_program, 2, 2);
+       glProgramUniform2f(ycbcr_program, 3, cbcr_offset[0], -cbcr_offset[1]);
+
+       float tx1 = 0.0f;
+       float tx2 = 1.0f;
+       float ty1 = 0.0f;
+       float ty2 = 1.0f;
+
+       double video_aspect = double(frame->width) / frame->height;
+       if (display_aspect > video_aspect) {
+               double extra_width = frame->height * display_aspect - frame->width;
+               tx1 = -0.5 * extra_width / frame->width;
+               tx2 = 1.0 + 0.5 * extra_width / frame->width;
+       } else if (display_aspect < video_aspect) {
+               double extra_height = frame->width / display_aspect - frame->height;
+               ty1 = -0.5 * extra_height / frame->height;
+               ty2 = 1.0 + 0.5 * extra_height / frame->height;
+       }
+
+       glBegin(GL_QUADS);
+
+       glVertexAttrib2f(1, tx1, ty1);
+       glVertex2f(0.0f, 0.0f);
+
+       glVertexAttrib2f(1, tx1, ty2);
+       glVertex2f(0.0f, 1.0f);
+
+       glVertexAttrib2f(1, tx2, ty2);
+       glVertex2f(1.0f, 1.0f);
+
+       glVertexAttrib2f(1, tx2, ty1);
+       glVertex2f(1.0f, 0.0f);
+
+       glEnd();
+}
+
+void VideoWidget::open(const string &filename)
+{
+       stop();
+       internal_rewind();
+       pathname = filename;
+       play();
+}
+
+void VideoWidget::play()
+{
+       if (running) {
+               std::lock_guard<std::mutex> lock(queue_mu);
+               command_queue.push_back(QueuedCommand { QueuedCommand::RESUME });
+               producer_thread_should_quit.wakeup();
+               return;
+       }
+       running = true;
+       producer_thread_should_quit.unquit();
+       producer_thread = std::thread(&VideoWidget::producer_thread_func, this);
+}
+
+void VideoWidget::pause()
+{
+       if (!running) {
+               return;
+       }
+       std::lock_guard<std::mutex> lock(queue_mu);
+       command_queue.push_back(QueuedCommand { QueuedCommand::PAUSE });
+       producer_thread_should_quit.wakeup();
+}
+
+void VideoWidget::seek(int64_t relative_seek_ms)
+{
+       if (!running) {
+               return;
+       }
+       std::lock_guard<std::mutex> lock(queue_mu);
+       command_queue.push_back(QueuedCommand { QueuedCommand::SEEK, relative_seek_ms, 0, 0 });
+       producer_thread_should_quit.wakeup();
+}
+
+void VideoWidget::seek_frames(int64_t relative_seek_frames)
+{
+       if (!running) {
+               return;
+       }
+       std::lock_guard<std::mutex> lock(queue_mu);
+       command_queue.push_back(QueuedCommand { QueuedCommand::SEEK, 0, relative_seek_frames, 0 });
+       producer_thread_should_quit.wakeup();
+}
+
+void VideoWidget::seek_absolute(int64_t position_ms)
+{
+       if (!running) {
+               return;
+       }
+       std::lock_guard<std::mutex> lock(queue_mu);
+       command_queue.push_back(QueuedCommand { QueuedCommand::SEEK_ABSOLUTE, 0, 0, position_ms });
+       producer_thread_should_quit.wakeup();
+}
+
+void VideoWidget::stop()
+{
+       if (!running) {
+               return;
+       }
+       running = false;
+       producer_thread_should_quit.quit();
+       producer_thread.join();
+}
+
+void VideoWidget::producer_thread_func()
+{
+       if (!producer_thread_should_quit.should_quit()) {
+               if (!play_video(pathname)) {
+                       // TODO: Send the error back to the UI somehow.
+               }
+       }
+}
+
+void VideoWidget::internal_rewind()
+{
+       pts_origin = last_pts = 0;
+       last_position = 0;
+       start = next_frame_start = steady_clock::now();
+}
+
+template<AVHWDeviceType type>
+AVPixelFormat get_hw_format(AVCodecContext *ctx, const AVPixelFormat *fmt)
+{
+       bool found_config_of_right_type = false;
+       for (int i = 0;; ++i) {  // Termination condition inside loop.
+               const AVCodecHWConfig *config = avcodec_get_hw_config(ctx->codec, i);
+               if (config == nullptr) {  // End of list.
+                       break;
+               }
+               if (!(config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) ||
+                   config->device_type != type) {
+                       // Not interesting for us.
+                       continue;
+               }
+
+               // We have a config of the right type, but does it actually support
+               // the pixel format we want? (Seemingly, FFmpeg's way of signaling errors
+               // is to just replace the pixel format with a software-decoded one,
+               // such as yuv420p.)
+               found_config_of_right_type = true;
+               for (const AVPixelFormat *fmt_ptr = fmt; *fmt_ptr != -1; ++fmt_ptr) {
+                       if (config->pix_fmt == *fmt_ptr) {
+                               fprintf(stderr, "Initialized '%s' hardware decoding for codec '%s'.\n",
+                                       av_hwdevice_get_type_name(type), ctx->codec->name);
+                               if (ctx->profile == FF_PROFILE_H264_BASELINE) {
+                                       fprintf(stderr, "WARNING: Stream claims to be H.264 Baseline, which is generally poorly supported in hardware decoders.\n");
+                                       fprintf(stderr, "         Consider encoding it as Constrained Baseline, Main or High instead.\n");
+                                       fprintf(stderr, "         Decoding might fail and fall back to software.\n");
+                               }
+                               return config->pix_fmt;
+                       }
+               }
+               fprintf(stderr, "Decoder '%s' supports only these pixel formats:", ctx->codec->name);
+               unordered_set<AVPixelFormat> seen;
+               for (const AVPixelFormat *fmt_ptr = fmt; *fmt_ptr != -1; ++fmt_ptr) {
+                       if (!seen.count(*fmt_ptr)) {
+                               fprintf(stderr, " %s", av_get_pix_fmt_name(*fmt_ptr));
+                               seen.insert(*fmt_ptr);
+                       }
+               }
+               fprintf(stderr, " (wanted %s for hardware acceleration)\n", av_get_pix_fmt_name(config->pix_fmt));
+
+       }
+
+       if (!found_config_of_right_type) {
+               fprintf(stderr, "Decoder '%s' does not support device type '%s'.\n", ctx->codec->name, av_hwdevice_get_type_name(type));
+       }
+
+       // We found no VA-API formats, so take the first software format.
+       for (const AVPixelFormat *fmt_ptr = fmt; *fmt_ptr != -1; ++fmt_ptr) {
+               if ((av_pix_fmt_desc_get(*fmt_ptr)->flags & AV_PIX_FMT_FLAG_HWACCEL) == 0) {
+                       fprintf(stderr, "Falling back to software format %s.\n", av_get_pix_fmt_name(*fmt_ptr));
+                       return *fmt_ptr;
+               }
+       }
+
+       // Fallback: Just return anything. (Should never really happen.)
+       return fmt[0];
+}
+
+AVFrameWithDeleter VideoWidget::decode_frame(AVFormatContext *format_ctx, AVCodecContext *video_codec_ctx,
+       const std::string &pathname, int video_stream_index,
+       bool *error)
+{
+       *error = false;
+
+       if (!queued_frames.empty()) {
+               AVFrameWithDeleter frame = std::move(queued_frames.front());
+               queued_frames.pop_front();
+               return frame;
+       }
+
+       // Read packets until we have a frame or there are none left.
+       bool frame_finished = false;
+       AVFrameWithDeleter video_avframe = av_frame_alloc_unique();
+       bool eof = false;
+       do {
+               AVPacket pkt;
+               unique_ptr<AVPacket, decltype(av_packet_unref)*> pkt_cleanup(
+                       &pkt, av_packet_unref);
+               av_init_packet(&pkt);
+               pkt.data = nullptr;
+               pkt.size = 0;
+               if (av_read_frame(format_ctx, &pkt) == 0) {
+                       if (pkt.stream_index == video_stream_index) {
+                               if (avcodec_send_packet(video_codec_ctx, &pkt) < 0) {
+                                       fprintf(stderr, "%s: Cannot send packet to video codec.\n", pathname.c_str());
+                                       *error = true;
+                                       return AVFrameWithDeleter(nullptr);
+                               }
+                       }
+               } else {
+                       eof = true;  // Or error, but ignore that for the time being.
+               }
+
+               // Decode video, if we have a frame.
+               int err = avcodec_receive_frame(video_codec_ctx, video_avframe.get());
+               if (err == 0) {
+                       frame_finished = true;
+                       break;
+               } else if (err != AVERROR(EAGAIN)) {
+                       fprintf(stderr, "%s: Cannot receive frame from video codec.\n", pathname.c_str());
+                       *error = true;
+                       return AVFrameWithDeleter(nullptr);
+               }
+       } while (!eof);
+
+       if (frame_finished)
+               return video_avframe;
+       else
+               return AVFrameWithDeleter(nullptr);
+}
+
+int find_stream_index(AVFormatContext *ctx, AVMediaType media_type)
+{
+       for (unsigned i = 0; i < ctx->nb_streams; ++i) {
+               if (ctx->streams[i]->codecpar->codec_type == media_type) {
+                       return i;
+               }
+       }
+       return -1;
+}
+
+steady_clock::time_point compute_frame_start(int64_t frame_pts, int64_t pts_origin, const AVRational &video_timebase, const steady_clock::time_point &origin, double rate)
+{
+       const duration<double> pts((frame_pts - pts_origin) * double(video_timebase.num) / double(video_timebase.den));
+       return origin + duration_cast<steady_clock::duration>(pts / rate);
+}
+
+bool VideoWidget::play_video(const string &pathname)
+{
+       queued_frames.clear();
+       AVFormatContextWithCloser format_ctx = avformat_open_input_unique(pathname.c_str(), /*fmt=*/nullptr,
+               /*options=*/nullptr);
+       if (format_ctx == nullptr) {
+               fprintf(stderr, "%s: Error opening file\n", pathname.c_str());
+               return false;
+       }
+
+       if (avformat_find_stream_info(format_ctx.get(), nullptr) < 0) {
+               fprintf(stderr, "%s: Error finding stream info\n", pathname.c_str());
+               return false;
+       }
+
+       int video_stream_index = find_stream_index(format_ctx.get(), AVMEDIA_TYPE_VIDEO);
+       if (video_stream_index == -1) {
+               fprintf(stderr, "%s: No video stream found\n", pathname.c_str());
+               return false;
+       }
+
+       // Open video decoder.
+       const AVCodecParameters *video_codecpar = format_ctx->streams[video_stream_index]->codecpar;
+       const AVCodec *video_codec = avcodec_find_decoder(video_codecpar->codec_id);
+
+       video_timebase = format_ctx->streams[video_stream_index]->time_base;
+       AVCodecContextWithDeleter video_codec_ctx = avcodec_alloc_context3_unique(nullptr);
+       if (avcodec_parameters_to_context(video_codec_ctx.get(), video_codecpar) < 0) {
+               fprintf(stderr, "%s: Cannot fill video codec parameters\n", pathname.c_str());
+               return false;
+       }
+       if (video_codec == nullptr) {
+               fprintf(stderr, "%s: Cannot find video decoder\n", pathname.c_str());
+               return false;
+       }
+
+       // Seemingly, it's not too easy to make something that just initializes
+       // “whatever goes”, so we don't get CUDA or VULKAN or whatever here
+       // without enumerating through several different types.
+       // VA-API and VDPAU will do for now. We prioritize VDPAU for the
+       // simple reason that there's a VA-API-via-VDPAU emulation for NVidia
+       // cards that seems to work, but just hangs when trying to transfer the frame.
+       //
+       // Note that we don't actually check codec support beforehand,
+       // so if you have a low-end VDPAU device but a high-end VA-API device,
+       // you lose out on the extra codec support from the latter.
+       AVBufferRef *hw_device_ctx = nullptr;
+       if (av_hwdevice_ctx_create(&hw_device_ctx, AV_HWDEVICE_TYPE_VDPAU, nullptr, nullptr, 0) >= 0) {
+               video_codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);
+               video_codec_ctx->get_format = get_hw_format<AV_HWDEVICE_TYPE_VDPAU>;
+       } else if (av_hwdevice_ctx_create(&hw_device_ctx, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0) >= 0) {
+               video_codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);
+               video_codec_ctx->get_format = get_hw_format<AV_HWDEVICE_TYPE_VAAPI>;
+       } else {
+               fprintf(stderr, "Failed to initialize VA-API or VDPAU for FFmpeg acceleration. Decoding video in software.\n");
+       }
+
+       if (avcodec_open2(video_codec_ctx.get(), video_codec, nullptr) < 0) {
+               fprintf(stderr, "%s: Cannot open video decoder\n", pathname.c_str());
+               return false;
+       }
+       unique_ptr<AVCodecContext, decltype(avcodec_close)*> video_codec_ctx_cleanup(
+               video_codec_ctx.get(), avcodec_close);
+
+       internal_rewind();
+
+       // Main loop.
+       int consecutive_errors = 0;
+       double rate = 1.0;
+       while (!producer_thread_should_quit.should_quit()) {
+               if (process_queued_commands(format_ctx.get(), video_codec_ctx.get(), video_stream_index, /*seeked=*/nullptr)) {
+                       return true;
+               }
+               if (paused) {
+                       producer_thread_should_quit.sleep_for(hours(1));
+                       continue;
+               }
+
+               bool error;
+               AVFrameWithDeleter frame = decode_frame(format_ctx.get(), video_codec_ctx.get(),
+                       pathname, video_stream_index, &error);
+               if (error) {
+                       if (++consecutive_errors >= 100) {
+                               fprintf(stderr, "More than 100 consecutive video frames, aborting playback.\n");
+                               return false;
+                       } else {
+                               continue;
+                       }
+               } else {
+                       consecutive_errors = 0;
+               }
+               if (frame == nullptr) {
+                       // EOF.
+                       return false;
+               }
+
+               // Sleep until it's time to present this frame.
+               for ( ;; ) {
+                       if (last_pts == 0 && pts_origin == 0) {
+                               pts_origin = frame->pts;
+                       }
+                       steady_clock::time_point now = steady_clock::now();
+                       next_frame_start = compute_frame_start(frame->pts, pts_origin, video_timebase, start, rate);
+
+                       if (duration<double>(now - next_frame_start).count() >= 0.1) {
+                               // If we don't have enough CPU to keep up, or if we have a live stream
+                               // where the initial origin was somehow wrong, we could be behind indefinitely.
+                               fprintf(stderr, "%s: Playback %.0f ms behind, resetting time scale\n",
+                                       pathname.c_str(),
+                                       1e3 * duration<double>(now - next_frame_start).count());
+                               pts_origin = frame->pts;
+                               start = next_frame_start = now;
+                       }
+                       bool finished_wakeup;
+                       finished_wakeup = producer_thread_should_quit.sleep_until(next_frame_start);
+                       if (finished_wakeup) {
+                               current_frame.reset(new Frame(make_video_frame(frame.get())));
+                               last_frame = steady_clock::now();
+                               update();
+                               break;
+                       } else {
+                               if (producer_thread_should_quit.should_quit()) break;
+
+                               bool seeked = false;
+                               if (process_queued_commands(format_ctx.get(), video_codec_ctx.get(), video_stream_index, &seeked)) {
+                                       return true;
+                               }
+
+                               if (paused) {
+                                       // Just paused, so present the frame immediately and then go into deep sleep.
+                                       current_frame.reset(new Frame(make_video_frame(frame.get())));
+                                       last_frame = steady_clock::now();
+                                       update();
+                                       break;
+                               }
+
+                               // If we just seeked, drop this frame on the floor and be done.
+                               if (seeked) {
+                                       break;
+                               }
+                       }
+               }
+               store_pts(frame->pts);
+       }
+       return true;
+}
+
+void VideoWidget::store_pts(int64_t pts)
+{
+       last_pts = pts;
+       last_position = lrint(pts * double(video_timebase.num) / double(video_timebase.den) * 1000);
+       emit position_changed(last_position);
+}
+
+// Taken from Movit (see the comment there for explanation)
+float compute_chroma_offset(float pos, unsigned subsampling_factor, unsigned resolution)
+{
+       float local_chroma_pos = (0.5 + pos * (subsampling_factor - 1)) / subsampling_factor;
+       if (fabs(local_chroma_pos - 0.5) < 1e-10) {
+               // x + (-0) can be optimized away freely, as opposed to x + 0.
+               return -0.0;
+       } else {
+               return (0.5 - local_chroma_pos) / resolution;
+       }
+}
+
+VideoWidget::Frame VideoWidget::make_video_frame(const AVFrame *frame)
+{
+       Frame video_frame;
+       AVFrameWithDeleter sw_frame;
+
+       if (frame->format == AV_PIX_FMT_VAAPI ||
+           frame->format == AV_PIX_FMT_VDPAU) {
+               // Get the frame down to the CPU. (TODO: See if we can keep it
+               // on the GPU all the way, since it will be going up again later.
+               // However, this only works if the OpenGL GPU is the same one.)
+               sw_frame = av_frame_alloc_unique();
+               int err = av_hwframe_transfer_data(sw_frame.get(), frame, 0);
+               if (err != 0) {
+                       fprintf(stderr, "%s: Cannot transfer hardware video frame to software.\n", pathname.c_str());
+               } else {
+                       sw_frame->pts = frame->pts;
+                       sw_frame->pkt_duration = frame->pkt_duration;
+                       frame = sw_frame.get();
+               }
+       }
+
+       if (sws_ctx == nullptr ||
+           sws_last_width != frame->width ||
+           sws_last_height != frame->height ||
+           sws_last_src_format != frame->format) {
+               sws_dst_format = decide_dst_format(AVPixelFormat(frame->format));
+               sws_ctx.reset(
+                       sws_getContext(frame->width, frame->height, AVPixelFormat(frame->format),
+                               frame->width, frame->height, sws_dst_format,
+                               SWS_BICUBIC, nullptr, nullptr, nullptr));
+               sws_last_width = frame->width;
+               sws_last_height = frame->height;
+               sws_last_src_format = frame->format;
+       }
+       if (sws_ctx == nullptr) {
+               fprintf(stderr, "Could not create scaler context\n");
+               abort();
+       }
+
+       uint8_t *pic_data[4] = { nullptr, nullptr, nullptr, nullptr };
+       int linesizes[4] = { 0, 0, 0, 0 };
+       const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(sws_dst_format);
+
+       video_frame.width = frame->width;
+       video_frame.height = frame->height;
+       video_frame.chroma_width = AV_CEIL_RSHIFT(int(frame->width), desc->log2_chroma_w);
+       video_frame.chroma_height = AV_CEIL_RSHIFT(int(frame->height), desc->log2_chroma_h);
+
+       // We always assume left chroma placement for now.
+       cbcr_offset[0] = compute_chroma_offset(0.0f, 1 << desc->log2_chroma_w, video_frame.chroma_width);
+       cbcr_offset[1] = compute_chroma_offset(0.5f, 1 << desc->log2_chroma_h, video_frame.chroma_height);
+
+       size_t len = frame->width * frame->height + 2 * video_frame.chroma_width * video_frame.chroma_height;
+       video_frame.data.reset(new uint8_t[len]);
+
+       pic_data[0] = video_frame.data.get();
+       linesizes[0] = frame->width;
+
+       pic_data[1] = pic_data[0] + frame->width * frame->height;
+       linesizes[1] = video_frame.chroma_width;
+
+       pic_data[2] = pic_data[1] + video_frame.chroma_width * video_frame.chroma_height;
+       linesizes[2] = video_frame.chroma_width;
+
+       sws_scale(sws_ctx.get(), frame->data, frame->linesize, 0, frame->height, pic_data, linesizes);
+
+       return video_frame;
+}
+
diff --git a/video_widget.h b/video_widget.h
new file mode 100644 (file)
index 0000000..293c968
--- /dev/null
@@ -0,0 +1,100 @@
+#ifndef _VIDEO_WIDGET_H
+#define _VIDEO_WIDGET_H 1
+
+#include <QOpenGLWidget>
+#include <string>
+#include <atomic>
+#include <thread>
+#include <chrono>
+#include <deque>
+
+extern "C" {
+#include <libavutil/pixfmt.h>
+#include <libavutil/rational.h>
+}
+
+#include "ffmpeg_raii.h"
+#include "quittable_sleeper.h"
+
+// Because QVideoWidget sucks, sadly. (Don't use GStreamer, kids.)
+
+class VideoWidget : public QOpenGLWidget {
+       Q_OBJECT
+
+public:
+       VideoWidget(QWidget *parent);
+       ~VideoWidget() { stop(); }
+
+       void open(const std::string &filename);
+       void play();
+       uint64_t get_position() const { return last_position; }  // In milliseconds.
+       void pause();
+       void stop();
+       void seek(int64_t relative_seek_ms);  // Relative seek.
+       void seek_frames(int64_t relative_frames);  // Relative seek.
+       void seek_absolute(int64_t position_ms);  // Absolute seek.
+
+       void initializeGL() override;
+       void resizeGL(int w, int h) override;
+       void paintGL() override;
+
+signals:
+       void position_changed(uint64_t pos);
+
+private:
+       // Should really have been persistent and a PBO, but this is OK for now.
+       struct Frame {
+               unsigned width, height;
+               unsigned chroma_width, chroma_height;
+               std::unique_ptr<uint8_t[]> data;  // Y, followed by Cb, followed by Cr.
+       };
+       std::shared_ptr<Frame> current_frame;
+       std::deque<AVFrameWithDeleter> queued_frames;  // Frames decoded but not displayed. Only used when frame-stepping backwards.
+
+       GLuint ycbcr_vertex_shader, ycbcr_fragment_shader, ycbcr_program;
+       GLuint bilinear_sampler;
+
+       GLuint tex[3];
+       GLuint last_width = 0, last_height = 0;
+       GLuint last_chroma_width = 0, last_chroma_height = 0;
+       GLfloat cbcr_offset[2];
+       double display_aspect = 1.0;
+
+       int64_t pts_origin;
+       int64_t last_pts;
+       std::atomic<uint64_t> last_position{0};  // TODO: sort of redundant wrt. last_pts (but this one can be read from the other thread)
+       std::atomic<bool> running{false};
+       bool paused = false;
+       std::chrono::steady_clock::time_point start, next_frame_start, last_frame;
+
+       SwsContextWithDeleter sws_ctx;
+       int sws_last_width = -1, sws_last_height = -1, sws_last_src_format = -1;
+       AVPixelFormat sws_dst_format = AVPixelFormat(-1);  // In practice, always initialized.
+       AVRational video_timebase, audio_timebase;
+
+       QuittableSleeper producer_thread_should_quit;
+       std::thread producer_thread;
+
+       std::mutex queue_mu;
+       struct QueuedCommand {
+               enum Command { PAUSE, RESUME, SEEK, SEEK_ABSOLUTE } command;
+               int64_t relative_seek_ms;  // For SEEK.
+               int64_t relative_seek_frames;  // For SEEK.
+               int64_t seek_ms;  // For SEEK_ABSOLUTE.
+       };
+       std::vector<QueuedCommand> command_queue;  // Protected by <queue_mu>.
+
+       std::string pathname;
+
+       void producer_thread_func();
+       bool play_video(const std::string &pathname);
+       void internal_rewind();
+       AVFrameWithDeleter decode_frame(AVFormatContext *format_ctx, AVCodecContext *video_codec_ctx,
+               const std::string &pathname, int video_stream_index,
+               bool *error);
+       Frame make_video_frame(const AVFrame *frame);
+       bool process_queued_commands(AVFormatContext *format_ctx, AVCodecContext *video_codec_ctx, int video_stream_index, bool *seeked);
+       void store_pts(int64_t pts);
+};
+
+#endif  // !defined(_VIDEO_WIDGET_H)