from dataclasses import dataclass, field
import os
import opentimelineio as otio
SyncEvent = otio.schema.schemadef.module_from_name('SyncEvent')
from datetime import datetime
from typing import List
[docs]
def find_overlapping_clips(source_clip, target_track):
"""Find clips on target_track that overlap with source_clip on source_track.
Args:
source_clip (Clip): A OTIO clip that we want to find the associated clips in the target_track
target_track (track): The track we are looking for the associated clips in.
Return:
[Clip]: The clip(s) that are in range.
"""
# Get the time range of the source clip in the timeline's coordinate system
source_range_in_timeline = source_clip.range_in_parent()
print(f"Found {source_range_in_timeline} for clip {source_clip.name}")
# Find overlapping clips on the target track
overlapping_clips = []
return target_track.children_in_range(source_range_in_timeline)
[docs]
@dataclass
class ReviewItemFrame:
"""
The frame that is being reviewed for a particular piece of media.
There will be at least one of:
* A note
* A annotated image
* A set of annotated commands that should create the annotated image.
Attributes:
review_item (ReviewItem): The Review item associated with these frames.
frame (int): The frame that is being reviewed.
duration (int): The duration of the note, this is typically 1 frame, but it could be more.
note (str): The reviewers note, this should be in markdown format.
status (str): The status of the review, i.e. is it approved. Note, the task is stored on the media, so whether its a comp, or anim, etc.
annotation_renderer (str): If you have chosen to add the annotation commands, we need to know which renderer you were using, in case there are incompabilities between the renderers.
annotation_image (str): The path to the annotated image. Ideally this is just the annotations, or even better is a un-premultiplied PNG of the annotated image with an alpha, so you can choose whether you view just the annotation or both.
canvas_size ([width, height]): For the annotation_commands what are the units of the brush-strokes.
ocio_annotation_color_space (str): OCIO color space using the color-interop naming convention.
"""
review_item: 'ReviewItem'
frame: int = None
duration: int = 1
note: str = None
status: str = None
annotation_renderer: str = None
annotation_image: str = None
ocio_annotation_color_space: str = None
canvas_size: List[int] = field(default_factory=list)
annotation_commands: List[SyncEvent.SyncEvent] = field(default_factory=SyncEvent.SyncEvent)
[docs]
def export_svg(self):
"""Export a SVG of any annotations."""
# TODO.
return None
def _otio_clip_read(self, clip):
# Populate metadata
for field in ['note', 'task', 'status', 'annotation_renderer', 'annotation_commands']:
if field in clip.metadata:
setattr(self, field, clip.metadata[field])
def _export_otio_clip(self):
"""
Export an OTIO Clip
"""
range = otio.opentime.TimeRange(start_time=otio.opentime.RationalTime(self.frame, rate=self.review_item.media.frame_rate),
duration=otio.opentime.RationalTime(self.duration, rate=self.review_item.media.frame_rate))
newclip = otio.schema.Clip(name=f"{self.review_item.media.name}.{self.frame}",
source_range=range)
mediarange = otio.opentime.TimeRange(start_time=otio.opentime.RationalTime(0, rate=self.review_item.media.frame_rate),
duration=otio.opentime.RationalTime(self.duration, rate=self.review_item.media.frame_rate))
if self.annotation_image is not None:
if not os.path.exists(self.annotation_image):
print("WARNING: annotation file {self.annotation_image} does not exist.")
media_ref = otio.schema.ExternalReference(
target_url=f"file:/{self.annotation_image}",
available_range=mediarange
)
newclip.media_reference = media_ref
newclip.metadata['annotation_commands'] = self.annotation_commands
newclip.metadata['annotated_clip_name'] = self.review_item.media.name
for field in ['note', 'status', 'annotation_renderer']:
if getattr(self, field):
newclip.metadata[field] = getattr(self, field)
return newclip
[docs]
@dataclass
class ReviewItem:
"""
This is a single piece of reviewed media, there might be multiple notes and annotations on it.
Attributes:
media (Media): A single piece of media associated with a list of things to be reviewed.
review_frames (List[ReviewItemFrame]): A list of ReviewItemFrames that we have notes on.
"""
media: Media = field(default_factory=Media)
review_frames: List[ReviewItemFrame] = field(default_factory=list)
def _export_otio_media(self, track):
"""Export the media to the specified track"""
lastframe = self.media.calc_otio_start_frame()
for frameinfo in self.review_frames:
frame = frameinfo.frame
print(f"\t{frame}")
if frame > lastframe:
range = otio.opentime.TimeRange(start_time=otio.opentime.RationalTime(lastframe+1, rate=self.media.frame_rate),
duration=otio.opentime.RationalTime(frame - lastframe - 1, rate=self.media.frame_rate))
gap = otio.schema.Gap(name='', source_range=range)
track.append(gap)
lastframe = frame
track.append(frameinfo._export_otio_clip())
endframe = self.media.calc_otio_end_frame()
if endframe > lastframe + 1:
print(f"\tDuration:{endframe - lastframe + 1}")
range = otio.opentime.TimeRange(start_time=otio.opentime.RationalTime(0, rate=self.media.frame_rate),
duration=otio.opentime.RationalTime(endframe - lastframe, rate=self.media.frame_rate))
gap = otio.schema.Gap(name='', source_range=range)
track.append(gap)
[docs]
@dataclass
class Review:
"""
Top level review, treated as a separate timeline.
Attributes:
title (str): The title of the review
review_start_time (datetime): When did this review start, useful if there is no timestamp in the title.
participants (List[str]): A list of partipants.
location (str): Where did this review happen.
notes (str): Any other overall notes for the review, also in markdown format.
Review_items (List[ReviewItem]): The list of things being reviewed.
"""
title: str
review_start_time: datetime = None
participants: List[str] = field(default_factory=list)
location: str = None
notes: str = None
review_items: List[ReviewItem] = field(default_factory=list)
[docs]
def otio_track_read(self, reviewgroup, timeline, track, mediamap):
# Populate metadata
for field in ["title", "location", "description", "vendor_name", "participants"]:
if field in track.metadata:
setattr(self, field, track.metadata[field])
ris = []
clipmatch = {}
for clip in track:
if isinstance(clip, otio.schema.Clip):
# Need to find what clip in the main timeline matches this clip.
match_clips = find_overlapping_clips(clip, timeline.tracks[0])
if match_clips:
print("Found:", match_clips)
match_clip = match_clips[0]
if match_clip.name not in clipmatch:
# NEED TO FIND Media Entry.
clipmatch[match_clip.name] = ReviewItem()
if match_clip.name in mediamap:
main_media = mediamap[match_clip.name]
clipmatch[match_clip.name].media = main_media
else:
print(f"ERROR: cannot find media clip:{match_clip.name} Media:{mediamap.keys()}")
continue
self.review_items.append(clipmatch[match_clip.name])
ri = clipmatch[match_clip.name]
rf = ReviewItemFrame(review_item = ri, frame = clip.source_range.start_time.value)
rf._otio_clip_read(clip)
clipmatch[match_clip.name].review_frames.append(rf)
def _export_otio_track(self, reviewgroup):
"""
internal function for exporting a single track of a review group.
"""
track = otio.schema.Track(self.title)
# Populate metadata
for field in ["title", "location", "notes", "participants"]:
if getattr(self, field):
track.metadata[field] = getattr(self, field)
if self.review_start_time:
# Special case since we are converting it too.
track.metadata['review_start_time'] = self.review_start_time.isoformat()
# Loop over media exporting it.
for media in reviewgroup.media:
# Lets find the media in the review item list.
for ri in self.review_items:
if ri.media == media:
ri._export_otio_media(track)
return track
[docs]
@dataclass
class ReviewGroup:
"""
This is a container for a selection of media, and one or more review
This will create a single OTIO file.
"""
media: List[Media] = None
reviews: List[Review] = None
def _export_otio_media_track(self):
"""
Internal function for exporting all the media-tracks.
"""
track = otio.schema.Track("Media")
for mediaitem in self.media:
otioclip = mediaitem._create_otio_clip()
track.append(otioclip)
return track
[docs]
def export_otio_timeline(self):
"""
Create an otio timeline of the whole reviewgroup
"""
timeline = otio.schema.Timeline()
master_track = self._export_otio_media_track()
timeline.tracks.append(master_track)
for review in self.reviews:
timeline.tracks.append(review._export_otio_track(self))
return timeline
[docs]
def read_otio_timeline(self, timeline):
"""
Read from an existing timeline, creating a full datastructure from that timeline.
"""
track = timeline.tracks[0]
media = []
mediamap = {}
for clip in track:
if isinstance(clip, otio.schema.Clip):
m = Media()
m._otio_clip_read(clip)
media.append(m)
if m.name is None:
print(f"WARNING: clip {clip} doesnt have a name")
mediamap[m.name] = m
self.media = media
# Read the review info.
reviews = []
self.reviews = reviews
for track in timeline.tracks[1:]:
print("Got Track:", track.name)
review = Review(title=track.name)
review.otio_track_read(self, timeline, track, mediamap)
reviews.append(review)
print(f"Got {len(reviews)} reviews from timeline.")