Custom file structure

The tutorial relies on defining a custom folder structure across the studio.

With ftrack, and a storage scenario, comes the ftrack_api.structure.Standard structure plugin which publishes files with the standard ftrack structure:

project/sequence/shot/model/v001/alembic.abc

With this tutorial, we are going to provide our custom studio file structure that puts publishes in a “PUBLISH” folder:

project/sequence/shot/PUBLISH/anim/alembic.abc

We are achieving this by defining our own structure plugin that we apply to the storage scenario location. This API/Connect plugin needs to reside server side:

mypipeline/custom-location-plugin:

hook/plugin_hook.py                 #  Enable structure within Connect
location/custom_location_plugin.py  #  Initialise the location - apply structure
location/structure.py               #  Provides the custom file structure

Structure

Within the structure plugin we define an asset resolver:

mypipeline/custom-location-plugin/location/structure.py

  1# :coding: utf-8
  2# :copyright: Copyright (c) 2022 ftrack
  3
  4import sys
  5import os
  6import re
  7import unicodedata
  8import logging
  9import traceback
 10
 11from collections import OrderedDict
 12
 13import ftrack_api.symbol
 14import ftrack_api.structure.base
 15
 16STUDIO_PUBLISH_FOLDER = "PUBLISH"
 17
 18
 19class Structure(ftrack_api.structure.base.Structure):
 20    '''
 21    Custom structure publishing to "_PUBLISH" folder beneath shot.
 22    '''
 23
 24    def __init__(
 25        self, project_versions_prefix=None, illegal_character_substitute='_'
 26    ):
 27        super(Structure, self).__init__()
 28        self.logger = logging.getLogger(
 29            'com.ftrack.framework-guide.custom-location-plugin.location.Structure'
 30        )
 31        self.project_versions_prefix = project_versions_prefix
 32        self.illegal_character_substitute = illegal_character_substitute
 33        self.resolvers = OrderedDict(
 34            {
 35                'FileComponent': self._resolve_filecomponent,
 36                'SequenceComponent': self._resolve_sequencecomponent,
 37                'ContainerComponent': self._resolve_containercomponent,
 38                'AssetVersion': self._resolve_version,
 39                'Asset': self._resolve_asset,
 40                'Task': self._resolve_task,
 41                'Project': self._resolve_project,
 42                'ContextEntity': self._resolve_context_entity,
 43            }
 44        )
 45
 46    def sanitise_for_filesystem(self, value):
 47        '''Return *value* with illegal filesystem characters replaced.
 48
 49        An illegal character is one that is not typically valid for filesystem
 50        usage, such as non ascii characters, or can be awkward to use in a
 51        filesystem, such as spaces. Replace these characters with
 52        the character specified by *illegal_character_substitute* on
 53        initialisation. If no character was specified as substitute then return
 54        *value* unmodified.
 55
 56        '''
 57        if self.illegal_character_substitute is None:
 58            return value
 59
 60        value = unicodedata.normalize('NFKD', str(value)).encode(
 61            'ascii', 'ignore'
 62        )
 63        value = re.sub(
 64            '[^\w\.-]',
 65            self.illegal_character_substitute,
 66            value.decode('utf-8'),
 67        )
 68        return str(value.strip().lower())
 69
 70    def _resolve_project(self, project, context=None):
 71        '''Return resource identifier for *project*.'''
 72        # Base on project name
 73        return [self.sanitise_for_filesystem(project['name'])]
 74
 75    def _resolve_context_entity(self, entity, context=None):
 76        '''Return resource identifier parts from general *entity*.'''
 77
 78        error_message = (
 79            'Entity {0!r} must be supported (have a link), be committed and have'
 80            ' a parent context.'.format(entity)
 81        )
 82
 83        if entity is ftrack_api.symbol.NOT_SET:
 84            raise ftrack_api.exception.StructureError(error_message)
 85
 86        session = entity.session
 87
 88        if not 'link' in entity:
 89            raise NotImplementedError(
 90                'Cannot generate resource identifier for unsupported '
 91                'entity {0!r}'.format(entity)
 92            )
 93
 94        link = entity['link']
 95
 96        if not link:
 97            raise ftrack_api.exception.StructureError(error_message)
 98
 99        structure_names = [item['name'] for item in link[1:]]
