Source code for hiero.core.FnNukeHelpers


"""
********************************************************************************
IMPORTANT: Most of the code in this file is now deprecated, with the replacement
functionality in FnNukeHelpersV2. Some of the functions are still in use (e.g.
for writing Clips, EffectTrackItems, Annotations).

If you make any further improvements to the code in this file, please move it
to FnNukeHelpersV2, clean it up, apply the changes there, and modify any usages
of it to the new version.
********************************************************************************

Punch addToNukeScript() functions into core classes to add equivalent Nuke nodes to a given script.
"""

import hiero.core
from hiero.core import (Clip,
                        Sequence,
                        TrackItem,
                        VideoTrack,
                        Format,
                        Keys,
                        Transition,
                        Annotation,
                        AnnotationText,
                        AnnotationStroke,
                        EffectTrackItem)
from . FnCompSourceInfo import (CompSourceInfo,
                                isNukeScript)

# Note, we have some ambiguity here.  'import nuke' imports the sub-module hiero.core.nuke, so we need to
# use 'import _nuke' to get the actual Nuke package.  Maybe we should rename hiero.core.nuke?
from . import nuke
import _nuke
import math
import os
import copy
import itertools
import re
import hiero.core.nuke


def nukeColourTransformNameFromHiero(colourTransformName, projectsettings):
  """
  Hiero and Nuke can have different names for equivalent colour transforms.
  This function converts from a hiero colour transform to a nuke one.

  TODO Don't think this function is necessary any more, but it's still in use,
  so leaving it alone for now
  """

  # If we're using a non nuke-default OCIO config, we don't want to do any
  # Hiero-Nuke conversions, since we most likely have selected a specific
  # colorspace from the config we are currently using.
  usingOCIO = projectsettings['lutUseOCIOForExport']
  usingNukeDefault = projectsettings["ocioConfigName"].find("nuke-default") != -1

  if usingOCIO and not usingNukeDefault:
    return colourTransformName

  if colourTransformName == 'raw':
    return 'linear'

  return colourTransformName

def isNonZeroStartFrameMovieFile(filename):
  """Returns True if the filename is a from a movie file which Nuke must read from frame 1 or later (e.g. QuickTime/FFmpeg/MXF), False otherwise"""
  (fullFileName, fileExtension) = os.path.splitext(filename)
  fileExtension = fileExtension.lower()
  if not fileExtension.startswith("."):
    fileExtension = "." + fileExtension

  # This is a list of file format extensions which Nuke must read at frame 1 (currently QuickTime+ffmpeg+MXF)
  return fileExtension in hiero.core.NonZeroStartFrameMovieFileExtensions


def getRetimeSourceOut( trackItem ):
  """ Determine the track item source out frame for writing to the OFlow node.
      This is now floored so the final frame matches what you get in the viewer, rather than possibly blending between two frames if the sourceOut() value didn't return an integer.
      The previous version of this is left commented out, this was added for Bug 45704, but caused incorrect behaviour in other cases. """
  return math.floor( trackItem.sourceOut() )
  #return trackItem.mapTimelineToSource( trackItem.timelineOut()+1 )-1


def _guidFromCopyTag(item):
  for tag in item.tags():
    if tag.name() == "Copy":
      return tag.metadata().value("tag.guid")
  return None


def _Clip_addAnnotationsToNukeScript(self, script, firstFrame, trimmed, trimStart=None, trimEnd=None):
  """ Add the annotations inside a clip to a Nuke script.  This is separated from Clip.addToNukeScript()
      so it's easier to control where in the script the annotations are placed.  The parameters are used to determine
      the frame range for the annotations. """

  startFrame = self.sourceIn()
  start, end = _Clip_getStartEndFrames(self, firstFrame, trimmed, trimStart, trimEnd)
  subTrackItemsOffset = firstFrame + startFrame - start
  if trimStart is not None:
    subTrackItemsOffset += trimStart

  # Add clip internal annotations
  annotations = [ item for item in itertools.chain( *itertools.chain(*self.subTrackItems()) ) if isinstance(item, Annotation) ]

  annotationNodes = createAnnotationsGroup(script, annotations, subTrackItemsOffset, inputs=1)

  return annotationNodes


Clip.addAnnotationsToNukeScript = _Clip_addAnnotationsToNukeScript


def _Clip_getStartEndFrames(self, firstFrame, trimmed, trimStart, trimEnd):
  """ Helper function to determine the start and end frames when writing a clip to a Nuke script.

      @param firstFrame: Custom offset to move start frame of clip
      @param trimmed: If True, a TimeClip node will be added to trim the range output by the Read node. The range defaults to the clip's soft trim range. If soft trims are not enabled on the clip, the range defaults to the clip range. The range can be overridden by passing trimStart and/or trimEnd values.
      @param trimStart: Override the trim range start with this value.
      @param trimEnd: Override the trim range end with this value.
  """

  source = self.mediaSource()
  fi = source.fileinfos()[0]

  # Get start frame. First frame of an image sequence. Zero if quicktime/r3d
  startFrame = self.sourceIn()
  hiero.core.log.debug( "startFrame: " + str(startFrame) )

  # Initialise to the source length, starting at the first frame of the media.
  start = startFrame
  end = start + self.duration()-1
  # Trim if soft trims are available and requested or they specified a trim range.
  if trimmed:
    # Everything within Hiero is zero-based so add file start frame to get real frame numbers at end.
    if self.softTrimsEnabled():
      start = self.softTrimsInTime()
      end = self.softTrimsOutTime()

    # Offset the trim range by the source.startTime() since the user sees frame numbers
    # on Sequences (including Clips) in the UI start numbering from 0.
    if trimStart is not None:
      start = trimStart + startFrame
    if trimEnd is not None:
      end = trimEnd + startFrame

  return start, end


#this map is used for mapping enum values from hiero to nuke
localisationMap = {
  Clip.kOnLocalize : "on",
  Clip.kAutoLocalize : "fromAutoLocalizePath",
  Clip.kOnDemandLocalize : "onDemand",
  Clip.kOffLocalize : "off" }

def _Clip_readInfoKey(self, readFilename):
  """ Generate a key for looking up read info for a clip. This is needed because
  you can have multiple clips which read the same source media with different ranges.
  In this case, separate Read nodes should be generated.
  """
  return "%s-%s-%s" % (readFilename, self.sourceIn(), self.duration())

def _Clip_getFilePath(self):
  """ Helper function for getting the file path from a Clip. This tries to use
  the Read node, but there are cases where this gets called for a Clip which was
  generated by the exporter and doesn't have a node, so in this case fall back to
  the old code which uses the clip's MediaSource.
  """
  try:
    path = self.readNode()['file'].getText()

    # For consistency with the behaviour before the path was being taken from the
    # Read node, and because it can cause problems with the frame server, apply path
    # remap settings to the path set in the comp
    path = hiero.core.util.remapPath(path)
    return path
  except:
    return self.mediaSource().fileinfos()[0].filename()


def _Clip_getReadInfo(self, firstFrame=None):
  """
  Get information (filename and start at value) for any Read Node in this clip.

  @param firstFrame: Custom offset to move start frame of clip
  """
  assert isinstance(self, Clip), "This function can only be punched into a Clip object."

  source = self.mediaSource()

  if source is None:
    return {}

  readFileInfo = {}

  readFilename = _Clip_getFilePath(self)

  try:
    compInfo = CompSourceInfo(self)
    if compInfo.isComp():
      # Change the readFilename to point to the nk script render path
      readFilename = compInfo.writePath
  except RuntimeError:
    if isNukeScript(readFilename):
      readFilename = None

  if readFilename is not None:
    readInfoKey = _Clip_readInfoKey(self, readFilename)
    readFileInfo[readInfoKey] = firstFrame

  return readFileInfo

Clip.getReadInfo = _Clip_getReadInfo


# Set of knob names to ignore when adding knobs from a clip's Read node
# to the generated script. These are already being handled by the export
# code
_Clip_readNodeKnobsToIgnore = set(('name',
                                  'file',
                                  'width',
                                  'height',
                                  'pixelAspect',
                                  'first',
                                  'last',
                                  'localizationPolicy'))

