@@ -82,6 +82,166 @@ def _transform_otio_to_world_coordinate(point):
8282 return (world_coordinate_x , world_coordinate_y , world_coordinate_width )
8383
8484
85+ def _get_source_image_height () -> float :
86+ """Return the pixel height of the first media source. Falls back to 1080."""
87+ try :
88+ media_switch = commands .nodeConnections ("MediaTrack" )[0 ][0 ]
89+ media_source_group = commands .nodeConnections (media_switch )[0 ][0 ]
90+ first_source_node = extra_commands .nodesInGroupOfType (media_source_group , "RVFileSource" )[0 ]
91+ media_info = commands .sourceMediaInfo (first_source_node )
92+ h = float (media_info .get ("height" , 0 ))
93+ if h > 1 :
94+ return h
95+ except Exception :
96+ pass
97+ return 1080.0
98+
99+
100+ def _transform_pos (x , y ):
101+ """Transform a 2D OTIO position to RV WCS. Returns (wcs_x, wcs_y) or None."""
102+ result = _transform_otio_to_world_coordinate (otio .schemadef .Point .Point (x = x , y = y , width = 0.0 ))
103+ if result is None :
104+ return None
105+ return result [0 ], result [1 ]
106+
107+
108+ def _transform_scalar (w ):
109+ """Scale a scalar OTIO distance to RV WCS via the x-axis scale, or return the raw value."""
110+ result = _transform_otio_to_world_coordinate (otio .schemadef .Point .Point (x = 0.0 , y = 0.0 , width = float (w )))
111+ return result [2 ] if result is not None else float (w )
112+
113+
114+ def _add_shape_to_frame_order (paint_node , stroke_id , frame , prefix ):
115+ """Insert a shape into the frame draw-order list."""
116+ frame_component = f"{ paint_node } .frame:{ frame } "
117+ if not commands .propertyExists (f"{ frame_component } .order" ):
118+ commands .newProperty (f"{ frame_component } .order" , commands .StringType , 1 )
119+ commands .insertStringProperty (f"{ frame_component } .order" , [f"{ prefix } :{ stroke_id } :{ frame } :annotation" ])
120+
121+
122+ def _create_bbox_shape (layer , paint_node , stroke_id , frame , shape_type ):
123+ """Create an RVPaint rect: or ellipse: node from a shape layer."""
124+ prefix = "rect" if shape_type .startswith ("Rectangle." ) else "ellipse"
125+ shape_component = f"{ paint_node } .{ prefix } :{ stroke_id } :{ frame } :annotation"
126+
127+ min_pos = _transform_pos (layer .min .x , layer .min .y )
128+ max_pos = _transform_pos (layer .max .x , layer .max .y )
129+ if min_pos is None or max_pos is None :
130+ logging .warning (f"annotation_hook: could not transform bbox coords for { shape_component } — skipping" )
131+ return
132+
133+ border_width = float (layer .border_width ) if layer .border_width is not None else 0.0
134+ wcs_border_width = _transform_scalar (border_width )
135+
136+ corner_radius = getattr (layer , "corner_radius" , None )
137+ wcs_corner_radius = float (corner_radius ) if corner_radius is not None else 0.0
138+
139+ effectHook .add_rv_effect_props (
140+ shape_component ,
141+ {
142+ "min" : list (min_pos ),
143+ "max" : list (max_pos ),
144+ "innerColor" : [float (c ) for c in layer .inner_color ],
145+ "borderColor" : [float (c ) for c in layer .border_color ],
146+ "borderWidth" : [wcs_border_width ],
147+ "cornerRadius" : [wcs_corner_radius ],
148+ "startFrame" : frame ,
149+ "duration" : 1 ,
150+ "eye" : 2 ,
151+ "softDeleted" : layer .visible is False ,
152+ "uuid" : [layer .id ],
153+ },
154+ )
155+
156+ if layer .visible is not False :
157+ _add_shape_to_frame_order (paint_node , stroke_id , frame , prefix )
158+
159+
160+ def _create_point_pair_shape (layer , paint_node , stroke_id , frame , shape_type ):
161+ """Create an RVPaint arrow: or line: node from a shape layer."""
162+ prefix = "arrow" if shape_type .startswith ("Arrow." ) else "line"
163+ shape_component = f"{ paint_node } .{ prefix } :{ stroke_id } :{ frame } :annotation"
164+
165+ start_pos = _transform_pos (layer .start_position .x , layer .start_position .y )
166+ end_pos = _transform_pos (layer .end_position .x , layer .end_position .y )
167+ if start_pos is None or end_pos is None :
168+ logging .warning (f"annotation_hook: could not transform point-pair coords for { shape_component } — skipping" )
169+ return
170+
171+ raw_bw = float (layer .width if shape_type .startswith ("Line." ) else layer .border_width )
172+ wcs_border_width = _transform_scalar (raw_bw )
173+
174+ props = {
175+ "startPos" : list (start_pos ),
176+ "endPos" : list (end_pos ),
177+ "borderColor" : [float (c ) for c in layer .border_color ],
178+ "borderWidth" : [wcs_border_width ],
179+ "startFrame" : frame ,
180+ "duration" : 1 ,
181+ "eye" : 2 ,
182+ "softDeleted" : layer .visible is False ,
183+ "uuid" : [layer .id ],
184+ }
185+
186+ if shape_type .startswith ("Arrow." ):
187+ wcs_thickness = _transform_scalar (float (layer .width ))
188+ props ["innerColor" ] = [float (c ) for c in layer .inner_color ]
189+ props ["thickness" ] = [wcs_thickness ]
190+
191+ effectHook .add_rv_effect_props (shape_component , props )
192+
193+ if layer .visible is not False :
194+ _add_shape_to_frame_order (paint_node , stroke_id , frame , prefix )
195+
196+
197+ def _create_text_shape (layer , paint_node , stroke_id , frame , source_node = None ):
198+ """Create an RVPaint text: node from a shape layer."""
199+ shape_component = f"{ paint_node } .text:{ stroke_id } :{ frame } :annotation"
200+
201+ anchor = layer .anchor
202+ anchor_pos = _transform_pos (anchor .x , anchor .y )
203+ if anchor_pos is None :
204+ logging .warning (f"annotation_hook: could not transform text anchor for { shape_component } — skipping" )
205+ return
206+
207+ # font_size in OTIO is in OTIO-space units (same coordinate space as
208+ # border_width). Convert to RVPaint logical pixels: OTIO → WCS via
209+ # _transform_scalar, then WCS → pixels by multiplying by image height.
210+ # Use the media track's source for height — the annotation source group
211+ # is a blank node whose sourceMediaInfo returns height=1, not the real
212+ # image dimensions.
213+ image_h = _get_source_image_height ()
214+ font_size_wcs = _transform_scalar (float (layer .font_size ))
215+ font_size_rv = font_size_wcs * image_h
216+
217+ # RVPaint .position is the BOTTOM of the text quad (y-up). The OTIO anchor
218+ # is the typographic baseline. Descent ≈ 0.2 × em, so the bottom of the
219+ # quad is ~0.2 × em below the baseline → shift position DOWN by 0.2 × em.
220+ anchor_pos = (anchor_pos [0 ], anchor_pos [1 ] - font_size_wcs * 0.2 )
221+
222+ effectHook .add_rv_effect_props (
223+ shape_component ,
224+ {
225+ "text" : [layer .text ],
226+ "fontFamily" : [layer .font_family ],
227+ "fontWeight" : [layer .font_weight ],
228+ "fontStyle" : [layer .font_style ],
229+ "textDecoration" : [layer .text_decoration ],
230+ "textAlign" : [layer .text_align ],
231+ "position" : list (anchor_pos ),
232+ "fontSize" : [font_size_rv ],
233+ "color" : [float (c ) for c in layer .inner_color ],
234+ "startFrame" : frame ,
235+ "duration" : 1 ,
236+ "softDeleted" : layer .visible is False ,
237+ "uuid" : [layer .id ],
238+ },
239+ )
240+
241+ if layer .visible is not False :
242+ _add_shape_to_frame_order (paint_node , stroke_id , frame , "text" )
243+
244+
85245def hook_function (in_timeline : otio .schemadef .Annotation .Annotation , argument_map : dict | None = None ) -> None :
86246 """A hook for the annotation schema"""
87247 try :
@@ -106,54 +266,71 @@ def hook_function(in_timeline: otio.schemadef.Annotation.Annotation, argument_ma
106266 paint_node = extra_commands .nodesInGroupOfType (source_node , "RVPaint" )[0 ]
107267 paint_component = f"{ paint_node } .paint"
108268 stroke_id = commands .getIntProperty (f"{ paint_component } .nextId" )[0 ] + 1
109- pen_component = f"{ paint_node } .pen:{ stroke_id } :{ frame } :annotation"
110269 frame_component = f"{ paint_node } .frame:{ frame } "
111270
112271 # Set properties on the paint component of the RVPaint node
113272 effectHook .set_rv_effect_props (paint_component , {"nextId" : stroke_id })
114273
115- start_time = int (time_range .start_time .value )
116- end_time = int (start_time + time_range .duration .value )
117-
118- duration = end_time - start_time
119-
120- # Add and set properties on the pen component of the RVPaint node
121- effectHook .add_rv_effect_props (
122- pen_component ,
123- {
124- "color" : list (map (float , layer .rgba )),
125- "brush" : [layer .brush ],
126- "debug" : 1 ,
127- "join" : 3 ,
128- "cap" : 1 ,
129- "splat" : 0 ,
130- "mode" : 0 if layer .type == "COLOR" else 1 ,
131- "startFrame" : start_time ,
132- "duration" : duration ,
133- "softDeleted" : layer .soft_deleted ,
134- "uuid" : [layer .id ],
135- },
136- )
137-
138- points_property = f"{ pen_component } .points"
139- width_property = f"{ pen_component } .width"
140-
141- if not commands .propertyExists (points_property ):
142- commands .newProperty (points_property , commands .FloatType , 2 )
143- if not commands .propertyExists (width_property ):
144- commands .newProperty (width_property , commands .FloatType , 1 )
145-
146- for point in layer .points :
147- world_coordinate_x , world_coordinate_y , world_coordinate_width = _transform_otio_to_world_coordinate (point )
148-
149- commands .insertFloatProperty (
150- points_property ,
151- [world_coordinate_x , world_coordinate_y ],
274+ shape_label = getattr (layer , "_serializable_label" , "" )
275+
276+ if isinstance (layer , otio .schemadef .Paint .Paint ):
277+ pen_component = f"{ paint_node } .pen:{ stroke_id } :{ frame } :annotation"
278+
279+ start_time = int (time_range .start_time .value )
280+ end_time = int (start_time + time_range .duration .value )
281+ duration = end_time - start_time
282+
283+ # Add and set properties on the pen component of the RVPaint node
284+ effectHook .add_rv_effect_props (
285+ pen_component ,
286+ {
287+ "color" : list (map (float , layer .rgba )),
288+ "brush" : [layer .brush ],
289+ "debug" : 1 ,
290+ "join" : 3 ,
291+ "cap" : 1 ,
292+ "splat" : 0 ,
293+ "mode" : 0 if layer .type == "COLOR" else 1 ,
294+ "startFrame" : start_time ,
295+ "duration" : duration ,
296+ "softDeleted" : layer .soft_deleted ,
297+ "uuid" : [layer .id ],
298+ },
152299 )
153- commands .insertFloatProperty (width_property , [world_coordinate_width ])
154300
155- if not layer .soft_deleted :
156- if not commands .propertyExists (f"{ frame_component } .order" ):
157- commands .newProperty (f"{ frame_component } .order" , commands .StringType , 1 )
301+ points_property = f"{ pen_component } .points"
302+ width_property = f"{ pen_component } .width"
303+
304+ if not commands .propertyExists (points_property ):
305+ commands .newProperty (points_property , commands .FloatType , 2 )
306+ if not commands .propertyExists (width_property ):
307+ commands .newProperty (width_property , commands .FloatType , 1 )
158308
159- commands .insertStringProperty (f"{ frame_component } .order" , [f"pen:{ stroke_id } :{ frame } :annotation" ])
309+ for point in layer .points :
310+ world_coordinate_x , world_coordinate_y , world_coordinate_width = _transform_otio_to_world_coordinate (
311+ point
312+ )
313+
314+ commands .insertFloatProperty (
315+ points_property ,
316+ [world_coordinate_x , world_coordinate_y ],
317+ )
318+ commands .insertFloatProperty (width_property , [world_coordinate_width ])
319+
320+ if not layer .soft_deleted :
321+ if not commands .propertyExists (f"{ frame_component } .order" ):
322+ commands .newProperty (f"{ frame_component } .order" , commands .StringType , 1 )
323+
324+ commands .insertStringProperty (f"{ frame_component } .order" , [f"pen:{ stroke_id } :{ frame } :annotation" ])
325+
326+ elif shape_label .startswith (("Rectangle." , "Ellipse." )):
327+ _create_bbox_shape (layer , paint_node , stroke_id , frame , shape_label )
328+
329+ elif shape_label .startswith (("Arrow." , "Line." )):
330+ _create_point_pair_shape (layer , paint_node , stroke_id , frame , shape_label )
331+
332+ elif shape_label .startswith ("Text." ):
333+ _create_text_shape (layer , paint_node , stroke_id , frame , source_node )
334+
335+ else :
336+ logging .warning (f"annotation_hook: unrecognised layer type { shape_label !r} — skipping" )
0 commit comments