]> git.sesse.net Git - nageru/blobdiff - futatabi/mainwindow.cpp
Fix a Clang error.
[nageru] / futatabi / mainwindow.cpp
index b9dee2755bedcebd4e99c9519efa33d65eb8fe74..23840ff8441a0d4dfcadcdd434e3c5a3bf2e617f 100644 (file)
@@ -1,14 +1,19 @@
 #include "mainwindow.h"
 
+#include "shared/aboutdialog.h"
 #include "clip_list.h"
-#include "disk_space_estimator.h"
+#include "export.h"
+#include "shared/disk_space_estimator.h"
 #include "flags.h"
 #include "frame_on_disk.h"
 #include "player.h"
 #include "shared/post_to_main_thread.h"
-#include "timebase.h"
+#include "shared/timebase.h"
 #include "ui_mainwindow.h"
 
+#include <QDesktopServices>
+#include <QFileDialog>
+#include <QMessageBox>
 #include <QMouseEvent>
 #include <QShortcut>
 #include <QTimer>
@@ -34,8 +39,58 @@ MainWindow::MainWindow()
        global_mainwindow = this;
        ui->setupUi(this);
 
+       // Load settings from database if needed.
+       if (!global_flags.interpolation_quality_set) {
+               SettingsProto settings = db.get_settings();
+               if (settings.interpolation_quality() != 0) {
+                       global_flags.interpolation_quality = settings.interpolation_quality() - 1;
+               }
+       }
+       if (global_flags.interpolation_quality == 0) {
+               // Allocate something just for simplicity; we won't be using it
+               // unless the user changes runtime, in which case 1 is fine.
+               flow_initialized_interpolation_quality = 1;
+       } else {
+               flow_initialized_interpolation_quality = global_flags.interpolation_quality;
+       }
+       save_settings();
+
        // The menus.
        connect(ui->exit_action, &QAction::triggered, this, &MainWindow::exit_triggered);
+       connect(ui->export_cliplist_clip_multitrack_action, &QAction::triggered, this, &MainWindow::export_cliplist_clip_multitrack_triggered);
+       connect(ui->export_playlist_clip_interpolated_action, &QAction::triggered, this, &MainWindow::export_playlist_clip_interpolated_triggered);
+       connect(ui->manual_action, &QAction::triggered, this, &MainWindow::manual_triggered);
+       connect(ui->about_action, &QAction::triggered, this, &MainWindow::about_triggered);
+       connect(ui->undo_action, &QAction::triggered, this, &MainWindow::undo_triggered);
+       connect(ui->redo_action, &QAction::triggered, this, &MainWindow::redo_triggered);
+       ui->undo_action->setEnabled(false);
+       ui->redo_action->setEnabled(false);
+
+       // The quality group.
+       QActionGroup *quality_group = new QActionGroup(ui->interpolation_menu);
+       quality_group->addAction(ui->quality_0_action);
+       quality_group->addAction(ui->quality_1_action);
+       quality_group->addAction(ui->quality_2_action);
+       quality_group->addAction(ui->quality_3_action);
+       quality_group->addAction(ui->quality_4_action);
+       if (global_flags.interpolation_quality == 0) {
+               ui->quality_0_action->setChecked(true);
+       } else if (global_flags.interpolation_quality == 1) {
+               ui->quality_1_action->setChecked(true);
+       } else if (global_flags.interpolation_quality == 2) {
+               ui->quality_2_action->setChecked(true);
+       } else if (global_flags.interpolation_quality == 3) {
+               ui->quality_3_action->setChecked(true);
+       } else if (global_flags.interpolation_quality == 4) {
+               ui->quality_4_action->setChecked(true);
+       } else {
+               assert(false);
+       }
+       connect(ui->quality_0_action, &QAction::toggled, bind(&MainWindow::quality_toggled, this, 0, _1));
+       connect(ui->quality_1_action, &QAction::toggled, bind(&MainWindow::quality_toggled, this, 1, _1));
+       connect(ui->quality_2_action, &QAction::toggled, bind(&MainWindow::quality_toggled, this, 2, _1));
+       connect(ui->quality_3_action, &QAction::toggled, bind(&MainWindow::quality_toggled, this, 3, _1));
+       connect(ui->quality_4_action, &QAction::toggled, bind(&MainWindow::quality_toggled, this, 4, _1));
 
        global_disk_space_estimator = new DiskSpaceEstimator(bind(&MainWindow::report_disk_space, this, _1, _2));
        disk_free_label = new QLabel(this);