def _Clip_addReadNodeKnobs(clip, scriptReadNode):
  """ Add the knobs from a clip's Read node to the generated script. Knobs are
  only written if aren't in the list of knobs which are being handled separately.
  """
  # Get the Read node. This might not exist if the clip was generated by the
  # export process and so does not belong to a project.
  try:
    clipReadNode = clip.readNode()
  except:
    return

  # To get the knob script, tell the node to write it's knobs, then parse the output.
  # You can get the script for individual knobs, but it doesn't always seem to written
  # in the form it would appear in the nk.
  knobsScript = clipReadNode.writeKnobs(_nuke.TO_SCRIPT|_nuke.WRITE_NON_DEFAULT_ONLY).split('\n')
  for knobScript in knobsScript:
    # Each line consists of the knob name, a space, then the value. Find the first
    # space and split the string. Some lines come in empty, in which case an exception
    # will be thrown
    try:
      firstSpace = knobScript.index(' ')
      name = knobScript[:firstSpace]
      if name not in _Clip_readNodeKnobsToIgnore:
        value = knobScript[firstSpace+1:]
        scriptReadNode.setKnob(name, value)
    except ValueError:
      continue


def _Clip_addToNukeScript(self,
                          script,
                          additionalNodes=None,
                          additionalNodesCallback=None,
                          firstFrame=None,
                          trimmed=True,
                          trimStart=None,
                          trimEnd=None,
                          colourTransform=None,
                          metadataNode=None,
                          includeMetadataNode=True,
                          nodeLabel=None,
                          enabled=True,
                          includeEffects=True,
                          beforeBehaviour=None,
                          afterBehaviour=None,
                          project = None,
                          readNodes = {},
                          addEffectsLifetime=True,
                          perFrameMetadataOffset=0):
  """addToNukeScript(self, script, trimmed=True, trimStart=None, trimEnd=None)

  Add a Read node to the Nuke script for each media sequence/file used in this clip. If there is no media, nothing is added.

  @param script: Nuke script object to add nodes
  @param additionalNodes: List of nodes to be added post read
  @param additionalNodesCallback: callback to allow custom additional node per item function([Clip|TrackItem|Track|Sequence])
  @param firstFrame: Custom offset to move start frame of clip
  @param trimmed: If True, a TimeClip node will be added to trim the range output by the Read node. The range defaults to the clip's soft trim range. If soft trims are not enabled on the clip, the range defaults to the clip range. The range can be overridden by passing trimStart and/or trimEnd values.
  @param trimStart: Override the trim range start with this value.
  @param trimEnd: Override the trim range end with this value.
  @param colourTransform: if specified, is set as the color transform for the clip
  @param metadataNode: node containing metadata to be inserted into the script
  @param includeMetadataNode: specifies whether a metadata node should be added to the script
  @param nodeLabel: optional label for the Read node
  @param enabled: enabled status of the read node. True by default
  @param includeEffects: if True, soft effects in the clip are included
  @param beforeBehaviour: What to do for frames before the first ([hold|loop|bounce|black])
  @param afterBehaviour: What to do for frames after the last ([hold|loop|bounce|black])
  """

  hiero.core.log.debug( "trimmed=%s, trimStart=%s, trimEnd=%s, firstFrame=%s" % (str(trimmed), str(trimStart), str(trimEnd), str(firstFrame)) )
  # Check that we are on the right type of object, just to be safe.
  assert isinstance(self, Clip), "This function can only be punched into a Clip object."

  added_nodes = []

  source = self.mediaSource()

  if source is None:
    # TODO: Add a constant here so that offline media has some representation within the nuke scene.
    # For now just do nothing
    return added_nodes

  # Get start frame. First frame of an image sequence. Zero if quicktime/r3d
  startFrame = self.sourceIn()
  hiero.core.log.debug( "startFrame: " + str(startFrame) )

  start, end = _Clip_getStartEndFrames(self, firstFrame, trimmed, trimStart, trimEnd)

  # Grab clip format
  format = self.format()

  isRead = False
  isPostageStamp = False

  readFilename = _Clip_getFilePath(self)

  hiero.core.log.debug( "- adding Nuke node for:%s %s %s", readFilename, start, end )

  # When writing a clip which points to an nk script, we can't just add a Read
  # node with the nk as path.
  # For an nk clip, try to find the metadata for the write path, and use that
  # in the Read node.  This should be present, it's set by nkReader.  If it's
  # not, CompSourceInfo will throw an exception, fall back to using a Precomp
  # just in case
  try:
    compInfo = CompSourceInfo(self)
    if compInfo.isComp():
      # Change the readFilename to point to the nk script render path
      readFilename = compInfo.unexpandedWritePath
  except RuntimeError:
    if isNukeScript(readFilename):
      # Create a Precomp node and reset readFilename to prevent a Read node
      # being created below
      read_node = nuke.PrecompNode( readFilename )
      readFilename = None

  # If there is a read filename, create a Read node
  if readFilename:
    # First check if we want to create a PostageStamp or Read node
    readInfoKey = _Clip_readInfoKey(self, readFilename)
    if enabled and readInfoKey in readNodes:
      readInfo = readNodes[readInfoKey]

      # Increment the usage
      readInfo.instancesUsed += 1
    else:
      readInfo = None

    # Only create a Read Node if this is the only usage of this filename, or this
    # is the last usage
    isPostageStamp = readInfo is not None and readInfo.instancesUsed < readInfo.totalInstances

    if isPostageStamp:
      # We will need a push command to connect it its Read Node
      pushCommandID = readInfo.readNodeID + "_" + str(readInfo.totalInstances)
      pushCommand = nuke.PushNode(pushCommandID)
      if script is not None:
        script.addNode(pushCommand)
      added_nodes.append(pushCommand)

      read_node = nuke.PostageStampNode()
    else:
      read_node = nuke.ReadNode(readFilename,
                                format.width(),
                                format.height(),
                                format.pixelAspect(),
                                round(start),
                                round(end),)
      read_node.setKnob("localizationPolicy", localisationMap[self.localizationPolicy()] )

      if firstFrame is not None:
        read_node.setKnob("frame_mode", 'start at')
        read_node.setKnob("frame", firstFrame)

      if beforeBehaviour is not None:
        read_node.setKnob("before", beforeBehaviour)
      if afterBehaviour is not None:
        read_node.setKnob("after", afterBehaviour)

      # Add the knobs from the clip's own Read node
      _Clip_addReadNodeKnobs(self, read_node)

      # If the colourTransform was specified, set the 'colorspace' knob to it,
      # overriding any settings from the Read node
      if colourTransform:
        read_node.setKnob('colorspace', colourTransform)

      # Set the color the node appears in the DAG. In Studio this is currently
      # stored on the parent bin item. The script format for the color is in the
      # form 0xrrggbbaa
      binItem = self.binItem()
      if binItem and binItem.displayColor().isValid():
        rgba = binItem.displayColor().getRgb() # Get tuple of rgba components
        colorString = "0x" + "".join("%02x" % i for i in rgba)
        read_node.setKnob("tile_color", colorString)

      isRead = True



  # If a node name has been specified
  if nodeLabel is not None:
    read_node.setKnob("label", nodeLabel)

  if script is not None:
    script.addNode(read_node)
  added_nodes.append(read_node)

  if readInfo:
    if isRead:
      # We'll need a set and a push command, so that the script can be reordered later to put all
      # the Read and associated Set commands to the top. The Push commands will stay
      # where they are so that Nodes will connect up properly afterwards
      if enabled:
        setCommandID = readInfo.readNodeID + "_" + str(readInfo.totalInstances)
      else:
        setCommandID = readInfo.readNodeID + "_disabled"

      setCommand = nuke.SetNode(setCommandID, 0)
      pushCommand = nuke.PushNode(setCommandID)
      if script is not None:
        script.addNode(setCommand)
        script.addNode(pushCommand)
      added_nodes.append(setCommand)
      added_nodes.append(pushCommand)
    elif isPostageStamp:
      # If it's a postage stamp node, we'll also need a time offset to correct the Frame Range
      # relative to the original read
      originalFirstFrame = readInfo.startAt
      if originalFirstFrame is not None:
        # There's a slight difference between how frame ranges are handled by Read Nodes
        # and TimeOffset's in Nuke, and the information we pass.
        # Ideally, the Timeoffset would work entirely in floating point, but it, and its interface,
        # don't. We have added the dtime_offset as a workaround for this, but there's an additional
        # problem that the Read Node's original range (originalFirstFrame here) gets cast to int
        # before getting through to Timeoffset.
        # This means that Timeoffset cannot properly process the range because the fractional part
        # of originalFirstFrame has already been lost. We compensate for that here, by adding the
        # fractional part to the Timeoffset value.
        fractpart, intpart = math.modf(originalFirstFrame)
        if fractpart is None:
          fractpart = 0
        timeOffset = nuke.TimeOffsetNode(firstFrame - originalFirstFrame + fractpart)
        if script is not None:
          script.addNode(timeOffset)
        added_nodes.append(timeOffset)

  if not isRead and not isPostageStamp and firstFrame is not None:

    timeClip = nuke.TimeClipNode( round(start), round(end), start, end, round(firstFrame) )
    added_nodes.append( timeClip )

  if not enabled:
    read_node.setKnob("disable", True)

  if includeMetadataNode:
    if metadataNode is None:
      metadataNode = nuke.MetadataNode()
      if script is not None:
        script.addNode(metadataNode)
      added_nodes.append(metadataNode)
      metadataNode.setInputNode(0, read_node)

    metadataNode.addMetadata([("hiero/clip", self.name())])
    # Also set the reel name (if any) on the metadata key the dpx writer expects for this.
    clipMetadata = self.metadata()
    if Keys.kSourceReelId in clipMetadata:
      reel = clipMetadata[Keys.kSourceReelId]
      if len(reel):
        metadataNode.addMetadata( [ ("hiero/reel", reel), ('dpx/input_device', reel), ('quicktime/reel', reel) ] )

    # Add Tags to metadata
    metadataNode.addMetadataFromTags( self.tags(), "clip/tags/" )

  if includeEffects:
    # Add clip internal soft effects
    # We need to offset the frame range of the effects from clip time into the output time.
    if firstFrame is not None:
      effectOffset = firstFrame + startFrame - start
    else:
      effectOffset = startFrame

    effects = [ item for item in itertools.chain( *itertools.chain(*self.subTrackItems()) ) if isinstance(item, EffectTrackItem) ]
    hiero.core.log.info("Clip.addToNukeScript effects %s %s" % (effects, self.subTrackItems()))
    for effect in effects:
      added_nodes.extend( effect.addToNukeScript(script, effectOffset, addLifetime=addEffectsLifetime) )

  postReadNodes = []
  if includeMetadataNode:
    nuke.appendMetadataNodesForPerFrameTagsToList( self.tags(), "clip/tags/", postReadNodes, perFrameMetadataOffset )
  if callable(additionalNodesCallback):
    postReadNodes.extend(additionalNodesCallback(self))

  if additionalNodes is not None:
    postReadNodes.extend(additionalNodes)

  if includeMetadataNode:
    prevNode = metadataNode
  else:
    prevNode = read_node

  for node in postReadNodes:
    # Add additional nodes
    if node is not None:
      node = copy.deepcopy(node)
      node.setInputNode(0, prevNode)
      prevNode = node

      # Disable additional nodes too
      if not enabled:
        node.setKnob("disable", "true")

      added_nodes.append(node)
      if script is not None:
        script.addNode(node)

  return added_nodes


