Skip to content

create_editorial_advanced

EditorialAdvancedCreator

Bases: TrayPublishCreator

Advanced Editorial creator class

Advanced editorial workflow creator. This creator will process imput editorial file and match its clips to files in folder.

Parameters:

Name Type Description Default
TrayPublishCreator Creator

Tray publisher plugin class

required
Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
class EditorialAdvancedCreator(TrayPublishCreator):
    """Advanced Editorial creator class

    Advanced editorial workflow creator. This creator will process imput
    editorial file and match its clips to files in folder.

    Args:
        TrayPublishCreator (Creator): Tray publisher plugin class
    """
    enabled = True
    label = "Editorial Advanced"
    product_type = "editorial"
    identifier = "editorial_advanced"
    default_variants = [
        "main"
    ]
    description = "Editorial files to generate shots."
    detailed_description = """
Supporting publishing new shots to project
or updating already created. Publishing will create OTIO file.
"""
    icon = "fa.file"
    product_type_presets = []

    def __init__(self, *args, **kwargs):
        self._shot_metadata_solver = ShotMetadataSolver(self.log)
        super().__init__(*args, **kwargs)

    def apply_settings(self, project_settings):
        editorial_creators = deepcopy(
            project_settings["traypublisher"]["editorial_creators"]
        )
        creator_settings = editorial_creators[self.identifier]

        self.enabled = creator_settings["enabled"]

        self._shot_metadata_solver.update_data(
            creator_settings["clip_name_tokenizer"],
            creator_settings["shot_rename"],
            creator_settings["shot_hierarchy"],
            creator_settings["shot_add_tasks"]
        )
        self.product_type_presets = creator_settings[
            "product_type_advanced_presets"]
        self.default_variants = creator_settings["default_variants"]

    def create(self, product_name, instance_data, pre_create_data):
        allowed_product_type_presets = self._get_allowed_product_type_presets(
            pre_create_data)

        ignored_keys = set(self.get_product_presets_with_names())
        ignored_keys |= {"sequence_filepath_data", "folder_path_data"}
        clip_instance_properties = {
            k: v
            for k, v in pre_create_data.items()
            if k not in ignored_keys
        }

        folder_path = instance_data["folderPath"]
        folder_entity = self.create_context.get_folder_entity(
            folder_path
        )

        if folder_entity and pre_create_data["fps"] == "from_selection":
            # get 'fps' from folder attributes
            fps = folder_entity["attrib"]["fps"]
        else:
            fps = float(pre_create_data["fps"])

        instance_data["fps"] = fps

        # get path of sequence
        sequence_paths = self._get_path_from_file_data(
            pre_create_data["sequence_filepath_data"],
            multi=True
        )

        media_folder_paths = self._get_path_from_file_data(
            pre_create_data["folder_path_data"],
            multi=True
        )

        # get all sequences into otio_timelines
        otio_timelines = []
        for sequence_path in sequence_paths:
            sequence_name = os.path.basename(sequence_path)
            # get otio timeline
            otio_timeline = self._create_otio_timeline(sequence_path, fps)
            otio_timelines.append((sequence_name, sequence_path, otio_timeline))

        # Create all clip instances
        clip_instance_properties.update({
            "fps": fps,
            "variant": instance_data["variant"]
        })

        ignore_clip_no_content = pre_create_data["ignore_clip_no_content"]
        for media_folder_path in media_folder_paths:
            for (sequence_name, sequence_path, otio_timeline) in otio_timelines:
                # create clip instances
                self._get_clip_instances(
                    folder_entity,
                    otio_timeline,
                    media_folder_path,
                    clip_instance_properties,
                    allowed_product_type_presets,
                    sequence_name,
                    ignore_clip_no_content,
                )

                # create otio editorial instance
                self._create_otio_instance(
                    product_name, instance_data, sequence_path, otio_timeline
                )

    def _create_otio_instance(
        self, product_name, instance_data, sequence_path, otio_timeline
    ):
        """Otio instance creating function

        Args:
            product_name (str): Product name.
            data (dict): instance data
            sequence_path (str): path to sequence file
            otio_timeline (otio.Timeline): otio timeline object
        """
        # Pass precreate data to creator attributes
        instance_data.update(
            {
                "sequenceFilePath": sequence_path,
                "otioTimeline": otio.adapters.write_to_string(otio_timeline),
            }
        )
        new_instance = CreatedInstance(
            self.product_type, product_name, instance_data, self
        )
        self._store_new_instance(new_instance)

    def _create_otio_timeline(self, sequence_path, fps):
        """Creating otio timeline from sequence path

        Args:
            sequence_path (str): path to sequence file
            fps (float): frame per second

        Returns:
            otio.Timeline: otio timeline object
        """
        # get editorial sequence file into otio timeline object
        extension = os.path.splitext(sequence_path)[1]

        kwargs = {}
        if extension.lower() == ".edl":
            # EDL has no frame rate embedded so needs explicit
            # frame rate else 24 is assumed.
            kwargs["rate"] = fps
            kwargs["ignore_timecode_mismatch"] = True

        return otio.adapters.read_from_file(sequence_path, **kwargs)

    def _get_path_from_file_data(self, file_path_data, multi=False):
        """Converting creator path data to single path string

        Args:
            file_path_data (FileDefItem): creator path data inputs
            multi (bool): switch to multiple files mode

        Raises:
            FileExistsError: in case nothing had been set

        Returns:
            Union[list[str], str]: Paths or single path based on
                'multi' value.
        """
        output_paths = []
        for item in file_path_data:
            dirpath = item["directory"]
            for filename in item["filenames"]:
                output_paths.append(os.path.join(dirpath, filename))

        if not output_paths:
            raise CreatorError(
                # The message is cryptic even for me might be worth to change
                f"File path was not added: {file_path_data}"
            )
        return output_paths if multi else output_paths[0]

    def _get_clip_instances(
        self,
        folder_entity,
        otio_timeline,
        media_folder_path,
        instance_data,
        product_type_presets,
        sequence_file_name,
        ignore_clip_no_content,
    ):
        """Helping function for creating clip instance

        Args:
            folder_entity (dict[str, Any]): Folder entity.
            otio_timeline (otio.Timeline): otio timeline object
            media_folder_path (str): Folder with media files
            instance_data (dict): clip instance data
            product_type_presets (list[dict]): list of dict settings
                product presets
            sequence_file_name (str): sequence file name
            ignore_clip_no_content (bool): ignore clips with no content
        """
        media_folder_path = media_folder_path.replace("\\", "/")

        # Get all tracks from otio timeline
        tracks = otio_timeline.video_tracks()


        # get all clipnames from otio timeline to list of strings
        clip_names_set = {clip.name for clip in otio_timeline.find_clips()}

        clip_folders = []
        # Iterate over all media files in media folder
        for root, folders, _ in os.walk(media_folder_path):
            # Use set intersection to find matching folder directly
            clip_folders.extend(
                folder
                for folder in folders
                if folder in clip_names_set
            )

        if not clip_folders:
            self.log.warning("No clip folder paths found")
            return

        clip_content: Dict[str, Dict[str, list[str]]] = {}
        # list content of clip folder and search for product items
        for clip_folder in clip_folders:
            abs_clip_folder = os.path.join(
                media_folder_path, clip_folder).replace("\\", "/")

            matched_product_items = []
            for root, foldernames, filenames in os.walk(abs_clip_folder):
                # iterate all product names in enabled presets
                for pres_product_data in product_type_presets:
                    product_name = pres_product_data["product_name"]

                    product_data_base = {
                        "preset_name": product_name,
                        "clip_dir_subpath": "",
                        "product_name": product_name,
                        "files": [],
                    }
                    root = root.replace("\\", "/")
                    cl_part_path = root.replace(abs_clip_folder, "")

                    for folder in foldernames:
                        product_data = deepcopy(product_data_base)
                        # need to include more since variants might occure
                        pattern_search = re.compile(
                            f".*({re.escape(product_name)}{VARIANTS_PATTERN}).*"
                        )
                        match = pattern_search.search(folder)
                        if not match:
                            continue

                        # form partial path without starting slash
                        partial_path = os.path.join(
                            cl_part_path.lstrip("/"), folder
                        ).replace("\\", "/")

                        # update product data it will be deepcopied later
                        # later in files processor
                        product_data.update(
                            {
                                "product_name": match.group(0),
                                "clip_dir_subpath": partial_path,
                            }
                        )
                        nested_files = list(
                                os.listdir(os.path.join(root, folder)))
                        self._include_files_for_processing(
                            product_name,
                            nested_files,
                            product_data,
                            matched_product_items,
                            strict=False,
                        )

                    product_data_base["clip_dir_subpath"] = "/"
                    self._include_files_for_processing(
                        product_name,
                        filenames,
                        product_data_base,
                        matched_product_items,
                    )

                # No matching product data can be skipped
                if not matched_product_items:
                    self.log.warning(
                        f"No matching product data found in {root}."
                        " Skipping folder."
                    )
                    continue

                clip_content[clip_folder] = matched_product_items

        for track in tracks:
            # set track name
            track.name = f"{sequence_file_name} - {otio_timeline.name}"

            try:
                track_start_frame = (
                    abs(track.source_range.start_time.value)
                )
                track_start_frame -= self.timeline_frame_start
            except AttributeError:
                track_start_frame = 0

            for otio_clip in track.find_clips():
                if not self._validate_clip_for_processing(otio_clip):
                    continue

                clip_related_content = clip_content.get(otio_clip.name)

                if ignore_clip_no_content:
                    if not clip_related_content:
                        continue

                    if not any(
                        item
                        for preset in product_type_presets
                        for item in clip_related_content
                        if preset["product_name"] in item["product_name"]
                    ):
                        self.log.warning(
                            f"Clip {otio_clip.name} has no related content."
                            " Skipping clip."
                        )
                        continue

                # convert timeline range to source range
                self._restore_otio_source_range(otio_clip)

                base_instance_data = self._get_base_instance_data(
                    otio_clip,
                    instance_data,
                    track_start_frame,
                    folder_entity
                )

                parenting_data = {
                    "instance_label": None,
                    "instance_id": None
                }

                self._make_shot_product_instance(
                    otio_clip,
                    deepcopy(base_instance_data),
                    parenting_data,
                )

                abs_clip_folder = os.path.join(
                    media_folder_path, otio_clip.name).replace("\\", "/")

                for pres_product_data in product_type_presets:
                    self._make_product_instance(
                        pres_product_data,
                        deepcopy(base_instance_data),
                        parenting_data,
                        clip_related_content,
                        abs_clip_folder,
                    )

    def _include_files_for_processing(
        self,
        product_name,
        filenames,
        product_data_base,
        collecting_items,
        strict=True,
    ):
        """Supporting function for getting clip content.

        Args:
            product_name (str): product name
            partial_path (str): clip folder path
            filenames (list): list of files in clip folder to collect
            product_data_base (dict): product data
            collecting_items (list): list for collecting product data items
            strict (Optional[bool]): strict mode for filtering files
        """
        # compile regex pattern for matching product name
        pattern_search = re.compile(
            f".*({re.escape(product_name)}{VARIANTS_PATTERN})"
        )
        # find intersection between files and sequences
        differences = find_string_differences(filenames)
        collections, reminders = clique.assemble(filenames)
        # iterate all collections and search for pattern in file name head
        for collection in collections:
            # check if collection is not empty
            if not collection:
                continue
            # check if pattern in name head is present
            head = collection.format("{head}")
            tail = collection.format("{tail}")
            match = pattern_search.search(head)

            # if pattern is not present in file name head
            if strict and not match:
                continue

            # add collected files to list
            # NOTE: Guess thumbnail file - potential danger.
            filtered_filenames = [
                file
                for file in filenames
                if file.startswith(head)
                if file.endswith(tail)
                if "thumb" not in file
            ]
            extension = os.path.splitext(filtered_filenames[0])[1]
            product_data = deepcopy(product_data_base)
            suffix = differences[head + tail]
            product_data.update({
                "type": "collection" if extension in IMAGE_EXTENSIONS else "other",
                "suffix": suffix,
                "files": filtered_filenames,
            })

            if strict and match:
                product_data["product_name"] = match.group(1)

            collecting_items.append(product_data)

        for reminder in reminders:
            # check if pattern in name head is present
            head, tail = os.path.splitext(reminder)
            match = pattern_search.search(head)

            # if pattern is not present in file name head
            if strict and not match:
                continue

            # add collected files to list
            filtered_filenames = [
                file for file in filenames
                if file.startswith(head)
                if file.endswith(tail)
            ]
            extension = os.path.splitext(filtered_filenames[0])[1]
            suffix = differences[filtered_filenames[0]]

            if match:
                # remove product name from suffix
                suffix = suffix.replace(match[1] or match[0], "")

            content_type = "other"
            if (
                extension in VIDEO_EXTENSIONS
                or extension in IMAGE_EXTENSIONS
            ):
                content_type = "single"

            # check if file is thumbnail
            if "thumb" in reminder:
                content_type = "thumbnail"

            product_data = deepcopy(product_data_base)
            product_data.update({
                "type": content_type,
                "suffix": suffix,
                "files": filtered_filenames,
            })

            if strict and match:
                # Extract matched pattern and handle special cases with dots
                # like name.thumbnail.jpg matches
                matched_pattern = match[1]

                product_data["product_name"] = matched_pattern

            collecting_items.append(product_data)

    def _restore_otio_source_range(self, otio_clip):
        """Infusing source range.

        Otio clip is missing proper source clip range so
        here we add them from from parent timeline frame range.

        Args:
            otio_clip (otio.Clip): otio clip object
        """
        otio_clip.source_range = otio_clip.range_in_parent()

    def _make_product_instance(
        self,
        product_preset,
        base_instance_data,
        parenting_data,
        clip_content_items,
        media_folder_path,
    ):
        """Creating product instances

        Args:
            product_preset (dict): product preset data
            base_instance_data (dict): base instance data
            parenting_data (dict): parenting data
            clip_content_items (list[dict]): list of clip content items
            media_folder_path (str): media folder path
        """
        pres_product_type = product_preset["product_type"]
        pres_product_name = product_preset["product_name"]
        pres_versioning = product_preset["versioning_type"]
        pres_representations = product_preset["representations"]

        # Dictionary to group files by product name
        grouped_representations = {}

        # First pass: group matching files by product name and representation
        for item in clip_content_items:
            item_type = item["type"]
            if pres_product_name != item["preset_name"]:
                continue

            product_name = item["product_name"]
            if product_name not in grouped_representations:
                grouped_representations[product_name] = {
                    "representations": []
                }

            # Check each representation preset against the item
            for repre_preset in pres_representations:
                preset_repre_name = repre_preset["name"]
                pres_repr_content_type = repre_preset["content_type"]
                pres_repr_tags = deepcopy(repre_preset.get("tags", []))
                pres_repr_custom_tags = deepcopy(
                    repre_preset.get("custom_tags", []))

                # Prepare filters
                extensions_filter = [
                    (ext if ext.startswith(".") else f".{ext}").lower()
                    for ext in repre_preset.get("extensions", [])
                ]
                patterns_filter = repre_preset.get("patterns", [])

                # Filter matching files
                matching_files = []
                for file in item["files"]:
                    # Validate content type matches item type mapping
                    if (
                        pres_repr_content_type not in CONTENT_TYPE_MAPPING[item_type]  # noqa
                    ):
                        continue

                    # Filter by extension
                    if not any(
                        str(file).lower().endswith(ext)
                        for ext in extensions_filter
                    ):
                        continue

                    # Filter by pattern
                    if patterns_filter and not any(
                        re.match(pattern, file)
                        for pattern in patterns_filter
                    ):
                        continue

                    matching_files.append(file)

                if not matching_files:
                    continue

                abs_dir_path = os.path.join(
                    media_folder_path, item["clip_dir_subpath"]
                ).replace("\\", "/")

                if item["clip_dir_subpath"] == "/":
                    abs_dir_path = media_folder_path

                # get extension from first file
                repre_ext = os.path.splitext(
                    matching_files[0])[1].lstrip(".").lower()

                if len(matching_files) == 1:
                    matching_files = matching_files[0]

                repre_data = {
                    "ext": repre_ext,
                    "name": preset_repre_name,
                    "files": matching_files,
                    "content_type": pres_repr_content_type,
                    # for reviewable checking in next step
                    "repre_preset_name": preset_repre_name,
                    "dir_path": abs_dir_path,
                    "tags": pres_repr_tags,
                    "custom_tags": pres_repr_custom_tags,
                }
                # Add optional output name suffix
                suffix = item["suffix"]
                if suffix and "thumb" not in suffix:
                    repre_data["outputName"] = suffix
                    repre_data["name"] += f"_{suffix}"
                grouped_representations[product_name][
                    "representations"].append(repre_data)

        # Second pass: create instances for each group
        for product_name, group_data in grouped_representations.items():
            representations = group_data["representations"]
            if not representations:
                continue

            # skip case where only thumbnail is present
            if (
                len(representations) == 1
                and representations[0]["content_type"] == "thumbnail"
            ):
                continue

            # get version from files with use of pattern
            # and versioning type
            version = None
            if pres_versioning == "from_file":
                version = self._extract_version_from_files(representations)
            elif pres_versioning == "locked":
                version = product_preset["locked"]

            # check if product is reviewable
            reviewable = False
            for rep_data in representations:
                for pres_rep in pres_representations:
                    if pres_rep["name"] == rep_data["repre_preset_name"]:
                        if "review" in pres_rep["tags"]:
                            reviewable = True
                            break
                if reviewable:
                    break

            # Get basic instance product data
            instance_data = deepcopy(base_instance_data)
            self._set_product_data_to_instance(
                instance_data,
                pres_product_type,
                product_name=product_name,
            )

            # Add review family and other data
            instance_data.update({
                "parent_instance_id": parenting_data["instance_id"],
                "creator_attributes": {
                    "parent_instance": parenting_data["instance_label"],
                    "add_review_family": reviewable,
                },
                "version": version,
                "prep_representations": representations,
            })

            creator_identifier = f"editorial_{pres_product_type}_advanced"
            editorial_clip_creator = self.create_context.creators[
                creator_identifier]

            # Create instance in creator context
            editorial_clip_creator.create(instance_data)

    def _extract_version_from_files(self, representations):
        """Extract version information from files

        Files are searched in in trimmed file repesentation data.

        Args:
            representations (list[dict]): list of representation data

        Returns:
            str: Highest version found in files, or None if no version found
        """
        all_found_versions = []
        for repre in representations:
            for file in repre["files"]:
                match = re.match(VERSION_IN_FILE_PATTERN, file)
                if match:
                    all_found_versions.append(int(match.group(1)))

        all_found_versions = set(all_found_versions)
        if all_found_versions:
            return max(all_found_versions)

        return None

    def _make_shot_product_instance(
        self,
        otio_clip,
        base_instance_data,
        parenting_data,
    ):
        """Making shot product instance from input preset

        Args:
            otio_clip (otio.Clip): otio clip object
            base_instance_data (dict): instance data
            parenting_data (dict): shot instance parent data

        Returns:
            CreatedInstance: creator instance object
        """
        instance_data = deepcopy(base_instance_data)
        label = self._set_product_data_to_instance(
            instance_data,
            "shot",
            product_name="shotMain",
        )
        instance_data["otioClip"] = otio.adapters.write_to_string(otio_clip)
        c_instance = self.create_context.creators["editorial_shot"].create(
            instance_data
        )
        parenting_data.update(
            {
                "instance_label": label,
                "instance_id": c_instance.data["instance_id"]
            }
        )
        return c_instance

    def _set_product_data_to_instance(
        self,
        instance_data,
        product_type,
        variant=None,
        product_name=None,
    ):
        """Product name maker

        Args:
            instance_data (dict): instance data
            product_type (str): product type
            variant (Optional[str]): product variant
                default is "main"
            product_name (Optional[str]): product name

        Returns:
            str: label string
        """
        if not variant:
            if product_name:
                variant = product_name.split(product_type)[-1].lower()
            else:
                variant = "main"

        folder_path = instance_data["creator_attributes"]["folderPath"]

        # product name
        product_name = product_name or f"{product_type}{variant.capitalize()}"
        label = f"{folder_path} {product_name}"

        instance_data.update(
            {
                "label": label,
                "variant": variant,
                "productType": product_type,
                "productName": product_name,
            }
        )

        return label

    def _get_base_instance_data(
        self,
        otio_clip,
        instance_data,
        track_start_frame,
        folder_entity,
    ):
        """Factoring basic set of instance data.

        Args:
            otio_clip (otio.Clip): otio clip object
            instance_data (dict): precreate instance data
            track_start_frame (int): track start frame

        Returns:
            dict: instance data

        """
        parent_folder_path = folder_entity["path"]
        parent_folder_name = parent_folder_path.rsplit("/", 1)[-1]

        # get clip instance properties
        handle_start = instance_data["handle_start"]
        handle_end = instance_data["handle_end"]
        timeline_offset = instance_data["timeline_offset"]
        workfile_start_frame = instance_data["workfile_start_frame"]
        fps = instance_data["fps"]
        variant_name = instance_data["variant"]

        # basic unique folder name
        clip_name = os.path.splitext(otio_clip.name)[0]
        project_entity = ayon_api.get_project(self.project_name)

        shot_name, shot_metadata = self._shot_metadata_solver.generate_data(
            clip_name,
            {
                "anatomy_data": {
                    "project": {
                        "name": self.project_name,
                        "code": project_entity["code"]
                    },
                    "parent": parent_folder_name,
                    "app": self.host_name
                },
                "selected_folder_entity": folder_entity,
                "project_entity": project_entity
            }
        )

        timing_data = self._get_timing_data(
            otio_clip,
            timeline_offset,
            track_start_frame,
            workfile_start_frame
        )

        # create creator attributes
        creator_attributes = {
            "workfile_start_frame": workfile_start_frame,
            "fps": fps,
            "handle_start": int(handle_start),
            "handle_end": int(handle_end)
        }
        # add timing data
        creator_attributes.update(timing_data)

        # create base instance data
        base_instance_data = {
            "shotName": shot_name,
            "variant": variant_name,
            "task": None,
            "newHierarchyIntegration": True,
            "trackStartFrame": track_start_frame,
            "timelineOffset": timeline_offset,

            # creator_attributes
            "creator_attributes": creator_attributes
        }
        # update base instance data with context data
        # and also update creator attributes with context data
        creator_attributes["folderPath"] = shot_metadata.pop("folderPath")
        base_instance_data["folderPath"] = parent_folder_path

        # add creator attributes to shared instance data
        base_instance_data["creator_attributes"] = creator_attributes
        # add hierarchy shot metadata
        base_instance_data.update(shot_metadata)

        return base_instance_data

    def _get_timing_data(
        self,
        otio_clip,
        timeline_offset,
        track_start_frame,
        workfile_start_frame
    ):
        """Returning available timing data

        Args:
            otio_clip (otio.Clip): otio clip object
            timeline_offset (int): offset value
            track_start_frame (int): starting frame input
            workfile_start_frame (int): start frame for shot's workfiles

        Returns:
            dict: timing metadata
        """
        # frame ranges data
        clip_in = otio_clip.range_in_parent().start_time.value
        clip_in += track_start_frame
        clip_out = otio_clip.range_in_parent().end_time_inclusive().value
        clip_out += track_start_frame

        # add offset in case there is any
        if timeline_offset:
            clip_in += timeline_offset
            clip_out += timeline_offset

        clip_duration = otio_clip.duration().value
        source_in = otio_clip.trimmed_range().start_time.value
        source_out = source_in + clip_duration

        # define starting frame for future shot
        frame_start = (
            clip_in if workfile_start_frame is None
            else workfile_start_frame
        )
        frame_end = frame_start + (clip_duration - 1)

        return {
            "frameStart": int(frame_start),
            "frameEnd": int(frame_end),
            "clipIn": int(clip_in),
            "clipOut": int(clip_out),
            "clipDuration": int(otio_clip.duration().value),
            "sourceIn": int(source_in),
            "sourceOut": int(source_out)
        }

    def _get_allowed_product_type_presets(self, pre_create_data):
        """Filter out allowed product type presets.

        Args:
            pre_create_data (dict): precreate attributes inputs

        Returns:
            list: Filtered list of extended preset items.
        """
        return [
            # return dict with name of preset and add preset dict
            {"product_name": product_name, **preset}
            for product_name, preset in self.get_product_presets_with_names().items()  # noqa
            if pre_create_data[product_name]
        ]

    def _validate_clip_for_processing(self, otio_clip):
        """Validate otio clip attributes

        Args:
            otio_clip (otio.Clip): otio clip object

        Returns:
            bool: True if all passing conditions
        """
        if otio_clip.name is None:
            return False

        if isinstance(otio_clip, otio.schema.Gap):
            return False

        # skip all generators like black empty
        if isinstance(
            otio_clip.media_reference,
                otio.schema.GeneratorReference):
            return False

        # Transitions are ignored, because Clips have the full frame
        # range.
        if isinstance(otio_clip, otio.schema.Transition):
            return False

        return True

    def get_pre_create_attr_defs(self):
        """Creating pre-create attributes at creator plugin.

        Returns:
            list: list of attribute object instances
        """
        # Use same attributes as for instance attrobites
        attr_defs = [
            FileDef(
                "sequence_filepath_data",
                folders=False,
                extensions=[".edl", ".xml", ".aaf", ".fcpxml"],
                allow_sequences=False,
                single_item=False,
                label="Sequence file",
            ),
            FileDef(
                "folder_path_data",
                folders=True,
                single_item=False,
                extensions=[],
                allow_sequences=False,
                label="Folder path",
            ),
            # TODO: perhaps better would be timecode and fps input
            NumberDef("timeline_offset", default=0, label="Timeline offset"),
            UISeparatorDef("one"),
            UILabelDef("Clip instance attributes"),
            BoolDef(
                "ignore_clip_no_content",
                label="Ignore clips with no content",
                default=True
            ),
            UILabelDef("Products Search"),
            UISeparatorDef("two"),
        ]

        # transform all items in product type presets to join product
        # type and product variant together as single camel case string
        product_names = self.get_product_presets_with_names()

        # add variants swithers
        attr_defs.extend(
            BoolDef(
                name,
                label=name,
                default=preset["default_enabled"],
            )
            for name, preset in product_names.items()
        )
        attr_defs.append(UISeparatorDef("three"))

        attr_defs.extend(CREATOR_CLIP_ATTR_DEFS)
        return attr_defs

    def get_product_presets_with_names(self):
        """Get product type presets names.
        Returns:
            dict: dict with product names and preset items
        """
        output = {}
        for item in self.product_type_presets:
            product_name = (
                f"{item['product_type']}"
                f"{(item['variant']).capitalize()}"
            )
            output[product_name] = item
        return output