@@ -43,6 +98,7 @@ MainWindow::MainWindow()
        ui->menuBar->setCornerWidget(disk_free_label);
 
        StateProto state = db.get_state();
+       undo_stack.push_back(state);  // The undo stack always has the current state on top.
 
        cliplist_clips = new ClipList(state.clip_list());
        ui->clip_list->setModel(cliplist_clips);
@@ -79,6 +135,9 @@ MainWindow::MainWindow()
        connect(play, &QShortcut::activated, ui->play_btn, &QPushButton::click);
        connect(ui->play_btn, &QPushButton::clicked, this, &MainWindow::play_clicked);
 
+       connect(ui->stop_btn, &QPushButton::clicked, this, &MainWindow::stop_clicked);
+       ui->stop_btn->setEnabled(false);
+
        QShortcut *preview_1 = new QShortcut(QKeySequence(Qt::Key_1), this);
        connect(preview_1, &QShortcut::activated, ui->preview_1_btn, &QPushButton::click);
        connect(ui->input1_display, &JPEGFrameView::clicked, ui->preview_1_btn, &QPushButton::click);
@@ -121,8 +180,8 @@ MainWindow::MainWindow()
                this, &MainWindow::playlist_selection_changed);
        playlist_selection_changed();  // First time set-up.
 
-       preview_player = new Player(ui->preview_display, /*also_output_to_stream=*/false);
-       live_player = new Player(ui->live_display, /*also_output_to_stream=*/true);
+       preview_player.reset(new Player(ui->preview_display, Player::NO_STREAM_OUTPUT));
+       live_player.reset(new Player(ui->live_display, Player::HTTPD_STREAM_OUTPUT));
        live_player->set_done_callback([this]{
                post_to_main_thread([this]{
                        live_player_clip_done();
@@ -139,11 +198,17 @@ MainWindow::MainWindow()
        defer_timeout = new QTimer(this);
        defer_timeout->setSingleShot(true);
        connect(defer_timeout, &QTimer::timeout, this, &MainWindow::defer_timer_expired);
+       ui->undo_action->setEnabled(true);
 
        connect(ui->clip_list->selectionModel(), &QItemSelectionModel::currentChanged,
                this, &MainWindow::clip_list_selection_changed);
 }
 
+MainWindow::~MainWindow()
+{
+       // Empty so that we can forward-declare Player in the .h file.
+}
+
 void MainWindow::cue_in_clicked()
 {
        if (!cliplist_clips->empty() && cliplist_clips->back()->pts_out < 0) {
@@ -154,6 +219,7 @@ void MainWindow::cue_in_clicked()
        clip.pts_in = current_pts;
        cliplist_clips->add_clip(clip);
        playlist_selection_changed();
+       ui->clip_list->scrollToBottom();
 }
 
 void MainWindow::cue_out_clicked()
@@ -177,6 +243,7 @@ void MainWindow::queue_clicked()
                if (clip.pts_out != -1) {
                        playlist_clips->add_clip(clip);
                        playlist_selection_changed();
+                       ui->playlist->scrollToBottom();
                }
                return;
        }
@@ -193,6 +260,12 @@ void MainWindow::queue_clicked()
        if (clip.pts_out != -1) {
                playlist_clips->add_clip(clip);
                playlist_selection_changed();
+               ui->playlist->scrollToBottom();
+               if (!ui->playlist->selectionModel()->hasSelection()) {
+                       // TODO: Figure out why this doesn't always seem to actually select the row.
+                       QModelIndex bottom = playlist_clips->index(playlist_clips->size() - 1, 0);
+                       ui->playlist->setCurrentIndex(bottom);
+               }
        }
 }
 
@@ -322,6 +395,24 @@ void MainWindow::content_changed()
 void MainWindow::state_changed(const StateProto &state)
 {
        db.store_state(state);
+
+       redo_stack.clear();
+       ui->redo_action->setEnabled(false);
+
+       undo_stack.push_back(state);
+       ui->undo_action->setEnabled(undo_stack.size() > 1);
+
+       // Make sure it doesn't grow without bounds.
+       while (undo_stack.size() >= 100) {
+               undo_stack.pop_front();
+       }
+}
+
+void MainWindow::save_settings()
+{
+       SettingsProto settings;
+       settings.set_interpolation_quality(global_flags.interpolation_quality + 1);
+       db.store_settings(settings);
 }
 
 void MainWindow::play_clicked()
@@ -342,6 +433,18 @@ void MainWindow::play_clicked()
        playlist_clips->set_progress({{ row, 0.0f }});
        playlist_clips->set_currently_playing(row, 0.0f);
        playlist_selection_changed();
+
+       ui->stop_btn->setEnabled(true);
+}
+
+void MainWindow::stop_clicked()
+{
+       Clip fake_clip;
+       fake_clip.pts_in = 0;
+       fake_clip.pts_out = 0;
+       size_t last_row = playlist_clips->size() - 1;
+       playlist_clips->set_currently_playing(last_row, 0.0f);
+       live_player->play_clip(fake_clip, last_row, 0);
 }
 
 void MainWindow::live_player_clip_done()
@@ -355,12 +458,16 @@ void MainWindow::live_player_clip_done()
                playlist_clips->set_progress({{ row + 1, 0.0f }});
                playlist_clips->set_currently_playing(row + 1, 0.0f);
        }