Clip.addToNukeScript = _Clip_addToNukeScript


def _TrackItem_getTransitions(trackItem):
  """ Get a track item's transitions if they exist and are enabled. """
  inTransition = trackItem.inTransition() if trackItem.inTransition() and trackItem.inTransition().isEnabled() else None
  outTransition = trackItem.outTransition() if trackItem.outTransition() and trackItem.outTransition().isEnabled() else None

  return inTransition, outTransition


def _TrackItem_addToNukeScript(self,
                              script=nuke.ScriptWriter(),
                              firstFrame=None,
                              additionalNodes=[],
                              additionalNodesCallback=None,
                              includeRetimes=False,
                              retimeMethod=None,
                              startHandle=None,
                              endHandle=None,
                              colourTransform=None,
                              offset=0,
                              nodeLabel=None,
                              includeAnnotations=False,
                              includeEffects=True,
                              outputToSequenceFormat=False):
  """This is a variation on the Clip.addToNukeScript() method that remaps the
  Read frame range to the range of the this TrackItem rather than the Clip's
  range. TrackItem retimes and reverses are applied via Retime and OFlow nodes
  if needed. The additionalNodes parameter takes a list of nodes to add before
  the source material is shifted to the TrackItem timeline time and trimmed to
  black outside of the cut. This means timing can be set in the original
  source range and adding channels, etc won't affect frames outside the cut
  length.

  @param retimeMethod: "Motion", "Blend", "Frame" - Knob setting for OFlow retime method
  @param offset: Optional, Global frame offset applied across whole script
  """

  # Check that we are on the right type of object, just to be safe.
  assert isinstance(self, TrackItem), "This function can only be punched into a TrackItem object."

  hiero.core.log.debug( "Add TrackItem (%s) to script, startHandle = %s, endHandle = %s, firstFrame=%s" % (self.name(), str(startHandle), str(endHandle), str(firstFrame)) )

  added_nodes = []
  sequencePerFrameMetadataNodes = []

  retimeRate = 1.0
  if includeRetimes:
    retimeRate = self.playbackSpeed()

  # Compensate for retime in HandleLength!!
  if startHandle is None:
    startHandle = 0
  if endHandle is None:
    endHandle = 0

  # Check for transitions
  inTransition, outTransition = _TrackItem_getTransitions(self)
  inTransitionHandle, outTransitionHandle = 0, 0

  # Adjust the clips to cover dissolve transition
  if outTransition is not None:
    if outTransition.alignment() == Transition.kDissolve:
      # Calculate the delta required to move the end of the clip to cover the dissolve transition
      outTransitionHandle = (outTransition.timelineOut() - self.timelineOut())
  if inTransition is not None:
    if inTransition.alignment() == Transition.kDissolve:
      # Calculate the delta required to move the beginning of the clip to cover the dissolve transition
      inTransitionHandle = (self.timelineIn() - inTransition.timelineIn())


  # If the clip is reversed, we need to swap the start and end times
  start = min(self.sourceIn(), self.sourceOut())
  end = max(self.sourceIn(), self.sourceOut())

  # Extend handles to incorporate transitions
  # If clip is reversed, handles are swapped
  if retimeRate >= 0.0:
    inHandle = startHandle + inTransitionHandle
    outHandle = endHandle + outTransitionHandle
  else:
    inHandle = startHandle + outTransitionHandle
    outHandle = endHandle + inTransitionHandle

  clip = self.source()
  # Recalculate handles clamping to available media range
  readStartHandle = min(start,  math.ceil(inHandle * abs(retimeRate) ))
  readEndHandle = min((clip.duration() - 1) - end ,  math.ceil(outHandle * abs(retimeRate) ))

  hiero.core.log.debug ( "readStartHandle", readStartHandle, "readEndHandle", readEndHandle )

  # Add handles to source range
  start -= readStartHandle
  end += readEndHandle

  # Read node frame range
  readStart, readEnd = start, end
  # First frame identifies the starting frame of the output. Defaults to timeline in time
  readNodeFirstFrame = firstFrame
  if readNodeFirstFrame is None:
    readNodeFirstFrame = self.timelineIn() -  min( min(self.sourceIn(), self.sourceOut()), inHandle)
  else:
    # If we have trimmed the handles, bump the start frame up by the difference
    readNodeFirstFrame += round(inHandle * abs(retimeRate)) - readStartHandle

  # Apply global offset
  readNodeFirstFrame+=offset

  # Calculate the frame range, != read range as read range may be clamped to available media range
  first_frame=start
  last_frame=end
  if firstFrame is not None:
    # if firstFrame is specified
    last_frame =  firstFrame + (startHandle + inTransitionHandle) + (self.duration() -1) + (endHandle + outTransitionHandle)
    hiero.core.log.debug( "last_frame(%i) =  firstFrame(%i) + startHandle(%i) + (self.duration() -1)(%i) + endHandle(%i)" % (last_frame, firstFrame, startHandle + inTransitionHandle, (self.duration() -1), endHandle + outTransitionHandle) )
    first_frame = firstFrame
  else:
    # if firstFrame not specified, use timeline time
    last_frame =  self.timelineIn() + (self.duration() -1) + (endHandle + outTransitionHandle)
    first_frame = (self.timelineIn() - (startHandle + inTransitionHandle))
    hiero.core.log.debug( "first_frame(%i) =  self.timelineIn(%i) - (startHandle(%i) + inTransitionHandle(%i)" % (first_frame, self.timelineIn(), startHandle, inTransitionHandle) )

  # Create a metadata node
  metadataNode = nuke.MetadataNode()
  reformatNode = None

  # Add TrackItem metadata to node
  metadataNode.addMetadata([("hiero/shot", self.name()), ("hiero/shot_guid", _guidFromCopyTag(self))])

  # sequence level metadata
  seq = self.parentSequence()
  if seq:
    seqTimecodeStart = seq.timecodeStart()
    seqTimecodeFrame = seqTimecodeStart + self.timelineIn() - inHandle
    seqTimecode = hiero.core.Timecode.timeToString(seqTimecodeFrame, seq.framerate(), hiero.core.Timecode.kDisplayTimecode)

    metadataNode.addMetadata( [ ("hiero/project", clip.project().name() ),
                                ("hiero/sequence/frame_rate", seq.framerate() ),
                                ("hiero/sequence/timecode", "[make_timecode %s %s %d]" % (seqTimecode, str(seq.framerate()), first_frame) )
                                ] )

  # Add Tags to metadata
  metadataNode.addMetadataFromTags( self.tags(), "shot/tags/" )

  # Add Track and Sequence here as these metadata nodes are going to be added per clip/track item. Not per sequence or track.
  if self.parent():
    metadataNode.addMetadata([("hiero/track", self.parent().name()), ("hiero/track_guid", _guidFromCopyTag(self.parent()))])
    metadataNode.addMetadataFromTags( self.parent().tags(), "track/tags/" )
    if self.parentSequence():
      metadataNode.addMetadata([("hiero/sequence", self.parentSequence().name()), ("hiero/sequence_guid", _guidFromCopyTag(self.parentSequence()))])
      metadataNode.addMetadataFromTags( self.parentSequence().tags(), "sequence/tags/" )
      nuke.appendMetadataNodesForPerFrameTagsToList( self.parentSequence().tags(), "sequence/tags/", sequencePerFrameMetadataNodes )

      # If we have clip and we're in a sequence then we output the reformat settings as another reformat node.
      reformat = self.reformatState()
      if reformat.type() != nuke.ReformatNode.kDisabled:
        formatString = str(seq.format())
        reformatNode = nuke.ReformatNode( resize=reformat.resizeType(),
                                          center=reformat.resizeCenter(),
                                          flip=reformat.resizeFlip(),
                                          flop=reformat.resizeFlop(),
                                          turn=reformat.resizeTurn(),
                                          to_type=reformat.type(),
                                          format=formatString,
                                          scale=reformat.scale(),
                                          pbb=True)

  # To support the TimeWarp effect, we now set the full clip's frame range in the Read node, with the desired.
  # frames selected by the TimeClip node.  This shifts the Read nodes 'start at' frame to compensate.
  readNodeFirstFrame -= readStart

  # Capture the clip nodes without adding to the script, so that we can group them as necessary
  clip_nodes = clip.addToNukeScript(None,
                                    firstFrame=readNodeFirstFrame,
                                    colourTransform=colourTransform,
                                    metadataNode=metadataNode,
                                    nodeLabel=nodeLabel,
                                    enabled=self.isEnabled(),
                                    includeEffects=includeEffects,
                                    perFrameMetadataOffset=int(self.timelineIn() - readStart))

  # Add the read node to the script
  # This assumes the read node will be the first node
  read_node = clip_nodes[0]
  if script:
    script.addNode(read_node)
  added_nodes.append(read_node)

  if includeAnnotations:
    # Add the clip annotations.  This goes immediately after the Read, so it is affected by the Reformat if there is one
    clipAnnotations = clip.addAnnotationsToNukeScript(script, firstFrame=readNodeFirstFrame, trimmed=True, trimStart=readStart, trimEnd=readEnd)
    added_nodes.extend(clipAnnotations)

  added_nodes.extend( clip_nodes[1:] )
  # Add all other clip nodes to the group
  for node in clip_nodes[1:]:
    script.addNode(node)

  # Add reformat node
  if reformatNode is not None:
    added_nodes.append(reformatNode)
    script.addNode(reformatNode)

  # Add sequence per frame metadata nodes
  for node in sequencePerFrameMetadataNodes:
    if not self.isEnabled():
      node.setKnob("disable", True)
    added_nodes.append(node)
    script.addNode(node)

  # Add metadata node
  added_nodes.append(metadataNode)
  script.addNode(metadataNode)

  # This parameter allow the whole nuke script to be shifted by a number of frames
  first_frame += offset
  last_frame += offset

  # Frame range is used to correct the range from OFlow
  timeClipNode = nuke.TimeClipNode( first_frame, last_frame, clip.sourceIn(), clip.sourceOut(), first_frame)
  timeClipNode.setKnob('label', 'Set frame range to [knob first] - [knob last]')
  added_nodes.append(timeClipNode)
  script.addNode(timeClipNode)

  # Add Additional nodes.
  postReadNodes = []
  if callable(additionalNodesCallback):
    postReadNodes.extend(additionalNodesCallback(self))
  if additionalNodes is not None:
    postReadNodes.extend(additionalNodes)

  # Add any additional nodes.
  for node in postReadNodes:
    if node is not None:
      node = copy.deepcopy(node)
      # Disable additional nodes too
      if not self.isEnabled():
        node.setKnob("disable", True)

      added_nodes.append(node)
      script.addNode(node)

  assert (not includeRetimes) or (retimeMethod is not None), "includeRetimes is true and retimeMethod is None"

  # If this clip is a freeze frame add a frame hold node
  isFreezeFrame = (retimeRate == 0.0)
  if isFreezeFrame:
    # first_frame is max of first_frame and readNodeFirstFrame because when
    # using a dissolve with a still clip first_frame is the first frame of 2
    # clips, which is lower than readNodeFirstFrame.
    frameHoldNode = nuke.Node("FrameHold", first_frame=max(first_frame, readNodeFirstFrame))
    added_nodes.append(frameHoldNode)
    script.addNode(frameHoldNode)

  # If the clip is retimed we need to also add an OFlow node.
  elif includeRetimes and retimeRate != 1 and retimeMethod != 'None' and retimeMethod is not None:

    # Obtain keyFrames
    tIn, tOut = self.timelineIn(), self.timelineOut()
    sIn, sOut = self.sourceIn(), getRetimeSourceOut(self)

    hiero.core.log.debug("sIn %f sOut %f tIn %i tOut %i" % (sIn, sOut, tIn, tOut))
    # Offset keyFrames, so that they match the input range (source times) and produce expected output range (timeline times)
    # timeline values must start at first_frame
    tOffset = (first_frame + startHandle + inTransitionHandle) - self.timelineIn()
    tIn += tOffset
    tOut += tOffset
    sOffset = readNodeFirstFrame
    sIn += sOffset
    sOut += sOffset

    hiero.core.log.debug("Creating OFlow:", tIn, sIn, tOut, sOut)
    # Create OFlow node for computed keyFrames
    keyFrames = "{{curve l x%d %f x%d %f}}" % (tIn, sIn, tOut, sOut)
    oflow = nuke.Node("OFlow2",
      interpolation=retimeMethod,
      timing="Source Frame",
      timingFrame=keyFrames)
    oflow.setKnob('label', 'retime ' + str(retimeRate))
    added_nodes.append(oflow)
    script.addNode(oflow)

  # Find linked effects if includeEffects is specified
  linkedEffects = []
  if includeEffects:
    effectOffset = first_frame - self.timelineIn() + inHandle
    linkedEffects = [ item for item in self.linkedItems() if isinstance(item, hiero.core.EffectTrackItem) ]

    # If includeRetimes is False, do not include retime effects in the export.  Note that clip-level Timewarps will still be included.
    # That's a lot trickier to deal with (how do we copy those when doing Build Track?), so leaving that for now.
    if not includeRetimes:
      linkedEffects = [ effect for effect in linkedEffects if not effect.isRetimeEffect() ]

    # Make sure the effects are in the correct order.  They should be written from lowest sub-track to highest
    linkedEffects.sort(key = lambda effect: effect.subTrackIndex())

  # If outputting to sequence format, or there are any linked effects, need to make sure the format matches the sequence.
  # If the track item reformat state is not set to 'to format', we need to:
  # - Add a Reformat node to sequence
  # - Then write out any linked effects
  # - Then, if not outputting to sequence format, add further reformat nodes to get back to whatever the format was before that

  # TODO Some of this code is duplicated in NukeShotExporter.writeTrackItem(), it needs to be cleaned up

  reformatState = self.reformatState()
  itemSetToSequenceFormat = (reformatState.type() == nuke.ReformatNode.kToFormat)
  needAdditionalReformatNodes = (not itemSetToSequenceFormat) and (outputToSequenceFormat or linkedEffects)

  if needAdditionalReformatNodes:
    # Reformat to sequence
    toSequenceFormatNode = nuke.ReformatNode( resize=nuke.ReformatNode.kResizeNone,
                                              format=str(seq.format()),
                                              center=reformatState.resizeCenter(),
                                              black_outside=False,
                                              pbb=True)
    script.addNode(toSequenceFormatNode)
    added_nodes.append( toSequenceFormatNode )

  # Write out the linked effects.
  for effect in linkedEffects:
    added_nodes.extend( effect.addToNukeScript( script,
                                                effectOffset,
                                                startHandle=inHandle,
                                                endHandle=outHandle,
                                                addLifetime = False
                                                ) )

  # If not outputting to sequence format, add Reformats to get back to the previous format state
  if needAdditionalReformatNodes and not outputToSequenceFormat:
    clipFormatNode = nuke.ReformatNode( resize=nuke.ReformatNode.kResizeNone,
                                        format=str(self.source().format()),
                                        center=reformatState.resizeCenter(),
                                        black_outside=False,
                                        pbb=True)
    script.addNode(clipFormatNode)
    added_nodes.append(clipFormatNode)

    # If the item reformat is set to 'Scale' we need to add two Reformat nodes to restore the format, one to put it back to the clip format,
    # and here another to re-apply the scaling.  The image has already been scaled, so resize knob should be 'none'
    if reformatState.type() == nuke.ReformatNode.kToScale:
      scaleReformatNode = nuke.ReformatNode(resize=nuke.ReformatNode.kResizeNone,
                                            to_type=reformatState.type(),
                                            scale=reformatState.scale(),
                                            pbb=True,
                                            black_outside=False)
      script.addNode(scaleReformatNode)
      added_nodes.append(scaleReformatNode)

  return added_nodes

