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