get_pre_create_attr_defs()

Creating pre-create attributes at creator plugin.

Returns:

Name Type Description
list

list of attribute object instances

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
def get_pre_create_attr_defs(self):
    """Creating pre-create attributes at creator plugin.

    Returns:
        list: list of attribute object instances
    """
    # Use same attributes as for instance attrobites
    attr_defs = [
        FileDef(
            "sequence_filepath_data",
            folders=False,
            extensions=[".edl", ".xml", ".aaf", ".fcpxml"],
            allow_sequences=False,
            single_item=False,
            label="Sequence file",
        ),
        FileDef(
            "folder_path_data",
            folders=True,
            single_item=False,
            extensions=[],
            allow_sequences=False,
            label="Folder path",
        ),
        # TODO: perhaps better would be timecode and fps input
        NumberDef("timeline_offset", default=0, label="Timeline offset"),
        UISeparatorDef("one"),
        UILabelDef("Clip instance attributes"),
        BoolDef(
            "ignore_clip_no_content",
            label="Ignore clips with no content",
            default=True
        ),
        UILabelDef("Products Search"),
        UISeparatorDef("two"),
    ]

    # transform all items in product type presets to join product
    # type and product variant together as single camel case string
    product_names = self.get_product_presets_with_names()

    # add variants swithers
    attr_defs.extend(
        BoolDef(
            name,
            label=name,
            default=preset["default_enabled"],
        )
        for name, preset in product_names.items()
    )
    attr_defs.append(UISeparatorDef("three"))

    attr_defs.extend(CREATOR_CLIP_ATTR_DEFS)
    return attr_defs