TrackItem.addToNukeScript = _TrackItem_addToNukeScript


def createAnnotationsGroup(script, annotations, offset, inputs, cliptype=None):
  """ Add a list of annotations to a script and place them inside a group. """

  # Don't do anything if there are no annotations
  if len(annotations) == 0:
    return []

  # Create the group with its disable knob linked to the annotations_show knob which we add to
  # the root node.
  annotationsGroup = nuke.GroupNode("Annotations", disable="{{!annotations_show }}", inputs=inputs)

  # If there are inputs, create an Input node for the group
  if inputs:
    annotationsGroup.addNode( nuke.Node("Input", inputs=0) )

  # Add the annotations to the group
  for annotation in annotations:
    annotation.addToNukeScript(annotationsGroup, offset=offset, inputs=inputs, cliptype=cliptype)
    inputs = 1 # Once we've added a node, any following should take their input from it

  # Add an Output node to the group
  annotationsGroup.addNode( nuke.Node("Output") )

  if script:
    script.addNode( annotationsGroup )

  # For consistency with all the other functions in this file, return a list of nodes
  return [ annotationsGroup ]


def _addTrackSubTrackItems(itemFilter, track, script, offset, inputs):
  """ Write out the sub-track items of type itemType for a track. """

  # Build a list of effects across all the sub-tracks
  items = [ item for item in itertools.chain(*track.subTrackItems()) if itemFilter(item) ]

  # Write them to the script.  If the track has no clips on it and the sequence is being written disconnected,
  # there might not be any inputs, after the first item is added set inputs to 1.
  added_nodes = []
  for item in items:
    itemNodes = item.addToNukeScript(script, offset, inputs)
    added_nodes.extend(itemNodes)
    inputs=1

  return added_nodes


