diff --git a/app/widget/viewer/CMakeLists.txt b/app/widget/viewer/CMakeLists.txt index 8251b2518..78852383e 100644 --- a/app/widget/viewer/CMakeLists.txt +++ b/app/widget/viewer/CMakeLists.txt @@ -24,8 +24,6 @@ set(OLIVE_SOURCES widget/viewer/viewer.h widget/viewer/viewerdisplay.cpp widget/viewer/viewerdisplay.h - widget/viewer/viewerplaybacktimer.cpp - widget/viewer/viewerplaybacktimer.h widget/viewer/viewerpreventsleep.cpp widget/viewer/viewerpreventsleep.h widget/viewer/viewerqueue.h diff --git a/app/widget/viewer/viewer.cpp b/app/widget/viewer/viewer.cpp index d9e157229..28e8a567c 100644 --- a/app/widget/viewer/viewer.cpp +++ b/app/widget/viewer/viewer.cpp @@ -57,24 +57,14 @@ namespace olive QVector ViewerWidget::instances; -// NOTE: Hardcoded interval of size of audio chunk to render and send to the output at a time. -// We want this to be as long as possible so the code has plenty of time to send the audio -// while also being as short as possible so users get relatively immediate feedback when -// changing values. 1/4 second seems to be a good middleground. -const Rational ViewerWidget::k_audio_playback_interval = Rational(1, 4); - -const Rational k_video_playback_interval = Rational(1, 10); - ViewerWidget::ViewerWidget(ViewerDisplayWidget *display, QWidget *parent) : super(false, true, parent) , playback_speed_(0) , color_menu_enabled_(true) , time_changed_from_timer_(false) - , prequeuing_video_(false) - , prequeuing_audio_(0) + , playback_(nullptr) , record_armed_(false) , recording_(false) - , first_requeue_watcher_(nullptr) , enable_audio_scrubbing_(true) , waveform_mode_(k_wf_automatic) , ignore_scrub_(0) @@ -113,10 +103,6 @@ ViewerWidget::ViewerWidget(ViewerDisplayWidget *display, QWidget *parent) &ViewerWidget::dropped); connect(display_widget_, &ViewerDisplayWidget::texture_changed, this, &ViewerWidget::texture_changed); - connect(display_widget_, &ViewerDisplayWidget::queue_starved, this, - &ViewerWidget::queue_starved); - connect(display_widget_, &ViewerDisplayWidget::queue_no_longer_starved, this, - &ViewerWidget::queue_no_longer_starved); connect(display_widget_, &ViewerDisplayWidget::create_addable_at, this, &ViewerWidget::create_addable_at); connect(sizer_, &ViewerSizer::request_scale, display_widget_, @@ -168,8 +154,8 @@ ViewerWidget::ViewerWidget(ViewerDisplayWidget *display, QWidget *parent) connect(waveform_view_, &AudioWaveformView::customContextMenuRequested, this, &ViewerWidget::show_context_menu); - connect(&playback_backup_timer_, &QTimer::timeout, this, - &ViewerWidget::playback_timer_update); + connect(&playback_poll_timer_, &QTimer::timeout, this, + &ViewerWidget::playback_poll_update); set_auto_max_scroll_bar(true); @@ -189,6 +175,10 @@ ViewerWidget::~ViewerWidget() { instances.removeOne(this); + // Stop and release the facade playback session. + oakengine_playback_free(playback_); + playback_ = nullptr; + auto windows = windows_; foreach (ViewerWindow *window, windows) { @@ -560,14 +550,6 @@ void ViewerWidget::update_auto_cacher() get_connected_node()->get_playhead()); } -void ViewerWidget::decrement_prequeued_audio() -{ - prequeuing_audio_--; - if (!prequeuing_audio_) { - finish_play_preprocess(); - } -} - void ViewerWidget::arm_for_recording() { controls_->start_play_blink(); @@ -657,14 +639,6 @@ void ViewerWidget::create_addable_at(const QRectF &f) } } -void ViewerWidget::handle_first_requeue_destroy() -{ - // Extra protection to ensure we don't reference a destroyed object - if (first_requeue_watcher_ == sender()) { - first_requeue_watcher_ = nullptr; - } -} - void ViewerWidget::show_subtitle_properties() { QFont f(OAK_CONFIG("DefaultSubtitleFamily").toString(), @@ -681,39 +655,6 @@ void ViewerWidget::show_subtitle_properties() } } -void ViewerWidget::dry_run_finished() -{ - RenderTicketWatcher *w = static_cast(sender()); - - if (dry_run_watchers_.contains(w)) { - request_next_dry_run(); - } - - delete w; -} - -void ViewerWidget::request_next_dry_run() -{ - if (is_playing()) { - Rational next_time = - Timecode::timestamp_to_time(dry_run_next_frame_, timebase()); - if (frame_exists_at_time(next_time)) { - if (next_time > get_connected_node()->get_playhead() + - RenderManager::k_dry_run_interval) { - QTimer::singleShot(timebase().to_double() / playback_speed_, - this, &ViewerWidget::request_next_dry_run); - } else { - RenderTicketWatcher *watcher = new RenderTicketWatcher(this); - connect(watcher, &RenderTicketWatcher::finished, this, - &ViewerWidget::dry_run_finished); - watcher->set_ticket(get_single_frame(next_time, true)); - dry_run_next_frame_ += playback_speed_; - dry_run_watchers_.append(watcher); - } - } - } -} - void ViewerWidget::save_frame_as_image() { Core::instance()->open_export_dialog_for_viewer(get_connected_node(), true); @@ -844,80 +785,6 @@ void ViewerWidget::update_waveform_view_from_mode() } } -void ViewerWidget::queue_next_audio_buffer() -{ - Rational queue_end = - audio_playback_queue_time_ + (k_audio_playback_interval * playback_speed_); - - // Clamp queue end by zero and the audio length - queue_end = std::clamp(queue_end, Rational(0), - get_connected_node()->get_audio_length()); - if ((playback_speed_ > 0 && queue_end <= audio_playback_queue_time_) || - (playback_speed_ < 0 && queue_end >= audio_playback_queue_time_)) { - // This will queue nothing, so stop the loop here - if (prequeuing_audio_) { - decrement_prequeued_audio(); - } - return; - } - - RenderTicketWatcher *watcher = new RenderTicketWatcher(this); - connect(watcher, &RenderTicketWatcher::finished, this, - &ViewerWidget::received_audio_buffer_for_playback); - audio_playback_queue_.push_back(watcher); - watcher->set_ticket(RenderManager::instance()->get_cacher()->get_range_of_audio( - get_connected_node(), TimeRange(audio_playback_queue_time_, queue_end))); - - audio_playback_queue_time_ = queue_end; -} - -void ViewerWidget::received_audio_buffer_for_playback() -{ - while (!audio_playback_queue_.empty() && - audio_playback_queue_.front()->has_result()) { - RenderTicketWatcher *watcher = audio_playback_queue_.front(); - audio_playback_queue_.pop_front(); - - if (watcher->has_result()) { - SampleBuffer samples = watcher->get().value(); - if (samples.is_allocated()) { - // If the samples must be reversed, reverse them now - if (playback_speed_ < 0) { - samples.reverse(); - } - - // Convert to packed data for audio output - AudioProcessor::Buffer buf; - int r = audio_processor_.convert(samples.to_raw_ptrs().data(), - samples.sample_count(), &buf); - - // TempoProcessor may have emptied the array - if (r >= 0) { - if (!buf.empty()) { - const QByteArray &pack = buf.at(0); - if (prequeuing_audio_) { - // Add to prequeued audio buffer - prequeued_audio_.append(pack); - } else { - // Push directly to audio manager - AudioManager::instance()->push_to_output( - audio_processor_.to(), pack); - } - } - } else { - qCritical() << "Failed to process audio for playback:" << r; - } - } - } - - if (prequeuing_audio_) { - decrement_prequeued_audio(); - } - - delete watcher; - } -} - void ViewerWidget::received_audio_buffer_for_scrubbing() { RenderTicketWatcher *watcher = static_cast(sender()); @@ -961,67 +828,6 @@ void ViewerWidget::received_audio_buffer_for_scrubbing() delete watcher; } -void ViewerWidget::queue_starved() -{ - static const int k_maximum_wait_time_ms = 250; - static const Rational k_maximum_wait_time(k_maximum_wait_time_ms, 1000); - qint64 now = QDateTime::currentMSecsSinceEpoch(); - - if (!queue_starved_start_) { - queue_starved_start_ = now; - } else if (now > queue_starved_start_ + k_maximum_wait_time_ms) { - if (first_requeue_watcher_) { - if (get_connected_node()->get_playhead() + k_maximum_wait_time < - first_requeue_watcher_->property("time").value()) { - // We still have time - return; - } - } - - force_requeue_from_current_time(); - queue_starved_start_ = 0; - } -} - -void ViewerWidget::queue_no_longer_starved() -{ - queue_starved_start_ = 0; -} - -void ViewerWidget::force_requeue_from_current_time() -{ - // Defer the requeue to the next event-loop iteration. This function is often - // called from paintEvent paths (QueueStarved) where synchronously cancelling - // watchers can re-enter the same RenderTicket mutex and deadlock. - QMetaObject::invokeMethod( - this, [this]() { force_requeue_from_current_time_internal(); }, - Qt::QueuedConnection); -} - -void ViewerWidget::force_requeue_from_current_time_internal() -{ - // Allow half a second for requeue to complete - static const Rational k_requeue_wait_time(1); - - RenderManager::instance()->get_cacher()->clear_single_frame_renders(); - queue_watchers_.clear(); - int queue = determine_playback_queue_size(); - playback_queue_next_frame_ = - get_timestamp() + - playback_speed_ * Timecode::time_to_timestamp( - k_requeue_wait_time, timebase(), Timecode::k_floor); - ; - first_requeue_watcher_ = nullptr; - for (int i = 0; i < queue; i++) { - RenderTicketWatcher *watcher = request_next_frame_for_queue(); - if (!first_requeue_watcher_) { - first_requeue_watcher_ = watcher; - connect(first_requeue_watcher_, &RenderTicketWatcher::destroyed, - this, &ViewerWidget::handle_first_requeue_destroy); - } - } -} - void ViewerWidget::update_texture_from_node() { if (!get_connected_node()) { @@ -1077,6 +883,13 @@ void ViewerWidget::play_internal(int speed, bool in_to_out_only) return; } + if (speed < 0) { + // The facade playback engine covers forward playback only this + // round; backward/shuttle playback is deferred (roadmap 附 A). + qWarning() << "ViewerWidget: backward playback is not supported yet"; + return; + } + // Kindly tell all viewers to stop playing and caching so all resources can be used for playback foreach (ViewerWidget *viewer, instances) { if (viewer != this) { @@ -1097,77 +910,70 @@ void ViewerWidget::play_internal(int speed, bool in_to_out_only) Rational last_frame = get_connected_node()->get_length() - timebase(); if (!in_to_out_only && get_connected_node()->get_playhead() >= last_frame) { - if (speed > 0) { - get_connected_node()->set_playhead(0); - } else { - get_connected_node()->set_playhead(last_frame); - } + get_connected_node()->set_playhead(0); } } playback_speed_ = speed; play_in_to_out_only_ = in_to_out_only; - playback_queue_next_frame_ = get_timestamp() + playback_speed_; - controls_->show_pause_button(); - queue_starved_start_ = 0; - - // Attempt to fill playback queue - if (is_video_visible()) { - prequeue_length_ = determine_playback_queue_size(); - - if (prequeue_length_ > 0) { - prequeuing_video_ = true; - prequeue_count_ = 0; - - for (int i = 0; i < prequeue_length_; i++) { - request_next_frame_for_queue(); - } - - dry_run_next_frame_ = playback_queue_next_frame_; - request_next_dry_run(); + // Start the facade playback session: its pull thread renders frames + // and 1/4s audio blocks ahead, pushes audio to the AudioManager + // itself and feeds our frame/audio callbacks. + if (!playback_) { + const VideoParams vp = get_connected_node()->get_video_params(); + playback_ = oakengine_playback_create( + reinterpret_cast(get_connected_node()), + vp.effective_width(), vp.effective_height(), + timebase().denominator(), timebase().numerator()); + if (!playback_) { + qWarning() << "ViewerWidget: failed to create playback session"; + playback_speed_ = 0; + controls_->show_play_button(); + return; } + oakengine_playback_set_frame_callback( + playback_, &ViewerWidget::facade_frame_callback, this); + oakengine_playback_set_audio_callback( + playback_, &ViewerWidget::facade_audio_callback, this); } - AudioParams ap = get_connected_node()->get_audio_params(); - qDebug() << "ViewerWidget::PlayInternal: audio params valid=" << ap.is_valid() - << "channel_count=" << ap.channel_count(); - if (ap.is_valid() && ap.channel_count() != 0) { - update_audio_processor(); - - // Verify audio processor output params are valid before using them - AudioParams output_params = audio_processor_.to(); - qDebug() << "ViewerWidget::PlayInternal: audio processor output params valid=" - << output_params.is_valid(); - if (!output_params.is_valid()) { - qWarning() - << "Audio processor output params are invalid, skipping audio playback"; - } else { - AudioManager::instance()->set_output_notify_interval( - output_params.time_to_bytes(k_audio_playback_interval)); - connect(AudioManager::instance(), &AudioManager::output_notify, this, - &ViewerWidget::queue_next_audio_buffer); - - static const int prequeue_count = 2; - prequeuing_audio_ = - prequeue_count; // Queue two buffers ahead of time - audio_playback_queue_time_ = get_connected_node()->get_playhead(); - qDebug() << "ViewerWidget::PlayInternal: prequeuing audio start time=" - << audio_playback_queue_time_.to_double(); - for (int i = 0; i < prequeue_count; i++) { - queue_next_audio_buffer(); - } - } + const int64_t start_ts = get_timestamp(); + if (oakengine_playback_start(playback_, start_ts, playback_speed_) != + OAKENGINE_OK) { + char err[512]; + err[0] = '\0'; + oakengine_playback_last_error(playback_, err, sizeof(err)); + qWarning() << "ViewerWidget: failed to start playback:" + << (err[0] ? err : "(no error)"); + playback_speed_ = 0; + controls_->show_play_button(); + return; } - // If there's nothing to prequeue, start playback immediately so the - // playhead advances even when only the audio waveform is visible. - if (!prequeuing_video_ && !prequeuing_audio_) { - finish_play_preprocess(); + // Waveform monitor stays UI-side (it needs the connected waveform + // metadata); the facade pushes audio to the output by itself. + if (get_connected_node()->get_audio_params().channel_count() > 0) { + AudioMonitor::start_waveform_on_all( + get_connected_node()->get_connected_waveform(), + get_connected_node()->get_playhead(), playback_speed_); } + display_widget_->reset_fps_timer(); + + foreach (ViewerDisplayWidget *dw, playback_devices_) { + dw->play(start_ts, playback_speed_, timebase(), is_video_visible()); + } + + // The UI poll timer drives the playhead, boundary and loop policy + // from the facade's playback position. + playback_poll_timer_.setInterval( + qMax(1, qFloor(timebase().to_double() * 1000.0))); + playback_poll_timer_.start(); + playback_poll_update(); + // Force screen to stay awake prevent_sleep(true); } @@ -1192,28 +998,15 @@ void ViewerWidget::pause_internal() dw->pause(); } - // Cancel in-flight render tickets before deleting watchers, - // otherwise the render thread keeps working on stale frames - // and blocks the single-frame render requested by UpdateTextureFromNode(). - foreach (RenderTicketWatcher *watcher, queue_watchers_) { - watcher->cancel(); + // The facade pause also stops the audio output (engine side). + if (playback_) { + oakengine_playback_pause(playback_); } - qDeleteAll(queue_watchers_); - queue_watchers_.clear(); - RenderManager::instance()->get_cacher()->clear_single_frame_renders(); + playback_poll_timer_.stop(); - playback_backup_timer_.stop(); - - // Handle audio - AudioManager::instance()->stop_output(); AudioMonitor::stop_on_all(); - prequeued_audio_.clear(); - disconnect(AudioManager::instance(), &AudioManager::output_notify, this, - &ViewerWidget::queue_next_audio_buffer); - qDeleteAll(audio_playback_queue_); - audio_playback_queue_.clear(); - update_audio_processor(); + RenderManager::instance()->get_cacher()->clear_single_frame_renders(); RenderManager::instance()->get_cacher()->set_thumbnails_paused(false); update_texture_from_node(); @@ -1221,14 +1014,74 @@ void ViewerWidget::pause_internal() RenderManager::instance()->set_aggressive_garbage_collection(false); } - prequeuing_video_ = false; - prequeuing_audio_ = 0; - dry_run_watchers_.clear(); - // Reset screen timeout timer prevent_sleep(false); } +void ViewerWidget::facade_frame_callback(const oak_playback_frame *frame, + void *userdata) +{ + ViewerWidget *viewer = static_cast(userdata); + + // Wrap the CPU pixels now (the payload dies when we return), then + // append to the display queue on the main thread -- the same + // destination the old renderer_generated_frame_for_queue used. + FramePtr copy = Frame::create(); + copy->set_video_params( + VideoParams(frame->width, frame->height, viewer->timebase(), + static_cast(frame->format), + VideoParams::k_internal_channel_count)); + copy->allocate(); + const int row_bytes = qMin(frame->linesize, copy->linesize_bytes()); + for (int y = 0; y < frame->height; y++) { + memcpy(copy->data() + y * copy->linesize_bytes(), + static_cast(frame->data) + y * frame->linesize, + size_t(row_bytes)); + } + + const Rational ts = + Timecode::timestamp_to_time(frame->timestamp, viewer->timebase()); + QMetaObject::invokeMethod(viewer, [viewer, copy, ts]() { + viewer->deliver_facade_frame(copy, ts); + }, Qt::QueuedConnection); +} + +void ViewerWidget::facade_audio_callback(const oak_playback_audio *audio, + void *userdata) +{ + ViewerWidget *viewer = static_cast(userdata); + + // Copy the block for the level monitor (the payload dies when we + // return; the facade already pushed it to the AudioManager). + const AudioParams params( + audio->sample_rate, + viewer->get_connected_node() ? + viewer->get_connected_node()->get_audio_params().channel_layout() : + core::k_channel_layout_stereo, + core::SampleFormat::f32_p); + SampleBuffer buffer(params, size_t(audio->sample_count)); + for (int ch = 0; ch < audio->channels; ch++) { + memcpy(buffer.to_raw_ptrs()[ch], audio->channel_data[ch], + size_t(audio->sample_count) * sizeof(float)); + } + + QMetaObject::invokeMethod(viewer, [buffer]() { + AudioMonitor::push_sample_buffer_on_all(buffer); + }, Qt::QueuedConnection); +} + +void ViewerWidget::deliver_facade_frame(const FramePtr &frame, + const Rational &ts) +{ + if (!is_playing()) { + return; + } + foreach (ViewerDisplayWidget *dw, playback_devices_) { + dw->queue()->append_timewise({ ts, QVariant::fromValue(frame) }, + playback_speed_); + } +} + void ViewerWidget::push_scrubbed_audio() { if (!is_playing() && get_connected_node() && @@ -1322,34 +1175,9 @@ void ViewerWidget::set_display_image(RenderTicketPtr ticket) } } -RenderTicketWatcher *ViewerWidget::request_next_frame_for_queue(bool increment) -{ - RenderTicketWatcher *watcher = nullptr; - - Rational next_time = - Timecode::timestamp_to_time(playback_queue_next_frame_, timebase()); - - if (frame_exists_at_time(next_time) || viewer_might_be_a_still()) { - if (increment) { - playback_queue_next_frame_ += playback_speed_; - } - - watcher = new RenderTicketWatcher(); - watcher->setProperty("start", QDateTime::currentMSecsSinceEpoch()); - watcher->setProperty("time", QVariant::fromValue(next_time)); - detect_multicam_node(next_time); - connect(watcher, &RenderTicketWatcher::finished, this, - &ViewerWidget::renderer_generated_frame_for_queue); - queue_watchers_.append(watcher); - watcher->set_ticket(get_frame(next_time)); - } - - return watcher; -} - RenderTicketPtr ViewerWidget::get_frame(const Rational &t) { - if (is_playing() || prequeuing_video_) { + if (is_playing()) { return get_single_frame(t); } @@ -1375,76 +1203,6 @@ RenderTicketPtr ViewerWidget::get_frame(const Rational &t) } } -void ViewerWidget::finish_play_preprocess() -{ - // Check if we're still waiting for video or audio respectively - if (prequeuing_video_ || prequeuing_audio_) { - return; - } - - int64_t playback_start_time = get_timestamp(); - - // Restart the audio output clock for this playback run; the playback - // timer uses it as its master clock - AudioManager::instance()->reset_output_clock(); - - // Start audio waveform playback - if (!prequeued_audio_.isEmpty()) { - QString error; - if (!AudioManager::instance()->push_to_output(audio_processor_.to(), - prequeued_audio_, &error)) { - QMessageBox::critical( - this, tr("Audio Error"), - tr("Failed to start audio: %1\n\n" - "Please check your audio preferences and try again.") - .arg(error)); - } - prequeued_audio_.clear(); - - AudioMonitor::start_waveform_on_all( - get_connected_node()->get_connected_waveform(), - get_connected_node()->get_playhead(), playback_speed_); - } - - display_widget_->reset_fps_timer(); - - foreach (ViewerDisplayWidget *dw, playback_devices_) { - dw->play(playback_start_time, playback_speed_, timebase(), - is_video_visible()); - } - - // This is our timer for loading the queue and setting the time - playback_backup_timer_.setInterval( - qMax(1, qFloor(timebase_dbl() * 1000.0))); - playback_backup_timer_.start(); - - playback_timer_update(); -} - -int ViewerWidget::determine_playback_queue_size() -{ - if (playback_speed_ == 0) { - return 0; - } - - int64_t end_ts; - - if (playback_speed_ > 0) { - end_ts = Timecode::time_to_timestamp( - get_connected_node()->get_video_length(), timebase()); - } else { - end_ts = 0; - } - - int remaining_frames = (end_ts - get_timestamp() - 1) / playback_speed_; - - // Generate maximum queue - int max_frames = - qCeil(k_video_playback_interval.to_double() / timebase().to_double()); - - return qMin(max_frames, remaining_frames); -} - void ViewerWidget::context_menu_set_full_screen(QAction *action) { set_full_screen(QGuiApplication::screens().at(action->data().toInt())); @@ -1513,80 +1271,6 @@ void ViewerWidget::renderer_generated_frame() delete ticket; } -void ViewerWidget::renderer_generated_frame_for_queue() -{ - RenderTicketWatcher *watcher = static_cast(sender()); - - if (queue_watchers_.contains(watcher)) { - queue_watchers_.removeOne(watcher); - - if (watcher->has_result()) { - QVariant frame = watcher->get(); - bool drop_frame = false; - - // Ignore this signal if we've paused now - if (is_playing() || prequeuing_video_) { - const qint64 start_ms = watcher->property("start").toLongLong(); - const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); - const int playback_step = qMax(1, qAbs(playback_speed_)); - const double frame_interval_ms = - qMax(1.0, timebase().to_double() * 1000.0 / - static_cast(playback_step)); - if (start_ms > 0 && (now_ms - start_ms) > frame_interval_ms) { - // If the queue is nearly empty, keep the frame anyway - // to prevent the viewer from freezing entirely when - // rendering can't keep up with playback speed. - if (display_widget_->queue()->size() >= 2) { - drop_frame = true; - } - } - - Rational ts = watcher->property("time").value(); - - if (!drop_frame) { - foreach (ViewerDisplayWidget *dw, playback_devices_) { - const bool is_multicam = - dynamic_cast(dw); - QVariant push; - if (is_multicam) { - push = watcher->get_ticket()->property( - "multicam_output"); - if (!push.isValid() || push.isNull()) { - // Fall back to the primary frame when multicam isn't available. - push = frame; - } - } else { - push = frame; - } - - dw->queue()->append_timewise({ ts, push }, - playback_speed_); - } - } - - if (prequeuing_video_) { - prequeue_count_++; - - if (prequeue_count_ == prequeue_length_) { - prequeuing_video_ = false; - finish_play_preprocess(); - } else { - // This call was mostly necessary to keep the threads busy between prequeue and playback. - // If we only have a single render thread, it's no longer necessary. - //RequestNextFrameForQueue(); - } - } - } - } - } - - if (first_requeue_watcher_ == watcher) { - first_requeue_watcher_ = nullptr; - } - - delete watcher; -} - void ViewerWidget::show_context_menu(const QPoint &pos) { if (!get_connected_node()) { @@ -1956,12 +1640,18 @@ void ViewerWidget::TimebaseChangedEvent(const Rational &timebase) length_changed_slot(get_connected_node() ? get_connected_node()->get_length() : 0); } -void ViewerWidget::playback_timer_update() +void ViewerWidget::playback_poll_update() { - Q_ASSERT(playback_speed_ != 0); + if (!playback_ || !is_playing() || !get_connected_node()) { + return; + } - Rational current_time = Timecode::timestamp_to_time( - display_widget_->timer()->get_timestamp_now(), timebase()); + // The facade playback engine owns the master clock; poll its + // position for the playhead, boundary and loop policy (the min/max + // part of the old playback_timer_update). + int64_t pos_ts = 0; + oakengine_playback_get_position(playback_, &pos_ts); + Rational current_time = Timecode::timestamp_to_time(pos_ts, timebase()); Rational min_time, max_time; @@ -1993,31 +1683,21 @@ void ViewerWidget::playback_timer_update() bool play_after_pause = false; if ((!recording_ || recording_range_.out() != recording_range_.in()) && - ((playback_speed_ < 0 && current_time <= min_time) || - (playback_speed_ > 0 && current_time >= max_time))) { - // Determine which timestamp we tripped - Rational tripped_time; - - if (current_time <= min_time) { - tripped_time = min_time; - } else { - tripped_time = max_time; - } - - // Signal that we've reached the end of whatever range we're playing and should either pause - // or restart playback + current_time >= max_time) { + // We've reached the end of whatever range we're playing and should either pause + // or restart playback (negative speeds are out of scope this round). end_of_line = true; if (OAK_CONFIG("Loop").toBool() && !recording_) { - // If we're looping, jump to the other side of the workarea and continue - time_to_set = (tripped_time == min_time) ? max_time : min_time; + // If we're looping, jump back to the start of the range and continue + time_to_set = min_time; // Signal to restart playback after the pause signalled by `end_of_line` play_after_pause = true; } else { // Pause at the boundary we tripped - time_to_set = tripped_time; + time_to_set = max_time; } } else { @@ -2031,6 +1711,13 @@ void ViewerWidget::playback_timer_update() time_changed_from_timer_ = true; get_connected_node()->set_playhead(time_to_set); time_changed_from_timer_ = false; + + // Feed the display clocks and purge consumed queue entries. + foreach (ViewerDisplayWidget *dw, playback_devices_) { + dw->set_playback_timestamp(pos_ts); + dw->queue()->purge_before(current_time, playback_speed_); + } + if (end_of_line) { // Cache the current speed int current_speed = playback_speed_; @@ -2040,20 +1727,6 @@ void ViewerWidget::playback_timer_update() play_internal(current_speed, play_in_to_out_only_); } } - - if (is_playing() && is_video_visible()) { - while ((int(display_widget_->queue()->size()) + - queue_watchers_.size()) < determine_playback_queue_size()) { - if (!request_next_frame_for_queue()) { - // Prevent infinite loop - break; - } - } - } - - foreach (ViewerDisplayWidget *dw, playback_devices_) { - dw->queue()->purge_before(current_time, playback_speed_); - } } void ViewerWidget::set_viewer_resolution(int width, int height) diff --git a/app/widget/viewer/viewer.h b/app/widget/viewer/viewer.h index c7a939b08..01286044f 100644 --- a/app/widget/viewer/viewer.h +++ b/app/widget/viewer/viewer.h @@ -32,7 +32,7 @@ #include "audio/audioprocessor.h" #include "audiowaveformview.h" #include "node/output/viewer/viewer.h" -#include "render/previewaudiodevice.h" +#include "oakengine/playback.h" #include "render/previewautocacher.h" #include "viewerdisplay.h" #include "viewersizer.h" @@ -251,14 +251,8 @@ private: void set_display_image(RenderTicketPtr ticket); - RenderTicketWatcher *request_next_frame_for_queue(bool increment = true); - RenderTicketPtr get_frame(const Rational &t); - void finish_play_preprocess(); - - int determine_playback_queue_size(); - static FramePtr decode_cached_image(const QString &cache_path, const QUuid &cache_id, const int64_t &time); @@ -273,8 +267,6 @@ private: void update_auto_cacher(); - void decrement_prequeued_audio(); - void arm_for_recording(); void disarm_recording(); @@ -285,6 +277,15 @@ private: bool is_video_visible() const; + void deliver_facade_frame(const FramePtr &frame, const Rational &ts); + + // Trampolines for the facade playback engine's pull-thread + // callbacks; both marshal onto the main thread. + static void facade_frame_callback(const oak_playback_frame *frame, + void *userdata); + static void facade_audio_callback(const oak_playback_audio *audio, + void *userdata); + ViewerSizer *sizer_; int playback_speed_; @@ -305,30 +306,21 @@ private: ViewerDisplayWidget *context_menu_widget_; - QTimer playback_backup_timer_; + // Facade playback session (created on first play; the engine drives + // frames/audio from its pull thread and we poll its position). + OakEnginePlayback *playback_; + QTimer playback_poll_timer_; + + // Sample-rate/channel converter for the audio SCRUB path (continuous + // playback audio is handled by the facade engine itself). + AudioProcessor audio_processor_; - int64_t playback_queue_next_frame_; - int64_t dry_run_next_frame_; QVector playback_devices_; - bool prequeuing_video_; - int prequeuing_audio_; - QList nonqueue_watchers_; Rational last_length_; - int prequeue_length_; - int prequeue_count_; - - QVector queue_watchers_; - - std::list audio_playback_queue_; - Rational audio_playback_queue_time_; - AudioProcessor audio_processor_; - QByteArray prequeued_audio_; - static const Rational k_audio_playback_interval; - static QVector instances; std::list audio_scrub_watchers_; @@ -340,15 +332,10 @@ private: Track::Reference recording_track_; QString recording_filename_; - qint64 queue_starved_start_; - RenderTicketWatcher *first_requeue_watcher_; - bool enable_audio_scrubbing_; WaveformMode waveform_mode_; - QVector dry_run_watchers_; - int ignore_scrub_; QVector timeline_selected_blocks_; @@ -357,7 +344,7 @@ private: MulticamWidget *multicam_panel_; private slots: - void playback_timer_update(); + void playback_poll_update(); void length_changed_slot(const Rational &length); @@ -387,8 +374,6 @@ private slots: void renderer_generated_frame(); - void renderer_generated_frame_for_queue(); - void viewer_invalidated_video_range(const olive::TimeRange &range); void update_waveform_mode_from_menu(QAction *a); @@ -397,30 +382,14 @@ private slots: void dropped(QDropEvent *event); - void queue_next_audio_buffer(); - - void received_audio_buffer_for_playback(); - void received_audio_buffer_for_scrubbing(); - void queue_starved(); - void queue_no_longer_starved(); - - void force_requeue_from_current_time(); - void force_requeue_from_current_time_internal(); - void update_audio_processor(); void create_addable_at(const QRectF &f); - void handle_first_requeue_destroy(); - void show_subtitle_properties(); - void dry_run_finished(); - - void request_next_dry_run(); - void save_frame_as_image(); void detect_multicam_node_now(); diff --git a/app/widget/viewer/viewerdisplay.cpp b/app/widget/viewer/viewerdisplay.cpp index 9d763b7bf..636f8d082 100644 --- a/app/widget/viewer/viewerdisplay.cpp +++ b/app/widget/viewer/viewerdisplay.cpp @@ -35,7 +35,6 @@ #include #include -#include "audio/audiomanager.h" #include "common/define.h" #include "common/html.h" #include "common/qtutils.h" @@ -1633,8 +1632,10 @@ void ViewerDisplayWidget::play(const int64_t &start_timestamp, playback_timebase_ = timebase; playback_speed_ = playback_speed; - timer_.start(start_timestamp, playback_speed, timebase.to_double(), - AudioManager::instance()); + // The facade playback engine owns the master clock; seed the display + // clock with the start timestamp (the ViewerWidget keeps feeding it + // from oakengine_playback_get_position afterwards). + external_ts_.store(start_timestamp); if (start_updating) { connect(this, &ViewerDisplayWidget::frame_swapped, this, @@ -1649,6 +1650,8 @@ void ViewerDisplayWidget::pause() disconnect(this, &ViewerDisplayWidget::frame_swapped, this, &ViewerDisplayWidget::update_from_queue); + external_ts_.store(-1); + queue_.clear(); queue_starved_ = false; } @@ -1664,7 +1667,11 @@ QPointF ViewerDisplayWidget::screen_to_scene_point(const QPoint &p) void ViewerDisplayWidget::update_from_queue() { - int64_t t = timer_.get_timestamp_now(); + const int64_t t = external_ts_.load(); + if (t < 0) { + // No facade playback position fed yet. + return; + } Rational time = Timecode::timestamp_to_time(t, playback_timebase_); diff --git a/app/widget/viewer/viewerdisplay.h b/app/widget/viewer/viewerdisplay.h index 6a0d0161f..3b9fc5303 100644 --- a/app/widget/viewer/viewerdisplay.h +++ b/app/widget/viewer/viewerdisplay.h @@ -22,6 +22,8 @@ #ifndef OAK_VIEWERGLWIDGET_H #define OAK_VIEWERGLWIDGET_H +#include + #include #include #include @@ -33,7 +35,6 @@ #include "node/output/track/tracklist.h" #include "node/traverser.h" #include "tool/tool.h" -#include "viewerplaybacktimer.h" #include "viewerqueue.h" #include "viewersafemargininfo.h" #include "viewertexteditor.h" @@ -154,9 +155,16 @@ public: return &queue_; } - ViewerPlaybackTimer *timer() + /** + * @brief Feed the facade playback position as this display's clock + * + * The facade playback engine owns the master clock now; the + * ViewerWidget polls it (oakengine_playback_get_position) and pushes + * the timestamp here for update_from_queue() to pop by. + */ + void set_playback_timestamp(int64_t ts) { - return &timer_; + external_ts_.store(ts); } QPointF screen_to_scene_point(const QPoint &p); @@ -461,7 +469,7 @@ private: // Playback ViewerQueue queue_; - ViewerPlaybackTimer timer_; + std::atomic external_ts_{ -1 }; Rational playback_timebase_; diff --git a/app/widget/viewer/viewerplaybacktimer.cpp b/app/widget/viewer/viewerplaybacktimer.cpp deleted file mode 100644 index 22490df7d..000000000 --- a/app/widget/viewer/viewerplaybacktimer.cpp +++ /dev/null @@ -1,69 +0,0 @@ -/*** - - Olive - Non-Linear Video Editor - Copyright (C) 2022 Olive Team - Modifications Copyright (C) 2025 mikesolar - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -***/ - -#include "viewerplaybacktimer.h" - -#include - -namespace olive -{ - -void ViewerPlaybackTimer::start(const int64_t &start_timestamp, - const int &playback_speed, - const double &timebase, - const PlaybackAudioClock *audio_clock) -{ - timer_.start(); - start_timestamp_ = start_timestamp; - playback_speed_ = playback_speed; - timebase_ = timebase * 1000; - audio_clock_ = audio_clock; -} - -int64_t ViewerPlaybackTimer::get_timestamp_now() const -{ - // The audio output clock is the master clock when available: sound card - // consumption is what the viewer must stay in sync with, and unlike the - // wall clock it cannot drift away from what is actually heard - if (audio_clock_) { - const double audio_seconds = audio_clock_->seconds(); - if (audio_seconds >= 0.0) { - // At speeds other than 1x the audio tempo is scaled, so one - // output second corresponds to |speed| timeline seconds. The - // result is already in timeline frames, so it is applied - // signed rather than multiplied by the speed again. - const double timeline_ms = - audio_seconds * 1000.0 * qAbs(playback_speed_); - const int64_t frames_since_start = qFloor(timeline_ms / timebase_); - return start_timestamp_ + - frames_since_start * (playback_speed_ < 0 ? -1 : 1); - } - } - - int64_t real_time = timer_.elapsed(); - - int64_t frames_since_start = - qFloor(static_cast(real_time) / (timebase_)); - - return start_timestamp_ + frames_since_start * playback_speed_; -} - -} diff --git a/app/widget/viewer/viewerplaybacktimer.h b/app/widget/viewer/viewerplaybacktimer.h deleted file mode 100644 index 212b39c89..000000000 --- a/app/widget/viewer/viewerplaybacktimer.h +++ /dev/null @@ -1,55 +0,0 @@ -/*** - - Olive - Non-Linear Video Editor - Copyright (C) 2022 Olive Team - Modifications Copyright (C) 2025 mikesolar - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -***/ - -#ifndef OAK_VIEWERPLAYBACKTIMER_H -#define OAK_VIEWERPLAYBACKTIMER_H - -#include -#include - -#include "common/define.h" -#include "common/playbackaudioclock.h" - -namespace olive -{ - -class ViewerPlaybackTimer { -public: - void start(const int64_t &start_timestamp, const int &playback_speed, - const double &timebase, - const PlaybackAudioClock *audio_clock = nullptr); - - int64_t get_timestamp_now() const; - -private: - QElapsedTimer timer_; - int64_t start_timestamp_; - - int playback_speed_; - - double timebase_; - - const PlaybackAudioClock *audio_clock_ = nullptr; -}; - -} - -#endif // OAK_VIEWERPLAYBACKTIMER_H diff --git a/engine/src/capi/playback.cpp b/engine/src/capi/playback.cpp index 398f32784..532ea6389 100644 --- a/engine/src/capi/playback.cpp +++ b/engine/src/capi/playback.cpp @@ -282,6 +282,9 @@ void pull_loop(OakEnginePlaybackState *state) state->paused_ts.store(end_ts); state->last_start_ts.store(end_ts); state->state.store(k_state_stopped); + if (olive::AudioManager::instance()) { + olive::AudioManager::instance()->stop_output(); + } break; } @@ -307,6 +310,11 @@ void stop_and_join(OakEnginePlaybackState *state) state->pull_thread.join(); } } + // Do not leave buffered audio playing out after a pause/stop (an + // output stream stays active and audible otherwise). + if (olive::AudioManager::instance()) { + olive::AudioManager::instance()->stop_output(); + } } } // namespace @@ -424,8 +432,10 @@ int oakengine_playback_start(OakEnginePlayback *self, int64_t start_ts, state->next_audio_time.store(start_ts * state->time_base.to_double()); state->wall_timer.restart(); if (olive::AudioManager::instance()) { - // Restart the master clock at zero for this run (the viewer does - // the same in finish_play_preprocess()). + // Flush whatever a previous run left in the output, then restart + // the master clock at zero for this run (the viewer did the same + // in finish_play_preprocess()). + olive::AudioManager::instance()->clear_buffered_output(); olive::AudioManager::instance()->reset_output_clock(); } state->anchor_clock.store(master_clock_seconds(state)); @@ -453,6 +463,10 @@ int oakengine_playback_pause(OakEnginePlayback *self) if (state->renderer) { oakengine_renderer_cancel(state->renderer); } + // Do not leave buffered audio playing out while paused. + if (olive::AudioManager::instance()) { + olive::AudioManager::instance()->stop_output(); + } } return OAKENGINE_OK; } diff --git a/tests/gtest/VIEWER_SMOKE_TESTS.md b/tests/gtest/VIEWER_SMOKE_TESTS.md index b01cc4df4..de9e39629 100644 --- a/tests/gtest/VIEWER_SMOKE_TESTS.md +++ b/tests/gtest/VIEWER_SMOKE_TESTS.md @@ -152,7 +152,7 @@ These tests depend on: The viewer subsystem has the following thread safety characteristics: -1. **ViewerPlaybackTimer**: Thread-safe for concurrent reads, but Start() should not be called concurrently with GetTimestampNow() +1. **ViewerPlaybackTimer**: removed when playback moved to the facade playback engine (the facade owns the master clock now) 2. **ViewerQueue**: NOT thread-safe - requires external synchronization for concurrent modifications 3. **ViewerSafeMarginInfo**: Thread-safe for read-only access after construction 4. **AudioPlaybackCache**: Uses internal locking for thread safety diff --git a/tests/gtest/viewer_smoke_test.cpp b/tests/gtest/viewer_smoke_test.cpp index 044b15b32..c272720c2 100644 --- a/tests/gtest/viewer_smoke_test.cpp +++ b/tests/gtest/viewer_smoke_test.cpp @@ -3,7 +3,6 @@ * Copyright (C) 2025 Olive CE Team * * Comprehensive smoke tests for the viewer and preview display subsystem including: - * - ViewerPlaybackTimer timing calculations * - ViewerQueue frame management * - ViewerSafeMarginInfo safety margin calculations * - PreviewAutoCacher cache management @@ -13,11 +12,9 @@ #include #include -#include #include // Viewer headers -#include "widget/viewer/viewerplaybacktimer.h" #include "widget/viewer/viewerqueue.h" #include "widget/viewer/viewersafemargininfo.h" #include "codec/conformmanager.h" @@ -39,167 +36,6 @@ namespace viewer namespace test { -// ============================================================================ -// Smoke Test: ViewerPlaybackTimer -// ============================================================================ - -TEST(ViewerSmokeTimer, DefaultConstruction) -{ - ViewerPlaybackTimer timer; - - // After Start() is called, the timer must return valid timestamps - timer.start(0, 1, 1.0 / 24.0); - EXPECT_GE(timer.get_timestamp_now(), 0); -} - -TEST(ViewerSmokeTimer, BasicTiming) -{ - ViewerPlaybackTimer timer; - - // Start at timestamp 0, 1x speed, 24fps (timebase = 1/24) - timer.start(0, 1, 1.0 / 24.0); - - // Immediately get timestamp (should be close to 0) - int64_t ts = timer.get_timestamp_now(); - EXPECT_GE(ts, 0); - - // Wait a bit and check timestamp has increased - QThread::msleep(50); // 50ms - int64_t ts2 = timer.get_timestamp_now(); - - // At 24fps, 50ms is more than one frame period (~41.7ms), so the - // timestamp must have advanced by at least one frame - EXPECT_GT(ts2, ts); -} - -TEST(ViewerSmokeTimer, PlaybackSpeedForward) -{ - ViewerPlaybackTimer timer; - - // Start at timestamp 100, 2x speed, 30fps - timer.start(100, 2, 1.0 / 30.0); - - int64_t ts1 = timer.get_timestamp_now(); - QThread::msleep(50); - int64_t ts2 = timer.get_timestamp_now(); - - // At 2x speed, time should advance twice as fast - EXPECT_GT(ts2, ts1); -} - -TEST(ViewerSmokeTimer, PlaybackSpeedReverse) -{ - ViewerPlaybackTimer timer; - - // Start at timestamp 1000, -1x speed (reverse), 24fps - timer.start(1000, -1, 1.0 / 24.0); - - int64_t ts1 = timer.get_timestamp_now(); - QThread::msleep(50); - int64_t ts2 = timer.get_timestamp_now(); - - // In reverse, timestamp should decrease - EXPECT_LT(ts2, ts1); -} - -TEST(ViewerSmokeTimer, DifferentTimebases) -{ - // Frame counts track wall time at each timebase's rate. Measure the - // actual interval so scheduling jitter on loaded CI runners (observed on - // macOS, where msleep(100) overslept ~2.5x) can't break the comparison. - for (double fps : { 24.0, 60.0 }) { - ViewerPlaybackTimer timer; - QElapsedTimer wall; - timer.start(0, 1, 1.0 / fps); - wall.start(); - QThread::msleep(100); - - const int64_t ts = timer.get_timestamp_now(); - const int64_t expected = - qFloor(static_cast(wall.elapsed()) / (1000.0 / fps)); - EXPECT_NEAR(ts, expected, 1) << "fps=" << fps; - } - - // With highly distinct timebases the faster one always produces more - // frames in the same interval, even on heavily loaded machines - ViewerPlaybackTimer slow, fast; - slow.start(0, 1, 1.0); - fast.start(0, 1, 1.0 / 240.0); - QThread::msleep(100); - - EXPECT_LT(slow.get_timestamp_now(), fast.get_timestamp_now()); -} - -TEST(ViewerSmokeTimer, ZeroSpeed) -{ - ViewerPlaybackTimer timer; - - // Start with 0 speed (paused) - timer.start(500, 0, 1.0 / 24.0); - - int64_t ts1 = timer.get_timestamp_now(); - QThread::msleep(50); - int64_t ts2 = timer.get_timestamp_now(); - - // With 0 speed, timestamp should not change - EXPECT_EQ(ts1, ts2); -} - -class FakeAudioClock : public PlaybackAudioClock { -public: - virtual double seconds() const override - { - return seconds_; - } - - double seconds_ = -1.0; -}; - -TEST(ViewerSmokeTimer, AudioClockDrivesTimestamp) -{ - FakeAudioClock clock; - ViewerPlaybackTimer timer; - - // Start at timestamp 100, 1x speed, 32fps (exact in floating point) - timer.start(100, 1, 1.0 / 32.0, &clock); - - // One second of consumed audio = 32 frames, regardless of wall time - clock.seconds_ = 1.0; - EXPECT_EQ(timer.get_timestamp_now(), 132); - - // The audio clock fully overrides the wall clock (no sleep involved) - clock.seconds_ = 0.5; - EXPECT_EQ(timer.get_timestamp_now(), 116); -} - -TEST(ViewerSmokeTimer, AudioClockScalesWithPlaybackSpeed) -{ - FakeAudioClock clock; - ViewerPlaybackTimer timer; - - // At 2x speed one output second is two timeline seconds - timer.start(0, 2, 1.0 / 32.0, &clock); - clock.seconds_ = 0.5; - EXPECT_EQ(timer.get_timestamp_now(), 32); - - // In reverse the playhead moves backward at |speed| - timer.start(100, -2, 1.0 / 32.0, &clock); - clock.seconds_ = 1.0; - EXPECT_EQ(timer.get_timestamp_now(), 36); -} - -TEST(ViewerSmokeTimer, InvalidAudioClockFallsBackToWallClock) -{ - FakeAudioClock clock; // seconds() returns -1: no clocked output running - ViewerPlaybackTimer timer; - - timer.start(0, 1, 1.0 / 24.0, &clock); - EXPECT_GE(timer.get_timestamp_now(), 0); - - QThread::msleep(50); // 50ms > one 24fps frame period - EXPECT_GT(timer.get_timestamp_now(), 0); -} - // ============================================================================ // Smoke Test: ViewerQueue // ============================================================================ @@ -615,41 +451,6 @@ TEST(ViewerSmokeRational, Flipped) // Smoke Test: Thread Safety // ============================================================================ -TEST(ViewerSmokeThread, ConcurrentTimerAccess) -{ - const int num_threads = 4; - const int num_iterations = 100; - - ViewerPlaybackTimer timer; - timer.start(0, 1, 1.0 / 30.0); - - std::vector threads; - std::atomic monotonic_violations{ 0 }; - - // Each thread reads the timer repeatedly; because playback is forward, a - // thread must never observe a timestamp smaller than the one it read before - for (int t = 0; t < num_threads; ++t) { - threads.emplace_back([&timer, &monotonic_violations, num_iterations]() { - int64_t previous = 0; - for (int i = 0; i < num_iterations; ++i) { - const int64_t ts = timer.get_timestamp_now(); - if (ts < previous) { - monotonic_violations++; - } - previous = ts; - } - }); - } - - for (auto &t : threads) { - t.join(); - } - - EXPECT_EQ(monotonic_violations.load(), 0); - // The threads ran long enough that the timer must have advanced at all - EXPECT_GE(timer.get_timestamp_now(), 0); -} - TEST(ViewerSmokeThread, ConcurrentQueueAccess) { const int num_threads = 4; @@ -684,39 +485,6 @@ TEST(ViewerSmokeThread, ConcurrentQueueAccess) // Smoke Test: Integration Scenarios // ============================================================================ -TEST(ViewerSmokeIntegration, PlaybackSequenceSimulation) -{ - // Simulate a basic playback sequence - ViewerPlaybackTimer timer; - ViewerQueue queue; - - // Start playback at frame 0, 24fps; timestamps are expressed in frames - timer.start(0, 1, 1.0 / 24.0); - - // Queue some frames - for (int i = 0; i < 10; i++) { - ViewerPlaybackFrame frame{ Rational(i, 24), QVariant(i) }; - queue.append_timewise(frame, 1); - } - - // Get current timestamp (in frames) and convert it to a time in seconds - const int64_t current_ts = timer.get_timestamp_now(); - const Rational current_time(current_ts, 24); - - // Find the first queued frame at or after the current playback time - bool found = false; - for (const auto &frame : queue) { - if (frame.timestamp >= current_time) { - found = true; - break; - } - } - - // Playback just started at frame 0 and the queue holds frames 0-9, so a - // current-or-future frame must be available - EXPECT_TRUE(found); -} - TEST(ViewerSmokeIntegration, SafeMarginWithDifferentAspectRatios) { // Test safe margins for different aspect ratios @@ -730,29 +498,6 @@ TEST(ViewerSmokeIntegration, SafeMarginWithDifferentAspectRatios) } } -TEST(ViewerSmokeIntegration, ReversePlaybackScenario) -{ - ViewerPlaybackTimer timer; - ViewerQueue queue; - - // Start reverse playback from frame 100 - timer.start(100, -1, 1.0 / 24.0); - - // Queue frames in reverse order - for (int i = 100; i >= 90; i--) { - ViewerPlaybackFrame frame{ Rational(i, 24), QVariant(i) }; - queue.append_timewise(frame, -1); - } - - // Get timestamps - should decrease - int64_t ts1 = timer.get_timestamp_now(); - QThread::msleep(50); - int64_t ts2 = timer.get_timestamp_now(); - - EXPECT_LT(ts2, ts1); - EXPECT_EQ(queue.front().timestamp, Rational(100, 24)); -} - } // namespace test } // namespace viewer } // namespace olive