get_product_presets_with_names()

Get product type presets names. Returns: dict: dict with product names and preset items

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
def get_product_presets_with_names(self):
    """Get product type presets names.
    Returns:
        dict: dict with product names and preset items
    """
    output = {}
    for item in self.product_type_presets:
        product_name = (
            f"{item['product_type']}"
            f"{(item['variant']).capitalize()}"
        )
        output[product_name] = item
    return output

EditorialAudioInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Audio product type class

Audio representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
197
198
199
200
201
202
203
204
class EditorialAudioInstanceCreator(EditorialClipInstanceCreatorBase):
    """Audio product type class

    Audio representation instance.
    """
    identifier = "editorial_audio_advanced"
    product_type = "audio"
    label = "Audio product"

EditorialCameraInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Camera product type class Camera representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
217
218
219
220
221
222
223
class EditorialCameraInstanceCreator(EditorialClipInstanceCreatorBase):
    """Camera product type class
    Camera representation instance.
    """
    identifier = "editorial_camera_advanced"
    product_type = "camera"
    label = "Camera product"

EditorialClipInstanceCreatorBase

Bases: HiddenTrayPublishCreator

Wrapper class for clip product type creators.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class EditorialClipInstanceCreatorBase(HiddenTrayPublishCreator):
    """Wrapper class for clip product type creators."""
    host_name = "traypublisher"

    def create(self, instance_data, source_data=None):
        product_name = instance_data["productName"]

        # Create new instance
        new_instance = CreatedInstance(
            self.product_type, product_name, instance_data, self
        )

        self._store_new_instance(new_instance)

        return new_instance

    def get_instance_attr_defs(self):
        return [
            BoolDef(
                "add_review_family",
                default=True,
                label="Review"
            ),
            TextDef(
                "parent_instance",
                label="Linked to",
                disabled=True
            ),
        ]

EditorialImageInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Image product type class

Plate representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
178
179
180
181
182
183
184
185
class EditorialImageInstanceCreator(EditorialClipInstanceCreatorBase):
    """Image product type class

    Plate representation instance.
    """
    identifier = "editorial_image_advanced"
    product_type = "image"
    label = "Image product"

EditorialModelInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Model product type class

Model representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
207
208
209
210
211
212
213
214
class EditorialModelInstanceCreator(EditorialClipInstanceCreatorBase):
    """Model product type class

    Model representation instance.
    """
    identifier = "editorial_model_advanced"
    product_type = "model"
    label = "Model product"

EditorialPlateInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Plate product type class

Plate representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
168
169
170
171
172
173
174
175
class EditorialPlateInstanceCreator(EditorialClipInstanceCreatorBase):
    """Plate product type class

    Plate representation instance.
    """
    identifier = "editorial_plate_advanced"
    product_type = "plate"
    label = "Plate product"

EditorialRenderInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Render product type class Render representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
188
189
190
191
192
193
194
class EditorialRenderInstanceCreator(EditorialClipInstanceCreatorBase):
    """Render product type class
    Render representation instance.
    """
    identifier = "editorial_render_advanced"
    product_type = "render"
    label = "Render product"

EditorialShotInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Shot product type class