def _addEffectsAnnotationsForTrack(track, includeEffects, includeAnnotations, script, offset, inputs=1):
  """ Write the soft effects and annotations for a given track. """

  added_nodes = []

  if includeAnnotations:
    annotationNodes = _addTrackSubTrackItems(lambda x: isinstance(x, Annotation), track, script, offset, inputs)
    if annotationNodes:
      added_nodes.extend( annotationNodes )
      inputs = 1

  if includeEffects:

    ## first add metadata node so the sequence time is correct in case of retimes on the clips

    sequence = track.parent()
    timecodeStart = sequence.timecodeStart()
    try:
     firstFrame = sequence.inTime()
    except:
     firstFrame = 0
    timecodeFrame = timecodeStart + firstFrame - offset
    scriptFrame = firstFrame

    timecodeStr = hiero.core.Timecode.timeToString(timecodeFrame, sequence.framerate(), hiero.core.Timecode.kDisplayTimecode)

    metadataNode = nuke.MetadataNode(inputs=inputs)
    metadataNode.addMetadata( [   ("hiero/sequence/timecode", "[make_timecode %s %s %d]" % (timecodeStr, str(sequence.framerate()), scriptFrame) ) ] )
    script.addNode( metadataNode )
    added_nodes.append(metadataNode)
    inputs = 1 # Next Node has metadata as input

    # Add track level effects, not including ones linked to a track item.  Those are added in TrackItem.addToNukeScript
    added_nodes.extend( _addTrackSubTrackItems( lambda x: isinstance(x, EffectTrackItem) and not x.linkedItems(), track, script, offset, inputs ) )

  return added_nodes


