Files
Mike-Solar a59c33715f fix: node graph edge display, teardown crashes, and event/audio lifetime bugs
- capi: oakengine_node_output_connection_at/_at_ex returned the source
  node as the connection destination; the actual destination is
  conn.second.node(). Out-edge enumeration was useless, so the node
  view could only draw in-edges and randomly lost whichever edges
  needed the out-edge path (random per context build order).
  Regression test in oakengine_node_test
- project teardown: Project::clear() pre-notifies node removal while
  nodes are fully constructed (observers used to crash on
  half-destroyed nodes); childEvent suppresses the removal dance while
  clearing; Node::disconnect_all/disconnect_edge get a silent mode for
  teardown so no invalidation/events touch dying members
  (is_being_cleared); ClipBlock marker disconnect guarded against
  dead viewer/markers; ProjectCopier and PreviewAutoCacher drop
  project references on Project::destroyed instead of disconnecting
  dead objects at shutdown
- preview: add oakengine_preview_request_get_audio_sample_count; the
  viewer queried sample count by passing nullptr to get_audio_samples
  which rejects it, so all playback audio was silently dropped
- app: fix unterminated input-id memcpy in ResolveGroupInput
  (nodeparamviewitem, widgetbridge) that corrupted every parameter id
- app: unsubscribe raw C-API event subscriptions in destructors of
  NodeParamViewKeyframeControl, NodeParamViewConnectedLabel and
  ExportDialog; playhead events used to fire into dead widgets
  (crash when dragging the playhead)
- tests: preview request roundtrip (video frame + audio range) and
  free-while-active teardown coverage; env-gated OAK_DEBUG_EDGES /
  OAK_DEBUG_INVALID_INPUT diagnostics
- docs: investigation notes in docs/zh/
2026-08-02 14:29:49 +08:00

512 lines
15 KiB
C++

