mirror of
https://gitlab.freedesktop.org/gstreamer/gstreamer.git
synced 2024-11-10 03:19:40 +00:00
721 lines
21 KiB
Python
721 lines
21 KiB
Python
# -*- coding: utf-8; mode: python; -*-
|
|
#
|
|
# GStreamer Debug Viewer - View and analyze GStreamer debug log files
|
|
#
|
|
# Copyright (C) 2007 René Stadler <mail@renestadler.de>
|
|
#
|
|
# 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/>.
|
|
|
|
"""GStreamer Debug Viewer timeline widget plugin."""
|
|
|
|
import logging
|
|
|
|
from GstDebugViewer import Common, Data, GUI
|
|
from GstDebugViewer.Plugins import *
|
|
|
|
import cairo
|
|
import gtk
|
|
|
|
def iter_model_reversed (model):
|
|
|
|
count = model.iter_n_children (None)
|
|
for i in xrange (count - 1, 0, -1):
|
|
yield model[i]
|
|
|
|
class LineFrequencySentinel (object):
|
|
|
|
def __init__ (self, model):
|
|
|
|
self.model = model
|
|
self.clear ()
|
|
|
|
def clear (self):
|
|
|
|
self.data = None
|
|
self.n_partitions = None
|
|
self.partitions = None
|
|
self.step = None
|
|
self.ts_range = (None, None,)
|
|
|
|
def _search_ts (self, target_ts, first_index, last_index):
|
|
|
|
model_get = self.model.get_value
|
|
model_iter_nth_child = self.model.iter_nth_child
|
|
col_id = self.model.COL_TIME
|
|
|
|
while True:
|
|
middle = (last_index - first_index) // 2 + first_index
|
|
if middle == first_index:
|
|
return last_index
|
|
ts = model_get (model_iter_nth_child (None, middle), col_id)
|
|
if ts < target_ts:
|
|
first_index = middle + 1
|
|
elif ts > target_ts:
|
|
last_index = middle - 1
|
|
else:
|
|
return middle
|
|
|
|
def run_for (self, n):
|
|
|
|
if n == 0:
|
|
raise ValueError ("illegal value for n")
|
|
|
|
self.n_partitions = n
|
|
|
|
def process (self):
|
|
|
|
model = self.model
|
|
result = []
|
|
partitions = []
|
|
|
|
first_ts = None
|
|
for row in self.model:
|
|
first_ts = row[model.COL_TIME]
|
|
if first_ts is not None:
|
|
break
|
|
|
|
if first_ts is None:
|
|
return
|
|
|
|
last_ts = None
|
|
for row in iter_model_reversed (self.model):
|
|
last_ts = row[model.COL_TIME]
|
|
# FIXME: We ignore 0 here (unparsable lines!), this should be
|
|
# handled differently!
|
|
if last_ts:
|
|
last_index = row.path[0]
|
|
break
|
|
|
|
if last_ts is None:
|
|
return
|
|
|
|
step = int (float (last_ts - first_ts) / float (self.n_partitions))
|
|
|
|
YIELD_LIMIT = 100
|
|
limit = YIELD_LIMIT
|
|
|
|
first_index = 0
|
|
target_ts = first_ts + step
|
|
old_found = 0
|
|
while target_ts < last_ts:
|
|
limit -= 1
|
|
if limit == 0:
|
|
limit = YIELD_LIMIT
|
|
yield True
|
|
found = self._search_ts (target_ts, first_index, last_index)
|
|
result.append (found - old_found)
|
|
partitions.append (found)
|
|
old_found = found
|
|
first_index = found
|
|
target_ts += step
|
|
|
|
self.step = step
|
|
self.data = result
|
|
self.partitions = partitions
|
|
self.ts_range = (first_ts, last_ts,)
|
|
|
|
class LevelDistributionSentinel (object):
|
|
|
|
def __init__ (self, freq_sentinel, model):
|
|
|
|
self.freq_sentinel = freq_sentinel
|
|
self.model = model
|
|
self.data = []
|
|
|
|
def clear (self):
|
|
|
|
del self.data[:]
|
|
|
|
def process (self):
|
|
|
|
YIELD_LIMIT = 10000
|
|
y = YIELD_LIMIT
|
|
|
|
model_get = self.model.get_value
|
|
model_next = self.model.iter_next
|
|
id_time = self.model.COL_TIME
|
|
id_level = self.model.COL_LEVEL
|
|
del self.data[:]
|
|
data = self.data
|
|
i = 0
|
|
partitions_i = 0
|
|
partitions = self.freq_sentinel.partitions
|
|
counts = [0] * 6
|
|
tree_iter = self.model.get_iter_first ()
|
|
while tree_iter:
|
|
y -= 1
|
|
if y == 0:
|
|
y = YIELD_LIMIT
|
|
yield True
|
|
level = model_get (tree_iter, id_level)
|
|
if i > partitions[partitions_i]:
|
|
data.append (tuple (counts))
|
|
counts = [0] * 6
|
|
partitions_i += 1
|
|
if partitions_i == len (partitions):
|
|
# FIXME?
|
|
break
|
|
i += 1
|
|
counts[level] += 1
|
|
tree_iter = model_next (tree_iter)
|
|
|
|
# FIXME: We lose the last partition here!
|
|
|
|
yield False
|
|
|
|
class UpdateProcess (object):
|
|
|
|
def __init__ (self, freq_sentinel, dist_sentinel):
|
|
|
|
self.freq_sentinel = freq_sentinel
|
|
self.dist_sentinel = dist_sentinel
|
|
self.is_running = False
|
|
self.dispatcher = Common.Data.GSourceDispatcher ()
|
|
|
|
def __process (self):
|
|
|
|
if self.freq_sentinel is None or self.dist_sentinel is None:
|
|
return
|
|
|
|
self.is_running = True
|
|
|
|
for x in self.freq_sentinel.process ():
|
|
yield True
|
|
|
|
self.handle_sentinel_finished (self.freq_sentinel)
|
|
|
|
for x in self.dist_sentinel.process ():
|
|
yield True
|
|
self.handle_sentinel_progress (self.dist_sentinel)
|
|
|
|
self.is_running = False
|
|
|
|
self.handle_sentinel_finished (self.dist_sentinel)
|
|
self.handle_process_finished ()
|
|
|
|
yield False
|
|
|
|
def run (self):
|
|
|
|
if self.is_running:
|
|
return
|
|
|
|
self.dispatcher (self.__process ())
|
|
|
|
def abort (self):
|
|
|
|
if not self.is_running:
|
|
return
|
|
|
|
self.dispatcher.cancel ()
|
|
self.is_running = False
|
|
|
|
def handle_sentinel_progress (self, sentinel):
|
|
|
|
pass
|
|
|
|
def handle_sentinel_finished (self, sentinel):
|
|
|
|
pass
|
|
|
|
def handle_process_finished (self):
|
|
|
|
pass
|
|
|
|
class VerticalTimelineWidget (gtk.DrawingArea):
|
|
|
|
__gtype_name__ = "GstDebugViewerVerticalTimelineWidget"
|
|
|
|
def __init__ (self):
|
|
|
|
gtk.DrawingArea.__init__ (self)
|
|
|
|
self.logger = logging.getLogger ("ui.vtimeline")
|
|
|
|
self.theme = GUI.ThreadColorThemeTango ()
|
|
self.params = None
|
|
self.thread_colors = {}
|
|
self.next_thread_color = 0
|
|
|
|
self.connect ("expose-event", self.__handle_expose_event)
|
|
self.connect ("size-request", self.__handle_size_request)
|
|
|
|
def __handle_expose_event (self, self_, event):
|
|
|
|
self.__draw (self.window)
|
|
|
|
def __draw (self, drawable):
|
|
|
|
ctx = drawable.cairo_create ()
|
|
x, y, w, h = self.get_allocation ()
|
|
|
|
# White background rectangle.
|
|
ctx.set_line_width (0.)
|
|
ctx.rectangle (0, 0, w, h)
|
|
ctx.set_source_rgb (1., 1., 1.)
|
|
ctx.fill ()
|
|
ctx.new_path ()
|
|
|
|
if self.params is None:
|
|
return
|
|
|
|
first_y, cell_height, data = self.params
|
|
first_ts, last_ts = data[0][0], data[-1][0]
|
|
ts_range = last_ts - first_ts
|
|
|
|
if ts_range == 0:
|
|
return
|
|
|
|
ctx.set_line_width (1.)
|
|
ctx.set_source_rgb (0., 0., 0.)
|
|
|
|
first_y += cell_height // 2 - .5
|
|
for i, i_data in enumerate (data):
|
|
ts, thread = i_data
|
|
if thread in self.thread_colors:
|
|
ctx.set_source_rgb (*self.thread_colors[thread])
|
|
else:
|
|
self.next_thread_color += 1
|
|
if self.next_thread_color == len (self.theme.colors):
|
|
self.next_thread_color = 0
|
|
color = self.theme.colors[self.next_thread_color][0].float_tuple ()
|
|
self.thread_colors[thread] = color
|
|
ctx.set_source_rgb (*color)
|
|
ts_fraction = float (ts - first_ts) / ts_range
|
|
ts_offset = ts_fraction * h
|
|
row_offset = first_y + i * cell_height
|
|
ctx.move_to (-.5, ts_offset)
|
|
ctx.line_to (4.5, ts_offset)
|
|
ctx.line_to (w - 4.5, row_offset)
|
|
ctx.line_to (w + .5, row_offset)
|
|
ctx.stroke ()
|
|
|
|
def __handle_size_request (self, self_, req):
|
|
|
|
req.width = 64 # FIXME
|
|
|
|
def clear (self):
|
|
|
|
self.params = None
|
|
self.thread_colors.clear ()
|
|
self.next_thread_color = 0
|
|
self.queue_draw ()
|
|
|
|
def update (self, first_y, cell_height, data):
|
|
|
|
# FIXME: Ideally we should be informed of the vertical position
|
|
# difference of the view (which is 0) with the current UI layout.
|
|
|
|
self.params = (first_y, cell_height, data,)
|
|
self.queue_draw ()
|
|
|
|
class TimelineWidget (gtk.DrawingArea):
|
|
|
|
__gtype_name__ = "GstDebugViewerTimelineWidget"
|
|
|
|
def __init__ (self):
|
|
|
|
gtk.DrawingArea.__init__ (self)
|
|
|
|
self.logger = logging.getLogger ("ui.timeline")
|
|
|
|
self.process = UpdateProcess (None, None)
|
|
self.connect ("expose-event", self.__handle_expose_event)
|
|
self.connect ("configure-event", self.__handle_configure_event)
|
|
self.connect ("size-request", self.__handle_size_request)
|
|
self.process.handle_sentinel_progress = self.handle_sentinel_progress
|
|
self.process.handle_sentinel_finished = self.handle_sentinel_finished
|
|
self.process.handle_process_finished = self.handle_process_finished
|
|
|
|
self.model = None
|
|
self.__offscreen = None
|
|
|
|
def handle_sentinel_progress (self, sentinel):
|
|
|
|
self.__redraw ()
|
|
|
|
def handle_sentinel_finished (self, sentinel):
|
|
|
|
if sentinel == self.process.freq_sentinel:
|
|
self.__redraw ()
|
|
|
|
def handle_process_finished (self):
|
|
|
|
self.__redraw ()
|
|
|
|
def __redraw (self):
|
|
|
|
if not self.props.visible:
|
|
return
|
|
|
|
x, y, w, h = self.get_allocation ()
|
|
self.__offscreen = gtk.gdk.Pixmap (self.window, w, h, -1)
|
|
|
|
self.__draw (self.__offscreen)
|
|
|
|
self.__update ()
|
|
|
|
def __update (self):
|
|
|
|
if not self.props.visible:
|
|
return
|
|
|
|
if self.__offscreen is None:
|
|
self.__redraw ()
|
|
|
|
gc = gtk.gdk.GC (self.window)
|
|
self.window.draw_drawable (gc, self.__offscreen, 0, 0, 0, 0, -1, -1)
|
|
|
|
def update (self, model):
|
|
|
|
self.model = model
|
|
|
|
width = self.get_allocation ()[2]
|
|
|
|
self.process.abort ()
|
|
if model:
|
|
self.process.freq_sentinel = LineFrequencySentinel (model)
|
|
self.process.dist_sentinel = LevelDistributionSentinel (self.process.freq_sentinel, model)
|
|
self.process.freq_sentinel.run_for (width)
|
|
self.process.run ()
|
|
|
|
def clear (self):
|
|
|
|
self.process.abort ()
|
|
self.process.freq_sentinel = None
|
|
self.process.dist_sentinel = None
|
|
self.__redraw ()
|
|
|
|
def update_position (self, start_ts, end_ts):
|
|
|
|
if not self.process.freq_sentinel:
|
|
return
|
|
|
|
if not self.process.freq_sentinel.data:
|
|
return
|
|
|
|
self.__update ()
|
|
|
|
first_ts, last_ts = self.process.freq_sentinel.ts_range
|
|
step = self.process.freq_sentinel.step
|
|
|
|
position1 = int (float (start_ts - first_ts) / step)
|
|
position2 = int (float (end_ts - first_ts) / step)
|
|
|
|
ctx = self.window.cairo_create ()
|
|
x, y, w, h = self.get_allocation ()
|
|
|
|
line_width = position2 - position1
|
|
if line_width <= 1:
|
|
ctx.set_source_rgb (1., 0., 0.)
|
|
ctx.set_line_width (1.)
|
|
ctx.move_to (position1 + .5, 0)
|
|
ctx.line_to (position1 + .5, h)
|
|
ctx.stroke ()
|
|
else:
|
|
ctx.set_source_rgba (1., 0., 0., .5)
|
|
ctx.rectangle (position1, 0, line_width, h)
|
|
ctx.fill ()
|
|
|
|
def find_indicative_time_step (self):
|
|
|
|
MINIMUM_PIXEL_STEP = 32
|
|
time_per_pixel = self.process.freq_sentinel.step
|
|
return 32 # FIXME use self.freq_sentinel.step and len (self.process.freq_sentinel.data)
|
|
|
|
def __draw (self, drawable):
|
|
|
|
ctx = drawable.cairo_create ()
|
|
x, y, w, h = self.get_allocation ()
|
|
|
|
# White background rectangle.
|
|
ctx.set_line_width (0.)
|
|
ctx.rectangle (0, 0, w, h)
|
|
ctx.set_source_rgb (1., 1., 1.)
|
|
ctx.fill ()
|
|
ctx.new_path ()
|
|
|
|
# Horizontal reference lines.
|
|
ctx.set_line_width (1.)
|
|
ctx.set_source_rgb (.95, .95, .95)
|
|
for i in range (h // 16):
|
|
y = i * 16 - .5
|
|
ctx.move_to (0, y)
|
|
ctx.line_to (w, y)
|
|
ctx.stroke ()
|
|
|
|
if self.process.freq_sentinel is None:
|
|
return
|
|
|
|
# Vertical reference lines.
|
|
pixel_step = self.find_indicative_time_step ()
|
|
ctx.set_source_rgb (.9, .9, .9)
|
|
for i in range (1, w // pixel_step + 1):
|
|
x = i * pixel_step - .5
|
|
ctx.move_to (x, 0)
|
|
ctx.line_to (x, h)
|
|
ctx.stroke ()
|
|
|
|
if not self.process.freq_sentinel.data:
|
|
self.logger.debug ("frequency sentinel has no data yet")
|
|
return
|
|
|
|
maximum = max (self.process.freq_sentinel.data)
|
|
|
|
ctx.set_source_rgb (0., 0., 0.)
|
|
self.__draw_graph (ctx, w, h, maximum, self.process.freq_sentinel.data)
|
|
|
|
if not self.process.dist_sentinel.data:
|
|
self.logger.debug ("level distribution sentinel has no data yet")
|
|
return
|
|
|
|
theme = GUI.LevelColorThemeTango ()
|
|
dist_data = self.process.dist_sentinel.data
|
|
|
|
def cumulative_level_counts (*levels):
|
|
for level_counts in dist_data:
|
|
yield sum ((level_counts[level] for level in levels))
|
|
|
|
level = Data.debug_level_info
|
|
levels_prev = (Data.debug_level_log, Data.debug_level_debug,)
|
|
ctx.set_source_rgb (*(theme.colors_float (level)[1]))
|
|
self.__draw_graph (ctx, w, h, maximum,
|
|
list (cumulative_level_counts (level, *levels_prev)))
|
|
|
|
level = Data.debug_level_debug
|
|
levels_prev = (Data.debug_level_log,)
|
|
ctx.set_source_rgb (*(theme.colors_float (level)[1]))
|
|
self.__draw_graph (ctx, w, h, maximum,
|
|
list (cumulative_level_counts (level, *levels_prev)))
|
|
|
|
level = Data.debug_level_log
|
|
ctx.set_source_rgb (*(theme.colors_float (level)[1]))
|
|
self.__draw_graph (ctx, w, h, maximum, [counts[level] for counts in dist_data])
|
|
|
|
# Draw error and warning triangle indicators:
|
|
|
|
for level in (Data.debug_level_warning, Data.debug_level_error,):
|
|
ctx.set_source_rgb (*(theme.colors_float (level)[1]))
|
|
for i, counts in enumerate (dist_data):
|
|
if counts[level] == 0:
|
|
continue
|
|
SIZE = 8
|
|
ctx.move_to (i - SIZE // 2, 0)
|
|
ctx.line_to (i + SIZE // 2, 0)
|
|
ctx.line_to (i, SIZE / 1.41)
|
|
ctx.close_path ()
|
|
ctx.fill ()
|
|
|
|
def __draw_graph (self, ctx, w, h, maximum, data):
|
|
|
|
if not data:
|
|
return
|
|
|
|
from operator import add
|
|
heights = [h * float (d) / maximum for d in data]
|
|
ctx.move_to (0, h)
|
|
for i in range (len (heights)):
|
|
ctx.line_to (i - .5, h - heights[i] + .5)
|
|
|
|
ctx.line_to (i, h)
|
|
ctx.close_path ()
|
|
|
|
ctx.fill ()
|
|
|
|
def __handle_expose_event (self, self_, event):
|
|
|
|
self.__redraw ()
|
|
return True
|
|
|
|
def __handle_configure_event (self, self_, event):
|
|
|
|
self.logger.debug ("widget size configured to %ix%i",
|
|
event.width, event.height)
|
|
|
|
if event.width < 16:
|
|
return False
|
|
|
|
self.update (self.model)
|
|
|
|
return False
|
|
|
|
def __handle_size_request (self, self_, req):
|
|
|
|
# FIXME:
|
|
req.height = 64
|
|
|
|
class TimelineFeature (FeatureBase):
|
|
|
|
state_section_name = "timeline"
|
|
|
|
def __init__ (self):
|
|
|
|
self.logger = logging.getLogger ("ui.timeline")
|
|
|
|
self.action_group = gtk.ActionGroup ("TimelineActions")
|
|
self.action_group.add_toggle_actions ([("show-timeline",
|
|
None, _("_Timeline"),)])
|
|
|
|
def handle_attach_window (self, window):
|
|
|
|
self.log_view = window.log_view
|
|
|
|
ui = window.ui_manager
|
|
|
|
ui.insert_action_group (self.action_group, 0)
|
|
|
|
self.merge_id = ui.new_merge_id ()
|
|
ui.add_ui (self.merge_id, "/menubar/ViewMenu/ViewMenuAdditions",
|
|
"ViewTimeline", "show-timeline",
|
|
gtk.UI_MANAGER_MENUITEM, False)
|
|
|
|
box = window.get_top_attach_point ()
|
|
|
|
self.timeline = TimelineWidget ()
|
|
self.timeline.add_events (gtk.gdk.ALL_EVENTS_MASK) # FIXME
|
|
self.timeline.connect ("button-press-event", self.handle_timeline_button_press_event)
|
|
self.timeline.connect ("motion-notify-event", self.handle_timeline_motion_notify_event)
|
|
box.pack_start (self.timeline, False, False, 0)
|
|
self.timeline.hide ()
|
|
|
|
box = window.get_side_attach_point ()
|
|
|
|
self.vtimeline = VerticalTimelineWidget ()
|
|
box.pack_start (self.vtimeline, False, False, 0)
|
|
self.vtimeline.hide ()
|
|
|
|
window.widgets.log_view_scrolled_window.props.vadjustment.connect ("value-changed",
|
|
self.handle_log_view_adjustment_value_changed)
|
|
|
|
handler = self.handle_show_action_toggled
|
|
action = self.action_group.get_action ("show-timeline")
|
|
action.connect ("toggled", handler)
|
|
action.activate ()
|
|
|
|
def handle_detach_window (self, window):
|
|
|
|
window.ui_manager.remove_ui (self.merge_id)
|
|
self.merge_id = None
|
|
|
|
window.ui_manager.remove_action_group (self.action_group)
|
|
|
|
self.timeline.destroy ()
|
|
self.timeline = None
|
|
|
|
def handle_attach_log_file (self, window, log_file):
|
|
|
|
model = window.log_filter
|
|
self.timeline.update (model)
|
|
# FIXME: On startup, this triggers a GtkWarning in
|
|
# view.get_visible_range for no apparent reason.
|
|
## self.update_vtimeline ()
|
|
|
|
def handle_detach_log_file (self, window, log_file):
|
|
|
|
self.timeline.clear ()
|
|
self.vtimeline.clear ()
|
|
|
|
def handle_log_view_adjustment_value_changed (self, adj):
|
|
|
|
# FIXME: If not visible, disconnect this handler!
|
|
if not self.timeline.props.visible:
|
|
return
|
|
|
|
model = self.log_view.props.model
|
|
start_path, end_path = self.log_view.get_visible_range ()
|
|
ts1 = model.get_value (model.get_iter (start_path),
|
|
model.COL_TIME)
|
|
ts2 = model.get_value (model.get_iter (end_path),
|
|
model.COL_TIME)
|
|
|
|
self.timeline.update_position (ts1, ts2)
|
|
|
|
self.update_vtimeline ()
|
|
|
|
def update_vtimeline (self):
|
|
|
|
model = self.log_view.props.model
|
|
start_path, end_path = self.log_view.get_visible_range ()
|
|
|
|
if not start_path or not end_path:
|
|
return
|
|
|
|
column = self.log_view.get_column (0)
|
|
cell_rect = self.log_view.get_cell_area (start_path, column)
|
|
first_y = self.log_view.convert_bin_window_to_widget_coords (cell_rect.x, cell_rect.y)[1]
|
|
bg_rect = self.log_view.get_background_area (start_path, column)
|
|
cell_height = bg_rect.height
|
|
|
|
data = []
|
|
tree_iter = model.get_iter (start_path)
|
|
while model.get_path (tree_iter) != end_path:
|
|
data.append (model.get (tree_iter, model.COL_TIME, model.COL_THREAD))
|
|
tree_iter = model.iter_next (tree_iter)
|
|
|
|
self.vtimeline.update (first_y, cell_height, data)
|
|
|
|
def handle_show_action_toggled (self, action):
|
|
|
|
show = action.props.active
|
|
|
|
if show:
|
|
self.timeline.show ()
|
|
self.vtimeline.show ()
|
|
else:
|
|
self.timeline.hide ()
|
|
self.vtimeline.hide ()
|
|
|
|
def handle_timeline_button_press_event (self, widget, event):
|
|
|
|
if event.button != 1:
|
|
return True
|
|
|
|
pos = int (event.x)
|
|
self.goto_time_position (pos)
|
|
return False
|
|
|
|
def handle_timeline_motion_notify_event (self, widget, event):
|
|
|
|
if not event.state & gtk.gdk.BUTTON1_MASK:
|
|
return True
|
|
|
|
pos = int (event.x)
|
|
self.goto_time_position (pos)
|
|
return False
|
|
|
|
def goto_time_position (self, pos):
|
|
|
|
if not self.timeline.process.freq_sentinel:
|
|
return True
|
|
|
|
data = self.timeline.process.freq_sentinel.data
|
|
if not data:
|
|
return True
|
|
|
|
if pos < 0:
|
|
pos = 0
|
|
elif pos >= len (data):
|
|
pos = len (data) - 1
|
|
|
|
count = sum (data[:pos + 1])
|
|
|
|
model = self.log_view.props.model
|
|
row = model[count]
|
|
self.log_view.scroll_to_cell ((count,), use_align = True, row_align = .5)
|
|
|
|
return False
|
|
|
|
class Plugin (PluginBase):
|
|
|
|
features = [TimelineFeature]
|