def _VideoTrack_addToNukeScript(self,
                                script = nuke.ScriptWriter(),
                                additionalNodes=[],
                                additionalNodesCallback=None,
                                includeRetimes=False,
                                retimeMethod=None,
                                offset=0,
                                skipOffline=True,
                                mediaToSkip=[],
                                disconnected=False,
                                includeAnnotations=False,
                                includeEffects=True):
  """Add a Read node for each track item to the script with Merge or Dissolve nodes
  to join them in a sequence. TimeClip nodes are added to pad any gaps between clips.

  @param script: Nuke script object to add nodes to.
  @param additionalNodes: List of nodes to be added post read, passed on to track items
  @param additionalNodesCallback: callback to allow custom additional node per item function([Clip|TrackItem|Track|Sequence])
  @param includeRetimes: True/False include retimes
  @param retimeMethod: "Motion", "Blend", "Frame" - Knob setting for OFlow retime method
  @param offset: Optional, Global frame offset applied across whole script
  @param skipOffline: If True, offline clips are not included in the export
  @param mediaToSkip: List of MediaSources which should be excluded from the export
  @param disconnected: If True, items on the track are not connected and no constant nodes are added to fill gaps
  @param includeAnnotations: If True, clip-level annotations will be included in the output
  @param includeEffects: If True, clip-level soft effects will be included in the output
  """

  # Check that we are on the right type of object, just to be safe.
  assert isinstance(self, VideoTrack), "This function can only be punched into a VideoTrack object."

  added_nodes = []

  merge_nodes = []

  sequence = self.parent()

  # Get the sequence format for setting on Constant nodes
  sequenceFormatStr = str(sequence.format())

  # Build the track by generating script for each TrackItem and using TimeClip nodes to set the active
  # frame ranges for the TrackItems.
  # Gaps in the track are handled by using black outside in the TimeClips.
  lastInTime = sequence.duration()
  lastTrackItem = None

  # For tracks with multiple track items, we will be creating either Merge nodes or Dissolves to
  # join the items together. However the Merge will be created on the next track item processed
  # after the Dot is created, ie. we will need to create the Dot node on the current track item,
  # but then connect it to a Node that is created on the next. We use lastDot to facilitate this.
  lastDot = None

  # Work backwards so that the Merge nodes hook up the right way around.
  for trackItem in reversed(list(self.items())):
    source = trackItem.source().mediaSource()

    # Check if the source is in the mediaToSkip list
    if source in mediaToSkip:
      continue

    if not source.isMediaPresent() and skipOffline:
      continue

    hiero.core.log.debug( "  - " + str(trackItem) )

    # Check for transitions
    inTransition, outTransition = _TrackItem_getTransitions(trackItem)

    script.pushLayoutContext("clip", trackItem.name() + str(trackItem.eventNumber()), label=trackItem.name())

    # In case additional nodes is a Tuple, we need to be able to append.
    tiAdditionalNodes = list(additionalNodes)

    trackitem_nodes = trackItem.addToNukeScript(script,
                                                additionalNodes=tiAdditionalNodes,
                                                additionalNodesCallback=additionalNodesCallback,
                                                includeRetimes=includeRetimes,
                                                retimeMethod=retimeMethod,
                                                offset=offset,
                                                includeAnnotations=includeAnnotations,
                                                includeEffects=includeEffects,
                                                outputToSequenceFormat=True)
    added_nodes = added_nodes + trackitem_nodes

    # Check if we're going to join this to another track item. If so we'll need a Dot node
    dot = None
    if trackItem != list(self.items())[0] and len(list(self.items())) > 1:
      dot = nuke.DotNode()
      dot.setInputNode(0, added_nodes[-1])
      script.addNode(dot)
      added_nodes.append(dot)

    # Don't add any merges if the track is disconnected
    if not disconnected and lastTrackItem is not None:

      # For dissolves create a Dissolve node rather than Merge
      if outTransition and outTransition.alignment() == Transition.kDissolve:
        merge = nuke.DissolveNode()
        merge.setWhichKeys( (outTransition.timelineIn()+offset, 0), (outTransition.timelineOut()+offset, 1) )

      else:
        merge = nuke.MergeNode()

      # Connect this to the Dot created on the last track processed (if any)
      if lastDot is not None:
        merge.setInputNode(0, lastDot)

      merge_nodes.append((merge, lastTrackItem or trackItem))

    lastDot = dot

    # Handle fade in and out.  Use the TimeClip node's fade controls for this.
    fadeIn = inTransition and inTransition.alignment() == Transition.kFadeIn
    fadeOut = outTransition and outTransition.alignment() == Transition.kFadeOut

    if fadeIn or fadeOut:

      # Find the TimeClip node created by the track item so we can set the fades
      trackItemTimeClipNode = next(n for n in trackitem_nodes if isinstance(n, nuke.TimeClipNode))

      if fadeIn:
        fadeInValue = inTransition.timelineOut() - inTransition.timelineIn()
        trackItemTimeClipNode.setKnob("fadeIn", fadeInValue)
        trackItemTimeClipNode.setKnob("fadeInType", "linear")

      if fadeOut:
        fadeOutValue = outTransition.timelineOut() - outTransition.timelineIn()
        trackItemTimeClipNode.setKnob("fadeOut", fadeOutValue)
        trackItemTimeClipNode.setKnob("fadeOutType", "linear")

    lastTrackItem = trackItem
    lastInTime = lastTrackItem.timelineIn()

    script.popLayoutContext()


  # Have to apply the Merge nodes in reverse order
  for node, trackItem in reversed(merge_nodes):
    added_nodes.append(node)
    script.addNode(node)

  perTrackNodes = []
  if callable(additionalNodesCallback):
    perTrackNodes.extend(additionalNodesCallback(self))

  # Add any additional nodes.
  for node in perTrackNodes:
    if node is not None:
      added_nodes.append(node)
      script.addNode(node)

  return added_nodes


VideoTrack.addToNukeScript = _VideoTrack_addToNukeScript


def getConnectedDisconnectedTracks(sequence,
                                   masterTracks,
                                   disconnected,
                                   includeEffects,
                                   includeAnnotations,
                                   view=None):
  """ Helper function to determine the connected and disconnected tracks for the
  given sequence, master tracks (possibly one per view) and disconnected setting.
  This can also filter the tracks for a particular view """

  if isinstance(masterTracks, VideoTrack):
    masterTracks = [masterTracks]

  connectedTracks = []
  disconnectedTracks = []
  tracks = []
  for track in sequence.videoTracks():
    # If a view was specified, check if the track should output to it
    if view:
      trackView = track.view()
      if trackView and trackView != view:
        continue

    # If the track has no TrackItems, check if it should be included based on the includeEffects and includeAnnotations settings.
    if not list(track.items()):
      hasEffects, hasAnnotations = False, False
      for item in itertools.chain(*track.subTrackItems()):
        if isinstance(item, Annotation):
          hasAnnotations = True
        elif isinstance(item, EffectTrackItem):
          hasEffects = True

      if (not includeAnnotations and hasAnnotations and not hasEffects) or (not includeEffects and hasEffects and not hasAnnotations):
        continue

    tracks.append(track)

  # If the disconnected parameter is not True, or no masterTrackItem was given, all tracks are connected.
  if disconnected:
    # A track is connected if:
    # a) it's in the list of master tracks, or
    # b) it only has effects/annotations, and it is above the master track
    # c) blending is enabled

    for track in tracks:
      isMaster = (track in masterTracks)
      isEffectsOnly = (not list(track.items()) and len(connectedTracks) > 0)
      isBlended = track.isBlendEnabled()

      if isMaster or isEffectsOnly or isBlended:
        connectedTracks.append(track)
      else:
        disconnectedTracks.append(track)

  else:
    connectedTracks = tracks

  return connectedTracks, disconnectedTracks