/*
* Oak Video Editor - Non-Linear Video Editor
* Copyright (C) 2025 Olive CE Team
*
* 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 "nodeviewcontext.h"
#include <QBrush>
#include "oakengine/node.h"
#include <QCoreApplication>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <QPen>
#include <QStyleOptionGraphicsItem>
#include "core.h"
#include "common/trackreferencehandle.h"
#include "nodeviewitem.h"
#include "common/colorcodingapp.h"
#include "oakengine/timeline.h"
#include "oakutil/qtutils.h"
#include "widget/timelinewidget/cliphandle.h"
#include "widget/viewer/vieweroutpututils.h"
namespace olive
{
#define super QGraphicsRectItem
NodeViewContext::NodeViewContext(oak::Node context, QGraphicsItem *item)
: super(item)
, context_(context)
, bridge_(new EngineEventBridge(this))
{
// Block contexts (clips/transitions nested in the node graph) show their
// track placement in the title. All engine queries go through the C ABI:
// oakengine_node_is_block() replaces dynamic_cast<Block*> (Block is
// abstract and carries no own type id), and the track type ordinals are
// the TrackReference mirror values (== engine Track::Type, pinned by the
// static_asserts in trackreferencehandle.h).
OakEngineNode *track_node =
oakengine_node_is_block(context_.handle()) ?
block_track_handle(
reinterpret_cast<OakEngineBlock *>(context_.handle())) :
nullptr;
OakEngineNode *track_seq =
track_node ? oakengine_track_get_sequence(track_node) : nullptr;
if (track_seq) {
Rational timebase = viewer_output_video_params(track_seq)
.frame_rate_as_time_base();
QString type_label;
switch (oakengine_track_get_type(track_node)) {
case TrackReference::k_video:
type_label = QCoreApplication::translate("NodeViewContext", "V");
break;
case TrackReference::k_audio:
type_label = QCoreApplication::translate("NodeViewContext", "A");
break;
case TrackReference::k_subtitle:
type_label = QCoreApplication::translate("NodeViewContext", "S");
break;
default:
break;
}
int in_num = 0, in_den = 1, out_num = 0, out_den = 1;
oakengine_block_get_in_rational(context_.handle(), &in_num, &in_den);
oakengine_block_get_out_rational(context_.handle(), &out_num, &out_den);
lbl_ =
QCoreApplication::translate("NodeViewContext", "%1 [%2] :: %3 - %4")
.arg(context_.label_and_name(),
type_label,
QString::fromStdString(Timecode::time_to_timecode(
Rational(in_num, in_den), timebase,
Core::instance()->get_timecode_display())),
QString::fromStdString(Timecode::time_to_timecode(
Rational(out_num, out_den), timebase,
Core::instance()->get_timecode_display())));
} else {
lbl_ = context_.label_and_name();
}
connect(bridge_, &EngineEventBridge::node_node_added_to_context, this,
[this](OakEngineNode *source, OakEngineNode *node) {
if (source == context_.handle()) {
add_child(node);
} else {
group_added_node(node, source);
}
});
connect(bridge_, &EngineEventBridge::node_node_removed_from_context, this,
[this](OakEngineNode *source, OakEngineNode *node) {
if (source == context_.handle()) {
remove_child(node);
} else {
group_removed_node(node, source);
}
});
connect(bridge_, &EngineEventBridge::node_context_position_changed, this,
[this](OakEngineNode *, OakEngineNode *node, double x, double y) {
set_child_position(node, QPointF(x, y));
});
connect(bridge_, &EngineEventBridge::node_input_connected, this,
[this](OakEngineNode *source, OakEngineNode *output,
const QString &input, int element) {
child_input_connected(output,
oak::Input(source, input, element));
});
connect(bridge_, &EngineEventBridge::node_input_disconnected, this,
[this](OakEngineNode *source, OakEngineNode *output,
const QString &input, int element) {
child_input_disconnected(output,
oak::Input(source, input, element));
});
node_subs_[context_.handle()].append(bridge_->subscribe(
context_.handle(),
OAKENGINE_EVENT_NODE_NODE_ADDED_TO_CONTEXT));
node_subs_[context_.handle()].append(bridge_->subscribe(
context_.handle(),
OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED));
node_subs_[context_.handle()].append(bridge_->subscribe(
context_.handle(),
OAKENGINE_EVENT_NODE_NODE_REMOVED_FROM_CONTEXT));
const int ctx_count = context_.context_node_count();
for (int i = 0; i < ctx_count; i++) {
oak::Node child = context_.context_node_at(i).node;
if (!child.is_null()) {
add_child(child.handle());
}
}
}
NodeViewContext::~NodeViewContext()
{
// Delete edges before items, because the edge constructor references the items
qDeleteAll(edges_);
edges_.clear();
}
void NodeViewContext::add_child(OakEngineNode *node)
{
if (context_.is_null()) {
return;
}
NodeViewItem *item = new NodeViewItem(oak::Node(node), context_, this);
item->set_flow_direction(flow_dir_);
add_node_internal(node, item);
oak::Node group_node(node);
if (group_node.is_group()) {
const int grp_count = group_node.context_node_count();
for (int i = 0; i < grp_count; i++) {
oak::Node grp_child = group_node.context_node_at(i).node;
if (!grp_child.is_null()) {
// Use this item as the representative for all of these nodes too
add_node_internal(grp_child.handle(), item);
}
}
node_subs_[node].append(bridge_->subscribe(
reinterpret_cast<void *>(node),
OAKENGINE_EVENT_NODE_NODE_ADDED_TO_CONTEXT));
node_subs_[node].append(bridge_->subscribe(
reinterpret_cast<void *>(node),
OAKENGINE_EVENT_NODE_NODE_REMOVED_FROM_CONTEXT));
}
update_rect();
}
void NodeViewContext::set_child_position(OakEngineNode *node, const QPointF &pos)
{
item_map_.value(node)->set_node_position(pos);
}
void NodeViewContext::remove_child(OakEngineNode *node)
{
foreach (int64_t id, node_subs_.take(node)) {
bridge_->unsubscribe(id);
}
NodeViewItem *item = item_map_.take(node);
// Remove from scene before emitting signal so that any drag functions that might be happening
// now can be handled before the item is destroyed
scene()->removeItem(item);
emit item_about_to_be_deleted(item);
// Delete edges first because the edge destructor will try to reference item (maybe that should
// be changed...)
QVector<NodeViewEdge *> edges_to_remove = item->get_all_edges_recursively();
foreach (NodeViewEdge *edge, edges_to_remove) {
if (item->get_node() == oak::Node(node) || edge->output() == oak::Node(node) ||
edge->input().node_handle() == node) {
child_input_disconnected(edge->output().handle(), edge->input());
}
}
// Check if this item is specifically for this node and the node is a group. If so, remove it for
// all other entries in the map.
if (item->get_node() == oak::Node(node)) {
if (item->get_node().is_group()) {
for (auto it = item_map_.begin(); it != item_map_.end();) {
if (it.value() == item) {
it = item_map_.erase(it);
} else {
it++;
}
}
}
delete item;
}
update_rect();
}
void NodeViewContext::child_input_connected(OakEngineNode *output, const oak::Input &input)
{
// Add edge
if (!input.is_hidden()) {
if (NodeViewItem *output_item = item_map_.value(output)) {
add_edge_internal(
output, input, output_item,
item_map_.value(input.node_handle())->get_item_for_input(input));
}
}
}
bool NodeViewContext::child_input_disconnected(OakEngineNode *output,
const oak::Input &input)
{
// Remove edge
for (int i = 0; i < edges_.size(); i++) {
NodeViewEdge *e = edges_.at(i);
if (e->output() == oak::Node(output) && e->input() == input) {
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: edge removed: %p -> %p (%s,%d) ctx=%p",
(void *)output, (void *)input.node_handle(),
qPrintable(input.input_id()), input.element(),
(void *)this);
}
delete e;
edges_.removeAt(i);
return true;
}
}
return false;
}
qreal get_text_offset(const QFontMetricsF &fm)
{
return fm.height() / 2;
}
void NodeViewContext::update_rect()
{
QFont f;
QFontMetricsF fm(f);
qreal lbl_offset = get_text_offset(fm);
QRectF cbr = childrenBoundingRect();
QRectF rect = cbr;
int pad = NodeViewItem::default_item_height();
rect.adjust(-pad, -lbl_offset * 2 - fm.height() - pad, pad, pad);
setRect(rect);
last_titlebar_height_ = rect.y() + (cbr.y() - rect.y()) - pad;
}
void NodeViewContext::set_flow_direction(NodeViewCommon::FlowDirection dir)
{
flow_dir_ = dir;
foreach (NodeViewItem *item, item_map_) {
item->set_flow_direction(dir);
}
}
void NodeViewContext::set_curved_edges(bool e)
{
curved_edges_ = e;
foreach (NodeViewEdge *edge, edges_) {
edge->set_curved(e);
}
}
void NodeViewContext::get_selected_for_deletion(QVector<OakEngineNode *> &nodes,
QVector<OakEngineNode *> &contexts,
QVector<NodeViewEdge *> &edges) const
{
// Collect any selected edges
foreach (NodeViewEdge *edge, edges_) {
if (edge->isSelected()) {
edges.append(edge);
}
}
// Collect any selected nodes
foreach (NodeViewItem *node, item_map_) {
if (node->isSelected()) {
nodes.append(node->get_node().handle());
contexts.append(context_.handle());
}
}
}
void NodeViewContext::select(const QVector<OakEngineNode *> &nodes)
{
foreach (OakEngineNode *n, nodes) {
if (NodeViewItem *item = item_map_.value(n)) {
item->setSelected(true);
}
}
}
QVector<NodeViewItem *> NodeViewContext::get_selected_items() const
{
QVector<NodeViewItem *> items;
for (auto it = item_map_.cbegin(); it != item_map_.cend(); it++) {
if (it.value()->isSelected()) {
if (!items.contains(it.value())) {
items.append(it.value());
}
}
}
return items;
}
QPointF NodeViewContext::map_scene_pos_to_node_pos_in_context(const QPointF &pos) const
{
for (auto it = item_map_.cbegin(); it != item_map_.cend(); it++) {
QPointF pos_inside_parent =
it.value()->mapToParent(it.value()->mapFromScene(pos));
return NodeViewItem::screen_to_node_point(pos_inside_parent, flow_dir_);
}
return QPointF(0, 0);
}
void NodeViewContext::paint(QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *widget)
{
// Set pen and brush
Color color = AppColorCoding::get_color(context_.effective_color_label());
QColor c = QtUtils::to_q_color(color);
QPen pen(c, 2);
if (option->state & QStyle::State_Selected) {
pen.setStyle(Qt::DotLine);
}
painter->setPen(pen);
QColor bg = c;
bg.setAlpha(128);
painter->setBrush(bg);
// Draw semi-transparent rect for whole item
int rounded = painter->fontMetrics().height();
painter->drawRoundedRect(rect(), rounded, rounded);
// Draw solid background for titlebar
QRectF titlebar_rect = rect();
titlebar_rect.setHeight(last_titlebar_height_ - rect().top());
painter->setClipRect(titlebar_rect);
painter->setBrush(c);
painter->drawRoundedRect(rect(), rounded, rounded);
painter->setClipping(false);
// Draw titlebar text
painter->setPen(AppColorCoding::get_ui_selector_color(color));
int offset = get_text_offset(painter->fontMetrics());
QRectF text_rect = rect();
text_rect.adjust(offset, offset, -offset, -offset);
painter->drawText(text_rect, lbl_);
}
QVariant NodeViewContext::itemChange(GraphicsItemChange change,
const QVariant &value)
{
return super::itemChange(change, value);
}
void NodeViewContext::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
bool clicked_inside_titlebar = (event->pos().y() < last_titlebar_height_);
setFlag(ItemIsMovable, clicked_inside_titlebar);
setFlag(ItemIsSelectable, clicked_inside_titlebar);
super::mousePressEvent(event);
}
void NodeViewContext::add_node_internal(OakEngineNode *node, NodeViewItem *item)
{
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: node added to ctx %p: %p", (void *)this,
(void *)node);
}
node_subs_[node].append(bridge_->subscribe(
reinterpret_cast<void *>(node),
OAKENGINE_EVENT_NODE_INPUT_CONNECTED));
node_subs_[node].append(bridge_->subscribe(
reinterpret_cast<void *>(node),
OAKENGINE_EVENT_NODE_INPUT_DISCONNECTED));
item_map_.insert(node, item);
if (node == context_.handle()) {
item->set_label_as_output(true);
}
// Iterate output connections via the wrapper
oak::Node wrapped(node);
const int out_count = wrapped.output_connection_count();
for (int i = 0; i < out_count; i++) {
oak::NodeConnection conn = wrapped.output_connection_at_ex(i);
if (!conn.hidden) {
if (NodeViewItem *other_item = item_map_.value(conn.node.handle())) {
oak::Input ai(conn.node.handle(), conn.input_id, conn.element);
add_edge_internal(node, ai, item,
other_item->get_item_for_input(ai));
} else if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: out-edge skipped (no item for %p): %p -> %p (%s) ctx=%p",
(void *)conn.node.handle(), (void *)node,
(void *)conn.node.handle(),
qPrintable(conn.input_id), (void *)this);
}
}
}
// Iterate input connections via the wrapper
const int in_count = wrapped.input_connection_count_all();
for (int i = 0; i < in_count; i++) {
oak::NodeConnection conn = wrapped.input_connection_at_all(i);
if (!conn.hidden) {
if (NodeViewItem *other_item = item_map_.value(conn.source_node.handle())) {
oak::Input ai(conn.node.handle(), conn.input_id, conn.element);
add_edge_internal(conn.source_node.handle(), ai, other_item,
item->get_item_for_input(ai));
} else if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: in-edge skipped (no item for %p): %p -> %p (%s) ctx=%p",
(void *)conn.source_node.handle(),
(void *)conn.source_node.handle(), (void *)node,
qPrintable(conn.input_id), (void *)this);
}
}
}
}
void NodeViewContext::add_edge_internal(OakEngineNode *output, const oak::Input &input,
NodeViewItem *from, NodeViewItem *to)
{
if (from == to) {
return;
}
if (qEnvironmentVariableIsSet("OAK_DEBUG_EDGES")) {
qWarning("EDGE-DEBUG: edge added: %p -> %p (%s,%d) ctx=%p", (void *)output,
(void *)input.node_handle(), qPrintable(input.input_id()),
input.element(), (void *)this);
}
NodeViewEdge *edge_ui = new NodeViewEdge(oak::Node(output), input, from, to, this);
edge_ui->adjust();
edge_ui->set_curved(curved_edges_);
edges_.append(edge_ui);
}
void NodeViewContext::group_added_node(OakEngineNode *node, OakEngineNode *group)
{
add_node_internal(node, item_map_.value(group));
}
void NodeViewContext::group_removed_node(OakEngineNode *node, OakEngineNode *group)
{
if (item_map_.value(node) == item_map_.value(group)) {
item_map_.remove(node);
}
}
}