/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
***/
#include "timelinewidget.h"
#include "timelinewidgetwaveformsync.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "oakengine/audio.h"
#include "core.h"
#include "engineeventbridge.h"
#include "common/range.h"
#include "dialog/proxy/proxydialog.h"
#include "dialog/sequence/sequence.h"
#include "dialog/speedduration/speeddurationdialog.h"
#include "node/block/transition/transition.h"
#include "node/project/footage/footage.h"
#include "oakengine/serializer.h"
#include "oakengine/undo.h"
#include "oakengine/events.h"
#include "oakengine/footage.h"
#include "oakengine/node.h"
#include "oakengine/project.h"
#include "oakengine/proxy.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include "render/audiowaveformcache.h"
#include "task/project/import/import.h"
#include "common/configwrapper.h"
#include "timeline/timelineundogeneral.h"
#include "timeline/timelineundopointer.h"
#include "timeline/timelineundoripple.h"
#include "tool/add.h"
#include "tool/beam.h"
#include "tool/edit.h"
#include "tool/pointer.h"
#include "tool/razor.h"
#include "tool/record.h"
#include "tool/ripple.h"
#include "tool/rolling.h"
#include "tool/slide.h"
#include "tool/slip.h"
#include "tool/trackselect.h"
#include "tool/transition.h"
#include "tool/zoom.h"
#include "tool/tool.h"
#include "trackview/trackview.h"
#include "widget/timelinewidget/cliphandle.h"
#include "widget/menu/menu.h"
#include "widget/menu/menushared.h"
#include "widget/nodeparamview/nodeparamview.h"
#include "widget/timeruler/timeruler.h"
#include "widget/toolbar/toolbar.h"
#include "widget/viewer/vieweroutpututils.h"
namespace olive
{
#define super TimeBasedWidget
using namespace timeline_waveform_sync;
namespace
{
struct SourceSyncClip {
ClipBlock *clip = nullptr;
oak_audio_sync_source_clip source;
Rational source_head;
};
bool get_source_sync_clip(Block *block, SourceSyncClip *out)
{
ClipBlock *clip = dynamic_cast(block);
if (!clip) {
return false;
}
Footage *footage = dynamic_cast(clip->connected_viewer());
if (!footage || !footage->has_source_start_time()) {
return false;
}
out->clip = clip;
const Rational source_start = footage->source_start_time();
const Rational media_in = clip_media_in(clip);
out->source.source_start_time_num = source_start.numerator();
out->source.source_start_time_den = source_start.denominator();
out->source.media_in_num = media_in.numerator();
out->source.media_in_den = media_in.denominator();
out->source.has_source_start_time = 1;
out->source_head = source_start + media_in;
return true;
}
QVector
get_selected_source_sync_clips(const QVector &blocks)
{
QVector clips;
for (Block *block : blocks) {
SourceSyncClip sync_clip;
if (get_source_sync_clip(block, &sync_clip)) {
clips.append(sync_clip);
}
}
return clips;
}
QVector get_selected_proxy_footage(const QVector &blocks)
{
QVector footage;
for (Block *block : blocks) {
ClipBlock *clip = dynamic_cast(block);
if (!clip) {
continue;
}
Footage *candidate = dynamic_cast(clip->connected_viewer());
oak_video_params _vp;
if (!candidate ||
oakengine_viewer_get_first_enabled_video_stream(
reinterpret_cast(candidate), &_vp) < 0 ||
!oakengine_video_params_is_valid(&_vp) ||
footage.contains(candidate)) {
continue;
}
footage.append(candidate);
}
return footage;
}
// Zoom slider <-> scale mapping (logarithmic: slider 0..1000 -> 0.1..10000 px/s)
constexpr double k_zoom_scale_min = 0.1;
constexpr double k_zoom_scale_max = 10000.0;
double zoom_slider_to_scale(int v)
{
return k_zoom_scale_min *
std::pow(k_zoom_scale_max / k_zoom_scale_min, v / 1000.0);
}
int scale_to_zoom_slider(double s)
{
if (s <= k_zoom_scale_min)
return 0;
if (s >= k_zoom_scale_max)
return 1000;
return qRound(1000.0 * std::log(s / k_zoom_scale_min) /
std::log(k_zoom_scale_max / k_zoom_scale_min));
}
} // namespace
TimelineWidget::TimelineWidget(QWidget *parent)
: super(true, true, parent)
, rubberband_(QRubberBand::Rectangle, this)
, active_tool_(nullptr)
, use_audio_time_units_(false)
, subtitle_show_command_(nullptr)
, subtitle_tentative_track_(nullptr)
{
QVBoxLayout *vert_layout = new QVBoxLayout(this);
vert_layout->setSpacing(0);
vert_layout->setContentsMargins(0, 0, 0, 0);
// Application toolbar row (31px) — replaces the dockable ToolPanel by
// default. The dockable ToolPanel remains available via the Window menu.
QHBoxLayout *toolbar_row = new QHBoxLayout();
toolbar_row->setContentsMargins(0, 0, 0, 0);
toolbar_row->setSpacing(4);
Toolbar *toolbar = new Toolbar(this);
toolbar->setFixedHeight(31);
toolbar->set_tool(Core::instance()->tool());
toolbar->set_snapping(Core::instance()->snapping());
toolbar_row->addWidget(toolbar);
connect(toolbar, &Toolbar::tool_changed, Core::instance(),
&Core::set_tool);
connect(Core::instance(), &Core::tool_changed, toolbar,
&Toolbar::set_tool);
connect(toolbar, &Toolbar::snapping_changed, Core::instance(),
&Core::set_snapping);
connect(Core::instance(), &Core::snapping_changed, toolbar,
&Toolbar::set_snapping);
connect(toolbar, &Toolbar::selected_transition_changed, Core::instance(),
&Core::set_selected_transition_object);
toolbar_row->addStretch();
// Zoom slider (logarithmic)
zoom_slider_ = new QSlider(Qt::Horizontal, this);
zoom_slider_->setRange(0, 1000);
zoom_slider_->setFixedWidth(120);
zoom_slider_->setToolTip(tr("Zoom"));
zoom_slider_->setValue(scale_to_zoom_slider(get_scale()));
toolbar_row->addWidget(zoom_slider_);
connect(zoom_slider_, &QSlider::valueChanged, this, [this](int v) {
set_scale(zoom_slider_to_scale(v));
});
// Track height slider
track_height_slider_ = new QSlider(Qt::Horizontal, this);
track_height_slider_->setRange(0, 8);
track_height_slider_->setFixedWidth(80);
track_height_slider_->setToolTip(tr("Track Height"));
track_height_slider_->setValue(3);
toolbar_row->addWidget(track_height_slider_);
connect(track_height_slider_, &QSlider::valueChanged, this, [this](int v) {
if (!get_connected_node()) {
return;
}
double h = oakengine_track_height_minimum() +
v * oakengine_track_height_interval();
foreach (Track *t, sequence()->get_tracks()) {
oakengine_track_set_height(
reinterpret_cast(sequence()), t->type(),
t->index(), h);
}
});
vert_layout->addLayout(toolbar_row);
QHBoxLayout *ruler_and_time_layout = new QHBoxLayout();
vert_layout->addLayout(ruler_and_time_layout);
timecode_label_ = new RationalSlider();
timecode_label_->set_alignment(Qt::AlignCenter);
timecode_label_->set_display_type(slider::k_time);
timecode_label_->setVisible(false);
timecode_label_->set_minimum(0);
ruler_and_time_layout->addWidget(timecode_label_);
ruler_and_time_layout->addWidget(ruler());
ruler()->setFocusPolicy(Qt::TabFocus);
QWidget::setTabOrder(ruler(), timecode_label_);
// Create list of TimelineViews - these MUST correspond to the ViewType enum
view_splitter_ = new QSplitter(Qt::Vertical);
vert_layout->addWidget(view_splitter_);
// Video view
views_.append(add_timeline_and_track_view(Qt::AlignBottom));
// Audio view
views_.append(add_timeline_and_track_view(Qt::AlignTop));
// Subtitle view
views_.append(add_timeline_and_track_view(Qt::AlignTop));
// Create tools
tools_.resize(olive::Tool::k_count);
tools_.fill(nullptr);
tools_.replace(olive::Tool::k_pointer, new PointerTool(this));
tools_.replace(olive::Tool::k_track_select, new TrackSelectTool(this));
tools_.replace(olive::Tool::k_edit, new EditTool(this));
tools_.replace(olive::Tool::k_ripple, new RippleTool(this));
tools_.replace(olive::Tool::k_rolling, new RollingTool(this));
tools_.replace(olive::Tool::k_razor, new RazorTool(this));
tools_.replace(olive::Tool::k_slip, new SlipTool(this));
tools_.replace(olive::Tool::k_slide, new SlideTool(this));
tools_.replace(olive::Tool::k_zoom, new ZoomTool(this));
tools_.replace(olive::Tool::k_transition, new TransitionTool(this));
tools_.replace(olive::Tool::k_record, new RecordTool(this));
tools_.replace(olive::Tool::k_add, new AddTool(this));
import_tool_ = new ImportTool(this);
// We add this to the list to make deleting all tools easier, it should never get accessed through the list under
// normal circumstances (but *technically* its index would be Tool::kCount)
tools_.append(import_tool_);
// Global scrollbar
connect(views_.first()->view()->horizontalScrollBar(),
&QScrollBar::rangeChanged, scrollbar(), &QScrollBar::setRange);
vert_layout->addWidget(scrollbar());
foreach (TimelineAndTrackView *tview, views_) {
TimelineView *view = tview->view();
view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
view->set_snap_service(this);
view->set_selection_list(&selections_);
view->set_ghost_list(&ghost_items_);
view_splitter_->addWidget(tview);
connect_timeline_view(view);
connect(view, &TimelineView::customContextMenuRequested, this,
&TimelineWidget::show_context_menu);
connect(view, &TimelineView::mouse_pressed, this,
&TimelineWidget::view_mouse_pressed);
connect(view, &TimelineView::mouse_moved, this,
&TimelineWidget::view_mouse_moved);
connect(view, &TimelineView::mouse_released, this,
&TimelineWidget::view_mouse_released);
connect(view, &TimelineView::mouse_double_clicked, this,
&TimelineWidget::view_mouse_double_clicked);
connect(view, &TimelineView::drag_entered, this,
&TimelineWidget::view_drag_entered);
connect(view, &TimelineView::drag_moved, this,
&TimelineWidget::view_drag_moved);
connect(view, &TimelineView::drag_left, this,
&TimelineWidget::view_drag_left);
connect(view, &TimelineView::drag_dropped, this,
&TimelineWidget::view_drag_dropped);
connect(tview->splitter(), &QSplitter::splitterMoved, this,
&TimelineWidget::update_horizontal_splitters);
}
// Split viewer 50/50
QList view_sizes;
view_sizes.reserve(views_.size());
view_sizes.append(height() / 2); // Video
view_sizes.append(height() / 2); // Audio
view_sizes.append(0); // Subtitle (hidden by default)
view_splitter_->setSizes(view_sizes);
// Video and audio are not collapsible, subtitle is
view_splitter_->setCollapsible(Track::k_video, false);
view_splitter_->setCollapsible(Track::k_audio, false);
view_splitter_->setCollapsible(Track::k_subtitle, true);
// FIXME: Magic number
SetScale(90.0);
set_auto_set_timebase(false);
connect(Core::instance(), &Core::tool_changed, this,
&TimelineWidget::tool_changed);
connect(Core::instance(), &Core::addable_object_changed, this,
&TimelineWidget::addable_object_changed);
signal_block_change_timer_ = new QTimer(this);
signal_block_change_timer_->setInterval(1);
signal_block_change_timer_->setSingleShot(true);
connect(signal_block_change_timer_, &QTimer::timeout, this, [this] {
signal_block_change_timer_->stop();
if (OAK_CONFIG("SelectAlsoSeeks").toBool()) {
Rational start = RATIONAL_MAX;
for (Block *b : selected_blocks_) {
start = std::min(start, b->in());
}
if (start != RATIONAL_MAX) {
oakengine_viewer_set_playhead(
reinterpret_cast(get_connected_node()),
start.numerator(), start.denominator());
}
}
QVector oak_blocks;
oak_blocks.reserve(selected_blocks_.size());
for (Block *b : selected_blocks_) {
oak_blocks.append(reinterpret_cast(b));
}
emit block_selection_changed(oak_blocks);
});
}
TimelineWidget::~TimelineWidget()
{
// Ensure no blocks are selected before any child widgets are destroyed (prevents corrupt ViewSelectionChanged() signal)
connect_viewer_node(nullptr);
clear();
qDeleteAll(tools_);
oakengine_undo_command_free(subtitle_show_command_);
}
void TimelineWidget::clear()
{
// Emit that we've deselected any selected blocks
signal_deselected_all_blocks();
// Set null timebase
SetTimebase(0);
}
void TimelineWidget::TimebaseChangedEvent(const Rational &timebase)
{
super::TimebaseChangedEvent(timebase);
timecode_label_->set_timebase(timebase);
timecode_label_->setVisible(!timebase.isNull());
update_view_timebases();
}
void TimelineWidget::resizeEvent(QResizeEvent *event)
{
super::resizeEvent(event);
// Update timecode label size
update_timecode_width_from_splitters(views_.first()->splitter());
}
void TimelineWidget::TimeChangedEvent(const Rational &t)
{
if (OAK_CONFIG("SeekAlsoSelects").toBool()) {
TimelineWidgetSelections sels;
QVector new_blocks;
for (auto it = sequence()->get_tracks().cbegin();
it != sequence()->get_tracks().cend(); it++) {
Track *track = *it;
if (track->is_locked()) {
continue;
}
Block *b = track->visible_block_at_time(sequence()->get_playhead());
if (!b || dynamic_cast(b)) {
continue;
}
new_blocks.push_back(b);
sels[track->to_reference()].insert(b->range());
}
if (selected_blocks_ != new_blocks) {
selected_blocks_ = new_blocks;
set_selections(sels, false);
signal_block_selection_change();
}
}
}
void TimelineWidget::ScaleChangedEvent(const double &scale)
{
super::ScaleChangedEvent(scale);
foreach (TimelineAndTrackView *view, views_) {
view->view()->set_scale(scale);
}
// Keep zoom slider in sync when scale changes elsewhere (ctrl+wheel etc.)
zoom_slider_->blockSignals(true);
zoom_slider_->setValue(scale_to_zoom_slider(scale));
zoom_slider_->blockSignals(false);
if (rubberband_.isVisible()) {
QMetaObject::invokeMethod(this, &TimelineWidget::force_update_rubber_band,
Qt::QueuedConnection);
}
}
void TimelineWidget::ConnectNodeEvent(ViewerOutput *n)
{
Sequence *s = static_cast(n);
OakEngineNode *handle = reinterpret_cast(s);
// Track add/remove are now received via bridge sequence_track_* signals
bridge_->subscribe(handle, OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED);
bridge_->subscribe(handle, OAKENGINE_EVENT_SEQUENCE_TRACK_REMOVED);
bridge_->subscribe(handle, OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED);
bridge_->subscribe(handle, OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED);
connect(bridge_, &EngineEventBridge::sequence_track_added, this,
[this](OakEngineTrack *track, int track_type) {
Track *t = reinterpret_cast