100
101        if 'project' in entity:
102            project = entity['project']
103        elif 'project_id' in entity:
104            project = session.get('Project', entity['project_id'])
105        else:
106            project = session.get('Project', link[0]['id'])
107
108        parts = self._resolve_project(project)
109
110        if structure_names:
111            for part in structure_names:
112                parts.append(self.sanitise_for_filesystem(part))
113        elif self.project_versions_prefix:
114            # Add *project_versions_prefix* if configured and the version is
115            # published directly under the project.
116            parts.append(
117                self.sanitise_for_filesystem(self.project_versions_prefix)
118            )
119
120        return parts
121
122    def _resolve_task(self, task, context=None):
123        '''Build resource identifier for *task*.'''
124        # Resolve parent context
125        parts = self._resolve_context_entity(task['parent'], context=context)
126        # TODO: Customise were task work files go
127        # Base on task name, and use underscore instead of whitespaces
128        parts.append(
129            self.sanitise_for_filesystem(task['name'].replace(' ', '_'))
130        )
131        return parts
132
133    def _resolve_asset(self, asset, context=None):
134        '''Build resource identifier for *asset*.'''
135        # Resolve parent context
136        parts = self._resolve_context_entity(asset['parent'], context=context)
137        # Framework guide customisation - publish to shot/asset "publish" subfolder
138        parts.append(STUDIO_PUBLISH_FOLDER)
139        # Base on its name
140        parts.append(self.sanitise_for_filesystem(asset['name']))
141        return parts
142
143    def _format_version(self, number):
144        '''Return a formatted string representing version *number*.'''
145        return 'v{0:03d}'.format(number)
146
147    def _resolve_version(self, version, component=None, context=None):
148        '''Get resource identifier for *version*.'''
149
150        error_message = 'Version {0!r} must be committed and have a asset with parent context.'.format(
151            version
152        )
153
154        if version is ftrack_api.symbol.NOT_SET and component:
155            version = component.session.get(
156                'AssetVersion', component['version_id']
157            )
158
159        if version is ftrack_api.symbol.NOT_SET or (
160            not component is None and version in component.session.created
161        ):
162            raise ftrack_api.exception.StructureError(error_message)
163
164        # Create version resource identifier from asset and version number
165        version_number = self._format_version(version['version'])
166        parts = self._resolve_asset(version['asset'], context=context)
167        parts.append(self.sanitise_for_filesystem(version_number))
168
169        return parts
170
171    def _resolve_sequencecomponent(self, sequencecomponent, context=None):
172        '''Get resource identifier for *sequencecomponent*.'''
173        # Create sequence expression for the sequence component and add it
174        # to the parts.
175        parts = self._resolve_version(
176            sequencecomponent['version'],
177            component=sequencecomponent,
178            context=context,
179        )
180        sequence_expression = self._get_sequence_expression(sequencecomponent)
181        parts.append(
182            '{0}.{1}{2}'.format(
183                self.sanitise_for_filesystem(sequencecomponent['name']),
184                sequence_expression,
185                self.sanitise_for_filesystem(sequencecomponent['file_type']),
186            )
187        )
188        return parts
189
190    def _resolve_container(self, component, container, context=None):
191        '''Get resource identifier for *container*, based on the *component*
192        supplied.'''
193        container_path = self.get_resource_identifier(
194            container, context=context
195        )
196        if container.entity_type in ('SequenceComponent',):
197            # Strip the sequence component expression from the parent
198            # container and back the correct filename, i.e.
199            # /sequence/component/sequence_component_name.0012.exr.
200            name = '{0}.{1}{2}'.format(
201                container['name'], component['name'], component['file_type']
202            )
203            parts = [
204                os.path.dirname(container_path),
205                self.sanitise_for_filesystem(name),
206            ]
207
208        else:
209            # Container is not a sequence component so add it as a
210            # normal component inside the container.
211            name = component['name'] + component['file_type']
212            parts = [container_path, self.sanitise_for_filesystem(name)]
213        return parts
214
215    def _resolve_filecomponent(self, filecomponent, context=None):
216        '''Get resource identifier for file component.'''
217        container = filecomponent['container']
218        if container:
219            parts = self._resolve_container(
220                filecomponent, container, context=context
221            )
222        else:
223            # File component does not have a container, construct name from
224            # component name and file type.
225            parts = self._resolve_version(
226                filecomponent['version'],
227                component=filecomponent,
228                context=context,
229            )
230            name = filecomponent['name'] + filecomponent['file_type']
231            parts.append(self.sanitise_for_filesystem(name))
232        return parts
233
234    def _resolve_containercomponent(self, containercomponent, context=None):
235        '''Get resource identifier for *containercomponent*.'''
236        # Get resource identifier for container component
237        # Add the name of the container to the resource identifier parts.
238        parts = self._resolve_version(
239            containercomponent['version'],
240            component=containercomponent,
241            context=context,
242        )
243        parts.append(self.sanitise_for_filesystem(containercomponent['name']))
244        return parts
245
246    def get_resource_identifier(self, entity, context=None):
247        '''Return a resource identifier for supplied *entity*.
248
249        *context* can be a mapping that supplies additional information, but
250        is unused in this implementation.
251
252
253        Raise a :py:exc:`ftrack_api.exeption.StructureError` if *entity* is a
254        component not attached to a committed version/asset with a parent
255        context, or if entity is not a proper Context.
256
257        '''
258
259        resolver_fn = self.resolvers.get(
260            entity.entity_type, self._resolve_context_entity
261        )
262
263        parts = resolver_fn(entity, context=context)
264
265        return self.path_separator.join(parts)