def _Sequence_addToNukeScript(self,
                              script = nuke.ScriptWriter(),
                              additionalNodes=[],
                              additionalNodesCallback=None,
                              includeRetimes=False,
                              retimeMethod=None,
                              offset=0,
                              skipOffline=True,
                              mediaToSkip=[],
                              disconnected=False,
                              masterTrackItem=None,
                              includeAnnotations=False,
                              includeEffects=True,
                              outputToFormat=None):
  """addToNukeScript(self, script)
  @param script: Nuke script object to add nodes to.
  @param includeRetimes: True/False include retimes
  @param retimeMethod: "Motion", "Blend", "Frame" - Knob setting for OFlow retime method
  @param additionalNodesCallback: callback to allow custom additional node per item function([Clip|TrackItem|Track|Sequence])
  @param offset: Optional, Global frame offset applied across whole script
  @param skipOffline: If True, offline clips are not included in the export
  @param mediaToSkip: List of MediaSources which should be excluded from the export
  @param disconnected: If True, tracks other than that containing the masterTrackItem are not connected to any inputs
  @param masterTrackItem: Used for controlling the script output if disconnected is specified
  @param includeAnnotations: If True, annotations are included in the exported script
  @param includeEffects: If True, soft effects are included in the exported script
  @param outputToFormat: Format to use for output.  If not specified, the sequence's own format is used.
  @return: None

  Add nodes representing this Sequence to the specified script.
  If there are no clips in the Sequence, nothing is added."""

  # Check that we are on the right type of object, just to be safe.
  assert isinstance(self, Sequence), "This function can only be punched into a Sequence object."

  added_nodes = []

  hiero.core.log.debug( '<'*10 + "Sequence.addToNukeScript()" + '>'*10 )
  previousTrack = None


  # First write the tracks in reverse order.  When it comes to detemining the inputs for the merges below,
  # Nuke uses a stack.  We also need to add each track's annotations and soft effects in the right place.
  # Effects/annotations on a track which also has clips should only apply to that track, so are added before the
  # track is merged.  Otherwise they should apply to all the tracks below, so are added after.
  # So for example if there are 4 tracks (Video 1, Video 2, Effects 1, Video 3) then the order is as follows:
  #   Video 3
  #   Video 3 annotations
  #   Video 3 effects
  #   Video 2
  #   Video 2 annotations
  #   Video 2 effects
  #   Video 1
  #   Video 1 annotations
  #   Video 1 effects
  #   Merge track 2 over track 1
  #   Effects 1
  #   Merge track 3 over track 2
  #   Write

  tracksWithVideo = set()

  # If layout is disconnected, only the 'master' track is connected to the Write node, any others
  # will be placed in the script but with clips disconnected.  To make this work, connected tracks
  # needs to be written last, so re-order the list. Effects/annotations which apply to the master track
  # also need to be connected

  connectedTracks, disconnectedTracks = getConnectedDisconnectedTracks(self, masterTrackItem.parent(), disconnected, includeEffects, includeAnnotations)
  tracks = connectedTracks + disconnectedTracks

  # Keep a record of the last Node in each track, since this will be used later to set the
  # correct connections to Merge nodes
  lastTrackNodeDict = {}

  # First write out the tracks and their annotations in reverse order, as described above
  for track in reversed(tracks):
    trackDisconnected = track in disconnectedTracks

    # Add the track and whether it is disconnected as data to the layout context
    script.pushLayoutContext("track", track.name(), track=track, disconnected=trackDisconnected)

    # If the track has any clips, write them and the effects out.
    trackItems = list(track.items())
    if len(trackItems) > 0:
      track_nodes = track.addToNukeScript(script,
                                         additionalNodes=additionalNodes,
                                         additionalNodesCallback=additionalNodesCallback,
                                         includeRetimes=includeRetimes,
                                         retimeMethod=retimeMethod,
                                         offset=offset,
                                         skipOffline=skipOffline,
                                         mediaToSkip=mediaToSkip,
                                         disconnected=trackDisconnected,
                                         includeAnnotations=includeAnnotations,
                                         includeEffects=includeEffects)
      added_nodes = added_nodes + track_nodes

      added_nodes.extend( _addEffectsAnnotationsForTrack(track, includeEffects, includeAnnotations, script, offset) )

      tracksWithVideo.add(track)

      # Check if we will be adding a merge node here later. If so, this would be the A input and
      # we will need a dot node to connect between this and the Merge
      if track != tracks[0] and not trackDisconnected and track.isBlendEnabled():
        dot = nuke.DotNode()
        # Set the dot node's input so we can properly align it after laying out the associated Merge node
        dot.setInputNode(0, added_nodes[-1])
        script.addNode(dot)
        added_nodes.append(dot)

    elif trackDisconnected:
      added_nodes.extend( _addEffectsAnnotationsForTrack(track, includeEffects, includeAnnotations, script, offset, inputs=0) )

    # Store the last node added to this track
    if added_nodes:
      lastTrackNodeDict[track] = added_nodes[-1]

    script.popLayoutContext()

  # Now iterate over the tracks in order, writing merges and their soft effects
  previousTrack = None
  for track in tracks:
    if previousTrack:
      # If we have a previous track, we will be creating a Merge node. In this case we don't want to
      # use the Track's Layout context, since Merges representing track blends should be on their own.
      script.pushLayoutContext("merge", "Merge " + previousTrack.name() + " " + track.name(), track=previousTrack)
    else:
      script.pushLayoutContext("track", track.name(), track=track)

    trackDisconnected = track in disconnectedTracks

    if not trackDisconnected and previousTrack:
      # We need a merge if this track contains any clips, if it's the first track it will go over
      # the background Constant node added above
      #
      # If blending is enabled, a Merge node is created, otherwise Copy.
      if track in tracksWithVideo:
        if track.isBlendEnabled():
          merge = nuke.MergeNode()
          blendMode = track.blendMode()
          merge.setKnob('operation', blendMode )
          if track.isBlendMaskEnabled() :
            # set the mask input to use the "A" channel instead of the "B" default
            inputAId = 1 + 1 # input 'A' is has 0-based index '1' (2nd input) plus the offset for the 'none' entry
            merge.setKnob('maskProviderInput', inputAId) # set by ID instead of string because the enum knob requires fromScript() to parse strings
            merge.setKnob("maskChannelInput", "alpha" ) # set the mask to use the upstream alpha channel - use string as the ChannelKnob supports it
        else:
          merge = nuke.CopyNode()
        if previousTrack:
          merge.setKnob( 'label', track.name()+' over '+previousTrack.name() )

          # Set the Merge's inputs, so we can use them to properly position the Merge later.
          if track in lastTrackNodeDict:
            merge.setInputNode(0, lastTrackNodeDict[track])
          if previousTrack in lastTrackNodeDict:
            merge.setInputNode(1, lastTrackNodeDict[previousTrack])

          # Any subsequent Merges will be connected to this one, so update the last Node in the track.
          lastTrackNodeDict[track] = merge

        else:
          merge.setKnob( 'label', track.name() )

        script.addNode(merge)
        added_nodes.append(merge)

      # If there were no clips on the track, write the effects and annotations after the merge so they get applied to the tracks below
      else:
        added_nodes.extend( _addEffectsAnnotationsForTrack(track, includeEffects, includeAnnotations, script, offset) )

    script.popLayoutContext()

    previousTrack = track

  # If an output format is specified, add a reformat node at the end.  Put this in the layout of the last connected track
  if outputToFormat:
    script.pushLayoutContext("track", connectedTracks[-1].name())
    added_nodes.append( outputToFormat.addToNukeScript(script, resize=nuke.ReformatNode.kResizeNone, black_outside=False) )
    script.popLayoutContext()

  perSequenceNodes = []
  if callable(additionalNodesCallback):
    perSequenceNodes.extend(additionalNodesCallback(self))

  # Add any additional nodes.
  for node in perSequenceNodes:
    if node is not None:
      added_nodes.append(node)
      script.addNode(node)

  # Add crop node with Sequence Format parameters
#  format = self.format()
#  crop = nuke.Node("Crop", box=('{0 0 %i %i}' % (format.width(), format.height())),  reformat='true' )
#  script.addNode(crop)
#  added_nodes.append(crop)

  return added_nodes

Sequence.addToNukeScript = _Sequence_addToNukeScript


def  _Format_addToNukeScript(self, script=None, resize=nuke.ReformatNode.kResizeWidth, black_outside=True):
  """self.addToNukeScript(self, script, to_type) -> adds a Reformat node matching this Format to the specified script and returns the nuke node object. \
  \
  @param script: Nuke script object to add nodes to, or None to just generate and return the node. \
  @param resize: Type of resize (use constants from nuke.ReformatNode, default is kResizeWidth). \
  @parm black_outside: Value for the black_outside knob. \
  @return: hiero.core.nuke.ReformatNode object
  """
  #Build the string representing the reformat
  formatstring = str(self)
  #Add Reformat node to script
  reformatNode = nuke.ReformatNode(resize=resize, to_type=nuke.ReformatNode.kToFormat, format=formatstring, pbb=True, black_outside=black_outside)
  if script is not None:
    script.addNode(reformatNode)

  return reformatNode

