app: migrate the viewer panel to the facade playback engine

This commit is contained in:
2026-07-20 18:55:04 +08:00
parent d63131cb12
commit 1384ad1e95
10 changed files with 215 additions and 925 deletions
-2
View File
@@ -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
+154 -481
View File
@@ -57,24 +57,14 @@ namespace olive
QVector<ViewerWidget *> 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<RenderTicketWatcher *>(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<SampleBuffer>();
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<RenderTicketWatcher *>(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<Rational>()) {
// 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);
}
}
}
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();
// 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<OakEngineSequence *>(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);
}
dry_run_next_frame_ = playback_queue_next_frame_;
request_next_dry_run();
}
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;
}
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();
}
}
// 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_);
}
// 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();
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<ViewerWidget *>(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<PixelFormat::Format>(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<const char *>(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<ViewerWidget *>(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<RenderTicketWatcher *>(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<double>(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<Rational>();
if (!drop_frame) {
foreach (ViewerDisplayWidget *dw, playback_devices_) {
const bool is_multicam =
dynamic_cast<MulticamDisplay *>(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)
+19 -50
View File
@@ -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<ViewerDisplayWidget *> playback_devices_;
bool prequeuing_video_;
int prequeuing_audio_;
QList<RenderTicketWatcher *> nonqueue_watchers_;
Rational last_length_;
int prequeue_length_;
int prequeue_count_;
QVector<RenderTicketWatcher *> queue_watchers_;
std::list<RenderTicketWatcher *> audio_playback_queue_;
Rational audio_playback_queue_time_;
AudioProcessor audio_processor_;
QByteArray prequeued_audio_;
static const Rational k_audio_playback_interval;
static QVector<ViewerWidget *> instances;
std::list<RenderTicketWatcher *> 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<RenderTicketWatcher *> dry_run_watchers_;
int ignore_scrub_;
QVector<Block *> 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();
+11 -4
View File
@@ -35,7 +35,6 @@
#include <QScreen>
#include <QTextEdit>
#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_);
+12 -4
View File
@@ -22,6 +22,8 @@
#ifndef OAK_VIEWERGLWIDGET_H
#define OAK_VIEWERGLWIDGET_H
#include <atomic>
#include <QImage>
#include <QMatrix4x4>
#include <QRubberBand>
@@ -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<int64_t> external_ts_{ -1 };
Rational playback_timebase_;
-69
View File
@@ -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 <http://www.gnu.org/licenses/>.
***/
#include "viewerplaybacktimer.h"
#include <QtMath>
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<double>(real_time) / (timebase_));
return start_timestamp_ + frames_since_start * playback_speed_;
}
}
-55
View File
@@ -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 <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_VIEWERPLAYBACKTIMER_H
#define OAK_VIEWERPLAYBACKTIMER_H
#include <QtGlobal>
#include <QElapsedTimer>
#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
+16 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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
-255
View File
@@ -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 <QCoreApplication>
#include <QThread>
#include <QElapsedTimer>
#include <QSignalSpy>
// 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<double>(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<std::thread> threads;
std::atomic<int> 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