The shot metadata instance carrier.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class EditorialShotInstanceCreator(EditorialClipInstanceCreatorBase):
    """Shot product type class

    The shot metadata instance carrier.
    """
    identifier = "editorial_shot_advanced"
    product_type = "shot"
    label = "Editorial Shot"

    def get_instance_attr_defs(self):
        instance_attributes = [
            TextDef(
                "folderPath",
                label="Folder path",
                disabled=True,
            )
        ]
        instance_attributes.extend(CREATOR_CLIP_ATTR_DEFS)
        instance_attributes.extend(CLIP_ATTR_DEFS)
        return instance_attributes

EditorialWorkfileInstanceCreator

Bases: EditorialClipInstanceCreatorBase

Workfile product type class

Workfile representation instance.

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
226
227
228
229
230
231
232
233
class EditorialWorkfileInstanceCreator(EditorialClipInstanceCreatorBase):
    """Workfile product type class

    Workfile representation instance.
    """
    identifier = "editorial_workfile_advanced"
    product_type = "workfile"
    label = "Workfile product"

find_string_differences(files)

Find common parts and differences between all strings in a list. Returns dictionary with original strings as keys and unique parts as values. The unique parts will: - not include file extensions - be stripped of whitespace - be stripped of dots and underscores from both ends - stripped of sequence numbers and padding