Format.addToNukeScript = _Format_addToNukeScript


def _Annotation_addToNukeScript(self, script, offset=0, inputs=0, cliptype=None):
  added_nodes = []

  # Add all the elements
  # Stroke elements can all be added in one RotoPaint node.
  # Each text element must use a separate Text node.
  # TODO Do we need to consider the ordering?

  # Use the start/end frames as the lifetime for the node
  startFrame = self.timelineIn() + offset
  endFrame = self.timelineOut() + offset

  knobSettings = {}
  knobSettings['inputs'] = inputs
  knobSettings['lifetimeStart'] = startFrame
  knobSettings['lifetimeEnd'] = endFrame
  knobSettings['useLifetime'] = "true"

  # Set the cliptype knob if specified
  if cliptype:
    knobSettings['cliptype'] = cliptype

  # Set the disabled knob if the annotation is disabled on the timeline
  if not self.isEnabled():
    knobSettings["disable"] = "true"

  strokes = []
  for element in self.elements():
    if isinstance(element, AnnotationText):
      added_nodes.extend( _AnnotationText_addToNukeScript(element, script, **knobSettings) )
      inputs = 1 # Once we've added a node, any following should take their input from it
    elif isinstance(element, AnnotationStroke):
      strokes.append( element )
    else:
      assert False

  if strokes:
    added_nodes.extend( _AnnotationStrokes_addToNukeScript(strokes, script, **knobSettings) )

  return added_nodes

Annotation.addToNukeScript = _Annotation_addToNukeScript


# Map from the text justify enum values to the strings used on the xjustify and yjustify knobs
_hJustifyTable = { AnnotationText.eHLeft : "left", AnnotationText.eHCenter : "center", AnnotationText.eHRight : "right", AnnotationText.eHJustify : "justify" }
_vJustifyTable = { AnnotationText.eVBaseline : "baseline", AnnotationText.eVTop : "top", AnnotationText.eVCenter : "center", AnnotationText.eVBottom : "bottom" }

# Annotations use a fixed font which is bundled with the application.  In the Text node, use a Python expression to determine the correct path.
# This works because the font file should always be in the same location relative to the executable.
_defaultFontExpression = "\\[python \\{os.path.split( nuke.env.get('ExecutablePath') )\\[0] + '/plugins/fonts/UtopiaRegular.pfa'\\}]"

def _AnnotationText_addToNukeScript(self, script, **knobs):
  added_nodes = []

  box = self.box()
  center = (box[0] + (box[2]/2), box[1] + (box[3]/2))

  r, g, b, a = self.color()

  textNode = nuke.Node("Text",
    message = self.text(),
    box = "{%s %s %s %s}" % (box[0], box[1], box[0]+box[2], box[1]+box[3]),
    font = _defaultFontExpression,
    color = "{%s %s %s 1}" % (r, g, b), # Alpha value is set on the opacity knob rather than color
    opacity = a,
    xjustify = _hJustifyTable[self.horizontalJustification()],
    yjustify = _vJustifyTable[self.verticalJustification()],
    size = self.fontSize(),
    rotate = self.rotation(),
    center = "{%s %s}" % (center), # Need to set the center point for rotation
    **knobs
    )

  added_nodes.append( textNode )

  if script:
    script.addNode( textNode )

  return added_nodes


def _AnnotationStroke_writeCurveStr(stroke, curve_id):
  """ Write out a stroke as a curve in the form expected by the RotoPaint node.
  Receives a stroke and the curve id (should be an integer)"""
  color  = stroke.color()
  # defines the curve's name.
  curve_name = "Brush" + str(curve_id)
  curveStr = (
      "{cubiccurve \"" + curve_name + "\" 512 catmullrom"
     "\n{cc"
      "\n{f 2080}"

      # Stringify each point and join them into the curve string.
      "\n{p\n" + "\n".join( ["{%s %s 1}" % (x, y) for x, y in stroke.points()] ) + "}}"

     "\n{t 0}"

     # Write the line width and color.  Note that the alpha is written as opc (opacity)
     "\n{ a bs %s h 0.9 r %s g %s b %s a 1 opc %s }}" % (stroke.lineWidth(), color[0], color[1], color[2], color[3])
     )
  return curveStr


def _AnnotationStrokes_addToNukeScript(strokes, script, **knobs):
  # Write all the strokes as curves in a RotoPaint node
  rotoNode = nuke.Node("RotoPaint", **knobs)

  # The start of a curves knob.  I don't know what all these values are for, but it's what Nuke
  # writes out.
  curvesKnob = ("{{{v x3f99999a}"
   "\n{f 0}"
   "\n{n"
   "\n{layer Root"
   "\n{a}\n"
   )

  # Use a list comprehension to write out each stroke as a curve and join them together into a single string
  # Use the list index to name each curve with a different name 'Brush1, Brush2, ...'
  curvesKnob = curvesKnob + "\n".join( [ _AnnotationStroke_writeCurveStr(stroke, index + 1) for index,stroke in enumerate(strokes) ] ) + "}}}}"
  rotoNode.setKnob("curves", curvesKnob)
  if script:
    script.addNode(rotoNode)
  return [rotoNode]


def offsetNodeAnimationFrames(node, offset):
  """ Iterate over all the knobs in a Nuke node, offsetting the key frame numbers. """

  if offset == 0:
    return

  isTimeWarp = node.Class() == "TimeWarp"
  for knob in list(node.knobs().values()):

    # Some knobs can have isAnimated True, but not actually have an animations() method.
    # This happens if the user chooses if for example 'Set key on all knobs'.
    # Swallow any exceptions that occur due to these issues.
    try:
      # If this is a link knob, need to modify the knob being linked to
      if isinstance(knob, _nuke.Link_Knob):
        knob = knob.getLinkedKnob()

      if knob.isAnimated():
        for curve in knob.animations():
          for key in list(curve.keys()):
            key.x = key.x + offset
            if isTimeWarp and knob.name() == "lookup":
              # time warp maps input frames to output so we need to adjust both the x and y curves
              key.y = key.y + offset
    except:
      pass


def _EffectTrackItem_addToNukeScript(self, script, offset=0, inputs=1, startHandle=0, endHandle=0, addLifetime=True):
  # Write an EffectTrackItem to the script.  We can access the Nuke node, so we just need
  # to write that out, plus any additional knobs that need to be added.

  node = self.node()

  # Apply the offset to all the Node's animations
  offsetNodeAnimationFrames(node, offset)

  # Any additional knobs we want in the script.  These are added as raw lines of text
  additionalKnobs = []

  # Set the lifetime knobs to disable the node outside the desired frame range.  Handles should be included
  startFrame = self.timelineIn() + offset - startHandle
  endFrame = self.timelineOut() + offset + endHandle

  if addLifetime:
    additionalKnobs.append( "lifetimeStart %s" % startFrame )
    additionalKnobs.append( "lifetimeEnd %s" % endFrame )
    additionalKnobs.append( "useLifetime true" )

  # Set the inputs knob
  additionalKnobs.append( "inputs %s" % inputs )

  # Set disabled knob if the effect is disabled on the timeline
  if not self.isEnabled():
    additionalKnobs.append( "disable true" )

  nodeStr = node.Class() + " {\n" + node.writeKnobs(_nuke.TO_SCRIPT) + "\n" + "\n".join(additionalKnobs) + "\n}"

  scriptNode = nuke.UserDefinedNode(nodeStr)

  added_nodes = []
  added_nodes.append( scriptNode )
  if script:
    script.addNode(added_nodes)

  # Reverse the offset, since the effect might be included in the export for multiple shots
  offsetNodeAnimationFrames(node, -offset)

  return added_nodes


EffectTrackItem.addToNukeScript = _EffectTrackItem_addToNukeScript


def _EffectTrackItem_isRetimeEffect(self):
  """ Check if an EffectTrackItem applies a retime.  Currently this only applies to TimeWarp effects. """
  return self.node().Class() == "TimeWarp"

EffectTrackItem.isRetimeEffect = _EffectTrackItem_isRetimeEffect