1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 '''Based on viewport_timeline_scrub standalone addon - Samuel Bernou'''
21 from .prefs
import get_addon_prefs
29 from gpu_extras
.batch
import batch_for_shader
31 from bpy
.props
import (BoolProperty
,
40 def nearest(array
, value
):
42 Get a numpy array and a target value
43 Return closest val found in array to passed value
45 idx
= (np
.abs(array
- value
)).argmin()
49 def draw_callback_px(self
, context
):
50 '''Draw callback use by modal to draw in viewport'''
51 if context
.area
!= self
.current_area
:
57 shader
= gpu
.shader
.from_builtin('2D_UNIFORM_COLOR') # initiate shader
58 bgl
.glEnable(bgl
.GL_BLEND
)
62 if self
.use_hud_time_line
:
64 shader
.uniform_float("color", self
.color_timeline
)
65 self
.batch_timeline
.draw(shader
)
68 if self
.use_hud_keyframes
:
69 if self
.keyframe_aspect
== 'LINE':
72 shader
.uniform_float("color", self
.color_timeline
)
73 self
.batch_keyframes
.draw(shader
)
77 shader
.uniform_float("color", self
.color_timeline
)
78 self
.batch_keyframes
.draw(shader
)
80 # Show current frame line
82 if self
.use_hud_playhead
:
83 playhead
= [(self
.cursor_x
, self
.my
+ self
.playhead_size
/2),
84 (self
.cursor_x
, self
.my
- self
.playhead_size
/2)]
85 batch
= batch_for_shader(shader
, 'LINES', {"pos": playhead
})
87 shader
.uniform_float("color", self
.color_playhead
)
90 # restore opengl defaults
91 bgl
.glDisable(bgl
.GL_BLEND
)
93 # Display current frame text
94 blf
.color(font_id
, *self
.color_text
)
95 if self
.use_hud_frame_current
:
96 blf
.position(font_id
, self
.mouse
[0]+10, self
.mouse
[1]+10, 0)
97 blf
.size(font_id
, 30, self
.dpi
)
98 blf
.draw(font_id
, f
'{self.new_frame:.0f}')
100 # Display frame offset text
101 if self
.use_hud_frame_offset
:
102 blf
.position(font_id
, self
.mouse
[0]+10,
103 self
.mouse
[1]+(40*self
.ui_scale
), 0)
104 blf
.size(font_id
, 16, self
.dpi
)
105 sign
= '+' if self
.offset
> 0 else ''
106 blf
.draw(font_id
, f
'{sign}{self.offset:.0f}')
109 class GPTS_OT_time_scrub(bpy
.types
.Operator
):
110 bl_idname
= "animation.time_scrub"
111 bl_label
= "Time scrub"
112 bl_description
= "Quick time scrubbing with a shortcut"
113 bl_options
= {"REGISTER", "INTERNAL", "UNDO"}
116 def poll(cls
, context
):
117 return context
.space_data
.type in ('VIEW_3D', 'SEQUENCE_EDITOR', 'CLIP_EDITOR')
119 def invoke(self
, context
, event
):
120 prefs
= get_addon_prefs().ts
122 self
.current_area
= context
.area
123 self
.key
= prefs
.keycode
124 self
.evaluate_gp_obj_key
= prefs
.evaluate_gp_obj_key
126 self
.dpi
= context
.preferences
.system
.dpi
127 self
.ui_scale
= context
.preferences
.system
.ui_scale
129 self
.color_timeline
= prefs
.color_timeline
130 self
.color_playhead
= prefs
.color_playhead
131 self
.color_text
= prefs
.color_playhead
132 self
.use_hud_time_line
= prefs
.use_hud_time_line
133 self
.use_hud_keyframes
= prefs
.use_hud_keyframes
134 self
.keyframe_aspect
= prefs
.keyframe_aspect
135 self
.use_hud_playhead
= prefs
.use_hud_playhead
136 self
.use_hud_frame_current
= prefs
.use_hud_frame_current
137 self
.use_hud_frame_offset
= prefs
.use_hud_frame_offset
139 self
.playhead_size
= prefs
.playhead_size
140 self
.lines_size
= prefs
.lines_size
142 self
.px_step
= prefs
.pixel_step
144 self
.mouse
= (event
.mouse_region_x
, event
.mouse_region_y
)
145 self
.init_mouse_x
= self
.cursor_x
= event
.mouse_region_x
146 # self.init_mouse_y = event.mouse_region_y # only to display init frame text
147 self
.init_frame
= self
.new_frame
= context
.scene
.frame_current
152 self
.snap_ctrl
= not prefs
.use_ctrl
153 self
.snap_shift
= not prefs
.use_shift
154 self
.snap_alt
= not prefs
.use_alt
155 self
.snap_mouse_key
= 'LEFTMOUSE' if self
.key
== 'RIGHTMOUSE' else 'RIGHTMOUSE'
159 if context
.space_data
.type != 'VIEW_3D':
160 ob
= None # do not consider any key
162 if ob
: # condition to allow empty scrubing
163 if ob
.type != 'GPENCIL' or self
.evaluate_gp_obj_key
:
164 # Get objet keyframe position
165 anim_data
= ob
.animation_data
169 action
= anim_data
.action
171 for fcu
in action
.fcurves
:
172 for kf
in fcu
.keyframe_points
:
173 if kf
.co
.x
not in self
.pos
:
174 self
.pos
.append(kf
.co
.x
)
176 if ob
.type == 'GPENCIL':
177 # Get GP frame position
181 for frame
in layer
.frames
:
182 if frame
.frame_number
not in self
.pos
:
183 self
.pos
.append(frame
.frame_number
)
185 # Add start and end to snap on
186 if context
.scene
.use_preview_range
:
187 play_bounds
= [context
.scene
.frame_preview_start
,
188 context
.scene
.frame_preview_end
]
190 play_bounds
= [context
.scene
.frame_start
, context
.scene
.frame_end
]
192 # Also snap on play bounds (sliced off for keyframe display)
193 self
.pos
+= play_bounds
194 self
.pos
= np
.asarray(self
.pos
)
197 self
.active_space_data
= context
.space_data
198 self
.onion_skin
= None
199 if context
.space_data
.type == 'VIEW_3D': # and 'GPENCIL' in context.mode
200 self
.onion_skin
= self
.active_space_data
.overlay
.use_gpencil_onion_skin
201 self
.active_space_data
.overlay
.use_gpencil_onion_skin
= False
203 self
.hud
= prefs
.use_hud
205 context
.window_manager
.modal_handler_add(self
)
206 return {'RUNNING_MODAL'}
209 width
= context
.area
.width
210 right
= int((width
- self
.init_mouse_x
) / self
.px_step
)
211 left
= int(self
.init_mouse_x
/ self
.px_step
)
214 for i
in range(1, left
):
215 hud_pos_x
.append(self
.init_mouse_x
- i
*self
.px_step
)
216 for i
in range(1, right
):
217 hud_pos_x
.append(self
.init_mouse_x
+ i
*self
.px_step
)
219 # - list of double coords
222 frame_height
= self
.lines_size
224 bound_h
= key_height
+ 19
225 bound_bracket_l
= self
.px_step
/2
227 self
.my
= my
= event
.mouse_region_y
233 self
.hud_lines
.append((x
, my
- (frame_height
/2)))
234 self
.hud_lines
.append((x
, my
+ (frame_height
/2)))
237 self
.hud_lines
+= [(self
.init_mouse_x
, my
- (init_height
/2)),
238 (self
.init_mouse_x
, my
+ (init_height
/2))]
240 # Add start/end boundary bracket to HUD
242 start_x
= self
.init_mouse_x
+ \
243 (play_bounds
[0] - self
.init_frame
) * self
.px_step
244 end_x
= self
.init_mouse_x
+ \
245 (play_bounds
[1] - self
.init_frame
) * self
.px_step
248 up
= (start_x
, my
- (bound_h
/2))
249 dn
= (start_x
, my
+ (bound_h
/2))
250 self
.hud_lines
.append(up
)
251 self
.hud_lines
.append(dn
)
253 self
.hud_lines
.append(up
)
254 self
.hud_lines
.append((up
[0] + bound_bracket_l
, up
[1]))
255 self
.hud_lines
.append(dn
)
256 self
.hud_lines
.append((dn
[0] + bound_bracket_l
, dn
[1]))
259 up
= (end_x
, my
- (bound_h
/2))
260 dn
= (end_x
, my
+ (bound_h
/2))
261 self
.hud_lines
.append(up
)
262 self
.hud_lines
.append(dn
)
264 self
.hud_lines
.append(up
)
265 self
.hud_lines
.append((up
[0] - bound_bracket_l
, up
[1]))
266 self
.hud_lines
.append(dn
)
267 self
.hud_lines
.append((dn
[0] - bound_bracket_l
, dn
[1]))
270 self
.hud_lines
+= [(0, my
), (width
, my
)]
272 # Prepare batchs to draw static parts
273 shader
= gpu
.shader
.from_builtin('2D_UNIFORM_COLOR') # initiate shader
274 self
.batch_timeline
= batch_for_shader(
275 shader
, 'LINES', {"pos": self
.hud_lines
})
278 if self
.keyframe_aspect
== 'LINE':
280 # Slice off position of start/end frame added last (in list for snapping)
281 for i
in self
.pos
[:-2]:
283 (self
.init_mouse_x
+ ((i
-self
.init_frame
) * self
.px_step
), my
- (key_height
/2)))
285 (self
.init_mouse_x
+ ((i
-self
.init_frame
)*self
.px_step
), my
+ (key_height
/2)))
287 self
.batch_keyframes
= batch_for_shader(
288 shader
, 'LINES', {"pos": key_lines
})
292 # keysize5 for square, 4 or 6 for diamond
293 keysize
= 6 if self
.keyframe_aspect
== 'DIAMOND' else 5
299 for i
in self
.pos
[:-2]:
300 center
= self
.init_mouse_x
+ ((i
-self
.init_frame
)*self
.px_step
)
301 if self
.keyframe_aspect
== 'DIAMOND':
302 # +1 on x is to correct pixel alignement
303 shaped_key
+= [(center
-keysize
, my
+upper
),
304 (center
+1, my
+keysize
+upper
),
305 (center
+keysize
, my
+upper
),
306 (center
+1, my
-keysize
+upper
)]
308 elif self
.keyframe_aspect
== 'SQUARE':
309 shaped_key
+= [(center
-keysize
+1, my
-keysize
+upper
),
310 (center
-keysize
+1, my
+keysize
+upper
),
311 (center
+keysize
, my
+keysize
+upper
),
312 (center
+keysize
, my
-keysize
+upper
)]
314 indices
+= [(0+idx_offset
, 1+idx_offset
, 2+idx_offset
),
315 (0+idx_offset
, 2+idx_offset
, 3+idx_offset
)]
318 self
.batch_keyframes
= batch_for_shader(
319 shader
, 'TRIS', {"pos": shaped_key
}, indices
=indices
)
321 args
= (self
, context
)
323 self
.spacetype
= 'WINDOW' # is PREVIEW for VSE, needed for handler remove
325 if context
.space_data
.type == 'VIEW_3D':
326 self
.viewtype
= bpy
.types
.SpaceView3D
327 self
._handle
= bpy
.types
.SpaceView3D
.draw_handler_add(
328 draw_callback_px
, args
, 'WINDOW', 'POST_PIXEL')
330 elif context
.space_data
.type == 'SEQUENCE_EDITOR':
331 self
.viewtype
= bpy
.types
.SpaceSequenceEditor
332 self
.spacetype
= 'PREVIEW'
333 self
._handle
= bpy
.types
.SpaceSequenceEditor
.draw_handler_add(
334 draw_callback_px
, args
, 'PREVIEW', 'POST_PIXEL')
336 elif context
.space_data
.type == 'CLIP_EDITOR':
337 self
.viewtype
= bpy
.types
.SpaceClipEditor
338 self
._handle
= bpy
.types
.SpaceClipEditor
.draw_handler_add(
339 draw_callback_px
, args
, 'WINDOW', 'POST_PIXEL')
341 context
.window_manager
.modal_handler_add(self
)
342 return {'RUNNING_MODAL'}
344 def _exit_modal(self
, context
):
345 if self
.onion_skin
is not None:
346 self
.active_space_data
.overlay
.use_gpencil_onion_skin
= self
.onion_skin
348 if self
.hud
and self
.viewtype
:
349 self
.viewtype
.draw_handler_remove(self
._handle
, self
.spacetype
)
350 context
.area
.tag_redraw()
352 def modal(self
, context
, event
):
354 if event
.type == 'MOUSEMOVE':
355 # - calculate frame offset from pixel offset
356 # - get mouse.x and add it to initial frame num
357 self
.mouse
= (event
.mouse_region_x
, event
.mouse_region_y
)
359 px_offset
= (event
.mouse_region_x
- self
.init_mouse_x
)
360 self
.offset
= int(px_offset
/ self
.px_step
)
361 self
.new_frame
= self
.init_frame
+ self
.offset
364 if self
.snap_ctrl
and event
.ctrl
:
366 if self
.snap_shift
and event
.shift
:
368 if self
.snap_alt
and event
.alt
:
371 if self
.snap_on
or mod_snap
:
372 self
.new_frame
= nearest(self
.pos
, self
.new_frame
)
374 context
.scene
.frame_current
= self
.new_frame
376 # - recalculate offset to snap cursor to frame
377 self
.offset
= self
.new_frame
- self
.init_frame
379 # - calculate cursor pixel position from frame offset
380 self
.cursor_x
= self
.init_mouse_x
+ (self
.offset
* self
.px_step
)
382 if event
.type == 'ESC':
383 # frame_set(self.init_frame) ?
384 context
.scene
.frame_current
= self
.init_frame
385 self
._exit
_modal
(context
)
388 # Snap if pressing NOT used mouse key (right or mid)
389 if event
.type == self
.snap_mouse_key
:
390 if event
.value
== "PRESS":
395 if event
.type == self
.key
and event
.value
== 'RELEASE':
396 self
._exit
_modal
(context
)
399 return {"RUNNING_MODAL"}
404 def auto_rebind(self
, context
):
409 class GPTS_OT_set_scrub_keymap(bpy
.types
.Operator
):
410 bl_idname
= "animation.ts_set_keymap"
411 bl_label
= "Change keymap"
412 bl_description
= "Quick time scrubbing with a shortcut"
413 bl_options
= {"REGISTER", "INTERNAL"}
415 def invoke(self
, context
, event
):
416 self
.prefs
= get_addon_prefs().ts
421 self
.init_value
= self
.prefs
.keycode
422 self
.prefs
.keycode
= ''
423 context
.window_manager
.modal_handler_add(self
)
424 return {'RUNNING_MODAL'}
426 def modal(self
, context
, event
):
427 exclude_keys
= {'MOUSEMOVE', 'INBETWEEN_MOUSEMOVE',
428 'TIMER_REPORT', 'ESC', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}
429 exclude_in
= ('SHIFT', 'CTRL', 'ALT')
430 if event
.type == 'ESC':
431 self
.prefs
.keycode
= self
.init_value
434 self
.ctrl
= event
.ctrl
435 self
.shift
= event
.shift
438 if event
.type not in exclude_keys
and not any(x
in event
.type for x
in exclude_in
):
439 print('key:', event
.type, 'value:', event
.value
)
440 if event
.value
== 'PRESS':
441 self
.report({'INFO'}, event
.type)
443 self
.prefs
.keycode
= event
.type
444 # Following condition to avoid unnecessary rebind update
445 if self
.prefs
.use_shift
!= event
.shift
:
446 self
.prefs
.use_shift
= event
.shift
448 if self
.prefs
.use_alt
!= event
.alt
:
449 self
.prefs
.use_alt
= event
.alt
451 # -# Trigger rebind update with last
452 self
.prefs
.use_ctrl
= event
.ctrl
456 return {"RUNNING_MODAL"}
459 class GPTS_timeline_settings(bpy
.types
.PropertyGroup
):
461 keycode
: StringProperty(
463 description
="Shortcut to trigger the scrub in viewport during press",
464 default
="MIDDLEMOUSE")
466 use_in_timeline_editor
: BoolProperty(
467 name
="Shortcut in timeline editors",
468 description
="Add the same shortcut to scrub in timeline editor windows",
472 use_shift
: BoolProperty(
473 name
="Combine With Shift",
474 description
="Add shift",
478 use_alt
: BoolProperty(
479 name
="Combine With Alt",
480 description
="Add alt",
484 use_ctrl
: BoolProperty(
485 name
="Combine With Ctrl",
486 description
="Add ctrl",
490 evaluate_gp_obj_key
: BoolProperty(
491 name
='Use Gpencil Object Keyframes',
492 description
="Also snap on greasepencil object keyframe (else only active layer frames)",
495 pixel_step
: IntProperty(
496 name
="Frame Interval On Screen",
497 description
="Pixel steps on screen that represent a frame intervals",
506 use_hud
: BoolProperty(
507 name
='Display Timeline Overlay',
508 description
="Display overlays with timeline information when scrubbing time in viewport",
511 use_hud_time_line
: BoolProperty(
513 description
="Display a static marks overlay to represent timeline when scrubbing",
516 use_hud_keyframes
: BoolProperty(
518 description
="Display shapes overlay to show keyframe position when scrubbing",
521 use_hud_playhead
: BoolProperty(
523 description
="Display the playhead as a vertical line to show position in time",
526 use_hud_frame_current
: BoolProperty(
527 name
='Text Frame Current',
528 description
="Display the current frame as text above mouse cursor",
531 use_hud_frame_offset
: BoolProperty(
532 name
='Text Frame Offset',
533 description
="Display frame offset from initial position as text above mouse cursor",
536 color_timeline
: FloatVectorProperty(
537 name
="Timeline Color",
538 subtype
='COLOR_GAMMA',
540 default
=(0.5, 0.5, 0.5, 0.6),
542 description
="Color of the temporary timeline")
544 color_playhead
: FloatVectorProperty(
546 subtype
='COLOR_GAMMA',
548 default
=(0.01, 0.64, 1.0, 0.8),
550 description
="Color of the temporary line cursor and text")
553 playhead_size
: IntProperty(
554 name
="Playhead Size",
555 description
="Playhead height in pixels",
564 lines_size
: IntProperty(
565 name
="Frame Lines Size",
566 description
="Frame lines height in pixels",
575 keyframe_aspect
: EnumProperty(
576 name
="Keyframe Display",
577 description
="Customize aspect of the keyframes",
581 'Keyframe displayed as thick lines', 'SNAP_INCREMENT', 0),
583 'Keyframe displayed as squares', 'HANDLETYPE_VECTOR_VEC', 1),
584 ('DIAMOND', 'Diamond',
585 'Keyframe displayed as diamonds', 'HANDLETYPE_FREE_VEC', 2),
589 def draw_ts_pref(prefs
, layout
):
591 layout
.label(text
='Timeline Scrub:')
592 layout
.prop(prefs
, 'evaluate_gp_obj_key')
593 layout
.prop(prefs
, 'pixel_step')
597 box
.label(text
='Keymap:')
598 box
.operator('animation.ts_set_keymap',
599 text
='Click here to change shortcut')
602 row
= box
.row(align
=True)
603 row
.prop(prefs
, 'use_ctrl', text
='Ctrl')
604 row
.prop(prefs
, 'use_alt', text
='Alt')
605 row
.prop(prefs
, 'use_shift', text
='Shift')
608 if prefs
.keycode
== 'LEFTMOUSE':
610 elif prefs
.keycode
== 'MIDDLEMOUSE':
612 elif prefs
.keycode
== 'RIGHTMOUSE':
615 row
.label(text
=f
'{prefs.keycode}', icon
=icon
)
618 row
.label(text
=f
'Key: {prefs.keycode}')
621 box
.label(text
='[ NOW TYPE KEY OR CLICK TO USE, WITH MODIFIER ]')
623 snap_text
= 'Snap to keyframes: '
624 snap_text
+= 'Left Mouse' if prefs
.keycode
== 'RIGHTMOUSE' else 'Right Mouse'
625 if not prefs
.use_ctrl
:
626 snap_text
+= ' or Ctrl'
627 if not prefs
.use_shift
:
628 snap_text
+= ' or Shift'
629 if not prefs
.use_alt
:
630 snap_text
+= ' or Alt'
631 box
.label(text
=snap_text
, icon
='SNAP_ON')
632 if prefs
.keycode
in ('LEFTMOUSE', 'RIGHTMOUSE', 'MIDDLEMOUSE') and not prefs
.use_ctrl
and not prefs
.use_alt
and not prefs
.use_shift
:
634 text
="Recommended to choose at least one modifier to combine with clicks (default: Ctrl+Alt)", icon
="ERROR")
636 box
.prop(prefs
, 'use_in_timeline_editor',
637 text
='Add same shortcut to scrub within timeline editors')
641 box
.prop(prefs
, 'use_hud')
645 row
.prop(prefs
, 'color_timeline')
646 row
.prop(prefs
, 'color_playhead', text
='Cursor And Text Color')
647 col
.label(text
='Show:')
649 row
.prop(prefs
, 'use_hud_time_line')
650 row
.prop(prefs
, 'lines_size')
652 row
.prop(prefs
, 'use_hud_playhead')
653 row
.prop(prefs
, 'playhead_size')
655 row
.prop(prefs
, 'use_hud_keyframes')
656 row
.prop(prefs
, 'keyframe_aspect', text
='')
658 row
.prop(prefs
, 'use_hud_frame_current')
659 row
.prop(prefs
, 'use_hud_frame_offset')
660 col
.enabled
= prefs
.use_hud
667 def register_keymaps():
668 prefs
= get_addon_prefs().ts
669 addon
= bpy
.context
.window_manager
.keyconfigs
.addon
670 km
= addon
.keymaps
.new(name
="Grease Pencil",
671 space_type
="EMPTY", region_type
='WINDOW')
673 if not prefs
.keycode
:
674 print(r
'/!\ Timeline scrub: no keycode entered for keymap')
676 kmi
= km
.keymap_items
.new(
677 'animation.time_scrub',
678 type=prefs
.keycode
, value
='PRESS',
679 alt
=prefs
.use_alt
, ctrl
=prefs
.use_ctrl
, shift
=prefs
.use_shift
, any
=False)
681 addon_keymaps
.append((km
, kmi
))
683 # - Add keymap in timeline editors
684 if prefs
.use_in_timeline_editor
:
687 ('Dopesheet', 'DOPESHEET_EDITOR', 'anim.change_frame'),
688 ('Graph Editor', 'GRAPH_EDITOR', 'graph.cursor_set'),
689 ("NLA Editor", "NLA_EDITOR", 'anim.change_frame'),
690 ("Sequencer", "SEQUENCE_EDITOR", 'anim.change_frame')
691 # ("Clip Graph Editor", "CLIP_EDITOR", 'clip.change_frame'),
694 for editor
, space
, operator
in editor_l
:
695 km
= addon
.keymaps
.new(name
=editor
, space_type
=space
)
696 kmi
= km
.keymap_items
.new(
697 operator
, type=prefs
.keycode
, value
='PRESS',
698 alt
=prefs
.use_alt
, ctrl
=prefs
.use_ctrl
, shift
=prefs
.use_shift
)
699 addon_keymaps
.append((km
, kmi
))
702 def unregister_keymaps():
703 for km
, kmi
in addon_keymaps
:
704 km
.keymap_items
.remove(kmi
)
705 addon_keymaps
.clear()
711 GPTS_OT_set_scrub_keymap
,
716 bpy
.utils
.register_class(cls
)
721 for cls
in reversed(classes
):
722 bpy
.utils
.unregister_class(cls
)