app: migrate the viewer panel to the facade playback engine
This commit is contained in:
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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_);
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user