Source code in client/ayon_traypublisher/plugins/create/create_editorial_advanced.py
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
def find_string_differences(files: List[str]) -> Dict[str, str]:
    """
    Find common parts and differences between all strings in a list.
    Returns dictionary with original strings as keys and unique parts as values.
    The unique parts will:
    - not include file extensions
    - be stripped of whitespace
    - be stripped of dots and underscores from both ends
    - stripped of sequence numbers and padding
    """
    if not files:
        return {}

    # convert first all files to collections and reminders
    files_collected = []
    collections, reminders = clique.assemble(files)
    for collection in collections:
        head = collection.format("{head}")
        tail = collection.format("{tail}")
        files_collected.append(head + tail)
    for reminder in reminders:
        files_collected.append(reminder)

    # Remove extensions and convert to list for processing
    processed_files = [os.path.splitext(f)[0] for f in files_collected]

    # Find common prefix using zip_longest to compare all characters at once
    prefix = ""
    for chars in zip_longest(*processed_files):
        if len(set(chars) - {None}) != 1:  # If there's more than one unique character
            break
        prefix += chars[0]

    # Find common suffix by reversing strings
    reversed_files = [f[::-1] for f in processed_files]
    suffix = ""
    for chars in zip_longest(*reversed_files):
        if len(set(chars) - {None}) != 1:
            break
        suffix = chars[0] + suffix

    # Create result dictionary
    prefix_len = len(prefix)
    suffix_len = len(suffix)
    result = {}

    for original, processed in zip(files_collected, processed_files):
        # Extract the difference
        diff = (
            processed[prefix_len:-suffix_len] if suffix
            else processed[prefix_len:]
        )
        # Clean up the difference
        # remove version pattern from the diff
        version_pattern = re.compile(r".*(v\d{2,4}).*")
        if match := re.match(version_pattern, diff):
            # version string included v##
            version_str = match[1]
            diff = diff.replace(version_str, "")

        # Remove whitespace, dots and underscores
        diff = diff.strip().strip("._")

        result[original] = diff

    return result