Location

The structure are then registered and used with the default location, if it is an unmanaged/server location, a default location at disk is used so publishes not are lost in system temp directory:

mypipeline/custom-location-plugin/location/custom_location_plugin.py

 1# :coding: utf-8
 2# :copyright: Copyright (c) 2022 ftrack
 3
 4import os
 5import functools
 6import logging
 7
 8import ftrack_api
 9import ftrack_api.accessor.disk
10
11logger = logging.getLogger(
12    'com.ftrack.ftrack-connect-pipeline.tutorial.custom-location-plugin.location.custom_location_plugin'
13)
14
15
16def configure_location(session, event):
17    '''Apply our custom structure to default storage scenario location.'''
18    import structure
19
20    DEFAULT_USER_DISK_PREFIX = os.path.join(
21        os.path.expanduser('~'), 'Documents', 'ftrack_tutorial'
22    )
23
24    location = session.pick_location()
25    if location['name'] in ['ftrack.unmanaged', 'ftrack.server']:
26        location.accessor = ftrack_api.accessor.disk.DiskAccessor(
27            prefix=DEFAULT_USER_DISK_PREFIX
28        )
29    location.structure = structure.Structure()
30    # location.priority = 1
31
32    logger.info(
33        u'Registered custom file structure at location "{0}", path: {1}.'.format(
34            location['name'], DEFAULT_USER_DISK_PREFIX
35        )
36    )
37
38
39def register(api_object, **kw):
40    '''Register location with *session*.'''
41
42    if not isinstance(api_object, ftrack_api.Session):
43        return
44
45    api_object.event_hub.subscribe(
46        'topic=ftrack.api.session.configure-location',
47        functools.partial(configure_location, api_object),
48    )
49
50    api_object.event_hub.subscribe(
51        'topic=ftrack.api.connect.configure-location',
52        functools.partial(configure_location, api_object),
53    )
54
55    logger.info(u'Registered tutorial location plugin.')

Source code

The complete source code for the API location structure plugin can be found here:

resource/custom-location-plugin