+       ui->stop_btn->setEnabled(false);
 }
 
 pair<Clip, size_t> MainWindow::live_player_get_next_clip()
 {
        // playlist_clips can only be accessed on the main thread.
        // Hopefully, we won't have to wait too long for this to come back.
+       //
+       // TODO: If MainWindow is in the process of being destroyed and waiting
+       // for Player to shut down, we could have a deadlock here.
        promise<pair<Clip, size_t>> clip_promise;
        future<pair<Clip, size_t>> clip = clip_promise.get_future();
        post_to_main_thread([this, &clip_promise] {
@@ -393,25 +500,11 @@ void MainWindow::live_player_clip_progress(const map<size_t, double> &progress)
 {
        playlist_clips->set_progress(progress);
 
-       // Look at the last clip and then start counting from there.
-       assert(!progress.empty());
-       auto last_it = progress.end();
-       --last_it;
-       double remaining = 0.0;
-       double last_fade_time_seconds = 0.0;
-       for (size_t row = last_it->first; row < playlist_clips->size(); ++row) {
-               const Clip clip = *playlist_clips->clip(row);
-               double clip_length = double(clip.pts_out - clip.pts_in) / TIMEBASE / 0.5;  // FIXME: stop hardcoding speed.
-               if (row == last_it->first) {
-                       // A clip we're playing: Subtract the part we've already played.
-                       remaining = clip_length * (1.0 - last_it->second);
-               } else {
-                       // A clip we haven't played yet: Subtract the part that's overlapping
-                       // with a previous clip (due to fade).
-                       remaining += max(clip_length - last_fade_time_seconds, 0.0);
-               }
-               last_fade_time_seconds = min(clip_length, clip.fade_time_seconds);
+       vector<Clip> clips;
+       for (size_t row = 0; row < playlist_clips->size(); ++row) {
+               clips.push_back(*playlist_clips->clip(row));
        }
+       double remaining = compute_time_left(clips, progress);
        set_output_status(format_duration(remaining) + " left");
 }
 
@@ -444,9 +537,9 @@ void set_pts_in(int64_t pts, int64_t current_pts, ClipProxy &clip)
 bool MainWindow::eventFilter(QObject *watched, QEvent *event)
 {
        constexpr int dead_zone_pixels = 3;  // To avoid that simple clicks get misinterpreted.
-       constexpr int scrub_sensitivity = 100;  // pts units per pixel.
-       constexpr int wheel_sensitivity = 100;  // pts units per degree.
        constexpr int camera_degrees_per_pixel = 15;  // One click of most mice.
+       int scrub_sensitivity = 100;  // pts units per pixel.
+       int wheel_sensitivity = 100;  // pts units per degree.
 
        unsigned stream_idx = ui->preview_display->get_stream_idx();
 
@@ -509,8 +602,16 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event)
                scrub_x_origin = mouse->x();
                scrub_type = type;
        } else if (event->type() == QEvent::MouseMove) {
+               QMouseEvent *mouse = (QMouseEvent *)event;
+               if (mouse->modifiers() & Qt::KeyboardModifier::ShiftModifier) {
+                       scrub_sensitivity *= 10;
+                       wheel_sensitivity *= 10;
+               }
+               if (mouse->modifiers() & Qt::KeyboardModifier::AltModifier) {  // Note: Shift + Alt cancel each other out.
+                       scrub_sensitivity /= 10;
+                       wheel_sensitivity /= 10;
+               }
                if (scrubbing) {
-                       QMouseEvent *mouse = (QMouseEvent *)event;
                        int offset = mouse->x() - scrub_x_origin;
                        int adjusted_offset;
                        if (offset >= dead_zone_pixels) {
@@ -556,6 +657,16 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event)
                }
        } else if (event->type() == QEvent::Wheel) {
                QWheelEvent *wheel = (QWheelEvent *)event;
+               int angle_delta = wheel->angleDelta().y();
+               if (wheel->modifiers() & Qt::KeyboardModifier::ShiftModifier) {
+                       scrub_sensitivity *= 10;
+                       wheel_sensitivity *= 10;
+               }
+               if (wheel->modifiers() & Qt::KeyboardModifier::AltModifier) {  // Note: Shift + Alt cancel each other out.
+                       scrub_sensitivity /= 10;
+                       wheel_sensitivity /= 10;
+                       angle_delta = wheel->angleDelta().x();  // Qt ickiness.
+               }
 
                QTableView *destination;
                int in_column, out_column, camera_column;
@@ -598,19 +709,19 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event)
                        }
                        if (column == in_column) {
                                current_change_id += "in:" + to_string(row);
-                               int64_t pts = clip->pts_in + wheel->angleDelta().y() * wheel_sensitivity;
+                               int64_t pts = clip->pts_in + angle_delta * wheel_sensitivity;
                                set_pts_in(pts, current_pts, clip);
                                preview_single_frame(pts, stream_idx, FIRST_AT_OR_AFTER);
                        } else if (column == out_column) {
                                current_change_id += "out:" + to_string(row);
-                               int64_t pts = clip->pts_out + wheel->angleDelta().y() * wheel_sensitivity;
+                               int64_t pts = clip->pts_out + angle_delta * wheel_sensitivity;
                                pts = std::max(pts, clip->pts_in);
                                pts = std::min(pts, current_pts);
                                clip->pts_out = pts;
                                preview_single_frame(pts, stream_idx, LAST_BEFORE);
                        } else if (column == camera_column) {
                                current_change_id += "camera:" + to_string(row);
-                               int angle_degrees = wheel->angleDelta().y();
+                               int angle_degrees = angle_delta;
                                if (last_mousewheel_camera_row == row) {
                                        angle_degrees += leftover_angle_degrees;
                                }
@@ -640,8 +751,7 @@ void MainWindow::preview_single_frame(int64_t pts, unsigned stream_idx, MainWind
                lock_guard<mutex> lock(frame_mu);
                if (frames[stream_idx].empty())
                        return;
-               auto it = lower_bound(frames[stream_idx].begin(), frames[stream_idx].end(), pts,
-                       [](const FrameOnDisk &frame, int64_t pts) { return frame.pts < pts; });
+               auto it = find_last_frame_before(frames[stream_idx], pts);
                if (it != frames[stream_idx].end()) {
                        pts = it->pts;
                }
@@ -650,8 +760,7 @@ void MainWindow::preview_single_frame(int64_t pts, unsigned stream_idx, MainWind
                lock_guard<mutex> lock(frame_mu);
                if (frames[stream_idx].empty())
                        return;
-               auto it = upper_bound(frames[stream_idx].begin(), frames[stream_idx].end(), pts - 1,
-                       [](int64_t pts, const FrameOnDisk &frame) { return pts < frame.pts; });
+               auto it = find_first_frame_at_or_after(frames[stream_idx], pts);
                if (it != frames[stream_idx].end()) {
                        pts = it->pts;
                }
@@ -735,6 +844,138 @@ void MainWindow::exit_triggered()
        close();
 }
 
+void MainWindow::export_cliplist_clip_multitrack_triggered()
+{
+       QItemSelectionModel *selected = ui->clip_list->selectionModel();
+       if (!selected->hasSelection()) {
+               QMessageBox msgbox;
+               msgbox.setText("No clip selected in the clip list. Select one and try exporting again.");
+               msgbox.exec();
+               return;
+       }
+
+       QModelIndex index = selected->currentIndex();
+       Clip clip = *cliplist_clips->clip(index.row());
+       QString filename = QFileDialog::getSaveFileName(this,
+               "Export multitrack clip", QString(), tr("Matroska video files (*.mkv)"));
+       if (filename.isNull()) {
+               // Cancel.
+               return;
+       }
+       if (!filename.endsWith(".mkv")) {
+               filename += ".mkv";
+       }
+       export_multitrack_clip(filename.toStdString(), clip);
+}
+
+void MainWindow::export_playlist_clip_interpolated_triggered()
+{
+       QItemSelectionModel *selected = ui->playlist->selectionModel();
+       if (!selected->hasSelection()) {
+               QMessageBox msgbox;
+               msgbox.setText("No clip selected in the playlist. Select one and try exporting again.");
+               msgbox.exec();
+               return;
+       }
+
+       QString filename = QFileDialog::getSaveFileName(this,
+               "Export interpolated clip", QString(), tr("Matroska video files (*.mkv)"));
+       if (filename.isNull()) {
+               // Cancel.
+               return;
+       }
+       if (!filename.endsWith(".mkv")) {
+               filename += ".mkv";
+       }
+
+       vector<Clip> clips;
+       QModelIndexList rows = selected->selectedRows();
+       for (QModelIndex index : rows) {
+               clips.push_back(*playlist_clips->clip(index.row()));
+       }
+       export_interpolated_clip(filename.toStdString(), clips);
+}
+
+void MainWindow::manual_triggered()
+{
+       if (!QDesktopServices::openUrl(QUrl("https://nageru.sesse.net/doc/"))) {
+               QMessageBox msgbox;
+               msgbox.setText("Could not launch manual in web browser.\nPlease see https://nageru.sesse.net/doc/ manually.");
+               msgbox.exec();
+       }
+}
+
+void MainWindow::about_triggered()
+{
+       AboutDialog("Futatabi", "Multicamera slow motion video server").exec();
+}
+
+void MainWindow::undo_triggered()
+{
+       // Finish any deferred action.
+       if (defer_timeout->isActive()) {
+               defer_timeout->stop();
+               state_changed(deferred_state);
+       }
+
+       StateProto redo_state;
+       *redo_state.mutable_clip_list() = cliplist_clips->serialize();
+       *redo_state.mutable_play_list() = playlist_clips->serialize();
+       redo_stack.push_back(std::move(redo_state));
+       ui->redo_action->setEnabled(true);
+
+       assert(undo_stack.size() > 1);
+
+       // Pop off the current state, which is always at the top of the stack.
+       undo_stack.pop_back();
+
+       StateProto state = undo_stack.back();
+       ui->undo_action->setEnabled(undo_stack.size() > 1);
+
+       replace_model(ui->clip_list, &cliplist_clips, new ClipList(state.clip_list()));
+       replace_model(ui->playlist, &playlist_clips, new PlayList(state.play_list()));
+
+       db.store_state(state);
+}
+
+void MainWindow::redo_triggered()
+{
+       assert(!redo_stack.empty());
+
+       ui->undo_action->setEnabled(true);
+       ui->redo_action->setEnabled(true);
+
+       undo_stack.push_back(std::move(redo_stack.back()));
+       redo_stack.pop_back();
+       ui->undo_action->setEnabled(true);
+       ui->redo_action->setEnabled(!redo_stack.empty());
+
+       const StateProto &state = undo_stack.back();
+       replace_model(ui->clip_list, &cliplist_clips, new ClipList(state.clip_list()));
+       replace_model(ui->playlist, &playlist_clips, new PlayList(state.play_list()));
+
+       db.store_state(state);
+}
+
+void MainWindow::quality_toggled(int quality, bool checked)
+{
+       if (!checked) {
+               return;
+       }
+       global_flags.interpolation_quality = quality;
+       if (quality != 0 &&  // Turning interpolation off is always possible.
+           quality != flow_initialized_interpolation_quality) {
+               QMessageBox msgbox;
+               msgbox.setText(QString::fromStdString(
+                       "The interpolation quality for the main output cannot be changed at runtime, "
+                       "except being turned completely off; it will take effect for exported files "
+                       "only until next restart. The live output quality thus remains at " + to_string(flow_initialized_interpolation_quality) + "."));
+               msgbox.exec();
+       }
+
+       save_settings();
+}
+
 void MainWindow::highlight_camera_input(int stream_idx)
 {
        if (stream_idx == 0) {
@@ -771,3 +1012,14 @@ pair<string, string> MainWindow::get_queue_status() const {
        lock_guard<mutex> lock(queue_status_mu);
        return {queue_status, "text/plain"};
 }
+
+template <class Model>
+void MainWindow::replace_model(QTableView *view, Model **model, Model *new_model)
+{
+       QItemSelectionModel *old_selection_model = view->selectionModel();
+       view->setModel(new_model);
+       delete *model;
+       delete old_selection_model;
+       *model = new_model;
+       connect(new_model, &Model::any_content_changed, this, &MainWindow::content_changed);
+}